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
58 changes: 58 additions & 0 deletions engine_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -308,6 +308,64 @@ func TestUp_RecreateForcesFresh(t *testing.T) {
}
}

func TestUp_ExtraMountsAndContainerEnv(t *testing.T) {
rt := newFakeRuntime()
eng, _ := New(EngineOptions{Runtime: rt})
ws := writeImageDevcontainer(t, `{
"image": "alpine:3.20",
"containerEnv": { "FROM_CONFIG": "config", "OVERRIDE_ME": "config" },
"mounts": [ "type=bind,source=/cfg-src,target=/cfg-tgt" ]
}`)

if _, err := eng.Up(context.Background(), UpOptions{
LocalWorkspaceFolder: ws,
ExtraMounts: []runtime.MountSpec{
{Type: runtime.MountBind, Source: "/host/dap", Target: "/dap"},
},
ExtraContainerEnv: map[string]string{
"FROM_EXTRA": "extra",
"OVERRIDE_ME": "extra",
},
}); err != nil {
t.Fatalf("Up: %v", err)
}

if rt.createdSpec == nil {
t.Fatal("createdSpec is nil")
}

// Mounts: workspace bind + cfg.Mounts entry + extra. Order:
// workspace, then cfg.Mounts, then extras (we don't pin the
// workspace target, just assert the cfg + extra entries are present).
var sawCfg, sawExtra bool
for _, m := range rt.createdSpec.Mounts {
if m.Source == "/cfg-src" && m.Target == "/cfg-tgt" {
sawCfg = true
}
if m.Source == "/host/dap" && m.Target == "/dap" {
sawExtra = true
}
}
if !sawCfg {
t.Errorf("cfg.Mounts entry missing from RunSpec: %+v", rt.createdSpec.Mounts)
}
if !sawExtra {
t.Errorf("ExtraMounts entry missing from RunSpec: %+v", rt.createdSpec.Mounts)
}

// Env: cfg entries preserved, extras merged on top, conflicts resolved
// in favor of extras.
if got := rt.createdSpec.Env["FROM_CONFIG"]; got != "config" {
t.Errorf("FROM_CONFIG = %q, want %q", got, "config")
}
if got := rt.createdSpec.Env["FROM_EXTRA"]; got != "extra" {
t.Errorf("FROM_EXTRA = %q, want %q", got, "extra")
}
if got := rt.createdSpec.Env["OVERRIDE_ME"]; got != "extra" {
t.Errorf("OVERRIDE_ME = %q, want extras to win, got %q", got, "extra")
}
}

func TestExec_SubstitutesContainerEnv(t *testing.T) {
rt := newFakeRuntime()
eng, _ := New(EngineOptions{Runtime: rt})
Expand Down
62 changes: 56 additions & 6 deletions up.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,23 @@ type UpOptions struct {
// Note: v1 initialize execution is a stub that returns an error;
// real host execution requires caller-supplied wiring (PRD §11).
RunInitializeCommand 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
// creation, since reattach inherits the original container's mounts).
// For compose sources, only Type == runtime.MountBind entries are
// honored — other mount types are silently dropped to match the
// devcontainer.json `mounts` semantics.
ExtraMounts []runtime.MountSpec

// ExtraContainerEnv is merged into the container's environment, layered
// on top of cfg.ContainerEnv. Entries here are baked into the container
// at start time, so every subsequent exec — including lifecycle scripts
// and feature install — inherits them. Use this for callers that need
// to inject host-derived env (PATH overrides, proxy vars, short-lived
// auth tokens) without mutating devcontainer.json.
ExtraContainerEnv map[string]string
}

// PullPolicy controls when images are pulled from a registry.
Expand Down Expand Up @@ -169,7 +186,7 @@ func (e *Engine) createFresh(ctx context.Context, cfg *config.ResolvedConfig, op
return nil, err
}

spec := buildRunSpec(cfg, finalImage)
spec := buildRunSpec(cfg, finalImage, opts.ExtraMounts, opts.ExtraContainerEnv)
c, err := e.runtime.RunContainer(ctx, spec)
if err != nil {
return nil, fmt.Errorf("create container: %w", err)
Expand Down Expand Up @@ -370,11 +387,23 @@ func (e *Engine) createFreshCompose(ctx context.Context, cfg *config.ResolvedCon
})
}
}
// Extra mounts: only bind types are expressible in compose overrides.
// Other types (volume, tmpfs) are silently dropped, mirroring how
// devcontainer.json `mounts` are filtered above.
for _, m := range opts.ExtraMounts {
if m.Type == runtime.MountBind && m.Source != "" && m.Target != "" {
bindMounts = append(bindMounts, compose.BindMount{
Source: m.Source,
Target: m.Target,
ReadOnly: m.ReadOnly,
})
}
}

