Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions docs-v2/content/en/docs/custom-actions.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,53 @@ A Custom Action has an execution mode associated with it that indicates Skaffold

This is the default configuration when no [`customActions[].executionMode`]({{< relref "/docs/references/yaml/#customActions-executionMode" >}}) is specified. With this execution mode, Skaffold will run every container associated to a given Custom Action with a Docker daemon.

##### Passing Docker run flags with `runArgs`

When an action needs to reach host resources (for example your local
`~/.config/gcloud` credentials) or to tighten its runtime (dropping
capabilities, pinning a non-root user), use
[`customActions[].executionMode.local.runArgs`]({{< relref
"/docs/references/yaml/#customActions-executionMode-local-runArgs" >}}).
Skaffold parses each entry with a whitelist and overlays the result on
the Docker `HostConfig`/`Config` used to start the container:

| Flag | Effect |
| --- | --- |
| `--network=<mode>` | Sets `HostConfig.NetworkMode`. Accepts `host`, `bridge`, `none`, or a named network. |
| `-v=<src>:<dst>[:opts]`, `--volume=<src>:<dst>[:opts]` | Appends to `HostConfig.Binds`. |
| `-e=<KEY>=<VALUE>`, `--env=<KEY>=<VALUE>` | Appends to `Config.Env`. |
| `--user=<uid[:gid]>` | Sets `Config.User`. |
| `--add-host=<host>:<ip>` | Appends to `HostConfig.ExtraHosts`. |
| `--tmpfs=<path>[:opts]` | Merges into `HostConfig.Tmpfs`. |
| `--privileged` | Sets `HostConfig.Privileged=true`. |
| `--cap-add=<CAP>`, `--cap-drop=<CAP>` | Appends to `HostConfig.CapAdd`/`CapDrop`. |

Only the `--flag=value` form is accepted — space-separated values and
unknown flags are rejected at load time so typos fail loudly rather
than silently dropping settings.

```yaml
customActions:
- name: reuse-local-adc
executionMode:
local:
runArgs:
- "-v=/root/.config/gcloud:/root/.config/gcloud:ro"
- "--network=host"
- "-e=CLOUDSDK_CORE_PROJECT=my-project"
containers:
- name: gcloud
image: google/cloud-sdk:slim
command: ["gcloud"]
args: ["auth", "list"]
```

> **Security note:** `runArgs` bypasses Skaffold's sandbox and hands
> raw flags to the host Docker daemon. Avoid committing `--privileged`,
> broad bind mounts (`-v=/:/host`) or secret values to source control.
> Prefer deploy parameters or environment files for per-invocation
> overrides.

#### Remote (K8s job)

With this execution mode, Skaffold will create a K8s job for each container associated with the given action. For the following configuration:
Expand Down
12 changes: 11 additions & 1 deletion docs-v2/content/en/schemas/v4beta14.json
Original file line number Diff line number Diff line change
Expand Up @@ -3442,6 +3442,15 @@
},
"LocalVerifier": {
"properties": {
"runArgs": {
"items": {
"type": "string"
},
"type": "array",
"description": "an optional list of docker-run-style flags applied to the verify test container. Accepts the same conservative whitelist as `customActions.*.executionMode.local.runArgs`: `--network`, `-v` / `--volume`, `-e` / `--env`, `--user`, `--add-host`, `--tmpfs`, `--privileged`, `--cap-add`, `--cap-drop`. Unknown flags are rejected. Each flag must be in `--flag=value` form.",
"x-intellij-html-description": "an optional list of docker-run-style flags applied to the verify test container. Accepts the same conservative whitelist as <code>customActions.*.executionMode.local.runArgs</code>: <code>--network</code>, <code>-v</code> / <code>--volume</code>, <code>-e</code> / <code>--env</code>, <code>--user</code>, <code>--add-host</code>, <code>--tmpfs</code>, <code>--privileged</code>, <code>--cap-add</code>, <code>--cap-drop</code>. Unknown flags are rejected. Each flag must be in <code>--flag=value</code> form.",
"default": "[]"
},
"useLocalImages": {
"type": "boolean",
"description": "if true, will first check if the containers images exist locally before triggering a pull. Defaults to false.",
Expand All @@ -3450,7 +3459,8 @@
}
},
"preferredOrder": [
"useLocalImages"
"useLocalImages",
"runArgs"
],
"additionalProperties": false,
"type": "object",
Expand Down
30 changes: 30 additions & 0 deletions integration/examples/custom-actions-runargs/skaffold.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
apiVersion: skaffold/v4beta15
kind: Config
metadata:
name: custom-actions-runargs

customActions:
- name: hardened-action
executionMode:
local:
runArgs:
- "--user=1000:1000"
- "--cap-drop=NET_RAW"
- "-e=SENTINEL=hardened"
containers:
- name: hardened
image: alpine:3.20
command: ["/bin/sh"]
args: ["-c", "echo uid=$(id -u) && echo sentinel=$SENTINEL"]

- name: gcloud-auth-list
executionMode:
local:
runArgs:
- "-v=/root/.config/gcloud:/root/.config/gcloud:ro"
- "--network=host"
containers:
- name: gcloud
image: google/cloud-sdk:slim
command: ["gcloud"]
args: ["auth", "list"]
8 changes: 8 additions & 0 deletions integration/exec_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,14 @@ func TestExec_LocalActions(t *testing.T) {
"[task7] bye-from-env-file",
},
},
{
description: "action with runArgs overlay",
action: "action-runargs",
expectedMsgs: []string{
"[runargs-task] uid=1000",
"[runargs-task] sentinel=from-runargs",
},
},
}

