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/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/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 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..c8e0c288677 --- /dev/null +++ b/pkg/skaffold/actions/docker/runargs.go @@ -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) + } + } + 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/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)) +} 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 { 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) 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]) + }) + } +}