plan: claim Plan 4 rev 1, the park engine
internal/park: exact-version remote parking with create-only manifests, the horizon and quiet_after acknowledgement, the at-rest sweep decision and executor, and journaled local retirement under the host guard.
Co-Authored-By: Codex <noreply@openai.com> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 file changed,
+2896
−0
docs/superpowers/plans/2026-10-05-host-lifecycle-claim-4-park-engine.md+2896 −0
CI cancelled — superseded by a newer push — run details
diff --git a/docs/superpowers/plans/2026-10-05-host-lifecycle-claim-4-park-engine.md b/docs/superpowers/plans/2026-10-05-host-lifecycle-claim-4-park-engine.md| new file mode 100644 |
| index 000000000..38c72a4ff |
| --- /dev/null |
| +++ b/docs/superpowers/plans/2026-10-05-host-lifecycle-claim-4-park-engine.md |
| @@ -0,0 +1,2896 @@ |
| +# Host Lifecycle Claim, Plan 4: the park engine |
| + |
| +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. |
| + |
| +**Goal:** add `internal/park`, a library that archives exact remote versions, waits for the late-completion horizon, acknowledges durable chores, decides and executes at-rest sweeps, and retires local artifacts under the host guard. No production path calls it. |
| + |
| +**Architecture:** remote work uses `lease.VersionedStore`, create-only manifests and `hostclaim.CompleteObligation`. The sweep consumes moves-owned marker and live-object information; it does not parse markers, choose restore generations or seal staging. Local work uses a descriptor-relative rename journal and the same flock as `hostguard.With`. Two small additive interfaces close gaps in the dependencies: persisted remote chore scope, including holderless sweep chores, and guard callbacks that retain the lock through parking after the installation stamp becomes inactive. |
| + |
| +**Tech Stack:** Go 1.26; existing `internal/hostclaim`, `internal/hostguard`, `internal/lease`, `internal/lease/leasetest`, `internal/release`; existing `golang.org/x/sys/unix`. No new dependency. |
| + |
| +**Spec:** `docs/superpowers/specs/2026-10-02-host-lifecycle-claim-design.md`, **rev 6.14**: §2, C3, C3a, C5, C6, §5.2/§5.3, §6.2, §6.2a, §6.3, §6.4 and §13. Plan 1's roadmap assigns exactly these parking mechanisms to Plan 4. |
| + |
| +**Depends on:** Plans **1, 2 and 3**. Use the real `hostclaim` package from `feat/hostclaim-library`, Plan 2's implementation on `origin/main`, and Plan 3's committed `feat/hostclaim-boxguard`. Plan 3's implementation was still being built when this plan was authored; the code below uses its published and present `Guard`, `Captured`, `With`, `Activate`, `RetireInstallation`, `locked`, `fresh`, `recordDir` and `record.retire` names. |
| + |
| +## Global Constraints |
| + |
| +- **Library only (R1).** No console, reconcile, activator, adoption, timer or background-pass caller. No flag is needed because nothing calls this package. Plans 5–7 remain inert behind an unset `CARLOS_HOST_CLAIM`; Plan 8 owns activation and the protocol floor. `internal/park` is distinct from `internal/lease`: it coordinates lifecycle authority and claim acknowledgements, whereas lease supplies storage primitives. |
| +- **Archive destination:** `parked/<incarnation>/<host>/<retirement-id>/{instances,moves}/<relative key>@<source versionId>`. Encode the version-id suffix with `url.QueryEscape` so `/`, `@`, `+`, spaces and `%` cannot create collisions. This is an injective spelling of the source version in the destination key. The manifest is `parked/<incarnation>/<host>/<retirement-id>/manifest.json`. Moves owns `superseded/<op>/<host>/<retirement-id>/...`; this engine does not implement that separate protocol. |
| +- **Two exact sets (§6.2).** Archive every non-marker version, current and noncurrent. Delete every version in the snapshot, including delete markers. Exclude each location's `lock.json` from snapshot, horizon listing, completeness and emptiness checks. Never call a bare-key delete or overwrite an archive destination. |
| +- **Manifest durability precedes destruction.** Copy with `createOnly=true`; recover an existing destination's exact version and size after `ErrDestinationExists`. Write the manifest using `PutIfAbsent` only after all copies succeed. `ErrExists` reads and validates the existing manifest; it never rewrites it. A retry with a manifest proceeds to its recorded deletes without trying to copy removed source versions. Every request error keeps the chore open. |
| +- **Horizon values verbatim:** `H = S3's signature-age limit (15 minutes) plus the longest request the platform issues`. A verification listing must be **taken later than `evidence time + H`**, not at equality. `SignatureAge` is exactly `15 * time.Minute`; `ParkScope.RequestLimit` is the caller's positive, enforced deployment-wide upper bound on those requests, with no invented default. `ParkScope.EvidenceAt` is genuine stop/fence evidence time, never a retry's current time. Capture the injected clock **before** starting verification. Residue after the horizon rotates the same obligation to a fresh retirement id, preserving its original evidence and scope; the next run uses its own manifest. Return `ErrPending` instead of sleeping. |
| +- **One acknowledgement CAS.** Max `quiet_after[region]` with `evidence time + H` and call `CompleteObligation(obligation.Op, obligation.ID)` in the same `hostclaim.Update`. Preserve a later existing value. The chore's own op id can differ from the current operation after release to serving. A CAS conflict requires a fresh retry. `FreeAfterPark` remains the console's step after all ordinary delete chores finish; it preserves `quiet_after`. |
| +- **C3a remains necessary.** Fifteen minutes bounds signature acceptance, not arbitrary request completion. Incarnation-isolated locations make a later completion safe; at-rest sweeps own its residue. This implementation does not claim that H proves a location can never receive another object. |
| +- **Complete discovery.** List all pages under both `instances/<host>/` and `moves/<host>/`. The existing `VersionedStore` has no delimiter API; using its full recursive version listing and deriving the first child gives the same location partition, also retaining marker-only locations. Exactly 64 hex characters followed by `/` form an incarnation location. Every other immediate child belongs to the legacy location. Never discover locations through the capped 32-entry `retired_incarnations` history. |
| +- **The live set is regional.** Use the moves-owned authoritative region, holder location and generation under the same claim ETag. In that bucket, retain the installed generation, every unfinished cold-admission predecessor back to the marker, every pinned noncurrent version and every version of the marker key. For simplicity retain an entire generation containing a pinned version. Other regions' copies of the same holder location are residue. An unrelated marker disagreement returns an alert and suppresses that live location. A matching unfinished run raises no alert. An operation other than serving/free suppresses new sweeps. A stopped `parking` operation has no executing live installation, but only the mandatory authorization callback can establish the stop/fence evidence that permits its work. |
| +- **Authority and leases are explicit dependencies.** `Authorize` must validate the current operation/phase, chore owner, original installation/scope and durable stop evidence or lost-box fence from Plan 3/8; for generation work it also validates moves' sealing/publication conditions. A no-op callback appears only in tests. `View` must perform complete marker/selection reads for the supplied observation and return that exact ETag. `Leases.Acquire(bucket, location)` is an **aggregate lease acquisition**: hold and renew the replica leases for every readable `instances`/`moves` location plus any moves-owned staging-work lease required for that scope. It is not permission to hold only the instance lock while mutating staging. `Check` refuses cancellation, expiry and lost ownership. `Close` stops renewal; only successful acknowledgement calls conditional `Release`, which removes only this worker's held lock version(s). Plans 5/6 supply these adapters; no production adapter is invented here. Each engine is bound to its own region's bucket; no sibling-bucket mutation (N13). |
| +- **Local identity and order.** Persist the original `hostguard.Captured{Incarnation, Stamp}` when selecting retirement work and reuse it on every retry. `RetireArtifacts(..., S)` parks only inventory entries tagged S, while the caller's captured stamp is the installation active during this operation; it changes no stamp. `RetireInstallation` persists the inactive stamp before the first artifact move, then parks all artifacts under the same flock. An inactive retry must still match the last retired stamp. Activation is still `hostguard.Activate`; never use installation retirement to clean a predecessor. |
| +- **Inventory and journal.** Inventory paths are relative to DataDir and are disjoint roots; a directory entry owns its entire subtree at the recorded stamp. Flat layouts enroll the host directory, DB/WAL/SHM, `<host>.env`, `<host>.config.env`, `<host>-sidecar.env`, generation/lineage markers and every other host-bound operational path. Nested layouts enroll the resolved instance directory. Use `RecordInventory` before materialization, including on layout changes; retire old paths before changing a populated layout. Missing inventory refuses; an explicitly empty entry can acknowledge. A never-installed box uses `AcknowledgeEmpty`, which additionally proves the local record is absent under the guard. |
| +- **Local destination and R9:** `<DataDir>/.parked/<incarnation>/<host>/<retirement-id>/artifacts/<inventory-relative-path>`, with `.journal.json` beside `artifacts/`. `.parked`, DataDir and Plan 3's guard directories must be provisioned with trusted ownership; `.parked` uses setgid group `carlos`, mode **2770**, like the guard directories. Plan 6 owns that provisioning. This library uses `Mkdirat` only for descendants of the pinned `.parked` descriptor; it does not change pathname ownership/mode. Every traversal uses directory descriptors and `O_NOFOLLOW`, every artifact inspection uses `AT_SYMLINK_NOFOLLOW`, and artifact moves use **`RENAME_NOREPLACE`**. Journal temporaries use exclusive, no-follow opens and fd-based mode/fsync; journal replacement is atomic. Fsync source/destination and journal directories before completion. `EXDEV`, conflicting destinations, changed source inodes, symlinks and any other error keep the chore open. Artifacts are never deleted. |
| +- **Portable build, fail-closed local operation.** Linux implements `Renameat2`; Darwin and Windows compile a local-retirement refusal stub that runs before stamp mutation. Remote parking remains portable. Do not create Go caches under `/tmp`; use the default shared cache. If the sandbox refuses writes there, use the already-existing shared on-disk `$TMPDIR/gocache`, never a per-task cache. |
| + |
| +## Review Focus |
| + |
| +1. A page failure must produce no archive, manifest, delete, inventory acknowledgement or quiet-after update. |
| +2. Crash after each copy, manifest, delete and verification, and before completion; an existing manifest must never require a vanished source version. |
| +3. An exact-horizon verification refuses; a late PUT causes a new retirement event, and listing that began too early cannot be rescued by the clock moving during it. |
| +4. A stopped authoritative replica can be parked; a serving authoritative replica, unfinished chain, pinned noncurrent version or marker cannot. |
| +5. A free host with residue can hold a durable sweep chore without inventing a holder; its start and completion preserve ordinary holderless validation and quiet-after history. |
| +6. Installation retirement holds one flock through inactive-record persistence, journaled renames and completion; stamp-only `RetireInstallation` followed by `With` would be an invalid composition. |
| +7. A crash after rename recognizes the recorded destination inode, never a same-path successor; source/destination/ancestor symlinks refuse. |
| + |
| +## File Structure |
| + |
| +| File | Responsibility | |
| +|---|---| |
| +| `internal/hostclaim/claim.go` | optional `ParkScope`; narrow holderless parking-sweep validation | |
| +| `internal/hostclaim/park_test.go` | additive scope and holderless refusal tests | |
| +| `internal/park/remote.go` | scope binding, exact manifests, remote engine, horizon and completion | |
| +| `internal/park/sweep.go` | moves-owned input contract, pure decision, namespace discovery and executor | |
| +| `internal/park/remote_test.go`, `internal/park/sweep_test.go` | fault injection, horizon, live-set, marker and sweep tests | |
| +| `internal/hostguard/retirement.go` | guarded installation-retirement callback and absent-installation callback | |
| +| `internal/park/local.go` | inventory registration, local retirement modes and acknowledgement | |
| +| `internal/park/local_linux.go` | descriptor-relative journal and no-replace renames | |
| +| `internal/park/local_other.go` | unsupported-platform refusal | |
| +| `internal/park/local_linux_test.go` | stamp, journal, crash, symlink, authorization and empty-box tests | |
| + |
| +--- |
| + |
| +### Task 1: Persist remote chore scope without breaking existing claims |
| + |
| +**Files:** modify `internal/hostclaim/claim.go`; create `internal/hostclaim/park_test.go`. |
| + |
| +**Interfaces:** adds optional `Obligation.Park *ParkScope` with location, archive incarnation, stop-evidence time and request-duration bound. Existing Plan 1 chores with no field still parse and are bound before execution. `ToParking`, `WriteSet`, `CompleteObligation`, `FreeAfterPark`, `Read` and `Update` retain their signatures. A holderless sweep uses `parking` plus `intent.park_sweep = "v1"`, with only scope-bound remote chores owned by that sweep's op; no executing box and no synthetic holder. |
| + |
| +- [ ] **Step 1: Create the implementation branch only after Plan 3 is committed** |
| + |
| +Run these commands in a clean implementation checkout, not the planning worktree: |
| + |
| +```bash |
| +git fetch origin |
| +git cat-file -e feat/hostclaim-boxguard:internal/hostguard/guard.go |
| +git cat-file -e feat/hostclaim-boxguard:internal/hostguard/record_unix.go |
| +git switch -c feat/hostclaim-park feat/hostclaim-boxguard |
| +git merge origin/main |
| +git merge-base --is-ancestor feat/hostclaim-boxguard HEAD |
| +git merge-base --is-ancestor origin/main HEAD |
| +``` |
| + |
| +Expected: both prerequisite files exist in the branch commit and both ancestry checks exit zero. An uncommitted file in the sibling boxguard worktree is not enough; wait for that branch's commit. Resolve integration conflicts against the actual interfaces, never copy an uncommitted dependency into this branch. |
| + |
| +- [ ] **Step 2: Write the failing scope tests** |
| + |
| +Write `internal/hostclaim/park_test.go` exactly as follows: |
| + |
| +```go |
| +package hostclaim |
| + |
| +import ( |
| + "testing" |
| + "time" |
| +) |
| + |
| +func TestParkScopeAndHolderlessSweep(t *testing.T) { |
| + c := &Claim{V: 2, Host: "shop.bab.example.com", Gen: 1, Op: Op{Kind: KindParking, ID: "sweep", Intent: map[string]string{"park_sweep": "v1"}}} |
| + c.Obligations = []Obligation{{ID: "ob", Op: "sweep", Kind: "park-replica", Region: "ie", Retirement: "rt", Owner: "region:ie", Park: &ParkScope{Location: "instances/" + c.Host + "/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", Incarnation: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", EvidenceAt: time.Now(), RequestLimit: time.Second}}} |
| + if err := c.Validate(); err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, change := range []func(*Claim){ |
| + func(c *Claim) { c.Obligations[0].Park = nil }, |
| + func(c *Claim) { c.Op.Exec = "box" }, |
| + func(c *Claim) { c.Obligations[0].Op = "another" }, |
| + func(c *Claim) { c.Op.Intent = nil }, |
| + func(c *Claim) { c.Obligations[0].Park.RequestLimit = 0 }, |
| + func(c *Claim) { c.Obligations[0].Park.EvidenceAt = time.Time{} }, |
| + func(c *Claim) { c.Obligations[0].Park.Location = "instances/other.example" }, |
| + } { |
| + copy := *c |
| + copy.Obligations = append([]Obligation(nil), c.Obligations...) |
| + scope := *c.Obligations[0].Park |
| + copy.Obligations[0].Park = &scope |
| + change(©) |
| + if err := copy.Validate(); err == nil { |
| + t.Fatal("invalid sweep admitted") |
| + } |
| + } |
| +} |
| +``` |
| + |
| +- [ ] **Step 3: Observe the missing types** |
| + |
| +```bash |
| +go test ./internal/hostclaim -run '^TestParkScopeAndHolderlessSweep$' -count=1 |
| +``` |
| + |
| +Expected before the edit: compile failure naming `ParkScope` and the missing `Obligation.Park` field. |
| + |
| +- [ ] **Step 4: Apply this exact additive claim edit** |
| + |
| +```bash |
| +python3 - <<'PY' |
| +from pathlib import Path |
| +p=Path('internal/hostclaim/claim.go') |
| +s=p.read_text() |
| +def replace(text, old, new): |
| + assert text.count(old)==1, (old,text.count(old)) |
| + return text.replace(old,new) |
| +s=replace(s,'type Obligation struct {','''type ParkScope struct { |
| + Location string `json:"location"` |
| + Incarnation string `json:"incarnation"` |
| + EvidenceAt time.Time `json:"evidence_at"` |
| + RequestLimit time.Duration `json:"request_limit_ns"` |
| +} |
| + |
| +type Obligation struct { |
| + Park *ParkScope `json:"park,omitempty"`''') |
| +s=replace(s,'if c.Op.Kind != "" && c.Op.Kind != KindPurging {','if c.Op.Kind != "" && c.Op.Kind != KindPurging && !holderlessSweep(c) {') |
| +s=replace(s,'if len(c.Obligations) > 0 {\n\t\t\treturn fmt.Errorf("%w: a free claim with outstanding obligations", ErrMalformed)','if len(c.Obligations) > 0 && !holderlessSweep(c) {\n\t\t\treturn fmt.Errorf("%w: a free claim with outstanding obligations", ErrMalformed)') |
| +s=replace(s,'\t\tseen[o.ID] = true',''' if o.Park != nil { |
| + p := o.Park |
| + if (o.Kind != "park-replica" && o.Kind != "park-generation") || !isHex64(p.Incarnation) || p.EvidenceAt.IsZero() || p.RequestLimit <= 0 || p.RequestLimit > time.Duration(1<<63-1)-15*time.Minute { |
| + return fmt.Errorf("%w: park scope", ErrMalformed) |
| + } |
| + base := "instances/"+c.Host |
| + if p.Location != base && p.Location != base+"/"+p.Incarnation { |
| + return fmt.Errorf("%w: park location", ErrMalformed) |
| + } |
| + } |
| + seen[o.ID] = true''') |
| +s+=''' |
| +func holderlessSweep(c *Claim) bool { |
| + if c.Holder != nil || c.Op.Kind != KindParking || c.Op.ID == "" || c.Op.Intent["park_sweep"] != "v1" || c.Op.Exec != "" || len(c.Obligations)==0 { return false } |
| + for _,o := range c.Obligations { |
| + if o.Op != c.Op.ID || o.Park == nil || (o.Kind != "park-replica" && o.Kind != "park-generation") {return false} |
| + } |
| + return true |
| +} |
| +''' |
| +p.write_text(s) |
| +PY |
| +``` |
| + |
| +The optional field preserves all pre-change claim shapes. The only new holderless state has remote chores with complete persisted scope. It blocks `Free()` and therefore blocks a new create while the sweep acts. The sweep's last acknowledgement calls `FreeAfterPark` in that same CAS. Ordinary holderless claims still cannot carry obligations. |
| + |
| +- [ ] **Step 5: Run the claim regression suite** |
| + |
| +```bash |
| +gofmt -w internal/hostclaim/claim.go internal/hostclaim/park_test.go |
| +go test ./internal/hostclaim -count=1 |
| +go vet ./internal/hostclaim |
| +``` |
| + |
| +Expected: all old create/delete/adoption/obligation/generation tests and the new validation test pass. |
| + |
| +--- |
| + |
| +### Task 2: Build the remote engine and at-rest decision/executor |
| + |
| +**Files:** create `internal/park/remote_test.go`, `internal/park/sweep_test.go`, `internal/park/remote.go`, `internal/park/sweep.go`. |
| + |
| +**Interfaces:** consumes real `lease.VersionedStore`, `ObjectVersion`, `ErrExists`, `ErrDestinationExists`, `ErrIsDeleteMarker` through marker exclusions, and `leasetest.VersionedBucket` with `FailOn`, `PageSize`, `FailListAfterPages`; produces `Engine.Bind`, `Engine.Run`, `Engine.Discover`, `Engine.Sweep`, `Decide`, `View`, `Decision`, `Alert`, `Work`, `Manifest` and lease contracts. The manifest retry and version semantics depend on Plan 2's shared fake/`NewS3Server` contract; this task adds no second storage model. |
| + |
| +`Engine.Bind` fills the optional scope of an existing Plan 1 chore from its holder only after authorization. `Sweep` queues already-bound chores for discovered locations. On free legacy locations the caller supplies an evidenced legacy archive incarnation label; the label affects destination naming only and cannot limit discovery. If the caller cannot establish it, refuse legacy cleanup instead of guessing from the capped retired list. A known scope survives retries and does not depend on whichever holder happens to exist later. |
| + |
| +`View.GenerationOf` is a pure moves-owned classifier over any source reference, including versions already deleted on an earlier attempt. Return `@root` or the empty string for the unsuffixed root generation; both normalize to `hostclaim.RootGeneration`. `MarkerKnown` distinguishes a successful absent-marker result from a read failure. Moves/Plan 7b supplies the marker identity, the current exact live versions, their generation membership and authoritative region; the park engine only applies protection rules. |
| + |
| +- [ ] **Step 1: Write both failing test files** |
| + |
| +Write `internal/park/remote_test.go` exactly as follows: |
| + |
| +```go |
| +package park |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "fmt" |
| + "strings" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/hostclaim" |
| + "github.com/carlosframework/platform/internal/lease" |
| + "github.com/carlosframework/platform/internal/lease/leasetest" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +type testLease struct { |
| + released int |
| + failed bool |
| +} |
| + |
| +func (l *testLease) Check(context.Context) error { |
| + if l.failed { |
| + return ErrChanged |
| + } |
| + return nil |
| +} |
| +func (l *testLease) Release(context.Context) error { l.released++; return nil } |
| +func (*testLease) Close() {} |
| + |
| +type testLeases struct{ held *testLease } |
| + |
| +func (l testLeases) Acquire(context.Context, string, string) (HeldLease, error) { return l.held, nil } |
| + |
| +type fixture struct { |
| + e Engine |
| + b *leasetest.VersionedBucket |
| + w Work |
| + now time.Time |
| + ob hostclaim.Obligation |
| + held *testLease |
| +} |
| + |
| +func newFixture(t *testing.T) *fixture { |
| + t.Helper() |
| + ctx := t.Context() |
| + now := time.Date(2026, 10, 5, 12, 0, 0, 0, time.UTC) |
| + host := "shop.bab.example.com" |
| + inc, err := hostclaim.Incarnation("bab", "jam", host, now) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + b := leasetest.NewVersionedBucket() |
| + b.AddBucket("data") |
| + b.PageSize = 1 |
| + f := &fixture{b: b, w: Work{host, "ob", "op"}, now: now.Add(16 * time.Minute), held: &testLease{}} |
| + f.ob = hostclaim.Obligation{ID: "ob", Op: "op", Kind: "park-replica", Region: "ie", Retirement: "rt1", Owner: "region:ie", Park: &hostclaim.ParkScope{Location: "instances/" + host + "/" + inc, Incarnation: inc, EvidenceAt: now, RequestLimit: time.Second}} |
| + st := release.NewDirStore(t.TempDir()) |
| + _, err = hostclaim.Update(ctx, st, hostclaim.Observed{Host: host, Absent: true}, func(c *hostclaim.Claim) error { |
| + c.Holder = &hostclaim.Holder{Family: "instance", Key: "apps/bab/jam/instances/" + host + ".json", Account: "bab", App: "jam", Incarnation: inc, CreatedAt: now, Location: f.ob.Park.Location, Stamp: 1} |
| + c.Op = hostclaim.Op{Kind: hostclaim.KindParking, ID: "op"} |
| + c.Obligations = []hostclaim.Obligation{f.ob} |
| + c.Touched = hostclaim.Touched{Complete: true, Regions: []string{"ie"}} |
| + return nil |
| + }) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + serial := 1 |
| + f.e = Engine{Store: b, Claims: st, Bucket: "data", Region: "ie", Leases: testLeases{f.held}, Now: func() time.Time { return f.now }, NewID: func() string { serial++; return fmt.Sprintf("rt%d", serial) }, Authorize: func(context.Context, hostclaim.Observed, hostclaim.Obligation) error { return nil }, View: func(_ context.Context, o hostclaim.Observed) (View, error) { |
| + return View{ClaimETag: o.ETag, Region: "ie", AuthoritativeRegion: "uk"}, nil |
| + }} |
| + return f |
| +} |
| +func (f *fixture) seed() []lease.ObjectVersion { |
| + p := f.ob.Park.Location + "/" |
| + f.b.Put("data", p+"0000/a", []byte("old")) |
| + f.b.Put("data", p+"0000/a", []byte("new")) |
| + f.b.BareDelete("data", p+"0000/a") |
| + f.b.Put("data", p+"lock.json", []byte("lease")) |
| + vs, _ := f.e.list(context.Background(), f.w.Host, f.ob.Park.Location) |
| + return vs |
| +} |
| +func (f *fixture) done(t *testing.T) { |
| + t.Helper() |
| + obs, err := hostclaim.Read(t.Context(), f.e.Claims, f.w.Host) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if len(obs.Claim.Obligations) != 0 { |
| + t.Fatal("obligation still open") |
| + } |
| + if got := obs.Claim.QuietAfter["ie"]; !got.Equal(f.ob.Park.EvidenceAt.Add(SignatureAge + time.Second)) { |
| + t.Fatal(got) |
| + } |
| + vs, err := f.e.list(t.Context(), f.w.Host, f.ob.Park.Location) |
| + if err != nil || len(vs) != 0 { |
| + t.Fatalf("residue: %v %v", vs, err) |
| + } |
| +} |
| +func TestRemoteCrashResume(t *testing.T) { |
| + for _, tc := range []struct { |
| + step string |
| + n int |
| + }{{"copy", 1}, {"copy", 2}, {"manifest", 1}, {"delete", 1}, {"delete", 2}, {"delete", 3}, {"verify", 1}, {"before-completion", 1}, {"completion", 1}} { |
| + t.Run(fmt.Sprintf("%s-%d", tc.step, tc.n), func(t *testing.T) { |
| + f := newFixture(t) |
| + source := f.seed() |
| + stop := errors.New("crash") |
| + n := 0 |
| + f.e.After = func(s string) error { |
| + if s == tc.step { |
| + n++ |
| + if n == tc.n { |
| + return stop |
| + } |
| + } |
| + return nil |
| + } |
| + if err := f.e.Run(t.Context(), f.w); !errors.Is(err, stop) { |
| + t.Fatalf("crash: %v", err) |
| + } |
| + if tc.step != "completion" && f.held.released != 0 { |
| + t.Fatal("released before ack") |
| + } |
| + f.e.After = nil |
| + if err := f.e.Run(t.Context(), f.w); err != nil { |
| + t.Fatal(err) |
| + } |
| + f.done(t) |
| + m, err := f.e.manifest(t.Context(), f.w, f.ob) |
| + if err != nil || m == nil || len(m.Delete) != len(source) || len(m.Archive) != 2 { |
| + t.Fatalf("manifest: %#v %v", m, err) |
| + } |
| + for _, a := range m.Archive { |
| + body, err := f.b.GetVersion(t.Context(), "data", a.Destination.Key, a.Destination.VersionID) |
| + if err != nil || int64(len(body)) != a.Source.Size { |
| + t.Fatalf("copy: %q %v", body, err) |
| + } |
| + } |
| + if f.b.LatestID("data", f.ob.Park.Location+"/lock.json") == "" { |
| + t.Fatal("lock deleted") |
| + } |
| + }) |
| + } |
| +} |
| +func TestStorageFailuresKeepObligation(t *testing.T) { |
| + for _, op := range []string{"list", "get", "copy", "put", "delete"} { |
| + t.Run(op, func(t *testing.T) { |
| + f := newFixture(t) |
| + f.seed() |
| + if op == "get" { |
| + f.e.After = func(s string) error { |
| + if s == "manifest" { |
| + return ErrPending |
| + } |
| + return nil |
| + } |
| + _ = f.e.Run(t.Context(), f.w) |
| + f.e.After = nil |
| + } |
| + failure := errors.New("injected") |
| + f.b.FailOn = func(got, key string) error { |
| + if got == op { |
| + return failure |
| + } |
| + return nil |
| + } |
| + if err := f.e.Run(t.Context(), f.w); err == nil { |
| + t.Fatal("failure acknowledged") |
| + } |
| + obs, err := hostclaim.Read(t.Context(), f.e.Claims, f.w.Host) |
| + if err != nil || len(obs.Claim.Obligations) != 1 || len(obs.Claim.QuietAfter) != 0 { |
| + t.Fatalf("claim: %v %v", obs, err) |
| + } |
| + f.b.FailOn = nil |
| + if err := f.e.Run(t.Context(), f.w); err != nil { |
| + t.Fatal(err) |
| + } |
| + f.done(t) |
| + }) |
| + } |
| +} |
| +func TestPageFailureDoesNotAct(t *testing.T) { |
| + f := newFixture(t) |
| + f.seed() |
| + before := f.b.Snapshot() |
| + f.b.FailListAfterPages = 1 |
| + if err := f.e.Run(t.Context(), f.w); err == nil { |
| + t.Fatal("short listing accepted") |
| + } |
| + if f.b.Snapshot() != before { |
| + t.Fatal("mutated on listing failure") |
| + } |
| +} |
| +func TestHorizonLatePut(t *testing.T) { |
| + f := newFixture(t) |
| + f.seed() |
| + deadline := f.ob.Park.EvidenceAt.Add(SignatureAge + time.Second) |
| + f.now = deadline |
| + if err := f.e.Run(t.Context(), f.w); !errors.Is(err, ErrPending) { |
| + t.Fatal(err) |
| + } |
| + late := f.b.Put("data", f.ob.Park.Location+"/0000/late", []byte("late")) |
| + f.now = deadline.Add(time.Nanosecond) |
| + if err := f.e.Run(t.Context(), f.w); !errors.Is(err, ErrPending) { |
| + t.Fatal(err) |
| + } |
| + obs, _ := hostclaim.Read(t.Context(), f.e.Claims, f.w.Host) |
| + if obs.Claim.Obligations[0].Retirement == f.ob.Retirement { |
| + t.Fatal("manifest reused") |
| + } |
| + if err := f.e.Run(t.Context(), f.w); err != nil { |
| + t.Fatal(err) |
| + } |
| + f.done(t) |
| + if _, err := f.b.GetVersion(t.Context(), "data", f.ob.Park.Location+"/0000/late", late); !errors.Is(err, lease.ErrMirrorNotFound) { |
| + t.Fatal(err) |
| + } |
| +} |
| +func TestVerificationListingStartsAfterHorizon(t *testing.T) { |
| + f := newFixture(t) |
| + f.seed() |
| + deadline := f.ob.Park.EvidenceAt.Add(SignatureAge + time.Second) |
| + f.now = deadline |
| + f.e.After = func(s string) error { |
| + if s == "verify" { |
| + f.now = deadline.Add(time.Hour) |
| + } |
| + return nil |
| + } |
| + if err := f.e.Run(t.Context(), f.w); !errors.Is(err, ErrPending) { |
| + t.Fatal(err) |
| + } |
| +} |
| +func TestManifestRetryDoesNotCopy(t *testing.T) { |
| + f := newFixture(t) |
| + f.seed() |
| + f.e.After = func(s string) error { |
| + if s == "delete" { |
| + return ErrPending |
| + } |
| + return nil |
| + } |
| + _ = f.e.Run(t.Context(), f.w) |
| + f.e.After = nil |
| + f.b.FailOn = func(op, key string) error { |
| + if op == "copy" { |
| + t.Error("retry copied") |
| + return ErrUnsafe |
| + } |
| + return nil |
| + } |
| + if err := f.e.Run(t.Context(), f.w); err != nil { |
| + t.Fatal(err) |
| + } |
| +} |
| +func TestCASConflictDoesNotAcknowledge(t *testing.T) { |
| + f := newFixture(t) |
| + f.seed() |
| + f.e.After = func(s string) error { |
| + if s == "before-completion" { |
| + obs, _ := hostclaim.Read(t.Context(), f.e.Claims, f.w.Host) |
| + _, err := hostclaim.Update(t.Context(), f.e.Claims, obs, func(c *hostclaim.Claim) error { c.Lost = append(c.Lost, "other-box"); return nil }) |
| + return err |
| + } |
| + return nil |
| + } |
| + if err := f.e.Run(t.Context(), f.w); !errors.Is(err, hostclaim.ErrConflict) { |
| + t.Fatal(err) |
| + } |
| + if f.held.released != 0 { |
| + t.Fatal("released unacknowledged lease") |
| + } |
| + f.e.After = nil |
| + if err := f.e.Run(t.Context(), f.w); err != nil { |
| + t.Fatal(err) |
| + } |
| +} |
| +func TestLiveGenerationCannotBeParked(t *testing.T) { |
| + f := newFixture(t) |
| + obs, _ := hostclaim.Read(t.Context(), f.e.Claims, f.w.Host) |
| + _, err := hostclaim.Update(t.Context(), f.e.Claims, obs, func(c *hostclaim.Claim) error { |
| + c.Op = hostclaim.Op{Kind: hostclaim.KindServing, Exec: "box"} |
| + return nil |
| + }) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + f.seed() |
| + f.e.View = func(_ context.Context, o hostclaim.Observed) (View, error) { |
| + return View{ClaimETag: o.ETag, Region: "ie", AuthoritativeRegion: "ie", MarkerKnown: true, MarkerGeneration: hostclaim.RootGeneration, GenerationOf: func(Ref) (string, bool) { return hostclaim.RootGeneration, true }}, nil |
| + } |
| + before := f.b.Snapshot() |
| + if err := f.e.Run(t.Context(), f.w); !errors.Is(err, ErrUnsafe) { |
| + t.Fatal(err) |
| + } |
| + if before != f.b.Snapshot() { |
| + t.Fatal("live data changed") |
| + } |
| +} |
| +func TestBoundScopeAndQuietAfter(t *testing.T) { |
| + f := newFixture(t) |
| + obs, _ := hostclaim.Read(t.Context(), f.e.Claims, f.w.Host) |
| + later := f.now.Add(time.Hour) |
| + _, err := hostclaim.Update(t.Context(), f.e.Claims, obs, func(c *hostclaim.Claim) error { |
| + c.Obligations[0].Park = nil |
| + c.QuietAfter = map[string]time.Time{"ie": later} |
| + return nil |
| + }) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := f.e.Bind(t.Context(), f.w, *f.ob.Park); err != nil { |
| + t.Fatal(err) |
| + } |
| + bad := *f.ob.Park |
| + bad.EvidenceAt = bad.EvidenceAt.Add(time.Second) |
| + if err := f.e.Bind(t.Context(), f.w, bad); !errors.Is(err, ErrChanged) { |
| + t.Fatal(err) |
| + } |
| + if err := f.e.Run(t.Context(), f.w); err != nil { |
| + t.Fatal(err) |
| + } |
| + obs, _ = hostclaim.Read(t.Context(), f.e.Claims, f.w.Host) |
| + if !obs.Claim.QuietAfter["ie"].Equal(later) { |
| + t.Fatal("quiet_after regressed") |
| + } |
| +} |
| +func TestLegacyLocationDoesNotIncludeIncarnationChildren(t *testing.T) { |
| + f := newFixture(t) |
| + key := f.ob.Park.Location + "/0000/data" |
| + f.b.Put("data", key, []byte("new incarnation")) |
| + vs, err := f.e.list(t.Context(), f.w.Host, "instances/"+f.w.Host) |
| + if err != nil || len(vs) != 0 { |
| + t.Fatalf("%v %v", vs, err) |
| + } |
| + for _, v := range []string{"a/b", "a@b", "a+b", "a b", "a?b"} { |
| + key := destination(f.w.Host, f.ob, lease.ObjectVersion{Key: key, VersionID: v}) |
| + if strings.HasSuffix(key, "@"+v) { |
| + t.Errorf("version not escaped: %s", v) |
| + } |
| + } |
| +} |
| + |
| +func TestDeleteParksStoppedAuthoritativeLocation(t *testing.T) { |
| + f := newFixture(t) |
| + f.seed() |
| + f.e.View = func(_ context.Context, o hostclaim.Observed) (View, error) { |
| + return View{ClaimETag: o.ETag, Region: "ie", AuthoritativeRegion: "ie"}, nil |
| + } |
| + if err := f.e.Run(t.Context(), f.w); err != nil { |
| + t.Fatal(err) |
| + } |
| + f.done(t) |
| +} |
| +func TestForeignRootGenerationRetirement(t *testing.T) { |
| + f := newFixture(t) |
| + f.seed() |
| + obs, _ := hostclaim.Read(t.Context(), f.e.Claims, f.w.Host) |
| + _, err := hostclaim.Update(t.Context(), f.e.Claims, obs, func(c *hostclaim.Claim) error { |
| + c.Obligations[0].Kind = "park-generation" |
| + c.Obligations[0].Generation = hostclaim.RootGeneration |
| + return nil |
| + }) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + f.e.View = func(_ context.Context, o hostclaim.Observed) (View, error) { |
| + return View{ClaimETag: o.ETag, Region: "ie", AuthoritativeRegion: "uk", GenerationOf: func(Ref) (string, bool) { return "", true }}, nil |
| + } |
| + if err := f.e.Run(t.Context(), f.w); err != nil { |
| + t.Fatal(err) |
| + } |
| + f.done(t) |
| +} |
| + |
| +func TestLeaseLossAndAuthorizationRefuse(t *testing.T) { |
| + for _, phase := range []string{"before-first-effect", "before-completion", "authorize"} { |
| + t.Run(phase, func(t *testing.T) { |
| + f := newFixture(t) |
| + f.seed() |
| + before := f.b.Snapshot() |
| + switch phase { |
| + case "before-first-effect": |
| + f.held.failed = true |
| + case "before-completion": |
| + f.e.After = func(s string) error { |
| + if s == phase { |
| + f.held.failed = true |
| + } |
| + return nil |
| + } |
| + case "authorize": |
| + f.e.Authorize = func(context.Context, hostclaim.Observed, hostclaim.Obligation) error { return ErrChanged } |
| + } |
| + if err := f.e.Run(t.Context(), f.w); !errors.Is(err, ErrChanged) { |
| + t.Fatal(err) |
| + } |
| + obs, _ := hostclaim.Read(t.Context(), f.e.Claims, f.w.Host) |
| + if len(obs.Claim.Obligations) != 1 || f.held.released != 0 { |
| + t.Fatal("acknowledged without authority") |
| + } |
| + if phase != "before-completion" && f.b.Snapshot() != before { |
| + t.Fatal("acted without authority") |
| + } |
| + }) |
| + } |
| +} |
| +func TestMalformedManifestNeverDeletes(t *testing.T) { |
| + f := newFixture(t) |
| + f.seed() |
| + _, err := f.b.PutIfAbsent(t.Context(), "data", archiveBase(f.w.Host, f.ob)+"manifest.json", []byte(`{"V":1,"Host":"other.example"}`)) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + before := f.b.Snapshot() |
| + if err := f.e.Run(t.Context(), f.w); err == nil { |
| + t.Fatal("accepted malformed manifest") |
| + } |
| + if before != f.b.Snapshot() { |
| + t.Fatal("deleted from bad manifest") |
| + } |
| +} |
| +``` |
| + |
| +Write `internal/park/sweep_test.go` exactly as follows: |
| + |
| +```go |
| +package park |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "strings" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/hostclaim" |
| + "github.com/carlosframework/platform/internal/lease" |
| +) |
| + |
| +func sweepFixture(t *testing.T) (*fixture, hostclaim.Observed, View, []lease.ObjectVersion) { |
| + f := newFixture(t) |
| + obs, _ := hostclaim.Read(t.Context(), f.e.Claims, f.w.Host) |
| + obs, err := hostclaim.Update(t.Context(), f.e.Claims, obs, func(c *hostclaim.Claim) error { |
| + c.Obligations = nil |
| + c.Op = hostclaim.Op{Kind: hostclaim.KindServing, Exec: "box"} |
| + c.Holder.Generation = "g3" |
| + c.Holder.Admissions = []hostclaim.AdmissionRecord{ |
| + {Session: 2, Generation: "g2", Predecessor: "g1", Mode: "cold", Region: "ie", PredecessorRegion: "ie"}, |
| + {Session: 3, Generation: "g3", Predecessor: "g2", Mode: "cold", Region: "ie", PredecessorRegion: "ie"}, |
| + } |
| + return nil |
| + }) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + p := f.ob.Park.Location + "/" |
| + for _, g := range []string{"g0", "g1", "g2", "g3"} { |
| + f.b.Put("data", p+g+"/data", []byte(g)) |
| + } |
| + f.b.Put("data", p+"generation.json", []byte("older")) |
| + markerID := f.b.Put("data", p+"generation.json", []byte("g1")) |
| + pinned := f.b.Put("data", p+"pinned/data", []byte("baseline")) |
| + f.b.Put("data", p+"pinned/data", []byte("newer")) |
| + classify := func(r Ref) (string, bool) { |
| + s := strings.TrimPrefix(r.Key, p) |
| + g, _, ok := strings.Cut(s, "/") |
| + if r.Key == p+"generation.json" { |
| + return "@marker", true |
| + } |
| + return g, ok |
| + } |
| + v := View{ClaimETag: obs.ETag, Region: "ie", AuthoritativeRegion: "ie", MarkerKnown: true, MarkerGeneration: "g1", Marker: Ref{p + "generation.json", markerID}, Pinned: map[Ref]bool{{p + "pinned/data", pinned}: true}, GenerationOf: classify} |
| + vs, err := f.e.Discover(t.Context(), f.w.Host) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return f, obs, v, vs |
| +} |
| +func TestSweepProtectsUnfinishedRunAndPins(t *testing.T) { |
| + f, obs, v, vs := sweepFixture(t) |
| + d, err := Decide(obs, v, vs, f.ob.Park.Incarnation) |
| + if err != nil || len(d.Alerts) != 0 || len(d.Candidates) != 1 || d.Candidates[0].Generation != "g0" { |
| + t.Fatalf("%+v %v", d, err) |
| + } |
| + f.e.View = func(_ context.Context, o hostclaim.Observed) (View, error) { |
| + x := v |
| + x.ClaimETag = o.ETag |
| + return x, nil |
| + } |
| + if _, err := f.e.Sweep(t.Context(), f.w.Host, f.ob.Park.Incarnation, f.ob.Park.EvidenceAt, time.Second); err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, x := range vs { |
| + if strings.Contains(x.Key, "/g0/") { |
| + continue |
| + } |
| + if _, err := f.b.GetVersion(t.Context(), "data", x.Key, x.VersionID); err != nil { |
| + t.Fatalf("live version lost: %+v %v", x, err) |
| + } |
| + } |
| +} |
| +func TestSweepDisagreementAndBusyRefuse(t *testing.T) { |
| + f, obs, v, vs := sweepFixture(t) |
| + v.MarkerGeneration = "unrelated" |
| + d, err := Decide(obs, v, vs, f.ob.Park.Incarnation) |
| + if err != nil || len(d.Alerts) != 1 || len(d.Candidates) != 0 { |
| + t.Fatalf("%+v %v", d, err) |
| + } |
| + obs.Claim.Op.Kind = hostclaim.KindMoving |
| + obs.Claim.Op.ID = "moving" |
| + d, err = Decide(obs, v, vs, f.ob.Park.Incarnation) |
| + if err != nil || len(d.Candidates) != 0 { |
| + t.Fatalf("%+v %v", d, err) |
| + } |
| +} |
| +func TestSweepDiscoversOldNamespaceAndMoves(t *testing.T) { |
| + f, obs, v, vs := sweepFixture(t) |
| + old := strings.Repeat("a", 64) |
| + other := strings.Repeat("b", 64) |
| + for _, key := range []string{"instances/" + f.w.Host + "/" + old + "/0000/a", "moves/" + f.w.Host + "/" + other + "/g-op/a", "instances/" + f.w.Host + "/0000/legacy", "moves/" + f.w.Host + "/g-old/staging"} { |
| + id := f.b.Put("data", key, []byte("old")) |
| + vs = append(vs, lease.ObjectVersion{Key: key, VersionID: id}) |
| + } |
| + d, err := Decide(obs, v, vs, f.ob.Park.Incarnation) |
| + if err != nil || len(d.Candidates) != 4 { |
| + t.Fatalf("%+v %v", d, err) |
| + } |
| + // Capped retired history is irrelevant to discovery. |
| + obs.Claim.RetiredIncarnations = nil |
| + v.Region = "us" |
| + d, err = Decide(obs, v, vs, f.ob.Park.Incarnation) |
| + if err != nil || len(d.Candidates) != 4 { |
| + t.Fatalf("foreign location not residue: %+v %v", d, err) |
| + } |
| +} |
| +func TestSweepFreeHostQueuesAndAcknowledges(t *testing.T) { |
| + f := newFixture(t) |
| + f.seed() |
| + obs, _ := hostclaim.Read(t.Context(), f.e.Claims, f.w.Host) |
| + _, err := hostclaim.Update(t.Context(), f.e.Claims, obs, func(c *hostclaim.Claim) error { c.Obligations = nil; return hostclaim.FreeAfterPark("op")(c) }) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + d, err := f.e.Sweep(t.Context(), f.w.Host, f.ob.Park.Incarnation, f.ob.Park.EvidenceAt, time.Second) |
| + if err != nil || len(d.Candidates) != 1 { |
| + t.Fatalf("%+v %v", d, err) |
| + } |
| + obs, err = hostclaim.Read(t.Context(), f.e.Claims, f.w.Host) |
| + if err != nil || !obs.Claim.Free() || len(obs.Claim.Obligations) != 0 { |
| + t.Fatalf("%+v %v", obs, err) |
| + } |
| +} |
| +func TestSweepPageFailureAndStaleView(t *testing.T) { |
| + f, obs, v, vs := sweepFixture(t) |
| + before := f.b.Snapshot() |
| + f.b.FailListAfterPages = 1 |
| + if _, err := f.e.Sweep(t.Context(), f.w.Host, f.ob.Park.Incarnation, f.ob.Park.EvidenceAt, time.Second); err == nil { |
| + t.Fatal("short sweep acted") |
| + } |
| + if before != f.b.Snapshot() { |
| + t.Fatal("mutation") |
| + } |
| + v.ClaimETag = "old" |
| + if _, err := Decide(obs, v, vs, f.ob.Park.Incarnation); !errors.Is(err, ErrUnsafe) { |
| + t.Fatal(err) |
| + } |
| +} |
| +``` |
| + |
| +- [ ] **Step 2: Run the red tests** |
| + |
| +```bash |
| +go test ./internal/park -run 'TestRemote|TestStorage|TestPage|TestHorizon|TestVerification|TestManifest|TestCAS|TestLive|TestBound|TestLegacy|TestDelete|TestForeign|TestLease|TestMalformed|TestSweep' -count=1 |
| +``` |
| + |
| +Expected: missing `Engine`, `Work`, `View`, `Manifest` and decision symbols. The tests use only temporary control directories and the in-memory versioned bucket; they do not need credentials, listeners or AWS. |
| + |
| +- [ ] **Step 3: Write the complete remote engine** |
| + |
| +Write `internal/park/remote.go` exactly as follows: |
| + |
| +```go |
| +package park |
| + |
| +import ( |
| + "bytes" |
| + "context" |
| + "encoding/hex" |
| + "encoding/json" |
| + "errors" |
| + "io" |
| + "net/url" |
| + "reflect" |
| + "sort" |
| + "strings" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/hostclaim" |
| + "github.com/carlosframework/platform/internal/lease" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +const SignatureAge = 15 * time.Minute |
| + |
| +var ( |
| + ErrPending = errors.New("park: waiting for verification after horizon") |
| + ErrUnsafe = errors.New("park: unsafe or incomplete input") |
| + ErrChanged = errors.New("park: authority changed") |
| +) |
| + |
| +type Ref struct{ Key, VersionID string } |
| + |
| +func ref(v lease.ObjectVersion) Ref { return Ref{v.Key, v.VersionID} } |
| + |
| +type Copy struct { |
| + Source lease.ObjectVersion |
| + Destination Ref |
| +} |
| +type Manifest struct { |
| + V int |
| + Host, Obligation, Op, Retirement, Region, Location, Incarnation, Generation string |
| + Archive []Copy |
| + Delete []lease.ObjectVersion |
| +} |
| +type Work struct{ Host, ID, Op string } |
| + |
| +// Close stops renewal; Release conditionally removes only the held lock version. |
| +// Acquire must renew until Close, and Check must refuse expired/lost leases. |
| +type HeldLease interface { |
| + Check(context.Context) error |
| + Release(context.Context) error |
| + Close() |
| +} |
| +type Leases interface { |
| + Acquire(context.Context, string, string) (HeldLease, error) |
| +} |
| + |
| +type Engine struct { |
| + Store lease.VersionedStore |
| + Claims release.Store |
| + Bucket, Region string |
| + Leases Leases |
| + Now func() time.Time |
| + NewID func() string |
| + // View is supplied by moves/Plan 7b, using precisely this fresh observation. |
| + View func(context.Context, hostclaim.Observed) (View, error) |
| + // Authorize checks phase-specific stop evidence/fences and chore ownership. |
| + // It must not manufacture stop evidence from a stopped parent alone. |
| + Authorize func(context.Context, hostclaim.Observed, hostclaim.Obligation) error |
| + // After is a test crash point, called after durable effects. Nil in callers. |
| + After func(string) error |
| +} |
| + |
| +func (e Engine) after(step string) error { |
| + if e.After != nil { |
| + return e.After(step) |
| + } |
| + return nil |
| +} |
| +func token(s string) bool { |
| + return s != "" && s != "." && s != ".." && !strings.ContainsAny(s, "/\\\x00") |
| +} |
| +func hex64(s string) bool { _, err := hex.DecodeString(s); return len(s) == 64 && err == nil } |
| +func find(c *hostclaim.Claim, w Work) (hostclaim.Obligation, bool) { |
| + if c == nil { |
| + return hostclaim.Obligation{}, false |
| + } |
| + for _, o := range c.Obligations { |
| + if o.ID == w.ID && o.Op == w.Op { |
| + return o, true |
| + } |
| + } |
| + return hostclaim.Obligation{}, false |
| +} |
| +func validateScope(host string, o hostclaim.Obligation) error { |
| + p := o.Park |
| + if p == nil || !hex64(p.Incarnation) || p.EvidenceAt.IsZero() || p.RequestLimit <= 0 || p.RequestLimit > time.Duration(1<<63-1)-SignatureAge || !token(o.Retirement) || o.Region == "" || o.Owner != "region:"+o.Region { |
| + return ErrUnsafe |
| + } |
| + base := "instances/" + host |
| + if p.Location != base && p.Location != base+"/"+p.Incarnation { |
| + return ErrUnsafe |
| + } |
| + if o.Kind != "park-replica" && o.Kind != "park-generation" { |
| + return ErrUnsafe |
| + } |
| + if o.Kind == "park-generation" && o.Generation == "" { |
| + return ErrUnsafe |
| + } |
| + return nil |
| +} |
| + |
| +// Bind persists recovery inputs before the first data-bucket effect. Existing |
| +// Plan 1 chores have no Park field; callers supply real stop-evidence time and |
| +// a deployment-wide upper bound enforced by every relevant request sender. |
| +func (e Engine) Bind(ctx context.Context, w Work, p hostclaim.ParkScope) error { |
| + obs, err := hostclaim.Read(ctx, e.Claims, w.Host) |
| + if err != nil { |
| + return err |
| + } |
| + o, ok := find(obs.Claim, w) |
| + if !ok { |
| + return ErrChanged |
| + } |
| + if o.Park != nil { |
| + if reflect.DeepEqual(*o.Park, p) { |
| + return nil |
| + } |
| + return ErrChanged |
| + } |
| + if obs.Claim.Holder == nil || p.Location != obs.Claim.Holder.Location || p.Incarnation != obs.Claim.Holder.Incarnation { |
| + return ErrUnsafe |
| + } |
| + o.Park = &p |
| + if err := validateScope(w.Host, o); err != nil { |
| + return err |
| + } |
| + if e.Authorize == nil { |
| + return ErrUnsafe |
| + } |
| + if err := e.Authorize(ctx, obs, o); err != nil { |
| + return err |
| + } |
| + _, err = hostclaim.Update(ctx, e.Claims, obs, func(c *hostclaim.Claim) error { |
| + for i := range c.Obligations { |
| + if c.Obligations[i].ID == w.ID && c.Obligations[i].Op == w.Op { |
| + c.Obligations[i].Park = &p |
| + return nil |
| + } |
| + } |
| + return ErrChanged |
| + }) |
| + return err |
| +} |
| +func prefixes(host, location string) []string { |
| + suffix := strings.TrimPrefix(location, "instances/"+host) |
| + return []string{location + "/", "moves/" + host + suffix + "/"} |
| +} |
| + |
| +// locationOf partitions legacy level/generation children from hex incarnation |
| +// children, including objects in moves/. No retired-incarnations lookup. |
| +func locationOf(host, key string) (string, error) { |
| + for _, root := range []string{"instances/", "moves/"} { |
| + base := root + host + "/" |
| + if !strings.HasPrefix(key, base) { |
| + continue |
| + } |
| + tail := strings.TrimPrefix(key, base) |
| + if tail == "" { |
| + return "", ErrUnsafe |
| + } |
| + first, _, child := strings.Cut(tail, "/") |
| + if child && hex64(first) { |
| + return "instances/" + host + "/" + first, nil |
| + } |
| + return "instances/" + host, nil |
| + } |
| + return "", ErrUnsafe |
| +} |
| +func isLock(host, location, key string) bool { |
| + for _, p := range prefixes(host, location) { |
| + if key == p+"lock.json" { |
| + return true |
| + } |
| + } |
| + return false |
| +} |
| +func (e Engine) list(ctx context.Context, host, location string) ([]lease.ObjectVersion, error) { |
| + var out []lease.ObjectVersion |
| + for _, p := range prefixes(host, location) { |
| + vs, err := e.Store.ListVersions(ctx, e.Bucket, p) |
| + if err != nil { |
| + return nil, err |
| + } |
| + out = append(out, vs...) |
| + } |
| + seen := map[Ref]bool{} |
| + filtered := out[:0] |
| + for _, v := range out { |
| + l, err := locationOf(host, v.Key) |
| + if err != nil || v.VersionID == "" || seen[ref(v)] { |
| + return nil, ErrUnsafe |
| + } |
| + seen[ref(v)] = true |
| + if l == location && !isLock(host, location, v.Key) { |
| + filtered = append(filtered, v) |
| + } |
| + } |
| + sort.Slice(filtered, func(i, j int) bool { |
| + a, b := filtered[i], filtered[j] |
| + if a.Key == b.Key { |
| + return a.VersionID < b.VersionID |
| + } |
| + return a.Key < b.Key |
| + }) |
| + return filtered, nil |
| +} |
| +func archiveBase(host string, o hostclaim.Obligation) string { |
| + return "parked/" + o.Park.Incarnation + "/" + host + "/" + o.Retirement + "/" |
| +} |
| +func destination(host string, o hostclaim.Obligation, v lease.ObjectVersion) string { |
| + for _, p := range prefixes(host, o.Park.Location) { |
| + if strings.HasPrefix(v.Key, p) { |
| + kind := strings.SplitN(p, "/", 2)[0] |
| + return archiveBase(host, o) + kind + "/" + strings.TrimPrefix(v.Key, p) + "@" + url.QueryEscape(v.VersionID) |
| + } |
| + } |
| + return "" |
| +} |
| +func (e Engine) current(ctx context.Context, key string) (lease.ObjectVersion, bool, error) { |
| + vs, err := e.Store.ListVersions(ctx, e.Bucket, key) |
| + if err != nil { |
| + return lease.ObjectVersion{}, false, err |
| + } |
| + var got lease.ObjectVersion |
| + ok := false |
| + for _, v := range vs { |
| + if v.Key == key && v.IsLatest { |
| + if ok || v.VersionID == "" { |
| + return got, false, ErrUnsafe |
| + } |
| + got, ok = v, true |
| + } |
| + } |
| + if ok && got.DeleteMarker { |
| + return got, false, ErrUnsafe |
| + } |
| + return got, ok, nil |
| +} |
| +func (e Engine) manifest(ctx context.Context, w Work, o hostclaim.Obligation) (*Manifest, error) { |
| + v, ok, err := e.current(ctx, archiveBase(w.Host, o)+"manifest.json") |
| + if err != nil || !ok { |
| + return nil, err |
| + } |
| + b, err := e.Store.GetVersion(ctx, e.Bucket, v.Key, v.VersionID) |
| + if err != nil { |
| + return nil, err |
| + } |
| + var m Manifest |
| + d := json.NewDecoder(bytes.NewReader(b)) |
| + d.DisallowUnknownFields() |
| + if err := d.Decode(&m); err != nil { |
| + return nil, err |
| + } |
| + if _, err := d.Token(); err != io.EOF { |
| + return nil, ErrUnsafe |
| + } |
| + if m.V != 1 || m.Host != w.Host || m.Obligation != w.ID || m.Op != w.Op || m.Retirement != o.Retirement || m.Region != o.Region || m.Location != o.Park.Location || m.Incarnation != o.Park.Incarnation || m.Generation != o.Generation { |
| + return nil, ErrUnsafe |
| + } |
| + deletes := map[Ref]lease.ObjectVersion{} |
| + for _, v := range m.Delete { |
| + l, err := locationOf(w.Host, v.Key) |
| + if err != nil || l != o.Park.Location || v.VersionID == "" || isLock(w.Host, l, v.Key) { |
| + return nil, ErrUnsafe |
| + } |
| + if _, ok := deletes[ref(v)]; ok { |
| + return nil, ErrUnsafe |
| + } |
| + deletes[ref(v)] = v |
| + } |
| + copies := map[Ref]bool{} |
| + for _, a := range m.Archive { |
| + v, ok := deletes[ref(a.Source)] |
| + if !ok || v.DeleteMarker || !reflect.DeepEqual(v, a.Source) || copies[ref(v)] || a.Destination.Key != destination(w.Host, o, v) || a.Destination.VersionID == "" { |
| + return nil, ErrUnsafe |
| + } |
| + copies[ref(v)] = true |
| + } |
| + for r, v := range deletes { |
| + if !v.DeleteMarker && !copies[r] { |
| + return nil, ErrUnsafe |
| + } |
| + } |
| + return &m, nil |
| +} |
| +func (e Engine) observe(ctx context.Context, w Work, held HeldLease, expected hostclaim.Obligation) (hostclaim.Observed, View, error) { |
| + if err := held.Check(ctx); err != nil { |
| + return hostclaim.Observed{}, View{}, err |
| + } |
| + obs, err := hostclaim.Read(ctx, e.Claims, w.Host) |
| + if err != nil { |
| + return obs, View{}, err |
| + } |
| + o, ok := find(obs.Claim, w) |
| + if !ok || !reflect.DeepEqual(o, expected) { |
| + return obs, View{}, ErrChanged |
| + } |
| + if err := e.Authorize(ctx, obs, o); err != nil { |
| + return obs, View{}, err |
| + } |
| + v, err := e.View(ctx, obs) |
| + if err != nil { |
| + return obs, v, err |
| + } |
| + if v.ClaimETag != obs.ETag || v.Region != e.Region { |
| + return obs, v, ErrChanged |
| + } |
| + return obs, v, nil |
| +} |
| +func (e Engine) selected(ctx context.Context, w Work, o hostclaim.Obligation, held HeldLease, vs []lease.ObjectVersion) (hostclaim.Observed, []lease.ObjectVersion, error) { |
| + obs, v, err := e.observe(ctx, w, held, o) |
| + if err != nil { |
| + return obs, nil, err |
| + } |
| + live, err := protected(obs.Claim, v) |
| + if err != nil { |
| + return obs, nil, err |
| + } |
| + out := make([]lease.ObjectVersion, 0, len(vs)) |
| + for _, x := range vs { |
| + loc, err := locationOf(w.Host, x.Key) |
| + if err != nil || loc != o.Park.Location || x.VersionID == "" { |
| + return obs, nil, ErrUnsafe |
| + } |
| + if isLock(w.Host, loc, x.Key) { |
| + return obs, nil, ErrUnsafe |
| + } |
| + if obs.Claim.Holder != nil && obs.Claim.Op.Kind != hostclaim.KindParking && loc == obs.Claim.Holder.Location && v.Region == v.AuthoritativeRegion { |
| + if x.Key == v.Marker.Key { |
| + if o.Kind == "park-generation" { |
| + continue |
| + } |
| + return obs, nil, ErrUnsafe |
| + } |
| + g, ok := v.GenerationOf(ref(x)) |
| + if !ok { |
| + return obs, nil, ErrUnsafe |
| + } |
| + g = generation(g) |
| + if o.Kind == "park-generation" && g != o.Generation { |
| + continue |
| + } |
| + if live[g] || v.Pinned[ref(x)] || ref(x) == v.Marker { |
| + return obs, nil, ErrUnsafe |
| + } |
| + } else if o.Kind == "park-generation" { |
| + if v.GenerationOf == nil { |
| + return obs, nil, ErrUnsafe |
| + } |
| + g, ok := v.GenerationOf(ref(x)) |
| + if !ok { |
| + return obs, nil, ErrUnsafe |
| + } |
| + if generation(g) != o.Generation { |
| + continue |
| + } |
| + } |
| + out = append(out, x) |
| + } |
| + return obs, out, nil |
| +} |
| + |
| +// Run performs one resumable event. ErrPending is a scheduler retry, never an |
| +// acknowledgement. A newly rotated event is persisted before returning it. |
| +func (e Engine) Run(ctx context.Context, w Work) error { |
| + if e.Store == nil || e.Claims == nil || e.Leases == nil || e.Now == nil || e.NewID == nil || e.View == nil || e.Authorize == nil || e.Bucket == "" || e.Region == "" { |
| + return ErrUnsafe |
| + } |
| + initial, err := hostclaim.Read(ctx, e.Claims, w.Host) |
| + if err != nil { |
| + return err |
| + } |
| + if initial.Absent { |
| + return ErrChanged |
| + } |
| + o, ok := find(initial.Claim, w) |
| + if !ok { |
| + return nil |
| + } |
| + if err := validateScope(w.Host, o); err != nil { |
| + return err |
| + } |
| + if o.Region != e.Region { |
| + return ErrUnsafe |
| + } |
| + held, err := e.Leases.Acquire(ctx, e.Bucket, o.Park.Location) |
| + if err != nil { |
| + return err |
| + } |
| + defer held.Close() |
| + if _, _, err = e.observe(ctx, w, held, o); err != nil { |
| + return err |
| + } |
| + m, err := e.manifest(ctx, w, o) |
| + if err != nil { |
| + return err |
| + } |
| + if m == nil { |
| + vs, err := e.list(ctx, w.Host, o.Park.Location) |
| + if err != nil { |
| + return err |
| + } |
| + _, vs, err = e.selected(ctx, w, o, held, vs) |
| + if err != nil { |
| + return err |
| + } |
| + m = &Manifest{V: 1, Host: w.Host, Obligation: w.ID, Op: w.Op, Retirement: o.Retirement, Region: o.Region, Location: o.Park.Location, Incarnation: o.Park.Incarnation, Generation: o.Generation, Delete: vs} |
| + for _, v := range vs { |
| + if v.DeleteMarker { |
| + continue |
| + } |
| + if _, _, err := e.selected(ctx, w, o, held, []lease.ObjectVersion{v}); err != nil { |
| + return err |
| + } |
| + dst := destination(w.Host, o, v) |
| + id, _, err := e.Store.CopyVersion(ctx, e.Bucket, v.Key, v.VersionID, e.Bucket, dst, true) |
| + if errors.Is(err, lease.ErrDestinationExists) { |
| + got, ok, getErr := e.current(ctx, dst) |
| + if getErr != nil { |
| + return getErr |
| + } |
| + if !ok || got.Size != v.Size { |
| + return ErrUnsafe |
| + } |
| + id, err = got.VersionID, nil |
| + } |
| + if err != nil { |
| + return err |
| + } |
| + if id == "" { |
| + return ErrUnsafe |
| + } |
| + m.Archive = append(m.Archive, Copy{v, Ref{dst, id}}) |
| + if err := e.after("copy"); err != nil { |
| + return err |
| + } |
| + } |
| + if _, _, err = e.selected(ctx, w, o, held, m.Delete); err != nil { |
| + return err |
| + } |
| + raw, err := json.Marshal(m) |
| + if err != nil { |
| + return err |
| + } |
| + _, err = e.Store.PutIfAbsent(ctx, e.Bucket, archiveBase(w.Host, o)+"manifest.json", raw) |
| + if errors.Is(err, lease.ErrExists) { |
| + m, err = e.manifest(ctx, w, o) |
| + } |
| + if err != nil { |
| + return err |
| + } |
| + if m == nil { |
| + return ErrUnsafe |
| + } |
| + if err := e.after("manifest"); err != nil { |
| + return err |
| + } |
| + } |
| + // Existing manifests go directly to their recorded deletes: never re-copy |
| + // vanished source versions. The live-set admission check still applies. |
| + for _, v := range m.Delete { |
| + _, selected, err := e.selected(ctx, w, o, held, []lease.ObjectVersion{v}) |
| + if err != nil { |
| + return err |
| + } |
| + if len(selected) != 1 { |
| + return ErrUnsafe |
| + } |
| + if err := e.Store.DeleteVersion(ctx, e.Bucket, v.Key, v.VersionID); err != nil { |
| + return err |
| + } |
| + if err := e.after("delete"); err != nil { |
| + return err |
| + } |
| + } |
| + started := e.Now() |
| + vs, err := e.list(ctx, w.Host, o.Park.Location) |
| + if err != nil { |
| + return err |
| + } |
| + obs, vs, err := e.selected(ctx, w, o, held, vs) |
| + if err != nil { |
| + return err |
| + } |
| + if err := e.after("verify"); err != nil { |
| + return err |
| + } |
| + deadline := o.Park.EvidenceAt.Add(SignatureAge + o.Park.RequestLimit) |
| + if !started.After(deadline) { |
| + return ErrPending |
| + } |
| + if len(vs) > 0 { |
| + next := e.NewID() |
| + if !token(next) || next == o.Retirement { |
| + return ErrUnsafe |
| + } |
| + _, err = hostclaim.Update(ctx, e.Claims, obs, func(c *hostclaim.Claim) error { |
| + for i := range c.Obligations { |
| + if c.Obligations[i].ID == w.ID && c.Obligations[i].Op == w.Op { |
| + c.Obligations[i].Retirement = next |
| + return nil |
| + } |
| + } |
| + return ErrChanged |
| + }) |
| + if err != nil { |
| + return err |
| + } |
| + return ErrPending |
| + } |
| + if err := e.after("before-completion"); err != nil { |
| + return err |
| + } |
| + if err := held.Check(ctx); err != nil { |
| + return err |
| + } |
| + _, err = hostclaim.Update(ctx, e.Claims, obs, func(c *hostclaim.Claim) error { |
| + if c.QuietAfter == nil { |
| + c.QuietAfter = map[string]time.Time{} |
| + } |
| + if c.QuietAfter[o.Region].Before(deadline) { |
| + c.QuietAfter[o.Region] = deadline |
| + } |
| + if err := hostclaim.CompleteObligation(w.Op, w.ID)(c); err != nil { |
| + return err |
| + } |
| + if c.Holder == nil && len(c.Obligations) == 0 && c.Op.Kind == hostclaim.KindParking && c.Op.Intent["park_sweep"] == "v1" { |
| + return hostclaim.FreeAfterPark(c.Op.ID)(c) |
| + } |
| + return nil |
| + }) |
| + if err != nil { |
| + return err |
| + } |
| + if err := e.after("completion"); err != nil { |
| + return err |
| + } |
| + return held.Release(ctx) |
| +} |
| +``` |
| + |
| +`ErrPending` is an open chore for the caller's scheduler. It may mean the horizon has not passed or a later verification found residue and persisted a new event id. Resume through `Run` using the same `Work{Host, ID, Op}`; reread the current retirement id from the claim. `NewID` must return globally unique unpredictable operation/obligation/event identifiers, not reuse an earlier event. A successful `Run` whose chore is already absent performs no storage work. |
| + |
| +- [ ] **Step 4: Write the complete sweep decision and executor** |
| + |
| +Write `internal/park/sweep.go` exactly as follows: |
| + |
| +```go |
| +package park |
| + |
| +import ( |
| + "context" |
| + "fmt" |
| + "sort" |
| + "strings" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/hostclaim" |
| + "github.com/carlosframework/platform/internal/lease" |
| +) |
| + |
| +// View is a moves-owned, completely read snapshot bound to one claim ETag. |
| +// GenerationOf is a pure classifier for any exact reference, including a |
| +// version already deleted by a previous attempt. It does no storage I/O. |
| +// Pinned includes the exact noncurrent versions needed by the installed root. |
| +// MarkerKnown means its read succeeded; an absent marker may denote @root. |
| +type View struct { |
| + ClaimETag, Region, AuthoritativeRegion string |
| + MarkerKnown bool |
| + MarkerGeneration string |
| + Marker Ref |
| + Pinned map[Ref]bool |
| + GenerationOf func(Ref) (string, bool) |
| +} |
| +type Alert struct{ Location, Reason string } |
| +type Candidate struct{ Location, Incarnation, Generation string } |
| +type Decision struct { |
| + Candidates []Candidate |
| + Alerts []Alert |
| +} |
| + |
| +func generation(s string) string { |
| + if s == "" { |
| + return hostclaim.RootGeneration |
| + } |
| + return s |
| +} |
| + |
| +func protected(c *hostclaim.Claim, v View) (map[string]bool, error) { |
| + live := map[string]bool{} |
| + if c.Holder == nil || c.Op.Kind == hostclaim.KindParking { |
| + return live, nil |
| + } |
| + if v.AuthoritativeRegion == "" { |
| + return nil, ErrUnsafe |
| + } |
| + if v.Region != v.AuthoritativeRegion { |
| + return live, nil |
| + } |
| + if !v.MarkerKnown || v.GenerationOf == nil { |
| + return nil, ErrUnsafe |
| + } |
| + for r, on := range v.Pinned { |
| + if on { |
| + g, ok := v.GenerationOf(r) |
| + if !ok { |
| + return nil, ErrUnsafe |
| + } |
| + live[generation(g)] = true |
| + } |
| + } |
| + h := c.Holder |
| + current := generation(h.Generation) |
| + marker := generation(v.MarkerGeneration) |
| + live[current] = true |
| + live[marker] = true |
| + if current == marker { |
| + return live, nil |
| + } |
| + // Walk the recorded chronological predecessor chain backwards. Warm |
| + // admissions do not form cold unfinished links and cannot bridge a gap. |
| + want, region := current, v.AuthoritativeRegion |
| + for i := len(h.Admissions) - 1; i >= 0; i-- { |
| + a := h.Admissions[i] |
| + if generation(a.Generation) != want || a.Region != region { |
| + continue |
| + } |
| + if a.Covered || a.Mode != "cold" { |
| + return nil, ErrUnsafe |
| + } |
| + pred := generation(a.Predecessor) |
| + if pred == want { |
| + return nil, ErrUnsafe |
| + } |
| + live[want] = true |
| + live[pred] = true |
| + want = pred |
| + region = a.PredecessorRegion |
| + if want == marker { |
| + if region != "" && region != v.AuthoritativeRegion { |
| + return nil, ErrUnsafe |
| + } |
| + return live, nil |
| + } |
| + } |
| + return nil, ErrUnsafe |
| +} |
| + |
| +// Decide uses a complete namespace version listing supplied by Discover. |
| +// LegacyIncarnation is a caller-supplied, evidenced archive label only. It is |
| +// never used to discover locations, and its absence refuses legacy cleanup. |
| +func Decide(obs hostclaim.Observed, v View, versions []lease.ObjectVersion, legacyIncarnation string) (Decision, error) { |
| + var d Decision |
| + if obs.Absent || obs.Claim == nil || obs.ETag == "" || v.ClaimETag != obs.ETag || v.Region == "" { |
| + return d, ErrUnsafe |
| + } |
| + c := obs.Claim |
| + if err := c.Validate(); err != nil { |
| + return d, err |
| + } |
| + if !c.Free() && c.Op.Kind != hostclaim.KindServing { |
| + return d, nil |
| + } |
| + for _, o := range c.Obligations { |
| + if o.Region == v.Region { |
| + return d, nil |
| + } |
| + } |
| + live, liveErr := protected(c, v) |
| + if liveErr != nil && c.Holder != nil { |
| + d.Alerts = append(d.Alerts, Alert{c.Holder.Location, "marker does not match the claim or an unfinished admission run"}) |
| + } |
| + groups := map[Candidate]bool{} |
| + seen := map[Ref]bool{} |
| + for _, x := range versions { |
| + loc, err := locationOf(c.Host, x.Key) |
| + if err != nil || x.VersionID == "" || seen[ref(x)] { |
| + return Decision{}, ErrUnsafe |
| + } |
| + seen[ref(x)] = true |
| + if isLock(c.Host, loc, x.Key) { |
| + continue |
| + } |
| + candidate := Candidate{Location: loc} |
| + if tail := strings.TrimPrefix(loc, "instances/"+c.Host+"/"); tail != loc { |
| + candidate.Incarnation = tail |
| + } else { |
| + candidate.Incarnation = legacyIncarnation |
| + } |
| + if c.Holder != nil && loc == c.Holder.Location && v.Region == v.AuthoritativeRegion { |
| + if liveErr != nil { |
| + continue |
| + } |
| + if x.Key == v.Marker.Key || v.Pinned[ref(x)] { |
| + continue |
| + } |
| + g, ok := v.GenerationOf(ref(x)) |
| + if !ok { |
| + return Decision{}, ErrUnsafe |
| + } |
| + g = generation(g) |
| + if live[g] { |
| + continue |
| + } |
| + candidate.Generation = g |
| + candidate.Incarnation = c.Holder.Incarnation |
| + } |
| + if !hex64(candidate.Incarnation) { |
| + return Decision{}, ErrUnsafe |
| + } |
| + groups[candidate] = true |
| + } |
| + for c := range groups { |
| + d.Candidates = append(d.Candidates, c) |
| + } |
| + sort.Slice(d.Candidates, func(i, j int) bool { |
| + a, b := d.Candidates[i], d.Candidates[j] |
| + if a.Location == b.Location { |
| + return a.Generation < b.Generation |
| + } |
| + return a.Location < b.Location |
| + }) |
| + return d, nil |
| +} |
| + |
| +// Discover expands both namespaces across every version page. This is a |
| +// conservative superset of delimiter discovery: deriving first child prefixes |
| +// from the complete version listing also discovers marker-only locations. |
| +func (e Engine) Discover(ctx context.Context, host string) ([]lease.ObjectVersion, error) { |
| + var out []lease.ObjectVersion |
| + for _, p := range []string{"instances/" + host + "/", "moves/" + host + "/"} { |
| + vs, err := e.Store.ListVersions(ctx, e.Bucket, p) |
| + if err != nil { |
| + return nil, err |
| + } |
| + out = append(out, vs...) |
| + } |
| + return out, nil |
| +} |
| + |
| +// Sweep queues every decision in one claim CAS; Run is the common executor. |
| +// EvidenceAt is genuine prior stop/fence evidence for the residue. A sweep |
| +// must not substitute now for evidence, even on a free host. |
| +func (e Engine) Sweep(ctx context.Context, host, legacyIncarnation string, evidenceAt time.Time, requestLimit time.Duration) (Decision, error) { |
| + var empty Decision |
| + if e.View == nil || e.NewID == nil || e.Authorize == nil { |
| + return empty, ErrUnsafe |
| + } |
| + obs, err := hostclaim.Read(ctx, e.Claims, host) |
| + if err != nil { |
| + return empty, err |
| + } |
| + v, err := e.View(ctx, obs) |
| + if err != nil { |
| + return empty, err |
| + } |
| + vs, err := e.Discover(ctx, host) |
| + if err != nil { |
| + return empty, err |
| + } |
| + d, err := Decide(obs, v, vs, legacyIncarnation) |
| + if err != nil || len(d.Candidates) == 0 { |
| + return d, err |
| + } |
| + op := e.NewID() |
| + if !token(op) { |
| + return d, ErrUnsafe |
| + } |
| + chores := make([]hostclaim.Obligation, 0, len(d.Candidates)) |
| + for _, x := range d.Candidates { |
| + o := hostclaim.Obligation{ID: e.NewID(), Op: op, Kind: "park-replica", Region: e.Region, Generation: x.Generation, Retirement: e.NewID(), Owner: "region:" + e.Region, Park: &hostclaim.ParkScope{Location: x.Location, Incarnation: x.Incarnation, EvidenceAt: evidenceAt, RequestLimit: requestLimit}} |
| + if x.Generation != "" { |
| + o.Kind = "park-generation" |
| + } |
| + if !token(o.ID) || validateScope(host, o) != nil { |
| + return d, ErrUnsafe |
| + } |
| + if err := e.Authorize(ctx, obs, o); err != nil { |
| + return d, err |
| + } |
| + chores = append(chores, o) |
| + } |
| + _, err = hostclaim.Update(ctx, e.Claims, obs, func(c *hostclaim.Claim) error { |
| + if c.Free() { |
| + c.Op = hostclaim.Op{Kind: hostclaim.KindParking, ID: op, Intent: map[string]string{"park_sweep": "v1"}} |
| + } |
| + c.Obligations = append(c.Obligations, chores...) |
| + return nil |
| + }) |
| + if err != nil { |
| + return d, err |
| + } |
| + for _, o := range chores { |
| + if err := e.Run(ctx, Work{host, o.ID, o.Op}); err != nil { |
| + return d, fmt.Errorf("sweep %s: %w", o.ID, err) |
| + } |
| + } |
| + return d, nil |
| +} |
| +``` |
| + |
| +The sweep preserves the serving operation and queues its own durable chore ids. Existing pending chores in this region defer new sweep discovery; the box scheduler must resume them through `Run` before asking for new sweep work. A free-host sweep installs its narrow holderless parking operation first. The same remote engine performs every event; the decision function cannot write anything. |
| + |
| +- [ ] **Step 5: Run the green tests and race check** |
| + |
| +```bash |
| +gofmt -w internal/park |
| +go test -race ./internal/park -count=1 |
| +go vet ./internal/park |
| +``` |
| + |
| +Expected: all tests pass. The crash table covers each of two archive copies, the manifest, each of three deletion-set entries including the marker, verification, immediately before completion and after completion. The storage failure table covers list/get/copy/put/delete errors. A completed manifest retry never invokes copy. Horizon tests prove strict ordering and capture time before the verification starts. Sweep tests preserve the unfinished run, pinned noncurrent baseline and both marker versions, detect disagreement, reject stale views, discover old incarnation/moves/legacy locations and acknowledge a free host's residue. |
| + |
| +--- |
| + |
| +### Task 3: Park local inventory under the same guard |
| + |
| +**Files:** create `internal/hostguard/retirement.go`, `internal/park/local.go`, `internal/park/local_linux.go`, `internal/park/local_other.go`, `internal/park/local_linux_test.go`. |
| + |
| +**Interfaces:** consumes Plan 3's `Guard.With`, `Captured`, private guard/record helpers and the real `hostclaim.InventoryEntry{Layout, Paths map[string]uint64}`. Adds `RetireInstallationWith` as the callback form of `RetireInstallation`'s stamp retirement, sharing `record.retire` and record persistence; it retains the flock through the callback and also resumes when the captured installation is already inactive. The existing stamp-only method remains available. Adds `WithNoInstallation`, which permits only an authoritatively absent local record and never bypasses an existing stamp. |
| + |
| +`Local.RecordInventory` persists enrollment under `Guard.With` before callers materialize. `RetireArtifacts` selects S-tagged paths without changing stamps. `RetireInstallation` retires the captured installation then parks every recorded artifact. `AcknowledgeEmpty` is reserved for explicitly empty inventory and no local installation record. Every retirement requires a phase/evidence authorization callback both before local effects and in its final CAS. |
| + |
| +- [ ] **Step 1: Write the Linux retirement tests** |
| + |
| +Write `internal/park/local_linux_test.go` exactly as follows: |
| + |
| +```go |
| +//go:build linux |
| + |
| +package park |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "errors" |
| + "os" |
| + "path/filepath" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/hostclaim" |
| + "github.com/carlosframework/platform/internal/hostguard" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +type localFixture struct { |
| + l Local |
| + host string |
| + captured hostguard.Captured |
| +} |
| + |
| +func localFixtureFor(t *testing.T, paths map[string]uint64) localFixture { |
| + t.Helper() |
| + host := "shop.bab.oncarlos.com" |
| + data, locks := t.TempDir(), t.TempDir() |
| + for _, name := range []string{"hostguard", ".parked"} { |
| + if err := os.Mkdir(filepath.Join(data, name), 0770); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + s := release.NewDirStore(t.TempDir()) |
| + at := time.Date(2026, 10, 4, 1, 0, 0, 0, time.UTC) |
| + inc, err := hostclaim.Incarnation("bab", "jam", host, at) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + 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"}, |
| + 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"}}} |
| + if err := c.Validate(); err != nil { |
| + t.Fatal(err) |
| + } |
| + b, err := json.Marshal(c) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + key, err := hostclaim.Key(host) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := s.Put(t.Context(), key, b, ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + g := hostguard.Guard{DataDir: data, LockDir: locks, Claims: hostguard.StoreReader{Store: s}} |
| + if err := g.Activate(t.Context(), host, "box", nil); err != nil { |
| + t.Fatal(err) |
| + } |
| + capture, err := hostguard.Capture(data, host) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return localFixture{l: Local{Authorize: func(context.Context, *hostclaim.Claim, hostclaim.Obligation) error { return nil }, Guard: g, Claims: s, DataDir: data, Box: "box"}, host: host, captured: capture} |
| +} |
| + |
| +func (f localFixture) write(t *testing.T, name, body string) { |
| + t.Helper() |
| + p := filepath.Join(f.l.DataDir, name) |
| + if err := os.MkdirAll(filepath.Dir(p), 0770); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.WriteFile(p, []byte(body), 0660); err != nil { |
| + t.Fatal(err) |
| + } |
| +} |
| + |
| +func (f localFixture) parked(name string) string { |
| + return filepath.Join(f.l.DataDir, ".parked", f.captured.Incarnation, f.host, "retirement", "artifacts", name) |
| +} |
| + |
| +func TestLocalArtifactStampAndInstallationRetirement(t *testing.T) { |
| + for _, mode := range []string{"artifacts", "installation"} { |
| + t.Run(mode, func(t *testing.T) { |
| + f := localFixtureFor(t, map[string]uint64{"old.db": 3, "new.db": 5, "nested/state": 3}) |
| + for _, p := range []string{"old.db", "new.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", "delete", 3) |
| + } else { |
| + err = f.l.RetireInstallation(t.Context(), f.host, f.captured, "local", "delete") |
| + } |
| + 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("parked %s: %q %v", p, b, err) |
| + } |
| + } |
| + got, err := hostguard.Capture(f.l.DataDir, f.host) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if mode == "artifacts" { |
| + if got != f.captured { |
| + t.Fatal("artifact retirement changed stamp") |
| + } |
| + if _, err := os.Stat(filepath.Join(f.l.DataDir, "new.db")); err != nil { |
| + t.Fatal(err) |
| + } |
| + } else if got.Stamp != 0 { |
| + t.Fatal("installation still active") |
| + } |
| + obs, err := hostclaim.Read(t.Context(), f.l.Claims, f.host) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if len(obs.Claim.Obligations) != 0 { |
| + t.Fatal("not acknowledged") |
| + } |
| + want := 0 |
| + if mode == "artifacts" { |
| + want = 1 |
| + } |
| + if len(obs.Claim.Inventory["box"].Paths) != want { |
| + t.Fatal("inventory mismatch") |
| + } |
| + if err := f.l.RetireInstallation(t.Context(), f.host, f.captured, "local", "delete"); err != nil { |
| + t.Fatal("completion retry", err) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +func TestLocalResumeAfterEveryRenameHoldsGuard(t *testing.T) { |
| + for _, stop := range []string{"a", "b"} { |
| + t.Run(stop, func(t *testing.T) { |
| + f := localFixtureFor(t, map[string]uint64{"a": 5, "b": 5}) |
| + f.write(t, "a", "a") |
| + f.write(t, "b", "b") |
| + crash := errors.New("crash after rename") |
| + f.l.afterRename = func(p string) error { |
| + if p != stop { |
| + return nil |
| + } |
| + ctx, cancel := context.WithTimeout(t.Context(), 30*time.Millisecond) |
| + defer cancel() |
| + if err := f.l.Guard.With(ctx, f.host, f.captured, func(*hostclaim.Claim) error { return nil }, func() error { t.Fatal("lock released inside retirement"); return nil }); !errors.Is(err, context.DeadlineExceeded) { |
| + t.Fatalf("competing writer %v", err) |
| + } |
| + return crash |
| + } |
| + if err := f.l.RetireInstallation(t.Context(), f.host, f.captured, "local", "delete"); !errors.Is(err, crash) { |
| + t.Fatal(err) |
| + } |
| + current, err := hostguard.Capture(f.l.DataDir, f.host) |
| + if err != nil || current.Stamp != 0 { |
| + t.Fatalf("not retired before rename: %+v %v", current, err) |
| + } |
| + obs, err := hostclaim.Read(t.Context(), f.l.Claims, f.host) |
| + if err != nil || len(obs.Claim.Obligations) != 1 { |
| + t.Fatal("early completion", err) |
| + } |
| + f.l.afterRename = nil |
| + if err := f.l.RetireInstallation(t.Context(), f.host, f.captured, "local", "delete"); err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, p := range []string{"a", "b"} { |
| + if b, err := os.ReadFile(f.parked(p)); err != nil || string(b) != p { |
| + t.Fatalf("lost %s", p) |
| + } |
| + } |
| + }) |
| + } |
| +} |
| + |
| +func TestLocalRefusesSymlinksAndOverlaps(t *testing.T) { |
| + for _, place := range []string{"source", "ancestor", "park-root", "destination", "overlap", "missing-inventory"} { |
| + t.Run(place, func(t *testing.T) { |
| + paths := map[string]uint64{"sub/a": 5} |
| + if place == "overlap" { |
| + paths["sub"] = 3 |
| + } |
| + f := localFixtureFor(t, paths) |
| + f.write(t, "sub/a", "source") |
| + outside := t.TempDir() |
| + sentinel := filepath.Join(outside, "a") |
| + if err := os.WriteFile(sentinel, []byte("sentinel"), 0600); err != nil { |
| + t.Fatal(err) |
| + } |
| + switch place { |
| + case "source": |
| + if err := os.Remove(filepath.Join(f.l.DataDir, "sub/a")); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.Symlink(sentinel, filepath.Join(f.l.DataDir, "sub/a")); err != nil { |
| + t.Fatal(err) |
| + } |
| + case "ancestor": |
| + if err := os.Rename(filepath.Join(f.l.DataDir, "sub"), filepath.Join(f.l.DataDir, "safe")); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.Symlink(outside, filepath.Join(f.l.DataDir, "sub")); err != nil { |
| + t.Fatal(err) |
| + } |
| + case "park-root": |
| + if err := os.Remove(filepath.Join(f.l.DataDir, ".parked")); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.Symlink(outside, filepath.Join(f.l.DataDir, ".parked")); err != nil { |
| + t.Fatal(err) |
| + } |
| + case "destination": |
| + if err := os.MkdirAll(filepath.Dir(f.parked("sub/a")), 0770); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.Symlink(sentinel, f.parked("sub/a")); err != nil { |
| + t.Fatal(err) |
| + } |
| + case "missing-inventory": |
| + obs, err := hostclaim.Read(t.Context(), f.l.Claims, f.host) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := hostclaim.Update(t.Context(), f.l.Claims, obs, func(c *hostclaim.Claim) error { delete(c.Inventory, "box"); return nil }); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + if err := f.l.RetireInstallation(t.Context(), f.host, f.captured, "local", "delete"); err == nil { |
| + t.Fatal("unsafe retirement accepted") |
| + } |
| + if b, err := os.ReadFile(sentinel); err != nil || string(b) != "sentinel" { |
| + t.Fatal("symlink target changed") |
| + } |
| + obs, err := hostclaim.Read(t.Context(), f.l.Claims, f.host) |
| + if err != nil || len(obs.Claim.Obligations) != 1 { |
| + t.Fatal("acknowledged failure", err) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +func TestLocalEmptyAndAbsentArtifactsAcknowledge(t *testing.T) { |
| + for _, paths := range []map[string]uint64{{}, {"missing/file": 5}} { |
| + f := localFixtureFor(t, paths) |
| + if err := f.l.RetireInstallation(t.Context(), f.host, f.captured, "local", "delete"); err != nil { |
| + t.Fatal(err) |
| + } |
| + obs, err := hostclaim.Read(t.Context(), f.l.Claims, f.host) |
| + if err != nil || len(obs.Claim.Obligations) != 0 { |
| + t.Fatal("not complete", err) |
| + } |
| + } |
| +} |
| + |
| +func TestLocalOldRetirementCannotFenceSuccessor(t *testing.T) { |
| + f := localFixtureFor(t, map[string]uint64{"a": 5}) |
| + f.write(t, "a", "old") |
| + f.l.afterRename = func(string) error { return errors.New("crash") } |
| + if err := f.l.RetireInstallation(t.Context(), f.host, f.captured, "local", "delete"); err == nil { |
| + t.Fatal("expected crash") |
| + } |
| + obs, err := hostclaim.Read(t.Context(), f.l.Claims, f.host) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := hostclaim.Update(t.Context(), f.l.Claims, obs, func(c *hostclaim.Claim) error { |
| + c.Holder.Stamp = c.Gen + 1 |
| + c.Inventory["box"].Paths["a"] = c.Gen + 1 |
| + return nil |
| + }); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := f.l.Guard.Activate(t.Context(), f.host, "box", nil); err != nil { |
| + t.Fatal(err) |
| + } |
| + f.write(t, "a", "successor") |
| + f.l.afterRename = nil |
| + if err := f.l.RetireInstallation(t.Context(), f.host, f.captured, "local", "delete"); !errors.Is(err, hostguard.ErrStampMismatch) { |
| + t.Fatal(err) |
| + } |
| + if b, err := os.ReadFile(filepath.Join(f.l.DataDir, "a")); err != nil || string(b) != "successor" { |
| + t.Fatal("successor damaged") |
| + } |
| +} |
| + |
| +func TestLocalArtifactResumeRefusesReusedSource(t *testing.T) { |
| + f := localFixtureFor(t, map[string]uint64{"a": 3}) |
| + f.write(t, "a", "predecessor") |
| + f.l.afterRename = func(string) error { return errors.New("crash") } |
| + if err := f.l.RetireArtifacts(t.Context(), f.host, f.captured, "local", "delete", 3); err == nil { |
| + t.Fatal("expected crash") |
| + } |
| + f.write(t, "a", "replacement") |
| + f.l.afterRename = nil |
| + if err := f.l.RetireArtifacts(t.Context(), f.host, f.captured, "local", "delete", 3); !errors.Is(err, ErrLocalJournal) { |
| + t.Fatal(err) |
| + } |
| + if b, err := os.ReadFile(filepath.Join(f.l.DataDir, "a")); err != nil || string(b) != "replacement" { |
| + t.Fatal("replacement changed") |
| + } |
| + if b, err := os.ReadFile(f.parked("a")); err != nil || string(b) != "predecessor" { |
| + t.Fatal("archive changed") |
| + } |
| + obs, err := hostclaim.Read(t.Context(), f.l.Claims, f.host) |
| + if err != nil || len(obs.Claim.Obligations) != 1 || len(obs.Claim.Inventory["box"].Paths) != 1 { |
| + t.Fatal("source reuse acknowledged", err) |
| + } |
| +} |
| + |
| +func TestLocalResolvedDirectoryAndDestinationConflict(t *testing.T) { |
| + t.Run("nested directory", func(t *testing.T) { |
| + f := localFixtureFor(t, map[string]uint64{"nested/instance": 5}) |
| + f.write(t, "nested/instance/data/db", "database") |
| + if err := f.l.RetireInstallation(t.Context(), f.host, f.captured, "local", "delete"); err != nil { |
| + t.Fatal(err) |
| + } |
| + if b, err := os.ReadFile(f.parked("nested/instance/data/db")); err != nil || string(b) != "database" { |
| + t.Fatal("nested tree lost", err) |
| + } |
| + }) |
| + t.Run("destination conflict", func(t *testing.T) { |
| + f := localFixtureFor(t, map[string]uint64{"a": 5}) |
| + f.write(t, "a", "source") |
| + if err := os.MkdirAll(filepath.Dir(f.parked("a")), 0770); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.WriteFile(f.parked("a"), []byte("foreign"), 0660); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := f.l.RetireInstallation(t.Context(), f.host, f.captured, "local", "delete"); !errors.Is(err, ErrLocalJournal) { |
| + t.Fatal(err) |
| + } |
| + if b, err := os.ReadFile(f.parked("a")); err != nil || string(b) != "foreign" { |
| + t.Fatal("destination overwritten") |
| + } |
| + if b, err := os.ReadFile(filepath.Join(f.l.DataDir, "a")); err != nil || string(b) != "source" { |
| + t.Fatal("source lost") |
| + } |
| + }) |
| +} |
| + |
| +func TestLocalAuthorizationAndInventoryEnrollment(t *testing.T) { |
| + t.Run("authorization", func(t *testing.T) { |
| + f := localFixtureFor(t, map[string]uint64{"a": 5}) |
| + f.write(t, "a", "data") |
| + denied := errors.New("stop evidence unavailable") |
| + f.l.Authorize = func(context.Context, *hostclaim.Claim, hostclaim.Obligation) error { return denied } |
| + if err := f.l.RetireInstallation(t.Context(), f.host, f.captured, "local", "delete"); !errors.Is(err, denied) { |
| + t.Fatal(err) |
| + } |
| + got, err := hostguard.Capture(f.l.DataDir, f.host) |
| + if err != nil || got != f.captured { |
| + t.Fatal("unauthorized stamp mutation", err) |
| + } |
| + if _, err := os.Stat(filepath.Join(f.l.DataDir, "a")); err != nil { |
| + t.Fatal(err) |
| + } |
| + }) |
| + t.Run("enrollment", func(t *testing.T) { |
| + f := localFixtureFor(t, map[string]uint64{"old": 3}) |
| + allow := func(*hostclaim.Claim) error { return nil } |
| + if err := f.l.RecordInventory(t.Context(), f.host, f.captured, "flat", []string{"new"}, allow); err != nil { |
| + t.Fatal(err) |
| + } |
| + obs, err := hostclaim.Read(t.Context(), f.l.Claims, f.host) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if obs.Claim.Inventory["box"].Paths["old"] != 3 || obs.Claim.Inventory["box"].Paths["new"] != 5 { |
| + t.Fatal("inventory was replaced") |
| + } |
| + if _, err := os.Stat(filepath.Join(f.l.DataDir, "new")); !errors.Is(err, os.ErrNotExist) { |
| + t.Fatal("enrollment materialized state") |
| + } |
| + for _, p := range []string{"old", "old/child", "new/child", "../escape", "hostguard/record", ".parked/file"} { |
| + if err := f.l.RecordInventory(t.Context(), f.host, f.captured, "flat", []string{p}, allow); !errors.Is(err, ErrLocalInventory) { |
| + t.Fatalf("%s: %v", p, err) |
| + } |
| + } |
| + if err := f.l.RecordInventory(t.Context(), f.host, f.captured, "nested", nil, allow); !errors.Is(err, ErrLocalInventory) { |
| + t.Fatal("changed populated layout", err) |
| + } |
| + }) |
| +} |
| + |
| +func TestLocalCompletionRechecksAuthority(t *testing.T) { |
| + f := localFixtureFor(t, map[string]uint64{"a": 5}) |
| + f.write(t, "a", "data") |
| + denied := errors.New("fence lost before acknowledgement") |
| + calls := 0 |
| + f.l.Authorize = func(context.Context, *hostclaim.Claim, hostclaim.Obligation) error { |
| + calls++ |
| + if calls > 1 { |
| + return denied |
| + } |
| + return nil |
| + } |
| + if err := f.l.RetireInstallation(t.Context(), f.host, f.captured, "local", "delete"); !errors.Is(err, denied) { |
| + t.Fatal(err) |
| + } |
| + obs, err := hostclaim.Read(t.Context(), f.l.Claims, f.host) |
| + if err != nil || len(obs.Claim.Obligations) != 1 { |
| + t.Fatal("completed without authority", err) |
| + } |
| + f.l.Authorize = func(context.Context, *hostclaim.Claim, hostclaim.Obligation) error { return nil } |
| + if err := f.l.RetireInstallation(t.Context(), f.host, f.captured, "local", "delete"); err != nil { |
| + t.Fatal(err) |
| + } |
| +} |
| + |
| +func TestLocalUntouchedBoxAcknowledgement(t *testing.T) { |
| + for _, kind := range []string{"absent-record", "active-record", "corrupt-record", "nonempty-inventory"} { |
| + t.Run(kind, func(t *testing.T) { |
| + paths := map[string]uint64{} |
| + if kind == "nonempty-inventory" { |
| + paths["a"] = 5 |
| + } |
| + f := localFixtureFor(t, paths) |
| + record := filepath.Join(f.l.DataDir, "hostguard", f.host+".json") |
| + if kind == "corrupt-record" { |
| + if err := os.WriteFile(record, []byte("{"), 0660); err != nil { |
| + t.Fatal(err) |
| + } |
| + } else if kind != "active-record" { |
| + if err := os.Remove(record); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + err := f.l.AcknowledgeEmpty(t.Context(), f.host, f.captured.Incarnation, "local", "delete") |
| + if kind == "absent-record" { |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + obs, err := hostclaim.Read(t.Context(), f.l.Claims, f.host) |
| + if err != nil || len(obs.Claim.Obligations) != 0 { |
| + t.Fatal("empty box not acknowledged", err) |
| + } |
| + if _, err := os.Stat(record); !errors.Is(err, os.ErrNotExist) { |
| + t.Fatal("empty retirement created stamp", err) |
| + } |
| + } else if err == nil { |
| + t.Fatal("unsafe empty acknowledgement") |
| + } |
| + }) |
| + } |
| +} |
| +``` |
| + |
| +- [ ] **Step 2: Run the red tests on Linux** |
| + |
| +```bash |
| +go test ./internal/park -run '^TestLocal' -count=1 |
| +``` |
| + |
| +Expected: undefined `Local` and its retirement methods. These tests create and own every fixture file, including symlinks; they never touch the deployment's DataDir or guard directory. |
| + |
| +- [ ] **Step 3: Add the guard callback forms** |
| + |
| +Write `internal/hostguard/retirement.go` exactly as follows: |
| + |
| +```go |
| +package hostguard |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "github.com/carlosframework/platform/internal/hostclaim" |
| +) |
| + |
| +func (g Guard) RetireInstallationWith(ctx context.Context, host string, captured Captured, check func(*hostclaim.Claim) error, fn func() error) error { |
| + if captured.Stamp == 0 || check == nil || fn == nil { |
| + return ErrInvalidInput |
| + } |
| + return g.locked(ctx, host, func(d *recordDir) error { |
| + c, err := g.fresh(ctx, host) |
| + if err != nil { |
| + return err |
| + } |
| + if c.Holder.Incarnation != captured.Incarnation { |
| + return ErrClaimIncarnation |
| + } |
| + if err := check(c); err != nil { |
| + return err |
| + } |
| + r, err := d.read(host) |
| + if err != nil { |
| + return err |
| + } |
| + if r.Incarnation != captured.Incarnation { |
| + return ErrRecordIncarnation |
| + } |
| + if r.ActiveStamp != 0 { |
| + if r.ActiveStamp != captured.Stamp { |
| + return ErrStampMismatch |
| + } |
| + r.retire() |
| + if err := ctx.Err(); err != nil { |
| + return err |
| + } |
| + if err := d.write(r); err != nil { |
| + return err |
| + } |
| + } else { |
| + n := len(r.RetiredStamps) |
| + if n == 0 || r.RetiredStamps[n-1] != captured.Stamp { |
| + return ErrStampMismatch |
| + } |
| + } |
| + if err := ctx.Err(); err != nil { |
| + return err |
| + } |
| + return fn() |
| + }) |
| +} |
| + |
| +func (g Guard) WithNoInstallation(ctx context.Context, host, incarnation string, check func(*hostclaim.Claim) error, fn func() error) error { |
| + if incarnation == "" || check == nil || fn == nil { |
| + return ErrInvalidInput |
| + } |
| + return g.locked(ctx, host, func(d *recordDir) error { |
| + c, err := g.fresh(ctx, host) |
| + if err != nil { |
| + return err |
| + } |
| + if c.Holder.Incarnation != incarnation { |
| + return ErrClaimIncarnation |
| + } |
| + if err := check(c); err != nil { |
| + return err |
| + } |
| + _, err = d.read(host) |
| + if !errors.Is(err, ErrNoRecord) { |
| + if err != nil { |
| + return err |
| + } |
| + return ErrStampMismatch |
| + } |
| + if err := ctx.Err(); err != nil { |
| + return err |
| + } |
| + return fn() |
| + }) |
| +} |
| +``` |
| + |
| +This is the explicit composition missing from a sequence of existing public calls: the record write makes the captured stamp inactive, then the callback runs without releasing the lock. Calling the old `RetireInstallation` followed by `With` would both open a race and refuse the inactive stamp. Never nest a second guard acquisition inside either callback. |
| + |
| +- [ ] **Step 4: Write the inventory and retirement coordinator** |
| + |
| +Write `internal/park/local.go` exactly as follows: |
| + |
| +```go |
| +package park |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "path" |
| + "sort" |
| + "strings" |
| + |
| + "github.com/carlosframework/platform/internal/hostclaim" |
| + "github.com/carlosframework/platform/internal/hostguard" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +var ( |
| + ErrLocalIdentity = errors.New("park: local retirement identity mismatch") |
| + ErrLocalInventory = errors.New("park: missing or unsafe inventory") |
| + ErrLocalJournal = errors.New("park: invalid or conflicting local journal") |
| + ErrLocalUnsupported = errors.New("park: local retirement unsupported") |
| + errLocalComplete = errors.New("park: local obligation already completed") |
| +) |
| + |
| +type Local struct { |
| + Authorize func(context.Context, *hostclaim.Claim, hostclaim.Obligation) error |
| + Guard hostguard.Guard |
| + Claims release.Store |
| + DataDir, Box string |
| + afterRename func(string) error |
| +} |
| + |
| +type localItem struct { |
| + Path string |
| + Stamp uint64 |
| + Dev, Ino uint64 |
| + Type uint32 |
| + Missing, Done bool |
| +} |
| + |
| +type localJournal struct { |
| + V int |
| + Host, Incarnation, Op, Obligation, Retirement, Mode string |
| + Installation, ArtifactStamp uint64 |
| + Items []localItem |
| +} |
| + |
| +func localComponent(s string) bool { |
| + return s != "" && s != "." && s != ".." && !strings.ContainsAny(s, "/\\\x00") |
| +} |
| + |
| +func localPath(s string) bool { |
| + if s == "" || path.IsAbs(s) || path.Clean(s) != s || strings.ContainsAny(s, "\\\x00") { |
| + return false |
| + } |
| + for _, p := range strings.Split(s, "/") { |
| + if !localComponent(p) { |
| + return false |
| + } |
| + } |
| + return s != "hostguard" && !strings.HasPrefix(s, "hostguard/") && s != ".parked" && !strings.HasPrefix(s, ".parked/") |
| +} |
| + |
| +func (l Local) chore(c *hostclaim.Claim, captured hostguard.Captured, id, op string) (hostclaim.Obligation, error) { |
| + if c == nil { |
| + return hostclaim.Obligation{}, ErrLocalIdentity |
| + } |
| + var found *hostclaim.Obligation |
| + for i := range c.Obligations { |
| + if c.Obligations[i].ID == id && c.Obligations[i].Op == op { |
| + found = &c.Obligations[i] |
| + break |
| + } |
| + } |
| + if found == nil { |
| + return hostclaim.Obligation{}, errLocalComplete |
| + } |
| + if c.Holder == nil || c.Holder.Incarnation != captured.Incarnation || found.Kind != "park-local" || found.Box != l.Box || found.Owner != "box:"+l.Box || !localComponent(found.Retirement) { |
| + return hostclaim.Obligation{}, ErrLocalIdentity |
| + } |
| + if _, ok := c.Inventory[l.Box]; !ok { |
| + return hostclaim.Obligation{}, ErrLocalInventory |
| + } |
| + return *found, nil |
| +} |
| + |
| +func (l Local) RetireArtifacts(ctx context.Context, host string, captured hostguard.Captured, id, op string, stamp uint64) error { |
| + if stamp == 0 { |
| + return ErrLocalIdentity |
| + } |
| + return l.retire(ctx, host, captured, id, op, "artifacts", stamp) |
| +} |
| + |
| +func (l Local) RetireInstallation(ctx context.Context, host string, captured hostguard.Captured, id, op string) error { |
| + return l.retire(ctx, host, captured, id, op, "installation", 0) |
| +} |
| + |
| +func (l Local) retire(ctx context.Context, host string, captured hostguard.Captured, id, op, mode string, stamp uint64) error { |
| + if !localSupported() { |
| + return ErrLocalUnsupported |
| + } |
| + if ctx == nil || l.Authorize == nil || l.Claims == nil || l.Box == "" || l.DataDir == "" || l.Guard.DataDir != l.DataDir || (captured.Stamp == 0 && mode != "empty") || id == "" || op == "" { |
| + return ErrLocalIdentity |
| + } |
| + obs, err := hostclaim.Read(ctx, l.Claims, host) |
| + if err != nil { |
| + return err |
| + } |
| + if obs.Absent { |
| + return ErrLocalIdentity |
| + } |
| + if _, err := l.chore(obs.Claim, captured, id, op); err != nil { |
| + if errors.Is(err, errLocalComplete) { |
| + return nil |
| + } |
| + return err |
| + } |
| + var j localJournal |
| + var inventory map[string]uint64 |
| + check := func(c *hostclaim.Claim) error { |
| + o, err := l.chore(c, captured, id, op) |
| + if err != nil { |
| + return err |
| + } |
| + if err := l.Authorize(ctx, c, o); err != nil { |
| + return err |
| + } |
| + inventory = c.Inventory[l.Box].Paths |
| + if mode == "empty" && len(inventory) != 0 { |
| + return ErrLocalInventory |
| + } |
| + paths := make([]string, 0, len(inventory)) |
| + for p, s := range inventory { |
| + if !localPath(p) || s == 0 { |
| + return ErrLocalInventory |
| + } |
| + paths = append(paths, p) |
| + } |
| + sort.Strings(paths) |
| + for i := range paths { |
| + for k := i + 1; k < len(paths); k++ { |
| + if strings.HasPrefix(paths[k], paths[i]+"/") { |
| + return ErrLocalInventory |
| + } |
| + } |
| + } |
| + j = localJournal{V: 1, Host: host, Incarnation: captured.Incarnation, Op: op, Obligation: id, Retirement: o.Retirement, Mode: mode, Installation: captured.Stamp, ArtifactStamp: stamp} |
| + for _, p := range paths { |
| + if mode == "installation" || inventory[p] == stamp { |
| + j.Items = append(j.Items, localItem{Path: p, Stamp: inventory[p]}) |
| + } |
| + } |
| + return nil |
| + } |
| + work := func() error { |
| + if err := l.parkLocal(ctx, &j); err != nil { |
| + return err |
| + } |
| + fresh, err := hostclaim.Read(ctx, l.Claims, host) |
| + if err != nil { |
| + return err |
| + } |
| + if fresh.Absent { |
| + return ErrLocalIdentity |
| + } |
| + _, err = hostclaim.Update(ctx, l.Claims, fresh, func(c *hostclaim.Claim) error { |
| + o, err := l.chore(c, captured, id, op) |
| + if err != nil { |
| + return err |
| + } |
| + if err := l.Authorize(ctx, c, o); err != nil { |
| + return err |
| + } |
| + entry := c.Inventory[l.Box] |
| + for _, item := range j.Items { |
| + if entry.Paths[item.Path] != item.Stamp { |
| + return ErrLocalInventory |
| + } |
| + } |
| + if (mode == "installation" || mode == "empty") && len(entry.Paths) != len(j.Items) { |
| + return ErrLocalInventory |
| + } |
| + for _, item := range j.Items { |
| + delete(entry.Paths, item.Path) |
| + } |
| + c.Inventory[l.Box] = entry |
| + return hostclaim.CompleteObligation(op, id)(c) |
| + }) |
| + return err |
| + } |
| + if mode == "installation" { |
| + err = l.Guard.RetireInstallationWith(ctx, host, captured, check, work) |
| + } else if mode == "empty" { |
| + err = l.Guard.WithNoInstallation(ctx, host, captured.Incarnation, check, work) |
| + } else { |
| + err = l.Guard.With(ctx, host, captured, check, work) |
| + } |
| + if errors.Is(err, errLocalComplete) { |
| + return nil |
| + } |
| + return err |
| +} |
| + |
| +func (l Local) RecordInventory(ctx context.Context, host string, captured hostguard.Captured, layout string, paths []string, check func(*hostclaim.Claim) error) error { |
| + if !localSupported() { |
| + return ErrLocalUnsupported |
| + } |
| + if ctx == nil || l.Claims == nil || l.Box == "" || l.Guard.DataDir != l.DataDir || l.DataDir == "" || check == nil || captured.Stamp == 0 || (layout != "flat" && layout != "nested") { |
| + return ErrLocalInventory |
| + } |
| + for _, p := range paths { |
| + if !localPath(p) { |
| + return ErrLocalInventory |
| + } |
| + } |
| + return l.Guard.With(ctx, host, captured, check, func() error { |
| + obs, err := hostclaim.Read(ctx, l.Claims, host) |
| + if err != nil { |
| + return err |
| + } |
| + if obs.Absent { |
| + return ErrLocalIdentity |
| + } |
| + _, err = hostclaim.Update(ctx, l.Claims, obs, func(c *hostclaim.Claim) error { |
| + if c.Holder == nil || c.Holder.Incarnation != captured.Incarnation { |
| + return ErrLocalIdentity |
| + } |
| + if err := check(c); err != nil { |
| + return err |
| + } |
| + if c.Inventory == nil { |
| + c.Inventory = make(map[string]hostclaim.InventoryEntry) |
| + } |
| + entry, exists := c.Inventory[l.Box] |
| + if exists && entry.Layout != layout && len(entry.Paths) != 0 { |
| + return ErrLocalInventory |
| + } |
| + entry.Layout = layout |
| + if entry.Paths == nil { |
| + entry.Paths = make(map[string]uint64) |
| + } |
| + for _, p := range paths { |
| + if old := entry.Paths[p]; old != 0 && old != captured.Stamp { |
| + return ErrLocalInventory |
| + } |
| + entry.Paths[p] = captured.Stamp |
| + } |
| + all := make([]string, 0, len(entry.Paths)) |
| + for p, s := range entry.Paths { |
| + if !localPath(p) || s == 0 { |
| + return ErrLocalInventory |
| + } |
| + all = append(all, p) |
| + } |
| + sort.Strings(all) |
| + for i := range all { |
| + for k := i + 1; k < len(all); k++ { |
| + if strings.HasPrefix(all[k], all[i]+"/") { |
| + return ErrLocalInventory |
| + } |
| + } |
| + } |
| + c.Inventory[l.Box] = entry |
| + return nil |
| + }) |
| + return err |
| + }) |
| +} |
| + |
| +func (l Local) AcknowledgeEmpty(ctx context.Context, host, incarnation, id, op string) error { |
| + return l.retire(ctx, host, hostguard.Captured{Incarnation: incarnation}, id, op, "empty", 0) |
| +} |
| +``` |
| + |
| +- [ ] **Step 5: Write the Linux descriptor-relative engine** |
| + |
| +Write `internal/park/local_linux.go` exactly as follows: |
| + |
| +```go |
| +//go:build linux |
| + |
| +package park |
| + |
| +import ( |
| + "bytes" |
| + "context" |
| + "crypto/rand" |
| + "encoding/hex" |
| + "encoding/json" |
| + "errors" |
| + "io" |
| + "os" |
| + "reflect" |
| + "strings" |
| + |
| + "golang.org/x/sys/unix" |
| +) |
| + |
| +func localSupported() bool { return true } |
| + |
| +func localDirAt(parent int, name string, create bool) (int, error) { |
| + if !localComponent(name) { |
| + return -1, ErrLocalInventory |
| + } |
| + if create { |
| + if err := unix.Mkdirat(parent, name, 0770); err != nil && !errors.Is(err, unix.EEXIST) { |
| + return -1, err |
| + } |
| + if err := unix.Fsync(parent); err != nil { |
| + return -1, err |
| + } |
| + } |
| + fd, err := unix.Openat(parent, name, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_NOFOLLOW|unix.O_CLOEXEC, 0) |
| + if err != nil { |
| + return -1, err |
| + } |
| + var st unix.Stat_t |
| + if err := unix.Fstat(fd, &st); err != nil || st.Mode&unix.S_IFMT != unix.S_IFDIR { |
| + unix.Close(fd) |
| + if err != nil { |
| + return -1, err |
| + } |
| + return -1, ErrLocalInventory |
| + } |
| + return fd, nil |
| +} |
| + |
| +func localWalk(root int, parts []string, create bool) (int, error) { |
| + fd, err := unix.Dup(root) |
| + if err != nil { |
| + return -1, err |
| + } |
| + unix.CloseOnExec(fd) |
| + for _, p := range parts { |
| + next, err := localDirAt(fd, p, create) |
| + unix.Close(fd) |
| + if err != nil { |
| + return -1, err |
| + } |
| + fd = next |
| + } |
| + return fd, nil |
| +} |
| + |
| +func localParent(root int, p string, create bool) (int, string, error) { |
| + if !localPath(p) { |
| + return -1, "", ErrLocalInventory |
| + } |
| + parts := strings.Split(p, "/") |
| + fd, err := localWalk(root, parts[:len(parts)-1], create) |
| + return fd, parts[len(parts)-1], err |
| +} |
| + |
| +func localStat(fd int, name string) (unix.Stat_t, error) { |
| + var st unix.Stat_t |
| + err := unix.Fstatat(fd, name, &st, unix.AT_SYMLINK_NOFOLLOW) |
| + if err == nil && st.Mode&unix.S_IFMT != unix.S_IFDIR && st.Mode&unix.S_IFMT != unix.S_IFREG { |
| + return st, ErrLocalInventory |
| + } |
| + return st, err |
| +} |
| + |
| +func localSame(st unix.Stat_t, item localItem) bool { |
| + return uint64(st.Dev) == item.Dev && st.Ino == item.Ino && st.Mode&unix.S_IFMT == item.Type |
| +} |
| + |
| +func localReadJournal(dir int) (*localJournal, error) { |
| + fd, err := unix.Openat(dir, ".journal.json", unix.O_RDONLY|unix.O_NOFOLLOW|unix.O_CLOEXEC|unix.O_NONBLOCK, 0) |
| + if err != nil { |
| + return nil, err |
| + } |
| + f := os.NewFile(uintptr(fd), "journal") |
| + defer f.Close() |
| + st, err := f.Stat() |
| + if err != nil { |
| + return nil, err |
| + } |
| + if !st.Mode().IsRegular() { |
| + return nil, ErrLocalJournal |
| + } |
| + b, err := io.ReadAll(io.LimitReader(f, (4<<20)+1)) |
| + if err != nil { |
| + return nil, err |
| + } |
| + if len(b) > 4<<20 { |
| + return nil, ErrLocalJournal |
| + } |
| + d := json.NewDecoder(bytes.NewReader(b)) |
| + d.DisallowUnknownFields() |
| + var j localJournal |
| + if err := d.Decode(&j); err != nil { |
| + return nil, err |
| + } |
| + if _, err := d.Token(); err != io.EOF { |
| + return nil, ErrLocalJournal |
| + } |
| + return &j, nil |
| +} |
| + |
| +func localWriteJournal(dir int, j *localJournal) error { |
| + b, err := json.Marshal(j) |
| + if err != nil { |
| + return err |
| + } |
| + var nonce [16]byte |
| + if _, err := rand.Read(nonce[:]); err != nil { |
| + return err |
| + } |
| + name := ".journal-" + hex.EncodeToString(nonce[:]) |
| + fd, err := unix.Openat(dir, name, unix.O_WRONLY|unix.O_CREAT|unix.O_EXCL|unix.O_NOFOLLOW|unix.O_CLOEXEC, 0660) |
| + if err != nil { |
| + return err |
| + } |
| + f := os.NewFile(uintptr(fd), "journal-temp") |
| + defer f.Close() |
| + defer unix.Unlinkat(dir, name, 0) |
| + if err := unix.Fchmod(fd, 0660); err != nil { |
| + return err |
| + } |
| + if _, err := f.Write(b); err != nil { |
| + return err |
| + } |
| + if err := f.Sync(); err != nil { |
| + return err |
| + } |
| + if err := unix.Renameat(dir, name, dir, ".journal.json"); err != nil { |
| + return err |
| + } |
| + return unix.Fsync(dir) |
| +} |
| + |
| +func localJournalMatches(a, b localJournal) bool { |
| + if len(a.Items) != len(b.Items) { |
| + return false |
| + } |
| + for i := range a.Items { |
| + if a.Items[i].Path != b.Items[i].Path || a.Items[i].Stamp != b.Items[i].Stamp { |
| + return false |
| + } |
| + if a.Items[i].Missing { |
| + if a.Items[i].Dev != 0 || a.Items[i].Ino != 0 || a.Items[i].Type != 0 { |
| + return false |
| + } |
| + } else if a.Items[i].Ino == 0 || (a.Items[i].Type != unix.S_IFDIR && a.Items[i].Type != unix.S_IFREG) { |
| + return false |
| + } |
| + } |
| + a.Items, b.Items = nil, nil |
| + return reflect.DeepEqual(a, b) |
| +} |
| + |
| +func (l Local) parkLocal(ctx context.Context, want *localJournal) error { |
| + root, err := unix.Open(l.DataDir, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_NOFOLLOW|unix.O_CLOEXEC, 0) |
| + if err != nil { |
| + return err |
| + } |
| + defer unix.Close(root) |
| + parked, err := localDirAt(root, ".parked", false) |
| + if err != nil { |
| + return err |
| + } |
| + defer unix.Close(parked) |
| + event, err := localWalk(parked, []string{want.Incarnation, want.Host, want.Retirement}, true) |
| + if err != nil { |
| + return err |
| + } |
| + defer unix.Close(event) |
| + dest, err := localDirAt(event, "artifacts", true) |
| + if err != nil { |
| + return err |
| + } |
| + defer unix.Close(dest) |
| + prior, err := localReadJournal(event) |
| + if errors.Is(err, unix.ENOENT) { |
| + for i := range want.Items { |
| + it := &want.Items[i] |
| + src, name, e := localParent(root, it.Path, false) |
| + if errors.Is(e, unix.ENOENT) { |
| + it.Missing = true |
| + continue |
| + } |
| + if e != nil { |
| + return e |
| + } |
| + st, e := localStat(src, name) |
| + unix.Close(src) |
| + if errors.Is(e, unix.ENOENT) { |
| + it.Missing = true |
| + continue |
| + } |
| + if e != nil { |
| + return e |
| + } |
| + it.Dev, it.Ino, it.Type = uint64(st.Dev), st.Ino, st.Mode&unix.S_IFMT |
| + dp, dn, e := localParent(dest, it.Path, true) |
| + if e != nil { |
| + return e |
| + } |
| + _, e = localStat(dp, dn) |
| + unix.Close(dp) |
| + if !errors.Is(e, unix.ENOENT) { |
| + if e != nil { |
| + return e |
| + } |
| + return ErrLocalJournal |
| + } |
| + } |
| + if err := localWriteJournal(event, want); err != nil { |
| + return err |
| + } |
| + } else if err != nil { |
| + return err |
| + } else { |
| + if !localJournalMatches(*prior, *want) { |
| + return ErrLocalJournal |
| + } |
| + *want = *prior |
| + } |
| + for i := range want.Items { |
| + if err := ctx.Err(); err != nil { |
| + return err |
| + } |
| + it := &want.Items[i] |
| + if it.Missing { |
| + it.Done = true |
| + continue |
| + } |
| + dp, dn, err := localParent(dest, it.Path, true) |
| + if err != nil { |
| + return err |
| + } |
| + sp, sn, err := localParent(root, it.Path, false) |
| + if err != nil { |
| + unix.Close(dp) |
| + return err |
| + } |
| + err = l.moveLocalItem(sp, sn, dp, dn, it) |
| + if err == nil { |
| + err = unix.Fsync(sp) |
| + } |
| + if err == nil { |
| + err = unix.Fsync(dp) |
| + } |
| + unix.Close(sp) |
| + unix.Close(dp) |
| + if err != nil { |
| + return err |
| + } |
| + it.Done = true |
| + if err := localWriteJournal(event, want); err != nil { |
| + return err |
| + } |
| + } |
| + if err := localWriteJournal(event, want); err != nil { |
| + return err |
| + } |
| + if err := unix.Fsync(dest); err != nil { |
| + return err |
| + } |
| + return unix.Fsync(event) |
| +} |
| + |
| +func (l Local) moveLocalItem(sp int, sn string, dp int, dn string, it *localItem) error { |
| + dst, de := localStat(dp, dn) |
| + if de == nil { |
| + if !localSame(dst, *it) { |
| + return ErrLocalJournal |
| + } |
| + _, se := localStat(sp, sn) |
| + if se == nil { |
| + return ErrLocalJournal |
| + } |
| + if se != nil && !errors.Is(se, unix.ENOENT) { |
| + return se |
| + } |
| + return nil |
| + } |
| + if !errors.Is(de, unix.ENOENT) { |
| + return de |
| + } |
| + if it.Done { |
| + return ErrLocalJournal |
| + } |
| + src, err := localStat(sp, sn) |
| + if err != nil { |
| + return err |
| + } |
| + if !localSame(src, *it) { |
| + return ErrLocalJournal |
| + } |
| + if err := unix.Renameat2(sp, sn, dp, dn, unix.RENAME_NOREPLACE); err != nil { |
| + return err |
| + } |
| + if l.afterRename != nil { |
| + return l.afterRename(it.Path) |
| + } |
| + return nil |
| +} |
| +``` |
| + |
| +The first journal records each artifact's device, inode and type before any rename. A resumed journal must match operation, obligation, retirement, mode, original captured installation, selected artifact stamp and inventory exactly. An existing destination must match that journaled identity and the source must be absent; a reused source refuses, even if its inventory stamp was not changed. This favors refusal over moving a possible successor. `Missing` artifacts were authoritatively absent under the guard at journal creation and have no object to rename. |
| + |
| +- [ ] **Step 6: Write the portable refusal stub** |
| + |
| +Write `internal/park/local_other.go` exactly as follows: |
| + |
| +```go |
| +//go:build !linux |
| + |
| +package park |
| + |
| +import "context" |
| + |
| +func (l Local) parkLocal(context.Context, *localJournal) error { return ErrLocalUnsupported } |
| + |
| +func localSupported() bool { return false } |
| +``` |
| + |
| +- [ ] **Step 7: Run local, guard and cross-platform checks** |
| + |
| +```bash |
| +gofmt -w internal/park internal/hostguard/retirement.go |
| +go test -race ./internal/park ./internal/hostguard ./internal/hostclaim -count=1 |
| +go vet ./internal/park ./internal/hostguard ./internal/hostclaim |
| +GOOS=darwin GOARCH=amd64 go build ./internal/park ./internal/hostguard |
| +GOOS=windows GOARCH=amd64 go build ./internal/park ./internal/hostguard |
| +GOOS=linux GOARCH=arm64 go build ./internal/park ./internal/hostguard |
| +``` |
| + |
| +Expected: tests, vet and builds pass. Local unsupported-platform calls refuse before mutating installation state; the remote code still compiles there. Linux fixtures cover both layouts, empty/absent artifacts, no-record acknowledgements, two-rename crash recovery, stamp isolation, a successor installation, authorization rechecks, inventory enrollment, source/ancestor/destination symlinks, overlapping paths, corrupt records and conflicting destinations. |
| + |
| +--- |
| + |
| +### Task 4: Review dependency contracts and run the repository gate |
| + |
| +**Files:** no new files; fix defects in the preceding tasks only. |
| + |
| +**Interfaces:** validate the complete claim/lease/guard boundaries before any caller is introduced. |
| + |
| +- [ ] **Step 1: Check the branch's production call surface** |
| + |
| +```bash |
| +rg -n 'internal/park|RetireInstallationWith|WithNoInstallation' --glob '*.go' . |
| +rg -n 'DeleteVersion|CopyVersion|PutIfAbsent|CompleteObligation|QuietAfter|Renameat2|Fsync' internal/park |
| +git diff --check |
| +git diff --stat |
| +``` |
| + |
| +Expected: no production package imports `internal/park`, no new environment-variable read, and no bare-key remote delete. All local artifact moves are descriptor-relative and no-replace. The only destructive remote calls name exact source versions. Journal temporary cleanup is the only local unlink. |
| + |
| +- [ ] **Step 2: Run the full repository gate** |
| + |
| +```bash |
| +go build ./... |
| +go vet ./... |
| +go test ./... -count=1 |
| +gofmt -l . |
| +GOOS=darwin go build ./... |
| +GOOS=windows go build ./... |
| +GOOS=linux GOARCH=arm64 go build ./... |
| +git diff --check |
| +``` |
| + |
| +Expected: build/vet/whitespace checks are silent, every test package passes, and `gofmt -l .` prints nothing. Use the existing shared Go cache and on-disk scratch; never `/tmp` caches. Tests against `leasetest.NewS3Server` belong to the existing Plan 2 contract suite and remain part of this full gate. |
| + |
| +- [ ] **Step 3: Obtain the repository's authority review** |
| + |
| +Review the complete branch with Opus, or explicitly name a supported equivalent if unavailable. Focus on authentic stop/fence evidence, aggregate leases, exact-version manifest contents, regional live-set input, empty-box acknowledgement, original-stamp recovery and the narrow holderless sweep state. A compilation result does not validate a production adapter; those adapters must be reviewed with their Plan 5/6 callers. |
| + |
| +- [ ] **Step 4: Describe and land the reviewed implementation branch through amadan** |
| + |
| +Follow AGENTS.md: push `feat/hostclaim-park` to the remote pointing at amadan.net, describe the final change and validation, squash implementation iterations to one change commit, push that tip, wait for green CI on that exact tip, then use `amadan branch merge -expect <tip>`. Read main's CI afterward. Nothing in this planning task commits, stages, pushes, merges or changes infrastructure. |
| + |
| +## Rollout note |
| + |
| +This branch is an unused library. Plans 5 and 6 own console/box callbacks, fleet inventory input, authenticated evidence recovery, `.parked` provisioning and scheduler retries. Plan 5 still queues ordinary region/box chores through `ToParking` and `WriteSet`, including every region and box when `Touched.Complete` is false, then frees only after all chores acknowledge. Plan 6 registers a box's inventory before first materialization, including an explicit empty entry for an untouched box; it cannot convert missing inventory into an empty acknowledgement. |
| + |
| +Before those callers can be enabled, implement and test their aggregate renewable lease adapters, genuine evidence-time/request-bound validation, per-region bucket binding, moves-owned marker/live-set adapter and durable original local capture. For legacy residue with unknown archive identity, obtain authoritative provenance rather than guessing an incarnation. A malformed or unavailable input refuses work. No adapter may silently substitute an empty live set or allow callback after a read error. |
| + |
| +Plans 5–7 remain behind `CARLOS_HOST_CLAIM` until Plan 8 publishes the shared floor. This plan issues no new IAM grants and deploys no resource. Plan 2 already supplies own-region version list/read/delete primitives and grants; N13 keeps staging cleanup in its destination region. |
| + |
| +## Self-review |
| + |
| +| Requirement | Evidence | |
| +|---|---| |
| +| R1 library, no production caller | File Structure; Task 4 call-surface check; rollout note | |
| +| R2 complete sets and exact versions | `Engine.list`, `Manifest.Archive/Delete`, crash table and marker version | |
| +| R2 create-only destinations/manifests | `CopyVersion(..., true)`, `ErrDestinationExists` recovery, `PutIfAbsent`, existing-manifest validation | |
| +| R2 resume existing manifest at deletes | `TestManifestRetryDoesNotCopy`; crashes after each delete | |
| +| R2 chore's own op id | `Work`, `find`, scope binding and `CompleteObligation(w.Op,w.ID)` | |
| +| R3 H and strict later-than listing | `SignatureAge`, persisted `RequestLimit/EvidenceAt`, pre-list clock capture and horizon tests | |
| +| R3 atomic monotone quiet_after | final `Update`; quiet-after preservation and CAS-conflict tests | |
| +| R4 pure decision plus executor | `Decide`, `Discover`, `Sweep`, shared `Run` | |
| +| R4 namespace discovery, not capped history | both namespaces, 64-hex partition, legacy/generation child tests | |
| +| R4 live set/unfinished run/alerts | regional `View`, `protected`, pins and both marker versions, disagreement tests | |
| +| R4 stopped authoritative retirement | dedicated regression distinguishes parking from serving protection | |
| +| R5 inventory before materialization | `RecordInventory`, tagged disjoint roots, relabel/layout refusals | |
| +| R5 two retirements | active-stamp-preserving artifacts mode; fenced installation callback and original-stamp retry | |
| +| R5 journal/fsync/no-replace | local identity journal, `Renameat2(RENAME_NOREPLACE)`, source/destination/journal directory fsync | |
| +| R5 R9 descriptor-relative boundary | no-follow directory/file opens; no pathname chmod/chown; symlink fixtures | |
| +| R5 empty acknowledgement | known empty inventory; `WithNoInstallation` distinguishes absent from corrupt/present | |
| +| R6 fault/crash tests | VersionedBucket failure table, PageSize 1, page refusal, every durable boundary, horizon late PUT | |
| +| R7 branch ancestry | Task 1 exact branch-from-boxguard then merge-origin/main commands | |
| +| R8 cache and workstation platforms | shared on-disk cache; local build tags and refusal stub; cross-build commands | |
| +| C3/C5 ownership and leases | mandatory fresh Authorize, aggregate lease Check, conditional post-CAS release | |
| +| C3a unbounded completion | isolated namespaces plus repeated at-rest retirement; H is not an absolute completion proof | |
| +| C6 read errors never act | strict claim/manifest reads, whole listings, record/journal refusals | |
| +| §5 touched and free | existing `WriteSet`/`ToParking` unchanged, durable chores block free; rollout caller contracts | |
| +| §13 S3 facts | production VersionedStore interface plus its existing fake/wire contract; no bare-key primitive | |
| + |
| +Plan-author verification: all **10 complete Go blocks** were extracted from this Markdown, and its exact claim-edit script was executed against the real Plan 1 source in on-disk scratch. The resulting claim file matched the compiled version byte-for-byte after gofmt. An overlay combined those files with `origin/main` storage code, the real `feat/hostclaim-library` package and the present Plan 3 guard implementation, without creating implementation files in this repository. The extracted park test binary compiled with `-race` and all its remote, sweep and local tests passed. The complete hostclaim test suite also passed with the additive scope changes. Tests-only overlays failed with the expected missing scope and engine symbols before implementation. |
| + |
| +The extracted park, hostguard and hostclaim packages built successfully for Darwin/amd64, Windows/amd64 and Linux/arm64; Linux code compiled and ran through the race test binary. Local retirement and guard code additionally passed their independent test/vet checks in the reviewer's scratch workspace. The default Go cache was not writable in this sandbox, so verification used the already-existing shared on-disk `$TMPDIR/gocache`. Test compilation used `-vet=off` for the virtual package directories and then ran the resulting binary directly; that is not a claim that the full repository vet/test gate passed. Task 4's full implementation-branch gate remains required. |
| + |
| +A separate **gpt-6-astra** authority review, used because Opus was unavailable, checked the code and final plan. Its findings about stopped authoritative replicas, foreign `@root` chores, all marker-key versions, aggregate lease ownership, authorization rechecks and empty boxes were addressed and rechecked. The final review reported no blockers. Production lease/evidence/moves adapters, caller wiring, provisioning and activation remain explicitly with the later plans. This planning session changed only this Markdown file and staged or committed nothing. |
| + |