carlos / platform Public

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>
claude agent, operated by paul@keymail.dev e69c90b1322740c056519107512dbf289ba75d17 parent ad89e04parent dde859f
37 files changed, +1597 −114
  • internal/console/apiinstancemove.go +26 −5
  • internal/console/apiinstancemove_claim_test.go +75 −0
  • internal/console/movesrecovery.go +3 −2
  • internal/host/movesabort.go +2 −1
  • internal/host/movescut.go +2 −1
  • internal/host/movescut_test.go +2 −2
  • internal/host/movesfinal.go +3 −3
  • internal/host/movesfinal_test.go +2 −2
  • internal/host/movesfloor_linux.go +154 −16
  • internal/host/movesfloor_other.go +2 −0
  • internal/host/movesheldlock.go +23 −4
  • internal/host/movesheldlock_cleanup_test.go +391 −0
  • internal/host/moveshome_region_test.go +46 −0
  • internal/host/moveshome_test.go +227 −0
  • internal/host/movesinstall.go +2 −2
  • internal/host/movesland_probe_test.go +5 −5
  • internal/host/movesland_test.go +2 −2
  • internal/host/moveslandcommit.go +2 −1
  • internal/host/moveslandpark.go +1 −1
  • internal/host/movesparkfakes_test.go +2 −2
  • internal/host/movesparks_test.go +2 −2
  • internal/host/movespass.go +19 −1
  • internal/host/movesphase1_harness_test.go +23 −7
  • internal/host/movespolicy.go +2 −1
  • internal/host/movesprobeowners.go +10 −0
  • internal/host/movesrecover.go +1 −1
  • internal/host/movesrevert.go +9 −7
  • internal/host/movesrollback.go +5 −5
  • internal/host/movessources.go +84 −25
  • internal/host/movessources_test.go +29 −11
  • internal/hostops/move_fence_release.go +6 −2
  • internal/hostops/move_recovery_commit_test.go +53 −0
  • internal/hostops/move_recovery_grant.go +5 −1
  • internal/hostops/plan12_fence_home_test.go +75 −0
  • internal/park/home_region_test.go +262 −0
  • internal/park/region.go +38 −0
  • internal/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