for _, test := range tests {
Expand Down
14 changes: 13 additions & 1 deletion integration/testdata/custom-actions-local/skaffold.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -76,4 +76,16 @@ customActions:
image: localtaks
env:
- name: FOO
value: from-local-img
value: from-local-img

- name: action-runargs
executionMode:
local:
runArgs:
- "--user=1000:1000"
- "-e=SENTINEL=from-runargs"
containers:
- name: runargs-task
image: alpine:3.15.4
command: ["/bin/sh"]
args: ["-c", "echo uid=$(id -u) && echo sentinel=$SENTINEL"]
9 changes: 8 additions & 1 deletion pkg/skaffold/actions/docker/exec_env.go
Original file line number Diff line number Diff line change
Expand Up @@ -169,13 +169,20 @@ func (e ExecEnv) createTasks(ctx context.Context, out io.Writer, aCfgs latest.Ac
timeout := *aCfgs.Config.Timeout
useLocalImages := aCfgs.ExecutionModeConfig.LocalExecutionMode.UseLocalImages

runArgs, err := ParseRunArgs(aCfgs.ExecutionModeConfig.LocalExecutionMode.RunArgs)
if err != nil {
return nil, nil, fmt.Errorf("action %q: %w", aCfgs.Name, err)
}

for _, cCfg := range containerCfgs {
art, err := e.pullArtifact(ctx, out, builts, useLocalImages, cCfg)
if err != nil {
return nil, nil, err
}

ts = append(ts, NewTask(cCfg, e.client, e.portManager, e.pResources, *art, timeout, &e))
task := NewTask(cCfg, e.client, e.portManager, e.pResources, *art, timeout, &e)
task.runArgs = runArgs
ts = append(ts, task)

tracked = append(tracked, graph.Artifact{
ImageName: cCfg.Image,
Expand Down
164 changes: 164 additions & 0 deletions pkg/skaffold/actions/docker/runargs.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
/*
Copyright 2026 The Skaffold Authors

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/

package docker

import (
"fmt"
"strings"

"github.com/docker/docker/api/types/container"
)

// RunArgs is the parsed, whitelisted projection of a user-supplied
// customActions.*.executionMode.local.runArgs list.
//
// Only a small, deliberately conservative subset of `docker run` flags is
// recognised. Unknown flags are rejected so users fail fast instead of
// silently being ignored. See ParseRunArgs for the full list.
type RunArgs struct {
NetworkMode string
Binds []string
Env []string
User string
ExtraHosts []string
Tmpfs map[string]string
Privileged bool
CapAdd []string
CapDrop []string
}

// ParseRunArgs parses a docker-run-style argument list and returns a
// whitelisted RunArgs projection. Supported flags:
//
// --network=VALUE
// -v=SRC:DST[:MODE] (also --volume=...)
// -e=KEY=VALUE (also --env=...)
// --user=UID[:GID]
// --add-host=HOST:IP
// --tmpfs=PATH[:OPTIONS]
// --privileged
// --cap-add=CAP
// --cap-drop=CAP
//
// Each flag must be in the `--flag=value` (or `-f=value`) form; the
// space-separated variant is not supported to keep the parser unambiguous.
// A nil / empty input returns a nil *RunArgs.
func ParseRunArgs(args []string) (*RunArgs, error) {
if len(args) == 0 {
return nil, nil
}
out := &RunArgs{}
for i, raw := range args {
arg := strings.TrimSpace(raw)
if arg == "" {
continue
}
if arg == "--privileged" || arg == "--privileged=true" {
out.Privileged = true
continue
}
if arg == "--privileged=false" {
out.Privileged = false
continue
}
key, val, ok := strings.Cut(arg, "=")
if !ok {
// No '=' means either an unknown bare flag or the space-separated form.
if strings.HasPrefix(arg, "--") || strings.HasPrefix(arg, "-") {
return nil, fmt.Errorf("runArgs[%d] %q: unsupported flag %q (only --flag=value form is supported; allowed: --network, -v/--volume, -e/--env, --user, --add-host, --tmpfs, --privileged, --cap-add, --cap-drop)", i, raw, arg)
}
return nil, fmt.Errorf("runArgs[%d] %q: only --flag=value form is supported (no space-separated values)", i, raw)
}
switch key {
case "--network":
out.NetworkMode = val
case "-v", "--volume":
out.Binds = append(out.Binds, val)
case "-e", "--env":
out.Env = append(out.Env, val)
case "--user":
out.User = val
case "--add-host":
out.ExtraHosts = append(out.ExtraHosts, val)
case "--tmpfs":
if out.Tmpfs == nil {
out.Tmpfs = map[string]string{}
}
mountPath, opts, _ := strings.Cut(val, ":")
out.Tmpfs[mountPath] = opts
case "--cap-add":
out.CapAdd = append(out.CapAdd, val)
case "--cap-drop":
out.CapDrop = append(out.CapDrop, val)
default:
return nil, fmt.Errorf("runArgs[%d] %q: unsupported flag %q (allowed: --network, -v/--volume, -e/--env, --user, --add-host, --tmpfs, --privileged, --cap-add, --cap-drop)", i, raw, key)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The error message here can be slightly misleading when the user provides a supported flag but with an invalid format (e.g., --privileged=foo). Since --privileged is listed in the allowed section of the error message, but it's not a case in the switch key (because it's handled separately before the Cut), the user might be confused as to why it's reported as "unsupported". Consider refining the error message or the logic to distinguish between truly unknown flags and invalid usage of supported ones.

}
}
return out, nil
}

// ApplyToContainerConfig overlays parsed runArgs fields that belong on the
// container.Config (User, additional env vars) onto cfg in place. A nil
// receiver is a no-op.
func (r *RunArgs) ApplyToContainerConfig(cfg *container.Config) {
if r == nil || cfg == nil {
return
}
if r.User != "" {
cfg.User = r.User
}
if len(r.Env) > 0 {
// RunArgs env entries win over anything already on the container
// config — consistent with deploy-parameter precedence.
cfg.Env = append(cfg.Env, r.Env...)
}
}

// ApplyToHostConfig overlays parsed runArgs fields that belong on the
// container.HostConfig onto hc in place. NetworkMode is only overridden
// when the user provided one. A nil receiver is a no-op.
func (r *RunArgs) ApplyToHostConfig(hc *container.HostConfig) {
if r == nil || hc == nil {
return
}
if r.NetworkMode != "" {
hc.NetworkMode = container.NetworkMode(r.NetworkMode)
}
if len(r.Binds) > 0 {
hc.Binds = append(hc.Binds, r.Binds...)
}
if len(r.ExtraHosts) > 0 {
hc.ExtraHosts = append(hc.ExtraHosts, r.ExtraHosts...)
}
if len(r.Tmpfs) > 0 {
if hc.Tmpfs == nil {
hc.Tmpfs = map[string]string{}
}
for k, v := range r.Tmpfs {
hc.Tmpfs[k] = v
}
}
if r.Privileged {
hc.Privileged = true

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The current implementation of ApplyToHostConfig only sets hc.Privileged = true if r.Privileged is true. If r.Privileged is false (which is the default for a bool), it does nothing. This means that if a future version of Skaffold or a different caller were to enable privileged mode by default, passing --privileged=false in runArgs would not be able to override it back to false. While not an issue with the current defaults, using a pointer (*bool) in the RunArgs struct would allow for a more robust overlay that can explicitly set the value to false.

}
if len(r.CapAdd) > 0 {
hc.CapAdd = append(hc.CapAdd, r.CapAdd...)
}
if len(r.CapDrop) > 0 {
hc.CapDrop = append(hc.CapDrop, r.CapDrop...)
}
}
Loading