Skip to content
Merged
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
7 changes: 7 additions & 0 deletions engine.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
72 changes: 72 additions & 0 deletions host_executor.go
Original file line number Diff line number Diff line change
@@ -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 <Shell>` 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)")
107 changes: 95 additions & 12 deletions lifecycle.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
94 changes: 91 additions & 3 deletions lifecycle_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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, `{
Expand All @@ -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))
}
}
Loading