if err := compose.WriteRunOverride(runOverridePath, project, compose.Override{
Service: src.Service,
ExtraBindMounts: bindMounts,
ExtraEnvironment: cfg.ContainerEnv,
ExtraEnvironment: mergeEnv(cfg.ContainerEnv, opts.ExtraContainerEnv),
Labels: map[string]string{
LabelDevcontainerID: cfg.DevcontainerID,
LabelLocalWorkspaceFolder: cfg.LocalWorkspaceFolder,
Expand Down Expand Up @@ -723,9 +752,10 @@ func (e *Engine) buildWorkspace(ctx context.Context, containerID string, cfg *co

// buildRunSpec converts a ResolvedConfig (image source) into a runtime.RunSpec.
// Mounts include the workspace bind (default or user-overridden) and any
// additional mounts from devcontainer.json. Labels are populated for
// label-based lookup.
func buildRunSpec(cfg *config.ResolvedConfig, image string) runtime.RunSpec {
// additional mounts from devcontainer.json. extraMounts are appended after
// the cfg-derived mounts; extraEnv is merged into cfg.ContainerEnv (extras
// win on key collision). Labels are populated for label-based lookup.
func buildRunSpec(cfg *config.ResolvedConfig, image string, extraMounts []runtime.MountSpec, extraEnv map[string]string) runtime.RunSpec {
labels := map[string]string{
LabelDevcontainerID: cfg.DevcontainerID,
LabelLocalWorkspaceFolder: cfg.LocalWorkspaceFolder,
Expand All @@ -737,13 +767,16 @@ func buildRunSpec(cfg *config.ResolvedConfig, image string) runtime.RunSpec {
// container, so it stays in the (host-side) ResolvedConfig instead.

mounts := buildMounts(cfg)
mounts = append(mounts, extraMounts...)

env := mergeEnv(cfg.ContainerEnv, extraEnv)

return runtime.RunSpec{
Image: image,
Name: containerName(WorkspaceID(cfg.DevcontainerID)),
User: cfg.ContainerUser,
WorkingDir: cfg.ContainerWorkspaceFolder,
Env: cfg.ContainerEnv,
Env: env,
Labels: labels,
Mounts: mounts,
RunArgs: cfg.RunArgs,
Expand All @@ -755,6 +788,23 @@ func buildRunSpec(cfg *config.ResolvedConfig, image string) runtime.RunSpec {
}
}

// mergeEnv returns a fresh map containing every entry in base, with extras
// applied on top. Returns nil if both inputs are empty so we don't allocate
// for the common no-extras path.
func mergeEnv(base, extras map[string]string) map[string]string {
if len(base) == 0 && len(extras) == 0 {
return nil
}
out := make(map[string]string, len(base)+len(extras))
for k, v := range base {
out[k] = v
}
for k, v := range extras {
out[k] = v
}
return out
}

// buildMounts assembles the workspace bind plus any additional mounts. The
// workspace mount is the resolved cfg.WorkspaceMount if set, otherwise a
// default bind (LocalWorkspaceFolder → ContainerWorkspaceFolder, with
Expand Down
Loading