park: local retirements under a move op keep the engine's layout; the journal names the op (§6.4, D6)
A park-local whose op is a move's id, under landing/committed and restoring, lands under .parked/<incarnation>/<host>/<retirement>/artifacts/ and its .journal.json names the move's op. A move leaves two local receipts, not one (K20): park.Local's journal and the moves-owned rename's moves-retirement.json. The host test drives a registered source's finalization, which writes both, and finds every one of the move's local retirements by op with the same rule the park test uses. Tests only; no interface changes (D6).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2 files changed,
+200
−2
internal/host/movesretire_byop_test.go+104 −0internal/park/local_linux_test.go+96 −2
diff --git a/internal/host/movesretire_byop_test.go b/internal/host/movesretire_byop_test.go| new file mode 100644 |
| index 000000000..56c03028c |
| --- /dev/null |
| +++ b/internal/host/movesretire_byop_test.go |
| @@ -0,0 +1,104 @@ |
| +package host |
| + |
| +import ( |
| + "encoding/json" |
| + "os" |
| + "path/filepath" |
| + "sort" |
| + "strings" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/hostclaim" |
| +) |
| + |
| +// retirementsByOp is how F3 finds a host's local retirements by op, one rule |
| +// for both receipts a retirement can carry (K20): park.Local's .journal.json |
| +// (Op) and the moves-owned rename's moves-retirement.json (op), each in the |
| +// event directory .parked/<incarnation>/<host>/<retirement>/. It returns the |
| +// retirement ids whose receipt names op, and those with no readable receipt. |
| +// internal/park's retirementsOf is the same rule. |
| +func retirementsByOp(t *testing.T, dataDir, incarnation, host, op string) (found, unreadable []string) { |
| + t.Helper() |
| + dir := filepath.Join(dataDir, ".parked", incarnation, host) |
| + es, err := os.ReadDir(dir) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, e := range es { |
| + var engine struct{ Op string } |
| + var owned struct { |
| + Op string `json:"op"` |
| + } |
| + named := "" |
| + if b, err := os.ReadFile(filepath.Join(dir, e.Name(), ".journal.json")); err == nil && json.Unmarshal(b, &engine) == nil { |
| + named = engine.Op |
| + } else if b, err := os.ReadFile(filepath.Join(dir, e.Name(), localParkReceiptName)); err == nil && json.Unmarshal(b, &owned) == nil { |
| + named = owned.Op |
| + } else { |
| + unreadable = append(unreadable, e.Name()) |
| + continue |
| + } |
| + if named == op { |
| + found = append(found, e.Name()) |
| + } |
| + } |
| + sort.Strings(found) |
| + return found, unreadable |
| +} |
| + |
| +// TestMoveLocalRetirementsAreFindableByOp (moves spec §6.4, D6, K20): a |
| +// move leaves two local receipts, not one. A registered source's |
| +// finalization retires its installation through park.Local (.journal.json) |
| +// and parks what the inventory does not list by the moves-owned rename |
| +// (moves-retirement.json), each in its own retirement under the engine's |
| +// layout. Both name the move's op, and nothing under the host's |
| +// incarnation lacks a receipt, so F3 finds every local retirement of the |
| +// move by op with one rule. |
| +func TestMoveLocalRetirementsAreFindableByOp(t *testing.T) { |
| + h := newFinalHarness(t) |
| + dd := h.cfg.DataDir |
| + for _, d := range []string{"hostguard", ".parked"} { |
| + if err := os.MkdirAll(filepath.Join(dd, d), 0o770); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + rec := `{"v":1,"host":"` + p1Host + `","incarnation":"` + h.inc + `","active_stamp":1,"retired_stamps":[]}` |
| + if err := os.WriteFile(filepath.Join(dd, "hostguard", p1Host+".json"), []byte(rec), 0o660); err != nil { |
| + t.Fatal(err) |
| + } |
| + rewriteP1Claim(t, h.s, func(c *hostclaim.Claim) { |
| + if c.Inventory == nil { |
| + c.Inventory = map[string]hostclaim.InventoryEntry{} |
| + } |
| + c.Inventory["ie-box"] = hostclaim.InventoryEntry{Layout: "flat", Paths: map[string]uint64{p1Host + ".db": 1, p1Host + ".db.gen": 1, p1Host: 1, p1Host + ".env": 1}} |
| + }) |
| + h.commit(t) |
| + var chore hostclaim.Obligation |
| + for _, o := range h.claim(t).Obligations { |
| + if o.Kind == "park-local" { |
| + chore = o |
| + } |
| + } |
| + if chore.Op != p1ID { |
| + t.Fatalf("the commit queued no park-local of the move: %+v", h.claim(t).Obligations) |
| + } |
| + h.sourcePass(t) |
| + owned := h.parkedJournal(t).Steps[finalStepLocal] |
| + if owned == "" || owned == chore.Retirement { |
| + t.Fatalf("the moves-owned retirement %q, park.Local's %q", owned, chore.Retirement) |
| + } |
| + for _, receipt := range []string{filepath.Join(chore.Retirement, ".journal.json"), filepath.Join(owned, localParkReceiptName)} { |
| + if _, err := os.Stat(filepath.Join(dd, ".parked", h.inc, p1Host, receipt)); err != nil { |
| + t.Fatalf("the receipt %s: %v", receipt, err) |
| + } |
| + } |
| + found, unreadable := retirementsByOp(t, dd, h.inc, p1Host, p1ID) |
| + want := []string{chore.Retirement, owned} |
| + sort.Strings(want) |
| + if strings.Join(found, " ") != strings.Join(want, " ") || len(unreadable) != 0 { |
| + t.Fatalf("by op %s: found %v (want %v), no receipt %v", p1ID, found, want, unreadable) |
| + } |
| + if other, _ := retirementsByOp(t, dd, h.inc, p1Host, "mv_zzzzzzzzzzzzzzzzzzzzzzzzzz"); len(other) != 0 { |
| + t.Fatalf("another op found the move's retirements: %v", other) |
| + } |
| +} |
diff --git a/internal/park/local_linux_test.go b/internal/park/local_linux_test.go| index aae79d7f9..106b2db64 100644 |
| --- a/internal/park/local_linux_test.go |
| +++ b/internal/park/local_linux_test.go |
| @@ -27,6 +27,13 @@ type localFixture struct { |
| } |
| func localFixtureFor(t *testing.T, paths map[string]uint64) localFixture { |
| + t.Helper() |
| + return localFixtureForOp(t, paths, hostclaim.Op{Kind: hostclaim.KindParking, ID: "delete", Exec: "box"}) |
| +} |
| + |
| +// localFixtureForOp is localFixtureFor under the claim op given; the |
| +// park-local chore is that op's. |
| +func localFixtureForOp(t *testing.T, paths map[string]uint64, op hostclaim.Op) localFixture { |
| t.Helper() |
| host := "shop.bab.oncarlos.com" |
| data, locks := t.TempDir(), t.TempDir() |
| @@ -43,9 +50,9 @@ func localFixtureFor(t *testing.T, paths map[string]uint64) localFixture { |
| } |
| c := &hostclaim.Claim{V: 2, Host: host, Gen: 5, |
| Holder: &hostclaim.Holder{Family: "instance", Key: "apps/bab/jam/instances/" + host + ".json", Account: "bab", App: "jam", CreatedAt: at, Incarnation: inc, Location: "instances/" + host + "/" + inc, Stamp: 5}, |
| - Op: hostclaim.Op{Kind: hostclaim.KindParking, ID: "delete", Exec: "box"}, |
| + Op: op, |
| Inventory: map[string]hostclaim.InventoryEntry{"box": {Layout: "flat", Paths: paths}}, |
| - Obligations: []hostclaim.Obligation{{ID: "local", Op: "delete", Kind: "park-local", Box: "box", Owner: "box:box", Retirement: "retirement"}}} |
| + Obligations: []hostclaim.Obligation{{ID: "local", Op: op.ID, Kind: "park-local", Box: "box", Owner: "box:box", Retirement: "retirement"}}} |
| if err := c.Validate(); err != nil { |
| t.Fatal(err) |
| } |
| @@ -671,3 +678,90 @@ func TestLocalUntouchedBoxAcknowledgement(t *testing.T) { |
| }) |
| } |
| } |
| + |
| +// retirementsOf is how F3 finds a host's local retirements by op, one rule |
| +// for both receipts a retirement can carry (K20): park.Local's .journal.json |
| +// (Op) and the moves-owned rename's moves-retirement.json (op), each in the |
| +// event directory .parked/<incarnation>/<host>/<retirement>/. It returns |
| +// the retirement ids whose receipt names op. |
| +func retirementsOf(t *testing.T, dataDir, incarnation, host, op string) []string { |
| + t.Helper() |
| + dir := filepath.Join(dataDir, ".parked", incarnation, host) |
| + es, err := os.ReadDir(dir) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + var out []string |
| + for _, e := range es { |
| + var engine struct{ Op string } |
| + var moves struct { |
| + Op string `json:"op"` |
| + } |
| + named := "" |
| + if b, err := os.ReadFile(filepath.Join(dir, e.Name(), ".journal.json")); err == nil && json.Unmarshal(b, &engine) == nil { |
| + named = engine.Op |
| + } else if b, err := os.ReadFile(filepath.Join(dir, e.Name(), "moves-retirement.json")); err == nil && json.Unmarshal(b, &moves) == nil { |
| + named = moves.Op |
| + } |
| + if named == op { |
| + out = append(out, e.Name()) |
| + } |
| + } |
| + return out |
| +} |
| + |
| +// TestLocalRetirementUnderAMoveOpIsFindableByOp (moves spec §6.4, D6, K20): |
| +// a park-local whose op is a move's id, under each move kind that queues |
| +// one, lands under the engine's own layout, |
| +// .parked/<incarnation>/<host>/<retirement>/artifacts/, and its journal names |
| +// the move's op, so F3 selects a move's local retirements by op as it does |
| +// the remote ones by manifest. The moves-owned rename's receipt is the |
| +// other half; internal/host's TestMoveLocalRetirementsAreFindableByOp |
| +// covers it with the same rule. |
| +func TestLocalRetirementUnderAMoveOpIsFindableByOp(t *testing.T) { |
| + const move = "mv_abcdefghijklmnopqrstuvwxyz" |
| + for _, op := range []hostclaim.Op{ |
| + {Kind: hostclaim.KindLanding, ID: move, Phase: hostclaim.PhaseCommitted, Exec: "box"}, |
| + {Kind: hostclaim.KindRestoring, ID: move, Phase: hostclaim.PhaseClaimed, Exec: "box"}, |
| + } { |
| + for _, mode := range []string{"artifacts", "installation"} { |
| + t.Run(op.Kind+"/"+mode, func(t *testing.T) { |
| + f := localFixtureForOp(t, map[string]uint64{"old.db": 3, "nested/state": 3}, op) |
| + for _, p := range []string{"old.db", "nested/state"} { |
| + f.write(t, p, p) |
| + } |
| + var err error |
| + if mode == "artifacts" { |
| + err = f.l.RetireArtifacts(t.Context(), f.host, f.captured, "local", move, 3) |
| + } else { |
| + err = f.l.RetireInstallation(t.Context(), f.host, f.captured, "local", move) |
| + } |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, p := range []string{"old.db", "nested/state"} { |
| + if b, err := os.ReadFile(f.parked(p)); err != nil || string(b) != p { |
| + t.Fatalf("not under the engine's layout: %s: %q %v", p, b, err) |
| + } |
| + } |
| + b, err := os.ReadFile(filepath.Join(f.l.DataDir, ".parked", f.captured.Incarnation, f.host, "retirement", ".journal.json")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + var j localJournal |
| + if err := json.Unmarshal(b, &j); err != nil { |
| + t.Fatal(err) |
| + } |
| + if j.Op != move || j.Host != f.host || j.Incarnation != f.captured.Incarnation || j.Retirement != "retirement" || j.Obligation != "local" || j.Mode != mode { |
| + t.Fatalf("the journal does not name the move: %+v", j) |
| + } |
| + if got := retirementsOf(t, f.l.DataDir, f.captured.Incarnation, f.host, move); len(got) != 1 || got[0] != "retirement" { |
| + t.Fatalf("retirements of %s: %v", move, got) |
| + } |
| + if got := retirementsOf(t, f.l.DataDir, f.captured.Incarnation, f.host, "delete"); len(got) != 0 { |
| + t.Fatalf("another op found the move's retirement: %v", got) |
| + } |
| + }) |
| + } |
| + } |
| +} |