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
17 changes: 17 additions & 0 deletions compose/orchestrator.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
62 changes: 62 additions & 0 deletions compose/orchestrator_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
}
6 changes: 6 additions & 0 deletions compose/plan.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
19 changes: 16 additions & 3 deletions up.go
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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,
},
Expand Down
Loading