From 698bb2e3168b69e5deb309d1f98a577cca440c74 Mon Sep 17 00:00:00 2001 From: bilby91 Date: Sun, 10 May 2026 01:00:55 -0300 Subject: [PATCH] engine: HostExecutor interface for initializeCommand (and future host hooks) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a caller-supplied HostExecutor interface that the engine dispatches host-side spec hooks to. initializeCommand now routes through it (Single + Parallel forms). secretsCommand will plug into the same interface in a follow-up. Library does NOT ship a default HostExecutor: host execution is security-sensitive (devcontainer.json can declare arbitrary commands), and policy decisions — sandboxing, env filtering, timeouts, working dir — belong to the embedding application. Unconfigured executor + a configured initializeCommand returns a *LifecycleError wrapping a typed sentinel ErrHostExecutorNotConfigured so callers can errors.Is and surface a useful message. Closes #11. Co-Authored-By: Claude Opus 4.7 (1M context) --- engine.go | 7 +++ host_executor.go | 72 +++++++++++++++++++++++++++++++ lifecycle.go | 107 ++++++++++++++++++++++++++++++++++++++++------ lifecycle_test.go | 94 ++++++++++++++++++++++++++++++++++++++-- 4 files changed, 265 insertions(+), 15 deletions(-) create mode 100644 host_executor.go diff --git a/engine.go b/engine.go index f6ed1a5..f69eebd 100644 --- a/engine.go +++ b/engine.go @@ -60,6 +60,13 @@ type EngineOptions struct { // the resolved digest — for reproducible builds. See // design/features.md §10.3. StrictFeatureVersionMatch bool + + // HostExecutor enables host-side spec hooks (initializeCommand, + // future secretsCommand). Nil means host hooks return a + // *LifecycleError wrapping ErrHostExecutorNotConfigured, since + // host execution is opt-in and security-sensitive — see + // HostExecutor docs. + HostExecutor HostExecutor } // New constructs an Engine. Returns an error if Runtime is nil or the diff --git a/host_executor.go b/host_executor.go new file mode 100644 index 0000000..a367a68 --- /dev/null +++ b/host_executor.go @@ -0,0 +1,72 @@ +package devcontainer + +import ( + "context" + "errors" +) + +// HostExecutor runs commands on the host. Callers supply one via +// EngineOptions.HostExecutor to enable host-side spec hooks like +// initializeCommand (and, in a follow-up, secretsCommand). The +// library does NOT ship a default implementation: host execution is +// security-sensitive (devcontainer.json can declare arbitrary +// commands), and the policy decisions — sandboxing, env filtering, +// timeouts, max output, working directory — belong to the embedding +// application. +// +// When EngineOptions.HostExecutor is nil and a hook would run, the +// engine returns a *LifecycleError wrapping ErrHostExecutorNotConfigured. +// Callers can detect this via errors.Is to surface a useful message +// ("set EngineOptions.HostExecutor to enable initializeCommand") or +// to skip silently in environments where host execution isn't +// permitted. +type HostExecutor interface { + // ExecHost runs a host-side command. The executor is responsible + // for the shell / exec dispatch (HostCommand.Shell vs + // HostCommand.Exec — exactly one is set), environment merging, + // and working-directory selection. Cancellation via ctx must + // propagate to the spawned process. + // + // Return a non-nil error only for executor-internal failures + // (process couldn't start, I/O error). A non-zero command exit + // is reported via HostExecResult.ExitCode with a nil error so + // the engine can wrap it consistently with container-side + // execution. + ExecHost(ctx context.Context, cmd HostCommand) (HostExecResult, error) +} + +// HostCommand is the input to HostExecutor.ExecHost. Shape mirrors +// runtime.ExecOptions / config.Command so callers building both can +// reuse mental model. +type HostCommand struct { + // Shell is a single shell command line (`sh -c ` style). + // Mutually exclusive with Exec. + Shell string + + // Exec is a literal argv invocation (no shell). Mutually + // exclusive with Shell. + Exec []string + + // Env is merged on top of the host process environment. + // Implementations decide whether to filter the inherited host + // env (e.g. drop secrets) or pass it through. + Env map[string]string + + // WorkingDir is the host directory to run in. Empty leaves the + // choice to the executor; the engine populates this with the + // workspace's LocalWorkspaceFolder for spec hooks. + WorkingDir string +} + +// HostExecResult is the outcome of HostExecutor.ExecHost. +type HostExecResult struct { + ExitCode int + Stdout string + Stderr string +} + +// ErrHostExecutorNotConfigured is returned (wrapped in *LifecycleError) +// when a host-side hook is configured in devcontainer.json but the +// engine has no HostExecutor to dispatch it. Callers wanting to +// detect this specifically use errors.Is. +var ErrHostExecutorNotConfigured = errors.New("host executor not configured (set EngineOptions.HostExecutor)") diff --git a/lifecycle.go b/lifecycle.go index 21e34ba..1baa49d 100644 --- a/lifecycle.go +++ b/lifecycle.go @@ -146,20 +146,103 @@ func (e *Engine) runPhase(ctx context.Context, ws *Workspace, phase config.Lifec return nil } -// runInitialize executes a host-side initializeCommand. Currently only -// the Single form is supported; parallel-named initialize commands fall -// through with a TODO. We intentionally do NOT pass the host environment -// or working directory verbatim — caller is responsible for providing a -// safe context via UpOptions if needed. +// runInitialize executes a host-side initializeCommand via the +// caller-supplied HostExecutor. Returns ErrHostExecutorNotConfigured +// (wrapped in *LifecycleError) when the engine has no executor — this +// is the explicit "host execution requires opt-in" stance, not a +// silent skip, so consumers see the misconfiguration. +// +// Single and Parallel command forms are both routed through the +// executor; for Parallel, named entries run concurrently and the +// first non-zero exit aggregates per-name stderr (mirrors +// execParallel for in-container hooks). func (e *Engine) runInitialize(ctx context.Context, ws *Workspace, cmd config.LifecycleCommand) error { - // Host-side execution is deliberately minimal in v1 — we don't shell - // out from inside the engine at all to avoid making the library look - // like a host CLI. A future opt-in HostExecutor field on - // EngineOptions can take this on; for now, log a warning and skip. - return &LifecycleError{ - Phase: config.LifecycleInitialize, - Cause: fmt.Errorf("initializeCommand not supported in v1 (host-side execution requires explicit caller wiring)"), + if e.opts.HostExecutor == nil { + return &LifecycleError{ + Phase: config.LifecycleInitialize, + Cause: ErrHostExecutorNotConfigured, + } + } + if cmd.Single != nil { + return e.runInitializeSingle(ctx, ws, *cmd.Single) + } + if len(cmd.Parallel) > 0 { + return e.runInitializeParallel(ctx, ws, cmd.Parallel) + } + return nil +} + +func (e *Engine) runInitializeSingle(ctx context.Context, ws *Workspace, c config.Command) error { + hc := HostCommand{ + Shell: c.Shell, + Exec: c.Exec, + WorkingDir: ws.Config.LocalWorkspaceFolder, + } + res, err := e.opts.HostExecutor.ExecHost(ctx, hc) + if err != nil { + return &LifecycleError{Phase: config.LifecycleInitialize, Cause: err} + } + if res.ExitCode != 0 { + return &LifecycleError{ + Phase: config.LifecycleInitialize, + ExitCode: res.ExitCode, + Stdout: res.Stdout, + Stderr: res.Stderr, + } + } + return nil +} + +func (e *Engine) runInitializeParallel(ctx context.Context, ws *Workspace, parallel map[string]config.Command) error { + names := make([]string, 0, len(parallel)) + for k := range parallel { + names = append(names, k) } + sort.Strings(names) + + type result struct { + exit int + stdout string + stderr string + err error + } + results := make([]result, len(names)) + + var wg sync.WaitGroup + for i, name := range names { + wg.Add(1) + go func(i int, c config.Command) { + defer wg.Done() + res, err := e.opts.HostExecutor.ExecHost(ctx, HostCommand{ + Shell: c.Shell, + Exec: c.Exec, + WorkingDir: ws.Config.LocalWorkspaceFolder, + }) + if err != nil { + results[i] = result{err: err} + return + } + results[i] = result{exit: res.ExitCode, stdout: res.Stdout, stderr: res.Stderr} + }(i, parallel[name]) + } + wg.Wait() + + for _, r := range results { + if r.err != nil { + return &LifecycleError{Phase: config.LifecycleInitialize, Cause: r.err} + } + } + for _, r := range results { + if r.exit != 0 { + return &LifecycleError{ + Phase: config.LifecycleInitialize, + ExitCode: r.exit, + Stdout: r.stdout, + Stderr: r.stderr, + } + } + } + return nil } // execLifecycleCommand routes a single LifecycleCommand through diff --git a/lifecycle_test.go b/lifecycle_test.go index 49bc1e9..7c0a1da 100644 --- a/lifecycle_test.go +++ b/lifecycle_test.go @@ -261,7 +261,7 @@ func TestRunLifecycle_EmptyPhaseIsNoop(t *testing.T) { } } -func TestInitializeCommand_NotSupportedInV1(t *testing.T) { +func TestInitializeCommand_RequiresHostExecutor(t *testing.T) { rt := newScriptedRuntime() eng, _ := New(EngineOptions{Runtime: rt}) ws := writeImageDevcontainer(t, `{ @@ -273,9 +273,97 @@ func TestInitializeCommand_NotSupportedInV1(t *testing.T) { RunInitializeCommand: true, }) if err == nil { - t.Fatal("expected error for initializeCommand in v1") + t.Fatal("expected error when HostExecutor is unset") } if !IsLifecycleError(err) { - t.Errorf("want *LifecycleError, got %T", err) + t.Fatalf("want *LifecycleError, got %T", err) + } + if !errors.Is(err, ErrHostExecutorNotConfigured) { + t.Errorf("want ErrHostExecutorNotConfigured in error chain, got %v", err) + } +} + +// fakeHostExecutor records every call and returns a canned exit code. +type fakeHostExecutor struct { + calls []HostCommand + exitCode int + stderr string + err error +} + +func (f *fakeHostExecutor) ExecHost(ctx context.Context, cmd HostCommand) (HostExecResult, error) { + f.calls = append(f.calls, cmd) + if f.err != nil { + return HostExecResult{}, f.err + } + return HostExecResult{ExitCode: f.exitCode, Stderr: f.stderr}, nil +} + +func TestInitializeCommand_RoutesToHostExecutorSingle(t *testing.T) { + rt := newScriptedRuntime() + hx := &fakeHostExecutor{} + eng, _ := New(EngineOptions{Runtime: rt, HostExecutor: hx}) + ws := writeImageDevcontainer(t, `{ + "image":"alpine:3.20", + "initializeCommand":"echo on-host" + }`) + if _, err := eng.Up(context.Background(), UpOptions{ + LocalWorkspaceFolder: ws, + RunInitializeCommand: true, + }); err != nil { + t.Fatalf("Up: %v", err) + } + if len(hx.calls) != 1 { + t.Fatalf("expected one host call, got %d", len(hx.calls)) + } + if hx.calls[0].Shell != "echo on-host" { + t.Errorf("Shell = %q, want %q", hx.calls[0].Shell, "echo on-host") + } + if hx.calls[0].WorkingDir != ws { + t.Errorf("WorkingDir = %q, want %q (LocalWorkspaceFolder)", hx.calls[0].WorkingDir, ws) + } +} + +func TestInitializeCommand_NonZeroExitProducesLifecycleError(t *testing.T) { + rt := newScriptedRuntime() + hx := &fakeHostExecutor{exitCode: 7, stderr: "boom"} + eng, _ := New(EngineOptions{Runtime: rt, HostExecutor: hx}) + ws := writeImageDevcontainer(t, `{ + "image":"alpine:3.20", + "initializeCommand":"false" + }`) + _, err := eng.Up(context.Background(), UpOptions{ + LocalWorkspaceFolder: ws, + RunInitializeCommand: true, + }) + if err == nil { + t.Fatal("expected error on non-zero host exit") + } + var le *LifecycleError + if !errors.As(err, &le) { + t.Fatalf("want *LifecycleError, got %T", err) + } + if le.ExitCode != 7 { + t.Errorf("ExitCode = %d, want 7", le.ExitCode) + } +} + +func TestInitializeCommand_SkippedByDefault(t *testing.T) { + // RunInitializeCommand=false (default) → host executor never called, + // even if devcontainer.json declares initializeCommand. + rt := newScriptedRuntime() + hx := &fakeHostExecutor{} + eng, _ := New(EngineOptions{Runtime: rt, HostExecutor: hx}) + ws := writeImageDevcontainer(t, `{ + "image":"alpine:3.20", + "initializeCommand":"echo never" + }`) + 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 RunInitializeCommand=true, got %d calls", len(hx.calls)) } }