diff --git a/config/raw.go b/config/raw.go index 7391b8e..fd3dc58 100644 --- a/config/raw.go +++ b/config/raw.go @@ -47,6 +47,7 @@ type rawConfig struct { OverrideFeatureInstallOrder []string `json:"overrideFeatureInstallOrder,omitempty"` InitializeCommand json.RawMessage `json:"initializeCommand,omitempty"` + SecretsCommand json.RawMessage `json:"secretsCommand,omitempty"` OnCreateCommand json.RawMessage `json:"onCreateCommand,omitempty"` UpdateContentCommand json.RawMessage `json:"updateContentCommand,omitempty"` PostCreateCommand json.RawMessage `json:"postCreateCommand,omitempty"` diff --git a/config/resolve.go b/config/resolve.go index d679b2d..a09005f 100644 --- a/config/resolve.go +++ b/config/resolve.go @@ -130,6 +130,12 @@ func resolveFromRaw(raw *rawConfig, input ResolveInput) (*ResolvedConfig, error) out.Lifecycle = lifecycle out.Warnings = append(out.Warnings, addSource(lcWarns, input.ConfigPath)...) + secretsCmd, err := decodeLifecycleCommand(raw.SecretsCommand) + if err != nil { + return nil, &ConfigInvalidError{Path: input.ConfigPath, Message: fmt.Sprintf("/secretsCommand: %v", err)} + } + out.SecretsCommand = secretsCmd + // WaitFor passes through verbatim; the spec default (postCreate, or // updateContent if any layer contributes one) is applied by Finalize // after the metadata-merge pipeline. @@ -490,6 +496,7 @@ func substituteAll(out *ResolvedConfig, ctx SubstitutionContext, source string) } substituteLifecycle(&out.Lifecycle, subStr) + substituteCommand(&out.SecretsCommand, "/secretsCommand", subStr) switch s := out.Source.(type) { case *ImageSource: diff --git a/config/resolved.go b/config/resolved.go index 6abfc2b..29cba50 100644 --- a/config/resolved.go +++ b/config/resolved.go @@ -40,6 +40,15 @@ type ResolvedConfig struct { Lifecycle LifecycleCommands WaitFor LifecyclePhase + // SecretsCommand is a host-side hook that runs before container start + // (analogous to initializeCommand) and whose stdout is parsed as + // key=value lines and merged into the container's environment. Unlike + // the lifecycle phases, it is not contributed by feature/base-image + // metadata layers — only the user's devcontainer.json sources it — + // so it is a single LifecycleCommand rather than a slice. Empty + // when devcontainer.json has no `secretsCommand`. + SecretsCommand LifecycleCommand + ForwardPorts []PortSpec PortsAttributes map[string]PortAttributes OtherPortsAttributes *PortAttributes diff --git a/secrets.go b/secrets.go new file mode 100644 index 0000000..b5fdfdb --- /dev/null +++ b/secrets.go @@ -0,0 +1,166 @@ +package devcontainer + +import ( + "context" + "sort" + "strings" + + "github.com/crunchloop/devcontainer/config" +) + +// collectSecretsEnv merges UpOptions.ExtraContainerEnv with any +// secretsCommand output and returns the result for use as +// `extraEnv` in buildRunSpec / compose overrides. Caller-supplied +// ExtraContainerEnv wins on key collision (explicit caller intent +// trumps spec hooks). Returns nil when neither source contributes. +// +// Returns (nil, *LifecycleError) if RunSecretsCommand was requested +// but the executor is missing or the host command failed. +func (e *Engine) collectSecretsEnv(ctx context.Context, cfg *config.ResolvedConfig, opts UpOptions) (map[string]string, error) { + if !opts.RunSecretsCommand { + return opts.ExtraContainerEnv, nil + } + secrets, err := e.runSecretsCommand(ctx, cfg) + if err != nil { + return nil, err + } + if len(secrets) == 0 { + return opts.ExtraContainerEnv, nil + } + if len(opts.ExtraContainerEnv) == 0 { + return secrets, nil + } + out := make(map[string]string, len(secrets)+len(opts.ExtraContainerEnv)) + for k, v := range secrets { + out[k] = v + } + for k, v := range opts.ExtraContainerEnv { + out[k] = v + } + return out, nil +} + +// runSecretsCommand executes the host-side secretsCommand via the +// engine's HostExecutor and returns its parsed key=value stdout as an +// env map. Returns (nil, nil) when no secretsCommand is configured. +// +// secretsCommand is conceptually parallel to initializeCommand: +// host-side, security-sensitive, opt-in. Where initializeCommand is +// fire-and-forget (exit code only), secretsCommand exists specifically +// to capture stdout and inject it into the container's environment +// before container start. +// +// On non-zero host exit we return a *LifecycleError so callers can +// surface stderr and the exit code via the same error shape used by +// initializeCommand failures. The phase tag is "secretsCommand" +// (synthetic — not a real LifecyclePhase, since it isn't an +// in-container phase and has no marker). +func (e *Engine) runSecretsCommand(ctx context.Context, cfg *config.ResolvedConfig) (map[string]string, error) { + if cfg.SecretsCommand.IsEmpty() { + return nil, nil + } + if e.opts.HostExecutor == nil { + return nil, &LifecycleError{ + Phase: phaseSecretsCommand, + Cause: ErrHostExecutorNotConfigured, + } + } + if cfg.SecretsCommand.Single != nil { + return e.execSecretsHost(ctx, cfg, *cfg.SecretsCommand.Single) + } + // Parallel form: run each named command (sequentially, in name + // order) and merge results. Spec doesn't strictly bless parallel + // for secretsCommand, but the command shape is shared with + // initializeCommand so we accept it for symmetry. Duplicate keys + // are resolved last-write-wins by name order (lexicographic, + // matching execParallel's determinism). + names := make([]string, 0, len(cfg.SecretsCommand.Parallel)) + for k := range cfg.SecretsCommand.Parallel { + names = append(names, k) + } + sort.Strings(names) + merged := map[string]string{} + for _, name := range names { + c := cfg.SecretsCommand.Parallel[name] + env, err := e.execSecretsHost(ctx, cfg, c) + if err != nil { + return nil, err + } + for k, v := range env { + merged[k] = v + } + } + if len(merged) == 0 { + return nil, nil + } + return merged, nil +} + +func (e *Engine) execSecretsHost(ctx context.Context, cfg *config.ResolvedConfig, c config.Command) (map[string]string, error) { + res, err := e.opts.HostExecutor.ExecHost(ctx, HostCommand{ + Shell: c.Shell, + Exec: c.Exec, + WorkingDir: cfg.LocalWorkspaceFolder, + }) + if err != nil { + return nil, &LifecycleError{Phase: phaseSecretsCommand, Cause: err} + } + if res.ExitCode != 0 { + return nil, &LifecycleError{ + Phase: phaseSecretsCommand, + ExitCode: res.ExitCode, + Stdout: res.Stdout, + Stderr: res.Stderr, + } + } + return parseSecretsKV(res.Stdout), nil +} + +// phaseSecretsCommand is the LifecycleError.Phase tag for +// secretsCommand failures. Not a real lifecycle phase — used only so +// callers can distinguish secretsCommand failures from +// initializeCommand failures via errors.As + Phase. +const phaseSecretsCommand config.LifecyclePhase = "secretsCommand" + +// parseSecretsKV parses a stdout stream of `KEY=VALUE` lines into a +// map. Rules (intentionally narrow — secretsCommand stdout is +// program-generated, not human-edited): +// - one entry per line +// - blank lines and lines starting with `#` (after leading +// whitespace) are ignored +// - leading/trailing whitespace around the key is trimmed; values +// are taken verbatim from after the first '=' (no quote +// stripping, no escape processing) +// - lines without `=`, or with an empty key after trimming, are +// skipped silently +// +// Deliberately no quote stripping or `\n` escape interpretation: +// callers needing that should emit clean k=v from secretsCommand. +// Doing magic here would silently mangle values that contain a +// literal `"` or `\`. +func parseSecretsKV(s string) map[string]string { + if s == "" { + return nil + } + out := map[string]string{} + for _, line := range strings.Split(s, "\n") { + line = strings.TrimSuffix(line, "\r") + trimmed := strings.TrimLeft(line, " \t") + if trimmed == "" || strings.HasPrefix(trimmed, "#") { + continue + } + i := strings.IndexByte(trimmed, '=') + if i <= 0 { + continue + } + key := strings.TrimRight(trimmed[:i], " \t") + if key == "" { + continue + } + out[key] = trimmed[i+1:] + } + if len(out) == 0 { + return nil + } + return out +} diff --git a/secrets_test.go b/secrets_test.go new file mode 100644 index 0000000..c0d473c --- /dev/null +++ b/secrets_test.go @@ -0,0 +1,190 @@ +package devcontainer + +import ( + "context" + "errors" + "testing" +) + +func TestParseSecretsKV(t *testing.T) { + cases := []struct { + name string + in string + want map[string]string + }{ + {"empty", "", nil}, + {"single", "FOO=bar\n", map[string]string{"FOO": "bar"}}, + {"trailing newline missing", "FOO=bar", map[string]string{"FOO": "bar"}}, + {"crlf", "FOO=bar\r\nBAZ=qux\r\n", map[string]string{"FOO": "bar", "BAZ": "qux"}}, + {"comments and blanks ignored", "# header\n\nFOO=bar\n # indented comment\nBAZ=qux\n", map[string]string{"FOO": "bar", "BAZ": "qux"}}, + {"value with =", "URL=https://x?a=b&c=d\n", map[string]string{"URL": "https://x?a=b&c=d"}}, + {"empty value", "EMPTY=\nFOO=bar\n", map[string]string{"EMPTY": "", "FOO": "bar"}}, + {"key whitespace trimmed", " FOO =bar\n", map[string]string{"FOO": "bar"}}, + {"value preserved verbatim", `QUOTED="x y"`, map[string]string{"QUOTED": `"x y"`}}, + {"no equals skipped", "GARBAGE\nFOO=bar\n", map[string]string{"FOO": "bar"}}, + {"empty key skipped", "=value\nFOO=bar\n", map[string]string{"FOO": "bar"}}, + {"only blanks", "\n\n# only\n", nil}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := parseSecretsKV(tc.in) + if len(got) != len(tc.want) { + t.Fatalf("len = %d, want %d (got=%v)", len(got), len(tc.want), got) + } + for k, v := range tc.want { + if got[k] != v { + t.Errorf("[%s] = %q, want %q", k, got[k], v) + } + } + }) + } +} + +func TestSecretsCommand_RequiresHostExecutor(t *testing.T) { + rt := newScriptedRuntime() + eng, _ := New(EngineOptions{Runtime: rt}) + ws := writeImageDevcontainer(t, `{ + "image":"alpine:3.20", + "secretsCommand":"echo TOKEN=abc" + }`) + _, err := eng.Up(context.Background(), UpOptions{ + LocalWorkspaceFolder: ws, + RunSecretsCommand: true, + }) + if err == nil { + t.Fatal("expected error when HostExecutor is unset") + } + if !errors.Is(err, ErrHostExecutorNotConfigured) { + t.Errorf("want ErrHostExecutorNotConfigured in error chain, got %v", err) + } + var le *LifecycleError + if !errors.As(err, &le) { + t.Fatalf("want *LifecycleError, got %T", err) + } + if le.Phase != phaseSecretsCommand { + t.Errorf("Phase = %q, want %q", le.Phase, phaseSecretsCommand) + } +} + +// secretsHostExecutor returns a canned stdout for the first call and +// records the host command shell so tests can assert on it. +type secretsHostExecutor struct { + stdout string + stderr string + exitCode int + calls []HostCommand +} + +func (s *secretsHostExecutor) ExecHost(ctx context.Context, cmd HostCommand) (HostExecResult, error) { + s.calls = append(s.calls, cmd) + return HostExecResult{ExitCode: s.exitCode, Stdout: s.stdout, Stderr: s.stderr}, nil +} + +func TestSecretsCommand_MergesIntoContainerEnv(t *testing.T) { + rt := newFakeRuntime() + hx := &secretsHostExecutor{stdout: "TOKEN=abc\nDB_URL=postgres://x\n"} + eng, _ := New(EngineOptions{Runtime: rt, HostExecutor: hx}) + ws := writeImageDevcontainer(t, `{ + "image":"alpine:3.20", + "containerEnv": {"FROM_CFG":"keep"}, + "secretsCommand":"echo TOKEN=abc" + }`) + if _, err := eng.Up(context.Background(), UpOptions{ + LocalWorkspaceFolder: ws, + RunSecretsCommand: true, + }); err != nil { + t.Fatalf("Up: %v", err) + } + if rt.createdSpec == nil { + t.Fatal("createdSpec is nil") + } + if len(hx.calls) != 1 { + t.Fatalf("expected one host call, got %d", len(hx.calls)) + } + if hx.calls[0].Shell != "echo TOKEN=abc" { + t.Errorf("Shell = %q, want %q", hx.calls[0].Shell, "echo TOKEN=abc") + } + if hx.calls[0].WorkingDir != ws { + t.Errorf("WorkingDir = %q, want %q", hx.calls[0].WorkingDir, ws) + } + for _, want := range []struct{ k, v string }{ + {"TOKEN", "abc"}, + {"DB_URL", "postgres://x"}, + {"FROM_CFG", "keep"}, + } { + if got := rt.createdSpec.Env[want.k]; got != want.v { + t.Errorf("Env[%s] = %q, want %q", want.k, got, want.v) + } + } +} + +func TestSecretsCommand_ExtraContainerEnvOverridesSecrets(t *testing.T) { + rt := newFakeRuntime() + hx := &secretsHostExecutor{stdout: "TOKEN=from-secrets\nLEAVE=alone\n"} + eng, _ := New(EngineOptions{Runtime: rt, HostExecutor: hx}) + ws := writeImageDevcontainer(t, `{ + "image":"alpine:3.20", + "secretsCommand":"emit" + }`) + if _, err := eng.Up(context.Background(), UpOptions{ + LocalWorkspaceFolder: ws, + RunSecretsCommand: true, + ExtraContainerEnv: map[string]string{"TOKEN": "from-caller"}, + }); err != nil { + t.Fatalf("Up: %v", err) + } + if got := rt.createdSpec.Env["TOKEN"]; got != "from-caller" { + t.Errorf("ExtraContainerEnv must win on collision, got TOKEN=%q", got) + } + if got := rt.createdSpec.Env["LEAVE"]; got != "alone" { + t.Errorf("non-colliding secret dropped: LEAVE=%q", got) + } +} + +func TestSecretsCommand_SkippedByDefault(t *testing.T) { + rt := newFakeRuntime() + hx := &secretsHostExecutor{stdout: "TOKEN=should-not-appear\n"} + eng, _ := New(EngineOptions{Runtime: rt, HostExecutor: hx}) + ws := writeImageDevcontainer(t, `{ + "image":"alpine:3.20", + "secretsCommand":"emit" + }`) + if _, err := eng.Up(context.Background(), UpOptions{ + LocalWorkspaceFolder: ws, + }); err != nil { + t.Fatalf("Up: %v", err) + } + if len(hx.calls) != 0 { + t.Errorf("HostExecutor must not be invoked unless RunSecretsCommand=true, got %d calls", len(hx.calls)) + } + if _, ok := rt.createdSpec.Env["TOKEN"]; ok { + t.Errorf("secretsCommand output leaked into env despite RunSecretsCommand=false") + } +} + +func TestSecretsCommand_NonZeroExitProducesLifecycleError(t *testing.T) { + rt := newFakeRuntime() + hx := &secretsHostExecutor{exitCode: 13, stderr: "no creds"} + eng, _ := New(EngineOptions{Runtime: rt, HostExecutor: hx}) + ws := writeImageDevcontainer(t, `{ + "image":"alpine:3.20", + "secretsCommand":"fail" + }`) + _, err := eng.Up(context.Background(), UpOptions{ + LocalWorkspaceFolder: ws, + RunSecretsCommand: true, + }) + if err == nil { + t.Fatal("expected error on non-zero secretsCommand exit") + } + var le *LifecycleError + if !errors.As(err, &le) { + t.Fatalf("want *LifecycleError, got %T", err) + } + if le.ExitCode != 13 { + t.Errorf("ExitCode = %d, want 13", le.ExitCode) + } + if le.Phase != phaseSecretsCommand { + t.Errorf("Phase = %q, want %q", le.Phase, phaseSecretsCommand) + } +} diff --git a/up.go b/up.go index 03357d1..df15337 100644 --- a/up.go +++ b/up.go @@ -54,6 +54,20 @@ type UpOptions struct { // real host execution requires caller-supplied wiring (PRD §11). RunInitializeCommand bool + // RunSecretsCommand, when true, runs the host-side secretsCommand + // before container creation and merges its stdout (parsed as + // key=value lines) into the container's environment. Default false + // for the same reason as RunInitializeCommand: arbitrary host + // execution is opt-in. Requires EngineOptions.HostExecutor to be + // set; otherwise a *LifecycleError wrapping + // ErrHostExecutorNotConfigured is returned. + // + // Only applied on fresh container creation. On reattach the + // existing container's env is already baked, so re-running + // secretsCommand would have no effect and we skip it; callers + // wanting a refresh should pass Recreate=true. + RunSecretsCommand bool + // ExtraMounts are appended to the mounts derived from devcontainer.json. // They layer on top of cfg.WorkspaceMount and cfg.Mounts and are // preserved across reattach (they only apply on fresh container @@ -148,7 +162,19 @@ func (e *Engine) Up(ctx context.Context, opts UpOptions) (*Workspace, error) { // stay, stopped ones restart, missing ones get created. So we // always go through createFreshCompose, regardless of whether // `existing` was found — compose handles the dispatch internally. - ws, err = e.createFreshCompose(ctx, cfg, opts) + // + // Reattach caveat for host-side hooks: when compose finds an + // existing container, its env is already baked. secretsCommand + // output would not actually flow into the running container, + // so suppress it here to keep the "fresh-only" contract + // documented on UpOptions.RunSecretsCommand. Callers wanting a + // refresh pass Recreate=true (which already nils out + // `existing` above). + composeOpts := opts + if existing != nil { + composeOpts.RunSecretsCommand = false + } + ws, err = e.createFreshCompose(ctx, cfg, composeOpts) case existing != nil: ws, err = e.attachExisting(ctx, existing, cfg, opts) default: @@ -215,7 +241,12 @@ func (e *Engine) createFresh(ctx context.Context, cfg *config.ResolvedConfig, op return nil, err } - spec := buildRunSpec(cfg, finalImage, opts.ExtraMounts, opts.ExtraContainerEnv) + extraEnv, err := e.collectSecretsEnv(ctx, cfg, opts) + if err != nil { + return nil, err + } + + spec := buildRunSpec(cfg, finalImage, opts.ExtraMounts, extraEnv) c, err := e.runtime.RunContainer(ctx, spec) if err != nil { return nil, fmt.Errorf("create container: %w", err) @@ -394,6 +425,11 @@ func (e *Engine) createFreshCompose(ctx context.Context, cfg *config.ResolvedCon return nil, err } + extraEnv, err := e.collectSecretsEnv(ctx, cfg, opts) + if err != nil { + return nil, err + } + tmp, err := os.MkdirTemp("", "dc-go-compose-*") if err != nil { return nil, fmt.Errorf("create compose override tmpdir: %w", err) @@ -438,7 +474,7 @@ func (e *Engine) createFreshCompose(ctx context.Context, cfg *config.ResolvedCon if err := compose.WriteRunOverride(runOverridePath, project, compose.Override{ Service: src.Service, ExtraBindMounts: bindMounts, - ExtraEnvironment: mergeEnv(cfg.ContainerEnv, opts.ExtraContainerEnv), + ExtraEnvironment: mergeEnv(cfg.ContainerEnv, extraEnv), Labels: map[string]string{ LabelDevcontainerID: cfg.DevcontainerID, LabelLocalWorkspaceFolder: cfg.LocalWorkspaceFolder,