Skip to content

compose: retained named volumes and strict reconciliation - #59

Merged
rgarcia merged 2 commits into
mainfrom
oss/compose-retained-volumes
Aug 3, 2026
Merged

compose: retained named volumes and strict reconciliation#59
rgarcia merged 2 commits into
mainfrom
oss/compose-retained-volumes

Conversation

@rgarcia

@rgarcia rgarcia commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

What

Adds retained named volumes to hypeman compose, backed by the existing Hypeman volume APIs, and makes reconciliation strict, predictable, and non-destructive by default.

Retained volumes

  • Top-level volumes: declarations (size_gb, optional explicit name) and per-service mounts, in shorthand (data:/var/lib/data[:ro|rw]) or mapping form.
  • Volumes are created before instances and tagged with compose ownership (hypeman.compose.name/resource/hash; no service tag since volumes can be shared).
  • Planned instance mounts carry the volume name so rendered hashes stay stable before the volume exists; names are resolved to server-assigned volume IDs immediately before instance creation.
  • Volumes are retained across instance replacement (--replace) and compose downdown reports them as skip: retained.
  • Deleting retained data requires the explicit destructive option hypeman compose down --volumes, with clear plan output (delete ... --volumes destroys retained data).
  • Volumes are immutable once created: changing the declared spec (e.g. size_gb) plans a conflict that blocks up, instead of silently replacing the volume and losing data.
  • If instance replacement fails after the old instance is deleted, the retained volume and its data are untouched; re-running up --replace recreates the instance on the same volume.

Strict reconciliation

  • Compose parsing is strict: unknown YAML fields and duplicate keys fail validation at every level, including inside volume mount mappings.
  • Invalid/ambiguous mount declarations fail validation: unknown volume reference, missing volume/path, relative mount path, duplicate mount path, same volume mounted twice in one service.
  • compose up/plan now prune owned instances/ingresses that are no longer declared in the file (delete: no longer declared in compose file). Resources without compose ownership tags are never touched. Undeclared owned volumes are reported skip: retained, never auto-deleted.

Backward compatibility

Existing stateless compose files are unaffected: with no volumes: declared there are no volume actions and plan/up/down behave as before (the only intentional behavioral change is that up prunes owned resources removed from the file, which is the strict-reconciliation feature itself). Runner.Down gains a DownOptions struct in place of the bare verbose bool.

Acceptance criteria mapping

  • Top-level named volumes + service mounts backed by existing Hypeman volume APIs
  • Volumes created before instances; retained across replacement and compose down by default
  • Deleting retained data requires a separate explicit destructive option (down --volumes) with clear plan output
  • Unknown YAML fields and invalid/ambiguous mounts fail validation
  • compose up plans pruning for removed owned instances/ingresses; unmanaged resources untouched
  • Replacement failure leaves the retained volume and its data recoverable
  • plan/up/down idempotent; stateless files backward compatible
  • Tests prove nonce persistence through replacement and down/up against an in-memory Hypeman API seam (httptest fake implementing the volumes/instances/ingresses/images endpoints compose uses)

Tests

go test ./... — full suite green. New coverage in lib/compose/volumes_test.go:

  • shorthand parsing, strict parsing (unknown fields / duplicate keys), spec validation table
  • deterministic volume/mount rendering and hash stability
  • volume plan semantics (create / unchanged / conflict-on-spec-change / unmanaged-name conflict)
  • integration: up → down → up retains volume + nonce; replace retains volume + nonce; failed replace leaves volume recoverable; down --volumes destroys data and down is idempotent; spec change conflicts without touching data; pruning removes only owned resources

Risks

  • compose up now deletes owned instances/ingresses removed from the file (intended strict behavior; called out above). Retained volumes are never auto-pruned.
  • The SDK retries 5xx on instance create; a failed replace can therefore take a few seconds before surfacing.
  • Shorthand mount strings cannot contain ${env:...}/${file:...} references (the : in the interpolation syntax is ambiguous with the mount separator); the mapping form supports interpolation. Mount parsing errors point at the line.

Review request

Please review with Cursor Bugbot (cursor bugbot review requested on this PR), especially around the plan/apply ordering, the name→ID volume resolution at apply time, and the prune claim-tracking in pruneActions.


Note

Medium Risk
Changes data lifecycle (retained volumes and explicit --volumes deletion) and makes compose up delete owned resources dropped from the file; mistakes in compose files or pruning order could cause unexpected deletes or hostname conflicts.

