Merge origin/main into settings-4-root-applies
Conflict: internal/host/movesfinal_test.go (newFinalHarness keeps main's p1Option opts and this branch's newFinalHarnessFrom split).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
37 files changed,
+1597
−114
internal/console/apiinstancemove.go+26 −5internal/console/apiinstancemove_claim_test.go+75 −0internal/console/movesrecovery.go+3 −2internal/host/movesabort.go+2 −1internal/host/movescut.go+2 −1internal/host/movescut_test.go+2 −2internal/host/movesfinal.go+3 −3internal/host/movesfinal_test.go+2 −2internal/host/movesfloor_linux.go+154 −16internal/host/movesfloor_other.go+2 −0internal/host/movesheldlock.go+23 −4internal/host/movesheldlock_cleanup_test.go+391 −0internal/host/moveshome_region_test.go+46 −0internal/host/moveshome_test.go+227 −0internal/host/movesinstall.go+2 −2internal/host/movesland_probe_test.go+5 −5internal/host/movesland_test.go+2 −2internal/host/moveslandcommit.go+2 −1internal/host/moveslandpark.go+1 −1internal/host/movesparkfakes_test.go+2 −2internal/host/movesparks_test.go+2 −2internal/host/movespass.go+19 −1internal/host/movesphase1_harness_test.go+23 −7internal/host/movespolicy.go+2 −1internal/host/movesprobeowners.go+10 −0internal/host/movesrecover.go+1 −1internal/host/movesrevert.go+9 −7internal/host/movesrollback.go+5 −5internal/host/movessources.go+84 −25internal/host/movessources_test.go+29 −11internal/hostops/move_fence_release.go+6 −2internal/hostops/move_recovery_commit_test.go+53 −0internal/hostops/move_recovery_grant.go+5 −1internal/hostops/plan12_fence_home_test.go+75 −0internal/park/home_region_test.go+262 −0internal/park/region.go+38 −0internal/provision/plan9_b8_census_test.go+2 −2
diff --git a/internal/console/apiinstancemove.go b/internal/console/apiinstancemove.go| index 9f44c8790..3f6a0af3b 100644 |
| --- a/internal/console/apiinstancemove.go |
| +++ b/internal/console/apiinstancemove.go |
| @@ -250,7 +250,15 @@ func stagingCodes(caps map[string]placementCapability) string { |
| // record's "" (the unconstrained value the fleet's reconcile has always |
| // scoped by). The record NEVER stores the code; the API never shows "". |
| func (s *Server) homeRegionIn(region string) string { |
| - if s.cfg.HomeRegion != "" && region == s.cfg.HomeRegion { |
| + return homePlacementCode(s.cfg.HomeRegion, region) |
| +} |
| + |
| +// homePlacementCode is homeRegionIn's rule for a given home name: the |
| +// home's name is the record's "", any other region is itself, and with no |
| +// home named nothing is the home. claimMoveRosterPlaces reads roster |
| +// regions through it too, so the door and the API translate home one way. |
| +func homePlacementCode(home, region string) string { |
| + if home != "" && region == home { |
| return "" |
| } |
| return region |
| @@ -761,7 +769,7 @@ func (s *Server) startClaimModeMove(w http.ResponseWriter, r *http.Request, acct |
| ctx := r.Context() |
| a := s.cfg.ClaimAuthority |
| to := ms.To |
| - if !claimMoveRosterPlaces(roster.Roster, obs.Claim.Op.Exec, target, recRegion, to) { |
| + if !claimMoveRosterPlaces(roster.Roster, obs.Claim.Op.Exec, target, recRegion, to, s.cfg.HomeRegion) { |
| jsonError(w, http.StatusConflict, "the host roster does not allow this move. the source and target must be active boxes in "+from+" and "+to) |
| return |
| } |
| @@ -1252,11 +1260,24 @@ func phase1Conflict(err error) string { |
| // the door's own observation: both boxes have active, enrolled entries |
| // under their box references, they differ, the source is in the record's |
| // region and the target in to, which is not the source's region. |
| -func claimMoveRosterPlaces(r hostactors.Roster, source, target, recRegion, to string) bool { |
| +// |
| +// The roster and the record name regions differently for one region, the |
| +// home: the roster refuses an empty region, so a home box is enrolled under |
| +// the home's name (CONSOLE_HOME_REGION, as boxchoice reads it), while the |
| +// record keeps "". Both boxes' roster regions are read as record codes |
| +// through homePlacementCode with home, the deployment's home name (the rule |
| +// homeRegionIn applies), so a move out of home is placed and a named region |
| +// is compared as it is. With no home named nothing translates, and a home |
| +// source is refused as before (home-region parks, part 1). |
| +func claimMoveRosterPlaces(r hostactors.Roster, source, target, recRegion, to, home string) bool { |
| src, sourceOK := r.Boxes[source] |
| dst, targetOK := r.Boxes[target] |
| - return sourceOK && targetOK && source != target && src.State == "active" && dst.State == "active" && src.Enrollment != "" && dst.Enrollment != "" && |
| - src.Region != "" && src.Region == recRegion && dst.Region == to && src.Region != to |
| + if !sourceOK || !targetOK { |
| + return false |
| + } |
| + from, into := homePlacementCode(home, src.Region), homePlacementCode(home, dst.Region) |
| + return source != target && src.State == "active" && dst.State == "active" && src.Enrollment != "" && dst.Enrollment != "" && |
| + src.Region != "" && dst.Region != "" && from == recRegion && into == to && from != to |
| } |
| // sourceEnrollment is the enrollment of the box that executes the claim, and |
diff --git a/internal/console/apiinstancemove_claim_test.go b/internal/console/apiinstancemove_claim_test.go| index 809bdf01a..c50af8914 100644 |
| --- a/internal/console/apiinstancemove_claim_test.go |
| +++ b/internal/console/apiinstancemove_claim_test.go |
| @@ -550,3 +550,78 @@ func TestMoveDoorHostClaimRetryAfterAFailedRecordStart(t *testing.T) { |
| t.Fatal("the failed attempt's registration is still held") |
| } |
| } |
| + |
| +// Home-region parks, part 1 (Paul's ruling on concern 2): the host-claim |
| +// door places a move out of home. The record names home "" and the roster |
| +// names the home box by the home's name (CONSOLE_HOME_REGION, "ie" here: |
| +// the roster refuses an empty region); the door compares them through |
| +// homeRegionIn, the one translation between the two, so the roster check |
| +// passes and the move registers. It then fails closed, writing nothing to |
| +// the claim or the record, at hostops.BeginClaimMove's own roster check, |
| +// which compares the record's "" with the roster's "ie" literally (claim |
| +// side, platform-1e, #236). |
| +func TestMoveDoorHostClaimPlacesAHomeSource(t *testing.T) { |
| + ctx := context.Background() |
| + fx, token := operatorInstanceFixture(t, "code-inst-move-home") |
| + fx.srv.cfg.HomeRegion = "ie" |
| + m := titoManifest() |
| + m.Regions = nil |
| + m.Pool = "" |
| + enableInstances(t, fx, "titogo", m) |
| + u := instancesURL(fx, "bab", "titogo") |
| + if c, _, out := rawReq(t, http.MethodPost, u, token, `{"host":"acme.go.tito.io","region":"ie"}`); c != http.StatusCreated { |
| + t.Fatalf("create = %d %s", c, out) |
| + } |
| + armMoveFleet(t, fx, ieBox("ie-box"), ukBox("uk-box")) |
| + claimFor(t, fx, "acme.go.tito.io", "ie-box", "ie") |
| + if rec, err := provision.GetRecord(ctx, fx.rel, "bab", "titogo", "acme.go.tito.io"); err != nil || rec.Region != "" { |
| + t.Fatalf("fixture: the record is not home's: %+v %v", rec, err) |
| + } |
| + armClaimMoves(t, fx, true, true, nil) |
| + claim := claimBytes(t, fx, "acme.go.tito.io") |
| + code, _, out := rawReq(t, http.MethodPost, u+"/acme.go.tito.io/move", token, `{"region":"uk"}`) |
| + if msg := errorText(out); strings.HasPrefix(msg, "the host roster does not allow this move") { |
| + t.Fatalf("the door's roster check refused a home source: %d %s", code, out) |
| + } |
| + if code != http.StatusConflict || errorText(out) != "the instance or its boxes changed under this request. read its state and try again" { |
| + t.Fatalf("request = %d %s, want BeginClaimMove's fail-closed refusal", code, out) |
| + } |
| + a, err := hostops.ReadAdmission(ctx, fx.rel, "bab", "titogo", claimMoveAppID) |
| + if err != nil || len(a.Value.Registrations) != 1 { |
| + t.Fatalf("the move did not register past the door's roster check: %+v %v", a.Value.Registrations, err) |
| + } |
| + if claimBytes(t, fx, "acme.go.tito.io") != claim { |
| + t.Fatal("the refused move wrote the claim") |
| + } |
| + if rec, err := provision.GetRecord(ctx, fx.rel, "bab", "titogo", "acme.go.tito.io"); err != nil || rec.Move != nil { |
| + t.Fatalf("the refused move journaled a move: %+v %v", rec, err) |
| + } |
| +} |
| + |
| +// claimMoveRosterPlaces reads roster regions through the console's home |
| +// translation (homePlacementCode, homeRegionIn's rule): the home's name is |
| +// the record's "", a named region is itself, and with no home named |
| +// nothing is home. |
| +func TestClaimMoveRosterPlacesTranslatesHome(t *testing.T) { |
| + box := func(b fleetBox) hostactors.Box { return moveRosterBox(b) } |
| + r := hostactors.Roster{Boxes: map[string]hostactors.Box{"ie-box": box(ieBox("ie-box")), "uk-box": box(ukBox("uk-box"))}} |
| + home, unnamed := "ie", "" |
| + for _, tc := range []struct { |
| + name string |
| + in string |
| + recRegion string |
| + to string |
| + ok bool |
| + }{ |
| + {"home source, home named", home, "", "uk", true}, |
| + {"home source, no home named", unnamed, "", "uk", false}, |
| + {"named source, home named elsewhere", "de", "ie", "uk", true}, |
| + {"named source, no home named", unnamed, "ie", "uk", true}, |
| + {"the record says ie but ie is home", home, "ie", "uk", false}, |
| + {"target in the source's region", home, "", "", false}, |
| + } { |
| + if got := claimMoveRosterPlaces(r, "ie-box", "uk-box", tc.recRegion, tc.to, tc.in); got != tc.ok { |
| + t.Fatalf("%s: %v, want %v", tc.name, got, tc.ok) |
| + } |
| + } |
| +} |
diff --git a/internal/console/movesrecovery.go b/internal/console/movesrecovery.go| index 2a04b2192..7ea56e4d3 100644 |
| --- a/internal/console/movesrecovery.go |
| +++ b/internal/console/movesrecovery.go |
| @@ -17,6 +17,7 @@ import ( |
| "github.com/carlosframework/platform/internal/hostfence" |
| "github.com/carlosframework/platform/internal/hostops" |
| "github.com/carlosframework/platform/internal/moves" |
| + "github.com/carlosframework/platform/internal/park" |
| "github.com/carlosframework/platform/internal/provision" |
| ) |
| @@ -197,7 +198,7 @@ func decideMoveRecovery(in moveRecoveryInput) moveRecoveryDecision { |
| case m.State == provision.MoveLanding && op.Kind == hostclaim.KindLanding && op.Phase != hostclaim.PhaseCommitted && moveDeadlinePassed(m, in.Now): |
| d.Action, d.Record = moveActRecord, hostops.RecoveryMoveDeadlineRollback |
| case m.State == provision.MoveRollingBack && rb != nil && rb.TargetPhase == provision.RbTargetReleased && !rb.Dirty && rb.TargetParkedAt == nil && |
| - hostclaim.LostMoveTargetParked(c, m.To): |
| + hostclaim.LostMoveTargetParked(c, park.PlacementRegion(m.To)): |
| d.Action = moveActStampLost |
| case owed > 0: |
| case m.State == provision.MoveCommitted && m.OpenedAt != nil && m.SourceParkedAt != nil && rec.Region == m.To && op.Kind == hostclaim.KindLanding && op.Phase == hostclaim.PhaseCommitted: |
| @@ -365,7 +366,7 @@ func (s *Server) movesRecovery(ctx context.Context, g *hostops.MoveRecoveryGrant |
| s.pageMove(ctx, movePageCloseOverdue, o, c, rec, now) |
| return nil |
| case moveActAbortOverdue: |
| - if s.moveAbortOverdue(o, c.QuietAfter[rec.Move.To], now) { |
| + if s.moveAbortOverdue(o, c.QuietAfter[park.PlacementRegion(rec.Move.To)], now) { |
| s.pageMove(ctx, movePageAbortOverdue, o, c, rec, now) |
| } |
| return nil |
diff --git a/internal/host/movesabort.go b/internal/host/movesabort.go| index 354e13c59..ee4a1eed3 100644 |
| --- a/internal/host/movesabort.go |
| +++ b/internal/host/movesabort.go |
| @@ -12,6 +12,7 @@ import ( |
| "github.com/carlosframework/platform/internal/hostclaim" |
| "github.com/carlosframework/platform/internal/lease" |
| "github.com/carlosframework/platform/internal/moves" |
| + "github.com/carlosframework/platform/internal/park" |
| "github.com/carlosframework/platform/internal/provision" |
| "github.com/carlosframework/platform/internal/release" |
| ) |
| @@ -234,7 +235,7 @@ func (w *movesWorker) abortClaim(ctx context.Context, s release.Store, rec provi |
| } |
| } |
| scope := moves.AbortScope(host, id, obs.Claim.Holder.Incarnation, bound) |
| - if _, err := hostclaim.Update(ctx, s, obs, hostclaim.Abort(id, rec.Move.To, scope, obligationID)); err != nil { |
| + if _, err := hostclaim.Update(ctx, s, obs, hostclaim.Abort(id, park.PlacementRegion(rec.Move.To), scope, obligationID)); err != nil { |
| return false, fmt.Errorf("abort the claim: %w", err) |
| } |
| log.Printf("moves: %s: the claim aborts move %s; the staging lane in %s is queued for its park there", host, id, rec.Move.To) |
diff --git a/internal/host/movescut.go b/internal/host/movescut.go| index f7b513c5d..1c946ea11 100644 |
| --- a/internal/host/movescut.go |
| +++ b/internal/host/movescut.go |
| @@ -20,6 +20,7 @@ import ( |
| "github.com/carlosframework/platform/internal/hostclaim" |
| "github.com/carlosframework/platform/internal/lease" |
| "github.com/carlosframework/platform/internal/moves" |
| + "github.com/carlosframework/platform/internal/park" |
| "github.com/carlosframework/platform/internal/provision" |
| "github.com/carlosframework/platform/internal/registry" |
| "github.com/carlosframework/platform/internal/release" |
| @@ -1318,7 +1319,7 @@ func (w *movesWorker) revertClaim(ctx context.Context, s release.Store, rec prov |
| } |
| } |
| scope := moves.StagingScope(rec.Host, id, obs.Claim.Holder.Incarnation, bound) |
| - if _, err := hostclaim.Update(ctx, s, obs, hostclaim.Revert(id, cfg.Box, rec.Move.To, &scope, obligationID)); err != nil { |
| + if _, err := hostclaim.Update(ctx, s, obs, hostclaim.Revert(id, cfg.Box, park.PlacementRegion(rec.Move.To), &scope, obligationID)); err != nil { |
| return fmt.Errorf("revert the claim: %w", err) |
| } |
| log.Printf("moves: %s: the claim reverts to this box; the staging lane in %s is queued for its park", rec.Host, rec.Move.To) |
diff --git a/internal/host/movescut_test.go b/internal/host/movescut_test.go| index 0d8226ce0..fb10f2292 100644 |
| --- a/internal/host/movescut_test.go |
| +++ b/internal/host/movescut_test.go |
| @@ -91,9 +91,9 @@ var _ lease.FilePutIfAbsent = (*fakeVersioned)(nil) |
| const lastTXKey = "0000/0000000000000003-0000000000000003.ltx" |
| -func newCutHarness(t *testing.T) *cutHarness { |
| +func newCutHarness(t *testing.T, opts ...p1Option) *cutHarness { |
| t.Helper() |
| - h := &cutHarness{phase1Harness: newPhase1Harness(t), ev: &fakeEvidence{stopped: true}, localPos: 3, |
| + h := &cutHarness{phase1Harness: newPhase1Harness(t, opts...), ev: &fakeEvidence{stopped: true}, localPos: 3, |
| bundle: map[string]string{"default": "e1", "environment": ""}} |
| h.timers = []registry.Timer{{Host: p1Host, Name: "nightly", Kind: "cron", Spec: "0 3 * * *", NextAt: 1000}} |
| last := ltxForTest(t, 4096, 4, 3, 3) |
diff --git a/internal/host/movesfinal.go b/internal/host/movesfinal.go| index c6401c950..0689533fe 100644 |
| --- a/internal/host/movesfinal.go |
| +++ b/internal/host/movesfinal.go |
| @@ -387,12 +387,12 @@ func (f *sourceFinal) engine(vs lease.VersionedStore) park.Engine { |
| if err := f.sourceOf(obs); err != nil { |
| return err |
| } |
| - if o.Op != f.g.ID || o.Region != cfg.Region { |
| + if o.Op != f.g.ID || o.Region != cfg.parkRegion() { |
| return fmt.Errorf("%w: not this finalization's chore", park.ErrUnsafe) |
| } |
| return nil |
| } |
| - return park.Engine{Store: vs, Claims: f.s, Bucket: cfg.OwnBucket, Region: cfg.Region, Leases: cfg.ParkLeases, |
| + return park.Engine{Store: vs, Claims: f.s, Bucket: cfg.OwnBucket, Region: cfg.parkRegion(), Leases: cfg.ParkLeases, |
| Now: f.now, NewID: obligationID, View: cfg.ParkView, Authorize: movesArmAuthorize(cfg.ParkAuthorize, own), After: cfg.parkFault} |
| } |
| @@ -406,7 +406,7 @@ func (f *sourceFinal) remote(ctx context.Context, obs hostclaim.Observed) (bool, |
| var chore *hostclaim.Obligation |
| for i := range obs.Claim.Obligations { |
| o := &obs.Claim.Obligations[i] |
| - if o.Op == id && o.Kind == "park-replica" && o.Region == cfg.Region && o.Park != nil && !isStagingLocation(o.Park.Location) { |
| + if o.Op == id && o.Kind == "park-replica" && o.Region == cfg.parkRegion() && o.Park != nil && !isStagingLocation(o.Park.Location) { |
| chore = o |
| } |
| } |
diff --git a/internal/host/movesfinal_test.go b/internal/host/movesfinal_test.go| index e29cd5cf2..17f6f9cee 100644 |
| --- a/internal/host/movesfinal_test.go |
| +++ b/internal/host/movesfinal_test.go |
| @@ -45,9 +45,9 @@ type finalHarness struct { |
| deletes int // DeleteHeld calls that deleted |
| } |
| -func newFinalHarness(t *testing.T) *finalHarness { |
| +func newFinalHarness(t *testing.T, opts ...p1Option) *finalHarness { |
| t.Helper() |
| - return newFinalHarnessFrom(t, newLandingHarness(t)) |
| + return newFinalHarnessFrom(t, newLandingHarness(t, opts...)) |
| } |
| // newFinalHarnessFrom is newFinalHarness over a landing harness the caller |
diff --git a/internal/host/movesfloor_linux.go b/internal/host/movesfloor_linux.go| index 88deb6e81..29a88713d 100644 |
| --- a/internal/host/movesfloor_linux.go |
| +++ b/internal/host/movesfloor_linux.go |
| @@ -13,6 +13,7 @@ import ( |
| "time" |
| "github.com/carlosframework/platform/internal/safefile" |
| + "golang.org/x/sys/unix" |
| ) |
| // movesDir is <DataDir>/.moves, opened with O_NOFOLLOW below a data dir |
| @@ -20,9 +21,10 @@ import ( |
| // symlink the sandboxed agent planted. Every operation on the floor files |
| // goes through one: the lock and the writes are all relative to its fd. |
| type movesDir struct { |
| - fd int |
| - path string |
| - lock *os.File |
| + fd int |
| + path string |
| + lock *os.File |
| + lockName string |
| } |
| // floorLockFile serialises every writer of the floor files (root's apply, |
| @@ -95,32 +97,168 @@ func (d *movesDir) Lock() error { return d.lockNamed(floorLockFile, floorLockWai |
| // read-only fd can be flocked), so the agent can lock a file root created |
| // and root a file the agent created; O_NOFOLLOW and O_NONBLOCK so a symlink |
| // or a FIFO planted in its place cannot redirect or park root. The fd is |
| -// kept until Close, which releases the lock. One lock per movesDir. |
| +// kept until Close (or unlockRemoving), which releases the lock. One lock |
| +// per movesDir. |
| +// |
| +// A holder may remove the file it holds before releasing it |
| +// (unlockRemoving, the held-stage lock). A waiter that opened that file |
| +// would then lock a file no longer at the name, beside whoever has locked |
| +// the new one there. So once it has the flock it checks that the name still |
| +// names the file it locked (fstat of the fd against an fstatat of the name) |
| +// and, when the name is gone or names another file, closes it and starts |
| +// again on whatever is there now. The same removal can land inside the |
| +// open itself (the name vanishes, or the file opened has no link left); |
| +// that is retried too, while a symlink, a FIFO or a second link at the name |
| +// is still refused. Each retry pauses (lockNamedRetryPause, capped at what |
| +// is left of the wait), and none starts once the wait has passed: the call |
| +// fails with busy then, an open race included, and holds nothing. |
| +// |
| +// The deadline bounds the wait; correctness does not depend on it. A flock |
| +// taken just after the deadline (the check and the syscall are not one |
| +// step) is still a valid, exclusive lock, and is kept. |
| func (d *movesDir) lockNamed(name string, wait time.Duration, busy error) error { |
| - f, err := safefile.OpenFileAt(d.fd, name, os.O_RDONLY|os.O_CREATE, 0o644) |
| - if err != nil { |
| - return fmt.Errorf("open %s: %w", filepath.Join(d.path, name), err) |
| - } |
| - fd := int(f.Fd()) |
| deadline := time.Now().Add(wait) |
| + timedOut := func() error { return fmt.Errorf("lock %s: %w", filepath.Join(d.path, name), busy) } |
| for { |
| - err := syscall.Flock(fd, syscall.LOCK_EX|syscall.LOCK_NB) |
| - if err == nil { |
| - d.lock = f |
| - return nil |
| + if time.Now().After(deadline) { |
| + return timedOut() |
| } |
| - if !errors.Is(err, syscall.EWOULDBLOCK) { |
| + f, err := lockNamedOpenAt(d.fd, name, os.O_RDONLY|os.O_CREATE, 0o644) |
| + if err != nil { |
| + if !d.removedDuringOpen(name, err) { |
| + return fmt.Errorf("open %s: %w", filepath.Join(d.path, name), err) |
| + } |
| + // A holder's removal landed inside the open: start again. |
| + lockNamedPause(deadline) |
| + continue |
| + } |
| + lockNamedOpened(name) |
| + if err := flockWithin(f, deadline); err != nil { |
| f.Close() |
| + if errors.Is(err, syscall.EWOULDBLOCK) { |
| + return timedOut() |
| + } |
| return fmt.Errorf("lock %s: %w", filepath.Join(d.path, name), err) |
| } |
| - if time.Now().After(deadline) { |
| + same, err := d.names(name, f) |
| + if err != nil { |
| f.Close() |
| - return fmt.Errorf("lock %s: %w", filepath.Join(d.path, name), busy) |
| + return fmt.Errorf("lock %s: %w", filepath.Join(d.path, name), err) |
| + } |
| + if same { |
| + d.lock, d.lockName = f, name |
| + return nil |
| + } |
| + // The holder before us removed this file as it released it. Start |
| + // again, within the wait: the publication waits inside the |
| + // registry's freeze, so a name that keeps changing ends in busy. |
| + f.Close() |
| + lockNamedPause(deadline) |
| + } |
| +} |
| + |
| +// lockNamedPause sleeps lockNamedRetryPause, or what is left of the wait |
| +// when that is less. |
| +func lockNamedPause(deadline time.Time) { |
| + if left := time.Until(deadline); left > 0 { |
| + time.Sleep(min(lockNamedRetryPause, left)) |
| + } |
| +} |
| + |
| +// flockWithin takes an exclusive flock on f, polling until deadline, and |
| +// once the deadline has passed stops trying and returns EWOULDBLOCK. A |
| +// flock that lands just after the deadline is kept: it is a valid, |
| +// exclusive lock, and the deadline only bounds the wait. |
| +func flockWithin(f *os.File, deadline time.Time) error { |
| + for { |
| + if time.Now().After(deadline) { |
| + return syscall.EWOULDBLOCK |
| + } |
| + err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB) |
| + if err == nil || !errors.Is(err, syscall.EWOULDBLOCK) { |
| + return err |
| } |
| time.Sleep(floorLockPoll) |
| } |
| } |
| +// names reports whether name in the directory is still the file f has |
| +// open: a regular file at the name with f's device and inode. A name that |
| +// is gone is false, not an error. |
| +func (d *movesDir) names(name string, f *os.File) (bool, error) { |
| + var held, now unix.Stat_t |
| + if err := unix.Fstat(int(f.Fd()), &held); err != nil { |
| + return false, err |
| + } |
| + err := unix.Fstatat(d.fd, name, &now, unix.AT_SYMLINK_NOFOLLOW) |
| + if errors.Is(err, unix.ENOENT) { |
| + return false, nil |
| + } |
| + if err != nil { |
| + return false, err |
| + } |
| + return now.Dev == held.Dev && now.Ino == held.Ino, nil |
| +} |
| + |
| +// removedDuringOpen reports whether err, from opening name, is the race |
| +// with a holder removing it: not an errno other than ENOENT (a real refusal, |
| +// such as EACCES or ELOOP), and the name is now gone or a regular file with |
| +// one link (a fresh lock file). Anything else at the name keeps the open's |
| +// refusal, and so does a .moves that has itself been removed (no create |
| +// can succeed in it, so waiting out the lock's wait would only delay the |
| +// error). |
| +func (d *movesDir) removedDuringOpen(name string, err error) bool { |
| + var errno syscall.Errno |
| + if errors.As(err, &errno) && errno != syscall.ENOENT { |
| + return false |
| + } |
| + var st unix.Stat_t |
| + if derr := unix.Fstat(d.fd, &st); derr != nil || st.Nlink == 0 { |
| + return false |
| + } |
| + serr := unix.Fstatat(d.fd, name, &st, unix.AT_SYMLINK_NOFOLLOW) |
| + if errors.Is(serr, unix.ENOENT) { |
| + return true |
| + } |
| + return serr == nil && st.Mode&unix.S_IFMT == unix.S_IFREG && st.Nlink == 1 |
| +} |
| + |
| +// unlockRemoving removes the lock file while still holding its flock, then |
| +// releases the lock (closing its fd). The name is removed only while it |
| +// still names the file held, so another holder's file is never taken. A |
| +// waiter on the removed file finds the name gone after its flock and starts |
| +// again (lockNamed), so two holders never exist at once. The lock is |
| +// released whatever the removal does; an error is the removal's. |
| +func (d *movesDir) unlockRemoving() error { |
| + if d.lock == nil { |
| + return nil |
| + } |
| + f, name := d.lock, d.lockName |
| + d.lock, d.lockName = nil, "" |
| + defer f.Close() |
| + same, err := d.names(name, f) |
| + if err != nil || !same { |
| + return err |
| + } |
| + if err := unix.Unlinkat(d.fd, name, 0); err != nil && !errors.Is(err, unix.ENOENT) { |
| + return fmt.Errorf("remove %s: %w", filepath.Join(d.path, name), err) |
| + } |
| + return nil |
| +} |
| + |
| +// lockNamedRetryPause is the pause between lockNamed's retries after a |
| +// holder's removal, capped at what is left of the wait. |
| +const lockNamedRetryPause = 10 * time.Millisecond |
| + |
| +// lockNamedOpenAt opens the lock file for lockNamed. A test seam: a test |
| +// can make every open meet the race with a holder's removal. |
| +var lockNamedOpenAt = safefile.OpenFileAt |
| + |
| +// lockNamedOpened runs in lockNamed after each open of the lock file and |
| +// before its flock. A test seam: the window in which the file a waiter has |
| +// open can be removed by the holder before it and replaced by a new one. |
| +var lockNamedOpened = func(name string) {} |
| + |
| // WriteDurable writes data to .moves/name the way writeDurable does (a |
| // temp file, fsync, rename, fsync of the directory), all relative to the |
| // directory's fd. The temp name is random, so two writers never share one. |
diff --git a/internal/host/movesfloor_other.go b/internal/host/movesfloor_other.go| index c21c86f92..dbb5ff9c0 100644 |
| --- a/internal/host/movesfloor_other.go |
| +++ b/internal/host/movesfloor_other.go |
| @@ -22,6 +22,8 @@ func (d *movesDir) Lock() error { return errSystemUnsupported } |
| func (d *movesDir) lockNamed(string, time.Duration, error) error { return errSystemUnsupported } |
| +func (d *movesDir) unlockRemoving() error { return errSystemUnsupported } |
| + |
| func (d *movesDir) WriteDurable(name string, data []byte, mode os.FileMode) error { |
| return errSystemUnsupported |
| } |
diff --git a/internal/host/movesheldlock.go b/internal/host/movesheldlock.go| index 4e12216dd..b60954a86 100644 |
| --- a/internal/host/movesheldlock.go |
| +++ b/internal/host/movesheldlock.go |
| @@ -3,6 +3,7 @@ package host |
| import ( |
| "errors" |
| "fmt" |
| + "log" |
| "os" |
| "time" |
| @@ -28,6 +29,17 @@ import ( |
| // call (D3's admit, which may read the bucket, runs before it on both |
| // sides), so it never inverts the order and never holds the registry's |
| // freeze for long. |
| +// |
| +// The file is removed when the stage finishes (region HA M-K25, #236), by |
| +// the holder, while it still holds the flock (unlockRemoving). Main's |
| +// empty-installation census refuses any file naming the host being |
| +// installed, so a lock left behind would refuse every later from-empty |
| +// install of that host on this box. A waiter that opened the removed file |
| +// re-checks the name after its flock and starts again on the new one |
| +// (lockNamed), so two holders never exist at once. A holder killed |
| +// mid-stage never removes it: the file stays, the census refuses that host |
| +// (region HA's CensusTree(".moves") fallback refuses only that host), and |
| +// the next stage of the host locks the same file and then removes it. |
| // heldLockWait bounds the wait for a held-stage lock. The publication waits |
| // for it inside the registry's freeze, after a claim read bounded by |
| @@ -45,9 +57,12 @@ var errHeldLockBusy = errors.New("another process holds this host's held-stage l |
| func heldStageLockFile(host string) string { return "held-" + host + ".lock" } |
| // withHeldStageLock runs fn holding host's held-stage lock, making .moves |
| -// when it is missing. An error opening or locking is returned without |
| -// running fn. Off Linux it always refuses (openMovesDir does), which fails |
| -// T7 and the publication closed; production boxes are Linux. |
| +// when it is missing, and removes the lock file before releasing it, |
| +// whatever fn returns. An error opening or locking is returned without |
| +// running fn. A removal that fails is logged and leaves the file as a crash |
| +// would; fn's result stands. Off Linux it always refuses (openMovesDir |
| +// does), which fails T7 and the publication closed; production boxes are |
| +// Linux. |
| func withHeldStageLock(dataDir, host string, fn func() error) error { |
| if !controlname.ValidHostShape(host) { |
| return fmt.Errorf("held-stage lock: malformed host %q", host) |
| @@ -63,5 +78,9 @@ func withHeldStageLock(dataDir, host string, fn func() error) error { |
| if err := d.lockNamed(heldStageLockFile(host), heldLockWait, errHeldLockBusy); err != nil { |
| return err |
| } |
| - return fn() |
| + err = fn() |
| + if rerr := d.unlockRemoving(); rerr != nil { |
| + log.Printf("moves: %s: held-stage lock: %v", host, rerr) |
| + } |
| + return err |
| } |
diff --git a/internal/host/movesheldlock_cleanup_test.go b/internal/host/movesheldlock_cleanup_test.go| new file mode 100644 |
| index 000000000..1ab34302d |
| --- /dev/null |
| +++ b/internal/host/movesheldlock_cleanup_test.go |
| @@ -0,0 +1,391 @@ |
| +//go:build linux |
| + |
| +package host |
| + |
| +import ( |
| + "errors" |
| + "os" |
| + "path/filepath" |
| + "sync/atomic" |
| + "syscall" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/hostbox" |
| + "github.com/carlosframework/platform/internal/hostclaim" |
| + "github.com/carlosframework/platform/internal/registry" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +// The held-stage lock's file is removed when the stage finishes (region HA |
| +// M-K25, answered by moves on #236): main's empty-installation census |
| +// refuses a file that names the host being installed, so a lock left |
| +// behind would refuse every later from-empty install of that host on the |
| +// box that held a stage for it. |
| + |
| +// heldLockCensus runs main's empty-installation census for a from-empty |
| +// install of host on a box whose data root is data, with the production |
| +// classification (claimGlobalSharedPaths) and any extra data entries. |
| +func heldLockCensus(t *testing.T, data, host string, extra ...string) error { |
| + t.Helper() |
| + base := t.TempDir() |
| + inventory, err := hostbox.OpenStopInventoryDB(filepath.Join(base, "inventory.db")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer inventory.Close() |
| + reg, err := registry.Open(filepath.Join(base, "registry.db")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer reg.Close() |
| + launches, err := hostbox.OpenLaunchJournal(filepath.Join(base, "launches.db")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer launches.Close() |
| + r := ClaimRuntimeResources{DataDir: data, OptDir: filepath.Join(base, "opt"), UnitsDir: filepath.Join(base, "units")} |
| + for _, dir := range []string{r.OptDir, r.UnitsDir} { |
| + if err := os.Mkdir(dir, 0o755); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + shared := claimGlobalSharedPaths(r) |
| + shared["data"] = append(shared["data"], extra...) |
| + c := hostbox.EmptyInstallationCensus{Inventory: inventory, Registry: reg, Launches: launches, Box: "box", Enrollment: "enrollment", DirectoryRevision: "1", |
| + Roots: map[string]string{"data": r.DataDir, "opt": r.OptDir, "units": r.UnitsDir}, SharedPaths: shared} |
| + obs := hostclaim.Observed{Host: host, Claim: &hostclaim.Claim{Holder: &hostclaim.Holder{Incarnation: "inc", Stamp: 1}}} |
| + return c.CheckLocal(t.Context(), obs, hostbox.RecordSelection{Host: host, Incarnation: "inc"}) |
| +} |
| + |
| +func heldLockInode(t *testing.T, path string) uint64 { |
| + t.Helper() |
| + info, err := os.Lstat(path) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return info.Sys().(*syscall.Stat_t).Ino |
| +} |
| + |
| +func TestHeldStageLockIsRemovedWhenTheStageFinishes(t *testing.T) { |
| + data := t.TempDir() |
| + path := filepath.Join(data, ".moves", heldStageLockFile(hsHost)) |
| + if err := heldLockCensus(t, data, hsHost); err != nil { |
| + t.Fatalf("census before any stage: %v", err) |
| + } |
| + for _, result := range []error{nil, errors.New("the stage failed")} { |
| + err := withHeldStageLock(data, hsHost, func() error { |
| + if _, err := os.Lstat(path); err != nil { |
| + t.Fatalf("no lock file while the lock is held: %v", err) |
| + } |
| + return result |
| + }) |
| + if err != result { |
| + t.Fatalf("stage: %v, want %v", err, result) |
| + } |
| + if _, err := os.Lstat(path); !errors.Is(err, os.ErrNotExist) { |
| + t.Fatalf("after a stage that returned %v, %s remains: %v", result, path, err) |
| + } |
| + } |
| + // The publication path: heldStageAuthority's then, inside the freeze. |
| + s := release.NewDirStore(t.TempDir()) |
| + hsClaim(t, s, movingOp()) |
| + hsReport(t, s, hsOp, "v7") |
| + auth := heldStageAuthority(testRegistry(t), s, hsBox, data) |
| + published := false |
| + if err := auth(hsRequest(), func() error { published = true; return nil }); err != nil || !published { |
| + t.Fatalf("publication: %v (ran %v)", err, published) |
| + } |
| + if _, err := os.Lstat(path); !errors.Is(err, os.ErrNotExist) { |
| + t.Fatalf("after a publication, %s remains: %v", path, err) |
| + } |
| + // A from-empty install of that host on this box passes main's census. |
| + if err := heldLockCensus(t, data, hsHost); err != nil { |
| + t.Fatalf("census after held stages of the host: %v", err) |
| + } |
| +} |
| + |
| +// A waiter that opened the file a holder then removes must not lock that |
| +// file and run beside whoever holds the new one at the same name: it |
| +// re-checks the name after its flock and retries. |
| +func TestHeldStageLockWaiterOnARemovedFileRetries(t *testing.T) { |
| + prevWait, prevHook := heldLockWait, lockNamedOpened |
| + t.Cleanup(func() { heldLockWait, lockNamedOpened = prevWait, prevHook }) |
| + heldLockWait = 10 * time.Second |
| + data := t.TempDir() |
| + path := filepath.Join(data, ".moves", heldStageLockFile(hsHost)) |
| + var holders, overlaps atomic.Int32 |
| + hold := func(entered chan<- struct{}, leave <-chan struct{}) func() error { |
| + return func() error { |
| + if holders.Add(1) != 1 { |
| + overlaps.Add(1) |
| + } |
| + defer holders.Add(-1) |
| + close(entered) |
| + if leave != nil { |
| + <-leave |
| + } |
| + return nil |
| + } |
| + } |
| + |
| + // The first holder holds the lock on the first file. |
| + in1, out1, done1 := make(chan struct{}), make(chan struct{}), make(chan error, 1) |
| + go func() { done1 <- withHeldStageLock(data, hsHost, hold(in1, out1)) }() |
| + <-in1 |
| + |
| + // The waiter opens that file and is parked before its flock. |
| + opened, resume := make(chan struct{}), make(chan struct{}) |
| + var opens atomic.Int32 |
| + lockNamedOpened = func(name string) { |
| + if name == heldStageLockFile(hsHost) && opens.Add(1) == 1 { |
| + close(opened) |
| + <-resume |
| + } |
| + } |
| + inW, doneW := make(chan struct{}), make(chan error, 1) |
| + go func() { doneW <- withHeldStageLock(data, hsHost, hold(inW, nil)) }() |
| + <-opened |
| + |
| + // The first holder finishes: it removes its file and releases. |
| + close(out1) |
| + if err := <-done1; err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := os.Lstat(path); !errors.Is(err, os.ErrNotExist) { |
| + close(resume) |
| + t.Fatalf("the first holder left %s: %v", path, err) |
| + } |
| + |
| + // A second holder arrives and locks a new file at the same name. |
| + in2, out2, done2 := make(chan struct{}), make(chan struct{}), make(chan error, 1) |
| + go func() { done2 <- withHeldStageLock(data, hsHost, hold(in2, out2)) }() |
| + <-in2 |
| + |
| + // The waiter resumes. Its fd names the removed file, which nobody |
| + // holds: it must not run until the second holder is done. |
| + close(resume) |
| + select { |
| + case <-inW: |
| + close(out2) |
| + t.Fatalf("the waiter ran while the second holder held the lock (overlaps %d)", overlaps.Load()) |
| + case <-time.After(500 * time.Millisecond): |
| + } |
| + close(out2) |
| + if err := <-done2; err != nil { |
| + t.Fatal(err) |
| + } |
| + select { |
| + case <-inW: |
| + case <-time.After(5 * time.Second): |
| + t.Fatal("the waiter never took the lock after the second holder released it") |
| + } |
| + if err := <-doneW; err != nil { |
| + t.Fatal(err) |
| + } |
| + if n := overlaps.Load(); n != 0 { |
| + t.Fatalf("%d holders ran beside another", n) |
| + } |
| + if _, err := os.Lstat(path); !errors.Is(err, os.ErrNotExist) { |
| + t.Fatalf("after every holder finished, %s remains: %v", path, err) |
| + } |
| +} |
| + |
| +// A holder killed mid-stage never reaches the removal, so its file stays |
| +// (the existing behaviour). Main's census then refuses an install of that |
| +// host; region HA's fallback (CensusTree(".moves")) refuses that host only. |
| +// The next stage of the host locks the file the crash left and removes it. |
| +func TestHeldStageLockLeftByACrashIsReusedThenRemoved(t *testing.T) { |
| + data := t.TempDir() |
| + path := filepath.Join(data, ".moves", heldStageLockFile(hsHost)) |
| + if err := os.Mkdir(filepath.Dir(path), 0o755); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.WriteFile(path, nil, 0o644); err != nil { |
| + t.Fatal(err) |
| + } |
| + left := heldLockInode(t, path) |
| + if err := heldLockCensus(t, data, hsHost); !errors.Is(err, hostbox.ErrTypedInventory) { |
| + t.Fatalf("main's census with a crashed stage's lock: %v, want ErrTypedInventory", err) |
| + } |
| + if err := heldLockCensus(t, data, hsHost, hostbox.CensusTree(".moves")); !errors.Is(err, hostbox.ErrTypedInventory) { |
| + t.Fatalf("the fallback census for the lock's host: %v, want ErrTypedInventory", err) |
| + } |
| + if err := heldLockCensus(t, data, "other.example.com", hostbox.CensusTree(".moves")); err != nil { |
| + t.Fatalf("the fallback census for another host: %v", err) |
| + } |
| + ran := false |
| + if err := withHeldStageLock(data, hsHost, func() error { |
| + ran = true |
| + if got := heldLockInode(t, path); got != left { |
| + t.Fatalf("the next stage locked a new file (inode %d), not the one the crash left (%d)", got, left) |
| + } |
| + return nil |
| + }); err != nil || !ran { |
| + t.Fatalf("the next stage: %v (ran %v)", err, ran) |
| + } |
| + if _, err := os.Lstat(path); !errors.Is(err, os.ErrNotExist) { |
| + t.Fatalf("the next stage left %s: %v", path, err) |
| + } |
| + if err := heldLockCensus(t, data, hsHost); err != nil { |
| + t.Fatalf("census after the next stage: %v", err) |
| + } |
| +} |
| + |
| +// A holder's removal can land inside a waiter's open: the name vanishes |
| +// between the create and the reopen, or the file reopened has no link |
| +// left. lockNamed opens again then, but keeps the refusal of anything |
| +// else at the name. |
| +func TestHeldStageLockOpenRetriesOnlyTheRemovalRace(t *testing.T) { |
| + data := t.TempDir() |
| + d, err := openMovesDir(data, true) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer d.Close() |
| + at := func(name string) string { return filepath.Join(data, ".moves", name) } |
| + if err := os.WriteFile(at("fresh.lock"), nil, 0o644); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.WriteFile(at("linked.lock"), nil, 0o644); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.Link(at("linked.lock"), at("second.lock")); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.Symlink(t.TempDir(), at("planted.lock")); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := syscall.Mkfifo(at("fifo.lock"), 0o644); err != nil { |
| + t.Fatal(err) |
| + } |
| + unlinked := &os.PathError{Op: "open", Path: "x", Err: errors.New("not a regular file with one link")} |
| + for _, c := range []struct { |
| + name string |
| + err error |
| + want bool |
| + }{ |
| + {"gone.lock", &os.PathError{Op: "open", Path: "x", Err: syscall.ENOENT}, true}, |
| + {"gone.lock", unlinked, true}, |
| + {"fresh.lock", unlinked, true}, |
| + {"fresh.lock", &os.PathError{Op: "open", Path: "x", Err: syscall.EACCES}, false}, |
| + {"linked.lock", unlinked, false}, |
| + {"planted.lock", unlinked, false}, |
| + {"fifo.lock", unlinked, false}, |
| + } { |
| + if got := d.removedDuringOpen(c.name, c.err); got != c.want { |
| + t.Errorf("%s after %v: retry %v, want %v", c.name, c.err, got, c.want) |
| + } |
| + } |
| + // A .moves removed under an open movesDir: nothing can be created in |
| + // it, so the open's error stands at once. |
| + gone := t.TempDir() |
| + g, err := openMovesDir(gone, true) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer g.Close() |
| + if err := os.Remove(filepath.Join(gone, ".moves")); err != nil { |
| + t.Fatal(err) |
| + } |
| + if g.removedDuringOpen("gone.lock", &os.PathError{Op: "open", Path: "x", Err: syscall.ENOENT}) { |
| + t.Error("a removed .moves was retried") |
| + } |
| +} |
| + |
| +// Fix round 1: the re-check's retries are bounded by the one wait. With |
| +// every file the waiter opens replaced at the name before its flock, each |
| +// re-check finds another file there; the call still ends with the busy |
| +// error once the wait has passed, and never runs fn. Publication waits |
| +// inside the registry's freeze, so the wait must hold. |
| +func TestHeldStageLockRetriesEndWithTheWait(t *testing.T) { |
| + prevWait, prevHook := heldLockWait, lockNamedOpened |
| + const wait = 200 * time.Millisecond |
| + heldLockWait = wait |
| + data := t.TempDir() |
| + path := filepath.Join(data, ".moves", heldStageLockFile(hsHost)) |
| + var stop atomic.Bool |
| + var replaced atomic.Int32 |
| + lockNamedOpened = func(name string) { |
| + if name != heldStageLockFile(hsHost) || stop.Load() { |
| + return |
| + } |
| + if err := os.Remove(path); err != nil { |
| + return |
| + } |
| + if err := os.WriteFile(path, nil, 0o644); err == nil { |
| + replaced.Add(1) |
| + } |
| + } |
| + var ran atomic.Bool |
| + done := make(chan error, 1) |
| + start := time.Now() |
| + go func() { done <- withHeldStageLock(data, hsHost, func() error { ran.Store(true); return nil }) }() |
| + t.Cleanup(func() { |
| + // A waiter still retrying is let through before the seam goes. |
| + stop.Store(true) |
| + select { |
| + case <-done: |
| + case <-time.After(10 * time.Second): |
| + } |
| + heldLockWait, lockNamedOpened = prevWait, prevHook |
| + }) |
| + var err error |
| + select { |
| + case err = <-done: |
| + done <- err |
| + case <-time.After(5 * time.Second): |
| + t.Fatalf("still retrying %v after a %v wait (%d files replaced, ran %v)", time.Since(start), wait, replaced.Load(), ran.Load()) |
| + } |
| + elapsed := time.Since(start) |
| + if !errors.Is(err, errHeldLockBusy) { |
| + t.Fatalf("with every opened file replaced: %v, want errHeldLockBusy", err) |
| + } |
| + if ran.Load() { |
| + t.Fatal("fn ran without the lock") |
| + } |
| + if elapsed > wait+2*time.Second { |
| + t.Fatalf("gave up after %v, want about %v", elapsed, wait) |
| + } |
| + if replaced.Load() < 2 { |
| + t.Fatalf("only %d files were replaced; the re-check was not exercised", replaced.Load()) |
| + } |
| + // Fix round 2: replacement retries pause (lockNamedRetryPause, capped |
| + // at what is left of the wait), so they cannot spin. A pause is a floor |
| + // on each retry's time, so this bound holds however loaded the box. |
| + if most := int32(wait/lockNamedRetryPause) + 2; replaced.Load() > most { |
| + t.Fatalf("%d replacement retries in a %v wait, want at most %d", replaced.Load(), wait, most) |
| + } |
| +} |
| + |
| +// Fix round 2: an open that meets a holder's removal after the wait has |
| +// passed ends in the caller's busy error, like every other way the wait |
| +// runs out, never the open's own error. Every open here meets that race: |
| +// it fails as an unlinked file does and the name is gone. |
| +func TestHeldStageLockOpenRaceAfterTheWaitIsBusy(t *testing.T) { |
| + prevWait, prevOpen := heldLockWait, lockNamedOpenAt |
| + t.Cleanup(func() { heldLockWait, lockNamedOpenAt = prevWait, prevOpen }) |
| + const wait = 150 * time.Millisecond |
| + heldLockWait = wait |
| + var opens atomic.Int32 |
| + lockNamedOpenAt = func(dir int, name string, flags int, mode os.FileMode) (*os.File, error) { |
| + opens.Add(1) |
| + return nil, &os.PathError{Op: "open", Path: name, Err: errors.New("not a regular file with one link")} |
| + } |
| + ran := false |
| + start := time.Now() |
| + err := withHeldStageLock(t.TempDir(), hsHost, func() error { ran = true; return nil }) |
| + elapsed := time.Since(start) |
| + if !errors.Is(err, errHeldLockBusy) { |
| + t.Fatalf("every open raced a removal until past the wait: %v, want errHeldLockBusy", err) |
| + } |
| + if ran { |
| + t.Fatal("fn ran without the lock") |
| + } |
| + if elapsed < wait || elapsed > wait+time.Second { |
| + t.Fatalf("gave up after %v, want about %v", elapsed, wait) |
| + } |
| + if opens.Load() < 2 { |
| + t.Fatalf("only %d opens; the race was not retried", opens.Load()) |
| + } |
| +} |
diff --git a/internal/host/moveshome_region_test.go b/internal/host/moveshome_region_test.go| new file mode 100644 |
| index 000000000..cbafb5a28 |
| --- /dev/null |
| +++ b/internal/host/moveshome_region_test.go |
| @@ -0,0 +1,46 @@ |
| +package host |
| + |
| +import ( |
| + "errors" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/hostclaim" |
| + "github.com/carlosframework/platform/internal/park" |
| + "github.com/carlosframework/platform/internal/provision" |
| +) |
| + |
| +// TestMovesBoxRegionIsThePlacements: the park view's region is this box's |
| +// as the claim knows it, and it must be the engine region of this box's own |
| +// placement code. A home box whose claim names home any other way (the |
| +// roster's region, a named code, the unset region) refuses rather than |
| +// reading a home record from a region that is not HomeRegion. |
| +func TestMovesBoxRegionIsThePlacements(t *testing.T) { |
| + for _, tc := range []struct { |
| + placement, claims string |
| + ok bool |
| + }{ |
| + {"", park.HomeRegion, true}, |
| + {"uk", "uk", true}, |
| + {"", "ie", false}, |
| + {"", "", false}, |
| + {"uk", "ie", false}, |
| + {"uk", park.HomeRegion, false}, |
| + {"IE", "IE", false}, |
| + {park.HomeRegion, park.HomeRegion, false}, |
| + } { |
| + f := newVwFixture(t) |
| + f.rec.Region = "" |
| + f.rec.Move = &provision.MoveState{ID: vwMove, Incarnation: f.inc, From: "", To: "uk", State: provision.MoveCutting} |
| + obs := vwObs(&hostclaim.Claim{Holder: f.holder(""), Op: hostclaim.Op{Kind: hostclaim.KindMoving, ID: vwMove, Phase: hostclaim.PhaseMoving, Exec: "box"}}) |
| + v, err := movesParkViewAt(f.records(), f.bucket, "data", movesBoxRegion(tc.placement, fixedRegion(tc.claims)))(t.Context(), obs) |
| + if tc.ok != (err == nil) { |
| + t.Fatalf("placement %q, claim region %q: view %+v, %v", tc.placement, tc.claims, v, err) |
| + } |
| + if tc.ok && v.Region != tc.claims { |
| + t.Fatalf("placement %q: the view's region %q", tc.placement, v.Region) |
| + } |
| + if !tc.ok && !errors.Is(err, park.ErrUnsafe) { |
| + t.Fatalf("placement %q, claim region %q: %v, want ErrUnsafe", tc.placement, tc.claims, err) |
| + } |
| + } |
| +} |
diff --git a/internal/host/moveshome_test.go b/internal/host/moveshome_test.go| new file mode 100644 |
| index 000000000..67923a1c5 |
| --- /dev/null |
| +++ b/internal/host/moveshome_test.go |
| @@ -0,0 +1,227 @@ |
| +package host |
| + |
| +import ( |
| + "errors" |
| + "strings" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/hostclaim" |
| + "github.com/carlosframework/platform/internal/moves" |
| + "github.com/carlosframework/platform/internal/park" |
| + "github.com/carlosframework/platform/internal/provision" |
| +) |
| + |
| +// Home-region parks, part 1 (Paul, 2026-10-11): a move out of the home |
| +// placement finishes its cleanup on its source box. The home's placement |
| +// code is ""; its engine region is park.HomeRegion, mapped once where a |
| +// placement code becomes an engine region (park.PlacementRegion). |
| + |
| +// TestMoveFromHomeClosesThroughTheSourcePark: the whole move out of home, |
| +// through the real paths. The target commits through Plan 3's committed arm, |
| +// which queues the source's location park in HomeRegion; the source box, |
| +// whose placement is "", runs it through the real park engine and the real |
| +// park view seen from home, retiring the pre-move generation from its |
| +// bucket; and the move closes. |
| +func TestMoveFromHomeClosesThroughTheSourcePark(t *testing.T) { |
| + h := newCloseHarness(t, fromHome) |
| + h.cfg.ParkView = movesParkView(MovesRecordsFrom(h.s), h.vs, "data-ie", park.HomeRegion) |
| + h.cfg.ParkAuthorize = allowParkAuthorize |
| + if rec := h.read(t, p1Host); rec.Move.From != "" || h.cfg.Region != "" { |
| + t.Fatalf("fixture: not a move out of home: from %q, source region %q", rec.Move.From, h.cfg.Region) |
| + } |
| + h.land(t) |
| + h.committedAndOpen(t) |
| + src := h.chore(t, park.HomeRegion, false) |
| + if src == nil || src.Owner != "region:"+park.HomeRegion || src.Park.Location != cutRoot { |
| + t.Fatalf("the commit queued the source's location park as %+v, want it in %s", src, park.HomeRegion) |
| + } |
| + for _, o := range h.claim(t).Obligations { |
| + if o.Region == "" || (o.Kind == "park-replica" && o.Region != park.HomeRegion && o.Region != "uk") { |
| + t.Fatalf("the commit queued %+v", o) |
| + } |
| + } |
| + // The target's probe recorded the home generation's region as home's, |
| + // and the commit's location park covered it. |
| + covered := false |
| + for _, a := range h.claim(t).Holder.Admissions { |
| + if a.Region == "uk" && a.Predecessor == "cur" { |
| + if a.PredecessorRegion != park.HomeRegion || !a.Covered { |
| + t.Fatalf("the probe's admission %+v: the home generation is not home's, or not covered", a) |
| + } |
| + covered = true |
| + } |
| + } |
| + if !covered { |
| + t.Fatalf("no admission retires the home generation: %+v", h.claim(t).Holder.Admissions) |
| + } |
| + h.clock.Advance(24 * time.Hour) |
| + h.land(t) |
| + if h.chore(t, "uk", true) != nil { |
| + t.Fatal("the staging park was not acknowledged") |
| + } |
| + h.notClosed(t, "the source has not parked") |
| + h.sourcePass(t) |
| + h.done(t) |
| + for k := range h.f.buckets["data-ie"] { |
| + if strings.HasPrefix(k, cutRoot+"/") { |
| + t.Fatalf("still at the home location: %s", k) |
| + } |
| + } |
| + pre := 0 |
| + for k := range h.f.buckets["data-ie"] { |
| + if strings.HasPrefix(k, "parked/"+h.inc+"/"+p1Host+"/"+src.Retirement+"/instances/") && strings.Contains(k, "/g-cur/") { |
| + pre++ |
| + } |
| + } |
| + if pre == 0 { |
| + t.Fatal("the pre-move generation was not archived under the location park's retirement") |
| + } |
| + q := h.claim(t).QuietAfter |
| + if _, ok := q[park.HomeRegion]; !ok { |
| + t.Fatalf("quiet_after %v names no %s", q, park.HomeRegion) |
| + } |
| + if _, ok := q[""]; ok { |
| + t.Fatalf("quiet_after %v names the unset region", q) |
| + } |
| + if m := h.read(t, p1Host).LastMove; m == nil || m.Outcome != "done" { |
| + t.Fatalf("the move did not close: %+v", m) |
| + } |
| +} |
| + |
| +// TestHomeParkViewNamesHomeAsARegion: the real park view for a move out of |
| +// home names HomeRegion where the spec's table names the source, seen from |
| +// home and from elsewhere; before the commit, home stays authoritative on |
| +// home (J2/F4). An unset region, a named region spelled "home" and a legacy |
| +// home holder with no admission still refuse. |
| +func TestHomeParkViewNamesHomeAsARegion(t *testing.T) { |
| + type row struct { |
| + name, seen string |
| + claim func(f *vwFixture) *hostclaim.Claim |
| + state string |
| + want string // "" wants a refusal |
| + } |
| + op := func(kind, phase string) hostclaim.Op { |
| + return hostclaim.Op{Kind: kind, ID: vwMove, Phase: phase, Exec: "box"} |
| + } |
| + home := park.HomeRegion |
| + rows := []row{ |
| + {"moving, a legacy holder, seen from home", home, func(f *vwFixture) *hostclaim.Claim { |
| + return &hostclaim.Claim{Holder: f.holder(""), Op: op(hostclaim.KindMoving, hostclaim.PhaseMoving)} |
| + }, provision.MoveCutting, home}, |
| + {"moving, admitted at home, seen from us", "us", func(f *vwFixture) *hostclaim.Claim { |
| + return &hostclaim.Claim{Holder: f.holder("cur", "cur@"+home), Op: op(hostclaim.KindMoving, hostclaim.PhaseMoving)} |
| + }, provision.MoveCutting, home}, |
| + {"reverting, seen from home", home, func(f *vwFixture) *hostclaim.Claim { |
| + return &hostclaim.Claim{Holder: f.holder("cur", "cur@"+home), Op: op(hostclaim.KindReverting, "")} |
| + }, provision.MoveReverting, home}, |
| + {"landing, the target's probe admitted, seen from home", home, func(f *vwFixture) *hostclaim.Claim { |
| + return &hostclaim.Claim{Holder: f.holder(vwMove, "cur@"+home, vwMove+"@uk"), Op: op(hostclaim.KindLanding, provision.LandProbing)} |
| + }, provision.MoveLanding, home}, |
| + {"landing, the target's probe admitted, seen from us", "us", func(f *vwFixture) *hostclaim.Claim { |
| + return &hostclaim.Claim{Holder: f.holder(vwMove, "cur@"+home, vwMove+"@uk"), Op: op(hostclaim.KindLanding, provision.LandProbing)} |
| + }, provision.MoveLanding, "uk"}, |
| + {"landing committed, seen from home", home, func(f *vwFixture) *hostclaim.Claim { |
| + return &hostclaim.Claim{Holder: f.holder(vwMove, "cur@"+home, vwMove+"@uk"), Op: op(hostclaim.KindLanding, hostclaim.PhaseCommitted)} |
| + }, provision.MoveCommitted, "uk"}, |
| + {"restoring before any admission", home, func(f *vwFixture) *hostclaim.Claim { |
| + return &hostclaim.Claim{Holder: f.holder(""), Op: op(hostclaim.KindRestoring, hostclaim.PhaseReleasing)} |
| + }, provision.MoveRollingBack, home}, |
| + {"moving, admitted in a named region spelled home", home, func(f *vwFixture) *hostclaim.Claim { |
| + return &hostclaim.Claim{Holder: f.holder("cur", "cur@home"), Op: op(hostclaim.KindMoving, hostclaim.PhaseMoving)} |
| + }, provision.MoveCutting, ""}, |
| + {"moving, seen from the unset region", "", func(f *vwFixture) *hostclaim.Claim { |
| + return &hostclaim.Claim{Holder: f.holder(""), Op: op(hostclaim.KindMoving, hostclaim.PhaseMoving)} |
| + }, provision.MoveCutting, ""}, |
| + {"serving, a legacy home holder with no admission", home, func(f *vwFixture) *hostclaim.Claim { |
| + return &hostclaim.Claim{Holder: f.holder(""), Op: hostclaim.Op{Kind: hostclaim.KindServing, Exec: "box"}} |
| + }, "", ""}, |
| + } |
| + for _, r := range rows { |
| + t.Run(r.name, func(t *testing.T) { |
| + f := newVwFixture(t) |
| + f.rec.Region = "" |
| + if r.state != "" { |
| + f.rec.Move = &provision.MoveState{ID: vwMove, Incarnation: f.inc, From: "", To: "uk", State: r.state} |
| + } |
| + obs := vwObs(r.claim(f)) |
| + v, err := movesParkView(f.records(), f.bucket, "data", r.seen)(t.Context(), obs) |
| + if r.want == "" { |
| + if !errors.Is(err, park.ErrUnsafe) { |
| + t.Fatalf("view %+v, %v; want a refusal", v, err) |
| + } |
| + return |
| + } |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if v.AuthoritativeRegion != r.want || v.Region != r.seen { |
| + t.Fatalf("view seen from %q names %q authoritative, want %q", v.Region, v.AuthoritativeRegion, r.want) |
| + } |
| + if (r.want == r.seen) != v.MarkerKnown { |
| + t.Fatalf("the marker was read %v, seen from %s with %s authoritative", v.MarkerKnown, r.seen, r.want) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +// TestHomeLaneIsArmed: the lane park's arming takes the home's "" as a |
| +// region (the lane predicate), and refuses a code that is no placement. |
| +func TestHomeLaneIsArmed(t *testing.T) { |
| + c := newRevertHarness(t).laneCfg() |
| + for code, armed := range map[string]bool{"uk": true, "": true, "home": true, "IE": false, park.HomeRegion: false, "a/b": false} { |
| + c.Region = code |
| + if revertLaneArmed(c) != armed { |
| + t.Fatalf("lane armed for %q: %v, want %v", code, !armed, armed) |
| + } |
| + } |
| +} |
| + |
| +// TestMovesClaimPolicyRecoveryFromHome: a rollback's or a revert's recovery |
| +// on a home source is granted to the source's selection in park.HomeRegion, |
| +// the claim's engine region for home, and refused to one in a named region, |
| +// one named "home" among them. |
| +func TestMovesClaimPolicyRecoveryFromHome(t *testing.T) { |
| + type base struct { |
| + claim func() *hostclaim.Claim |
| + record func() *provision.Record |
| + } |
| + for name, b := range map[string]base{ |
| + "restoring probing": {func() *hostclaim.Claim { return rpRestoringClaim(rpClaimPhase(provision.RbSourceProbing)) }, |
| + func() *provision.Record { return rpRestoringRecord(provision.RbSourceProbing) }}, |
| + "reverting probing": {func() *hostclaim.Claim { return rpRevertingClaim(hostclaim.PhaseProbing) }, rpRevertingRecord}, |
| + } { |
| + for region, granted := range map[string]bool{park.HomeRegion: true, "home": false, "ie": false, "uk": false} { |
| + c, rec := b.claim(), b.record() |
| + rec.Move.From = "" |
| + s := rpSelection(c, "spawn") |
| + s.Region = region |
| + err := MovesClaimPolicy((&mpReader{rec: rec}).read)(t.Context(), c, s, rpIdentity("spawn")) |
| + if (err == nil) != granted { |
| + t.Fatalf("%s from home, selection in %q: %v, want granted %v", name, region, err, granted) |
| + } |
| + } |
| + } |
| +} |
| + |
| +// TestHomeLaneStagingLeaseKey: the staging lease of a lane in home (a move |
| +// out of home's return lane, taken with the record's From) is keyed by |
| +// park.HomeRegion, never an empty segment, and apart from every named |
| +// region, one named "home" among them; a code that is no placement refuses. |
| +func TestHomeLaneStagingLeaseKey(t *testing.T) { |
| + const host = "a.go.example.com" |
| + home, err := stagingLeaseKey("", host) |
| + if err != nil || home != "control/staging-leases/"+park.HomeRegion+"/"+host || strings.Contains(home, "//") { |
| + t.Fatalf("the home lane's staging lease is %q, %v", home, err) |
| + } |
| + for _, code := range []string{"uk", "home", "none"} { |
| + if got, err := stagingLeaseKey(code, host); err != nil || got == home || got != moves.StagingLeasePrefix(code, host) { |
| + t.Fatalf("%s's staging lease is %q, %v (home's %q)", code, got, err, home) |
| + } |
| + } |
| + for _, code := range []string{"IE", park.HomeRegion, "a/b"} { |
| + if got, err := stagingLeaseKey(code, host); !errors.Is(err, park.ErrUnsafe) { |
| + t.Fatalf("invalid code %q: %q, %v", code, got, err) |
| + } |
| + } |
| +} |
diff --git a/internal/host/movesinstall.go b/internal/host/movesinstall.go| index c131af407..11ce9e776 100644 |
| --- a/internal/host/movesinstall.go |
| +++ b/internal/host/movesinstall.go |
| @@ -485,7 +485,7 @@ func (ir *installRun) parking(ctx context.Context) (bool, error) { |
| return false, err |
| } |
| scope := moves.ResidueScope(holder, *ir.rec.Move.FlippedAt) |
| - if obs, err = hostclaim.Update(ctx, ir.s, obs, hostclaim.QueueResidueParks(id, cfg.Region, scope, gens, obligationID)); err != nil { |
| + if obs, err = hostclaim.Update(ctx, ir.s, obs, hostclaim.QueueResidueParks(id, cfg.parkRegion(), scope, gens, obligationID)); err != nil { |
| return false, fmt.Errorf("queue the residue parks: %w", err) |
| } |
| if err := ir.fault("after-residue-queued"); err != nil { |
| @@ -494,7 +494,7 @@ func (ir *installRun) parking(ctx context.Context) (bool, error) { |
| eng := ir.engine(vs) |
| pending := 0 |
| for _, o := range obs.Claim.Obligations { |
| - if o.Op != id || o.Kind != "park-generation" || o.Region != cfg.Region { |
| + if o.Op != id || o.Kind != "park-generation" || o.Region != cfg.parkRegion() { |
| continue |
| } |
| w := park.Work{Host: host, ID: o.ID, Op: id} |
diff --git a/internal/host/movesland_probe_test.go b/internal/host/movesland_probe_test.go| index 6896b4a21..aae06d1fd 100644 |
| --- a/internal/host/movesland_probe_test.go |
| +++ b/internal/host/movesland_probe_test.go |
| @@ -53,7 +53,8 @@ func (p *probeLog) wake(ctx context.Context, cfg activator.ProbeConfig) (activat |
| if p.before != nil { |
| p.before(n) |
| } |
| - k := p.h.read(p.t, p1Host).Move.Landing.Attempts |
| + m := p.h.read(p.t, p1Host).Move |
| + k := m.Landing.Attempts |
| obs, err := hostclaim.Read(ctx, p.h.s, p1Host) |
| if err != nil { |
| return activator.ProbeResult{}, err |
| @@ -63,10 +64,9 @@ func (p *probeLog) wake(ctx context.Context, cfg activator.ProbeConfig) (activat |
| if h.WriterBox == "uk-box" { |
| gen = "fresh" + strconv.Itoa(n) |
| } |
| - from := "ie" |
| - if len(h.Admissions) > 0 { |
| - from = h.Admissions[len(h.Admissions)-1].Region |
| - } |
| + // The predecessor's region is the probe owners' own rule: the last |
| + // admission's region, or the move's source's engine region. |
| + from := predecessorRegion(h, m.From) |
| admitted, err := hostclaim.Update(ctx, p.h.s, obs, hostclaim.AdmitWriter(hostclaim.Admission{ObservedOpID: p1ID, ObservedPhase: provision.LandProbing, |
| ObservedSession: h.Session, ObservedGeneration: h.Generation, Box: "uk-box", Mode: "cold", NewGeneration: gen, KeepStamp: h.Stamp > h.Session, Region: "uk", PredecessorRegion: from})) |
| if err != nil { |
diff --git a/internal/host/movesland_test.go b/internal/host/movesland_test.go| index 851882a29..4ab71d15a 100644 |
| --- a/internal/host/movesland_test.go |
| +++ b/internal/host/movesland_test.go |
| @@ -276,9 +276,9 @@ func countRowsForTest(t *testing.T, path string) int { |
| return n |
| } |
| -func newLandingHarness(t *testing.T) *landingHarness { |
| +func newLandingHarness(t *testing.T, opts ...p1Option) *landingHarness { |
| t.Helper() |
| - h := &landingHarness{cutHarness: newCutHarness(t), leases: &fakeParkLeases{}, release: moves.Check{OK: true}} |
| + h := &landingHarness{cutHarness: newCutHarness(t, opts...), leases: &fakeParkLeases{}, release: moves.Check{OK: true}} |
| // The real replica replaces the harness's fake level files under the |
| // source's lineage; the final checkpoint adds nothing (the database is |
| // quiet at N). |
diff --git a/internal/host/moveslandcommit.go b/internal/host/moveslandcommit.go| index 60f6b17cb..a8e63313c 100644 |
| --- a/internal/host/moveslandcommit.go |
| +++ b/internal/host/moveslandcommit.go |
| @@ -8,6 +8,7 @@ import ( |
| "github.com/carlosframework/platform/internal/hostclaim" |
| "github.com/carlosframework/platform/internal/moves" |
| + "github.com/carlosframework/platform/internal/park" |
| "github.com/carlosframework/platform/internal/provision" |
| "github.com/carlosframework/platform/internal/registry" |
| "github.com/carlosframework/platform/internal/release" |
| @@ -162,7 +163,7 @@ func (l *landingRun) commitClaim(ctx context.Context) error { |
| if err != nil { |
| return err |
| } |
| - if _, err := hostclaim.Update(ctx, l.s, obs, hostclaim.KeepWriterSession(id, hostclaim.Commit(id, l.rec.Move.From, obs.Claim.Op.Intent["source"], cfg.Region, source, staging, obligationID))); err != nil { |
| + if _, err := hostclaim.Update(ctx, l.s, obs, hostclaim.KeepWriterSession(id, hostclaim.Commit(id, park.PlacementRegion(l.rec.Move.From), obs.Claim.Op.Intent["source"], cfg.parkRegion(), source, staging, obligationID))); err != nil { |
| return fmt.Errorf("move %s: the claim's commit: %w", id, err) |
| } |
| log.Printf("moves: %s: the claim of landing %s is committed", l.rec.Host, id) |
diff --git a/internal/host/moveslandpark.go b/internal/host/moveslandpark.go| index 645780250..6e18be1f6 100644 |
| --- a/internal/host/moveslandpark.go |
| +++ b/internal/host/moveslandpark.go |
| @@ -148,7 +148,7 @@ func isHexString(s string) bool { |
| // check (the arming requires all three). |
| func (ir *installRun) engine(vs lease.VersionedStore) park.Engine { |
| cfg := ir.cfg() |
| - return park.Engine{Store: vs, Claims: ir.s, Bucket: cfg.OwnBucket, Region: cfg.Region, Leases: cfg.ParkLeases, |
| + return park.Engine{Store: vs, Claims: ir.s, Bucket: cfg.OwnBucket, Region: cfg.parkRegion(), Leases: cfg.ParkLeases, |
| Now: cfg.Now, NewID: obligationID, View: cfg.ParkView, Authorize: movesArmAuthorize(cfg.ParkAuthorize, ir.authorizeObligation), After: cfg.parkFault} |
| } |
diff --git a/internal/host/movesparkfakes_test.go b/internal/host/movesparkfakes_test.go| index fbaca8928..09db434e7 100644 |
| --- a/internal/host/movesparkfakes_test.go |
| +++ b/internal/host/movesparkfakes_test.go |
| @@ -29,9 +29,9 @@ func standInView(region, role string, m *provision.MoveState) func(context.Conte |
| var authoritative string |
| switch role { |
| case roleTarget: |
| - authoritative = m.From |
| + authoritative = park.PlacementRegion(m.From) |
| case roleSource: |
| - authoritative = m.To |
| + authoritative = park.PlacementRegion(m.To) |
| default: |
| return park.View{}, park.ErrUnsafe |
| } |
diff --git a/internal/host/movesparks_test.go b/internal/host/movesparks_test.go| index ec4613b75..3f2051ac1 100644 |
| --- a/internal/host/movesparks_test.go |
| +++ b/internal/host/movesparks_test.go |
| @@ -26,9 +26,9 @@ import ( |
| // source region's acknowledgement yet (C4's regional runner), so |
| // regionalAck stands in for it. |
| -func newCloseHarness(t *testing.T) *finalHarness { |
| +func newCloseHarness(t *testing.T, opts ...p1Option) *finalHarness { |
| t.Helper() |
| - h := newFinalHarness(t) |
| + h := newFinalHarness(t, opts...) |
| if _, err := provision.SetRecordMove(context.Background(), h.s, "bab", "app", p1Host, func(rec *provision.Record) error { |
| rec.Move.CutAttempts = 2 |
| return nil |
diff --git a/internal/host/movespass.go b/internal/host/movespass.go| index 1d3ca644f..7c51332fc 100644 |
| --- a/internal/host/movespass.go |
| +++ b/internal/host/movespass.go |
| @@ -284,6 +284,20 @@ func movesDataDir(dd string) string { |
| return abs |
| } |
| +// stagingLeaseKey is the staging lease of a lane in targetRegion, the |
| +// lane's placement code (the record's To, or its From for a return lane): |
| +// moves.StagingLeasePrefix keyed by the lane's engine region, so a lane in |
| +// home is park.HomeRegion and never an empty segment |
| +// (control/staging-leases//<host>; home-region parks, part 1). Named codes |
| +// keep their keys. A code that is no placement refuses. |
| +func stagingLeaseKey(targetRegion, host string) (string, error) { |
| + r := park.PlacementRegion(targetRegion) |
| + if r == "" { |
| + return "", fmt.Errorf("%w: %q is not a placement code", park.ErrUnsafe, targetRegion) |
| + } |
| + return moves.StagingLeasePrefix(r, host), nil |
| +} |
| + |
| // armPhase1 wires the Phase 1 hooks (moves design §7.3). Only with the |
| // switch on: off, none is set and eb.Strict stays nil. writes is the |
| // agent's host-write configuration (the claim wiring's): the landing's |
| @@ -296,7 +310,11 @@ func armPhase1(cfg *moveCfg, w *movesWorker, hib activator.Config, dataDir strin |
| cfg.DeployBucketRegion = edgesFileRegion(getenv, readPlacementRegion()) |
| bucket, region := cfg.DeployBucket, cfg.DeployBucketRegion |
| cfg.StagingLease = func(ctx context.Context, targetRegion, host string) (moves.SafeUntilGuard, func(context.Context) error, error) { |
| - h, err := lease.AcquireTTL(ctx, bucket, moves.StagingLeasePrefix(targetRegion, host), region, moves.StagingLeaseTTL) |
| + key, err := stagingLeaseKey(targetRegion, host) |
| + if err != nil { |
| + return nil, nil, err |
| + } |
| + h, err := lease.AcquireTTL(ctx, bucket, key, region, moves.StagingLeaseTTL) |
| if err != nil { |
| return nil, nil, err |
| } |
diff --git a/internal/host/movesphase1_harness_test.go b/internal/host/movesphase1_harness_test.go| index b989ac083..6b0483220 100644 |
| --- a/internal/host/movesphase1_harness_test.go |
| +++ b/internal/host/movesphase1_harness_test.go |
| @@ -19,6 +19,7 @@ import ( |
| "github.com/carlosframework/platform/internal/hostclaim" |
| "github.com/carlosframework/platform/internal/lease" |
| "github.com/carlosframework/platform/internal/moves" |
| + "github.com/carlosframework/platform/internal/park" |
| "github.com/carlosframework/platform/internal/provision" |
| "github.com/carlosframework/platform/internal/registry" |
| "github.com/carlosframework/platform/internal/release" |
| @@ -351,11 +352,26 @@ type phase1Harness struct { |
| // worker1 is the source box's worker and worker2 the target's, each |
| // kept across passes as one agent keeps its own. |
| worker1, worker2 *movesWorker |
| + // src is the source box's placement code: "ie", or the home's "" for a |
| + // move out of home (fromHome). |
| + src string |
| } |
| -func newPhase1Harness(t *testing.T) *phase1Harness { |
| +// p1Option adjusts a Phase 1 harness before it is built. |
| +type p1Option func(*phase1Harness) |
| + |
| +// fromHome builds the harness for a move out of the home placement: the |
| +// source box, its record and its move name the home's code, "" (home-region |
| +// parks, part 1). The source box keeps its name and bucket. |
| +func fromHome(h *phase1Harness) { h.src = "" } |
| + |
| +func newPhase1Harness(t *testing.T, opts ...p1Option) *phase1Harness { |
| t.Helper() |
| - h := &phase1Harness{moveHarness: newMoveHarness(t), lease: &fakeStagingLease{}, clock: &stepClock{now: time.Date(2026, 10, 7, 9, 0, 0, 0, time.UTC)}} |
| + h := &phase1Harness{moveHarness: newMoveHarness(t), lease: &fakeStagingLease{}, clock: &stepClock{now: time.Date(2026, 10, 7, 9, 0, 0, 0, time.UTC)}, src: "ie"} |
| + for _, o := range opts { |
| + o(h) |
| + } |
| + h.cfg.Region = h.src |
| h.vs = &fakeVersioned{f: h.f} |
| h.lease.until = h.clock.now.Add(moves.StagingLeaseTTL) |
| inc, err := hostclaim.Incarnation("bab", "app", p1Host, p1Created) |
| @@ -374,7 +390,7 @@ func newPhase1Harness(t *testing.T) *phase1Harness { |
| h.optDir = t.TempDir() |
| h.route = registry.Route{Host: p1Host, Kind: "instance", App: "app", Account: "bab", Hibernate: true, Status: "ready", |
| Socket: provision.DefaultSocket(p1Host), DBPath: provision.DefaultDBPath(h.cfg.DataDir, p1Host), Provisioned: true} |
| - h.env = map[string]string{"CARLOS_REGION": "ie", "CARLOS_CONTROL_SOCKET": "/run/carlos/" + p1Host + ".sock.control", "APP_MODE": "prod"} |
| + h.env = map[string]string{"CARLOS_REGION": h.src, "CARLOS_CONTROL_SOCKET": "/run/carlos/" + p1Host + ".sock.control", "APP_MODE": "prod"} |
| h.probe, h.strict = moves.Check{OK: true}, moves.Check{OK: true} |
| ctx := context.Background() |
| @@ -439,7 +455,7 @@ func newPhase1Harness(t *testing.T) *phase1Harness { |
| // The source's edges file: ie publishes its data bucket too (T10). |
| if _, err := h.s.Put(ctx, "control/edges/ie-box.json", |
| - []byte(`{"v":1,"box":"ie-box","eip":"1.2.3.5","placement":"ie","data_bucket":"data-ie","data_bucket_region":"eu-west-1","hosts":{}}`), ""); err != nil { |
| + []byte(`{"v":1,"box":"ie-box","eip":"1.2.3.5","placement":"`+h.src+`","data_bucket":"data-ie","data_bucket_region":"eu-west-1","hosts":{}}`), ""); err != nil { |
| t.Fatal(err) |
| } |
| // The app's instances manifest: it declares no readiness path. |
| @@ -477,19 +493,19 @@ func pointLiveForTest(t *testing.T, optDir, host, v string) { |
| func (h *phase1Harness) start(t *testing.T, cut string) provision.Record { |
| t.Helper() |
| ctx := context.Background() |
| - rec := provision.Record{Schema: 1, Host: p1Host, Slug: "s", App: "app", Account: "bab", Region: "ie", Channel: "stable", CreatedAt: p1Created} |
| + rec := provision.Record{Schema: 1, Host: p1Host, Slug: "s", App: "app", Account: "bab", Region: h.src, Channel: "stable", CreatedAt: p1Created} |
| if err := provision.CreateRecord(ctx, h.s, rec); err != nil { |
| t.Fatal(err) |
| } |
| if _, err := provision.SetRecordMove(ctx, h.s, "bab", "app", p1Host, provision.MoveStartTransition(provision.MoveStart{ |
| - ID: p1ID, Incarnation: h.inc, From: "ie", To: "uk", Cut: cut, By: "op", Now: h.clock.Now()})); err != nil { |
| + ID: p1ID, Incarnation: h.inc, From: h.src, To: "uk", Cut: cut, By: "op", Now: h.clock.Now()})); err != nil { |
| t.Fatal(err) |
| } |
| obs, err := hostclaim.Update(ctx, h.s, hostclaim.Observed{Host: p1Host, Absent: true}, func(c *hostclaim.Claim) error { |
| c.Holder = &hostclaim.Holder{Family: "instance", Key: provision.RecordKey("bab", "app", p1Host), Account: "bab", App: "app", |
| Incarnation: h.inc, CreatedAt: p1Created, Location: "instances/" + p1Host + "/" + h.inc, Generation: "cur", Stamp: 1} |
| c.Op = hostclaim.Op{Kind: hostclaim.KindServing, Exec: "ie-box"} |
| - c.Touched = hostclaim.Touched{Complete: true, Regions: []string{"ie"}, Boxes: []string{"ie-box"}} |
| + c.Touched = hostclaim.Touched{Complete: true, Regions: []string{park.PlacementRegion(h.src)}, Boxes: []string{"ie-box"}} |
| return nil |
| }) |
| if err != nil { |
diff --git a/internal/host/movespolicy.go b/internal/host/movespolicy.go| index b5eaa465b..de2b1fc22 100644 |
| --- a/internal/host/movespolicy.go |
| +++ b/internal/host/movespolicy.go |
| @@ -10,6 +10,7 @@ import ( |
| "github.com/carlosframework/platform/internal/hostbox" |
| "github.com/carlosframework/platform/internal/hostclaim" |
| "github.com/carlosframework/platform/internal/hostwrite" |
| + "github.com/carlosframework/platform/internal/park" |
| "github.com/carlosframework/platform/internal/provision" |
| "github.com/carlosframework/platform/internal/registry" |
| "github.com/carlosframework/platform/internal/release" |
| @@ -436,7 +437,7 @@ func movesRecoveryPolicy(ctx context.Context, read MovesRecordReader, c *hostcla |
| if rec == nil || rec.Host != c.Host || rec.Move == nil || rec.Move.ID != s.Op || rec.Move.Incarnation != c.Holder.Incarnation { |
| return refuse("the record is not this move", nil) |
| } |
| - if rec.Move.From != s.Region { |
| + if park.PlacementRegion(rec.Move.From) != s.Region { |
| return refuse("this region is not the move's source region", nil) |
| } |
| var ( |
diff --git a/internal/host/movesprobeowners.go b/internal/host/movesprobeowners.go| index 165056fac..421b1eeb0 100644 |
| --- a/internal/host/movesprobeowners.go |
| +++ b/internal/host/movesprobeowners.go |
| @@ -26,6 +26,7 @@ import ( |
| "github.com/carlosframework/platform/internal/hostwrite" |
| "github.com/carlosframework/platform/internal/moves" |
| "github.com/carlosframework/platform/internal/moves/archive" |
| + "github.com/carlosframework/platform/internal/park" |
| "github.com/carlosframework/platform/internal/provision" |
| "github.com/carlosframework/platform/internal/registry" |
| "github.com/carlosframework/platform/internal/release" |
| @@ -390,6 +391,15 @@ func predecessorRegion(h *hostclaim.Holder, from string) string { |
| if n := len(h.Admissions); n > 0 { |
| return h.Admissions[n-1].Region |
| } |
| + // from is the move's source placement code. Its engine region is the |
| + // predecessor's (park.HomeRegion for the home's ""): the claim reads a |
| + // predecessor region of "" as holding nothing anywhere and covers it |
| + // with no chore, which a home generation does not (home-region parks, |
| + // part 1). A code that is no placement is kept as it is: it names no |
| + // region, so nothing covers it by mistake. |
| + if r := park.PlacementRegion(from); r != "" { |
| + return r |
| + } |
| return from |
| } |
diff --git a/internal/host/movesrecover.go b/internal/host/movesrecover.go| index 74bc04486..52fced74a 100644 |
| --- a/internal/host/movesrecover.go |
| +++ b/internal/host/movesrecover.go |
| @@ -672,7 +672,7 @@ func (r *recoverRun) probed(ctx context.Context) (bool, error) { |
| return false, err |
| } |
| scope := moves.ReturnLaneScope(host, r.rb, obs.Claim.Holder.Incarnation, seal.Bound()) |
| - lane = hostclaim.QueueReturnLanePark(id, cfg.Region, scope, obligationID) |
| + lane = hostclaim.QueueReturnLanePark(id, cfg.parkRegion(), scope, obligationID) |
| } |
| switch obs.Claim.Op.Phase { |
| case hostclaim.PhaseProbing: |
diff --git a/internal/host/movesrevert.go b/internal/host/movesrevert.go| index 0a0346de1..91ac4b200 100644 |
| --- a/internal/host/movesrevert.go |
| +++ b/internal/host/movesrevert.go |
| @@ -158,11 +158,13 @@ func revertSourceArmed(cfg moveCfg) bool { |
| } |
| // revertLaneArmed reports whether this box can run the target region's lane |
| -// park: the claim's park leases, view and authorizer, and this box's own |
| -// bucket. In production armMovesParks sets the three only with claim Plan |
| -// 12, so this answers false there until it lands. |
| +// park: an engine region (parkRegion, the home's "" included as |
| +// park.HomeRegion; a code that is no placement is not one), the claim's |
| +// park leases, view and authorizer, and this box's own bucket. In |
| +// production armMovesParks sets the three only with claim Plan 12, so this |
| +// answers false there until it lands. |
| func revertLaneArmed(cfg moveCfg) bool { |
| - return cfg.Box != "" && cfg.Region != "" && cfg.OwnBucket != "" && parkHooksArmed(cfg) && cfg.Versioned != nil |
| + return cfg.Box != "" && cfg.parkRegion() != "" && cfg.OwnBucket != "" && parkHooksArmed(cfg) && cfg.Versioned != nil |
| } |
| // restatusHold is moveCfg.RestatusHold in production: an op's own hold |
| @@ -899,7 +901,7 @@ func (w *movesWorker) laneParkTarget(ctx context.Context, s release.Store, rec p |
| lane := moves.ScopeLocation(host, id) |
| var chores []hostclaim.Obligation |
| for _, o := range obs.Claim.Obligations { |
| - if o.Op == id && o.Kind == "park-replica" && o.Region == cfg.Region && o.Park != nil && o.Park.Location == lane { |
| + if o.Op == id && o.Kind == "park-replica" && o.Region == cfg.parkRegion() && o.Park != nil && o.Park.Location == lane { |
| chores = append(chores, o) |
| } |
| } |
| @@ -917,12 +919,12 @@ func (w *movesWorker) laneParkTarget(ctx context.Context, s release.Store, rec p |
| own := func(_ context.Context, obs hostclaim.Observed, o hostclaim.Obligation) error { |
| c := obs.Claim |
| if obs.Absent || c == nil || !inOp(c.Op) || (!committed && c.Op.Exec == cfg.Box) || |
| - o.Op != id || o.Kind != "park-replica" || o.Region != cfg.Region || o.Park == nil || o.Park.Location != lane { |
| + o.Op != id || o.Kind != "park-replica" || o.Region != cfg.parkRegion() || o.Park == nil || o.Park.Location != lane { |
| return fmt.Errorf("%w: not this move's lane park", park.ErrUnsafe) |
| } |
| return nil |
| } |
| - e := park.Engine{Store: vs, Claims: s, Bucket: cfg.OwnBucket, Region: cfg.Region, Leases: cfg.ParkLeases, |
| + e := park.Engine{Store: vs, Claims: s, Bucket: cfg.OwnBucket, Region: cfg.parkRegion(), Leases: cfg.ParkLeases, |
| Now: w.now, NewID: obligationID, View: cfg.ParkView, Authorize: movesArmAuthorize(cfg.ParkAuthorize, own), After: cfg.parkFault} |
| for _, o := range chores { |
| err := e.Run(ctx, park.Work{Host: host, ID: o.ID, Op: id}) |
diff --git a/internal/host/movesrollback.go b/internal/host/movesrollback.go| index cc7fa5539..df525c985 100644 |
| --- a/internal/host/movesrollback.go |
| +++ b/internal/host/movesrollback.go |
| @@ -556,7 +556,7 @@ func (r *rollbackRun) claimReleased(ctx context.Context, evidence string) error |
| } |
| holder := obs.Claim.Holder |
| scope := moves.TargetLocationScope(*holder, report.CheckedAt) |
| - if _, err := hostclaim.Update(ctx, r.s, obs, hostclaim.TargetReleased(r.g.ID, cfg.Box, cfg.Region, evidence, scope, obligationID)); err != nil { |
| + if _, err := hostclaim.Update(ctx, r.s, obs, hostclaim.TargetReleased(r.g.ID, cfg.Box, cfg.parkRegion(), evidence, scope, obligationID)); err != nil { |
| return fmt.Errorf("the claim half of the release: %w", err) |
| } |
| log.Printf("moves: %s: the target gave execution up for rollback %s, citing %s", r.rec.Host, r.rb, evidence) |
| @@ -1200,7 +1200,7 @@ func (r *rollbackRun) remote(ctx context.Context, obs hostclaim.Observed, j *mov |
| return false, err |
| } |
| scope := moves.StagingScope(host, id, obs.Claim.Holder.Incarnation, staged.Bound()) |
| - if _, err := hostclaim.Update(ctx, r.s, obs, hostclaim.QueueStagingPark(id, cfg.Region, scope, obligationID)); err != nil { |
| + if _, err := hostclaim.Update(ctx, r.s, obs, hostclaim.QueueStagingPark(id, cfg.parkRegion(), scope, obligationID)); err != nil { |
| return false, fmt.Errorf("queue the move's lane park: %w", err) |
| } |
| if err := mark(rbStepLaneQueued, ""); err != nil { |
| @@ -1218,7 +1218,7 @@ func (r *rollbackRun) remote(ctx context.Context, obs hostclaim.Observed, j *mov |
| e := r.engine(vs) |
| waiting := 0 |
| for _, o := range obs.Claim.Obligations { |
| - if o.Op != id || o.Kind != "park-replica" || o.Region != cfg.Region || o.Park == nil { |
| + if o.Op != id || o.Kind != "park-replica" || o.Region != cfg.parkRegion() || o.Park == nil { |
| continue |
| } |
| work := park.Work{Host: host, ID: o.ID, Op: id} |
| @@ -1255,7 +1255,7 @@ func (r *rollbackRun) engine(vs lease.VersionedStore) park.Engine { |
| } |
| return r.authorizeChore(obs.Claim, o) |
| } |
| - return park.Engine{Store: vs, Claims: r.s, Bucket: cfg.OwnBucket, Region: cfg.Region, Leases: cfg.ParkLeases, |
| + return park.Engine{Store: vs, Claims: r.s, Bucket: cfg.OwnBucket, Region: cfg.parkRegion(), Leases: cfg.ParkLeases, |
| Now: r.now, NewID: obligationID, View: cfg.ParkView, Authorize: movesArmAuthorize(cfg.ParkAuthorize, own), After: cfg.parkFault} |
| } |
| @@ -1270,7 +1270,7 @@ func (r *rollbackRun) authorizeChore(c *hostclaim.Claim, o hostclaim.Obligation) |
| return fmt.Errorf("%w: not this rollback's chore", park.ErrUnsafe) |
| } |
| switch { |
| - case o.Kind == "park-replica" && o.Region == cfg.Region: |
| + case o.Kind == "park-replica" && o.Region == cfg.parkRegion(): |
| case o.Kind == "park-local" && o.Box == cfg.Box: |
| default: |
| return fmt.Errorf("%w: not this target's chore", park.ErrUnsafe) |
diff --git a/internal/host/movessources.go b/internal/host/movessources.go| index 72a23bda7..fa02f2353 100644 |
| --- a/internal/host/movessources.go |
| +++ b/internal/host/movessources.go |
| @@ -57,9 +57,43 @@ import ( |
| // movesNoLiveRegion is the authoritative region the view names when the |
| // spec's table says none: a claim with no holder, or a creating, deleting, |
| -// parking or purging op. No region code is "none", so the engine protects |
| +// parking or purging op. The "@" keeps it outside the placement-code |
| +// alphabet (provision.ValidRegion; "none" itself is a valid code) and apart |
| +// from park.HomeRegion, so no box's region is ever it and the engine protects |
| // nothing in any region, which is what "nothing is live" means. |
| -const movesNoLiveRegion = "none" |
| +const movesNoLiveRegion = "@none" |
| + |
| +// parkRegion is this box's engine region: its placement code through |
| +// park.PlacementRegion, so the home's "" is park.HomeRegion (home-region |
| +// parks, part 1). Every park engine a moves arm builds, every chore it looks |
| +// for or queues in its own region, and the region its claim's engine must |
| +// have go through it. An invalid code maps to "", which arms nothing and |
| +// which every engine entry refuses. |
| +func (c moveCfg) parkRegion() string { return park.PlacementRegion(c.Region) } |
| + |
| +// movesBoxRegion is the park view's region resolver in production: this box's |
| +// region as the claim knows it (resolve, the verified roster row), accepted |
| +// only when it is the engine region of this box's own placement code. A home |
| +// box (placement "") whose claim names it anything but park.HomeRegion, the |
| +// roster's own name for home among them, refuses: the view would otherwise |
| +// read a home record's "" as HomeRegion while this box's engine runs under |
| +// another name, and protect nothing of home's live set here. |
| +func movesBoxRegion(placement string, resolve func(context.Context) (string, error)) func(context.Context) (string, error) { |
| + return func(ctx context.Context) (string, error) { |
| + want := park.PlacementRegion(placement) |
| + if want == "" { |
| + return "", fmt.Errorf("%w: this box's placement %q is not a placement code", park.ErrUnsafe, placement) |
| + } |
| + got, err := resolve(ctx) |
| + if err != nil { |
| + return "", err |
| + } |
| + if got != want { |
| + return "", fmt.Errorf("%w: the claim names this box's region %q, its placement's engine region is %q", park.ErrUnsafe, got, want) |
| + } |
| + return got, nil |
| + } |
| +} |
| // movesClaimPlan12 is what claim Plan 12 adds to the MoveParks capability. |
| // Nothing on main implements it. |
| @@ -141,8 +175,11 @@ func cachedRegion(resolve func(context.Context) (string, error)) func(context.Co |
| // movesParkViewAt is movesParkView seen from the region regionOf answers. |
| // Production's is the claim's verified roster row's region |
| // (productionRosterRegion), the region the claim's engine runs in, so the |
| -// view's region is the claim's by construction (M17; fix round 1). The home |
| -// region, "", is a region like any other. |
| +// view's region is the claim's by construction (M17; fix round 1), held to |
| +// this box's placement by movesBoxRegion. Every region it names is an engine |
| +// region: the home is park.HomeRegion, never "", and a region of "" (unset) |
| +// refuses. The record's placement codes are mapped once, in |
| +// movesAuthoritative. |
| func movesParkViewAt(records MovesRecordReader, data genselect.Reader, bucket string, regionOf func(context.Context) (string, error)) func(context.Context, hostclaim.Observed) (park.View, error) { |
| return func(ctx context.Context, obs hostclaim.Observed) (park.View, error) { |
| if ctx == nil || records == nil || data == nil || bucket == "" || regionOf == nil { |
| @@ -155,6 +192,9 @@ func movesParkViewAt(records MovesRecordReader, data genselect.Reader, bucket st |
| if err != nil { |
| return park.View{}, fmt.Errorf("this box's region: %w", err) |
| } |
| + if region == "" { |
| + return park.View{}, fmt.Errorf("%w: this box's region is unset", park.ErrUnsafe) |
| + } |
| auth, err := movesAuthoritative(ctx, records, obs, region) |
| if err != nil { |
| return park.View{}, err |
| @@ -365,6 +405,14 @@ func readMarker(ctx context.Context, data genselect.Reader, bucket, host, locati |
| // (the record-first close, M4), when the newest admission decides alone. A |
| // region the claim's history contradicts refuses. So does any other kind |
| // (placing, relocating): it has no row in the table. |
| +// |
| +// The move's From and To are placement codes; they are compared and |
| +// answered as engine regions (park.PlacementRegion), so a move out of home |
| +// names park.HomeRegion, which no named region equals, and a code that is no |
| +// placement refuses. region is already an engine region. A serving holder |
| +// with no admission still takes the record's region as it is, and a record |
| +// with the home's "" there still refuses: that row is the claim's sweep of |
| +// every legacy home host, not a move's, and is left as it was. |
| func movesAuthoritative(ctx context.Context, records MovesRecordReader, obs hostclaim.Observed, region string) (string, error) { |
| c := obs.Claim |
| h := c.Holder |
| @@ -404,6 +452,10 @@ func movesAuthoritative(ctx context.Context, records MovesRecordReader, obs host |
| } |
| return "", fmt.Errorf("%w: the record does not carry move %s", park.ErrUnsafe, c.Op.ID) |
| } |
| + from, to := park.PlacementRegion(m.From), park.PlacementRegion(m.To) |
| + if from == "" || to == "" { |
| + return "", fmt.Errorf("%w: move %s names %q and %q, which are not both placement codes", park.ErrUnsafe, m.ID, m.From, m.To) |
| + } |
| contradicts := func(want ...string) error { |
| if !known { |
| return nil |
| @@ -417,38 +469,38 @@ func movesAuthoritative(ctx context.Context, records MovesRecordReader, obs host |
| } |
| switch c.Op.Kind { |
| case hostclaim.KindMoving, hostclaim.KindReverting: |
| - if err := contradicts(m.From); err != nil { |
| + if err := contradicts(from); err != nil { |
| return "", err |
| } |
| - return m.From, nil |
| + return from, nil |
| case hostclaim.KindLanding: |
| - if err := contradicts(m.From, m.To); err != nil { |
| + if err := contradicts(from, to); err != nil { |
| return "", err |
| } |
| - if known && admitted == m.To { |
| - if region == m.From && c.Op.Phase != hostclaim.PhaseCommitted && m.State != provision.MoveCommitted { |
| + if known && admitted == to { |
| + if region == from && c.Op.Phase != hostclaim.PhaseCommitted && m.State != provision.MoveCommitted { |
| // J2/F4 (Paul's #236 ruling): the source's live set, its |
| // files live set among it, stays protected on the source |
| // until the record commits. Seen from the source's region the |
| // source stays authoritative, so the engine protects there |
| // too (and refuses a park of the location, whose current |
| // generation the source's marker does not name). |
| - return m.From, nil |
| + return from, nil |
| } |
| - return m.To, nil |
| + return to, nil |
| } |
| if c.Op.Phase == hostclaim.PhaseCommitted || m.State == provision.MoveCommitted { |
| - return "", fmt.Errorf("%w: move %s committed with no admission in %s", park.ErrUnsafe, m.ID, m.To) |
| + return "", fmt.Errorf("%w: move %s committed with no admission in %s", park.ErrUnsafe, m.ID, to) |
| } |
| - return m.From, nil |
| + return from, nil |
| default: // restoring |
| - if err := contradicts(m.From, m.To); err != nil { |
| + if err := contradicts(from, to); err != nil { |
| return "", err |
| } |
| if known { |
| return admitted, nil |
| } |
| - return m.From, nil |
| + return from, nil |
| } |
| } |
| @@ -575,9 +627,11 @@ func registerMovesParks(src movesParkSources) error { |
| // armMovesParks sets the moves arms' park hooks from the claim's MoveParks |
| // capability (M17): its engine's leases, view and authorizer, only when the |
| -// engine's bucket and region are this box's own bucket and placement |
| -// region, and claim Plan 12's RunHeld and local authorizer (M5, M9). Any |
| -// refusal leaves every hook unset, so every arm stays unarmed. |
| +// engine's bucket and region are this box's own bucket and its placement's |
| +// engine region (parkRegion: park.HomeRegion for the home's ""), and claim |
| +// Plan 12's RunHeld and local authorizer (M5, M9). An engine region of "" |
| +// never arms. Any refusal leaves every hook unset, so every arm stays |
| +// unarmed. |
| func armMovesParks(ctx context.Context, cfg *moveCfg, providers ClaimProviders, plan12 func() (movesClaimPlan12, error)) error { |
| if ctx == nil || cfg == nil || plan12 == nil { |
| return hostwrite.ErrUnavailable |
| @@ -586,8 +640,8 @@ func armMovesParks(ctx context.Context, cfg *moveCfg, providers ClaimProviders, |
| if err != nil { |
| return fmt.Errorf("the claim's MoveParks: %w", err) |
| } |
| - if engine.Bucket != cfg.OwnBucket || engine.Region != cfg.Region { |
| - return fmt.Errorf("%w: the claim's park engine is %s in %q, this box's moves use %s in %q", hostbox.ErrChanged, engine.Bucket, engine.Region, cfg.OwnBucket, cfg.Region) |
| + if want := cfg.parkRegion(); want == "" || engine.Bucket != cfg.OwnBucket || engine.Region != want { |
| + return fmt.Errorf("%w: the claim's park engine is %s in %q, this box's moves use %s in %q (placement %q)", hostbox.ErrChanged, engine.Bucket, engine.Region, cfg.OwnBucket, want, cfg.Region) |
| } |
| if engine.Leases == nil || engine.View == nil || engine.Authorize == nil { |
| return errors.Join(hostwrite.ErrUnavailable, errors.New("the claim's park engine lacks its leases, view or authorizer")) |
| @@ -612,10 +666,14 @@ func armMovesParks(ctx context.Context, cfg *moveCfg, providers ClaimProviders, |
| // on (ruling H1), over the deployment store's records, this box's data |
| // bucket read by exact version (lease.NewVersionedS3, through rootaws), and |
| // the region regionOf answers, behind the claim Plan 12 guard. Production's |
| -// regionOf is productionRosterRegion: the claim's verified roster row, the |
| -// home region "" included (fix round 1). Off, it registers nothing and the |
| -// claim keeps its named refusals. Task 5's evidence goes through refusals, |
| -// which pages its anomalies once (movesrefusals.go). |
| +// regionOf is productionRosterRegion: the claim's verified roster row (fix |
| +// round 1), held by movesBoxRegion to the engine region of this box's own |
| +// placement, CARLOS_REGION. The roster refuses a row with region "", so on |
| +// a home box it names home otherwise than park.HomeRegion, and the view |
| +// refuses there until the claim names home as the engine does (home-region |
| +// parks, part 1). Off, it registers nothing and the claim keeps its named |
| +// refusals. Task 5's evidence goes through refusals, which pages its |
| +// anomalies once (movesrefusals.go). |
| func registerProductionMovesParks(ctx context.Context, getenv func(string) string, store release.Store, hib activator.Config, regionOf func(context.Context) (string, error), refusals *movesRefusals) error { |
| if !hostwrite.Enabled(getenv) || !moves.Enabled(getenv) { |
| return nil |
| @@ -629,7 +687,8 @@ func registerProductionMovesParks(ctx context.Context, getenv func(string) strin |
| } |
| records := MovesRecordsFrom(store) |
| evidence := refusals.evidence(movesRetirementEvidence{Records: records, Now: time.Now}) |
| - src, err := newMovesParkSources(records, data, hib.Bucket, cachedRegion(regionOf), evidence, movesPlan12Parks) |
| + region := movesBoxRegion(strings.TrimSpace(getenv("CARLOS_REGION")), cachedRegion(regionOf)) |
| + src, err := newMovesParkSources(records, data, hib.Bucket, region, evidence, movesPlan12Parks) |
| if err != nil { |
| return err |
| } |
diff --git a/internal/host/movessources_test.go b/internal/host/movessources_test.go| index b46336d09..10289f532 100644 |
| --- a/internal/host/movessources_test.go |
| +++ b/internal/host/movessources_test.go |
| @@ -558,31 +558,49 @@ func TestRegisterProductionMovesParksNeedsBothSwitches(t *testing.T) { |
| } |
| } |
| -// TestHomeBoxArms (fix round 1, region source): the home region, "", is a |
| -// region like any other. A home box's view is seen from "" and its arms take |
| -// an engine whose region is "". |
| +// TestHomeBoxArms (fix round 1, region source; home-region parks, part 1): |
| +// a home box, whose placement code is "", is seen from park.HomeRegion and |
| +// its arms take an engine whose region is park.HomeRegion. An engine region |
| +// of "" (unset) never arms, and neither does a box whose code is no |
| +// placement, even when the engine's region spells the same string. |
| func TestHomeBoxArms(t *testing.T) { |
| f := newVwFixture(t) |
| f.rec.Region = "" |
| - view := movesParkViewAt(f.records(), f.bucket, "data", fixedRegion("")) |
| + view := movesParkViewAt(f.records(), f.bucket, "data", fixedRegion(park.HomeRegion)) |
| obs := vwObs(&hostclaim.Claim{Holder: f.holder(""), Op: hostclaim.Op{Kind: hostclaim.KindMoving, ID: vwMove, Phase: hostclaim.PhaseMoving, Exec: "box"}}) |
| f.rec.Move = &provision.MoveState{ID: vwMove, Incarnation: f.inc, From: "", To: "uk", State: provision.MoveCutting} |
| v, err := view(t.Context(), obs) |
| - if err != nil || v.Region != "" || v.AuthoritativeRegion != "" || !v.MarkerKnown { |
| + if err != nil || v.Region != park.HomeRegion || v.AuthoritativeRegion != park.HomeRegion || !v.MarkerKnown { |
| t.Fatalf("the home view %+v, %v", v, err) |
| } |
| auth := func(context.Context, hostclaim.Observed, hostclaim.Obligation) error { return nil } |
| - providers := ClaimProviders{moveParks: &claimMoveParks{ |
| - local: func(context.Context, *hostclaim.Claim, hostclaim.Obligation) error { return nil }, |
| - engine: park.Engine{Bucket: "data-home", Region: "", Leases: &fakeParkLeases{}, View: view, Authorize: auth}, |
| - leases: movesLeaseSet("data-home"), check: func(context.Context) error { return nil }}} |
| - c := moveCfg{OwnBucket: "data-home", Region: ""} |
| - if err := armMovesParks(t.Context(), &c, providers, func() (movesClaimPlan12, error) { return &fakePlan12{}, nil }); err != nil { |
| + arm := func(engineRegion, placement string) (moveCfg, error) { |
| + providers := ClaimProviders{moveParks: &claimMoveParks{ |
| + local: func(context.Context, *hostclaim.Claim, hostclaim.Obligation) error { return nil }, |
| + engine: park.Engine{Bucket: "data-home", Region: engineRegion, Leases: &fakeParkLeases{}, View: view, Authorize: auth}, |
| + leases: movesLeaseSet("data-home"), check: func(context.Context) error { return nil }}} |
| + c := moveCfg{OwnBucket: "data-home", Region: placement} |
| + err := armMovesParks(t.Context(), &c, providers, func() (movesClaimPlan12, error) { return &fakePlan12{}, nil }) |
| + return c, err |
| + } |
| + c, err := arm(park.HomeRegion, "") |
| + if err != nil { |
| t.Fatalf("a home box did not arm: %v", err) |
| } |
| if !parkHooksArmed(c) { |
| t.Fatal("hooks unset") |
| } |
| + if c, err := arm("uk", "uk"); err != nil || !parkHooksArmed(c) { |
| + t.Fatalf("a named box did not arm: %v", err) |
| + } |
| + for _, tc := range [][2]string{{"", ""}, {"ie", ""}, {"home", ""}, {park.HomeRegion, "home"}, {park.HomeRegion, park.HomeRegion}, {"IE", "IE"}, {"", "uk"}} { |
| + if c, err := arm(tc[0], tc[1]); !errors.Is(err, hostbox.ErrChanged) || parkHooksArmed(c) { |
| + t.Fatalf("engine in %q, placement %q: armed (%v)", tc[0], tc[1], err) |
| + } |
| + } |
| + if provision.ValidRegion(movesNoLiveRegion) || movesNoLiveRegion == park.HomeRegion { |
| + t.Fatalf("the no-live sentinel %q can be a box's region", movesNoLiveRegion) |
| + } |
| } |
| // TestParkViewRegionIsTheRosters (fix round 1, region source): the view |
diff --git a/internal/hostops/move_fence_release.go b/internal/hostops/move_fence_release.go| index 939bf2c21..2a7063128 100644 |
| --- a/internal/hostops/move_fence_release.go |
| +++ b/internal/hostops/move_fence_release.go |
| @@ -7,6 +7,7 @@ import ( |
| "github.com/carlosframework/platform/internal/hostclaim" |
| "github.com/carlosframework/platform/internal/hostops/internal/fencerelease" |
| "github.com/carlosframework/platform/internal/moves" |
| + "github.com/carlosframework/platform/internal/park" |
| "github.com/carlosframework/platform/internal/provision" |
| "github.com/carlosframework/platform/internal/release" |
| ) |
| @@ -94,7 +95,10 @@ func (g *MoveRecoveryGrant) ReleaseFencedTarget(ctx context.Context, ref hostcla |
| return writeRecord() |
| } |
| h := o.Claim.Holder |
| - r := hostclaim.FencedTargetRelease{OpID: m.ID, RollbackID: rb.ID, Target: o.Claim.Op.Intent["target"], TargetRegion: m.To, SourceRegion: m.From, |
| + // The record's codes become engine regions (park.PlacementRegion): a |
| + // move out of home names its source park.HomeRegion, never "", which |
| + // the claim refuses (home-region parks, part 1). |
| + r := hostclaim.FencedTargetRelease{OpID: m.ID, RollbackID: rb.ID, Target: o.Claim.Op.Intent["target"], TargetRegion: park.PlacementRegion(m.To), SourceRegion: park.PlacementRegion(m.From), |
| Location: moves.TargetLocationScope(*h, fence.ObservedAt()), Lane: moves.StagingScope(o.Host, m.ID, h.Incarnation, fence.ObservedAt()), NewID: moveLossNewID(ref.SHA256), Fence: fence} |
| if err := CheckCreateFreeze(ctx, g.w.raw, g.w.pub); err != nil { |
| return err |
| @@ -159,7 +163,7 @@ func (g *MoveRecoveryGrant) StampLostMoveTargetParked(ctx context.Context) error |
| return err |
| } |
| m := rec.Move |
| - if m == nil || m.ID != g.o.OpID || m.Incarnation != g.o.Incarnation || m.Rollback == nil || !hostclaim.LostMoveTargetParked(o.Claim, m.To) { |
| + if m == nil || m.ID != g.o.OpID || m.Incarnation != g.o.Incarnation || m.Rollback == nil || !hostclaim.LostMoveTargetParked(o.Claim, park.PlacementRegion(m.To)) { |
| return ErrPending |
| } |
| if m.Rollback.TargetParkedAt != nil { |
diff --git a/internal/hostops/move_recovery_commit_test.go b/internal/hostops/move_recovery_commit_test.go| index e2045a9bc..36cc707b1 100644 |
| --- a/internal/hostops/move_recovery_commit_test.go |
| +++ b/internal/hostops/move_recovery_commit_test.go |
| @@ -6,6 +6,7 @@ import ( |
| "github.com/carlosframework/platform/internal/hostclaim" |
| "github.com/carlosframework/platform/internal/moves" |
| + "github.com/carlosframework/platform/internal/park" |
| "github.com/carlosframework/platform/internal/provision" |
| ) |
| @@ -59,3 +60,55 @@ func TestMoveRecoveryCommitUsesSharedSealValidation(t *testing.T) { |
| }) |
| } |
| } |
| + |
| +// Home-region parks, part 1: the console's commit of a move out of home |
| +// queues the source's location park in park.HomeRegion, exactly as the |
| +// box's commit does (K9), and covers the home generation's admission. The |
| +// record names home by its code, "", which the claim would refuse. |
| +func TestMoveRecoveryCommitFromHome(t *testing.T) { |
| + w := newLossWorld(t, "landing", func(c *hostclaim.Claim, r *provision.Record, _ *AppAdmission) { |
| + c.Op.Phase = hostclaim.PhaseLandingProbed |
| + r.Move.State = provision.MoveCommitted |
| + r.Move.CommittedAt = r.Move.FlippedAt |
| + r.Move.From = "" |
| + for i := range c.Holder.Admissions { |
| + a := &c.Holder.Admissions[i] |
| + if a.Region == "ie" { |
| + a.Region = park.HomeRegion |
| + } |
| + if a.PredecessorRegion == "ie" { |
| + a.PredecessorRegion = park.HomeRegion |
| + } |
| + } |
| + }) |
| + before := w.claim(t) |
| + err := w.control.RunRecovery(context.Background(), func(ctx context.Context) error { |
| + return runMoveRecovery(ctx, before, func(ctx context.Context, g *MoveRecoveryGrant) error { |
| + return g.Claim(ctx, RecoveryMoveCommit) |
| + }) |
| + }) |
| + after := w.claim(t) |
| + if err != nil || after.Claim.Op.Phase != hostclaim.PhaseCommitted { |
| + t.Fatalf("commit: %v, claim=%+v", err, after.Claim.Op) |
| + } |
| + found := false |
| + for _, o := range after.Claim.Obligations { |
| + if o.Region == "" { |
| + t.Fatalf("a chore in the unset region: %+v", o) |
| + } |
| + if o.Kind == "park-replica" && o.Park != nil && o.Park.Location == "instances/"+lossHost { |
| + if o.Region != park.HomeRegion || o.Owner != "region:"+park.HomeRegion { |
| + t.Fatalf("the source's location park %+v, want it in %s", o, park.HomeRegion) |
| + } |
| + found = true |
| + } |
| + } |
| + if !found { |
| + t.Fatalf("no location park: %+v", after.Claim.Obligations) |
| + } |
| + for _, a := range after.Claim.Holder.Admissions { |
| + if a.PredecessorRegion == park.HomeRegion && !a.Covered { |
| + t.Fatalf("the home generation's admission is not covered: %+v", a) |
| + } |
| + } |
| +} |
diff --git a/internal/hostops/move_recovery_grant.go b/internal/hostops/move_recovery_grant.go| index 47ad94212..284c96077 100644 |
| --- a/internal/hostops/move_recovery_grant.go |
| +++ b/internal/hostops/move_recovery_grant.go |
| @@ -11,6 +11,7 @@ import ( |
| "github.com/carlosframework/platform/internal/hostclaim" |
| "github.com/carlosframework/platform/internal/moves" |
| + "github.com/carlosframework/platform/internal/park" |
| "github.com/carlosframework/platform/internal/provision" |
| "github.com/carlosframework/platform/internal/release" |
| ) |
| @@ -418,7 +419,10 @@ func (g *MoveRecoveryGrant) Claim(ctx context.Context, action MoveRecoveryClaimA |
| return err |
| } |
| n := 0 |
| - transition = hostclaim.Commit(g.o.OpID, m.From, c.Op.Intent["source"], m.To, source, staging, func() string { n++; return id + strconv.Itoa(n) }) |
| + // The record's codes become engine regions as the box's commit |
| + // makes them (park.PlacementRegion; K9): a move out of home queues |
| + // its location park in park.HomeRegion, never "". |
| + transition = hostclaim.Commit(g.o.OpID, park.PlacementRegion(m.From), c.Op.Intent["source"], park.PlacementRegion(m.To), source, staging, func() string { n++; return id + strconv.Itoa(n) }) |
| default: |
| return ErrIdentity |
| } |
diff --git a/internal/hostops/plan12_fence_home_test.go b/internal/hostops/plan12_fence_home_test.go| new file mode 100644 |
| index 000000000..57e51a797 |
| --- /dev/null |
| +++ b/internal/hostops/plan12_fence_home_test.go |
| @@ -0,0 +1,75 @@ |
| +package hostops |
| + |
| +import ( |
| + "context" |
| + "strings" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/hostclaim" |
| + "github.com/carlosframework/platform/internal/park" |
| + "github.com/carlosframework/platform/internal/provision" |
| +) |
| + |
| +// Home-region parks, part 1, after claim Plan 12: the recovery grant's |
| +// release of a fenced target (ReleaseFencedTarget) for a move out of home. |
| +// The record names the source home by its code, ""; the release hands the |
| +// claim the source's engine region, park.HomeRegion, which the claim |
| +// requires to be a region and not the target's. Every chore it queues |
| +// names a region, and the lost target's parks are found under the |
| +// target's engine region (LostMoveTargetParked). |
| +func TestPlan12RecoveryFenceGrantFromHome(t *testing.T) { |
| + w := newC7World(t, func(c *hostclaim.Claim, r *provision.Record, _ *AppAdmission) { |
| + c.Op.Kind, c.Op.Phase, c.Op.Exec = hostclaim.KindLanding, hostclaim.PhaseProbing, "source" |
| + c.Op.Intent["source"], c.Op.Intent["target"], c.Op.Intent["to"], c.Op.Intent["target_enrollment"] = "target", "source", "ie", strings.Repeat("a", 32) |
| + c.Op.Participants = []string{"target", "source"} |
| + c.Obligations = nil |
| + c.Op.Journal = map[string]string{hostclaim.JournalSourceStamp: "1", hostclaim.JournalSrcGen: "src"} |
| + r.Region = "" |
| + r.Move.From, r.Move.To, r.Move.State = "", "ie", provision.MoveRollingBack |
| + at := r.CreatedAt |
| + r.Move.Rollback = &provision.MoveRollback{ID: "rb_abcdefghijklmnopqrstuvwxyz", Reason: provision.RollbackTargetFenced, Evidence: "control/box-fences/" + strings.Repeat("a", 32) + ".json", RequestedAt: &at, TargetPhase: provision.RbTargetLost, LossRef: "control/box-fences/" + strings.Repeat("a", 32) + ".json", LostAt: &at, SourcePhase: provision.RbSourceWaiting} |
| + }) |
| + authority := &plan12BumpFence{c7ReplayAuthority: w.authority} |
| + act := func(fn func(context.Context, *MoveRecoveryGrant) error) error { |
| + return w.control.RunRecovery(t.Context(), func(ctx context.Context) error { return runMoveRecovery(ctx, w.claim(t), fn, authority) }) |
| + } |
| + if err := act(func(ctx context.Context, g *MoveRecoveryGrant) error { return g.ReleaseFencedTarget(ctx, w.ref) }); err != nil { |
| + t.Fatalf("the fenced target of a move out of home was not released: %v", err) |
| + } |
| + o := w.claim(t) |
| + if o.Claim.Op.Phase != hostclaim.PhaseReleased || len(o.Claim.Obligations) == 0 { |
| + t.Fatalf("the release: %+v", o.Claim) |
| + } |
| + for _, ob := range o.Claim.Obligations { |
| + if ob.Region == "" && ob.Kind != "park-local" { |
| + t.Fatalf("a chore in the unset region: %+v", ob) |
| + } |
| + if ob.Region == park.HomeRegion { |
| + t.Fatalf("a target's chore in home: %+v", ob) |
| + } |
| + } |
| + // The replay writes the record's half (the target released). |
| + if err := act(func(ctx context.Context, g *MoveRecoveryGrant) error { return g.ReleaseFencedTarget(ctx, w.ref) }); err != nil { |
| + t.Fatalf("the release's replay: %v", err) |
| + } |
| + if rec, err := provision.GetRecord(t.Context(), w.raw, "bab", "app", w.host); err != nil || rec.Move.Rollback.TargetPhase != provision.RbTargetReleased { |
| + t.Fatalf("the record after the replay: %+v %v", rec, err) |
| + } |
| + o = w.claim(t) |
| + if hostclaim.LostMoveTargetParked(o.Claim, park.PlacementRegion("ie")) { |
| + t.Fatal("parked with the target's parks owed") |
| + } |
| + current := o |
| + for _, ob := range o.Claim.Obligations { |
| + var err error |
| + if current, err = hostclaim.Update(t.Context(), w.raw, current, hostclaim.CompleteObligation(ob.Op, ob.ID)); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + if err := act(func(ctx context.Context, g *MoveRecoveryGrant) error { return g.StampLostMoveTargetParked(ctx) }); err != nil { |
| + t.Fatalf("the lost target's parked stamp: %v", err) |
| + } |
| + if rec, err := provision.GetRecord(t.Context(), w.raw, "bab", "app", w.host); err != nil || rec.Move.Rollback.TargetParkedAt == nil { |
| + t.Fatalf("not stamped: %+v %v", rec, err) |
| + } |
| +} |
diff --git a/internal/park/home_region_test.go b/internal/park/home_region_test.go| new file mode 100644 |
| index 000000000..256e8b6fd |
| --- /dev/null |
| +++ b/internal/park/home_region_test.go |
| @@ -0,0 +1,262 @@ |
| +package park |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "strings" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/hostclaim" |
| + "github.com/carlosframework/platform/internal/provision" |
| +) |
| + |
| +// Home-region parks, part 1 (Paul, 2026-10-11). The home placement's code is |
| +// "", which the engine has always read as unset and refused. Home gets its |
| +// own engine region, HomeRegion, outside the placement-code alphabet, mapped |
| +// once from the placement code (PlacementRegion); "" keeps its meaning. |
| + |
| +// TestHomeRegionIsReserved: HomeRegion is not a placement code, so no named |
| +// region can ever equal it, and PlacementRegion agrees with the placement |
| +// validator on what a code is. |
| +func TestHomeRegionIsReserved(t *testing.T) { |
| + if HomeRegion == "" || provision.ValidRegion(HomeRegion) { |
| + t.Fatalf("HomeRegion %q is a placement code", HomeRegion) |
| + } |
| + if got := PlacementRegion(""); got != HomeRegion { |
| + t.Fatalf("the home's code maps to %q, want %q", got, HomeRegion) |
| + } |
| + for _, code := range []string{"ie", "uk", "home", "none", "0", "a-b", "eu-west-1", strings.Repeat("a", 16)} { |
| + if got := PlacementRegion(code); got != code || got == HomeRegion { |
| + t.Fatalf("named code %q maps to %q", code, got) |
| + } |
| + } |
| + for _, code := range []string{HomeRegion, hostclaim.RootGeneration, "IE", "ie/", "/", "a b", "ie\x00", "..", "région", strings.Repeat("a", 17)} { |
| + if got := PlacementRegion(code); got != "" { |
| + t.Fatalf("invalid code %q maps to %q, want unset", code, got) |
| + } |
| + } |
| + // The local alphabet is the validator's, never wider. |
| + for _, code := range []string{"", "a", "-", "z9", "ie", "IE", "@home", "@", "a_b", "a.b", strings.Repeat("x", 16), strings.Repeat("x", 17)} { |
| + if (PlacementRegion(code) != "") != provision.ValidRegion(code) { |
| + t.Fatalf("PlacementRegion(%q) = %q disagrees with provision.ValidRegion = %v", code, PlacementRegion(code), provision.ValidRegion(code)) |
| + } |
| + } |
| +} |
| + |
| +// choreIn rewrites the fixture's one chore (and the claim's touched region) |
| +// to region. |
| +func choreIn(t *testing.T, f *fixture, region string) { |
| + t.Helper() |
| + obs, err := hostclaim.Read(t.Context(), f.e.Claims, f.w.Host) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + f.ob.Region, f.ob.Owner = region, "region:"+region |
| + if _, err := hostclaim.Update(t.Context(), f.e.Claims, obs, func(c *hostclaim.Claim) error { |
| + c.Obligations = []hostclaim.Obligation{f.ob} |
| + c.Touched.Regions = []string{region} |
| + return nil |
| + }); err != nil { |
| + t.Fatal(err) |
| + } |
| +} |
| + |
| +// engineIn points the fixture's engine at region, its view seen from seen. |
| +func engineIn(f *fixture, region, seen string) { |
| + f.e.Region = region |
| + f.e.View = func(_ context.Context, o hostclaim.Observed) (View, error) { |
| + return View{ClaimETag: o.ETag, Region: seen, AuthoritativeRegion: "uk"}, nil |
| + } |
| +} |
| + |
| +// TestHomeAndNamedEnginesNeverRunEachOthersChores: through Engine.Run, an |
| +// engine for home never acts on a named region's chore, a named region's |
| +// engine (one named "home" among them) never acts on home's, and a home |
| +// engine whose view is seen from another region acts on nothing. Each |
| +// refusal leaves the bucket and the claim as they were. |
| +func TestHomeAndNamedEnginesNeverRunEachOthersChores(t *testing.T) { |
| + for _, tc := range []struct { |
| + name string |
| + chore, engine, view string |
| + want error |
| + }{ |
| + {"home engine, named chore", "ie", HomeRegion, HomeRegion, ErrUnsafe}, |
| + {"named engine, home chore", HomeRegion, "ie", "ie", ErrUnsafe}, |
| + {"engine named home, home chore", HomeRegion, "home", "home", ErrUnsafe}, |
| + {"home engine, home chore, view from ie", HomeRegion, HomeRegion, "ie", ErrChanged}, |
| + {"unset engine, unset view, home chore", HomeRegion, "", "", ErrUnsafe}, |
| + } { |
| + t.Run(tc.name, func(t *testing.T) { |
| + f := newFixture(t) |
| + f.seed() |
| + choreIn(t, f, tc.chore) |
| + engineIn(f, tc.engine, tc.view) |
| + before := f.b.Snapshot() |
| + if err := f.e.Run(t.Context(), f.w); !errors.Is(err, tc.want) { |
| + t.Fatalf("run: %v, want %v", err, tc.want) |
| + } |
| + if f.b.Snapshot() != before { |
| + t.Fatal("the bucket changed") |
| + } |
| + obs, err := hostclaim.Read(t.Context(), f.e.Claims, f.w.Host) |
| + if err != nil || len(obs.Claim.Obligations) != 1 || obs.Claim.Obligations[0].Region != tc.chore || len(obs.Claim.QuietAfter) != 0 { |
| + t.Fatalf("the claim changed: %+v %v", obs.Claim, err) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +// TestHomeEngineParksUnderItsOwnRegion: a home engine runs a home chore to |
| +// its acknowledgement. The archive's keys carry no region and no empty |
| +// segment; the manifest, the quiet window and the coverage name HomeRegion, |
| +// never "" and never a named region. |
| +func TestHomeEngineParksUnderItsOwnRegion(t *testing.T) { |
| + f := newFixture(t) |
| + source := f.seed() |
| + choreIn(t, f, HomeRegion) |
| + engineIn(f, HomeRegion, HomeRegion) |
| + if err := f.e.Run(t.Context(), f.w); err != nil { |
| + t.Fatal(err) |
| + } |
| + obs, err := hostclaim.Read(t.Context(), f.e.Claims, f.w.Host) |
| + if err != nil || len(obs.Claim.Obligations) != 0 { |
| + t.Fatalf("still owed: %+v %v", obs.Claim, err) |
| + } |
| + if got, want := obs.Claim.QuietAfter[HomeRegion], f.ob.Park.EvidenceAt.Add(SignatureAge+time.Second); !got.Equal(want) || len(obs.Claim.QuietAfter) != 1 { |
| + t.Fatalf("quiet_after %v, want only %s at %v", obs.Claim.QuietAfter, HomeRegion, want) |
| + } |
| + m, err := f.e.manifest(t.Context(), f.w, f.ob) |
| + if err != nil || m == nil || m.Region != HomeRegion || len(m.Delete) != len(source) { |
| + t.Fatalf("manifest %+v %v", m, err) |
| + } |
| + base := "parked/" + f.ob.Park.Incarnation + "/" + f.w.Host + "/" + f.ob.Retirement + "/" |
| + for _, a := range m.Archive { |
| + k := a.Destination.Key |
| + if !strings.HasPrefix(k, base+"instances/") || strings.Contains(k, "//") || strings.Contains(k, HomeRegion) { |
| + t.Fatalf("archive key %q is not under %sinstances/ or carries a region", k, base) |
| + } |
| + } |
| + if vs, err := f.e.list(t.Context(), f.w.Host, f.ob.Park.Location); err != nil || len(vs) != 0 { |
| + t.Fatalf("residue %v %v", vs, err) |
| + } |
| +} |
| + |
| +// TestHomeLiveSetIsRegionQualified: the live set the engine protects is |
| +// qualified by HomeRegion. Admissions recorded in HomeRegion form the home's |
| +// unfinished chain; the same admissions recorded in a named region spelled |
| +// "home" do not, and a view seen from a named region protects nothing of |
| +// home's. A pending chore in a named region never stands for home's. |
| +func TestHomeLiveSetIsRegionQualified(t *testing.T) { |
| + inRegion := func(t *testing.T, region string) (*fixture, hostclaim.Observed, View) { |
| + f, obs, v, _ := sweepFixture(t) |
| + obs, err := hostclaim.Update(t.Context(), f.e.Claims, obs, func(c *hostclaim.Claim) error { |
| + for i := range c.Holder.Admissions { |
| + c.Holder.Admissions[i].Region, c.Holder.Admissions[i].PredecessorRegion = region, region |
| + } |
| + return nil |
| + }) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + v.ClaimETag, v.Region, v.AuthoritativeRegion = obs.ETag, HomeRegion, HomeRegion |
| + return f, obs, v |
| + } |
| + _, obs, v := inRegion(t, HomeRegion) |
| + live, err := LiveGenerations(obs.Claim, v, "") |
| + if err != nil || !live["g3"] || !live["g2"] || !live["g1"] { |
| + t.Fatalf("home's own chain: %v %v", live, err) |
| + } |
| + _, obs, v = inRegion(t, "home") |
| + if live, err := LiveGenerations(obs.Claim, v, ""); !errors.Is(err, ErrUnsafe) { |
| + t.Fatalf("a chain recorded in the named region home stood for HomeRegion's: %v %v", live, err) |
| + } |
| + _, obs, v = inRegion(t, HomeRegion) |
| + v.Region = "ie" |
| + if live, err := LiveGenerations(obs.Claim, v, ""); err != nil || len(live) != 0 { |
| + t.Fatalf("seen from ie, home's live set: %v %v", live, err) |
| + } |
| + |
| + // A pending chore blocks Decide only in its own region. |
| + f, obs, v := inRegion(t, HomeRegion) |
| + vs, err := f.e.Discover(t.Context(), f.w.Host) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, region := range []string{"ie", "home", HomeRegion} { |
| + ob := f.ob |
| + ob.Region, ob.Owner = region, "region:"+region |
| + c := *obs.Claim |
| + c.Obligations = []hostclaim.Obligation{ob} |
| + d, err := Decide(hostclaim.Observed{Host: obs.Host, Claim: &c, ETag: obs.ETag}, v, vs, f.ob.Park.Incarnation) |
| + if err != nil { |
| + t.Fatalf("%s: %v", region, err) |
| + } |
| + if blocked := len(d.Candidates) == 0; blocked != (region == HomeRegion) { |
| + t.Fatalf("a pending chore in %s: candidates %+v", region, d.Candidates) |
| + } |
| + } |
| +} |
| + |
| +// TestHomeSweepQueuesUnderHomeRegion: a home engine's sweep queues its |
| +// chores as HomeRegion's, owned by region:HomeRegion, runs them and keys the |
| +// quiet window by HomeRegion. |
| +func TestHomeSweepQueuesUnderHomeRegion(t *testing.T) { |
| + f := newFixture(t) |
| + f.seed() |
| + obs, _ := hostclaim.Read(t.Context(), f.e.Claims, f.w.Host) |
| + if _, err := hostclaim.Update(t.Context(), f.e.Claims, obs, func(c *hostclaim.Claim) error { |
| + c.Obligations = nil |
| + return hostclaim.FreeAfterPark("op")(c) |
| + }); err != nil { |
| + t.Fatal(err) |
| + } |
| + engineIn(f, HomeRegion, HomeRegion) |
| + var queued []hostclaim.Obligation |
| + f.e.Authorize = func(_ context.Context, _ hostclaim.Observed, o hostclaim.Obligation) error { |
| + queued = append(queued, o) |
| + return nil |
| + } |
| + 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) |
| + } |
| + if len(queued) == 0 { |
| + t.Fatal("nothing was queued") |
| + } |
| + for _, o := range queued { |
| + if o.Region != HomeRegion || o.Owner != "region:"+HomeRegion { |
| + t.Fatalf("queued %+v, want region %s", o, HomeRegion) |
| + } |
| + } |
| + 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) |
| + } |
| + if _, ok := obs.Claim.QuietAfter[HomeRegion]; !ok || len(obs.Claim.QuietAfter) != 1 { |
| + t.Fatalf("quiet_after %v", obs.Claim.QuietAfter) |
| + } |
| +} |
| + |
| +// TestUnsetRegionStillRefuses: "" is still unset at every engine entry, and |
| +// an invalid placement code maps to it. |
| +func TestUnsetRegionStillRefuses(t *testing.T) { |
| + f, obs, v, vs := sweepFixture(t) |
| + v.Region = "" |
| + if _, err := Decide(obs, v, vs, f.ob.Park.Incarnation); !errors.Is(err, ErrUnsafe) { |
| + t.Fatalf("decide from an unset region: %v", err) |
| + } |
| + v.Region, v.AuthoritativeRegion = "ie", "" |
| + if _, err := LiveGenerations(obs.Claim, v, ""); !errors.Is(err, ErrUnsafe) { |
| + t.Fatalf("an unset authoritative region: %v", err) |
| + } |
| + g := newFixture(t) |
| + g.seed() |
| + for _, code := range []string{"IE", "@home", "a/b"} { |
| + engineIn(g, PlacementRegion(code), PlacementRegion(code)) |
| + before := g.b.Snapshot() |
| + if err := g.e.Run(t.Context(), g.w); !errors.Is(err, ErrUnsafe) || g.b.Snapshot() != before { |
| + t.Fatalf("an engine for invalid code %q: %v", code, err) |
| + } |
| + } |
| +} |
diff --git a/internal/park/region.go b/internal/park/region.go| new file mode 100644 |
| index 000000000..8b680c3e5 |
| --- /dev/null |
| +++ b/internal/park/region.go |
| @@ -0,0 +1,38 @@ |
| +package park |
| + |
| +import "regexp" |
| + |
| +// HomeRegion is the engine region of the home placement (home-region parks, |
| +// part 1, Paul 2026-10-11). The home's placement code is "", and "" already |
| +// means unset to the engine and to the claim: an engine, view, chore or |
| +// admission with region "" is refused or read as "holds nothing anywhere". |
| +// So home is never "" here. It is this token, mapped once from the placement |
| +// code by PlacementRegion where a box's or a record's code becomes an engine |
| +// region. |
| +// |
| +// The "@" is outside the placement-code alphabet (provision.ValidRegion: |
| +// lowercase a-z, 0-9 and hyphen), so no named region, one named "home" |
| +// included, can ever equal it. It reaches the claim's obligation region, its |
| +// "region:" owner, its quiet_after key and one control key, the home lane's |
| +// staging lease (control/staging-leases/@home/<host>, keyed in the host's |
| +// wiring). It never reaches an archive path: the archive is |
| +// parked/<incarnation>/<host>/<retirement>/. |
| +const HomeRegion = "@home" |
| + |
| +// placementCode is a named placement code: provision.ValidRegion without the |
| +// empty code. TestHomeRegionIsReserved holds the two together. |
| +var placementCode = regexp.MustCompile(`^[a-z0-9-]{1,16}$`) |
| + |
| +// PlacementRegion is the engine region of a placement code: HomeRegion for |
| +// the home's "", a named code unchanged, and "" (unset, which every engine |
| +// entry refuses) for anything that is not a placement code, HomeRegion |
| +// itself included. |
| +func PlacementRegion(code string) string { |
| + switch { |
| + case code == "": |
| + return HomeRegion |
| + case placementCode.MatchString(code): |
| + return code |
| + } |
| + return "" |
| +} |
diff --git a/internal/provision/plan9_b8_census_test.go b/internal/provision/plan9_b8_census_test.go| index b8f4ca651..91cdd9cd5 100644 |
| --- a/internal/provision/plan9_b8_census_test.go |
| +++ b/internal/provision/plan9_b8_census_test.go |
| @@ -478,8 +478,8 @@ var plan9B8CensusInventory = []censusEntry{ |
| {"internal/hostops/move_loss_admission.go", "moveLossOwner.ValidateLossRecovery", "key-site", "key:RecordKey", "ea2aa35a069ffdc846ecb208c33940245fc865795188818e86ea7a93135dd6a9"}, |
| {"internal/host/claimretirementcensus.go", "claimArchivedLocations", "key-site", "lit:instances", "9ba8ea4ecb5ad63621c1322467b9a80f8f5703896ba1fc3f7cb1addd56418950"}, |
| {"internal/host/claimretirementcensus.go", "claimLegacyIncarnation", "key-site", "lit:instances", "31a14ea105c67e00a24f06b9500a221ee5bfc47402b9c33cecfde807e51db530"}, |
| - {"internal/hostops/move_fence_release.go", "MoveRecoveryGrant.ReleaseFencedTarget", "record-writer", "key:WriteRecord,key:writeRecord", "031a3a8169a92775bff8a1a1b08cfe82a5a613156a1bce45dc9cfcb34c56befe"}, |
| - {"internal/hostops/move_fence_release.go", "MoveRecoveryGrant.StampLostMoveTargetParked", "record-writer", "key:WriteRecord", "b082db5634a2f4d9a5b45cc4a9cb725964e9bb24b7aeb53a6346167d7737227c"}, |
| + {"internal/hostops/move_fence_release.go", "MoveRecoveryGrant.ReleaseFencedTarget", "record-writer", "key:WriteRecord,key:writeRecord", "13e4506d5825781237b85eb28db5641e25278f4ba9ea2e5475b6adca03d3ee12"}, |
| + {"internal/hostops/move_fence_release.go", "MoveRecoveryGrant.StampLostMoveTargetParked", "record-writer", "key:WriteRecord", "911ab90f0786d646b494a62c2d061852c35273dc538408636ebf2ec613fa2198"}, |
| {"internal/hostops/move_recovery_grant.go", "MoveRecoveryGrant.Convert", "record-writer", "key:WriteRecord", "794963a24a0a50d980505f83ac912973a10267b21f74bd41c56584ab9b0b25a1"}, |
| // The recovery pass's move log writer: one create-only entry of a move's |
| // log under its report prefix, for a host whose claim still names the |