From 7d778c79a3cdd40b3fce238d008fc4ecd265cf8c Mon Sep 17 00:00:00 2001 From: Bogdan Nazarenko Date: Fri, 24 Apr 2026 01:39:55 -0400 Subject: [PATCH 1/5] feat(actions/docker): parse runArgs whitelist and splice into HostConfig Introduces ParseRunArgs in pkg/skaffold/actions/docker/runargs.go that accepts a docker-run-style flag list using a conservative whitelist (--network, -v/--volume, -e/--env, --user, --add-host, --tmpfs, --privileged, --cap-add, --cap-drop). Unknown flags return an error so users fail fast rather than have settings silently dropped. Only the '--flag=value' form is supported to keep the parser unambiguous. Two small helpers project the parsed result onto the existing docker API types: ApplyToContainerConfig writes User and appends Env onto container.Config; ApplyToHostConfig overlays NetworkMode, Binds, ExtraHosts, Tmpfs, Privileged, CapAdd and CapDrop onto a container.HostConfig. To make the overlay available at run time, ContainerCreateOpts in pkg/skaffold/docker/image.go gains an optional HostConfigApply hook that is invoked after the default HostConfig is constructed. The Task parses the owning action's runArgs once in createTasks, stores it on the Task, and wires both helpers through the existing config paths. No runtime change occurs when runArgs is absent: the overlay methods are no-ops on a nil receiver. --- pkg/skaffold/actions/docker/exec_env.go | 9 +- pkg/skaffold/actions/docker/runargs.go | 160 ++++++++++++++++++++++++ pkg/skaffold/actions/docker/task.go | 8 ++ pkg/skaffold/docker/image.go | 7 ++ 4 files changed, 183 insertions(+), 1 deletion(-) create mode 100644 pkg/skaffold/actions/docker/runargs.go diff --git a/pkg/skaffold/actions/docker/exec_env.go b/pkg/skaffold/actions/docker/exec_env.go index 55f0ffa892d..351e693bb3c 100644 --- a/pkg/skaffold/actions/docker/exec_env.go +++ b/pkg/skaffold/actions/docker/exec_env.go @@ -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, diff --git a/pkg/skaffold/actions/docker/runargs.go b/pkg/skaffold/actions/docker/runargs.go new file mode 100644 index 00000000000..6c179c809cc --- /dev/null +++ b/pkg/skaffold/actions/docker/runargs.go @@ -0,0 +1,160 @@ +/* +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 { + 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) + } + } + 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 + } + if len(r.CapAdd) > 0 { + hc.CapAdd = append(hc.CapAdd, r.CapAdd...) + } + if len(r.CapDrop) > 0 { + hc.CapDrop = append(hc.CapDrop, r.CapDrop...) + } +} diff --git a/pkg/skaffold/actions/docker/task.go b/pkg/skaffold/actions/docker/task.go index 342090124f3..d56805fcf23 100644 --- a/pkg/skaffold/actions/docker/task.go +++ b/pkg/skaffold/actions/docker/task.go @@ -64,6 +64,11 @@ type Task struct { // Reference to the associated execution environment. execEnv *ExecEnv + + // Optional whitelisted docker-run-style flags parsed from the owning + // action's executionMode.local.runArgs. Nil when the user didn't + // provide any. + runArgs *RunArgs } var NewTask = newTask @@ -153,6 +158,7 @@ func (t Task) containerCreateOpts(ctx context.Context, containerName string) (*d ContainerConfig: containerCfg, Bindings: bindings, Wait: true, + HostConfigApply: t.runArgs.ApplyToHostConfig, }, nil } @@ -177,6 +183,8 @@ func (t Task) generateContainerCfg(ctx context.Context) (*container.Config, erro containerCfg.Env = append(envVars, t.envVars...) + t.runArgs.ApplyToContainerConfig(containerCfg) + return containerCfg, nil } diff --git a/pkg/skaffold/docker/image.go b/pkg/skaffold/docker/image.go index 3bd7f51eb60..f4c7ab66209 100644 --- a/pkg/skaffold/docker/image.go +++ b/pkg/skaffold/docker/image.go @@ -76,6 +76,10 @@ type ContainerCreateOpts struct { ContainerConfig *container.Config VerifyTestName string Labels map[string]string + // HostConfigApply, if non-nil, is invoked after the default HostConfig + // has been constructed in Run and lets callers overlay additional + // fields (e.g. Binds, ExtraHosts, NetworkMode overrides). + HostConfigApply func(*container.HostConfig) } // LocalDaemon talks to a local Docker API. @@ -231,6 +235,9 @@ func (l *localDaemon) Run(ctx context.Context, out io.Writer, opts ContainerCrea PortBindings: opts.Bindings, Mounts: opts.Mounts, } + if opts.HostConfigApply != nil { + opts.HostConfigApply(hCfg) + } c, err := l.apiClient.ContainerCreate(ctx, opts.ContainerConfig, hCfg, nil, nil, opts.Name) if err != nil { From 6b3c537d0975653431ae616e41d2e69fb698e850 Mon Sep 17 00:00:00 2001 From: Bogdan Nazarenko Date: Fri, 24 Apr 2026 01:41:33 -0400 Subject: [PATCH 2/5] test(actions/docker): unit coverage for runArgs parser and overlays Covers: - nil and empty input return nil, nil - every supported flag parses into the expected field, including repeated -v/--volume, -e/--env, --cap-add, --cap-drop - unknown bare flags (e.g. --rm) are rejected with a clear message - non-flag bare values are rejected with the space-separated hint - empty/whitespace entries are skipped - ApplyToContainerConfig is a no-op on nil receiver or nil cfg - ApplyToContainerConfig sets User and appends Env preserving existing - ApplyToHostConfig is a no-op on nil receiver or nil hc - ApplyToHostConfig overrides NetworkMode only when provided, appends Binds / ExtraHosts / CapAdd / CapDrop, unions Tmpfs, and honours Privileged=true --- pkg/skaffold/actions/docker/runargs.go | 4 + pkg/skaffold/actions/docker/runargs_test.go | 142 ++++++++++++++++++++ 2 files changed, 146 insertions(+) create mode 100644 pkg/skaffold/actions/docker/runargs_test.go diff --git a/pkg/skaffold/actions/docker/runargs.go b/pkg/skaffold/actions/docker/runargs.go index 6c179c809cc..c8e0c288677 100644 --- a/pkg/skaffold/actions/docker/runargs.go +++ b/pkg/skaffold/actions/docker/runargs.go @@ -77,6 +77,10 @@ func ParseRunArgs(args []string) (*RunArgs, error) { } 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 { diff --git a/pkg/skaffold/actions/docker/runargs_test.go b/pkg/skaffold/actions/docker/runargs_test.go new file mode 100644 index 00000000000..4c05438c07c --- /dev/null +++ b/pkg/skaffold/actions/docker/runargs_test.go @@ -0,0 +1,142 @@ +/* +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 ( + "strings" + "testing" + + "github.com/docker/docker/api/types/container" + + "github.com/GoogleContainerTools/skaffold/v2/testutil" +) + +func TestParseRunArgs_Empty(t *testing.T) { + got, err := ParseRunArgs(nil) + testutil.CheckError(t, false, err) + if got != nil { + t.Fatalf("expected nil, got %+v", got) + } + + got, err = ParseRunArgs([]string{}) + testutil.CheckError(t, false, err) + if got != nil { + t.Fatalf("expected nil, got %+v", got) + } +} + +func TestParseRunArgs_Supported(t *testing.T) { + got, err := ParseRunArgs([]string{ + "--network=host", + "-v=/host:/container:ro", + "--volume=/data:/data", + "-e=FOO=bar", + "--env=BAZ=qux", + "--user=1000:1000", + "--add-host=db:127.0.0.1", + "--tmpfs=/tmp:size=64m", + "--privileged", + "--cap-add=NET_ADMIN", + "--cap-drop=AUDIT_WRITE", + }) + testutil.CheckError(t, false, err) + testutil.CheckDeepEqual(t, "host", got.NetworkMode) + testutil.CheckDeepEqual(t, []string{"/host:/container:ro", "/data:/data"}, got.Binds) + testutil.CheckDeepEqual(t, []string{"FOO=bar", "BAZ=qux"}, got.Env) + testutil.CheckDeepEqual(t, "1000:1000", got.User) + testutil.CheckDeepEqual(t, []string{"db:127.0.0.1"}, got.ExtraHosts) + testutil.CheckDeepEqual(t, map[string]string{"/tmp": "size=64m"}, got.Tmpfs) + testutil.CheckDeepEqual(t, true, got.Privileged) + testutil.CheckDeepEqual(t, []string{"NET_ADMIN"}, got.CapAdd) + testutil.CheckDeepEqual(t, []string{"AUDIT_WRITE"}, got.CapDrop) +} + +func TestParseRunArgs_UnsupportedFlag(t *testing.T) { + _, err := ParseRunArgs([]string{"--rm"}) + if err == nil || !strings.Contains(err.Error(), "unsupported flag") { + t.Fatalf("expected unsupported flag error, got %v", err) + } +} + +func TestParseRunArgs_SpaceSeparated(t *testing.T) { + _, err := ParseRunArgs([]string{"plain value"}) + if err == nil || !strings.Contains(err.Error(), "only --flag=value form") { + t.Fatalf("expected only --flag=value error, got %v", err) + } +} + +func TestParseRunArgs_SkipsEmptyEntries(t *testing.T) { + got, err := ParseRunArgs([]string{"", " ", "--network=host"}) + testutil.CheckError(t, false, err) + testutil.CheckDeepEqual(t, "host", got.NetworkMode) +} + +func TestApplyToContainerConfig_NilSafe(t *testing.T) { + var r *RunArgs + r.ApplyToContainerConfig(nil) // nil config, nil receiver + r.ApplyToContainerConfig(&container.Config{}) + (&RunArgs{}).ApplyToContainerConfig(nil) +} + +func TestApplyToContainerConfig_SetsUserAndAppendsEnv(t *testing.T) { + r := &RunArgs{User: "1000", Env: []string{"A=1", "B=2"}} + cfg := &container.Config{Env: []string{"PRE=existing"}} + r.ApplyToContainerConfig(cfg) + testutil.CheckDeepEqual(t, "1000", cfg.User) + testutil.CheckDeepEqual(t, []string{"PRE=existing", "A=1", "B=2"}, cfg.Env) +} + +func TestApplyToHostConfig_NilSafe(t *testing.T) { + var r *RunArgs + r.ApplyToHostConfig(nil) + r.ApplyToHostConfig(&container.HostConfig{}) + (&RunArgs{}).ApplyToHostConfig(nil) +} + +func TestApplyToHostConfig_OverridesAndAppends(t *testing.T) { + r := &RunArgs{ + NetworkMode: "host", + Binds: []string{"/a:/a"}, + ExtraHosts: []string{"h:1.2.3.4"}, + Tmpfs: map[string]string{"/tmp": "size=16m"}, + Privileged: true, + CapAdd: []string{"NET_ADMIN"}, + CapDrop: []string{"AUDIT_WRITE"}, + } + hc := &container.HostConfig{ + NetworkMode: container.NetworkMode("bridge"), + Binds: []string{"/pre:/pre"}, + ExtraHosts: []string{"pre:0.0.0.0"}, + Tmpfs: map[string]string{"/run": "size=8m"}, + CapAdd: []string{"SYS_TIME"}, + } + r.ApplyToHostConfig(hc) + testutil.CheckDeepEqual(t, "host", string(hc.NetworkMode)) + testutil.CheckDeepEqual(t, []string{"/pre:/pre", "/a:/a"}, hc.Binds) + testutil.CheckDeepEqual(t, []string{"pre:0.0.0.0", "h:1.2.3.4"}, hc.ExtraHosts) + testutil.CheckDeepEqual(t, map[string]string{"/run": "size=8m", "/tmp": "size=16m"}, hc.Tmpfs) + testutil.CheckDeepEqual(t, true, hc.Privileged) + testutil.CheckDeepEqual(t, []string{"SYS_TIME", "NET_ADMIN"}, []string(hc.CapAdd)) + testutil.CheckDeepEqual(t, []string{"AUDIT_WRITE"}, []string(hc.CapDrop)) +} + +func TestApplyToHostConfig_EmptyRunArgsDoesNotOverrideNetwork(t *testing.T) { + r := &RunArgs{} + hc := &container.HostConfig{NetworkMode: container.NetworkMode("bridge")} + r.ApplyToHostConfig(hc) + testutil.CheckDeepEqual(t, "bridge", string(hc.NetworkMode)) +} From 6e9d8f412315580c4a4a922eecb0d34bdb6b6b7a Mon Sep 17 00:00:00 2001 From: Bogdan Nazarenko Date: Fri, 24 Apr 2026 09:20:32 -0400 Subject: [PATCH 3/5] docs(custom-actions): document runArgs whitelist + integration test Adds a new 'Passing Docker run flags with runArgs' subsection under the local execution-mode docs covering the whitelist, the accepted --flag=value form, and a security warning about bind mounts and privileged mode. Includes a worked example that mounts the host's ADC credentials into a google/cloud-sdk container. Introduces an integration example at integration/examples/custom-actions-runargs/ demonstrating a hardened action (non-root user, dropped cap) and a gcloud credential-passthrough action. Extends TestExec_LocalActions with an 'action with runArgs overlay' case that asserts --user=1000:1000 pins uid=1000 inside the container and -e=SENTINEL=from-runargs reaches the process env. --- docs-v2/content/en/docs/custom-actions.md | 47 +++++++++++++++++++ .../custom-actions-runargs/skaffold.yaml | 30 ++++++++++++ integration/exec_test.go | 8 ++++ .../custom-actions-local/skaffold.yaml | 14 +++++- 4 files changed, 98 insertions(+), 1 deletion(-) create mode 100644 integration/examples/custom-actions-runargs/skaffold.yaml diff --git a/docs-v2/content/en/docs/custom-actions.md b/docs-v2/content/en/docs/custom-actions.md index 0fb2008600e..4aeed2091e2 100644 --- a/docs-v2/content/en/docs/custom-actions.md +++ b/docs-v2/content/en/docs/custom-actions.md @@ -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=` | Sets `HostConfig.NetworkMode`. Accepts `host`, `bridge`, `none`, or a named network. | +| `-v=:[:opts]`, `--volume=:[:opts]` | Appends to `HostConfig.Binds`. | +| `-e==`, `--env==` | Appends to `Config.Env`. | +| `--user=` | Sets `Config.User`. | +| `--add-host=:` | Appends to `HostConfig.ExtraHosts`. | +| `--tmpfs=[:opts]` | Merges into `HostConfig.Tmpfs`. | +| `--privileged` | Sets `HostConfig.Privileged=true`. | +| `--cap-add=`, `--cap-drop=` | 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: diff --git a/integration/examples/custom-actions-runargs/skaffold.yaml b/integration/examples/custom-actions-runargs/skaffold.yaml new file mode 100644 index 00000000000..78f90e4306b --- /dev/null +++ b/integration/examples/custom-actions-runargs/skaffold.yaml @@ -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"] diff --git a/integration/exec_test.go b/integration/exec_test.go index b29f82cb801..40b2aa21dcc 100644 --- a/integration/exec_test.go +++ b/integration/exec_test.go @@ -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 { diff --git a/integration/testdata/custom-actions-local/skaffold.yaml b/integration/testdata/custom-actions-local/skaffold.yaml index 9112a7f7ffc..3d1e323b02e 100644 --- a/integration/testdata/custom-actions-local/skaffold.yaml +++ b/integration/testdata/custom-actions-local/skaffold.yaml @@ -76,4 +76,16 @@ customActions: image: localtaks env: - name: FOO - value: from-local-img \ No newline at end of file + 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"] \ No newline at end of file From d2005318e6d2411ba4548f3ac7e87436e7a51c02 Mon Sep 17 00:00:00 2001 From: Bogdan Nazarenko Date: Fri, 24 Apr 2026 13:56:08 -0400 Subject: [PATCH 4/5] feat(verify/docker): apply runArgs whitelist to verify test containers Extends the schema-level `LocalVerifier` with an optional `runArgs` list and plumbs it through the local docker verifier so users can reuse the same conservative whitelist already supported on `customActions.*.executionMode.local.runArgs`: verify: - name: smoke executionMode: local: useLocalImages: true runArgs: - --network=host - -v=/var/run/docker.sock:/var/run/docker.sock - --user=1000:1000 container: image: myorg/smoke:latest Implementation * schema: LocalVerifier gains `RunArgs []string` with the same godoc whitelist as custom-actions. * verify/docker: createAndRunContainer parses the per-test-case runArgs via actionsdocker.ParseRunArgs; an unknown flag is surfaced as a typed per-test error instead of being silently dropped. The parsed result overlays the container config (User, extra Env) after the schema-sourced env and --verify-env-file values so it keeps the same precedence as custom-actions, and exposes ApplyToHostConfig via the existing ContainerCreateOpts.HostConfigApply hook for NetworkMode, Binds, ExtraHosts, Tmpfs, Privileged, CapAdd, CapDrop. No runtime change occurs when runArgs is absent: both overlay methods are no-ops on a nil `*RunArgs` receiver. --- docs-v2/content/en/schemas/v4beta14.json | 12 +++++++++++- pkg/skaffold/schema/latest/config.go | 8 ++++++++ pkg/skaffold/verify/docker/verify.go | 16 ++++++++++++++++ 3 files changed, 35 insertions(+), 1 deletion(-) diff --git a/docs-v2/content/en/schemas/v4beta14.json b/docs-v2/content/en/schemas/v4beta14.json index 67c70f6ac1f..eb40b203b5e 100755 --- a/docs-v2/content/en/schemas/v4beta14.json +++ b/docs-v2/content/en/schemas/v4beta14.json @@ -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 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.", + "default": "[]" + }, "useLocalImages": { "type": "boolean", "description": "if true, will first check if the containers images exist locally before triggering a pull. Defaults to false.", @@ -3450,7 +3459,8 @@ } }, "preferredOrder": [ - "useLocalImages" + "useLocalImages", + "runArgs" ], "additionalProperties": false, "type": "object", diff --git a/pkg/skaffold/schema/latest/config.go b/pkg/skaffold/schema/latest/config.go index 465ea754a46..ca0b6eca408 100644 --- a/pkg/skaffold/schema/latest/config.go +++ b/pkg/skaffold/schema/latest/config.go @@ -684,6 +684,14 @@ type LocalVerifier struct { // UseLocalImages if true, will first check if the containers images exist locally before triggering a pull. // Defaults to false. UseLocalImages bool `yaml:"useLocalImages,omitempty"` + + // RunArgs is 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. + RunArgs []string `yaml:"runArgs,omitempty"` } // KubernetesClusterVerifier uses the `kubectl` CLI to create veriy test case diff --git a/pkg/skaffold/verify/docker/verify.go b/pkg/skaffold/verify/docker/verify.go index bea221c0b29..0aad69c5a50 100644 --- a/pkg/skaffold/verify/docker/verify.go +++ b/pkg/skaffold/verify/docker/verify.go @@ -35,6 +35,7 @@ import ( "github.com/pkg/errors" "github.com/GoogleContainerTools/skaffold/v2/pkg/skaffold/constants" + actionsdocker "github.com/GoogleContainerTools/skaffold/v2/pkg/skaffold/actions/docker" dockerport "github.com/GoogleContainerTools/skaffold/v2/pkg/skaffold/deploy/docker/port" "github.com/GoogleContainerTools/skaffold/v2/pkg/skaffold/deploy/label" dockerutil "github.com/GoogleContainerTools/skaffold/v2/pkg/skaffold/docker" @@ -225,6 +226,17 @@ func (v *Verifier) createAndRunContainer(ctx context.Context, out io.Writer, art containerCfg.Cmd = tc.Container.Args } + // Parse the optional per-test-case runArgs whitelist. Unknown flags are + // surfaced as a test-case failure instead of being silently dropped. + var runArgs *actionsdocker.RunArgs + if local := tc.ExecutionMode.LocalExecutionMode; local != nil { + parsed, err := actionsdocker.ParseRunArgs(local.RunArgs) + if err != nil { + return fmt.Errorf("verify test %q: %w", tc.Name, err) + } + runArgs = parsed + } + // Use container name from test case if available, otherwise derive from image containerName := v.getContainerName(ctx, artifact.ImageName, tc.Container.Name) @@ -233,6 +245,7 @@ func (v *Verifier) createAndRunContainer(ctx context.Context, out io.Writer, art Network: v.network, ContainerConfig: containerCfg, VerifyTestName: tc.Name, + HostConfigApply: runArgs.ApplyToHostConfig, } bindings, err := v.portManager.AllocatePorts(artifact.ImageName, v.resources, containerCfg, nat.PortMap{}) @@ -252,6 +265,9 @@ func (v *Verifier) createAndRunContainer(ctx context.Context, out io.Writer, art envVars = append(envVars, k+"="+v) } opts.ContainerConfig.Env = envVars + // Apply runArgs overlays (User, additional env vars) last so they stack + // on top of schema-sourced env and --verify-env-file values. + runArgs.ApplyToContainerConfig(opts.ContainerConfig) eventV2.VerifyInProgress(opts.VerifyTestName) statusCh, errCh, id, err := v.client.Run(ctx, out, opts) From 9a0bf21edfeee956b17749178c8cc85ace260eb5 Mon Sep 17 00:00:00 2001 From: Bogdan Nazarenko Date: Fri, 24 Apr 2026 13:56:17 -0400 Subject: [PATCH 5/5] test(verify/docker): unit coverage for runArgs overlay + unknown-flag error Extends fakeDockerDaemon with a RunOpts capture slice so tests can assert on the ContainerCreateOpts passed to Run. Adds Test_RunArgs covering: * nil runArgs: HostConfigApply is still a safe method value on a nil *RunArgs receiver and leaves the HostConfig untouched. * parsed runArgs overlay the expected container.Config fields (User, Env) and HostConfig fields (NetworkMode, Binds, CapAdd, Privileged). * unknown --gpus=all flag fails the test case rather than being silently ignored. --- pkg/skaffold/verify/docker/verify_test.go | 98 +++++++++++++++++++++++ 1 file changed, 98 insertions(+) diff --git a/pkg/skaffold/verify/docker/verify_test.go b/pkg/skaffold/verify/docker/verify_test.go index b20b04cbc23..e9d299aa1d6 100644 --- a/pkg/skaffold/verify/docker/verify_test.go +++ b/pkg/skaffold/verify/docker/verify_test.go @@ -39,6 +39,7 @@ type fakeDockerDaemon struct { PulledImages []string ImgsInDaemon map[string]string + RunOpts []docker.ContainerCreateOpts } func (fd *fakeDockerDaemon) NetworkCreate(ctx context.Context, name string, labels map[string]string) error { @@ -56,6 +57,7 @@ func (fd *fakeDockerDaemon) ImageID(ctx context.Context, ref string) (string, er } func (fd *fakeDockerDaemon) Run(ctx context.Context, out io.Writer, opts docker.ContainerCreateOpts) (<-chan container.WaitResponse, <-chan error, string, error) { + fd.RunOpts = append(fd.RunOpts, opts) statusCh := make(chan container.WaitResponse) go func() { statusCh <- container.WaitResponse{Error: nil, StatusCode: 0} @@ -197,3 +199,99 @@ func TestGetContainerName(t *testing.T) { ) } } + +func Test_RunArgs(t *testing.T) { + tests := []struct { + description string + runArgs []string + shouldErr bool + verify func(t *testutil.T, opts docker.ContainerCreateOpts) + }{ + { + description: "nil runArgs leaves HostConfigApply a no-op on nil receiver", + runArgs: nil, + verify: func(t *testutil.T, opts docker.ContainerCreateOpts) { + // ApplyToHostConfig is bound as a method value on *RunArgs; for a + // nil parsed result it is still safe to invoke. + hc := &container.HostConfig{} + opts.HostConfigApply(hc) + t.CheckDeepEqual(&container.HostConfig{}, hc) + }, + }, + { + description: "parsed runArgs overlay container + host config", + runArgs: []string{ + "--network=host", + "-v=/src:/dst", + "--user=1000:1000", + "-e=FOO=bar", + "--cap-add=NET_ADMIN", + "--privileged", + }, + verify: func(t *testutil.T, opts docker.ContainerCreateOpts) { + t.CheckDeepEqual("1000:1000", opts.ContainerConfig.User) + envFound := false + for _, e := range opts.ContainerConfig.Env { + if e == "FOO=bar" { + envFound = true + } + } + t.CheckTrue(envFound) + + hc := &container.HostConfig{} + opts.HostConfigApply(hc) + t.CheckDeepEqual(container.NetworkMode("host"), hc.NetworkMode) + t.CheckDeepEqual([]string{"/src:/dst"}, hc.Binds) + t.CheckDeepEqual([]string{"NET_ADMIN"}, []string(hc.CapAdd)) + t.CheckTrue(hc.Privileged) + }, + }, + { + description: "unknown flag fails the test case", + runArgs: []string{"--gpus=all"}, + shouldErr: true, + }, + } + + for _, test := range tests { + testutil.Run(t, test.description, func(t *testutil.T) { + testEvent.InitializeState([]latest.Pipeline{{}}) + ctx := context.TODO() + runCtx := &runcontext.RunContext{} + + fd := &fakeDockerDaemon{ + LocalDaemon: docker.NewLocalDaemon(&testutil.FakeAPIClient{}, nil, false, nil), + ImgsInDaemon: map[string]string{"gcr.io/img:latest": "id"}, + } + t.Override(&docker.NewAPIClient, func(context.Context, docker.Config) (docker.LocalDaemon, error) { + return fd, nil + }) + + cases := []*latest.VerifyTestCase{{ + Name: "t1", + Config: latest.VerifyConfig{}, + ExecutionMode: latest.VerifyExecutionModeConfig{ + VerifyExecutionModeType: latest.VerifyExecutionModeType{ + LocalExecutionMode: &latest.LocalVerifier{ + UseLocalImages: true, + RunArgs: test.runArgs, + }, + }, + }, + Container: latest.VerifyContainer{Name: "c1", Image: "gcr.io/img:latest"}, + }} + + verifier, err := NewVerifier(ctx, runCtx, &label.DefaultLabeller{}, cases, nil, "", nil) + t.CheckError(false, err) + + err = verifier.Verify(ctx, nil, nil) + if test.shouldErr { + t.CheckError(true, err) + return + } + t.CheckError(false, err) + t.CheckTrue(len(fd.RunOpts) == 1) + test.verify(t, fd.RunOpts[0]) + }) + } +}