Overview
Adds retained named volumes to hypeman compose: top-level volumes (size_gb, optional name), service mounts (shorthand vol:/path[:ro|rw] or mapping), create-before-instance apply order, compose ownership tags (no per-service tag on volumes), and name→ID resolution at instance create so hashes stay stable. Volumes survive down, --replace, and pruning; compose down --volumes is the only supported way to destroy their data. Declared volume changes after create surface as conflicts (not replace).

Tightens reconciliation: strict YAML (KnownFields, duplicate keys, mount validation), up/plan prune compose-owned instances and ingresses removed from the file (unmanaged resources untouched), with prune deletes ordered before creates so hostnames/names can be reused. Ambiguous ingress rename conflicts claim candidate IDs so prune does not delete them. Runner.Down takes DownOptions (including Volumes); CLI wires --volumes.

Docs and a large volumes_test.go suite cover persistence, failed replace recovery, and apply ordering.

Reviewed by Cursor Bugbot for commit ac4827a. Bugbot is set up for automated code reviews on this repo. Configure here.

Add top-level named volumes to hypeman compose, backed by the existing
Hypeman volume APIs, and make reconciliation strict and non-destructive
by default:

- Compose files declare top-level volumes with size_gb (optional
  explicit name) and attach them to services via shorthand
  (volume:/abs/path[:ro|rw]) or mapping mount declarations.
- Volumes are created before instances and tagged with compose
  ownership. Planned mounts carry the volume name so rendered hashes
  stay stable; names resolve to server IDs at apply time.
- Volumes are retained across instance replacement and compose down.
  Deleting retained data requires the explicit destructive option
  compose down --volumes, and volume spec changes conflict rather than
  silently replacing data.
- compose up/plan prune owned instances and ingresses that are no
  longer declared, without touching unmanaged resources.
- Compose parsing is strict: unknown fields and duplicate keys fail
  validation, including inside volume mount mappings. Invalid or
  ambiguous mount declarations (unknown volume, relative path,
  duplicate mount path, duplicate volume attachment) fail validation.

Existing stateless compose files are unaffected: no volumes means no
volume actions and identical plan/up/down behavior.

Tests cover shorthand parsing, strict parsing, validation, deterministic
rendering, volume plan semantics, and nonce persistence through
replacement, failed replacement, and down/up against an in-memory
Hypeman API seam.

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using high effort and found 2 potential issues.

Fix All in Cursor

Bugbot Autofix prepared fixes for both issues found in the latest run.

  • ✅ Fixed: Prune deletes after creates
    • Pruned delete actions are now explicitly prioritized during Up so owned resources removed from the compose file are deleted before create/replace actions run.
  • ✅ Fixed: Conflict skips prune claims
    • Ambiguous ingress-rename conflicts now record all candidate ingress IDs as claimed, preventing pruneActions from emitting contradictory deletes for those same owned ingresses.

Create PR

Or push these changes by commenting:

@cursor push 91d3109abb
Preview (91d3109abb)
diff --git a/lib/compose/compose.go b/lib/compose/compose.go
--- a/lib/compose/compose.go
+++ b/lib/compose/compose.go
@@ -64,12 +64,19 @@ type Action struct {
 	instanceID    string
 	ingressID     string
 	volumeID      string
+	prune         bool
+	claimedIDs    claimedResourceIDs
 	instanceInput hypeman.InstanceNewParams
 	ingressInput  hypeman.IngressNewParams
 	volumeInput   hypeman.VolumeNewParams
 	buildInput    *desiredBuild
 }
 
