diff --git a/compose/orchestrator.go b/compose/orchestrator.go index d6c62ce..9b667f2 100644 --- a/compose/orchestrator.go +++ b/compose/orchestrator.go @@ -488,6 +488,23 @@ func (o *Orchestrator) ensureService( if len(existing) > 0 { c := existing[0] + // Resume: reattach on-disk state exactly. Reuse the existing + // container unconditionally — start if stopped, attach if + // running — without consulting the config-hash. A restored + // workspace's primary image is feature-layered fresh every boot + // (new digest), so a hash check would force a recreate that + // abandons the container's upperdir and binds a new empty + // anonymous volume; adoption keeps both. Recreate-mode Up has + // already torn the project down before reaching here, so this + // only fires on non-recreating (resume) Ups. + if plan.AdoptExisting { + if c.State != runtime.StateRunning { + if err := o.rt.StartContainer(ctx, c.ID); err != nil { + return "", fmt.Errorf("StartContainer(resumed %s): %w", svc.Name, err) + } + } + return c.ID, nil + } // Inspect to read the stored hash + image digest. Recreate if // either has drifted; the image-digest check is the second // line of defense for tag-update scenarios where ConfigHash diff --git a/compose/orchestrator_test.go b/compose/orchestrator_test.go index 6b4070e..6591f40 100644 --- a/compose/orchestrator_test.go +++ b/compose/orchestrator_test.go @@ -1075,3 +1075,65 @@ func TestUp_ServiceNetworkModeResolvesToContainer(t *testing.T) { t.Errorf("app NetworkMode = %q, want %q", got, want) } } + +// AdoptExisting (resume) reuses an existing container even when its stored +// config-hash no longer matches — a restored workspace's primary image is +// feature-layered fresh every boot, so the hash always drifts; adoption must +// keep the container (and its upperdir + anon volumes) anyway. +func TestUp_AdoptExistingReusesDespiteHashDrift(t *testing.T) { + rt := newMockRuntime() + orch := NewOrchestrator(rt, "docker") + proj := newProject(t, map[string][]string{"app": nil}) + + // A prior orchestrator-made container whose stored hash is stale. + rt.containers["resumed-1"] = &mockContainer{ + id: "resumed-1", name: "dc-x-app-1", image: "alpine", + labels: map[string]string{ + LabelComposeProject: "dc-x", + LabelComposeService: "app", + LabelConfigHash: "STALE-hash-from-a-previous-boot", + LabelImageDigest: "sha256:stale", + }, + state: runtime.StateExited, + } + + res, err := orch.Up(context.Background(), &Plan{ + Project: proj, ProjectName: "dc-x", AdoptExisting: true, + }) + if err != nil { + t.Fatalf("Up: %v", err) + } + if res.ContainerIDs["app"] != "resumed-1" { + t.Errorf("ContainerIDs[app] = %q, want the adopted resumed-1", res.ContainerIDs["app"]) + } + if rt.removeCalls != 0 { + t.Errorf("removeCalls = %d; adoption must not recreate despite hash drift", rt.removeCalls) + } + if rt.runCalls != 0 { + t.Errorf("runCalls = %d; adoption must not create a replacement", rt.runCalls) + } + if rt.containers["resumed-1"].state != runtime.StateRunning { + t.Errorf("adopted container not started: state=%v", rt.containers["resumed-1"].state) + } +} + +// Without AdoptExisting, hash drift still recreates (guards the default path). +func TestUp_NoAdoptRecreatesOnHashDrift(t *testing.T) { + rt := newMockRuntime() + orch := NewOrchestrator(rt, "docker") + proj := newProject(t, map[string][]string{"app": nil}) + rt.containers["old-1"] = &mockContainer{ + id: "old-1", name: "dc-x-app-1", image: "alpine", + labels: map[string]string{ + LabelComposeProject: "dc-x", LabelComposeService: "app", + LabelConfigHash: "STALE", LabelImageDigest: "sha256:stale", + }, + state: runtime.StateExited, + } + if _, err := orch.Up(context.Background(), &Plan{Project: proj, ProjectName: "dc-x"}); err != nil { + t.Fatalf("Up: %v", err) + } + if rt.removeCalls == 0 { + t.Error("expected recreate (remove) on hash drift without AdoptExisting") + } +} diff --git a/compose/plan.go b/compose/plan.go index 23f5bcb..5e346a5 100644 --- a/compose/plan.go +++ b/compose/plan.go @@ -35,6 +35,12 @@ type Plan struct { // fills these in with the devcontainer ID label set so // Engine.Attach can find the primary container. Labels map[string]string + + // AdoptExisting reuses any existing (project, service) container + // unconditionally — start if stopped, attach if running, never + // recreate on a config-hash / image-digest mismatch. The resume + // contract: reattach on-disk state exactly, do not reconcile drift. + AdoptExisting bool } // DownPlan describes a teardown request. Used by Orchestrator.Down diff --git a/up.go b/up.go index fe58192..d56bb87 100644 --- a/up.go +++ b/up.go @@ -37,6 +37,18 @@ type UpOptions struct { // running container is attached to. Recreate bool + // AdoptExisting, for compose sources, reuses any existing container + // found for a (project, service) regardless of whether its stored + // config-hash / image-digest still match — start it if stopped, + // attach if running, never recreate. This is the RESUME contract: + // reattach the workspace exactly as it was left, preserving every + // container's writable upperdir and its (possibly anonymous) volumes, + // rather than reconciling against config drift (a rebuilt + // feature-layered primary image has a fresh digest every boot, which + // otherwise forces a recreate that abandons the upperdir and binds a + // new empty anonymous volume). Ignored when Recreate is true. + AdoptExisting bool + // PullPolicy controls image pulling. Default IfNotPresent. PullPolicy PullPolicy @@ -715,9 +727,10 @@ func (e *Engine) upComposeNative( orch := compose.NewOrchestrator(e.runtime, "") res, err := orch.Up(ctx, &compose.Plan{ - Project: project, - ProjectName: projectName, - Services: src.RunServices, + Project: project, + ProjectName: projectName, + Services: src.RunServices, + AdoptExisting: opts.AdoptExisting && !opts.Recreate, Labels: map[string]string{ LabelDevcontainerID: cfg.DevcontainerID, },