+type claimedResourceIDs struct {
+	instances []string
+	ingresses []string
+}
+
 func NewRunner(file string, client hypeman.Client, opts ...option.RequestOption) (*Runner, error) {
 	spec, err := loadComposeSpec(file)
 	if err != nil {

@@ -64,12 +64,19 @@ type Action struct {
 	instanceID    string
 	ingressID     string
 	volumeID      string
+	prune         bool
+	claimedIDs    claimedResourceIDs
 	instanceInput hypeman.InstanceNewParams
 	ingressInput  hypeman.IngressNewParams
 	volumeInput   hypeman.VolumeNewParams
 	buildInput    *desiredBuild
 }
 
+type claimedResourceIDs struct {
+	instances []string
+	ingresses []string
+}
+
 func NewRunner(file string, client hypeman.Client, opts ...option.RequestOption) (*Runner, error) {
 	spec, err := loadComposeSpec(file)
 	if err != nil {

diff --git a/lib/compose/compose_test.go b/lib/compose/compose_test.go
--- a/lib/compose/compose_test.go
+++ b/lib/compose/compose_test.go
@@ -476,3 +476,46 @@ func TestPlanIngressActionConflictsWhenRenameCandidateIsAmbiguous(t *testing.T)
 	assert.Equal(t, "multiple owned ingresses for service have changed names", action.Reason)
 	assert.Empty(t, action.ingressID)
 }
+
+func TestPruneActionsSkipsAmbiguousIngressRenameConflictCandidates(t *testing.T) {
+	desired := desiredIngress{
+		Name:    "app-api-public",
+		Service: "api",
+		Hash:    "public-hash",
+		Input: hypeman.IngressNewParams{
+			Name: "app-api-public",
+		},
+	}
+	owned := []hypeman.Ingress{
+		{
+			ID:   "old-http-id",
+			Name: "app-api-http",
+			Tags: composeTags("app", "api", composeResourceIngress, "http-hash"),
+		},
+		{
+			ID:   "old-grpc-id",
+			Name: "app-api-grpc",
+			Tags: composeTags("app", "api", composeResourceIngress, "grpc-hash"),
+		},
+	}
+
+	action := planIngressAction(desired, owned, nil, map[string]struct{}{
+		"app-api-public": {},
+	})
+	require.Equal(t, "conflict", action.Action)
+
+	pruned := pruneActions(nil, owned, []Action{action})
+	assert.Empty(t, pruned)
+}
+
+func TestUpActionOrderRunsPruneDeletesBeforeCreatesAndReplaces(t *testing.T) {
+	actions := []Action{
+		{Action: "create", Type: "ingress", Name: "app-api-public"},
+		{Action: "replace", Type: "instance", Name: "app-api"},
+		{Action: "delete", Type: "instance", Name: "app-cache", prune: true},
+		{Action: "delete", Type: "ingress", Name: "app-api-http", prune: true},
+		{Action: "delete", Type: "volume", Name: "app-data"},
+	}
+
+	assert.Equal(t, []int{2, 3, 0, 1, 4}, upActionOrder(actions))
+}

@@ -476,3 +476,46 @@ func TestPlanIngressActionConflictsWhenRenameCandidateIsAmbiguous(t *testing.T)
 	assert.Equal(t, "multiple owned ingresses for service have changed names", action.Reason)
 	assert.Empty(t, action.ingressID)
 }
+
+func TestPruneActionsSkipsAmbiguousIngressRenameConflictCandidates(t *testing.T) {
+	desired := desiredIngress{
+		Name:    "app-api-public",
+		Service: "api",
+		Hash:    "public-hash",
+		Input: hypeman.IngressNewParams{
+			Name: "app-api-public",
+		},
+	}
+	owned := []hypeman.Ingress{
+		{
+			ID:   "old-http-id",
+			Name: "app-api-http",
+			Tags: composeTags("app", "api", composeResourceIngress, "http-hash"),
+		},
+		{
+			ID:   "old-grpc-id",
+			Name: "app-api-grpc",
+			Tags: composeTags("app", "api", composeResourceIngress, "grpc-hash"),
+		},
+	}
+
+	action := planIngressAction(desired, owned, nil, map[string]struct{}{
+		"app-api-public": {},
+	})
+	require.Equal(t, "conflict", action.Action)
+
+	pruned := pruneActions(nil, owned, []Action{action})
+	assert.Empty(t, pruned)
+}
+
+func TestUpActionOrderRunsPruneDeletesBeforeCreatesAndReplaces(t *testing.T) {
+	actions := []Action{
+		{Action: "create", Type: "ingress", Name: "app-api-public"},
+		{Action: "replace", Type: "instance", Name: "app-api"},
+		{Action: "delete", Type: "instance", Name: "app-cache", prune: true},
+		{Action: "delete", Type: "ingress", Name: "app-api-http", prune: true},
+		{Action: "delete", Type: "volume", Name: "app-data"},
+	}
+
+	assert.Equal(t, []int{2, 3, 0, 1, 4}, upActionOrder(actions))
+}

diff --git a/lib/compose/reconcile.go b/lib/compose/reconcile.go
--- a/lib/compose/reconcile.go
+++ b/lib/compose/reconcile.go
@@ -116,7 +116,7 @@ func (r *Runner) Up(ctx context.Context, opts UpOptions) (Plan, error) {
 		return result, fmt.Errorf("replace required:\n%s\n\nRun again with --replace to recreate changed resources.", strings.Join(blockers, "\n"))
 	}
 
-	for i := range result.Actions {
+	for _, i := range upActionOrder(result.Actions) {
 		action := &result.Actions[i]
 		switch action.Action {
 		case "create":

@@ -116,7 +116,7 @@ func (r *Runner) Up(ctx context.Context, opts UpOptions) (Plan, error) {
 		return result, fmt.Errorf("replace required:\n%s\n\nRun again with --replace to recreate changed resources.", strings.Join(blockers, "\n"))
 	}
 
-	for i := range result.Actions {
+	for _, i := range upActionOrder(result.Actions) {
 		action := &result.Actions[i]
 		switch action.Action {
 		case "create":
@@ -565,9 +565,15 @@ func pruneActions(ownedInstances []hypeman.Instance, ownedIngresses []hypeman.In
 		if action.instanceID != "" {
 			claimedInstances[action.instanceID] = struct{}{}
 		}
+		for _, id := range action.claimedIDs.instances {
+			claimedInstances[id] = struct{}{}
+		}
 		if action.ingressID != "" {
 			claimedIngresses[action.ingressID] = struct{}{}
 		}
+		for _, id := range action.claimedIDs.ingresses {
+			claimedIngresses[id] = struct{}{}
+		}
 	}
 	var pruned []Action
 	for _, inst := range ownedInstances {

@@ -565,9 +565,15 @@ func pruneActions(ownedInstances []hypeman.Instance, ownedIngresses []hypeman.In
 		if action.instanceID != "" {
 			claimedInstances[action.instanceID] = struct{}{}
 		}
+		for _, id := range action.claimedIDs.instances {
+			claimedInstances[id] = struct{}{}
+		}
 		if action.ingressID != "" {
 			claimedIngresses[action.ingressID] = struct{}{}
 		}
+		for _, id := range action.claimedIDs.ingresses {
+			claimedIngresses[id] = struct{}{}
+		}
 	}
 	var pruned []Action
 	for _, inst := range ownedInstances {
@@ -580,6 +586,7 @@ func pruneActions(ownedInstances []hypeman.Instance, ownedIngresses []hypeman.In
 			Name:       inst.Name,
 			Service:    inst.Tags[composeTagService],
 			Reason:     "no longer declared in compose file",
+			prune:      true,
 			instanceID: inst.ID,
 		})
 	}

@@ -580,6 +586,7 @@ func pruneActions(ownedInstances []hypeman.Instance, ownedIngresses []hypeman.In
 			Name:       inst.Name,
 			Service:    inst.Tags[composeTagService],
 			Reason:     "no longer declared in compose file",
+			prune:      true,
 			instanceID: inst.ID,
 		})
 	}
@@ -593,6 +600,7 @@ func pruneActions(ownedInstances []hypeman.Instance, ownedIngresses []hypeman.In
 			Name:      ing.Name,
 			Service:   ing.Tags[composeTagService],
 			Reason:    "no longer declared in compose file",
+			prune:     true,
 			ingressID: ing.ID,
 		})
 	}

@@ -593,6 +600,7 @@ func pruneActions(ownedInstances []hypeman.Instance, ownedIngresses []hypeman.In
 			Name:      ing.Name,
 			Service:   ing.Tags[composeTagService],
 			Reason:    "no longer declared in compose file",
+			prune:     true,
 			ingressID: ing.ID,
 		})
 	}
@@ -645,6 +653,7 @@ func planIngressAction(desired desiredIngress, owned []hypeman.Ingress, all []hy
 	if len(renameCandidates) > 1 {
 		action.Action = "conflict"
 		action.Reason = "multiple owned ingresses for service have changed names"
+		action.claimedIDs.ingresses = ingressIDs(renameCandidates)
 		return action
 	}
 	for _, ing := range all {

@@ -645,6 +653,7 @@ func planIngressAction(desired desiredIngress, owned []hypeman.Ingress, all []hy
 	if len(renameCandidates) > 1 {
 		action.Action = "conflict"
 		action.Reason = "multiple owned ingresses for service have changed names"
+		action.claimedIDs.ingresses = ingressIDs(renameCandidates)
 		return action
 	}
 	for _, ing := range all {
@@ -671,6 +680,14 @@ func desiredIngressNamesByService(ingresses []desiredIngress) map[string]map[str
 	return names
 }
 
+func ingressIDs(ingresses []hypeman.Ingress) []string {
+	ids := make([]string, 0, len(ingresses))
+	for _, ingress := range ingresses {
+		ids = append(ids, ingress.ID)
+	}
+	return ids
+}
+
 func (r *Runner) listComposeInstances(ctx context.Context) ([]hypeman.Instance, error) {
 	instances, err := r.client.Instances.List(ctx, hypeman.InstanceListParams{
 		Tags: map[string]string{composeTagName: r.spec.Name},

@@ -671,6 +680,14 @@ func desiredIngressNamesByService(ingresses []desiredIngress) map[string]map[str
 	return names
 }
 
+func ingressIDs(ingresses []hypeman.Ingress) []string {
+	ids := make([]string, 0, len(ingresses))
+	for _, ingress := range ingresses {
+		ids = append(ids, ingress.ID)
+	}
+	return ids
+}
+
 func (r *Runner) listComposeInstances(ctx context.Context) ([]hypeman.Instance, error) {
 	instances, err := r.client.Instances.List(ctx, hypeman.InstanceListParams{
 		Tags: map[string]string{composeTagName: r.spec.Name},
@@ -751,6 +768,22 @@ func conflictBlockers(actions []Action) []string {
 	return blockers
 }
 
+func upActionOrder(actions []Action) []int {
+	order := make([]int, 0, len(actions))
+	for i := range actions {
+		if actions[i].Action == "delete" && actions[i].prune {
+			order = append(order, i)
+		}
+	}
+	for i := range actions {
+		if actions[i].Action == "delete" && actions[i].prune {
+			continue
+		}
+		order = append(order, i)
+	}
+	return order
+}
+
 func summarizeComposeActions(actions []Action) Summary {
 	var summary Summary
 	for _, action := range actions {

@@ -751,6 +768,22 @@ func conflictBlockers(actions []Action) []string {
 	return blockers
 }
 
+func upActionOrder(actions []Action) []int {
+	order := make([]int, 0, len(actions))
+	for i := range actions {
+		if actions[i].Action == "delete" && actions[i].prune {
+			order = append(order, i)
+		}
+	}
+	for i := range actions {
+		if actions[i].Action == "delete" && actions[i].prune {
+			continue
+		}
+		order = append(order, i)
+	}
+	return order
+}
+
 func summarizeComposeActions(actions []Action) Summary {
 	var summary Summary
 	for _, action := range actions {

You can send follow-ups to the cloud agent here.

Reviewed by Cursor Bugbot for commit 820fcc5. Configure here.

Comment thread lib/compose/reconcile.go
Comment thread lib/compose/reconcile.go

@rgarcia rgarcia left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Independent review — round 1

Strong implementation overall: retained-volume semantics (create-before-instance, name→ID resolution at apply time, immutability-as-conflict, retention across down/--replace), strict YAML parsing, and the fake-API test suite covering nonce persistence, replace-failure recovery, destructive down --volumes, and prune scoping all check out. CI is green and I reproduced go test ./lib/compose/... locally. Docs match behavior.

Two findings before I can sign off — both overlap with Cursor Bugbot's findings on this head, which I verified independently:

Important

  1. Prune deletes run after creates, which can wedge up on key moves (lib/compose/reconcile.go, Plan/Up). Prune actions are appended last, so when a uniquely-constrained key (most plausibly an ingress hostname) moves from a removed service to a new/renamed one, the create for the new ingress executes while the pruned owned ingress still holds the hostname. If the server enforces uniqueness, up errors, and re-running hits the same deterministic order — the user must manually delete or compose down to recover. Consider applying prune deletes before creates/replaces (or at least before ingress creates), mirroring how applyReplace already deletes before creating.

Minor

  1. Conflict paths without resource IDs cause contradictory plan output. planIngressAction's multiple-rename-candidates conflict doesn't set ingressID, so pruneActions also emits delete: no longer declared in compose file for the same owned ingresses. Conflicts block Up, so nothing is actually deleted, but compose plan shows both a conflict and deletes for resources in an ambiguous state. Suppressing prunes when the plan contains conflicts (or claiming rename candidates) would make plan output coherent.
  2. (nit) resolveInstanceVolumeIDs re-lists compose volumes per instance create; a single lookup shared across the apply pass would avoid N+1 list calls. Not blocking.
  3. (nit) planVolumeAction's "name exists without compose ownership" reason also fires for volumes owned by a different compose project (any non-matching hypeman.compose.name); conflicting is correct, the reason string is slightly misleading.

Verdict for this round: changes requested on finding 1 (and Bugbot's unresolved findings). Everything else is in good shape — once ordering is addressed this should be a quick re-approve.

Review round 1 fixes:

- Plan prune deletes before instance/ingress creates/replaces so a pruned
  owned resource frees its unique keys (names, ingress hostnames) before a
  new resource reuses them; previously a hostname moving from a removed
  service to a new one could deterministically wedge up until manual
  intervention. Mirrors applyReplace's delete-then-create.
- The ambiguous ingress rename conflict now claims its rename candidates,
  so plan no longer shows both a conflict and prune deletes for the same
  owned ingresses.
- resolveInstanceVolumeIDs shares one compose-volume list across the apply
  pass instead of re-listing per instance create.
- planVolumeAction reports when a conflicting volume is owned by a
  different compose project instead of claiming missing ownership.

Regression tests: hostname move across services no longer wedges up (fake
enforces hostname uniqueness), conflicted rename candidates are not
planned for deletion, and the volume lookup is listed once per apply pass.
@rgarcia

rgarcia commented Aug 3, 2026

Copy link
Copy Markdown
Contributor Author

Round 1 findings addressed in ac4827a:

  1. Prune deletes after creates (important)Plan now orders prune deletes before instance/ingress creates and replaces, so pruned owned resources free unique keys (names, ingress hostnames) before new resources reuse them, mirroring applyReplace's delete-then-create. Regression test: TestComposeUpPrunesBeforeCreatesSoMovedHostnameDoesNotWedge moves a hostname from a removed service to a new one against a fake that enforces hostname uniqueness (409) — verified it fails with the old ordering and passes now.
  2. Conflict skips prune claims — the ambiguous ingress rename conflict now records its rename candidates as claimed (claimedIngressIDs), so compose plan no longer shows both a conflict and prune deletes for the same owned ingresses. Covered by TestComposePlanConflictDoesNotPlanPruneForAmbiguousRenameCandidates and TestPruneActionsSkipsIngressesClaimedByConflict.
  3. Volume list N+1resolveInstanceVolumeIDs now lists compose volumes once per Up apply pass, shared across instance creates (volume actions always precede instance actions, so the first resolve sees all volumes created that pass). Covered by TestComposeUpSharesVolumeLookupAcrossInstanceCreates.
  4. Misleading conflict reasonplanVolumeAction now reports name is owned by a different compose project "<name>" when the conflicting volume carries another project's ownership tag.

go build ./..., go vet ./..., golangci-lint run ./lib/compose/..., and go test -count=1 ./... all pass.

@rgarcia rgarcia left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Independent review — round 2

Reviewed head ac4827a. All round-1 findings are addressed and I verified each independently:

  1. Prune ordering (important, round 1 #1) — fixed. Plan now emits prune deletes before instance/ingress creates/replaces, and Up applies actions in plan order, so plan output matches execution order (cleaner than reordering only at apply time). TestComposeUpPrunesBeforeCreatesSoMovedHostnameDoesNotWedge proves the moved-hostname case against a fake that enforces hostname uniqueness, including delete-before-create request ordering, plan ordering, and idempotent re-run.
  2. Conflict prune claims (round 1 #2) — fixed via claimedIngressIDs on ambiguous-rename conflicts; pruneActions honors them. Covered by unit + end-to-end tests, which also confirm Up refuses with conflicts and touches nothing.
  3. N+1 volume list (nit) — fixed with a per-apply-pass volumeIDsByName cache, reset at the start of each Up; volumes are still created before instance resolves. Test asserts request counts.
  4. Misleading conflict reason (nit) — a volume owned by a different compose project now reports name is owned by a different compose project "<name>".

Verified locally on ac4827a: go build ./..., go vet, and full go test ./... pass. CI (lint, semgrep) is green on the head, Cursor Bugbot's check on this head completed with no new findings, and both of its round-1 threads are resolved.

Review satisfied — no blocking or important findings. (Formal approval omitted since this is my own PR per GitHub self-approval rules; the workflow verdict is authoritative.)

@rgarcia
rgarcia requested a review from sjmiller609 August 3, 2026 21:41
@rgarcia
rgarcia merged commit 17b89fc into main Aug 3, 2026
7 checks passed
@rgarcia
rgarcia deleted the oss/compose-retained-volumes branch August 3, 2026 22:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants