carlos / platform Public

Merge origin/main (claim Plan 13 a0806e4c, held-lock fix dde859f3) into moves-phase1-5f

Conflicts:
- internal/host/movespass.go (moveCfg fields): took main's ParkHomeRegion
  field and alignment; kept this branch's ParkRunHeld/ParkLocalAuthorize
  comment (no claim Plan 12 text).
- internal/host/movessources.go (armMovesParks tail): kept the wake-source
  gate (movesWakeSourceRegistered) and the removal of the p12 check; took
  main's cfg.ParkHomeRegion = engine.HomeRegion. Main's auto-merged M17
  comparison through engine.OwnsRegion (regionname) stands.

Follow-ups in the merge:
- armMovesParks: a home box (placement "") arms only when the claim grants
  the @home alias (engine.HomeRegion set); otherwise it stays unarmed, even
  over an engine that names itself @home (coordinator's ruling). The gate
  stays shut in production until Task 8 either way.
- TestHomeBoxArms rewritten to that rule: granted home arms with
  ParkHomeRegion kept; no grant, another home, an @home-named engine and an
  unset engine refuse; named boxes arm whatever the fleet home.
- plan13_views_test.go: TestPlan13MovesEngineBindings and
  TestPlan13HomeLaneEngineBinding ported off the deleted stand-ins
  (movesLeaseSet, fakePlan12, the plan12 argument) to the open gate
  (withMovesWakeSource), ReplicaParkLeaseSet and lending leases.

The fallbacks stay deleted. B8 census unchanged by the merge.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 76eb992baa78555bab712ca000730fcd5deb59f5 parent 229c351parent a0806e4
97 files changed, +3174 −279
  • home_region_comparison_test.go +180 −0
  • internal/boxchoice/boxchoice.go +2 −6
  • internal/console/apidbclaim.go +29 −1
  • internal/console/apiinstancemove.go +22 −20
  • internal/console/apiinstancemove_claim_test.go +25 −20
  • internal/console/apiinstances.go +1 −4
  • internal/console/apisidecars.go +1 −4
  • internal/console/claim_first_route.go +1 −1
  • internal/console/claim_invariant_world_test.go +12 −0
  • internal/console/claim_runtime.go +4 −1
  • internal/console/movesrecovery.go +1 −1
  • internal/console/plan13_amendments_test.go +90 −0
  • internal/console/plan13_legacy_test.go +52 −0
  • internal/console/plan13_region_test.go +63 −0
  • internal/console/testdata/claim-authority-sites.txt +1 −1
  • internal/host/claimassembly.go +6 −5
  • internal/host/claimcleanup_identity.go +2 −1
  • internal/host/claimconstruction.go +1 −1
  • internal/host/claimhandover_recovery.go +2 −1
  • internal/host/claimlaunch_authority.go +3 −2
  • internal/host/claimmovefence.go +1 −1
  • internal/host/claimowners.go +3 −1
  • internal/host/claimproduction.go +22 −8
  • internal/host/claimrecoveryreceipt.go +3 −2
  • internal/host/claimregion.go +23 −0
  • internal/host/movesfinal.go +3 −3
  • 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/movesinstall.go +1 −1
  • internal/host/movesland_probe_test.go +4 −1
  • internal/host/moveslandpark.go +1 −1
  • internal/host/movespass.go +6 −5
  • internal/host/movesphase1.go +6 −2
  • internal/host/movespolicy.go +9 −3
  • internal/host/movesprobeowners.go +47 −26
  • internal/host/movesreport.go +26 −8
  • internal/host/movesrevert.go +3 −3
  • internal/host/movesrollback.go +3 −3
  • internal/host/movessources.go +23 −15
  • internal/host/movessources_test.go +31 −16
  • internal/host/plan12_handover_test.go +5 −2
  • internal/host/plan13_amendments_test.go +46 −0
  • internal/host/plan13_fix1_test.go +112 −0
  • internal/host/plan13_policy_test.go +37 −0
  • internal/host/plan13_quiet_test.go +23 −0
  • internal/host/plan13_receipt_test.go +94 −0
  • internal/host/plan13_region_test.go +78 −0
  • internal/host/plan13_views_test.go +152 −0
  • internal/hostactors/plan13_home_test.go +37 −0
  • internal/hostactors/roster.go +9 −0
  • internal/hostbox/identity.go +9 −1
  • internal/hostbox/park.go +1 −1
  • internal/hostbox/plan13_admission_test.go +46 −0
  • internal/hostbox/plan13_region_test.go +103 −0
  • internal/hostbox/reconcile.go +2 −1
  • internal/hostbox/regionalworker.go +2 −2
  • internal/hostbox/work.go +10 −6
  • internal/hostbox/writer.go +3 −1
  • internal/hostclaim/fence.go +6 −3
  • internal/hostclaim/generation.go +7 −6
  • internal/hostclaim/move.go +15 −0
  • internal/hostclaim/move_target_fence.go +4 −2
  • internal/hostclaim/plan13_amendments_test.go +137 −0
  • internal/hostclaim/relocating.go +2 −1
  • internal/hostfence/claim_fence.go +1 −1
  • internal/hostops/backfill.go +1 −0
  • internal/hostops/lifecycle_record.go +3 −2
  • internal/hostops/move_enrollment.go +26 −6
  • internal/hostops/move_fence_regions.go +34 −0
  • internal/hostops/move_fence_release.go +1 −1
  • internal/hostops/move_loss_admission.go +2 −1
  • internal/hostops/move_loss_owner.go +3 −2
  • internal/hostops/move_recovery_bridge.go +14 −7
  • internal/hostops/move_recovery_bridge_test.go +2 −1
  • internal/hostops/move_recovery_grant.go +2 −2
  • internal/hostops/move_region.go +27 −0
  • internal/hostops/plan13_conversion_test.go +78 −0
  • internal/hostops/plan13_enrollment_test.go +66 −0
  • internal/hostops/plan13_fence_test.go +115 −0
  • internal/hostops/plan13_fix1_test.go +29 −0
  • internal/hostops/plan13_loss_test.go +77 −0
  • internal/hostops/plan13_remote_test.go +37 −0
  • internal/hostops/production_create.go +10 −2
  • internal/hostops/remote.go +1 −1
  • internal/park/home_view.go +33 −0
  • internal/park/plan13_holder_test.go +25 −0
  • internal/park/plan13_observe_test.go +134 −0
  • internal/park/plan13_region_test.go +51 −0
  • internal/park/region.go +3 −15
  • internal/park/region_names.go +69 −0
  • internal/park/remote.go +20 −4
  • internal/park/sweep.go +16 −7
  • internal/provision/move_relocation.go +1 −1
  • internal/provision/plan9_b8_census_test.go +12 −12
  • internal/regionname/region.go +58 −0
diff --git a/home_region_comparison_test.go b/home_region_comparison_test.go
new file mode 100644
index 000000000..93d43918c
--- /dev/null
+++ b/home_region_comparison_test.go
@@ -0,0 +1,180 @@
+package main
+
+import (
+ "bytes"
+ "go/ast"
+ "go/parser"
+ "go/printer"
+ "go/token"
+ "io/fs"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+// String fields cannot encode the region vocabulary in their Go types. This
+// lint checks scalar comparisons, call arguments and map keys at recognizable
+// record/engine boundaries. Runtime witnesses cover local variable aliases.
+func plan13Expr(e ast.Node) string {
+ var b bytes.Buffer
+ _ = printer.Fprint(&b, token.NewFileSet(), e)
+ return b.String()
+}
+func plan13Placement(s string) bool {
+ if strings.Contains(s, "PlacementRegion(") {
+ return true
+ }
+ return s == "to" || s == "from" || s == "rec.Region" || s == "record.Region" || s == "recRegion" || s == "mv.To" || s == "mv.From" || s == "m.To" || s == "m.From" || strings.HasSuffix(s, ".Move.To") || strings.HasSuffix(s, ".Move.From")
+}
+func plan13Engine(s string) bool {
+ if strings.Contains(s, ".Intent[") && (strings.HasSuffix(s, `["to"]`) || strings.HasSuffix(s, `["region"]`)) {
+ return true
+ }
+ if strings.HasSuffix(s, ".Region()") || strings.HasSuffix(s, ".Touched.Regions") || strings.HasSuffix(s, ".QuietAfter") {
+ return true
+ }
+ switch s {
+ case "actor.Region", "src.Region", "dst.Region", "b.Region", "engine.Region", "e.Region", "r.Holder.Region", "in.SourceRegion", "in.TargetRegion", "s.Region", "a.Region", "o.Region", "m.Region", "t.Region", "fence.Region", "verified.Region", "regions":
+ return true
+ }
+ return false
+}
+func plan13Operand(e ast.Expr, classify func(string) bool) bool {
+ if classify(plan13Expr(e)) {
+ return true
+ }
+ switch x := e.(type) {
+ case *ast.ParenExpr:
+ return plan13Operand(x.X, classify)
+ case *ast.CallExpr:
+ switch plan13Expr(x.Fun) {
+ case "park.SameRegion", "regionname.SameRegion", "SameRegion", "park.PlacementMatches", "PlacementMatches", "park.QuietUntil", "QuietUntil":
+ return false
+ }
+ for _, arg := range x.Args {
+ if plan13Operand(arg, classify) {
+ return true
+ }
+ }
+ }
+ return false
+}
+func plan13MixedRegion(x *ast.BinaryExpr) bool {
+ if x.Op != token.EQL && x.Op != token.NEQ {
+ return false
+ }
+ return plan13Operand(x.X, plan13Placement) && plan13Operand(x.Y, plan13Engine) || plan13Operand(x.Y, plan13Placement) && plan13Operand(x.X, plan13Engine)
+}
+func plan13MixedRegionNode(n ast.Node) bool {
+ switch x := n.(type) {
+ case *ast.BinaryExpr:
+ return plan13MixedRegion(x)
+ case *ast.IndexExpr:
+ return plan13Operand(x.X, plan13Engine) && plan13Operand(x.Index, plan13Placement)
+ case *ast.CallExpr:
+ name := plan13Expr(x.Fun)
+ switch name {
+ case "park.SameRegion", "regionname.SameRegion", "SameRegion", "park.PlacementMatches", "PlacementMatches", "park.QuietUntil", "QuietUntil":
+ return false
+ }
+ placement, engine := false, false
+ for _, arg := range x.Args {
+ placement = placement || plan13Operand(arg, plan13Placement)
+ engine = engine || plan13Operand(arg, plan13Engine)
+ }
+ return placement && engine
+ }
+ return false
+}
+
+func TestPlan13NoDirectPlacementEngineComparison(t *testing.T) {
+ err := filepath.WalkDir(".", func(path string, d fs.DirEntry, err error) error {
+ if err != nil {
+ return err
+ }
+ if d.IsDir() {
+ if path != "." && strings.HasPrefix(d.Name(), ".") {
+ return filepath.SkipDir
+ }
+ return nil
+ }
+ if !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") {
+ return nil
+ }
+ fset := token.NewFileSet()
+ f, err := parser.ParseFile(fset, path, nil, 0)
+ if err != nil {
+ return err
+ }
+ ast.Inspect(f, func(n ast.Node) bool {
+ if plan13MixedRegionNode(n) {
+ t.Errorf("%s: placement and engine regions need the shared rule: %s", fset.Position(n.Pos()), plan13Expr(n))
+ }
+ return true
+ })
+ return nil
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+}
+
+func TestPlan13RegionComparisonLintWitness(t *testing.T) {
+ for _, expr := range []string{`rec.Region != src.Region`, `rec.Move.To == c.Op.Intent["to"]`, `in.SourceRegion == mv.From`, `park.PlacementRegion(rec.Region) != src.Region`, `rec.Move.To == s.Region`, `m.From == m.Region`, `rec.Region == fence.Region()`, `to == c.Op.Intent["to"]`, `canonical(rec.Region) == canonical(actor.Region)`} {
+ e, err := parser.ParseExpr(expr)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !plan13MixedRegion(e.(*ast.BinaryExpr)) {
+ t.Fatal("missed", expr)
+ }
+ }
+ for _, expr := range []string{`rec.Region != cfg.Region`, `rec.Move.To == rec.Region`, `receipt.Region != box.Region`, `canonical(rec.Region) != canonical(cfg.Region)`} {
+ e, err := parser.ParseExpr(expr)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if plan13MixedRegion(e.(*ast.BinaryExpr)) {
+ t.Fatal("false positive", expr)
+ }
+ }
+}
+
+func TestPlan13RegionCallAndMapLintWitness(t *testing.T) {
+ for _, expr := range []string{
+ `containsString(c.Touched.Regions, rec.Move.To)`,
+ `slices.Contains(c.Touched.Regions, rec.Move.To)`,
+ `strings.EqualFold(rec.Region, actor.Region)`,
+ `same(rec.Move.From, verified.Region())`,
+ `same(canonical(rec.Move.From), canonical(verified.Region()))`,
+ `c.QuietAfter[rec.Move.To]`,
+ `c.QuietAfter[canonical(rec.Move.To)]`,
+ `regions[rec.Region]`,
+ `slices.Contains(c.Touched.Regions, park.PlacementRegion(rec.Move.To))`,
+ `c.QuietAfter[park.PlacementRegion(rec.Move.To)]`,
+ } {
+ e, err := parser.ParseExpr(expr)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !plan13MixedRegionNode(e) {
+ t.Fatal("missed", expr)
+ }
+ }
+ for _, expr := range []string{
+ `containsString(c.Touched.Boxes, box)`,
+ `strings.EqualFold(rec.Region, cfg.Region)`,
+ `park.PlacementMatches(home, rec.Region, actor.Region)`,
+ `park.SameRegion(home, park.PlacementRegion(rec.Region), actor.Region)`,
+ `park.QuietUntil(c.QuietAfter, home, park.PlacementRegion(rec.Move.To))`,
+ `fleet.targets[rec.Region]`,
+ } {
+ e, err := parser.ParseExpr(expr)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if plan13MixedRegionNode(e) {
+ t.Fatal("false positive", expr)
+ }
+ }
+}
diff --git a/internal/boxchoice/boxchoice.go b/internal/boxchoice/boxchoice.go
index 7308877e7..10a7eccf9 100644
--- a/internal/boxchoice/boxchoice.go
+++ b/internal/boxchoice/boxchoice.go
@@ -10,6 +10,7 @@ package boxchoice
import (
"crypto/sha256"
"encoding/hex"
+ "github.com/carlosframework/platform/internal/regionname"
"slices"
"sort"
@@ -110,12 +111,7 @@ func Eligible(snap Snapshot, roster map[string]RosterBox, placement, awsRegion,
if !snap.Complete || expectedFP == "" || awsRegion == "" {
return nil
}
- home := func(code string) string {
- if homeName != "" && code == homeName {
- return ""
- }
- return code
- }
+ home := func(code string) string { return regionname.HomePlacementCode(homeName, code) }
var out []Candidate
for _, f := range snap.Boxes {
want := home(placement)
diff --git a/internal/console/apidbclaim.go b/internal/console/apidbclaim.go
index d9fb9e8f0..99ca385ff 100644
--- a/internal/console/apidbclaim.go
+++ b/internal/console/apidbclaim.go
@@ -10,6 +10,7 @@ import (
"encoding/json"
"errors"
"fmt"
+ "github.com/carlosframework/platform/internal/park"
"io"
"net/http"
"os"
@@ -176,7 +177,34 @@ func (s *Server) dbClaimFailure(w http.ResponseWriter, r *http.Request, g dbRequ
}
}
+func (cfg Config) claimPlacementRegion(code string) string {
+ region := park.PlacementRegion(code)
+ if region == park.HomeRegion && park.PlacementRegion(cfg.HomeRegion) == cfg.HomeRegion && cfg.HomeRegion != "" {
+ return cfg.HomeRegion
+ }
+ return region
+}
+
+// dbClaimHomeRegion binds the old fallback only for this configured home
+// bucket. Admission contents never choose the compatibility name.
+func (cfg Config) dbClaimHomeRegion() string {
+ if cfg.HomeRegion != "" {
+ return cfg.HomeRegion
+ }
+ legacy := strings.TrimSpace(cfg.HibernateRegion)
+ if cfg.HostClaim && cfg.Region == "" && cfg.HibernateBucket != "" && legacy != "" && park.PlacementRegion(legacy) == legacy {
+ return legacy
+ }
+ return ""
+}
+
func (cfg Config) dbClaimRegion() string {
+ if cfg.HostClaim {
+ if cfg.Region == "" && cfg.HomeRegion == "" && strings.TrimSpace(cfg.HibernateRegion) == "" {
+ return ""
+ }
+ return cfg.claimPlacementRegion(cfg.Region)
+ }
for _, region := range []string{cfg.Region, cfg.HomeRegion, cfg.HibernateRegion} {
if region = strings.TrimSpace(region); region != "" {
return region
@@ -190,7 +218,7 @@ func (s *Server) listDBVersions(ctx context.Context, g dbRequest, level int) (db
out := dbdoor.ListResponse{Protocol: 2, Plan: true, Files: []dbdoor.LTXFile{}}
h := g.claim.Claim.Holder
plan, err := genselect.Select(ctx, s.cfg.DBVersions, genselect.Request{
- Bucket: s.cfg.HibernateBucket, Region: s.cfg.dbClaimRegion(), Host: g.host, Holder: *h,
+ Bucket: s.cfg.HibernateBucket, Region: s.cfg.dbClaimRegion(), Host: g.host, Holder: park.HolderRegionView(*h, s.cfg.dbClaimHomeRegion(), s.cfg.dbClaimRegion()),
})
if err != nil {
return dbdoor.ListResponse{}, errDBClaimChanged
diff --git a/internal/console/apiinstancemove.go b/internal/console/apiinstancemove.go
index 3f6a0af3b..ca41bae39 100644
--- a/internal/console/apiinstancemove.go
+++ b/internal/console/apiinstancemove.go
@@ -7,6 +7,7 @@ import (
"encoding/json"
"errors"
"fmt"
+ "github.com/carlosframework/platform/internal/park"
"log"
"net/http"
"os"
@@ -250,7 +251,11 @@ 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 {
- return homePlacementCode(s.cfg.HomeRegion, region)
+ home := s.cfg.HomeRegion
+ if s.cfg.HostClaim {
+ home = s.cfg.dbClaimHomeRegion()
+ }
+ return homePlacementCode(home, region)
}
// homePlacementCode is homeRegionIn's rule for a given home name: the
@@ -258,14 +263,14 @@ func (s *Server) homeRegionIn(region string) string {
// 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
+ return park.HomePlacementCode(home, region)
}
func (s *Server) homeRegionOut(region string) string {
if region == "" {
+ if s.cfg.HostClaim {
+ return s.cfg.dbClaimHomeRegion()
+ }
return s.cfg.HomeRegion
}
return region
@@ -769,7 +774,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, s.cfg.HomeRegion) {
+ if !roster.Roster.MatchesHome(s.cfg.dbClaimHomeRegion()) || !claimMoveRosterPlaces(roster.Roster, obs.Claim.Op.Exec, target, recRegion, to, s.cfg.dbClaimHomeRegion()) {
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
}
@@ -806,7 +811,7 @@ func (s *Server) startClaimModeMove(w http.ResponseWriter, r *http.Request, acct
}
return
}
- moving, err := hostops.BeginClaimMove(ctx, s.cfg.Releases, a.Directory, h, obs, ms.ID, ms.By, target, to, ms.Now)
+ moving, err := hostops.BeginClaimMove(ctx, s.cfg.Releases, a.Directory, h, obs, ms.ID, ms.By, target, to, ms.Now, s.cfg.dbClaimHomeRegion())
if err != nil {
switch {
case errors.Is(err, hostops.ErrIdentity), errors.Is(err, hostactors.ErrRoster):
@@ -1269,15 +1274,8 @@ func phase1Conflict(err error) string {
// 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]
- 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
+func claimMoveRosterPlaces(r hostactors.Roster, source, target, recRegion, to string, home ...string) bool {
+ return hostops.ClaimMoveRosterPlaces(r, source, target, recRegion, to, home...)
}
// sourceEnrollment is the enrollment of the box that executes the claim, and
@@ -1410,7 +1408,7 @@ func moveRelocationRegion(rec *provision.Record, obs hostclaim.Observed) (string
// The door answers relocating for it.
func moveConvertedAfterCommit(rec *provision.Record, obs hostclaim.Observed) bool {
region, ok := moveRelocationRegion(rec, obs)
- return ok && rec.Move.State == provision.MoveCommitted && region == rec.Move.To
+ return ok && rec.Move.State == provision.MoveCommitted && park.PlacementMatches(obs.Claim.Op.Intent["home_region"], rec.Move.To, region)
}
// writeMoveDetail answers an operator's detail=1. The claim read and the
@@ -1486,9 +1484,13 @@ func (s *Server) writeMoveDetail(w http.ResponseWriter, r *http.Request, acct *A
// Any phase that runs on no box (starting, releasing and pinning
// today, hostclaim's validateRelocating) is waiting for one.
if region, ok := moveRelocationRegion(rec, obs); ok && obs.Claim.Op.Exec == "none" {
- live, known := s.regionHasLiveBox(ctx, region)
+ code, valid := park.EnginePlacement(obs.Claim.Op.Intent["home_region"], region)
+ live, known := false, false
+ if valid {
+ live, known = s.regionHasLiveBox(ctx, code)
+ }
if known && !live {
- out.Detail.WaitingForBox = s.homeRegionOut(region)
+ out.Detail.WaitingForBox = s.homeRegionOut(code)
}
}
}
@@ -1573,7 +1575,7 @@ func (s *Server) regionHasLiveBox(ctx context.Context, region string) (live, kno
aws = c.AWSRegion
}
}
- return len(eligibleBoxes(snap, roster, region, aws, s.cfg.HomeRegion, s.expectedSecretsFP(), nil)) > 0, true
+ return len(eligibleBoxes(snap, roster, region, aws, s.cfg.dbClaimHomeRegion(), s.expectedSecretsFP(), nil)) > 0, true
}
// ---- the delete door during a move (Plan 6 Task 2b, preflight L6) ----
diff --git a/internal/console/apiinstancemove_claim_test.go b/internal/console/apiinstancemove_claim_test.go
index c50af8914..99698a97a 100644
--- a/internal/console/apiinstancemove_claim_test.go
+++ b/internal/console/apiinstancemove_claim_test.go
@@ -2,6 +2,7 @@ package console
import (
"context"
+ "crypto/ed25519"
"crypto/sha256"
"encoding/hex"
"encoding/json"
@@ -551,15 +552,9 @@ func TestMoveDoorHostClaimRetryAfterAFailedRecordStart(t *testing.T) {
}
}
-// 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).
+// The door and claim transition both resolve the record's empty home placement
+// against the roster's named home. The admitted operation retains that binding
+// while its move record keeps placement codes.
func TestMoveDoorHostClaimPlacesAHomeSource(t *testing.T) {
ctx := context.Background()
fx, token := operatorInstanceFixture(t, "code-inst-move-home")
@@ -577,24 +572,34 @@ func TestMoveDoorHostClaimPlacesAHomeSource(t *testing.T) {
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)
}
+ d := hostactors.Directory{Store: fx.rel, PublicKey: fx.priv.Public().(ed25519.PublicKey), Bucket: moveTestBucket}
+ roster, err := d.Read(ctx)
+ if err != nil {
+ t.Fatal(err)
+ }
+ next := roster.Roster
+ next.HomeRegion = "ie"
+ if _, err := d.Replace(ctx, release.RootSigner(fx.priv), roster, next); err != nil {
+ t.Fatal(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)
+ if code != http.StatusAccepted {
+ t.Fatalf("request = %d %s, want an admitted home-source move", 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)
+ t.Fatalf("the move did not register: %+v %v", a.Value.Registrations, err)
}
- if claimBytes(t, fx, "acme.go.tito.io") != claim {
- t.Fatal("the refused move wrote the claim")
+ o, err := hostclaim.Read(ctx, fx.rel, "acme.go.tito.io")
+ if err != nil || o.Absent || o.Claim == nil {
+ t.Fatalf("read admitted claim: %+v %v", o, err)
+ }
+ if o.Claim.Op.Kind != hostclaim.KindMoving || o.Claim.Op.Exec != "ie-box" || o.Claim.Op.Intent["to"] != "uk" || o.Claim.Op.Intent["home_region"] != "ie" {
+ t.Fatalf("admitted operation lost its home binding: %+v", o.Claim.Op)
}
- 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)
+ if rec, err := provision.GetRecord(ctx, fx.rel, "bab", "titogo", "acme.go.tito.io"); err != nil || rec.Region != "" || rec.Move == nil || rec.Move.From != "" || rec.Move.To != "uk" || rec.Move.ID != o.Claim.Op.ID {
+ t.Fatalf("admitted move did not retain placement codes: %+v %v", rec, err)
}
}
diff --git a/internal/console/apiinstances.go b/internal/console/apiinstances.go
index 7a535f4a6..e29b2643c 100644
--- a/internal/console/apiinstances.go
+++ b/internal/console/apiinstances.go
@@ -1395,10 +1395,7 @@ func (s *Server) handleAPIInstanceCreate(w http.ResponseWriter, r *http.Request)
claimRequest, createErr = s.beginClaimCreate(r.Context(), acct, app, host, principal, func(now time.Time, acquisition string) (hostops.Record, error) {
rec.CreatedAt = time.Time{}
rec.Acquisition = acquisition
- region := s.cfg.dbClaimRegion()
- if rec.Region != "" {
- region = rec.Region
- }
+ region := s.cfg.claimPlacementRegion(rec.Region)
prepared, err := hostops.PrepareInstance(rec, now, region)
if err == nil {
rec.CreatedAt = now
diff --git a/internal/console/apisidecars.go b/internal/console/apisidecars.go
index 67f9a491a..b16346bc7 100644
--- a/internal/console/apisidecars.go
+++ b/internal/console/apisidecars.go
@@ -343,10 +343,7 @@ func (s *Server) handleAPISidecarCreate(w http.ResponseWriter, r *http.Request)
request, err := s.beginClaimCreate(r.Context(), acct, app, rec.Host, principal, func(now time.Time, acquisition string) (hostops.Record, error) {
rec.CreatedAt = time.Time{}
rec.Acquisition = acquisition
- region := s.cfg.dbClaimRegion()
- if rec.Region != "" {
- region = rec.Region
- }
+ region := s.cfg.claimPlacementRegion(rec.Region)
prepared, err := hostops.PrepareSidecar(rec, now, region)
if err == nil {
rec.CreatedAt = now
diff --git a/internal/console/claim_first_route.go b/internal/console/claim_first_route.go
index 6ac0d9a66..7dabf8794 100644
--- a/internal/console/claim_first_route.go
+++ b/internal/console/claim_first_route.go
@@ -17,7 +17,7 @@ func (s *Server) attachClaimFirstRoute(ctx context.Context, acct *Account, app,
}
request, err := s.beginClaimCreate(ctx, acct, app, host, principal, func(now time.Time, acquisition string) (hostops.Record, error) {
rec := provision.Record{Schema: 1, Host: host, App: app, Account: acct.Sqid, Channel: channel, Kind: "static", CreatedBy: principal, Acquisition: acquisition}
- record, err := hostops.PrepareInstance(rec, now, s.cfg.dbClaimRegion())
+ record, err := hostops.PrepareInstance(rec, now, s.cfg.claimPlacementRegion(rec.Region))
record.VerifiedOwnership = true
return record, err
})
diff --git a/internal/console/claim_invariant_world_test.go b/internal/console/claim_invariant_world_test.go
index e7afb98fd..ac9e74b26 100644
--- a/internal/console/claim_invariant_world_test.go
+++ b/internal/console/claim_invariant_world_test.go
@@ -545,6 +545,18 @@ func newClaimCaseWith(t *testing.T, opts ...func(*Config)) *claimCase {
for _, opt := range opts {
opt(&cfg)
}
+ if home := cfg.dbClaimHomeRegion(); home != "" {
+ d := hostactors.Directory{Store: rawStore, PublicKey: pub, Bucket: claimFxBucket}
+ o, e := d.Read(ctx)
+ if e != nil {
+ t.Fatal(e)
+ }
+ r := o.Roster
+ r.HomeRegion = home
+ if _, e = d.Replace(ctx, release.RootSigner(priv), o, r); e != nil {
+ t.Fatal(e)
+ }
+ }
srv, err = NewServer(cfg, st)
if err != nil {
t.Fatalf("NewServer with HostClaim: %v", err)
diff --git a/internal/console/claim_runtime.go b/internal/console/claim_runtime.go
index f4fa9ce18..570ae1d52 100644
--- a/internal/console/claim_runtime.go
+++ b/internal/console/claim_runtime.go
@@ -62,6 +62,9 @@ func (s *Server) ConfigureClaimRuntime(ctx context.Context) error {
if err != nil {
return err
}
+ if !roster.Roster.MatchesHome(s.cfg.dbClaimHomeRegion()) {
+ return hostops.ErrContract
+ }
self, ok := roster.Roster.Consoles[s.cfg.HostConsoleID]
if !ok || len(self.Namespaces) != 1 {
return hostops.ErrContract
@@ -69,7 +72,7 @@ func (s *Server) ConfigureClaimRuntime(ctx context.Context) error {
if err = s.store.migrateClaimAppIdentity(); err != nil {
return err
}
- a := &hostops.ProductionAuthority{Store: data, Control: control, Directory: d, Now: time.Now, ConsoleID: s.cfg.HostConsoleID, Enrollment: self.Enrollment, Namespace: self.Namespaces[0], Signer: release.RootSigner(s.cfg.Key)}
+ a := &hostops.ProductionAuthority{HomeRegion: s.cfg.dbClaimHomeRegion(), Store: data, Control: control, Directory: d, Now: time.Now, ConsoleID: s.cfg.HostConsoleID, Enrollment: self.Enrollment, Namespace: self.Namespaces[0], Signer: release.RootSigner(s.cfg.Key)}
a.Backfill = &hostops.Backfill{Store: data, Directory: d}
a.Evidence = hostactors.ExactEvidence{PublicKey: s.cfg.ReleasePub, Store: data, Boxes: d.PrivateBoxReport, Self: s.localHostReport}
c := hostops.Controller{Store: data, Control: control, Authority: a, Now: time.Now, NewID: claimOperationID}
diff --git a/internal/console/movesrecovery.go b/internal/console/movesrecovery.go
index 7ea56e4d3..2b60f6496 100644
--- a/internal/console/movesrecovery.go
+++ b/internal/console/movesrecovery.go
@@ -366,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[park.PlacementRegion(rec.Move.To)], now) {
+ if s.moveAbortOverdue(o, park.QuietUntil(c.QuietAfter, s.cfg.dbClaimHomeRegion(), park.PlacementRegion(rec.Move.To)), now) {
s.pageMove(ctx, movePageAbortOverdue, o, c, rec, now)
}
return nil
diff --git a/internal/console/plan13_amendments_test.go b/internal/console/plan13_amendments_test.go
new file mode 100644
index 000000000..2cd2cc41d
--- /dev/null
+++ b/internal/console/plan13_amendments_test.go
@@ -0,0 +1,90 @@
+package console
+
+import (
+ "github.com/carlosframework/platform/internal/hostactors"
+ "github.com/carlosframework/platform/internal/hostclaim"
+ "github.com/carlosframework/platform/internal/park"
+ "github.com/carlosframework/platform/internal/provision"
+ "github.com/carlosframework/platform/internal/release"
+ "net/http"
+ "testing"
+ "time"
+)
+
+func TestPlan13CompleteUnnamedHomeCreate(t *testing.T) {
+ cx := newClaimCaseWith(t, func(cfg *Config) {
+ cfg.HomeRegion = ""
+ cfg.HibernateRegion = "eu-west-1"
+ cfg.HibernateBucket = "data"
+ })
+ d := cx.srv.cfg.ClaimAuthority.Directory
+ roster, err := d.Read(t.Context())
+ if err != nil {
+ t.Fatal(err)
+ }
+ for ref, b := range roster.Roster.Boxes {
+ b.Region = "eu-west-1"
+ roster.Roster.Boxes[ref] = b
+ }
+ roster.Roster.Regions["eu-west-1"] = roster.Roster.Regions["ie"]
+ delete(roster.Roster.Regions, "ie")
+ body, err := release.SignRootEnvelope(release.RootSigner(cx.priv), roster.Roster)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err = cx.rel.Put(t.Context(), hostactors.Key, body, roster.ETag); err != nil {
+ t.Fatal(err)
+ }
+ manifest := titoManifest()
+ manifest.Regions = nil
+ enableInstances(t, cx.consoleFixture, "titogo", manifest)
+ cx.mustAPI(t, http.MethodPost, "/api/cli/apps/bab/titogo/instances", `{"host":"acme.go.tito.io"}`)
+ cx.readyClaims(t)
+ rec, err := provision.GetRecord(t.Context(), cx.rel, "bab", "titogo", "acme.go.tito.io")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if rec.Region != "" {
+ t.Fatal("record lost home placement", rec.Region)
+ }
+ o, err := hostclaim.Read(t.Context(), cx.rel, rec.Host)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if o.Absent || o.Claim.Holder == nil || o.Claim.Op.Kind != hostclaim.KindServing || o.Claim.Op.Exec == "" {
+ t.Fatalf("create incomplete: %+v", o)
+ }
+ if len(o.Claim.Touched.Regions) == 0 || o.Claim.Touched.Regions[0] != park.HomeRegion {
+ t.Fatal("lost engine region", o.Claim.Touched)
+ }
+}
+
+func TestPlan13HomeRecoveryStatus(t *testing.T) {
+ for _, region := range []string{"uk", park.HomeRegion} {
+ t.Run(region, func(t *testing.T) {
+ fx, token, url := moveDoorWithClaim(t, "plan13-home-status")
+ readMoveAnswer(t, http.MethodPost, url, token, `{"region":"uk","cut":"manual"}`, http.StatusAccepted)
+ fx.srv.cfg.HomeRegion = "uk"
+ now := time.Now().UTC()
+ setMove(t, fx, func(rec *provision.Record) {
+ rec.Region = ""
+ rec.Move.To = ""
+ rec.Move.State = provision.MoveCommitted
+ rec.Move.CutPhase = provision.CutFlipped
+ rec.Move.FlippedAt = &now
+ rec.Move.CommittedAt = &now
+ })
+ rewriteClaim(t, fx, "acme.go.tito.io", func(c *hostclaim.Claim) {
+ c.Op.Kind = hostclaim.KindRelocating
+ c.Op.Phase = "pinning"
+ c.Op.Exec = "none"
+ c.Op.Participants = nil
+ c.Op.Intent = map[string]string{"region": region, "source_box": "uk-box", "home_region": "uk"}
+ })
+ a, out := readMoveAnswer(t, http.MethodGet, url+"?detail=1", token, "", http.StatusOK)
+ if a.State != provision.MoveRelocating || waitingFor(a) != "" {
+ t.Fatalf("home has a live box: %s", out)
+ }
+ })
+ }
+}
diff --git a/internal/console/plan13_legacy_test.go b/internal/console/plan13_legacy_test.go
new file mode 100644
index 000000000..861fd4f58
--- /dev/null
+++ b/internal/console/plan13_legacy_test.go
@@ -0,0 +1,52 @@
+package console
+
+import (
+ "github.com/carlosframework/platform/internal/hostclaim"
+ "github.com/carlosframework/platform/internal/park"
+ "testing"
+)
+
+func TestPlan13VerifiedLegacyHomeRead(t *testing.T) {
+ for _, which := range []string{"home", "named", "no-bucket", "invalid", "explicit"} {
+ cfg := Config{HostClaim: true, HibernateRegion: "eu-west-1", HibernateBucket: "data"}
+ switch which {
+ case "named":
+ cfg.Region = "uk"
+ case "no-bucket":
+ cfg.HibernateBucket = ""
+ case "invalid":
+ cfg.HibernateRegion = "@home"
+ case "explicit":
+ cfg.HomeRegion = "ie"
+ }
+ h := hostclaim.Holder{Admissions: []hostclaim.AdmissionRecord{{Region: "eu-west-1", PredecessorRegion: "eu-west-1"}}}
+ got := park.HolderRegionView(h, cfg.dbClaimHomeRegion(), cfg.dbClaimRegion())
+ want := "eu-west-1"
+ if which == "home" {
+ want = park.HomeRegion
+ }
+ if got.Admissions[0].Region != want || h.Admissions[0].Region != "eu-west-1" {
+ t.Fatal(which, got)
+ }
+ }
+}
+
+func TestPlan13UnnamedHomeUnfinishedSelection(t *testing.T) {
+ s, g, _, m := doorPlanFixture(t, true)
+ s.cfg.HomeRegion = ""
+ s.cfg.HibernateRegion = "eu-west-1"
+ c := cloneDoorClaim(g.claim.Claim)
+ c.Holder.Generation = "next"
+ c.Holder.Admissions = []hostclaim.AdmissionRecord{{Generation: "next", Predecessor: hostclaim.RootGeneration, Region: "eu-west-1", PredecessorRegion: "eu-west-1", Mode: "cold"}}
+ writeDoorClaim(t, s, g, c)
+ var err error
+ g.claim, err = hostclaim.Read(t.Context(), s.cfg.Releases, g.host)
+ if err != nil {
+ t.Fatal(err)
+ }
+ m.Put("data", dbVersionKey(c.Holder.Location, 0, 1, 1), doorLTXHeader(t, 1, 1))
+ got, err := s.listDBVersions(t.Context(), g, 0)
+ if err != nil || len(got.Files) != 1 {
+ t.Fatal(got, err)
+ }
+}
diff --git a/internal/console/plan13_region_test.go b/internal/console/plan13_region_test.go
new file mode 100644
index 000000000..5a0ed2e3f
--- /dev/null
+++ b/internal/console/plan13_region_test.go
@@ -0,0 +1,63 @@
+package console
+
+import (
+ "github.com/carlosframework/platform/internal/hostactors"
+ "github.com/carlosframework/platform/internal/park"
+ "testing"
+)
+
+func TestPlan13ConsoleClaimRegion(t *testing.T) {
+ for _, tc := range []struct {
+ cfg Config
+ code, want string
+ }{
+ {Config{HostClaim: true, HomeRegion: "ie"}, "", "ie"},
+ {Config{HostClaim: true, HomeRegion: "ie"}, "uk", "uk"},
+ {Config{HostClaim: true}, "", park.HomeRegion},
+ {Config{HostClaim: true, HomeRegion: "ie"}, "@home", ""},
+ } {
+ if got := tc.cfg.claimPlacementRegion(tc.code); got != tc.want {
+ t.Fatalf("%+v: %q", tc, got)
+ }
+ }
+ for _, tc := range []struct {
+ cfg Config
+ want string
+ }{
+ {Config{Region: "", HomeRegion: "ie", HibernateRegion: "eu-west-1"}, "ie"},
+ {Config{HibernateRegion: "eu-west-1"}, "eu-west-1"},
+ {Config{HostClaim: true, HomeRegion: "ie", HibernateRegion: "eu-west-1"}, "ie"},
+ {Config{HostClaim: true, Region: "uk", HomeRegion: "ie"}, "uk"},
+ {Config{HostClaim: true, HibernateRegion: "eu-west-1"}, park.HomeRegion},
+ } {
+ if got := tc.cfg.dbClaimRegion(); got != tc.want {
+ t.Fatalf("%+v: %q", tc, got)
+ }
+ }
+}
+
+func TestPlan13ConsoleResolvedHomeAgreement(t *testing.T) {
+ for _, cfg := range []Config{
+ {HostClaim: true, HomeRegion: "ie", HibernateRegion: "eu-west-1", HibernateBucket: "data"},
+ {HostClaim: true, HibernateRegion: "eu-west-1", HibernateBucket: "data"},
+ } {
+ home := cfg.dbClaimHomeRegion()
+ if !(hostactors.Roster{HomeRegion: home}).MatchesHome(home) {
+ t.Fatal(cfg)
+ }
+ if (hostactors.Roster{HomeRegion: "ca"}).MatchesHome(home) {
+ t.Fatal("conflicting console home accepted")
+ }
+ }
+}
+
+func TestPlan13UnnamedHomeMoveNames(t *testing.T) {
+ s := &Server{cfg: Config{HostClaim: true, HibernateRegion: "eu-west-1", HibernateBucket: "data"}}
+ if s.homeRegionOut("") != "eu-west-1" || s.homeRegionIn("eu-west-1") != "" {
+ t.Fatal("move door used a second home binding")
+ }
+ s.cfg.HostClaim = false
+ if s.homeRegionOut("") != "" || s.homeRegionIn("eu-west-1") != "eu-west-1" {
+ t.Fatal("flag-off changed")
+ }
+}
diff --git a/internal/console/testdata/claim-authority-sites.txt b/internal/console/testdata/claim-authority-sites.txt
index 3ea4c1055..cf7a27c10 100644
--- a/internal/console/testdata/claim-authority-sites.txt
+++ b/internal/console/testdata/claim-authority-sites.txt
@@ -13,7 +13,7 @@
../hostops/move_enrollment.go|RegisterMove|a.Control.RegisterStart(ctx, h.Account, h.App, appID, id, reg, a.Now())
../hostops/move_enrollment.go|StartClaimMoveRecord|h.captureMoveStartBase(ctx, seq)
../hostops/move_fence_release.go|ReleaseFencedTarget|hostclaim.ReleaseFencedTarget(r)
-../hostops/move_fence_release.go|ReleaseFencedTarget|hostclaim.VerifyMoveTargetFence(ctx, g.w.raw, o, ref, g.authority)
+../hostops/move_fence_release.go|ReleaseFencedTarget|hostclaim.VerifyMoveTargetFence(ctx, g.w.raw, o, ref, MoveFenceRegions{Authority: g.authority, HomeRegion: o.Claim.Op.Intent["home_region"]})
../hostops/move_loss_authority.go|DeclareMoveLoss|a.Control.DeclareMoveLoss(ctx, a.Signer, a.Directory.Bucket, actor, account, app, host, box, in)
../hostops/move_loss_authority.go|InspectMoveLoss|a.Control.InspectMoveLoss(ctx, a.Signer, a.Directory.Bucket, actor, account, app, host, box)
../hostops/move_recovery_bridge.go|RecoverTerminatedMovingHost|hostclaim.ConvertMovingToRelocating(mh.Lifecycle(ctx), s, fresh, c.Op.ID, ref, authority, guard)
diff --git a/internal/host/claimassembly.go b/internal/host/claimassembly.go
index 555a1449f..0e6e41ae1 100644
--- a/internal/host/claimassembly.go
+++ b/internal/host/claimassembly.go
@@ -156,12 +156,13 @@ func (a ClaimAssembly) Build(ctx context.Context) (ClaimProviders, error) {
}
a.Guard.Claims = hostguard.StoreReader{Store: a.Claims}
// Continue is the moves C1 session table, nil unless both flags are on (D1).
- source := hostbox.WorkSource{Guard: a.Guard, Box: actor.Box, Region: actor.Region, Now: time.Now, Policy: a.Policy.Check, Local: a.Identity.Local(a.Local), Continue: a.Wake.Continue}
+ source := hostbox.WorkSource{FleetHomeRegion: actor.FleetHomeRegion, HomeRegion: actor.HomeRegion, Guard: a.Guard, Box: actor.Box, Region: park.PlacementRegion(actor.Region), Now: time.Now, Policy: a.Policy.Check, Local: a.Identity.Local(a.Local), Continue: a.Wake.Continue}
a.Reconcile.Reconciler.Claims = a.Claims
a.Reconcile.Reconciler.Guard = a.Guard
a.Reconcile.Reconciler.Writes = hostwrite.Config{Enabled: true, Selector: source}
a.Reconcile.Reconciler.Box = actor.Box
- a.Reconcile.Reconciler.Region = actor.Region
+ a.Reconcile.Reconciler.Region = park.PlacementRegion(actor.Region)
+ a.Reconcile.Reconciler.HomeRegion = actor.HomeRegion
a.Reconcile.Reconciler.Now = time.Now
a.Reconcile.Reconciler.Local = park.Local{Guard: a.Guard, Claims: a.Claims, DataDir: a.Guard.DataDir, Box: actor.Box}
a.Reconcile.Box = actor.Box
@@ -177,7 +178,7 @@ func (a ClaimAssembly) Build(ctx context.Context) (ClaimProviders, error) {
a.Provision = a.Reconcile.Configure
check := claimBoundIdentity(a.Identity.Verify, actor)
data := hostbox.RequestStore{Store: a.Data, Check: check}
- writer := hostbox.Writer{Loss: a.LossAdmission, Claims: a.Claims, Data: data, Bucket: actor.Bucket, Region: actor.Region, Box: actor.Box, AdmissionEvidence: a.AdmissionEvidence, PublicationEvidence: a.PublicationEvidence, NewID: a.NewID}
+ writer := hostbox.Writer{HomeRegion: actor.HomeRegion, Loss: a.LossAdmission, Claims: a.Claims, Data: data, Bucket: actor.Bucket, Region: park.PlacementRegion(actor.Region), Box: actor.Box, AdmissionEvidence: a.AdmissionEvidence, PublicationEvidence: a.PublicationEvidence, NewID: a.NewID}
a.bindClaimWakeStop()
if local, ok := a.Local.(*productionClaimLocal); ok {
if err := local.bind(ctx, &a); err != nil {
@@ -227,7 +228,7 @@ func (a ClaimAssembly) Build(ctx context.Context) (ClaimProviders, error) {
}
return a.View(ctx, o)
}
- engine := park.Engine{Store: data, Claims: a.Claims, Bucket: actor.Bucket, Region: actor.Region, Leases: hostbox.AggregateLeases{Leases: hostbox.RenewingLeases{Region: actor.Cloud.Region}, Resolve: leases}, Now: time.Now, NewID: a.NewID, View: view, Authorize: authorize}
+ engine := park.Engine{HomeRegion: actor.HomeRegion, Store: data, Claims: a.Claims, Bucket: actor.Bucket, Region: park.PlacementRegion(actor.Region), Leases: hostbox.AggregateLeases{Leases: hostbox.RenewingLeases{Region: actor.Cloud.Region}, Resolve: leases}, Now: time.Now, NewID: a.NewID, View: view, Authorize: authorize}
engine.VerifyMovesSweep = func(ctx context.Context, o hostclaim.Observed, versions []lease.ObjectVersion) (park.MoveSweepProof, error) {
if err := check(ctx); err != nil {
return park.MoveSweepProof{}, err
@@ -267,7 +268,7 @@ func (e claimRegionalEnrollment) VerifyRegional(ctx context.Context, region, buc
if err != nil {
return err
}
- if a != e.actor || a.Region != region || a.Bucket != bucket {
+ if a != e.actor || !park.SameRegion(a.HomeRegion, park.PlacementRegion(a.Region), region) || a.Bucket != bucket {
return hostbox.ErrChanged
}
return nil
diff --git a/internal/host/claimcleanup_identity.go b/internal/host/claimcleanup_identity.go
index b87b8d456..5b2d8cca3 100644
--- a/internal/host/claimcleanup_identity.go
+++ b/internal/host/claimcleanup_identity.go
@@ -35,11 +35,12 @@ func verifyClaimCleanupEnrollment(ctx context.Context, identity hostbox.Identity
if !ok || roster.Roster.RequestLimitNS != int64(hostbox.RequestLimit) {
return nil, ErrClaimStopIdentity
}
- actor := hostbox.Actor{ControlBucket: directory.Bucket, Box: row.BoxID, Enrollment: row.Enrollment, Revision: roster.Roster.Revision, Region: row.Region, Bucket: identity.Bucket, Namespace: row.Namespace, Cloud: hostbox.CloudIdentity{Provider: row.Cloud.Provider, Account: row.Cloud.Account, Region: row.Cloud.Region, InstanceID: row.Cloud.InstanceID}}
+ actor := hostbox.Actor{FleetHomeRegion: roster.Roster.HomeRegion, ControlBucket: directory.Bucket, Box: row.BoxID, Enrollment: row.Enrollment, Revision: roster.Roster.Revision, Region: row.Region, Bucket: identity.Bucket, Namespace: row.Namespace, Cloud: hostbox.CloudIdentity{Provider: row.Cloud.Provider, Account: row.Cloud.Account, Region: row.Cloud.Region, InstanceID: row.Cloud.InstanceID}}
identity.Roster = cleanupCloudRoster{actor}
if _, err := identity.Verify(ctx); err != nil {
return nil, err
}
+ actor.HomeRegion = identity.HomeRegion
actor.Eligible, actor.Quarantined = false, true
return &claimCleanupEnrollment{actor: actor, directory: directory}, nil
}
diff --git a/internal/host/claimconstruction.go b/internal/host/claimconstruction.go
index eee40d899..3a7330acc 100644
--- a/internal/host/claimconstruction.go
+++ b/internal/host/claimconstruction.go
@@ -258,7 +258,7 @@ func OpenProductionClaimAssembly(ctx context.Context, store release.Store, r Cla
var publisher ClaimFencedPublication
var groupD claimGroupD
if movesC1 {
- serving = &claimServingOwners{Data: hostbox.RequestStore{Store: owners.Data, Check: func(ctx context.Context) error { _, err := owners.Identity.Verify(ctx); return err }}, Bucket: actor.Bucket, Region: actor.Region, Box: actor.Box, NewID: newID, Now: time.Now, Sources: owners.Serving}
+ serving = &claimServingOwners{HomeRegion: actor.HomeRegion, Data: hostbox.RequestStore{Store: owners.Data, Check: func(ctx context.Context) error { _, err := owners.Identity.Verify(ctx); return err }}, Bucket: actor.Bucket, Region: park.PlacementRegion(actor.Region), Box: actor.Box, NewID: newID, Now: time.Now, Sources: owners.Serving}
groupD, err = claimGroupDWiring(claimEntryEnv(ctx), serving.owners(), owners.Moves, owners.Relocation)
if err != nil {
return nil, err
diff --git a/internal/host/claimhandover_recovery.go b/internal/host/claimhandover_recovery.go
index 37655558b..38a190dbb 100644
--- a/internal/host/claimhandover_recovery.go
+++ b/internal/host/claimhandover_recovery.go
@@ -12,6 +12,7 @@ import (
"github.com/carlosframework/platform/internal/hostclaim"
"github.com/carlosframework/platform/internal/hostguard"
"github.com/carlosframework/platform/internal/moves"
+ "github.com/carlosframework/platform/internal/park"
"github.com/carlosframework/platform/internal/registry"
"strconv"
)
@@ -72,7 +73,7 @@ func (h claimCrossBoxHandover) recoveryEvidence(ctx context.Context, o hostclaim
if err != nil {
return err
}
- if verified.Box() != p.Box || verified.Region() != op.Intent["to"] {
+ if (op.Intent["home_region"] != "" && op.Intent["home_region"] != verified.FleetHomeRegion()) || verified.Box() != p.Box || !park.SameRegion(op.Intent["home_region"], verified.Region(), op.Intent["to"]) {
return hostclaim.ErrFence
}
return nil
diff --git a/internal/host/claimlaunch_authority.go b/internal/host/claimlaunch_authority.go
index a6f9fd96a..e7258733b 100644
--- a/internal/host/claimlaunch_authority.go
+++ b/internal/host/claimlaunch_authority.go
@@ -3,6 +3,7 @@ package host
import (
"context"
"errors"
+ "github.com/carlosframework/platform/internal/park"
"os"
"path/filepath"
"strconv"
@@ -105,7 +106,7 @@ func (h *ClaimLaunchRoot) authorize(ctx context.Context, peer StopPeer, q hostbo
if err := h.Kernel.VerifyDigest(ctx, q.Executable, r.Executable, q.Spec.Digest); err != nil {
return err
}
- selection := hostbox.Selection{Host: t.Host, Action: claimSpawnAction(c), Box: t.Box, Region: t.Region, Captured: hostguard.Captured{Incarnation: t.Incarnation, Stamp: t.Stamp}, Kind: c.Op.Kind, Op: c.Op.ID, Phase: c.Op.Phase, Generation: c.Holder.Generation, Session: t.Session}
+ selection := hostbox.Selection{HomeRegion: actor.HomeRegion, FleetHomeRegion: actor.FleetHomeRegion, Host: t.Host, Action: claimSpawnAction(c), Box: t.Box, Region: t.Region, Captured: hostguard.Captured{Incarnation: t.Incarnation, Stamp: t.Stamp}, Kind: c.Op.Kind, Op: c.Op.ID, Phase: c.Op.Phase, Generation: c.Holder.Generation, Session: t.Session}
if err := h.Local.Check(ctx, actor, c, selection); err != nil {
return err
}
@@ -135,7 +136,7 @@ func (h *ClaimLaunchRoot) authorize(ctx context.Context, peer StopPeer, q hostbo
if c.Op.Kind != hostclaim.KindServing && boot >= state.SafeUntil {
return execlease.ErrLost
}
- if until, ok := c.QuietAfter[t.Region]; ok && !now.After(until) {
+ if until := park.QuietUntil(c.QuietAfter, actor.HomeRegion, t.Region); !until.IsZero() && !now.After(until) {
return hostbox.ErrChanged
}
return nil
diff --git a/internal/host/claimmovefence.go b/internal/host/claimmovefence.go
index 6a2014065..533632db8 100644
--- a/internal/host/claimmovefence.go
+++ b/internal/host/claimmovefence.go
@@ -36,6 +36,6 @@ func claimBoxFenceVerifier(check func(context.Context) error, store release.Stor
if json.Unmarshal([]byte(o.Claim.Op.Journal["fence."+o.Claim.Op.Intent["target"]]), &ref) != nil {
return hostclaim.ReturnFence{}, hostclaim.ErrFence
}
- return hostclaim.VerifyMoveTargetFence(ctx, store, o, ref, hostfence.ClaimFenceAuthority{Directory: authority.Directory, Now: time.Now})
+ return hostclaim.VerifyMoveTargetFence(ctx, store, o, ref, hostops.MoveFenceRegions{Authority: hostfence.ClaimFenceAuthority{Directory: authority.Directory, Now: time.Now}, HomeRegion: o.Claim.Op.Intent["home_region"]})
}
}
diff --git a/internal/host/claimowners.go b/internal/host/claimowners.go
index 8b335f3c4..5bfcb2204 100644
--- a/internal/host/claimowners.go
+++ b/internal/host/claimowners.go
@@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"errors"
+ "github.com/carlosframework/platform/internal/park"
"path/filepath"
"reflect"
"strconv"
@@ -306,6 +307,7 @@ func (d ClaimOwnerDispatch) PublicationEvidence(ctx context.Context, o hostclaim
// acknowledged (INV-11), and publication evidence binds the exact marker
// version this process wrote and the exact root version it named.
type claimServingOwners struct {
+ HomeRegion string
Data genselect.Reader
Bucket, Region, Box string
NewID func() string
@@ -330,7 +332,7 @@ func (s *claimServingOwners) serving(o hostclaim.Observed) error {
}
func (s *claimServingOwners) baseline(ctx context.Context, o hostclaim.Observed) (*genselect.Plan, error) {
- return genselect.Select(ctx, s.Data, genselect.Request{Bucket: s.Bucket, Region: s.Region, Host: o.Host, Holder: *o.Claim.Holder})
+ return genselect.Select(ctx, s.Data, genselect.Request{Bucket: s.Bucket, Region: s.Region, Host: o.Host, Holder: park.HolderRegionView(*o.Claim.Holder, s.HomeRegion, s.Region)})
}
// claimEmptyBootstrap: a created incarnation before its first writer.
diff --git a/internal/host/claimproduction.go b/internal/host/claimproduction.go
index 8a2deeb1c..c5958ae80 100644
--- a/internal/host/claimproduction.go
+++ b/internal/host/claimproduction.go
@@ -64,6 +64,7 @@ func RegisterProductionClaimMoveSources(s ClaimProductionMoveSources) error {
// and the directory's row for the box, whose enrollment and cloud instance
// are the enrollment's.
type productionRosterRow struct {
+ home string
pub ed25519.PublicKey
enrollment hostfloor.Enrollment
directory hostactors.Directory
@@ -92,7 +93,7 @@ func readProductionRosterRow(ctx context.Context, store release.Store, getenv fu
if !ok || row.Enrollment != enrollment.Enrollment || row.Cloud.InstanceID != enrollment.InstanceID {
return rr, hostbox.ErrChanged
}
- return productionRosterRow{pub: pub, enrollment: enrollment, directory: directory, row: row}, nil
+ return productionRosterRow{home: roster.Roster.HomeRegion, pub: pub, enrollment: enrollment, directory: directory, row: row}, nil
}
// ProductionClaimOwnerProvider constructs claim-owned authority directly. Only
@@ -118,7 +119,11 @@ func (ProductionClaimOwnerProvider) OpenClaimOwners(ctx context.Context, store r
return out, hostwrite.ErrUnavailable
}
authority := productionClaimRoster{Directory: directory, DataBucket: dataBucket, FloorDir: filepath.Join(r.OptDir, "system", "guard")}
- out.Identity = hostbox.IdentityProvider{AWSRegion: row.Cloud.Region, Account: row.Cloud.Account, Box: row.BoxID, Region: row.Region, Bucket: dataBucket, Namespace: row.Namespace, Certs: certs, Roster: authority, Evidence: productionBoxEvidence{store}}
+ _, homeRegion, err := claimRegionBinding(row.Region, os.Getenv("CARLOS_REGION"), rr.home)
+ if err != nil {
+ return out, err
+ }
+ out.Identity = hostbox.IdentityProvider{HomeRegion: homeRegion, AWSRegion: row.Cloud.Region, Account: row.Cloud.Account, Box: row.BoxID, Region: row.Region, Bucket: dataBucket, Namespace: row.Namespace, Certs: certs, Roster: authority, Evidence: productionBoxEvidence{store}}
var actor hostbox.Actor
if r.Mode == "instance-control" {
out.cleanupEnrollment, err = verifyClaimCleanupEnrollment(ctx, out.Identity, directory)
@@ -341,7 +346,7 @@ func (p productionClaimRoster) Actor(ctx context.Context, box string) (hostbox.A
if local.Digest != remote.Digest || o.Roster.Publication != "published" || o.Roster.FloorSHA256 != remote.Digest {
return a, hostfloor.ErrFloor
}
- return hostbox.Actor{ControlBucket: p.Directory.Bucket, Box: box, Enrollment: b.Enrollment, Revision: o.Roster.Revision, Region: b.Region, Bucket: p.DataBucket, Namespace: b.Namespace, Eligible: true, Cloud: hostbox.CloudIdentity{Provider: b.Cloud.Provider, Account: b.Cloud.Account, Region: b.Cloud.Region, InstanceID: b.Cloud.InstanceID}}, nil
+ return hostbox.Actor{FleetHomeRegion: o.Roster.HomeRegion, ControlBucket: p.Directory.Bucket, Box: box, Enrollment: b.Enrollment, Revision: o.Roster.Revision, Region: b.Region, Bucket: p.DataBucket, Namespace: b.Namespace, Eligible: true, Cloud: hostbox.CloudIdentity{Provider: b.Cloud.Provider, Account: b.Cloud.Account, Region: b.Cloud.Region, InstanceID: b.Cloud.InstanceID}}, nil
}
type productionClaimRegional struct {
@@ -355,13 +360,22 @@ func (p productionClaimRegional) View(ctx context.Context, o hostclaim.Observed)
if p.Parks == nil || p.Parks.View == nil {
return park.View{}, &ClaimDependencyError{Contracts: []string{"moves Plan 5 exact-version generation and files-manifest view"}}
}
- if _, err := p.Identity.Verify(ctx); err != nil {
+ actor, err := p.Identity.Verify(ctx)
+ if err != nil {
+ return park.View{}, err
+ }
+ if o.Claim == nil {
+ return park.View{}, park.ErrUnsafe
+ }
+ home, err := park.VerifiedHome(actor.FleetHomeRegion, actor.HomeRegion, actor.Region, o.Claim.Op.Intent["home_region"])
+ if err != nil {
return park.View{}, err
}
- v, err := p.Parks.View(ctx, o)
+ v, err := p.Parks.View(ctx, park.HomeView(o, home))
if err != nil {
return park.View{}, err
}
+ v.HomeRegion = home
v.ArchivedIncarnations = claimArchivedLocations(o.Claim)
v.RefusedLocations = map[string]error{}
if _, err := claimLegacyIncarnation(o.Claim); err != nil {
@@ -393,7 +407,7 @@ func (p productionClaimRegional) Authorize(ctx context.Context, o hostclaim.Obse
if err != nil {
return err
}
- if o.Claim == nil || ob.Op != o.Claim.Op.ID || (ob.Owner != "box:"+a.Box && ob.Owner != "region:"+a.Region) || (ob.Box != "" && ob.Box != a.Box) || (ob.Region != "" && ob.Region != a.Region) {
+ if o.Claim == nil || ob.Op != o.Claim.Op.ID || (ob.Owner != "box:"+a.Box && (ob.Owner != "region:"+ob.Region || !park.SameRegion(a.HomeRegion, ob.Region, a.Region))) || (ob.Box != "" && ob.Box != a.Box) || (ob.Region != "" && !park.SameRegion(a.HomeRegion, ob.Region, a.Region)) {
return hostbox.ErrChanged
}
found := false
@@ -445,12 +459,12 @@ func (t claimAdminTokens) With(ctx context.Context, host string, effect func() e
return err
}
c, h := o.Claim, o.Claim.Holder
- sel := hostbox.Selection{Host: host, Action: "spawn", Box: actor.Box, Region: actor.Region, Captured: hostguard.Captured{Incarnation: h.Incarnation, Stamp: h.Stamp}, Kind: c.Op.Kind, Op: c.Op.ID, Phase: c.Op.Phase, Generation: h.Generation, Session: h.Session}
+ sel := hostbox.Selection{HomeRegion: actor.HomeRegion, FleetHomeRegion: actor.FleetHomeRegion, Host: host, Action: "spawn", Box: actor.Box, Region: actor.Region, Captured: hostguard.Captured{Incarnation: h.Incarnation, Stamp: h.Stamp}, Kind: c.Op.Kind, Op: c.Op.ID, Phase: c.Op.Phase, Generation: h.Generation, Session: h.Session}
check := func(c *hostclaim.Claim) error {
if c.Op.Kind != sel.Kind || c.Op.ID != sel.Op || c.Op.Phase != sel.Phase || c.Holder.Incarnation != sel.Captured.Incarnation || c.Holder.Stamp != sel.Captured.Stamp || c.Holder.Session != sel.Session || c.Holder.Generation != sel.Generation {
return hostbox.ErrChanged
}
- if until, ok := c.QuietAfter[sel.Region]; ok && !t.Now().After(until) {
+ if until := park.QuietUntil(c.QuietAfter, actor.HomeRegion, sel.Region); !until.IsZero() && !t.Now().After(until) {
return hostbox.ErrChanged
}
if err := t.Local(ctx, c, sel); err != nil {
diff --git a/internal/host/claimrecoveryreceipt.go b/internal/host/claimrecoveryreceipt.go
index d9762f92e..5d6052101 100644
--- a/internal/host/claimrecoveryreceipt.go
+++ b/internal/host/claimrecoveryreceipt.go
@@ -9,6 +9,7 @@ import (
"github.com/carlosframework/platform/internal/hostbox"
"github.com/carlosframework/platform/internal/hostclaim"
"github.com/carlosframework/platform/internal/hostevidence"
+ "github.com/carlosframework/platform/internal/park"
"strconv"
)
@@ -34,7 +35,7 @@ func (h claimCrossBoxHandover) recoveryStopReceipt(ctx context.Context, o hostcl
stamp, err := strconv.ParseUint(c.Op.Journal[hostclaim.JournalTargetStamp], 10, 64)
if err != nil || stamp == 0 || r.V != 1 || r.Kind != "stopped" || r.Host != o.Host || r.Box != p.Box || r.OperationID != c.Op.ID ||
r.Incarnation != c.Holder.Incarnation || r.WriterSession != p.Session || r.InstallationStamp != stamp ||
- r.Region != c.Op.Intent["to"] || r.Enrollment != c.Op.Intent["target_enrollment"] || r.Phase != hostclaim.PhaseReleasing ||
+ !park.SameRegion(c.Op.Intent["home_region"], r.Region, c.Op.Intent["to"]) || r.Enrollment != c.Op.Intent["target_enrollment"] || r.Phase != hostclaim.PhaseReleasing ||
key != stopEvidenceKey(o.Host, c.Op.ID, p.Box, r.ID) || r.FenceID == "" || r.ObligationID == "" || r.ClaimGen == 0 ||
r.EvidenceAt.IsZero() || r.RequestLimitNS != int64(hostbox.RequestLimit) || len(r.CgroupIdentities) == 0 ||
!stopDigest(r.InventorySHA256) || !stopDigest(r.LocalJournalSHA256) || !stopDigest(r.ProducerDrainDigest) {
@@ -45,7 +46,7 @@ func (h claimCrossBoxHandover) recoveryStopReceipt(ctx context.Context, o hostcl
return err
}
box, ok := roster.Roster.Boxes[p.Box]
- if !ok || box.Enrollment != r.Enrollment || box.Region != r.Region {
+ if (c.Op.Intent["home_region"] != "" && !roster.Roster.MatchesHome(c.Op.Intent["home_region"])) || !ok || box.Enrollment != r.Enrollment || !park.SameRegion("", box.Region, r.Region) {
return ErrClaimStopIdentity
}
sig, _, err := h.Evidence.Get(ctx, key+".sig")
diff --git a/internal/host/claimregion.go b/internal/host/claimregion.go
new file mode 100644
index 000000000..673c391cb
--- /dev/null
+++ b/internal/host/claimregion.go
@@ -0,0 +1,23 @@
+package host
+
+import (
+ "github.com/carlosframework/platform/internal/hostbox"
+ "github.com/carlosframework/platform/internal/park"
+)
+
+// The signed roster supplies both the engine name and the fleet home.
+// An empty local placement binds an alias only when those names agree.
+func claimRegionBinding(roster, code, fleetHome string) (engine, home string, err error) {
+ engine = park.PlacementRegion(roster)
+ placement := park.PlacementRegion(code)
+ if roster == "" || engine == "" || placement == "" || (code != "" && engine != placement) {
+ return "", "", hostbox.ErrChanged
+ }
+ if code == "" {
+ if fleetHome == "" || roster != fleetHome {
+ return "", "", hostbox.ErrChanged
+ }
+ home = engine
+ }
+ return engine, home, nil
+}
diff --git a/internal/host/movesfinal.go b/internal/host/movesfinal.go
index 690b1772a..1df6fc778 100644
--- a/internal/host/movesfinal.go
+++ b/internal/host/movesfinal.go
@@ -390,12 +390,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.parkRegion() {
+ if o.Op != f.g.ID || !park.PlacementMatches(cfg.ParkHomeRegion, cfg.Region, o.Region) {
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.parkRegion(), Leases: cfg.ParkLeases,
+ return park.Engine{HomeRegion: cfg.ParkHomeRegion, 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}
}
@@ -409,7 +409,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.parkRegion() && o.Park != nil && !isStagingLocation(o.Park.Location) {
+ if o.Op == id && o.Kind == "park-replica" && park.PlacementMatches(cfg.ParkHomeRegion, cfg.Region, o.Region) && o.Park != nil && !isStagingLocation(o.Park.Location) {
chore = o
}
}
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/movesinstall.go b/internal/host/movesinstall.go
index d13f229b0..dc0238360 100644
--- a/internal/host/movesinstall.go
+++ b/internal/host/movesinstall.go
@@ -479,7 +479,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.parkRegion() {
+ if o.Op != id || o.Kind != "park-generation" || !park.PlacementMatches(cfg.ParkHomeRegion, cfg.Region, o.Region) {
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 aae06d1fd..467141f59 100644
--- a/internal/host/movesland_probe_test.go
+++ b/internal/host/movesland_probe_test.go
@@ -66,7 +66,10 @@ func (p *probeLog) wake(ctx context.Context, cfg activator.ProbeConfig) (activat
}
// 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)
+ from, err := predecessorRegion(h, m.From)
+ if err != nil {
+ return activator.ProbeResult{}, err
+ }
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/moveslandpark.go b/internal/host/moveslandpark.go
index ffa876ba0..d6d4f9cf3 100644
--- a/internal/host/moveslandpark.go
+++ b/internal/host/moveslandpark.go
@@ -147,7 +147,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.parkRegion(), Leases: cfg.ParkLeases,
+ return park.Engine{HomeRegion: cfg.ParkHomeRegion, 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/movespass.go b/internal/host/movespass.go
index 21ffd0d8a..7934771ed 100644
--- a/internal/host/movespass.go
+++ b/internal/host/movespass.go
@@ -626,11 +626,12 @@ type moveCfg struct {
// registry's; RemoveVerifyEntry is
// dbverify.RemoveHost; WriteGen is hostbox.WriteGenerationMarker, the
// one <dbPath>.gen writer (ruling G2).
- Guard hostguard.Guard
- Local park.Local
- ParkLeases park.Leases
- ParkView func(context.Context, hostclaim.Observed) (park.View, error)
- ParkAuthorize func(context.Context, hostclaim.Observed, hostclaim.Obligation) error
+ Guard hostguard.Guard
+ Local park.Local
+ ParkHomeRegion string
+ ParkLeases park.Leases
+ ParkView func(context.Context, hostclaim.Observed) (park.View, error)
+ ParkAuthorize func(context.Context, hostclaim.Observed, hostclaim.Obligation) error
// ParkRunHeld is park.Engine.RunHeld (movesRunHeld): it runs a chore
// under a lease the caller holds (M5: the landing's residue park under
// its own-location lease), borrowed through the claim's leases
diff --git a/internal/host/movesphase1.go b/internal/host/movesphase1.go
index 32d6ad17f..ba94c7068 100644
--- a/internal/host/movesphase1.go
+++ b/internal/host/movesphase1.go
@@ -6,10 +6,12 @@ import (
"encoding/json"
"errors"
"fmt"
+ "github.com/carlosframework/platform/internal/park"
"io"
"log"
"os"
"path/filepath"
+ "slices"
"strings"
"time"
@@ -318,12 +320,14 @@ func (w *movesWorker) sourceRound(ctx context.Context, s release.Store, rec prov
// K12 and T9: the target region and box are in touched before the
// first staging write.
targetBox := obs.Claim.Op.Intent["target"]
- if !containsString(obs.Claim.Touched.Regions, rec.Move.To) || (targetBox != "" && !containsString(obs.Claim.Touched.Boxes, targetBox)) {
+ if !slices.ContainsFunc(obs.Claim.Touched.Regions, func(region string) bool {
+ return park.PlacementMatches(obs.Claim.Op.Intent["home_region"], rec.Move.To, region)
+ }) || (targetBox != "" && !containsString(obs.Claim.Touched.Boxes, targetBox)) {
var boxes []string
if targetBox != "" {
boxes = []string{targetBox}
}
- if _, err := hostclaim.Update(ctx, s, obs, hostclaim.Touch(id, []string{rec.Move.To}, boxes)); err != nil {
+ if _, err := hostclaim.Update(ctx, s, obs, hostclaim.Touch(id, []string{park.PlacementRegion(rec.Move.To)}, boxes)); err != nil {
return nil, nil, fmt.Errorf("touch %s in the host claim: %w", rec.Move.To, err)
}
}
diff --git a/internal/host/movespolicy.go b/internal/host/movespolicy.go
index de2b1fc22..0d0df30fb 100644
--- a/internal/host/movespolicy.go
+++ b/internal/host/movespolicy.go
@@ -297,7 +297,10 @@ func MovesClaimPolicyWithInputs(read MovesRecordReader, committed func(context.C
if len(c.Op.Participants) != 2 || c.Op.Participants[1] != s.Box || c.Op.Intent["target"] != s.Box || c.Op.Intent["source"] == s.Box {
return refuse("this box is not the move's target", nil)
}
- if c.Op.Intent["to"] != s.Region {
+ if _, err := park.VerifiedHome(s.FleetHomeRegion, s.HomeRegion, s.Region, c.Op.Intent["home_region"]); err != nil {
+ return refuse("this region is not the move's target region", err)
+ }
+ if !park.SameRegion(s.HomeRegion, c.Op.Intent["to"], s.Region) {
return refuse("this region is not the move's target region", nil)
}
for _, lost := range c.Lost {
@@ -416,7 +419,10 @@ func movesRecoveryPolicy(ctx context.Context, read MovesRecordReader, c *hostcla
if len(c.Op.Participants) == 0 || c.Op.Participants[0] != s.Box || c.Op.Intent["source"] != s.Box || c.Op.Intent["target"] == s.Box {
return refuse("this box is not the move's source", nil)
}
- if c.Op.Intent["to"] == s.Region {
+ if _, err := park.VerifiedHome(s.FleetHomeRegion, s.HomeRegion, s.Region, c.Op.Intent["home_region"]); err != nil {
+ return refuse("this region is the move's target region", err)
+ }
+ if park.SameRegion(s.HomeRegion, c.Op.Intent["to"], s.Region) {
return refuse("this region is the move's target region", nil)
}
for _, lost := range c.Lost {
@@ -437,7 +443,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 park.PlacementRegion(rec.Move.From) != s.Region {
+ if !park.PlacementMatches(s.HomeRegion, 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 c147a9eb2..ae575bcfb 100644
--- a/internal/host/movesprobeowners.go
+++ b/internal/host/movesprobeowners.go
@@ -92,6 +92,7 @@ import (
// Production: nothing registers this owner set yet. The probe is unarmed in
// production until Plan 5 Task 9 arms the parks; the tests use fakes.
type movesProbeOwners struct {
+ HomeRegion, FleetHomeRegion string
// Claims is the claim store (the deployment store in production).
Claims release.Store
Records MovesRecordReader
@@ -181,6 +182,7 @@ func (m *movesProbeOwners) valid() bool {
// probeLanding is what one landing probe's checks read.
type probeLanding struct {
+ homeRegion string
// committed is a landing wake after the commit (Plan 3 Task 7): the
// claim at landing/committed, the record committed.
committed bool
@@ -202,6 +204,11 @@ func (m *movesProbeOwners) landing(ctx context.Context, o hostclaim.Observed) (p
return v, hostbox.ErrUnsupported
}
c := o.Claim
+ var bindingErr error
+ v.homeRegion, bindingErr = park.VerifiedHome(m.FleetHomeRegion, m.HomeRegion, m.Region, c.Op.Intent["home_region"])
+ if bindingErr != nil {
+ return v, bindingErr
+ }
switch c.Op.Phase {
case hostclaim.PhaseLandingProbing:
case hostclaim.PhaseLandingCommitted:
@@ -210,7 +217,7 @@ func (m *movesProbeOwners) landing(ctx context.Context, o hostclaim.Observed) (p
return v, hostbox.ErrChanged
}
if c.Host != o.Host || c.Op.ID == "" || c.Op.Exec != m.Box || c.Op.Intent["target"] != m.Box ||
- c.Op.Intent["source"] == m.Box || c.Op.Intent["to"] != m.Region || len(c.Op.Participants) != 2 || c.Op.Participants[1] != m.Box {
+ c.Op.Intent["source"] == m.Box || !park.SameRegion(m.HomeRegion, c.Op.Intent["to"], m.Region) || len(c.Op.Participants) != 2 || c.Op.Participants[1] != m.Box {
return v, hostbox.ErrChanged
}
for _, lost := range c.Lost {
@@ -227,7 +234,7 @@ func (m *movesProbeOwners) landing(ctx context.Context, o hostclaim.Observed) (p
return v, hostbox.ErrChanged
}
mv := rec.Move
- if mv.ID != v.op || mv.Incarnation != v.holder.Incarnation || mv.To != m.Region || mv.Inputs == nil || mv.Inputs.Release == "" {
+ if park.PlacementRegion(mv.From) == "" || mv.ID != v.op || mv.Incarnation != v.holder.Incarnation || !park.PlacementMatches(m.HomeRegion, mv.To, m.Region) || mv.Inputs == nil || mv.Inputs.Release == "" {
return v, hostbox.ErrChanged
}
if v.committed {
@@ -364,7 +371,7 @@ func (m *movesProbeOwners) descendsFromMove(h *hostclaim.Holder, g, op string) b
if a.Generation != g || a.Mode != "cold" {
continue
}
- if a.Box != m.Box || a.Region != m.Region {
+ if a.Box != m.Box || !park.SameRegion(m.HomeRegion, a.Region, m.Region) {
return false
}
g, found = a.Predecessor, true
@@ -378,7 +385,7 @@ func (m *movesProbeOwners) descendsFromMove(h *hostclaim.Holder, g, op string) b
return false
}
for _, a := range h.Admissions {
- if a.Generation == op && a.Box == m.Box && a.Region == m.Region && a.Mode == "cold" {
+ if a.Generation == op && a.Box == m.Box && park.SameRegion(m.HomeRegion, a.Region, m.Region) && a.Mode == "cold" {
return true
}
}
@@ -387,24 +394,18 @@ func (m *movesProbeOwners) descendsFromMove(h *hostclaim.Holder, g, op string) b
// predecessorRegion is AdmitWriter's predecessor region: the last
// admission's, or the move's source region before any.
-func predecessorRegion(h *hostclaim.Holder, from string) string {
+func predecessorRegion(h *hostclaim.Holder, from string) (string, error) {
+ if !provision.ValidRegion(from) {
+ return "", hostbox.ErrChanged
+ }
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
+ return h.Admissions[n-1].Region, nil
+ }
+ return park.PlacementRegion(from), nil
}
func (m *movesProbeOwners) request(host string, h *hostclaim.Holder) genselect.Request {
- return genselect.Request{Bucket: m.Bucket, Region: m.Region, Host: host, Holder: *h}
+ return genselect.Request{Bucket: m.Bucket, Region: m.Region, Host: host, Holder: park.HolderRegionView(*h, m.HomeRegion, m.Region)}
}
func preparedPin(v probeLanding) genselect.PreparedPin {
@@ -532,8 +533,12 @@ func (m *movesProbeOwners) Select(ctx context.Context, rt registry.Route, o host
}
h := v.holder
prepared := firstAdmission(h, m.Box)
+ predecessor, err := predecessorRegion(h, v.rec.Move.From)
+ if err != nil {
+ return ClaimLaunchInputs{}, err
+ }
admission := hostclaim.Admission{ObservedOpID: v.op, ObservedPhase: o.Claim.Op.Phase, ObservedSession: h.Session, ObservedGeneration: h.Generation, Box: m.Box, Mode: "cold",
- KeepStamp: h.Stamp > h.Session, Region: m.Region, PredecessorRegion: predecessorRegion(h, v.rec.Move.From)}
+ KeepStamp: h.Stamp > h.Session, Region: m.Region, PredecessorRegion: predecessor}
if prepared {
files, db, err := m.localMarkers(o.Host, rt.DBPath)
if err != nil {
@@ -595,9 +600,13 @@ func (m *movesProbeOwners) AdmissionEvidence(ctx context.Context, o hostclaim.Ob
return err
}
h := v.holder
- if in.ObservedOpID != v.op || in.ObservedPhase != o.Claim.Op.Phase || in.ObservedSession != h.Session || in.ObservedGeneration != h.Generation || in.Box != m.Box || in.Region != m.Region ||
+ predecessor, err := predecessorRegion(h, v.rec.Move.From)
+ if err != nil {
+ return err
+ }
+ if in.ObservedOpID != v.op || in.ObservedPhase != o.Claim.Op.Phase || in.ObservedSession != h.Session || in.ObservedGeneration != h.Generation || in.Box != m.Box || !park.SameRegion(m.HomeRegion, in.Region, m.Region) ||
in.Mode != "cold" || in.PredecessorCertified || in.LossID != "" || len(in.AdditionalWritesLost) != 0 || in.KeepStamp != (h.Stamp > h.Session) ||
- in.PredecessorRegion != predecessorRegion(h, v.rec.Move.From) || baseline == nil || in.RemoteMaxTXID != baseline.MaxTXID {
+ !park.SameRegion(v.homeRegion, in.PredecessorRegion, predecessor) || baseline == nil || in.RemoteMaxTXID != baseline.MaxTXID {
return hostbox.ErrChanged
}
prepared := firstAdmission(h, m.Box)
@@ -1284,10 +1293,14 @@ func withoutProbeVars(env []string) []string {
return out
}
-func (m *movesProbeOwners) committedAdmission(o hostclaim.Observed, v probeLanding, gen string, plan *genselect.Plan) hostclaim.Admission {
+func (m *movesProbeOwners) committedAdmission(o hostclaim.Observed, v probeLanding, gen string, plan *genselect.Plan) (hostclaim.Admission, error) {
h := v.holder
+ predecessor, err := predecessorRegion(h, v.rec.Move.From)
+ if err != nil {
+ return hostclaim.Admission{}, err
+ }
return hostclaim.Admission{ObservedOpID: v.op, ObservedPhase: o.Claim.Op.Phase, ObservedSession: h.Session, ObservedGeneration: h.Generation, Box: m.Box, Mode: "cold",
- NewGeneration: gen, KeepStamp: h.Stamp > h.Session, Region: m.Region, PredecessorRegion: predecessorRegion(h, v.rec.Move.From), RemoteMaxTXID: plan.MaxTXID}
+ NewGeneration: gen, KeepStamp: h.Stamp > h.Session, Region: m.Region, PredecessorRegion: predecessor, RemoteMaxTXID: plan.MaxTXID}, nil
}
func (m *movesProbeOwners) selectCommitted(ctx context.Context, rt registry.Route, o hostclaim.Observed, v probeLanding) (ClaimLaunchInputs, error) {
@@ -1320,7 +1333,11 @@ func (m *movesProbeOwners) selectCommitted(ctx context.Context, rt registry.Rout
if pins.EnvReport == "" || id != pinnedLaunchIdentity(pins) {
return ClaimLaunchInputs{}, fmt.Errorf("%w: the launch is not the move's pinned inputs", hostbox.ErrChanged)
}
- return ClaimLaunchInputs{Roles: []string{"main"}, Identity: id, Baseline: plan, Admission: m.committedAdmission(o, v, gen, plan),
+ admission, err := m.committedAdmission(o, v, gen, plan)
+ if err != nil {
+ return ClaimLaunchInputs{}, err
+ }
+ return ClaimLaunchInputs{Roles: []string{"main"}, Identity: id, Baseline: plan, Admission: admission,
Executable: l.Executable, ExecutableSHA256: l.ExecutableSHA256, Args: append([]string(nil), l.Args...), Env: withoutProbeVars(l.Env),
DBPath: rt.DBPath, Version: l.Version, Limit: l.Limit}, nil
}
@@ -1330,7 +1347,11 @@ func (m *movesProbeOwners) committedEvidence(ctx context.Context, o hostclaim.Ob
if baseline == nil || h.WriterBox != m.Box || in.NewGeneration == v.op || !claimFreshGeneration(h, in.NewGeneration) || in.PredecessorCertified || in.LossID != "" || len(in.AdditionalWritesLost) != 0 {
return hostbox.ErrChanged
}
- if !reflect.DeepEqual(in, m.committedAdmission(o, v, in.NewGeneration, baseline)) {
+ want, err := m.committedAdmission(o, v, in.NewGeneration, baseline)
+ if err != nil {
+ return err
+ }
+ if !reflect.DeepEqual(in, want) {
return hostbox.ErrChanged
}
fresh, err := genselect.Select(ctx, m.Data, m.request(o.Host, h))
@@ -1507,7 +1528,7 @@ func (m *movesProbeOwners) unpublishedPath(h *hostclaim.Holder, from, to string)
break
}
}
- if found == nil || found.Box != m.Box || found.Region != m.Region || found.Predecessor == "" {
+ if found == nil || found.Box != m.Box || !park.SameRegion(m.HomeRegion, found.Region, m.Region) || found.Predecessor == "" {
return nil, false
}
g = found.Predecessor
diff --git a/internal/host/movesreport.go b/internal/host/movesreport.go
index a02c2b76a..5540fa8a9 100644
--- a/internal/host/movesreport.go
+++ b/internal/host/movesreport.go
@@ -6,11 +6,13 @@ import (
"encoding/json"
"errors"
"fmt"
+ "github.com/carlosframework/platform/internal/park"
"io"
"io/fs"
"log"
"os"
"path/filepath"
+ "slices"
"strings"
"time"
@@ -242,10 +244,10 @@ func (w *movesWorker) targetReport(ctx context.Context, s release.Store, rec pro
}
rep.Checks[moves.CheckDisk] = w.checkDisk(max(staged, dbBytes), replicaErr, art)
rep.Checks[moves.CheckRelease] = w.checkRelease(ctx, s, rec, source)
- rep.Checks[moves.CheckOneOwner] = checkOneOwner(fleet, rec.Move.To)
+ rep.Checks[moves.CheckOneOwner] = checkOneOwner(fleet, rec.Move.To, moveRegionName(obs.Claim, park.PlacementRegion(rec.Move.To)))
rep.Checks[moves.CheckClaim] = w.checkClaim(ctx, obs, rec)
rep.Checks[moves.CheckLane] = w.checkLane(ctx, rec, fleet)
- rep.Checks[moves.CheckQuiet] = checkQuiet(obs, rec.Move.To, now)
+ rep.Checks[moves.CheckQuiet] = checkQuiet(obs, park.PlacementRegion(rec.Move.To), now)
rep.Checks[moves.CheckArmed] = checkArmed(cfg)
// T11 from measured rates: this box's own, and the source's cross rate.
@@ -705,9 +707,13 @@ func (w *movesWorker) checkRelease(ctx context.Context, s release.Store, rec pro
// checkOneOwner is T8: exactly one hosting box publishes the target
// placement.
-func checkOneOwner(fleet moveFleet, to string) moves.Check {
+func checkOneOwner(fleet moveFleet, to string, names ...string) moves.Check {
+ name := moves.RegionText(to)
+ if len(names) == 1 && names[0] != "" {
+ name = names[0]
+ }
if n := len(fleet.boxes[to]); n != 1 {
- return moves.Check{Reason: t8Count(n, to)}
+ return moves.Check{Reason: t8Count(n, name)}
}
return moves.Check{OK: true}
}
@@ -724,8 +730,10 @@ func (w *movesWorker) checkClaim(ctx context.Context, obs hostclaim.Observed, re
if c.Holder == nil || c.Holder.Incarnation != rec.Move.Incarnation {
return moves.Check{Reason: t9Incarnation}
}
- if !containsString(c.Touched.Regions, rec.Move.To) {
- return moves.Check{Reason: t9Untouched(rec.Move.To)}
+ if !slices.ContainsFunc(c.Touched.Regions, func(region string) bool {
+ return park.PlacementMatches(c.Op.Intent["home_region"], rec.Move.To, region)
+ }) {
+ return moves.Check{Reason: t9Untouched(moveRegionName(c, park.PlacementRegion(rec.Move.To)))}
}
if !containsString(c.Touched.Boxes, w.cfg.Box) {
return moves.Check{Reason: t9Untouched(w.cfg.Box)}
@@ -772,8 +780,8 @@ func (w *movesWorker) checkLane(ctx context.Context, rec provision.Record, fleet
// checkQuiet is T12: the claim's quiet_after for the target region passed.
func checkQuiet(obs hostclaim.Observed, region string, now time.Time) moves.Check {
- if until, ok := obs.Claim.QuietAfter[region]; ok && now.Before(until) {
- return moves.Check{Reason: t12NotQuiet(region, until)}
+ if until := park.QuietUntil(obs.Claim.QuietAfter, obs.Claim.Op.Intent["home_region"], region); !until.IsZero() && now.Before(until) {
+ return moves.Check{Reason: t12NotQuiet(moveRegionName(obs.Claim, region), until)}
}
return moves.Check{OK: true}
}
@@ -811,3 +819,13 @@ func (w *movesWorker) measure(ctx context.Context, rec provision.Record) moves.M
w.mu.Unlock()
return m
}
+
+func moveRegionName(c *hostclaim.Claim, region string) string {
+ if park.SameRegion(c.Op.Intent["home_region"], region, park.HomeRegion) {
+ if home := c.Op.Intent["home_region"]; home != "" {
+ return home
+ }
+ return moves.HomeRegionText
+ }
+ return region
+}
diff --git a/internal/host/movesrevert.go b/internal/host/movesrevert.go
index 29e39c730..471c6e8fa 100644
--- a/internal/host/movesrevert.go
+++ b/internal/host/movesrevert.go
@@ -903,7 +903,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.parkRegion() && o.Park != nil && o.Park.Location == lane {
+ if o.Op == id && o.Kind == "park-replica" && park.PlacementMatches(cfg.ParkHomeRegion, cfg.Region, o.Region) && o.Park != nil && o.Park.Location == lane {
chores = append(chores, o)
}
}
@@ -921,12 +921,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.parkRegion() || o.Park == nil || o.Park.Location != lane {
+ o.Op != id || o.Kind != "park-replica" || !park.PlacementMatches(cfg.ParkHomeRegion, cfg.Region, o.Region) || 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.parkRegion(), Leases: cfg.ParkLeases,
+ e := park.Engine{HomeRegion: cfg.ParkHomeRegion, 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 d7223b6c2..a00b080a2 100644
--- a/internal/host/movesrollback.go
+++ b/internal/host/movesrollback.go
@@ -1124,7 +1124,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.parkRegion() || o.Park == nil {
+ if o.Op != id || o.Kind != "park-replica" || !park.PlacementMatches(cfg.ParkHomeRegion, cfg.Region, o.Region) || o.Park == nil {
continue
}
work := park.Work{Host: host, ID: o.ID, Op: id}
@@ -1161,7 +1161,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.parkRegion(), Leases: cfg.ParkLeases,
+ return park.Engine{HomeRegion: cfg.ParkHomeRegion, 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}
}
@@ -1176,7 +1176,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.parkRegion():
+ case o.Kind == "park-replica" && park.PlacementMatches(cfg.ParkHomeRegion, cfg.Region, o.Region):
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 236451ece..c102f5de3 100644
--- a/internal/host/movessources.go
+++ b/internal/host/movessources.go
@@ -74,13 +74,8 @@ const movesNoLiveRegion = "@none"
// 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.
+// movesBoxRegion refuses a placement that disagrees with the verified roster.
+// productionRosterRegion maps home only after checking the signed designation.
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)
@@ -239,14 +234,14 @@ func movesParkViewAt(records MovesRecordReader, data genselect.Reader, bucket st
if err != nil {
return park.View{}, err
}
- v := park.View{ClaimETag: obs.ETag, Region: region, AuthoritativeRegion: auth}
+ v := park.View{HomeRegion: obs.Claim.Op.Intent["home_region"], ClaimETag: obs.ETag, Region: region, AuthoritativeRegion: auth}
h := obs.Claim.Holder
if h == nil {
return v, nil
}
location := strings.TrimSuffix(h.Location, "/")
v.GenerationOf = func(r park.Ref) (string, bool) { return landingGenerationOf(location, r.Key) }
- if auth != region {
+ if !park.SameRegion(v.HomeRegion, auth, region) {
return v, nil
}
listing, err := data.ListVersions(ctx, bucket, location+"/")
@@ -501,7 +496,7 @@ func movesAuthoritative(ctx context.Context, records MovesRecordReader, obs host
return nil
}
for _, w := range want {
- if admitted == w {
+ if park.SameRegion(c.Op.Intent["home_region"], admitted, w) {
return nil
}
}
@@ -517,8 +512,8 @@ func movesAuthoritative(ctx context.Context, records MovesRecordReader, obs host
if err := contradicts(from, to); err != nil {
return "", err
}
- if known && admitted == to {
- if region == from && c.Op.Phase != hostclaim.PhaseCommitted && m.State != provision.MoveCommitted {
+ if known && park.SameRegion(c.Op.Intent["home_region"], admitted, to) {
+ if park.SameRegion(c.Op.Intent["home_region"], 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
@@ -616,7 +611,8 @@ 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 its placement's
-// engine region (parkRegion: park.HomeRegion for the home's ""); RunHeld
+// engine region (parkRegion, compared through the engine's OwnsRegion; a home
+// box only when the claim grants the @home alias, claim Plan 13); RunHeld
// over those leases, which must lend a held lease (M5); and the MoveParks
// local authorizer (M9). An engine region of "" never arms, and nothing
// arms while the wake-source gate is shut (movesWakeSourceRegistered). Any
@@ -629,9 +625,16 @@ func armMovesParks(ctx context.Context, cfg *moveCfg, providers ClaimProviders)
if err != nil {
return fmt.Errorf("the claim's MoveParks: %w", err)
}
- if want := cfg.parkRegion(); want == "" || engine.Bucket != cfg.OwnBucket || engine.Region != want {
+ if want := cfg.parkRegion(); want == "" || engine.Bucket != cfg.OwnBucket || !engine.OwnsRegion(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 cfg.parkRegion() == park.HomeRegion && engine.HomeRegion == "" {
+ // A home box arms only when the claim grants it the @home alias
+ // (claim Plan 13: its signed roster region is the fleet's signed home
+ // designation); any other engine, one naming itself @home included,
+ // leaves it unarmed.
+ return fmt.Errorf("%w: the claim grants this home box no @home alias (engine %s in %q)", hostbox.ErrChanged, engine.Bucket, engine.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"))
}
@@ -644,6 +647,7 @@ func armMovesParks(ctx context.Context, cfg *moveCfg, providers ClaimProviders)
if err := movesWakeSourceRegistered(); err != nil {
return err
}
+ cfg.ParkHomeRegion = engine.HomeRegion
cfg.ParkLeases, cfg.ParkView, cfg.ParkAuthorize = engine.Leases, engine.View, engine.Authorize
cfg.ParkRunHeld = movesRunHeld
cfg.ParkLocalAuthorize = local
@@ -696,6 +700,10 @@ func productionRosterRegion(store release.Store, getenv func(string) string) fun
if err != nil {
return "", err
}
- return rr.row.Region, nil
+ engine, home, err := claimRegionBinding(rr.row.Region, getenv("CARLOS_REGION"), rr.home)
+ if err != nil {
+ return "", err
+ }
+ return park.PlacementRegion(park.HomePlacementCode(home, engine)), nil
}
}
diff --git a/internal/host/movessources_test.go b/internal/host/movessources_test.go
index 17560326c..4f98fbdad 100644
--- a/internal/host/movessources_test.go
+++ b/internal/host/movessources_test.go
@@ -762,11 +762,17 @@ func TestRegisterProductionMovesParksNeedsBothSwitches(t *testing.T) {
}
}
-// 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.
+// TestHomeBoxArms (fix round 1, region source; home-region parks, part 1;
+// claim Plan 13): a home box, whose placement code is "", is seen from
+// park.HomeRegion, and its arms take the claim's engine only when the claim
+// grants it the @home alias: the engine is named by the box's signed roster
+// region and carries that region as its HomeRegion (the fleet's signed home
+// designation). Without the grant, or with a home that is another region, a
+// home box stays unarmed, even over an engine that names itself @home. 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. A
+// named box arms over its own named engine whatever the fleet's home is. The
+// gate is open here; in production it stays shut until Task 8.
func TestHomeBoxArms(t *testing.T) {
withMovesWakeSource(t, true)
f := newVwFixture(t)
@@ -779,28 +785,37 @@ func TestHomeBoxArms(t *testing.T) {
t.Fatalf("the home view %+v, %v", v, err)
}
auth := func(context.Context, hostclaim.Observed, hostclaim.Obligation) error { return nil }
- arm := func(engineRegion, placement string) (moveCfg, error) {
+ arm := func(engineRegion, home, placement string) (moveCfg, error) {
providers := ClaimProviders{moveParks: &claimMoveParks{
local: claimMoveLocalAuthorizer(func(context.Context) error { return nil }, auth),
- engine: park.Engine{Bucket: "data-home", Region: engineRegion, Leases: (&renewTable{}).leases(), View: view, Authorize: auth},
+ engine: park.Engine{Bucket: "data-home", Region: engineRegion, HomeRegion: home, Leases: (&renewTable{}).leases(), View: view, Authorize: auth},
leases: hostbox.ReplicaParkLeaseSet, check: func(context.Context) error { return nil }}}
c := moveCfg{OwnBucket: "data-home", Region: placement}
err := armMovesParks(t.Context(), &c, providers)
return c, err
}
- c, err := arm(park.HomeRegion, "")
+ c, err := arm("ie", "ie", "")
if err != nil {
- t.Fatalf("a home box did not arm: %v", err)
+ t.Fatalf("a home box the claim grants @home did not arm: %v", err)
}
- if !parkHooksArmed(c) {
- t.Fatal("hooks unset")
+ if !parkHooksArmed(c) || c.ParkHomeRegion != "ie" {
+ t.Fatalf("hooks unset or the home binding lost: %q", c.ParkHomeRegion)
}
- if c, err := arm("uk", "uk"); err != nil || !parkHooksArmed(c) {
- t.Fatalf("a named box did not arm: %v", err)
+ for _, tc := range [][3]string{{"uk", "", "uk"}, {"uk", "ie", "uk"}} {
+ if c, err := arm(tc[0], tc[1], tc[2]); err != nil || !parkHooksArmed(c) {
+ t.Fatalf("a named box (engine %q, home %q) did not arm: %v", tc[0], tc[1], 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)
+ for _, tc := range [][3]string{
+ {"ie", "", ""}, // no alias granted
+ {"ie", "uk", ""}, // the fleet's home is another region
+ {park.HomeRegion, "", ""}, // an engine naming itself @home, no grant
+ {"", "", ""}, {"", "ie", ""}, // an unset engine region
+ {"home", "", ""}, // the roster's own word, with no grant
+ {park.HomeRegion, "", "home"}, {park.HomeRegion, "", park.HomeRegion}, {"IE", "", "IE"}, {"", "", "uk"},
+ } {
+ if c, err := arm(tc[0], tc[1], tc[2]); !errors.Is(err, hostbox.ErrChanged) || parkHooksArmed(c) {
+ t.Fatalf("engine in %q, home %q, placement %q: armed (%v)", tc[0], tc[1], tc[2], err)
}
}
if provision.ValidRegion(movesNoLiveRegion) || movesNoLiveRegion == park.HomeRegion {
diff --git a/internal/host/plan12_handover_test.go b/internal/host/plan12_handover_test.go
index e4a6d1012..484ea9329 100644
--- a/internal/host/plan12_handover_test.go
+++ b/internal/host/plan12_handover_test.go
@@ -22,13 +22,16 @@ import (
"time"
)
-type plan12FenceAuthority struct{ reject bool }
+type plan12FenceAuthority struct {
+ reject bool
+ home string
+}
func (a plan12FenceAuthority) ValidateFence(context.Context, termfence.Record) (hostclaim.FenceBinding, error) {
if a.reject {
return hostclaim.FenceBinding{}, hostclaim.ErrFence
}
- return hostclaim.FenceBinding{Region: "uk", RosterRevision: 1, RosterETag: "roster"}, nil
+ return hostclaim.FenceBinding{FleetHomeRegion: a.home, Region: "uk", RosterRevision: 1, RosterETag: "roster"}, nil
}
func plan12Handover(t *testing.T, kind, mode string) (*recoveryFixture, hostclaim.Observed, claimCrossBoxHandover) {
t.Helper()
diff --git a/internal/host/plan13_amendments_test.go b/internal/host/plan13_amendments_test.go
new file mode 100644
index 000000000..2e266831a
--- /dev/null
+++ b/internal/host/plan13_amendments_test.go
@@ -0,0 +1,46 @@
+package host
+
+import (
+ "github.com/carlosframework/platform/internal/activator"
+ "github.com/carlosframework/platform/internal/hostclaim"
+ "github.com/carlosframework/platform/internal/registry"
+ "strings"
+ "testing"
+)
+
+func TestPlan13LandingInvalidSourceWithoutHistory(t *testing.T) {
+ for _, from := range []string{"@home", "IE"} {
+ f := newPOFixture(t)
+ c := f.poFlipClaim()
+ c.Holder.Admissions = nil
+ f.rec.Move.From = from
+ o := hostclaim.Observed{Host: poHost, Claim: c}
+ if _, err := f.owners.landing(t.Context(), o); err == nil {
+ t.Fatal("invalid source accepted", from)
+ }
+ if got, err := predecessorRegion(c.Holder, from); err == nil || got != "" {
+ t.Fatal("invalid predecessor accepted", got, err)
+ }
+ }
+}
+
+func TestPlan13NamedAdmissionReadsHomeStorageWithC8Gate(t *testing.T) {
+ f := plan9D5Fixture(t)
+ f.owners.HomeRegion = "ie"
+ f.owners.Region = "@home"
+ f.file(t, "old", 9, 1, 5)
+ f.marker(t, "old")
+ o := hostclaim.Observed{Host: recoveryHost, Claim: &hostclaim.Claim{Holder: &hostclaim.Holder{Incarnation: "inc", Location: "instances/" + recoveryHost, Generation: "new", Admissions: []hostclaim.AdmissionRecord{{Generation: "new", Predecessor: "old", Region: "ie", PredecessorRegion: "ie", Mode: "cold"}}}}}
+ plan, err := f.owners.baseline(t.Context(), o)
+ if err != nil || plan == nil || plan.MaxTXID != 5 {
+ t.Fatal(plan, err)
+ }
+ if o.Claim.Holder.Admissions[0].Region != "ie" {
+ t.Fatal("rewrote history")
+ }
+ f.owners.Sources.Files = nil
+ o.Claim.Op = hostclaim.Op{Kind: hostclaim.KindServing, Exec: f.owners.Box}
+ if _, err := f.owners.Select(t.Context(), registry.Route{}, o, activator.StartPolicy{}); err == nil || !strings.Contains(err.Error(), "C8") {
+ t.Fatal("C8 provider gate removed", err)
+ }
+}
diff --git a/internal/host/plan13_fix1_test.go b/internal/host/plan13_fix1_test.go
new file mode 100644
index 000000000..13383954b
--- /dev/null
+++ b/internal/host/plan13_fix1_test.go
@@ -0,0 +1,112 @@
+package host
+
+import (
+ "encoding/json"
+ "github.com/carlosframework/platform/internal/hostclaim"
+ "github.com/carlosframework/platform/internal/lease"
+ "github.com/carlosframework/platform/internal/moves"
+ "github.com/carlosframework/platform/internal/park"
+ "strings"
+ "testing"
+ "time"
+)
+
+func TestPlan13HomeTargetReadiness(t *testing.T) {
+ for _, stored := range []string{park.HomeRegion, "ie", "ca"} {
+ t.Run(stored, func(t *testing.T) {
+ h := newPhase1Harness(t)
+ h.start(t, "manual")
+ rec := h.read(t, p1Host)
+ rec.Move.To = ""
+ obs, err := hostclaim.Read(t.Context(), h.s, p1Host)
+ if err != nil {
+ t.Fatal(err)
+ }
+ obs.Claim.Op.Intent["home_region"] = "ie"
+ obs.Claim.Touched.Regions = []string{stored}
+ obs.Claim.Touched.Boxes = append(obs.Claim.Touched.Boxes, "uk-box")
+ raw, err := json.Marshal(moves.MoveJSON{MoveID: rec.Move.ID, Incarnation: rec.Move.Incarnation, Host: rec.Host, From: rec.Move.From, To: rec.Move.To})
+ if err != nil {
+ t.Fatal(err)
+ }
+ h.f.store("data-uk", moves.MoveJSONKey(rec.Host, rec.Move.ID), fakeMoveObject{lease.MirrorObject{ETag: "x", Size: int64(len(raw))}, string(raw)})
+ w := &movesWorker{cfg: h.targetCfg(), mirrors: map[string]lease.MirrorStore{}}
+ got := w.checkClaim(t.Context(), obs, rec)
+ if got.OK != (stored != "ca") {
+ t.Fatalf("%s: %+v", stored, got)
+ }
+ if !got.OK && got.Reason != t9Untouched("ie") {
+ t.Fatal(got.Reason)
+ }
+ })
+ }
+}
+func TestPlan13HomeQuietReason(t *testing.T) {
+ now := time.Unix(1000, 0)
+ o := hostclaim.Observed{Claim: &hostclaim.Claim{Op: hostclaim.Op{Intent: map[string]string{"home_region": "ie"}}, QuietAfter: map[string]time.Time{park.HomeRegion: now.Add(time.Minute)}}}
+ got := checkQuiet(o, park.HomeRegion, now)
+ if got.OK || strings.Contains(got.Reason, park.HomeRegion) || got.Reason != t12NotQuiet("ie", now.Add(time.Minute)) {
+ t.Fatal(got)
+ }
+}
+
+func TestPlan13ProbeHomeHistory(t *testing.T) {
+ h := &hostclaim.Holder{Admissions: []hostclaim.AdmissionRecord{
+ {Generation: "move", Region: "ie", Box: "box", Mode: "cold"},
+ {Generation: "next", Predecessor: "move", Region: "@home", Box: "box", Mode: "cold"},
+ }}
+ for _, home := range []string{"ie", ""} {
+ m := &movesProbeOwners{Region: "ie", HomeRegion: home, Box: "box"}
+ if got := m.descendsFromMove(h, "next", "move"); got != (home != "") {
+ t.Fatal("descent", home, got)
+ }
+ _, ok := m.unpublishedPath(h, "next", "move")
+ if ok != (home != "") {
+ t.Fatal("unpublished path", home, ok)
+ }
+ }
+}
+
+func TestPlan13LandingUsesLocalBinding(t *testing.T) {
+ for _, tc := range []struct {
+ local, fleet, operation string
+ ok bool
+ }{
+ {"", "ie", "uk", false}, {"", "uk", "uk", false}, {"uk", "uk", "uk", true}, {"uk", "ie", "uk", false},
+ } {
+ f := newPOFixture(t)
+ c := f.poFlipClaim()
+ c.Op.Intent["to"], c.Op.Intent["home_region"] = park.HomeRegion, tc.operation
+ f.rec.Move.To = ""
+ f.owners.HomeRegion, f.owners.FleetHomeRegion = tc.local, tc.fleet
+ _, err := f.owners.landing(t.Context(), hostclaim.Observed{Host: poHost, Claim: c})
+ if (err == nil) != tc.ok {
+ t.Fatalf("%+v: %v", tc, err)
+ }
+ }
+}
+func TestPlan13OwnStorageProjectionIgnoresOperationHome(t *testing.T) {
+ h := &hostclaim.Holder{Admissions: []hostclaim.AdmissionRecord{{Region: park.HomeRegion, PredecessorRegion: park.HomeRegion}}}
+ m := &movesProbeOwners{Region: "uk"}
+ r := m.request("test.example", h)
+ if r.Holder.Admissions[0].Region != park.HomeRegion {
+ t.Fatal("named storage borrowed a home alias")
+ }
+ m.HomeRegion = "uk"
+ r = m.request("test.example", h)
+ if r.Holder.Admissions[0].Region != "uk" || h.Admissions[0].Region != park.HomeRegion {
+ t.Fatal("home view lost binding or rewrote history")
+ }
+}
+
+func TestPlan13HomeOwnerReason(t *testing.T) {
+ got := checkOneOwner(moveFleet{}, "", "ie")
+ if got.OK || got.Reason != t8Count(0, "ie") {
+ t.Fatal(got)
+ }
+ o := hostclaim.Observed{Claim: &hostclaim.Claim{QuietAfter: map[string]time.Time{park.HomeRegion: time.Unix(2, 0)}}}
+ got = checkQuiet(o, park.HomeRegion, time.Unix(1, 0))
+ if strings.Contains(got.Reason, park.HomeRegion) || strings.Contains(got.Reason, "left recently") {
+ t.Fatal(got)
+ }
+}
diff --git a/internal/host/plan13_policy_test.go b/internal/host/plan13_policy_test.go
new file mode 100644
index 000000000..e8988990d
--- /dev/null
+++ b/internal/host/plan13_policy_test.go
@@ -0,0 +1,37 @@
+package host
+
+import (
+ "github.com/carlosframework/platform/internal/hostclaim"
+ "github.com/carlosframework/platform/internal/park"
+ "github.com/carlosframework/platform/internal/provision"
+ "testing"
+)
+
+func TestPlan13HomeMovePolicy(t *testing.T) {
+ for _, home := range []string{"uk", ""} {
+ c := mpClaim(hostclaim.KindLanding, provision.LandConfigured)
+ c.Op.Intent["to"] = park.HomeRegion
+ c.Op.Intent["home_region"] = home
+ s := mpSelection(c, "restore")
+ policy, _ := mpPolicy()
+ err := policy(t.Context(), c, s, mpIdentity("restore"))
+ if err == nil {
+ t.Fatalf("home=%q: %v", home, err)
+ }
+ }
+}
+
+func TestPlan13VerifiedHomeMovePolicy(t *testing.T) {
+ c := mpClaim(hostclaim.KindLanding, provision.LandConfigured)
+ c.Op.Intent["to"], c.Op.Intent["home_region"] = park.HomeRegion, "uk"
+ s := mpSelection(c, "restore")
+ s.HomeRegion, s.FleetHomeRegion = "uk", "uk"
+ policy, _ := mpPolicy()
+ if err := policy(t.Context(), c, s, mpIdentity("restore")); err != nil {
+ t.Fatal(err)
+ }
+ s.FleetHomeRegion = "ie"
+ if err := policy(t.Context(), c, s, mpIdentity("restore")); err == nil {
+ t.Fatal("conflicting fleet home accepted")
+ }
+}
diff --git a/internal/host/plan13_quiet_test.go b/internal/host/plan13_quiet_test.go
new file mode 100644
index 000000000..54e89f75f
--- /dev/null
+++ b/internal/host/plan13_quiet_test.go
@@ -0,0 +1,23 @@
+package host
+
+import (
+ "github.com/carlosframework/platform/internal/hostclaim"
+ "github.com/carlosframework/platform/internal/park"
+ "testing"
+ "time"
+)
+
+func TestPlan13MoveQuietReadsLegacyAndHomeToken(t *testing.T) {
+ now := time.Unix(100, 0)
+ for _, stored := range []string{"ie", park.HomeRegion, "uk"} {
+ o := hostclaim.Observed{Claim: &hostclaim.Claim{Op: hostclaim.Op{Intent: map[string]string{"home_region": "ie"}}, QuietAfter: map[string]time.Time{stored: now.Add(time.Hour)}}}
+ got := checkQuiet(o, park.HomeRegion, now)
+ if got.OK != (stored == "uk") {
+ t.Fatalf("%q: %+v", stored, got)
+ }
+ got = checkQuiet(o, "uk", now)
+ if got.OK != (stored != "uk") {
+ t.Fatalf("named %q: %+v", stored, got)
+ }
+ }
+}
diff --git a/internal/host/plan13_receipt_test.go b/internal/host/plan13_receipt_test.go
new file mode 100644
index 000000000..085992ef8
--- /dev/null
+++ b/internal/host/plan13_receipt_test.go
@@ -0,0 +1,94 @@
+package host
+
+import (
+ "crypto/ed25519"
+ "crypto/sha256"
+ "encoding/hex"
+ "encoding/json"
+ "github.com/carlosframework/platform/internal/hostactors"
+ "github.com/carlosframework/platform/internal/hostclaim"
+ "github.com/carlosframework/platform/internal/hostevidence"
+ "github.com/carlosframework/platform/internal/release"
+ "strings"
+ "testing"
+ "time"
+)
+
+func TestPlan13RecoveryHomeReceipt(t *testing.T) {
+ for _, bad := range []string{"", "session", "stamp", "enrollment", "signature", "operation", "region", "digest", "unbound", "roster-name"} {
+ t.Run(bad, func(t *testing.T) {
+ f, o, h := plan12Handover(t, hostclaim.KindRestoring, "stop")
+ o.Claim.Op.Intent["to"] = "@home"
+ o.Claim.Op.Intent["home_region"] = "uk"
+ if bad == "unbound" {
+ delete(o.Claim.Op.Intent, "home_region")
+ }
+ key := ed25519.NewKeyFromSeed([]byte(strings.Repeat("b", 32)))
+ en := strings.Repeat("a", 32)
+ d := hostactors.Directory{Store: f.claims, PublicKey: f.signer.Public().(ed25519.PublicKey), Bucket: "bucket"}
+ roster := hostactors.Roster{HomeRegion: "uk", V: 1, Revision: 1, DeploymentBucket: "bucket", Boxes: map[string]hostactors.Box{"box-2": {Enrollment: en, BoxID: "box-2", Label: "box-2", Namespace: "platform", Region: "uk", Pool: "default", State: "active", Cloud: hostactors.Cloud{Provider: "aws", Account: "123", Region: "eu-west-2", InstanceID: "i-target"}, EvidenceSHA256: strings.Repeat("d", 64), CensusPublicKey: key.Public().(ed25519.PublicKey)}}, Consoles: map[string]hostactors.Console{}, Holds: map[string]hostactors.Reservation{}, Activations: map[string]hostactors.Reservation{}}
+ if bad == "roster-name" {
+ b := roster.Boxes["box-2"]
+ b.Region = "ie"
+ roster.Boxes["box-2"] = b
+ }
+ wire, _ := release.SignRootEnvelope(release.RootSigner(f.signer), roster)
+ if _, err := f.claims.Put(t.Context(), hostactors.Key, wire, ""); err != nil {
+ t.Fatal(err)
+ }
+ r := StopReceipt{V: 1, ID: "stop", Kind: "stopped", Host: o.Host, Incarnation: o.Claim.Holder.Incarnation, InstallationStamp: 2, WriterSession: 2, OperationID: "m1", Phase: "releasing", ObligationID: "writer-session-2", Box: "box-2", Region: "uk", Enrollment: en, EvidenceAt: time.Now(), RequestLimitNS: int64(2 * time.Minute), ClaimGen: 1, FenceID: "fence", InventorySHA256: strings.Repeat("e", 64), LocalJournalSHA256: strings.Repeat("f", 64), ProducerDrainDigest: strings.Repeat("a", 64), CgroupIdentities: []string{"root-role"}}
+ switch bad {
+ case "session":
+ r.WriterSession++
+ case "stamp":
+ r.InstallationStamp++
+ case "enrollment":
+ r.Enrollment = strings.Repeat("e", 32)
+ case "operation":
+ r.OperationID = "other"
+ case "region":
+ r.Region = "ie"
+ }
+ body, _ := json.Marshal(r)
+ receiptKey := stopEvidenceKey(o.Host, "m1", "box-2", "stop")
+ sum := sha256.Sum256(body)
+ signer := key
+ if bad == "signature" {
+ signer = f.signer
+ }
+ if err := hostevidence.Publish(t.Context(), f.claims, signer, receiptKey, body); err != nil {
+ t.Fatal(err)
+ }
+ h.Directory = d
+ o.Claim.Op.Journal["target_stop_receipt"] = receiptKey
+ o.Claim.Op.Journal["target_stop_sha256"] = hex.EncodeToString(sum[:])
+ o.Claim.Op.Journal[hostclaim.JournalTargetStamp] = "2"
+ // Remove the unsigned report path: the receipt alone must suffice.
+ delete(o.Claim.Op.Journal, hostclaim.JournalEvidenceTarget)
+ if bad == "digest" {
+ o.Claim.Op.Journal["target_stop_sha256"] = strings.Repeat("0", 64)
+ }
+ err := h.recoveryEvidence(t.Context(), o)
+ stored, _, readErr := f.claims.Get(t.Context(), receiptKey)
+ if readErr != nil || string(stored) != string(body) {
+ t.Fatal("receipt bytes changed")
+ }
+ if (err == nil) != (bad == "") {
+ t.Fatal(bad, err)
+ }
+ })
+ }
+}
+
+func TestPlan13HomeRecoveryFenceBinding(t *testing.T) {
+ for _, home := range []string{"uk", "", "ie"} {
+ _, o, h := plan12Handover(t, hostclaim.KindRestoring, "fence")
+ o.Claim.Op.Intent["to"] = "@home"
+ o.Claim.Op.Intent["home_region"] = home
+ h.FenceAuthority = plan12FenceAuthority{home: "uk"}
+ err := h.recoveryEvidence(t.Context(), o)
+ if (err == nil) != (home == "uk") {
+ t.Fatal(home, err)
+ }
+ }
+}
diff --git a/internal/host/plan13_region_test.go b/internal/host/plan13_region_test.go
new file mode 100644
index 000000000..2c10da20d
--- /dev/null
+++ b/internal/host/plan13_region_test.go
@@ -0,0 +1,78 @@
+package host
+
+import (
+ "errors"
+ "github.com/carlosframework/platform/internal/hostbox"
+ "github.com/carlosframework/platform/internal/hostclaim"
+ "github.com/carlosframework/platform/internal/park"
+ "testing"
+)
+
+func TestPlan13ClaimRegionBinding(t *testing.T) {
+ for _, tc := range []struct {
+ roster, code, engine, home string
+ bad bool
+ }{
+ {"ie", "", "ie", "ie", false}, {"uk", "uk", "uk", "", false},
+ {"ca", "", "", "", true}, {"uk", "ie", "", "", true}, {"", "", "", "", true}, {"@home", "", "", "", true}, {"ie", "@home", "", "", true},
+ } {
+ e, h, err := claimRegionBinding(tc.roster, tc.code, "ie")
+ if e != tc.engine || h != tc.home || (err != nil) != tc.bad {
+ t.Fatalf("%+v: %q %q %v", tc, e, h, err)
+ }
+ }
+}
+func TestPlan13VerifyRegionalHomeNames(t *testing.T) {
+ for _, home := range []string{"region", ""} {
+ identity := handoverRecoveryIdentity(t, hostbox.Actor{Box: "box", Enrollment: "enrolled", Region: "region", FleetHomeRegion: "region", ControlBucket: "control", Revision: 1})
+ identity.HomeRegion = home
+ actor, err := identity.Verify(t.Context())
+ if err != nil {
+ t.Fatal(err)
+ }
+ e := claimRegionalEnrollment{identity: identity, actor: actor}
+ for _, region := range []string{"region", park.HomeRegion, "other", ""} {
+ err = e.VerifyRegional(t.Context(), region, actor.Bucket, hostbox.RequestLimit)
+ want := region == "region" || (home != "" && region == park.HomeRegion)
+ if (err == nil) != want {
+ t.Fatalf("home %q region %q: %v", home, region, err)
+ }
+ }
+ if err = e.VerifyRegional(t.Context(), actor.Region, "other", hostbox.RequestLimit); !errors.Is(err, hostbox.ErrChanged) {
+ t.Fatal(err)
+ }
+ }
+}
+
+func TestPlan13UnverifiedAliasRefused(t *testing.T) {
+ for _, fleet := range []string{"", "ie"} {
+ identity := handoverRecoveryIdentity(t, hostbox.Actor{Box: "box", Enrollment: "enrolled", Region: "ca", FleetHomeRegion: fleet, Revision: 1})
+ identity.HomeRegion = "ca"
+ if _, err := identity.Verify(t.Context()); err == nil {
+ t.Fatal("unverified home alias accepted", fleet)
+ }
+ }
+}
+
+func TestPlan13NamedBoxCannotAcknowledgeHomeDelete(t *testing.T) {
+ if _, _, err := claimRegionBinding("ca", "", "ie"); err == nil {
+ t.Fatal("missing CARLOS_REGION granted alias")
+ }
+ identity := handoverRecoveryIdentity(t, hostbox.Actor{Box: "ca-box", Enrollment: "enrolled", Region: "ca", FleetHomeRegion: "ie", Revision: 1})
+ p := productionClaimRegional{Identity: identity, Local: &productionClaimLocal{Identity: identity, Resources: ClaimRuntimeResources{Mode: "agent", DataDir: t.TempDir()}, assembly: &ClaimAssembly{}, lifetime: t.Context()}}
+ for _, kind := range []string{hostclaim.KindDeleting, hostclaim.KindParking, hostclaim.KindPurging} {
+ ob := hostclaim.Obligation{ID: "chore", Op: "delete", Kind: "park-replica", Owner: "region:ca", Region: "ca", Retirement: "retirement"}
+ o := hostclaim.Observed{Host: "test.example", Claim: &hostclaim.Claim{Holder: &hostclaim.Holder{}, Op: hostclaim.Op{ID: "delete", Kind: kind}, Obligations: []hostclaim.Obligation{ob}}}
+ if err := p.Authorize(t.Context(), o, ob); err != nil {
+ t.Fatal("named control", err)
+ }
+ ob.Region, ob.Owner = park.HomeRegion, "region:@home"
+ o.Claim.Obligations[0] = ob
+ if err := p.Authorize(t.Context(), o, ob); err == nil {
+ t.Fatal("acknowledged home delete", kind)
+ }
+ if (park.Engine{Region: "ca"}).OwnsRegion(park.HomeRegion) {
+ t.Fatal("named engine owns home")
+ }
+ }
+}
diff --git a/internal/host/plan13_views_test.go b/internal/host/plan13_views_test.go
new file mode 100644
index 000000000..02129fc63
--- /dev/null
+++ b/internal/host/plan13_views_test.go
@@ -0,0 +1,152 @@
+package host
+
+import (
+ "context"
+ "github.com/carlosframework/platform/internal/hostbox"
+ "github.com/carlosframework/platform/internal/hostclaim"
+ "github.com/carlosframework/platform/internal/lease"
+ "github.com/carlosframework/platform/internal/park"
+ "github.com/carlosframework/platform/internal/provision"
+ "github.com/carlosframework/platform/internal/release"
+ "testing"
+)
+
+func TestPlan13ProductionRemoteHomeView(t *testing.T) {
+ f := newVwFixture(t)
+ f.rec.Region = ""
+ f.rec.Move = &provision.MoveState{ID: vwMove, Incarnation: f.inc, From: "", To: "uk", State: provision.MoveLanding}
+ h := f.holder("src", "src@ie")
+ o := hostclaim.Observed{Host: vwHost, ETag: "tag", Claim: &hostclaim.Claim{Host: vwHost, Holder: h, Op: hostclaim.Op{ID: vwMove, Kind: hostclaim.KindLanding, Phase: "installing", Intent: map[string]string{"home_region": "ie"}}}}
+ identity := handoverRecoveryIdentity(t, hostbox.Actor{Box: "box", Enrollment: "enrolled", Region: "uk", FleetHomeRegion: "ie", ControlBucket: "control", Revision: 1})
+ p := productionClaimRegional{Identity: identity, Local: &productionClaimLocal{Identity: identity, Resources: ClaimRuntimeResources{Mode: "agent", DataDir: t.TempDir()}, assembly: &ClaimAssembly{}, lifetime: t.Context()}, Parks: &claimMoveParkSources{View: f.view("uk")}}
+ if _, err := p.View(t.Context(), hostclaim.Observed{}); err == nil {
+ t.Fatal("missing claim accepted")
+ }
+ v, err := p.View(t.Context(), o)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if v.AuthoritativeRegion != park.HomeRegion || v.Region != "uk" || v.HomeRegion != "ie" {
+ t.Fatalf("view: %+v", v)
+ }
+ if h.Admissions[0].Region != "ie" {
+ t.Fatal("history rewritten")
+ }
+ ob := hostclaim.Obligation{ID: "persisted", Kind: "park-generation", Op: vwMove, Owner: "region:uk", Region: "uk", Generation: "old", Retirement: "retirement"}
+ store := release.NewDirStore(t.TempDir())
+ op := o.Claim.Op
+ o, err = hostclaim.Update(t.Context(), store, hostclaim.Observed{Host: vwHost, Absent: true}, func(c *hostclaim.Claim) error {
+ c.Holder = h
+ c.Op = op
+ c.Obligations = []hostclaim.Obligation{ob}
+ return nil
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ o, err = hostclaim.Read(t.Context(), store, vwHost)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := p.Authorize(t.Context(), o, ob); err != nil {
+ t.Fatal("authorized control", err)
+ }
+ ob.Owner, ob.Region = "region:@home", park.HomeRegion
+ o, err = hostclaim.Update(t.Context(), store, o, func(c *hostclaim.Claim) error { c.Obligations = []hostclaim.Obligation{ob}; return nil })
+ if err != nil {
+ t.Fatal(err)
+ }
+ o, err = hostclaim.Read(t.Context(), store, vwHost)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := p.Authorize(t.Context(), o, ob); err == nil {
+ t.Fatal("named worker owns home")
+ }
+}
+func TestPlan13MovesEngineBindings(t *testing.T) {
+ withMovesWakeSource(t, true)
+ for _, home := range []string{"ie", ""} {
+ region := "uk"
+ if home != "" {
+ region = "ie"
+ }
+ e := park.Engine{HomeRegion: home, Region: region, Bucket: "data", Leases: claimParkLeases(&fakeParkLeases{}), View: func(context.Context, hostclaim.Observed) (park.View, error) { return park.View{}, nil }, Authorize: allowParkAuthorize}
+ providers := ClaimProviders{moveParks: &claimMoveParks{engine: e, leases: hostbox.ReplicaParkLeaseSet, local: func(context.Context, *hostclaim.Claim, hostclaim.Obligation) error { return nil }, check: func(context.Context) error { return nil }}}
+ cfg := moveCfg{Region: region, OwnBucket: "data"}
+ if home != "" {
+ cfg.Region = ""
+ }
+ if err := armMovesParks(t.Context(), &cfg, providers); err != nil {
+ t.Fatal(err)
+ }
+ w := &movesWorker{cfg: cfg}
+ engines := []park.Engine{(&installRun{w: w}).engine(nil), (&sourceFinal{w: w}).engine(nil), (&rollbackRun{w: w}).engine(nil)}
+ for _, got := range engines {
+ if got.HomeRegion != home || !got.OwnsRegion(park.PlacementRegion(cfg.Region)) {
+ t.Fatalf("binding lost: %+v", got)
+ }
+ }
+ }
+}
+
+func TestPlan13HomeLaneEngineBinding(t *testing.T) {
+ withMovesWakeSource(t, true)
+ h := newAbortHarness(t)
+ h.abort(t)
+ h.pass(t)
+ chore := h.laneChore(t)
+ if chore == nil {
+ t.Fatal("no chore")
+ }
+ h.pastHorizon(t, chore)
+ cfg := h.laneCfg()
+ cfg.Region = ""
+ engine := park.Engine{Region: "uk", HomeRegion: "uk", Bucket: cfg.OwnBucket, Leases: cfg.ParkLeases, Authorize: allowParkAuthorize, View: func(_ context.Context, o hostclaim.Observed) (park.View, error) {
+ return park.View{Region: park.HomeRegion, ClaimETag: o.ETag, AuthoritativeRegion: "ie"}, nil
+ }}
+ p := ClaimProviders{moveParks: &claimMoveParks{engine: engine, leases: hostbox.ReplicaParkLeaseSet, local: func(context.Context, *hostclaim.Claim, hostclaim.Obligation) error { return nil }, check: func(context.Context) error { return nil }}}
+ if err := armMovesParks(t.Context(), &cfg, p); err != nil {
+ t.Fatal(err)
+ }
+ o, err := hostclaim.Read(t.Context(), h.s, p1Host)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err = hostclaim.Update(t.Context(), h.s, o, func(c *hostclaim.Claim) error { c.Op.Intent["home_region"] = "uk"; return nil }); err != nil {
+ t.Fatal(err)
+ }
+ rec := h.read(t, p1Host)
+ rec.Move.To = ""
+ worker := &movesWorker{cfg: cfg, mirrors: map[string]lease.MirrorStore{}}
+ if err := worker.laneParkTarget(t.Context(), h.s, rec, hostclaim.KindMoving, hostclaim.PhaseAborting); err != nil {
+ t.Fatal(err)
+ }
+ if h.laneChore(t) != nil {
+ t.Fatal("named home chore was not acknowledged")
+ }
+}
+
+func TestPlan13MovesViewMapsAllHomePlacements(t *testing.T) {
+ for _, moving := range []bool{false, true} {
+ f := newVwFixture(t)
+ f.rec.Region = ""
+ h := f.holder("")
+ op := hostclaim.Op{Kind: hostclaim.KindServing}
+ if moving {
+ h = f.holder("src", "src@ie")
+ op = hostclaim.Op{Kind: hostclaim.KindMoving, ID: vwMove, Intent: map[string]string{"home_region": "ie"}}
+ f.rec.Move = &provision.MoveState{ID: vwMove, Incarnation: f.inc, From: "", To: "uk", State: provision.MoveCutting}
+ }
+ v, err := f.view(park.HomeRegion)(t.Context(), vwObs(&hostclaim.Claim{Holder: h, Op: op}))
+ if !moving {
+ if err == nil {
+ t.Fatal("unbound legacy home accepted")
+ }
+ continue
+ }
+ if err != nil || v.AuthoritativeRegion != park.HomeRegion || !v.MarkerKnown {
+ t.Fatal(moving, v, err)
+ }
+ }
+}
diff --git a/internal/hostactors/plan13_home_test.go b/internal/hostactors/plan13_home_test.go
new file mode 100644
index 000000000..01509e147
--- /dev/null
+++ b/internal/hostactors/plan13_home_test.go
@@ -0,0 +1,37 @@
+package hostactors
+
+import "testing"
+
+func TestPlan13SignedHomeAgreement(t *testing.T) {
+ for _, home := range []string{"", "ie", "ca", "@home", "IE"} {
+ r := Roster{HomeRegion: "ie"}
+ if r.MatchesHome(home) != (home == "ie") {
+ t.Fatal(home)
+ }
+ }
+ if !(Roster{}).MatchesHome("") || (Roster{}).MatchesHome("ie") {
+ t.Fatal("legacy roster grants alias")
+ }
+}
+
+func TestPlan13HomeDesignationIsSignedAndOptional(t *testing.T) {
+ d, sg, o := fixture(t)
+ if _, err := d.Read(t.Context()); err != nil {
+ t.Fatal("legacy roster", err)
+ }
+ next := Clone(o.Roster)
+ next.HomeRegion = "ie"
+ updated, err := d.Replace(t.Context(), sg, o, next)
+ if err != nil {
+ t.Fatal(err)
+ }
+ read, err := d.Read(t.Context())
+ if err != nil || !read.Roster.MatchesHome("ie") {
+ t.Fatal(read, err)
+ }
+ next = Clone(updated.Roster)
+ next.HomeRegion = "@home"
+ if _, err := d.Replace(t.Context(), sg, updated, next); err == nil {
+ t.Fatal("engine token accepted as designation")
+ }
+}
diff --git a/internal/hostactors/roster.go b/internal/hostactors/roster.go
index 70114f142..478054861 100644
--- a/internal/hostactors/roster.go
+++ b/internal/hostactors/roster.go
@@ -15,6 +15,7 @@ import (
"github.com/carlosframework/platform/internal/boxid"
"github.com/carlosframework/platform/internal/hostquarantine"
+ "github.com/carlosframework/platform/internal/regionname"
"github.com/carlosframework/platform/internal/release"
)
@@ -61,6 +62,7 @@ type Reservation struct {
Hashes map[string][]string `json:"hashes,omitempty"`
}
type Roster struct {
+ HomeRegion string `json:"home_region,omitempty"`
RequestLimitNS int64 `json:"request_limit_ns,omitempty"`
EnrollmentAttempts map[string]EnrollmentAttempt `json:"enrollment_attempts,omitempty"`
Publication string `json:"publication,omitempty"`
@@ -91,6 +93,9 @@ func (r Roster) Validate(bucket string) error {
if r.V != 1 || bucket == "" || r.DeploymentBucket != bucket || r.Revision == 0 || r.Boxes == nil || r.Consoles == nil || r.Holds == nil || r.Activations == nil {
return ErrRoster
}
+ if r.HomeRegion != "" && regionname.PlacementRegion(r.HomeRegion) != r.HomeRegion {
+ return ErrRoster
+ }
if r.Publication != "" && r.Publication != "preparing" && r.Publication != "published" {
return ErrRoster
}
@@ -323,3 +328,7 @@ func (d Directory) Initialize(ctx context.Context, sg release.Signer) error {
}
return release.CreateExact(ctx, d.Store, Key, wire)
}
+
+func (r Roster) MatchesHome(home string) bool {
+ return home == r.HomeRegion && (home == "" || regionname.PlacementRegion(home) == home)
+}
diff --git a/internal/hostbox/identity.go b/internal/hostbox/identity.go
index dc4bf55d5..97f268ca5 100644
--- a/internal/hostbox/identity.go
+++ b/internal/hostbox/identity.go
@@ -3,6 +3,7 @@ package hostbox
import (
"context"
"crypto/x509"
+ "github.com/carlosframework/platform/internal/park"
"github.com/carlosframework/platform/internal/boxid"
"github.com/carlosframework/platform/internal/hostclaim"
@@ -12,6 +13,8 @@ import (
// Actor is the verified projection of the sole control/host-actors.json roster.
// Roster authenticates that document and its revision before returning a row.
type Actor struct {
+ FleetHomeRegion string
+ HomeRegion string
ControlBucket string
Box, Enrollment string
Revision uint64
@@ -31,6 +34,7 @@ type BoxEvidence interface {
}
type IdentityProvider struct {
+ HomeRegion string
AWSRegion string
Roster Roster
Evidence BoxEvidence
@@ -67,6 +71,10 @@ func (p IdentityProvider) Verify(ctx context.Context) (Actor, error) {
if ev.Box != p.Box || ev.Cloud != cloud {
return Actor{}, ErrChanged
}
+ if p.HomeRegion != "" && (p.HomeRegion != a.FleetHomeRegion || p.HomeRegion != a.Region || park.PlacementRegion(p.HomeRegion) != p.HomeRegion) {
+ return Actor{}, ErrChanged
+ }
+ a.HomeRegion = p.HomeRegion
return a, nil
}
@@ -90,7 +98,7 @@ func (p IdentityProvider) Local(authority LocalAuthority) func(context.Context,
if err != nil {
return err
}
- if s.Box != actor.Box || s.Region != actor.Region {
+ if s.Box != actor.Box || s.Region != actor.Region || s.HomeRegion != actor.HomeRegion || s.FleetHomeRegion != actor.FleetHomeRegion {
return ErrChanged
}
return authority.Check(ctx, actor, c, s)
diff --git a/internal/hostbox/park.go b/internal/hostbox/park.go
index ee6b7b0bc..8383a8760 100644
--- a/internal/hostbox/park.go
+++ b/internal/hostbox/park.go
@@ -41,7 +41,7 @@ func (r Regional) Run(ctx context.Context, work park.Work) error {
if ob.ID != work.ID || ob.Op != work.Op {
continue
}
- if ob.Region != r.Engine.Region || ob.Owner != "region:"+r.Engine.Region || (ob.Kind != "park-replica" && ob.Kind != "park-generation") {
+ if !r.Engine.OwnsRegion(ob.Region) || ob.Owner != "region:"+ob.Region || (ob.Kind != "park-replica" && ob.Kind != "park-generation") {
return ErrUnsupported
}
return r.Engine.Run(ctx, work)
diff --git a/internal/hostbox/plan13_admission_test.go b/internal/hostbox/plan13_admission_test.go
new file mode 100644
index 000000000..49c329d9a
--- /dev/null
+++ b/internal/hostbox/plan13_admission_test.go
@@ -0,0 +1,46 @@
+//go:build unix
+
+package hostbox
+
+import (
+ "context"
+ "github.com/carlosframework/platform/internal/genselect"
+ "github.com/carlosframework/platform/internal/hostclaim"
+ "github.com/carlosframework/platform/internal/park"
+ "testing"
+)
+
+func TestPlan13AdmissionKeepsExistingEngineName(t *testing.T) {
+ for _, home := range []string{"ie", ""} {
+ t.Run("home="+home, func(t *testing.T) {
+ _, store, o := workFixture(t)
+ writer := Writer{HomeRegion: home, Claims: store, Box: "box", Region: park.PlacementRegion("ie"), AdmissionEvidence: func(context.Context, hostclaim.Observed, hostclaim.Admission, *genselect.Plan) error { return nil }}
+ in := hostclaim.Admission{Box: "box", Mode: "cold", NewGeneration: "old", Region: "ie", PredecessorRegion: "ie"}
+ old, err := writer.Admit(t.Context(), o, in, nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ // Restart against the stored named admission, rather than a synthesized holder.
+ old, err = hostclaim.Read(t.Context(), store, o.Host)
+ if err != nil {
+ t.Fatal(err)
+ }
+ in.NewGeneration = "next"
+ in.ObservedSession = old.Claim.Holder.Session
+ in.ObservedGeneration = "old"
+ next, err := writer.Admit(t.Context(), old, in, nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ a := next.Claim.Holder.Admissions
+ if len(a) != 2 || a[0].Region != "ie" || a[1].Region != "ie" || a[1].PredecessorRegion != "ie" || a[1].Predecessor != "old" {
+ t.Fatalf("%+v", a)
+ }
+ writer.Region = ""
+ in.Region = ""
+ if _, err = writer.Admit(t.Context(), next, in, nil); err == nil {
+ t.Fatal("unset admitted")
+ }
+ })
+ }
+}
diff --git a/internal/hostbox/plan13_region_test.go b/internal/hostbox/plan13_region_test.go
new file mode 100644
index 000000000..e5e058179
--- /dev/null
+++ b/internal/hostbox/plan13_region_test.go
@@ -0,0 +1,103 @@
+//go:build unix
+
+package hostbox
+
+import (
+ "context"
+ "errors"
+ "github.com/carlosframework/platform/internal/hostclaim"
+ "github.com/carlosframework/platform/internal/hostwrite"
+ "github.com/carlosframework/platform/internal/lease"
+ "github.com/carlosframework/platform/internal/lease/leasetest"
+ "github.com/carlosframework/platform/internal/park"
+ "github.com/carlosframework/platform/internal/release"
+ "testing"
+ "time"
+)
+
+func TestPlan13HomeQuietWindow(t *testing.T) {
+ for _, home := range []string{"ie", ""} {
+ t.Run("home="+home, func(t *testing.T) {
+ source, store, obs := workFixture(t)
+ source.HomeRegion = home
+ _, err := hostclaim.Update(t.Context(), store, obs, func(c *hostclaim.Claim) error {
+ c.QuietAfter = map[string]time.Time{park.HomeRegion: time.Unix(200, 0)}
+ return nil
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ work, err := source.Select(t.Context(), obs.Host, "probe")
+ if err != nil {
+ t.Fatal(err)
+ }
+ ran := false
+ err = work.With(t.Context(), hostwrite.Identity{}, func() error { ran = true; return nil })
+ if home != "" && (!errors.Is(err, ErrChanged) || ran) {
+ t.Fatalf("home: %v %v", ran, err)
+ }
+ if home == "" && err != nil {
+ t.Fatalf("named: %v", err)
+ }
+ h := obs.Claim.Holder
+ selected := RecordSelection{Host: obs.Host, Key: h.Key, Incarnation: h.Incarnation, Family: h.Family, ETag: "record"}
+ r := Reconciler{HomeRegion: home, Claims: store, Box: "box", Region: "ie", Now: source.Now, CheckRecord: func(context.Context, RecordSelection) error { return nil }, CheckLocal: func(context.Context, hostclaim.Observed, RecordSelection) error { return nil }}
+ current, err := hostclaim.Read(t.Context(), store, obs.Host)
+ if err != nil {
+ t.Fatal(err)
+ }
+ err = r.check(t.Context(), current, selected)
+ if (err != nil) != (home != "") {
+ t.Fatalf("reconcile home=%q: %v", home, err)
+ }
+ })
+ }
+}
+func TestPlan13RegionalHomeAcknowledgesBothNames(t *testing.T) {
+ for _, region := range []string{park.HomeRegion, "ie", "uk"} {
+ t.Run(region, func(t *testing.T) {
+ claims := release.NewDirStore(t.TempDir())
+ bucket := leasetest.NewVersionedBucket()
+ bucket.AddBucket("data")
+ now := time.Unix(2000, 0).UTC()
+ host := "home.example"
+ inc, err := hostclaim.Incarnation("bab", "app", host, now)
+ if err != nil {
+ t.Fatal(err)
+ }
+ _, err = hostclaim.Update(t.Context(), claims, hostclaim.Observed{Host: host, Absent: true}, func(c *hostclaim.Claim) error {
+ c.Holder = &hostclaim.Holder{Family: "instance", Key: "apps/bab/app/instances/" + host + ".json", Account: "bab", App: "app", Incarnation: inc, CreatedAt: now, Location: "instances/" + host + "/" + inc, Stamp: 1}
+ c.Op = hostclaim.Op{Kind: hostclaim.KindParking, ID: "op"}
+ c.Obligations = []hostclaim.Obligation{{ID: "id", Op: "op", Kind: "park-replica", Region: region, Owner: "region:" + region, Retirement: "ret", Park: &hostclaim.ParkScope{Location: c.Holder.Location, Incarnation: inc, EvidenceAt: time.Unix(1, 0).UTC(), RequestLimit: RequestLimit}}}
+ c.Touched = hostclaim.Touched{Complete: true, Regions: []string{region}}
+ return nil
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ e := park.Engine{HomeRegion: "ie", Claims: claims, Store: bucket, Bucket: "data", Region: "ie", Leases: workerLeases{}, Now: func() time.Time { return now }, NewID: func() string { return "id2" }, Authorize: func(context.Context, hostclaim.Observed, hostclaim.Obligation) error { return nil }, View: func(_ context.Context, o hostclaim.Observed) (park.View, error) {
+ return park.View{ClaimETag: o.ETag, Region: park.HomeRegion}, nil
+ }}
+ w := RegionalWorker{Regional: Regional{Engine: e, Owner: func(context.Context, hostclaim.Observed) error { return nil }}, Enrollment: enrollmentFunc(func(context.Context, string, string, time.Duration) error { return nil }), Evidence: evidenceFunc(func(context.Context, hostclaim.Observed, []lease.ObjectVersion) (string, time.Time, error) {
+ return "", now, nil
+ })}
+ hosts, err := w.Census(t.Context())
+ if err != nil {
+ t.Fatal(err)
+ }
+ if (len(hosts) == 1) != (region != "uk") {
+ t.Fatalf("census %v", hosts)
+ }
+ if err = w.Run(t.Context()); err != nil {
+ t.Fatal(err)
+ }
+ o, err := hostclaim.Read(t.Context(), claims, host)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if (len(o.Claim.Obligations) == 0) != (region != "uk") {
+ t.Fatalf("obligations %+v", o.Claim.Obligations)
+ }
+ })
+ }
+}
diff --git a/internal/hostbox/reconcile.go b/internal/hostbox/reconcile.go
index 7cf5d4a59..0081ca0ab 100644
--- a/internal/hostbox/reconcile.go
+++ b/internal/hostbox/reconcile.go
@@ -18,6 +18,7 @@ type RecordSelection struct {
DryRun bool
}
type Reconciler struct {
+ HomeRegion string
Claims release.Store
Guard hostguard.Guard
Local park.Local
@@ -60,7 +61,7 @@ func (r Reconciler) check(ctx context.Context, obs hostclaim.Observed, selected
if err := Serving(obs.Claim, Selection{Box: r.Box}); err != nil {
return err
}
- if until, ok := obs.Claim.QuietAfter[r.Region]; ok && !r.Now().After(until) {
+ if until := park.QuietUntil(obs.Claim.QuietAfter, r.HomeRegion, r.Region); !until.IsZero() && !r.Now().After(until) {
return ErrChanged
}
if err := r.CheckRecord(ctx, selected); err != nil {
diff --git a/internal/hostbox/regionalworker.go b/internal/hostbox/regionalworker.go
index 8171abf6e..a88b5f04a 100644
--- a/internal/hostbox/regionalworker.go
+++ b/internal/hostbox/regionalworker.go
@@ -102,7 +102,7 @@ func (w RegionalWorker) Census(ctx context.Context) ([]string, error) {
return nil, readErr
}
for _, o := range obs.Claim.Obligations {
- if o.Region == w.Regional.Engine.Region && o.Owner == "region:"+w.Regional.Engine.Region {
+ if w.Regional.Engine.OwnsRegion(o.Region) && o.Owner == "region:"+o.Region {
hosts[host] = true
break
}
@@ -197,7 +197,7 @@ func (w RegionalWorker) runHost(ctx context.Context, host string) error {
if !found {
continue
}
- if o.Owner != "region:"+w.Regional.Engine.Region || o.Region != w.Regional.Engine.Region {
+ if o.Owner != "region:"+o.Region || !w.Regional.Engine.OwnsRegion(o.Region) {
continue
}
if o.Kind != "park-replica" && o.Kind != "park-generation" {
diff --git a/internal/hostbox/work.go b/internal/hostbox/work.go
index 03a14ca05..14831fbf9 100644
--- a/internal/hostbox/work.go
+++ b/internal/hostbox/work.go
@@ -3,6 +3,7 @@ package hostbox
import (
"context"
"errors"
+ "github.com/carlosframework/platform/internal/park"
"sync"
"time"
@@ -15,6 +16,7 @@ var ErrChanged = errors.New("hostbox: selected authority changed")
var ErrUnsupported = errors.New("hostbox: unresolved lifecycle contract")
type Selection struct {
+ HomeRegion, FleetHomeRegion string
Host, Action, Box, Region string
Captured hostguard.Captured
Kind, Op, Phase, Generation string
@@ -22,10 +24,12 @@ type Selection struct {
}
type WorkSource struct {
- Prepare func(context.Context, hostclaim.Observed) error
- Guard hostguard.Guard
- Box, Region string
- Now func() time.Time
+ FleetHomeRegion string
+ HomeRegion string
+ Prepare func(context.Context, hostclaim.Observed) error
+ Guard hostguard.Guard
+ Box, Region string
+ Now func() time.Time
// Policy checks the action's role/phase and the selected config/release.
Policy func(context.Context, *hostclaim.Claim, Selection, hostwrite.Identity) error
// Local checks quarantine, execution deadlines and failed cleanup under flock.
@@ -74,7 +78,7 @@ func (s WorkSource) Select(ctx context.Context, host, action string) (hostwrite.
if c.Holder.Incarnation != captured.Incarnation || c.Holder.Stamp != captured.Stamp || captured.Stamp == 0 {
return nil, ErrChanged
}
- w := selectedWork{source: s, selection: Selection{Host: host, Action: action, Box: s.Box, Region: s.Region, Captured: captured, Kind: c.Op.Kind, Op: c.Op.ID, Phase: c.Op.Phase, Generation: c.Holder.Generation, Session: c.Holder.Session}, checked: &checkedPhase{phase: c.Op.Phase}}
+ w := selectedWork{source: s, selection: Selection{HomeRegion: s.HomeRegion, FleetHomeRegion: s.FleetHomeRegion, Host: host, Action: action, Box: s.Box, Region: s.Region, Captured: captured, Kind: c.Op.Kind, Op: c.Op.ID, Phase: c.Op.Phase, Generation: c.Holder.Generation, Session: c.Holder.Session}, checked: &checkedPhase{phase: c.Op.Phase}}
return w, nil
}
@@ -86,7 +90,7 @@ func (w selectedWork) With(ctx context.Context, identity hostwrite.Identity, fn
}
switch selected.Action {
case "install", "restore", "spawn", "seed", "writer", "probe":
- if until, ok := c.QuietAfter[s.Region]; ok && !s.Now().After(until) {
+ if until := park.QuietUntil(c.QuietAfter, s.HomeRegion, s.Region); !until.IsZero() && !s.Now().After(until) {
return ErrChanged
}
}
diff --git a/internal/hostbox/writer.go b/internal/hostbox/writer.go
index 7a1409c47..f6d61eebf 100644
--- a/internal/hostbox/writer.go
+++ b/internal/hostbox/writer.go
@@ -3,6 +3,7 @@ package hostbox
import (
"context"
"fmt"
+ "github.com/carlosframework/platform/internal/park"
"strings"
"github.com/carlosframework/platform/internal/genselect"
@@ -12,6 +13,7 @@ import (
// Writer supplies transition adapters, not stop, certificate or marker evidence.
type Writer struct {
+ HomeRegion string
Loss *LossAdmission
Claims release.Store
Data genselect.Reader
@@ -25,7 +27,7 @@ func (w Writer) Baseline(ctx context.Context, observed hostclaim.Observed) (*gen
if w.Data == nil || w.Bucket == "" || w.Region == "" || observed.Absent || observed.Claim == nil || observed.Claim.Holder == nil {
return nil, ErrUnsupported
}
- return genselect.Select(ctx, w.Data, genselect.Request{Bucket: w.Bucket, Region: w.Region, Host: observed.Host, Holder: *observed.Claim.Holder})
+ return genselect.Select(ctx, w.Data, genselect.Request{Bucket: w.Bucket, Region: w.Region, Host: observed.Host, Holder: park.HolderRegionView(*observed.Claim.Holder, w.HomeRegion, w.Region)})
}
func (w Writer) Admit(ctx context.Context, observed hostclaim.Observed, in hostclaim.Admission, baseline *genselect.Plan) (hostclaim.Observed, error) {
diff --git a/internal/hostclaim/fence.go b/internal/hostclaim/fence.go
index bb90dd9bf..e8d050b76 100644
--- a/internal/hostclaim/fence.go
+++ b/internal/hostclaim/fence.go
@@ -21,9 +21,10 @@ type FenceRef struct {
}
type FenceBinding struct {
- Region string
- RosterRevision uint64
- RosterETag string
+ FleetHomeRegion string
+ Region string
+ RosterRevision uint64
+ RosterETag string
}
// FenceAuthority checks and identifies one authenticated roster observation.
@@ -107,3 +108,5 @@ func VerifyFenceForBox(ctx context.Context, s release.Store, authority FenceAuth
}
return nil
}
+
+func (f VerifiedFence) FleetHomeRegion() string { return f.binding.FleetHomeRegion }
diff --git a/internal/hostclaim/generation.go b/internal/hostclaim/generation.go
index 8c7e71022..351462b6b 100644
--- a/internal/hostclaim/generation.go
+++ b/internal/hostclaim/generation.go
@@ -2,6 +2,7 @@ package hostclaim
import (
"fmt"
+ "github.com/carlosframework/platform/internal/regionname"
"reflect"
"strconv"
)
@@ -114,7 +115,7 @@ func AdmitWriter(in Admission) func(*Claim) error {
if len(h.Admissions) > 0 {
predecessor = h.Admissions[len(h.Admissions)-1]
}
- if in.Mode == "warm" && in.Region != predecessor.Region {
+ if in.Mode == "warm" && !regionname.SameRegion(c.Op.Intent["home_region"], in.Region, predecessor.Region) {
return fmt.Errorf("%w: a warm admission stays in its predecessor's region", ErrWrongState)
}
type writerSession struct {
@@ -144,7 +145,7 @@ func AdmitWriter(in Admission) func(*Claim) error {
// Only a park of the holder's whole location retires the
// predecessor; a scoped park of another location (an old
// incarnation's residue, a staging prefix) does not.
- if ob.Kind == "park-replica" && ob.Region == entry.PredecessorRegion &&
+ if ob.Kind == "park-replica" && regionname.SameRegion(c.Op.Intent["home_region"], ob.Region, entry.PredecessorRegion) &&
(ob.Park == nil || ob.Park.Location == h.Location) {
entry.Covered = true
break
@@ -226,12 +227,12 @@ func PublishGeneration(observedOpID, observedPhase string, observedSession uint6
record.Covered = true
continue
}
- if record.PredecessorRegion != region {
+ if !regionname.SameRegion(c.Op.Intent["home_region"], record.PredecessorRegion, region) {
continue
}
queued := false
for _, ob := range c.Obligations {
- if ob.Kind == "park-generation" && ob.Region == region && ob.Generation == predecessor {
+ if ob.Kind == "park-generation" && regionname.SameRegion(c.Op.Intent["home_region"], ob.Region, region) && ob.Generation == predecessor {
queued = true
break
}
@@ -258,7 +259,7 @@ func CoverRegionPredecessors(observedOpID, observedPhase string, region string)
}
for i := range c.Holder.Admissions {
record := &c.Holder.Admissions[i]
- if !record.Covered && record.PredecessorRegion == region {
+ if !record.Covered && regionname.SameRegion(c.Op.Intent["home_region"], record.PredecessorRegion, region) {
record.Covered = true
}
}
@@ -284,7 +285,7 @@ func CoverParkedPredecessors(observedOpID, observedPhase string, region string,
if predecessor == "" {
predecessor = RootGeneration
}
- if !record.Covered && record.PredecessorRegion == region && parked[predecessor] {
+ if !record.Covered && regionname.SameRegion(c.Op.Intent["home_region"], record.PredecessorRegion, region) && parked[predecessor] {
record.Covered = true
}
}
diff --git a/internal/hostclaim/move.go b/internal/hostclaim/move.go
index 81899281e..aed39076a 100644
--- a/internal/hostclaim/move.go
+++ b/internal/hostclaim/move.go
@@ -8,6 +8,7 @@ import (
"strings"
"time"
+ "github.com/carlosframework/platform/internal/regionname"
"github.com/carlosframework/platform/internal/release"
)
@@ -68,6 +69,7 @@ func BeginMove(opID, by, sourceBox, targetBox, targetRegion string, now time.Tim
// holds the claim), and both boxes' enrollments from the signed roster.
// hostclaim only records them; hostops.BeginClaimMove does the proving.
type MoveAdmission struct {
+ HomeRegion string
AppID, AdmissionID, SourceEnrollment, TargetEnrollment string
}
@@ -81,9 +83,22 @@ func BeginAdmittedMove(opID, by, sourceBox, targetBox, targetRegion string, now
if a.AppID == "" || a.AdmissionID == "" || a.AdmissionID != opID || !validEnrollment(a.SourceEnrollment) || !validEnrollment(a.TargetEnrollment) || a.SourceEnrollment == a.TargetEnrollment {
return fmt.Errorf("%w: an admitted move needs its app, its registration and both enrollments", ErrMalformed)
}
+ if a.HomeRegion != "" && regionname.PlacementRegion(a.HomeRegion) != a.HomeRegion {
+ return ErrMalformed
+ }
+ regions := []string{targetRegion}
+ if regionname.SameRegion(a.HomeRegion, targetRegion, regionname.HomeRegion) {
+ regions = append(regions, a.HomeRegion, regionname.HomeRegion)
+ }
+ if err := StartGuard(c, regions, []string{targetBox}); err != nil {
+ return err
+ }
if err := BeginMove(opID, by, sourceBox, targetBox, targetRegion, now)(c); err != nil {
return err
}
+ if a.HomeRegion != "" {
+ c.Op.Intent["home_region"] = a.HomeRegion
+ }
c.Op.Intent["app_id"] = a.AppID
c.Op.Intent["admission_id"] = a.AdmissionID
c.Op.Intent["source_enrollment"] = a.SourceEnrollment
diff --git a/internal/hostclaim/move_target_fence.go b/internal/hostclaim/move_target_fence.go
index 514943b72..799aa5841 100644
--- a/internal/hostclaim/move_target_fence.go
+++ b/internal/hostclaim/move_target_fence.go
@@ -4,7 +4,9 @@ import (
"context"
"encoding/json"
"github.com/carlosframework/platform/internal/hostops/fencerelease"
+ "github.com/carlosframework/platform/internal/regionname"
"github.com/carlosframework/platform/internal/release"
+ "strings"
"time"
)
@@ -156,7 +158,7 @@ func ConsumedMoveTargetFence(c *Claim) (FenceRef, bool) {
// LostMoveTargetParked requires revocation and completion of every target chore.
func LostMoveTargetParked(c *Claim, region string) bool {
- if c == nil || c.Holder == nil || c.Op.Kind != KindRestoring || region == "" || region != c.Op.Intent["to"] {
+ if c == nil || c.Holder == nil || c.Op.Kind != KindRestoring || region == "" || !regionname.SameRegion(c.Op.Intent["home_region"], region, c.Op.Intent["to"]) {
return false
}
target := c.Op.Intent["target"]
@@ -164,7 +166,7 @@ func LostMoveTargetParked(c *Claim, region string) bool {
return false
}
for _, o := range c.Obligations {
- if o.Op == c.Op.ID && (o.Box == target || o.Owner == "box:"+target || o.Region == region || o.Owner == "region:"+region) {
+ if o.Op == c.Op.ID && (o.Box == target || o.Owner == "box:"+target || regionname.SameRegion(c.Op.Intent["home_region"], o.Region, region) || (strings.HasPrefix(o.Owner, "region:") && regionname.SameRegion(c.Op.Intent["home_region"], strings.TrimPrefix(o.Owner, "region:"), region))) {
return false
}
}
diff --git a/internal/hostclaim/plan13_amendments_test.go b/internal/hostclaim/plan13_amendments_test.go
new file mode 100644
index 000000000..6a8477fd8
--- /dev/null
+++ b/internal/hostclaim/plan13_amendments_test.go
@@ -0,0 +1,137 @@
+package hostclaim
+
+import (
+ "errors"
+ "fmt"
+ "reflect"
+ "strings"
+ "testing"
+ "time"
+)
+
+func TestPlan13BoundHomeMoveStartCleanup(t *testing.T) {
+ for _, target := range []string{"@home", "ie"} {
+ c := &Claim{Holder: &Holder{}, Op: Op{Kind: KindServing, Exec: "src"}, Obligations: []Obligation{{ID: "cleanup", Op: "old", Kind: "park-replica", Region: "ie", Owner: "region:ie"}}}
+ before := append([]Obligation(nil), c.Obligations...)
+ a := MoveAdmission{AppID: "app", AdmissionID: "move", SourceEnrollment: strings.Repeat("a", 32), TargetEnrollment: strings.Repeat("b", 32), HomeRegion: "ie"}
+ if err := BeginAdmittedMove("move", "person", "src", "dst", target, time.Now(), a)(c); !errors.Is(err, ErrPendingChore) {
+ t.Fatal("pending home cleanup allowed move", target, err)
+ }
+ if !reflect.DeepEqual(c.Obligations, before) || c.Op.Kind != KindServing {
+ t.Fatal("refusal mutated claim")
+ }
+ }
+}
+
+func TestPlan13WholeLocationCoversHomeWithoutObjects(t *testing.T) {
+ for _, release := range []bool{false, true} {
+ for _, bound := range []bool{false, true} {
+ c := probedLanding(t)
+ home, region := "ie", "@home"
+ if release {
+ c = targetWroteLanding(t)
+ home = "uk"
+ c.Holder.Admissions = append(c.Holder.Admissions, AdmissionRecord{Region: "uk", PredecessorRegion: "uk", Session: 10, Box: "uk-box", Generation: "gen-r2", Predecessor: moveA, Mode: "cold"})
+ c.Holder.Session, c.Holder.Generation = 10, "gen-r2"
+ if err := RollBack(moveA, rbA)(c); err != nil {
+ t.Fatal(err)
+ }
+ c.Op.Intent["to"] = region
+ }
+ if bound {
+ c.Op.Intent["home_region"] = home
+ }
+ n := 0
+ ids := func() string { n++; return fmt.Sprint("park", n) }
+ var err error
+ if release {
+ err = TargetReleased(moveA, "uk-box", region, stoppedTarget, releaseScope(c), ids)(c)
+ } else {
+ src, lane := commitScopes(c)
+ err = Commit(moveA, region, "ie-box", "uk", src, lane, ids)(c)
+ }
+ if err != nil {
+ t.Fatal(err)
+ }
+ idx := 0
+ if release {
+ idx = 1
+ }
+ if c.Holder.Admissions[idx].Covered != bound {
+ t.Fatalf("release=%v bound=%v: %+v", release, bound, c.Holder.Admissions)
+ }
+ if c.Holder.Admissions[idx].PredecessorRegion != home {
+ t.Fatal("rewrote admission identity")
+ }
+ }
+ }
+}
+
+func TestPlan13ReturningWriterHomeCoverage(t *testing.T) {
+ for _, bound := range []bool{false, true} {
+ c := targetWroteLanding(t)
+ if err := RollBack(moveA, rbA)(c); err != nil {
+ t.Fatal(err)
+ }
+ c.Op.Intent["to"] = "@home"
+ if bound {
+ c.Op.Intent["home_region"] = "uk"
+ }
+ n := 0
+ ids := func() string { n++; return fmt.Sprint("park", n) }
+ if err := TargetReleased(moveA, "uk-box", "@home", stoppedTarget, releaseScope(c), ids)(c); err != nil {
+ t.Fatal(err)
+ }
+ if err := ReturnToSource(moveA, "ie-box", false, ReturnFence{})(c); err != nil {
+ t.Fatal(err)
+ }
+ h := c.Holder
+ in := Admission{ObservedOpID: c.Op.ID, ObservedPhase: c.Op.Phase, ObservedSession: h.Session, ObservedGeneration: h.Generation, Box: c.Op.Exec, Mode: "cold", NewGeneration: "return", Region: "ie"}
+ if err := AdmitWriter(in)(c); err != nil {
+ t.Fatal(err)
+ }
+ last := c.Holder.Admissions[len(c.Holder.Admissions)-1]
+ if last.Covered != bound || last.PredecessorRegion != "uk" {
+ t.Fatalf("bound=%v: %+v", bound, last)
+ }
+ for _, ob := range append([]Obligation(nil), c.Obligations...) {
+ if err := CompleteObligation(ob.Op, ob.ID)(c); err != nil {
+ t.Fatal(err)
+ }
+ }
+ if c.Holder.Admissions[len(c.Holder.Admissions)-1].Covered != bound {
+ t.Fatal("empty cleanup lost coverage")
+ }
+ }
+}
+func TestPlan13RetainedHomeChoreBlocksLostTargetParked(t *testing.T) {
+ for _, ob := range []Obligation{
+ {ID: "pending", Op: "move", Kind: "park-generation", Region: "ie", Owner: "region:ie"},
+ {ID: "pending", Op: "move", Kind: "park-generation", Region: "other", Owner: "region:ie"},
+ } {
+ c := &Claim{Holder: &Holder{}, Op: Op{ID: "move", Kind: KindRestoring, Exec: "source", Intent: map[string]string{"target": "target", "to": "@home", "home_region": "ie"}}, Lost: []string{"target"}, Obligations: []Obligation{ob}}
+ if LostMoveTargetParked(c, "@home") {
+ t.Fatal("pending retained-name chore ignored", ob)
+ }
+ c.Obligations = nil
+ if !LostMoveTargetParked(c, "ie") {
+ t.Fatal("completed bound target refused")
+ }
+ }
+}
+
+func TestPlan13WarmAndPublishedHomeCoverage(t *testing.T) {
+ for _, home := range []string{"ie", ""} {
+ c := &Claim{Gen: 2, Holder: &Holder{Session: 2, Stamp: 2, Generation: "new", WriterBox: "box", Admissions: []AdmissionRecord{{Session: 2, Region: "ie", PredecessorRegion: "ie", Generation: "new", Predecessor: "old", Mode: "cold"}}}, Op: Op{Kind: KindServing, Exec: "box", Intent: map[string]string{"home_region": home}}}
+ if err := PublishGeneration("", "", 2, "new", "@home", counter())(c); err != nil {
+ t.Fatal(err)
+ }
+ if c.Holder.Admissions[0].Covered != (home != "") {
+ t.Fatal("publication misses bound predecessor", home)
+ }
+ in := Admission{ObservedSession: 2, ObservedGeneration: "new", Box: "box", Mode: "warm", PredecessorCertified: true, Region: "@home"}
+ if err := AdmitWriter(in)(c); (err == nil) != (home != "") {
+ t.Fatal("warm admission", home, err)
+ }
+ }
+}
diff --git a/internal/hostclaim/relocating.go b/internal/hostclaim/relocating.go
index fbce8d0e5..1cc29e179 100644
--- a/internal/hostclaim/relocating.go
+++ b/internal/hostclaim/relocating.go
@@ -18,7 +18,8 @@ const KindRelocating = "relocating"
// source and exact fence reference. A snapshot of an undecided record is not
// an implementation of this interface. While the claim is moving the source
// is its "source" and the region is the record's; once relocating they are
-// its "source_box" and "region". The returned region is the record's.
+// its "source_box" and "region". The returned region is the verified engine
+// identity of the record placement, never an empty placement code.
type PreFlipGuard interface {
CheckMovingBeforeFlip(context.Context, Observed, string, FenceRef) (string, error)
}
diff --git a/internal/hostfence/claim_fence.go b/internal/hostfence/claim_fence.go
index 3a45e34dc..cda241eb3 100644
--- a/internal/hostfence/claim_fence.go
+++ b/internal/hostfence/claim_fence.go
@@ -29,7 +29,7 @@ func (a ClaimFenceAuthority) ValidateFence(ctx context.Context, f termfence.Reco
if err := a.Directory.ValidateTerminationFenceAuthority(ctx, o, f.Box, f, a.Now()); err != nil {
return hostclaim.FenceBinding{}, err
}
- return hostclaim.FenceBinding{Region: o.Roster.Boxes[f.Box].Region, RosterRevision: o.Roster.Revision, RosterETag: o.ETag}, nil
+ return hostclaim.FenceBinding{FleetHomeRegion: o.Roster.HomeRegion, Region: o.Roster.Boxes[f.Box].Region, RosterRevision: o.Roster.Revision, RosterETag: o.ETag}, nil
}
func ClaimFenceRef(ref EvidenceRef) hostclaim.FenceRef {
diff --git a/internal/hostops/backfill.go b/internal/hostops/backfill.go
index 31bc6363c..b413e3ebb 100644
--- a/internal/hostops/backfill.go
+++ b/internal/hostops/backfill.go
@@ -82,6 +82,7 @@ type Backfill struct {
Directory hostactors.Directory
}
type ProductionAuthority struct {
+ HomeRegion string
Store release.Store
Control *ControlWriter
Directory hostactors.Directory
diff --git a/internal/hostops/lifecycle_record.go b/internal/hostops/lifecycle_record.go
index 93530fa23..d8be7fa65 100644
--- a/internal/hostops/lifecycle_record.go
+++ b/internal/hostops/lifecycle_record.go
@@ -7,6 +7,7 @@ import (
"time"
"github.com/carlosframework/platform/internal/hostclaim"
+ "github.com/carlosframework/platform/internal/park"
"github.com/carlosframework/platform/internal/provision"
"github.com/carlosframework/platform/internal/release"
)
@@ -84,11 +85,11 @@ func (s conditionalRecordStore) putLifecycle(ctx context.Context, key string, bo
return "", ErrIdentity
}
var from, to string
- if json.Unmarshal(before["region"], &from) != nil || json.Unmarshal(next["region"], &to) != nil {
+ if (len(before["region"]) != 0 && json.Unmarshal(before["region"], &from) != nil) || (len(next["region"]) != 0 && json.Unmarshal(next["region"], &to) != nil) || !provision.ValidRegion(from) || !provision.ValidRegion(to) {
return "", ErrIdentity
}
if from != to {
- flip := o.Claim.Op.Kind == hostclaim.KindMoving && o.Claim.Op.Phase == "sealed" && a.State == "cutting" && a.CutPhase == "archived" && a.FlippedAt == nil && a.CommittedAt == nil && a.Rollback == nil && b.State == "landing" && b.CutPhase == "flipped" && b.FlippedAt != nil && b.LandingDeadline != nil && b.LandingDeadline.After(*b.FlippedAt) && b.Rollback == nil && from == b.From && to == b.To && to == o.Claim.Op.Intent["to"]
+ flip := o.Claim.Op.Kind == hostclaim.KindMoving && o.Claim.Op.Phase == "sealed" && a.State == "cutting" && a.CutPhase == "archived" && a.FlippedAt == nil && a.CommittedAt == nil && a.Rollback == nil && b.State == "landing" && b.CutPhase == "flipped" && b.FlippedAt != nil && b.LandingDeadline != nil && b.LandingDeadline.After(*b.FlippedAt) && b.Rollback == nil && from == b.From && to == b.To && park.PlacementMatches(o.Claim.Op.Intent["home_region"], to, o.Claim.Op.Intent["to"])
restore := o.Claim.Op.Kind == hostclaim.KindLanding && o.Claim.Op.Phase != "committed" && a.State == "landing" && a.CommittedAt == nil && a.Rollback == nil && b.State == "rolling_back" && b.CommittedAt == nil && b.Rollback != nil && b.Rollback.ID != "" && b.Rollback.RequestedAt != nil && b.Rollback.TargetPhase == "releasing" && b.Rollback.SourcePhase == "waiting" && b.Rollback.ReleaseDeadline != nil && b.Rollback.ReleaseDeadline.Equal(b.Rollback.RequestedAt.Add(15*time.Minute)) && from == b.To && to == b.From
// The rollback region is the original record's Move.From (a.From ==
// b.From above), never claim input: the claim carries no source region.
diff --git a/internal/hostops/move_enrollment.go b/internal/hostops/move_enrollment.go
index f00908be9..84ff45882 100644
--- a/internal/hostops/move_enrollment.go
+++ b/internal/hostops/move_enrollment.go
@@ -3,6 +3,7 @@ package hostops
import (
"context"
"errors"
+ "github.com/carlosframework/platform/internal/park"
"sync/atomic"
"time"
@@ -59,10 +60,20 @@ func (a ProductionAuthority) RegisterMove(ctx context.Context, o hostclaim.Obser
// the roster. The admission is reread inside the transition, so the last
// admission read precedes the one claim CAS, which itself runs under h's
// lifecycle grant (the data boundary rereads admission again at the Put).
-func BeginClaimMove(ctx context.Context, s release.Store, d hostactors.Directory, h *StartHandle, o hostclaim.Observed, id, by, target, to string, now time.Time) (hostclaim.Observed, error) {
+func BeginClaimMove(ctx context.Context, s release.Store, d hostactors.Directory, h *StartHandle, o hostclaim.Observed, id, by, target, to string, now time.Time, homeNames ...string) (hostclaim.Observed, error) {
if h == nil || h.w == nil || s == nil || o.Absent || o.Claim == nil || o.Claim.Holder == nil || o.ETag == "" || id == "" || id != h.id || now.IsZero() {
return hostclaim.Observed{}, ErrIdentity
}
+ home := ""
+ if len(homeNames) > 1 {
+ return hostclaim.Observed{}, ErrIdentity
+ }
+ if len(homeNames) == 1 {
+ home = homeNames[0]
+ }
+ if home != "" && park.PlacementRegion(home) != home {
+ return hostclaim.Observed{}, ErrIdentity
+ }
holder := o.Claim.Holder
if holder.Family != "instance" || holder.Account != h.account || holder.App != h.app {
return hostclaim.Observed{}, ErrIdentity
@@ -72,9 +83,12 @@ func BeginClaimMove(ctx context.Context, s release.Store, d hostactors.Directory
if err != nil {
return hostclaim.Observed{}, err
}
+ if !roster.Roster.MatchesHome(home) {
+ return hostclaim.Observed{}, ErrIdentity
+ }
src, sourceOK := roster.Roster.Boxes[source]
dst, targetOK := roster.Roster.Boxes[target]
- if !sourceOK || !targetOK || source == target || src.State != "active" || dst.State != "active" || src.Region == "" || dst.Region != to || src.Region == to {
+ if !sourceOK || !targetOK || source == target || src.State != "active" || dst.State != "active" || src.Region == "" || dst.Region == "" {
return hostclaim.Observed{}, ErrIdentity
}
// Lookup also refuses a box with a termination fence.
@@ -95,15 +109,21 @@ func BeginClaimMove(ctx context.Context, s release.Store, d hostactors.Directory
return hostclaim.Observed{}, err
}
inc, err := hostclaim.Incarnation(rec.Account, rec.App, rec.Host, rec.CreatedAt)
- if err != nil || inc != holder.Incarnation || rec.Region != src.Region || rec.Move != nil {
+ if err != nil || inc != holder.Incarnation || !ClaimMoveRosterPlaces(roster.Roster, source, target, rec.Region, to, home) || rec.Move != nil {
return hostclaim.Observed{}, ErrIdentity
}
- admitted := hostclaim.MoveAdmission{AppID: h.appID, AdmissionID: h.id, SourceEnrollment: src.Enrollment, TargetEnrollment: dst.Enrollment}
+ admitted := hostclaim.MoveAdmission{HomeRegion: home, AppID: h.appID, AdmissionID: h.id, SourceEnrollment: src.Enrollment, TargetEnrollment: dst.Enrollment}
return hostclaim.Update(h.Lifecycle(ctx), s, o, func(c *hostclaim.Claim) error {
if err := liveMoveRegistration(ctx, s, h, o); err != nil {
return err
}
- return hostclaim.BeginAdmittedMove(id, by, source, target, to, now, admitted)(c)
+ if err := hostclaim.BeginAdmittedMove(id, by, source, target, park.PlacementRegion(to), now, admitted)(c); err != nil {
+ return err
+ }
+ if home != "" {
+ c.Op.Intent["home_region"] = home
+ }
+ return nil
})
}
@@ -134,7 +154,7 @@ func StartClaimMoveRecord(ctx context.Context, s release.Store, h *StartHandle,
return nil, ErrIdentity
}
c := moving.Claim
- if c.Op.Kind != hostclaim.KindMoving || c.Op.Phase != hostclaim.PhaseMoving || p.ID != c.Op.ID || p.Incarnation != c.Holder.Incarnation || p.To != c.Op.Intent["to"] ||
+ if c.Op.Kind != hostclaim.KindMoving || c.Op.Phase != hostclaim.PhaseMoving || p.ID != c.Op.ID || p.Incarnation != c.Holder.Incarnation || !park.PlacementMatches(c.Op.Intent["home_region"], p.To, c.Op.Intent["to"]) ||
c.Op.Intent["admission_id"] != h.id || c.Op.Intent["app_id"] != h.appID {
return nil, ErrIdentity
}
diff --git a/internal/hostops/move_fence_regions.go b/internal/hostops/move_fence_regions.go
new file mode 100644
index 000000000..9b7499f1d
--- /dev/null
+++ b/internal/hostops/move_fence_regions.go
@@ -0,0 +1,34 @@
+package hostops
+
+import (
+ "context"
+ "github.com/carlosframework/platform/internal/hostclaim"
+ "github.com/carlosframework/platform/internal/park"
+ "github.com/carlosframework/platform/internal/termfence"
+)
+
+// MoveFenceRegions preserves the verifier's roster/enrollment checks, then
+// projects only its region binding into the move's engine vocabulary.
+// Signed fence bytes and the cloud region are never edited.
+type MoveFenceRegions struct {
+ Authority hostclaim.FenceAuthority
+ HomeRegion string
+}
+
+func (a MoveFenceRegions) ValidateFence(ctx context.Context, f termfence.Record) (hostclaim.FenceBinding, error) {
+ if a.Authority == nil {
+ return hostclaim.FenceBinding{}, hostclaim.ErrFence
+ }
+ binding, err := a.Authority.ValidateFence(ctx, f)
+ if err != nil {
+ return hostclaim.FenceBinding{}, err
+ }
+ if binding.Region == "" || (a.HomeRegion != "" && (a.HomeRegion != binding.FleetHomeRegion || park.PlacementRegion(a.HomeRegion) != a.HomeRegion)) {
+ return hostclaim.FenceBinding{}, hostclaim.ErrFence
+ }
+ binding.Region = park.PlacementRegion(park.HomePlacementCode(a.HomeRegion, binding.Region))
+ if binding.Region == "" {
+ return hostclaim.FenceBinding{}, hostclaim.ErrFence
+ }
+ return binding, nil
+}
diff --git a/internal/hostops/move_fence_release.go b/internal/hostops/move_fence_release.go
index 2a7063128..3124c1c96 100644
--- a/internal/hostops/move_fence_release.go
+++ b/internal/hostops/move_fence_release.go
@@ -58,7 +58,7 @@ func (g *MoveRecoveryGrant) ReleaseFencedTarget(ctx context.Context, ref hostcla
}
return writeRecord()
}
- fence, err := hostclaim.VerifyMoveTargetFence(ctx, g.w.raw, o, ref, g.authority)
+ fence, err := hostclaim.VerifyMoveTargetFence(ctx, g.w.raw, o, ref, MoveFenceRegions{Authority: g.authority, HomeRegion: o.Claim.Op.Intent["home_region"]})
if err != nil {
return err
}
diff --git a/internal/hostops/move_loss_admission.go b/internal/hostops/move_loss_admission.go
index a2b798571..0cad51332 100644
--- a/internal/hostops/move_loss_admission.go
+++ b/internal/hostops/move_loss_admission.go
@@ -2,6 +2,7 @@ package hostops
import (
"github.com/carlosframework/platform/internal/hostclaim"
+ "github.com/carlosframework/platform/internal/park"
"strconv"
"strings"
)
@@ -41,7 +42,7 @@ func (m *moveLossOwner) ValidateLossRecovery(c hostclaim.Claim, loss hostclaim.L
c.Op.ID != in.Move || c.Op.Kind != hostclaim.KindRestoring || c.Op.Phase != hostclaim.PhaseProbing ||
c.Op.Intent["rollback"] != in.Rollback || c.Op.Exec != in.Source || c.Op.Intent["source"] != in.Source ||
c.Op.Intent["target"] != in.Target || c.Op.Intent["to"] != in.TargetRegion ||
- !hostclaim.LostTargetReleased(&c, loss.ID) || a.Box != in.Source || a.Region != in.SourceRegion ||
+ !hostclaim.LostTargetReleased(&c, loss.ID) || a.Box != in.Source || !park.SameRegion(c.Op.Intent["home_region"], a.Region, in.SourceRegion) ||
a.Mode != "cold" || a.Coverage != "" || a.KeepStamp || a.PredecessorCertified {
return hostclaim.ErrLossRecord
}
diff --git a/internal/hostops/move_loss_owner.go b/internal/hostops/move_loss_owner.go
index 8204632b3..37beecd57 100644
--- a/internal/hostops/move_loss_owner.go
+++ b/internal/hostops/move_loss_owner.go
@@ -10,6 +10,7 @@ import (
"encoding/json"
"errors"
"fmt"
+ "github.com/carlosframework/platform/internal/park"
"io"
"reflect"
"strconv"
@@ -313,13 +314,13 @@ func lossIntentAt(ctx context.Context, store release.Store, pub ed25519.PublicKe
// rollback, phase, Dirty and return lane.
func recordLossIntent(in *moveLossIntent, rec provision.Record, etag string, seq uint64, op hostclaim.Op, claimETag string, prior *supersededLoss) error {
m := rec.Move
- if m == nil || m.ID != in.Move || m.Incarnation != in.Incarnation || m.To != in.TargetRegion || m.From == "" || m.From == m.To {
+ if m == nil || m.ID != in.Move || m.Incarnation != in.Incarnation || !park.PlacementMatches(op.Intent["home_region"], m.To, in.TargetRegion) || park.PlacementRegion(m.From) == "" || m.From == m.To {
return hostresolve.ErrResolve
}
if m.State == provision.MoveCommitted || m.CommittedAt != nil {
return moveLossRefusal(provision.RefusedCommitted)
}
- in.SourceRegion, in.RecordETag, in.RecordSeq = m.From, etag, seq
+ in.SourceRegion, in.RecordETag, in.RecordSeq = park.PlacementRegion(m.From), etag, seq
switch m.State {
case provision.MoveLanding:
if op.Kind != hostclaim.KindLanding || m.Rollback != nil {
diff --git a/internal/hostops/move_recovery_bridge.go b/internal/hostops/move_recovery_bridge.go
index 52e1fe31b..42e0b6ded 100644
--- a/internal/hostops/move_recovery_bridge.go
+++ b/internal/hostops/move_recovery_bridge.go
@@ -5,6 +5,7 @@ import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
+ "github.com/carlosframework/platform/internal/park"
"strconv"
"time"
@@ -72,7 +73,9 @@ var _ hostclaim.PreFlipGuard = movingRecordDecision{}
// CheckMovingBeforeFlip reads the move's own intent while the claim is
// moving ("source", "to") and the conversion's once relocating
// ("source_box", "region", "move_to_region"). The region it returns is the
-// record's; ConvertMovingToRelocating compares it with the fence binding.
+// verified roster identity for the record placement; the home code stays
+// empty in the decision. ConvertMovingToRelocating compares the returned
+// identity with the unmodified fence binding.
// ConvertMovingToRelocating calls it again inside its transition, so the
// admission reread here is the last read before the one claim CAS.
func (g movingRecordDecision) CheckMovingBeforeFlip(ctx context.Context, o hostclaim.Observed, opID string, ref hostclaim.FenceRef) (string, error) {
@@ -105,7 +108,11 @@ func (g movingRecordDecision) CheckMovingBeforeFlip(ctx context.Context, o hostc
if d.MovingIdentity != moving || c.Gen < d.ClaimGen {
return "", hostclaim.ErrConflict
}
- source, region, to = c.Op.Intent["source"], rec.Region, c.Op.Intent["to"]
+ region = park.PlacementRegion(rec.Region)
+ if rec.Region == "" && c.Op.Intent["home_region"] != "" {
+ region = c.Op.Intent["home_region"]
+ }
+ source, to = c.Op.Intent["source"], c.Op.Intent["to"]
case hostclaim.KindRelocating:
convertedGen, err := strconv.ParseUint(c.Op.Journal["conversion_claim_gen"], 10, 64)
raw, e := json.Marshal(d)
@@ -125,7 +132,7 @@ func (g movingRecordDecision) CheckMovingBeforeFlip(ctx context.Context, o hostc
return "", err
}
if source == "" || region == "" || d.DecidedAt.IsZero() || d.Fence != string(raw) || d.Enrollment != ref.Enrollment || d.Enrollment != c.Op.Intent["source_enrollment"] || d.SourceBox != source ||
- d.Region != region || rec.Region != d.Region || rec.Move.From != d.Region || to == "" || rec.Move.To != to || rec.Move.FlippedAt != nil || rec.Move.CommittedAt != nil || rec.Move.Rollback != nil {
+ !park.PlacementMatches(c.Op.Intent["home_region"], d.Region, region) || rec.Region != d.Region || rec.Move.From != d.Region || !park.PlacementMatches(c.Op.Intent["home_region"], rec.Move.To, to) || rec.Move.FlippedAt != nil || rec.Move.CommittedAt != nil || rec.Move.Rollback != nil {
return "", ErrIdentity
}
switch d.PreviousState {
@@ -136,10 +143,10 @@ func (g movingRecordDecision) CheckMovingBeforeFlip(ctx context.Context, o hostc
if g.placement == nil {
return "", ErrContract
}
- if err := g.placement.CheckOneBoxRegion(ctx, source, d.Region); err != nil {
+ if err := g.placement.CheckOneBoxRegion(ctx, source, region); err != nil {
return "", err
}
- return d.Region, nil
+ return region, nil
}
// ConversionDecisionDigest binds the converted claim to the exact record
@@ -242,10 +249,10 @@ func RecoverTerminatedMovingHost(ctx context.Context, s release.Store, mh *Start
return err
}
region := rec.Region
- if region == "" || region != fence.Region() || rec.Move == nil || rec.Move.From != region {
+ if !park.PlacementMatches(c.Op.Intent["home_region"], region, fence.Region()) || rec.Move == nil || rec.Move.From != region {
return hostclaim.ErrFence
}
- if err := placement.CheckOneBoxRegion(ctx, source, region); err != nil {
+ if err := placement.CheckOneBoxRegion(ctx, source, fence.Region()); err != nil {
return err
}
raw, err := json.Marshal(ref)
diff --git a/internal/hostops/move_recovery_bridge_test.go b/internal/hostops/move_recovery_bridge_test.go
index 79a092493..7f5f9a055 100644
--- a/internal/hostops/move_recovery_bridge_test.go
+++ b/internal/hostops/move_recovery_bridge_test.go
@@ -20,6 +20,7 @@ import (
)
type c7ReplayAuthority struct {
+ home string
record termfence.Record
region string
}
@@ -35,7 +36,7 @@ func (a c7ReplayAuthority) ValidateFence(_ context.Context, f termfence.Record)
if f != a.record {
return hostclaim.FenceBinding{}, hostclaim.ErrFence
}
- return hostclaim.FenceBinding{Region: a.region, RosterRevision: 1, RosterETag: "roster"}, nil
+ return hostclaim.FenceBinding{FleetHomeRegion: a.home, Region: a.region, RosterRevision: 1, RosterETag: "roster"}, nil
}
type c7DecisionHook struct {
diff --git a/internal/hostops/move_recovery_grant.go b/internal/hostops/move_recovery_grant.go
index 284c96077..74e1fa99c 100644
--- a/internal/hostops/move_recovery_grant.go
+++ b/internal/hostops/move_recovery_grant.go
@@ -481,10 +481,10 @@ func (g *MoveRecoveryGrant) Convert(ctx context.Context, ref hostclaim.FenceRef)
}
return hostclaim.VerifyOperationFence(ctx, g.w.raw, authority, c.Op, c.Op.Intent["source_box"])
}
- if c.Op.Kind != hostclaim.KindMoving || c.Op.Intent["source_enrollment"] != ref.Enrollment || fence.Box() != c.Op.Intent["source"] || fence.Region() != rec.Region {
+ if c.Op.Kind != hostclaim.KindMoving || c.Op.Intent["source_enrollment"] != ref.Enrollment || fence.Box() != c.Op.Intent["source"] || !park.PlacementMatches(c.Op.Intent["home_region"], rec.Region, fence.Region()) {
return hostclaim.ErrFence
}
- if err := placement.CheckOneBoxRegion(ctx, fence.Box(), rec.Region); err != nil {
+ if err := placement.CheckOneBoxRegion(ctx, fence.Box(), fence.Region()); err != nil {
return err
}
if rec.Move == nil || rec.Move.ID != g.o.OpID || rec.Move.Incarnation != h.Incarnation {
diff --git a/internal/hostops/move_region.go b/internal/hostops/move_region.go
new file mode 100644
index 000000000..206203b37
--- /dev/null
+++ b/internal/hostops/move_region.go
@@ -0,0 +1,27 @@
+package hostops
+
+import (
+ "github.com/carlosframework/platform/internal/hostactors"
+ "github.com/carlosframework/platform/internal/park"
+)
+
+// ClaimMoveRosterPlaces is shared by the console preflight and the start CAS.
+func ClaimMoveRosterPlaces(r hostactors.Roster, source, target, recRegion, to string, homeNames ...string) bool {
+ if len(homeNames) > 1 {
+ return false
+ }
+ home := ""
+ if len(homeNames) == 1 {
+ home = homeNames[0]
+ }
+ if home != "" && park.PlacementRegion(home) != home {
+ return false
+ }
+ src, sourceOK := r.Boxes[source]
+ dst, targetOK := r.Boxes[target]
+ if !sourceOK || !targetOK || source == target || src.State != "active" || dst.State != "active" || src.Enrollment == "" || dst.Enrollment == "" || src.Region == "" || dst.Region == "" {
+ return false
+ }
+ from, into := park.PlacementRegion(park.HomePlacementCode(home, src.Region)), park.PlacementRegion(park.HomePlacementCode(home, dst.Region))
+ return from != "" && into != "" && from != into && from == park.PlacementRegion(recRegion) && into == park.PlacementRegion(to)
+}
diff --git a/internal/hostops/plan13_conversion_test.go b/internal/hostops/plan13_conversion_test.go
new file mode 100644
index 000000000..5e3574339
--- /dev/null
+++ b/internal/hostops/plan13_conversion_test.go
@@ -0,0 +1,78 @@
+package hostops
+
+import (
+ "context"
+ "github.com/carlosframework/platform/internal/hostclaim"
+ "github.com/carlosframework/platform/internal/park"
+ "github.com/carlosframework/platform/internal/provision"
+ "testing"
+)
+
+func TestPlan13PreFlipHomeConversion(t *testing.T) {
+ for _, direction := range []string{"source-home", "target-home", "named"} {
+ for _, route := range []string{"grant", "held"} {
+ t.Run(direction+"/"+route, func(t *testing.T) {
+ w := newC7World(t, func(c *hostclaim.Claim, r *provision.Record, _ *AppAdmission) {
+ if direction == "source-home" {
+ r.Region, r.Move.From = "", ""
+ c.Op.Intent["home_region"] = "ie"
+ }
+ if direction == "target-home" {
+ r.Move.To = ""
+ c.Op.Intent["to"] = park.HomeRegion
+ c.Op.Intent["home_region"] = "uk"
+ }
+ })
+ run := func() error {
+ if route == "held" {
+ return RecoverTerminatedMovingHost(t.Context(), w.productionData(t, w.raw), w.mh, w.authority, w.host, w.ref, w.now)
+ }
+ return w.control.RunRecovery(t.Context(), func(ctx context.Context) error {
+ return runMoveRecovery(ctx, w.claim(t), func(ctx context.Context, g *MoveRecoveryGrant) error { return g.Convert(ctx, w.ref) }, w.authority)
+ })
+ }
+ for i := 0; i < 3; i++ {
+ if err := run(); err != nil {
+ t.Fatalf("pass %d: %v", i, err)
+ }
+ }
+ c := w.claim(t).Claim
+ if c.Op.Kind != hostclaim.KindRelocating || c.Op.Intent["region"] != "ie" {
+ t.Fatalf("claim: %+v", c.Op)
+ }
+ r, err := provision.GetRecord(t.Context(), w.raw, "bab", "app", w.host)
+ if err != nil {
+ t.Fatal(err)
+ }
+ from, to := "ie", "uk"
+ if direction == "source-home" {
+ from = ""
+ }
+ if direction == "target-home" {
+ to = ""
+ }
+ if r.Region != from || r.Move.From != from || r.Move.To != to || r.Move.Relocation.Region != from {
+ t.Fatalf("rewritten record: %+v", r)
+ }
+ tag := w.claim(t).ETag
+ rtag := w.recordTag(t)
+ if err := run(); err != nil {
+ t.Fatal(err)
+ }
+ if tag != w.claim(t).ETag || rtag != w.recordTag(t) {
+ t.Fatal("replay wrote")
+ }
+ })
+ }
+ }
+}
+func TestPlan13PreFlipUnboundHomeRefuses(t *testing.T) {
+ w := newC7World(t, func(c *hostclaim.Claim, r *provision.Record, _ *AppAdmission) { r.Region, r.Move.From = "", "" })
+ tag := w.recordTag(t)
+ if err := RecoverTerminatedMovingHost(t.Context(), w.productionData(t, w.raw), w.mh, w.authority, w.host, w.ref, w.now); err == nil {
+ t.Fatal("unbound home accepted")
+ }
+ if tag != w.recordTag(t) {
+ t.Fatal("refusal wrote")
+ }
+}
diff --git a/internal/hostops/plan13_enrollment_test.go b/internal/hostops/plan13_enrollment_test.go
new file mode 100644
index 000000000..ad558ccdc
--- /dev/null
+++ b/internal/hostops/plan13_enrollment_test.go
@@ -0,0 +1,66 @@
+package hostops
+
+import (
+ "encoding/json"
+ "github.com/carlosframework/platform/internal/hostactors"
+ "github.com/carlosframework/platform/internal/hostclaim"
+ "github.com/carlosframework/platform/internal/park"
+ "github.com/carlosframework/platform/internal/provision"
+ "testing"
+)
+
+func TestPlan13BeginClaimMoveHomeAndNamed(t *testing.T) {
+ for _, tc := range []struct{ name, home, from, to, target, want string }{
+ {"out-home", "ie", "", "uk", "uk-box", "uk"},
+ {"into-home", "uk", "ie", "", "uk-box", park.HomeRegion},
+ {"named", "", "ie", "uk", "uk-box", "uk"},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ f := newMoveStartFixture(t, func(r *hostactors.Roster) { r.HomeRegion = tc.home })
+ rec, etag, err := provision.GetRecordETag(t.Context(), f.raw, "bab", "app", f.host)
+ if err != nil {
+ t.Fatal(err)
+ }
+ rec.Region = tc.from
+ raw, err := json.Marshal(rec)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err = f.raw.Store.Put(t.Context(), f.raw.recordKey, raw, etag); err != nil {
+ t.Fatal(err)
+ }
+ o := f.read(t)
+ // This is an existing claim, including its named touched region.
+ h, err := f.authority.RegisterMove(t.Context(), o, "canonical", moveStartID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ got, err := BeginClaimMove(t.Context(), f.raw, f.authority.Directory, h, o, moveStartID, "operator", tc.target, tc.to, f.now, tc.home)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got.Claim.Op.Intent["to"] != tc.want || got.Claim.Touched.Regions[0] != "ie" {
+ t.Fatalf("%+v", got.Claim)
+ }
+ if got.Claim.Op.Intent["home_region"] != tc.home {
+ t.Fatal("lost home binding")
+ }
+ _ = hostclaim.KindMoving
+ })
+ }
+}
+
+func TestPlan13MoveStartRefusesFalseHome(t *testing.T) {
+ f := newMoveStartFixture(t, func(r *hostactors.Roster) { r.HomeRegion = "ie" })
+ o := f.read(t)
+ h, err := f.authority.RegisterMove(t.Context(), o, "canonical", moveStartID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := BeginClaimMove(t.Context(), f.raw, f.authority.Directory, h, o, moveStartID, "operator", "uk-box", "", f.now, "uk"); err == nil {
+ t.Fatal("operation home overrode signed fleet home")
+ }
+ if f.read(t).ETag != o.ETag {
+ t.Fatal("refusal mutated claim")
+ }
+}
diff --git a/internal/hostops/plan13_fence_test.go b/internal/hostops/plan13_fence_test.go
new file mode 100644
index 000000000..a29b085d8
--- /dev/null
+++ b/internal/hostops/plan13_fence_test.go
@@ -0,0 +1,115 @@
+package hostops
+
+import (
+ "context"
+ "github.com/carlosframework/platform/internal/hostclaim"
+ "github.com/carlosframework/platform/internal/park"
+ "github.com/carlosframework/platform/internal/provision"
+ "strings"
+ "testing"
+)
+
+func TestPlan13FenceRecoveryHomeAndNamed(t *testing.T) {
+ for _, which := range []string{"source-home", "target-home", "named"} {
+ t.Run(which, func(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 = "uk"
+ r.Move.From, r.Move.To, r.Move.State = "uk", "ie", provision.MoveRollingBack
+ if which == "source-home" {
+ r.Region = ""
+ r.Move.From = ""
+ c.Op.Intent["home_region"] = "uk"
+ }
+ if which == "target-home" {
+ r.Move.To = ""
+ c.Op.Intent["to"] = park.HomeRegion
+ c.Op.Intent["home_region"] = "ie"
+ }
+ at := r.CreatedAt
+ key := "control/box-fences/" + strings.Repeat("a", 32) + ".json"
+ r.Move.Rollback = &provision.MoveRollback{ID: "rb_abcdefghijklmnopqrstuvwxyz", Reason: provision.RollbackTargetFenced, Evidence: key, RequestedAt: &at, TargetPhase: provision.RbTargetLost, LossRef: key, LostAt: &at, SourcePhase: provision.RbSourceWaiting}
+ })
+ if which == "source-home" {
+ w.authority.home = "uk"
+ }
+ if which == "target-home" {
+ w.authority.home = "ie"
+ }
+ run := func() error {
+ return w.control.RunRecovery(t.Context(), func(ctx context.Context) error {
+ return runMoveRecovery(ctx, w.claim(t), func(ctx context.Context, g *MoveRecoveryGrant) error { return g.ReleaseFencedTarget(ctx, w.ref) }, w.authority)
+ })
+ }
+ if err := run(); err != nil {
+ t.Fatal(err)
+ }
+ c := w.claim(t).Claim
+ if c.Op.Phase != hostclaim.PhaseReleased {
+ t.Fatal(c.Op)
+ }
+ want := "ie"
+ if which == "target-home" {
+ want = park.HomeRegion
+ }
+ for _, ob := range c.Obligations {
+ if ob.Region != want {
+ t.Fatalf("%+v", ob)
+ }
+ }
+ if err := run(); err != nil {
+ t.Fatal("record completion", err)
+ }
+ rec, err := provision.GetRecord(t.Context(), w.raw, "bab", "app", w.host)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if which == "source-home" && (rec.Region != "" || rec.Move.From != "") {
+ t.Fatal("source placement rewritten")
+ }
+ if which == "target-home" && rec.Move.To != "" {
+ t.Fatal("target placement rewritten")
+ }
+ if which == "target-home" {
+ o := w.claim(t)
+ for _, ob := range o.Claim.Obligations {
+ var e error
+ o, e = hostclaim.Update(t.Context(), w.raw, o, hostclaim.CompleteObligation(ob.Op, ob.ID))
+ if e != nil {
+ t.Fatal(e)
+ }
+ }
+ o, err = hostclaim.Update(t.Context(), w.raw, o, func(c *hostclaim.Claim) error {
+ c.Obligations = append(c.Obligations, hostclaim.Obligation{ID: "retained", Op: c.Op.ID, Kind: "park-generation", Region: "ie", Owner: "region:ie", Generation: "old", Retirement: "retirement"})
+ return nil
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ stamp := func() error {
+ return w.control.RunRecovery(t.Context(), func(ctx context.Context) error {
+ return runMoveRecovery(ctx, w.claim(t), func(ctx context.Context, g *MoveRecoveryGrant) error { return g.StampLostMoveTargetParked(ctx) }, w.authority)
+ })
+ }
+ if err := stamp(); err == nil {
+ t.Fatal("parked stamp ignored retained-name chore")
+ }
+ rec, err = provision.GetRecord(t.Context(), w.raw, "bab", "app", w.host)
+ if err != nil || rec.Move.Rollback.TargetParkedAt != nil {
+ t.Fatal("premature parked stamp", err)
+ }
+ if _, err = hostclaim.Update(t.Context(), w.raw, o, hostclaim.CompleteObligation(o.Claim.Op.ID, "retained")); err != nil {
+ t.Fatal(err)
+ }
+ if err := stamp(); err != nil {
+ t.Fatal("completed target refused", err)
+ }
+ }
+
+ })
+ }
+}
diff --git a/internal/hostops/plan13_fix1_test.go b/internal/hostops/plan13_fix1_test.go
new file mode 100644
index 000000000..99eefd2b9
--- /dev/null
+++ b/internal/hostops/plan13_fix1_test.go
@@ -0,0 +1,29 @@
+package hostops
+
+import (
+ "context"
+ "github.com/carlosframework/platform/internal/hostclaim"
+ "github.com/carlosframework/platform/internal/termfence"
+ "testing"
+)
+
+type plan13BoundFence struct{ home, region string }
+
+func (a plan13BoundFence) ValidateFence(context.Context, termfence.Record) (hostclaim.FenceBinding, error) {
+ return hostclaim.FenceBinding{FleetHomeRegion: a.home, Region: a.region, RosterRevision: 1, RosterETag: "signed"}, nil
+}
+func TestPlan13FenceRequiresSignedHome(t *testing.T) {
+ for _, home := range []string{"ie", "ca", ""} {
+ a := MoveFenceRegions{Authority: plan13BoundFence{home: "ie", region: "ca"}, HomeRegion: home}
+ got, err := a.ValidateFence(t.Context(), termfence.Record{})
+ if home == "ca" {
+ if err == nil {
+ t.Fatal("named fence projected to home", got)
+ }
+ continue
+ }
+ if err != nil || got.Region != "ca" {
+ t.Fatal(got, err)
+ }
+ }
+}
diff --git a/internal/hostops/plan13_loss_test.go b/internal/hostops/plan13_loss_test.go
new file mode 100644
index 000000000..de3da639b
--- /dev/null
+++ b/internal/hostops/plan13_loss_test.go
@@ -0,0 +1,77 @@
+package hostops
+
+import (
+ "encoding/json"
+ "github.com/carlosframework/platform/internal/hostclaim"
+ "github.com/carlosframework/platform/internal/park"
+ "github.com/carlosframework/platform/internal/provision"
+ "testing"
+)
+
+func TestPlan13LossSourceRegionIsEngineName(t *testing.T) {
+ for _, tc := range []struct{ from, to, want string }{
+ {"", "uk", park.HomeRegion}, {"ie", "uk", "ie"}, {"uk", "", "uk"},
+ } {
+ t.Run(tc.from+"-"+tc.to, func(t *testing.T) {
+ in := moveLossIntent{Move: lossOp, Incarnation: "inc", Target: "target", TargetRegion: park.PlacementRegion(tc.to)}
+ rec := provision.Record{Region: tc.to, Move: &provision.MoveState{ID: lossOp, Incarnation: "inc", From: tc.from, To: tc.to, State: provision.MoveLanding}}
+ op := hostclaim.Op{ID: lossOp, Kind: hostclaim.KindLanding}
+ if err := recordLossIntent(&in, rec, "etag", 1, op, "claim-etag", nil); err != nil {
+ t.Fatal(err)
+ }
+ if in.SourceRegion != tc.want || rec.Move.From != tc.from || rec.Region != tc.to {
+ t.Fatalf("%+v %+v", in, rec)
+ }
+ raw, err := json.Marshal(in)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var persisted map[string]any
+ if err = json.Unmarshal(raw, &persisted); err != nil {
+ t.Fatal(err)
+ }
+ if persisted["source_region"] != tc.want {
+ t.Fatal(persisted)
+ }
+ })
+ }
+}
+func TestPlan13HomeLossDeclarationKeepsPlacement(t *testing.T) {
+ w := newLossWorld(t, "landing", func(c *hostclaim.Claim, r *provision.Record, _ *AppAdmission) {
+ r.Move.From = ""
+ c.Op.Intent["home_region"] = "ie"
+ })
+ inspect := w.inspect(t, "uk-label")
+ after, err := w.declare(w.submission(inspect))
+ if err != nil {
+ t.Fatal(err)
+ }
+ rec, _ := w.record(t)
+ if rec.Region != "" || rec.Move.From != "" {
+ t.Fatalf("record leaked engine token: %+v", rec)
+ }
+ loss, err := hostclaim.ReadLossRecord(t.Context(), w.raw, w.pub, inspect.Details.ID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ in, _, err := lossIntentOf(loss.Record)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if in.SourceRegion != park.HomeRegion || after.Claim.Op.Kind != hostclaim.KindRestoring {
+ t.Fatalf("%+v", in)
+ }
+}
+
+func TestPlan13LossRecoveryNamedHomeAdmission(t *testing.T) {
+ for _, home := range []string{"ie", "", "uk"} {
+ in := moveLossIntent{Kind: moveLossIntentKind, Move: "move", Incarnation: "inc", Rollback: "rb_abcdefghijklmnopqrstuvwxyz", Target: "target", Source: "source", TargetRegion: "uk", SourceRegion: park.HomeRegion, RecordKey: "record", RecordETag: "etag", RecordState: moveLossRecordLanding}
+ raw, _ := in.encode()
+ loss := hostclaim.LossRecord{ID: "loss_1", OpID: "move", Box: "target", HolderIncarnation: "inc", ConfirmedConsequences: hostclaim.LossConsequences{RecoveryIntent: raw}}
+ c := hostclaim.Claim{Holder: &hostclaim.Holder{Incarnation: "inc", Key: "record", Stamp: 7}, Op: hostclaim.Op{Kind: hostclaim.KindRestoring, ID: "move", Phase: hostclaim.PhaseProbing, Exec: "source", Intent: map[string]string{"source": "source", "target": "target", "to": "uk", "rollback": in.Rollback, "home_region": home}, Journal: map[string]string{hostclaim.JournalSourceStamp: "7", "lost_target_released": "loss_1"}}}
+ err := MoveLossRecoveryOwner().ValidateLossRecovery(c, loss, hostclaim.Admission{Box: "source", Region: "ie", Mode: "cold"})
+ if (err == nil) != (home == "ie") {
+ t.Fatal(home, err)
+ }
+ }
+}
diff --git a/internal/hostops/plan13_remote_test.go b/internal/hostops/plan13_remote_test.go
new file mode 100644
index 000000000..c80bd3996
--- /dev/null
+++ b/internal/hostops/plan13_remote_test.go
@@ -0,0 +1,37 @@
+package hostops
+
+import (
+ "context"
+ "errors"
+ "github.com/carlosframework/platform/internal/hostclaim"
+ "github.com/carlosframework/platform/internal/park"
+ "testing"
+)
+
+func TestPlan13RemoteAdapterUsesBoundHome(t *testing.T) {
+ f := newMoveStartFixture(t, nil)
+ o := f.read(t)
+ _, err := hostclaim.Update(t.Context(), f.raw, o, func(c *hostclaim.Claim) error {
+ c.Obligations = []hostclaim.Obligation{{ID: "chore", Op: "old", Kind: "park-replica", Region: "@home", Owner: "region:@home", Retirement: "ret"}}
+ return nil
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ sentinel := errors.New("scope reached")
+ for _, home := range []string{"ie", ""} {
+ e := park.Engine{Claims: f.raw, Region: "ie", HomeRegion: home, Leases: plan13NoLeases{}, Authorize: func(context.Context, hostclaim.Observed, hostclaim.Obligation) error { return nil }, View: func(context.Context, hostclaim.Observed) (park.View, error) { return park.View{}, nil }}
+ err := RunOwnedRemote(t.Context(), &e, park.Work{Host: f.host, ID: "chore", Op: "old"}, func(context.Context, hostclaim.Observed, hostclaim.Obligation) (hostclaim.ParkScope, error) {
+ return hostclaim.ParkScope{}, sentinel
+ })
+ if errors.Is(err, sentinel) != (home != "") {
+ t.Fatal(home, err)
+ }
+ }
+}
+
+type plan13NoLeases struct{}
+
+func (plan13NoLeases) Acquire(context.Context, string, string) (park.HeldLease, error) {
+ return nil, errors.New("unexpected lease")
+}
diff --git a/internal/hostops/production_create.go b/internal/hostops/production_create.go
index 39631761c..36b7f6776 100644
--- a/internal/hostops/production_create.go
+++ b/internal/hostops/production_create.go
@@ -16,6 +16,7 @@ import (
"github.com/carlosframework/platform/internal/hostactors"
"github.com/carlosframework/platform/internal/hostclaim"
"github.com/carlosframework/platform/internal/hostfloor"
+ "github.com/carlosframework/platform/internal/park"
"github.com/carlosframework/platform/internal/provision"
)
@@ -121,13 +122,16 @@ func (a ProductionAuthority) selectExecutor(ctx context.Context, r Record) (stri
if err != nil {
return "", "", "", err
}
+ if !roster.Roster.MatchesHome(a.HomeRegion) {
+ return "", "", "", ErrIdentity
+ }
candidates := map[string]*executorCandidate{}
servingScope := 0
for ref, b := range roster.Roster.Boxes {
if b.Namespace == a.Namespace && b.Pool == pool && b.State == "active" {
servingScope++
}
- if b.Namespace != a.Namespace || b.Region != r.Holder.Region || b.Pool != pool || b.State != "active" {
+ if b.Namespace != a.Namespace || !park.SameRegion(a.HomeRegion, b.Region, r.Holder.Region) || b.Pool != pool || b.State != "active" {
continue
}
fenced, err := a.Directory.TerminationFence(ctx, roster, ref, a.Now())
@@ -201,7 +205,11 @@ func (a ProductionAuthority) StartCreate(ctx context.Context, before hostclaim.O
if err != nil {
return hostclaim.Observed{}, err
}
- scope := hostactors.Scope{Namespace: a.Namespace, Regions: []string{r.Holder.Region}, Pools: []string{pool}, Participants: []string{box}, ConsoleID: a.ConsoleID}
+ region := r.Holder.Region
+ if region == park.HomeRegion {
+ region = a.HomeRegion
+ }
+ scope := hostactors.Scope{Namespace: a.Namespace, Regions: []string{region}, Pools: []string{pool}, Participants: []string{box}, ConsoleID: a.ConsoleID}
// Final admission check is after census/reservation, immediately before
// the one CAS against the observation saved before registration.
if err = a.Create(ctx, before, r, id); err != nil {
diff --git a/internal/hostops/remote.go b/internal/hostops/remote.go
index 09c99302d..67bec086f 100644
--- a/internal/hostops/remote.go
+++ b/internal/hostops/remote.go
@@ -26,7 +26,7 @@ func RunOwnedRemote(ctx context.Context, e *park.Engine, w park.Work, scope Park
if ob.ID != w.ID || ob.Op != w.Op {
continue
}
- if ob.Region != e.Region || ob.Owner != "region:"+e.Region || (ob.Kind != "park-replica" && ob.Kind != "park-generation") {
+ if !e.OwnsRegion(ob.Region) || ob.Owner != "region:"+ob.Region || (ob.Kind != "park-replica" && ob.Kind != "park-generation") {
return ErrIdentity
}
if ob.Park == nil {
diff --git a/internal/park/home_view.go b/internal/park/home_view.go
new file mode 100644
index 000000000..599e662d7
--- /dev/null
+++ b/internal/park/home_view.go
@@ -0,0 +1,33 @@
+package park
+
+import "github.com/carlosframework/platform/internal/hostclaim"
+
+// HolderRegionView is for read-only generation selection. Never persist it
+// or use it to validate signed stop/loss evidence; those keep exact tuples.
+func HolderRegionView(h hostclaim.Holder, home, region string) hostclaim.Holder {
+ if home == "" || !SameRegion(home, region, HomeRegion) {
+ return h
+ }
+ h.Admissions = append([]hostclaim.AdmissionRecord(nil), h.Admissions...)
+ for i := range h.Admissions {
+ a := &h.Admissions[i]
+ if SameRegion(home, a.Region, region) {
+ a.Region = region
+ }
+ if SameRegion(home, a.PredecessorRegion, region) {
+ a.PredecessorRegion = region
+ }
+ }
+ return h
+}
+
+func HomeView(o hostclaim.Observed, home string) hostclaim.Observed {
+ if home == "" || o.Claim == nil || o.Claim.Holder == nil {
+ return o
+ }
+ c := *o.Claim
+ h := HolderRegionView(*c.Holder, home, HomeRegion)
+ c.Holder = &h
+ o.Claim = &c
+ return o
+}
diff --git a/internal/park/plan13_holder_test.go b/internal/park/plan13_holder_test.go
new file mode 100644
index 000000000..0530849e8
--- /dev/null
+++ b/internal/park/plan13_holder_test.go
@@ -0,0 +1,25 @@
+package park
+
+import (
+ "github.com/carlosframework/platform/internal/hostclaim"
+ "testing"
+)
+
+func TestPlan13HolderProjectionPreservesEvidence(t *testing.T) {
+ h := hostclaim.Holder{Admissions: []hostclaim.AdmissionRecord{{Region: "ie", PredecessorRegion: HomeRegion}, {Region: "uk", PredecessorRegion: ""}}}
+ got := HolderRegionView(h, "ie", "ie")
+ if got.Admissions[0].PredecessorRegion != "ie" || got.Admissions[1].Region != "uk" || got.Admissions[1].PredecessorRegion != "" {
+ t.Fatal(got)
+ }
+ if h.Admissions[0].PredecessorRegion != HomeRegion {
+ t.Fatal("rewrote input")
+ }
+ got = HolderRegionView(h, "ie", HomeRegion)
+ if got.Admissions[0].Region != HomeRegion {
+ t.Fatal(got)
+ }
+ got = HolderRegionView(h, "", "uk")
+ if got.Admissions[0].Region != "ie" || got.Admissions[0].PredecessorRegion != HomeRegion {
+ t.Fatal("unbound alias")
+ }
+}
diff --git a/internal/park/plan13_observe_test.go b/internal/park/plan13_observe_test.go
new file mode 100644
index 000000000..ede6df026
--- /dev/null
+++ b/internal/park/plan13_observe_test.go
@@ -0,0 +1,134 @@
+package park
+
+import (
+ "context"
+ "github.com/carlosframework/platform/internal/hostclaim"
+ "testing"
+)
+
+func TestPlan13ObserveRemoteBinding(t *testing.T) {
+ f := newFixture(t)
+ o, err := hostclaim.Read(t.Context(), f.e.Claims, f.w.Host)
+ if err != nil {
+ t.Fatal(err)
+ }
+ _, err = hostclaim.Update(t.Context(), f.e.Claims, o, func(c *hostclaim.Claim) error { c.Op.Intent = map[string]string{"home_region": "ie"}; return nil })
+ if err != nil {
+ t.Fatal(err)
+ }
+ f.e.Region = "uk"
+ f.e.View = func(_ context.Context, o hostclaim.Observed) (View, error) {
+ return View{Region: "uk", HomeRegion: "ie", ClaimETag: o.ETag}, nil
+ }
+ _, v, err := f.e.observe(t.Context(), f.w, f.held, f.ob)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if v.HomeRegion != "ie" || f.e.OwnsRegion(HomeRegion) {
+ t.Fatal("history binding lost or ownership widened")
+ }
+ c := &hostclaim.Claim{Holder: &hostclaim.Holder{Location: f.ob.Park.Location, Generation: "new", Admissions: []hostclaim.AdmissionRecord{{Generation: "new", Predecessor: "old", Region: "ie", PredecessorRegion: HomeRegion, Mode: "cold"}}}, Op: hostclaim.Op{Kind: hostclaim.KindServing}}
+ v.GenerationOf = func(Ref) (string, bool) { return "old", true }
+ v.Region = HomeRegion
+ v.AuthoritativeRegion = HomeRegion
+ v.MarkerKnown = true
+ v.MarkerGeneration = "old"
+ live, err := LiveGenerations(c, v, c.Holder.Location)
+ if err != nil || !live["old"] || !live["new"] {
+ t.Fatal(live, err)
+ }
+}
+func TestPlan13CompletedHomeParkCoversNamedPredecessor(t *testing.T) {
+ f := newFixture(t)
+ f.e.HomeRegion = "ie"
+ f.e.Region = HomeRegion
+ o, err := hostclaim.Read(t.Context(), f.e.Claims, f.w.Host)
+ if err != nil {
+ t.Fatal(err)
+ }
+ _, err = hostclaim.Update(t.Context(), f.e.Claims, o, func(c *hostclaim.Claim) error {
+ c.Obligations[0].Region = HomeRegion
+ c.Obligations[0].Owner = "region:" + HomeRegion
+ c.Holder.Generation = "next"
+ c.Holder.Admissions = []hostclaim.AdmissionRecord{{Generation: "next", Predecessor: "old", Region: "uk", PredecessorRegion: "ie", Mode: "cold"}, {Generation: "foreign", Predecessor: "old", Region: "fr", PredecessorRegion: "fr", Mode: "cold"}}
+ return nil
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ f.e.View = func(_ context.Context, o hostclaim.Observed) (View, error) {
+ return View{Region: HomeRegion, ClaimETag: o.ETag, AuthoritativeRegion: "uk", GenerationOf: func(Ref) (string, bool) { return "old", true }}, nil
+ }
+ f.b.Put("data", f.ob.Park.Location+"/g-old/0000/a", []byte("old"))
+ if err := f.e.Run(t.Context(), f.w); err != nil {
+ t.Fatal(err)
+ }
+ done, err := hostclaim.Read(t.Context(), f.e.Claims, f.w.Host)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(done.Claim.Obligations) != 0 || !done.Claim.Holder.Admissions[0].Covered || done.Claim.Holder.Admissions[1].Covered {
+ t.Fatalf("coverage: %+v", done.Claim.Holder.Admissions)
+ }
+}
+
+func TestPlan13SweepRejectsConflictingHome(t *testing.T) {
+ f := newFixture(t)
+ f.e.HomeRegion = "ie"
+ o, err := hostclaim.Read(t.Context(), f.e.Claims, f.w.Host)
+ if err != nil {
+ t.Fatal(err)
+ }
+ _, err = hostclaim.Update(t.Context(), f.e.Claims, o, func(c *hostclaim.Claim) error { c.Op.Intent = map[string]string{"home_region": "uk"}; return nil })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err = f.e.Sweep(t.Context(), f.w.Host, "", f.e.Now(), f.ob.Park.RequestLimit); err == nil {
+ t.Fatal("conflicting home binding accepted")
+ }
+}
+
+func TestPlan13SweepAbsentClaimRefuses(t *testing.T) {
+ f := newFixture(t)
+ f.e.View = func(context.Context, hostclaim.Observed) (View, error) { return View{}, nil }
+ if _, err := f.e.Sweep(t.Context(), "absent.example", "", f.e.Now(), f.ob.Park.RequestLimit); err == nil {
+ t.Fatal("absent claim accepted")
+ }
+}
+
+func TestPlan13ReturningWriterEmptyHomePark(t *testing.T) {
+ f := newFixture(t)
+ choreIn(t, f, HomeRegion)
+ f.e.HomeRegion = "ie"
+ engineIn(f, HomeRegion, HomeRegion)
+ o, err := hostclaim.Read(t.Context(), f.e.Claims, f.w.Host)
+ if err != nil {
+ t.Fatal(err)
+ }
+ o, err = hostclaim.Update(t.Context(), f.e.Claims, o, func(c *hostclaim.Claim) error {
+ c.Op = hostclaim.Op{ID: "op", Kind: hostclaim.KindRestoring, Phase: hostclaim.PhaseClaimed, Exec: "returner", Intent: map[string]string{"home_region": "ie"}}
+ c.Holder.Generation, c.Holder.Session, c.Holder.WriterBox = "old", 1, "target"
+ c.Holder.Admissions = []hostclaim.AdmissionRecord{{Generation: "old", Region: "ie", Session: 1, Box: "target", Mode: "cold"}}
+ return nil
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ o, err = hostclaim.Update(t.Context(), f.e.Claims, o, hostclaim.AdmitWriter(hostclaim.Admission{ObservedOpID: "op", ObservedPhase: hostclaim.PhaseClaimed, ObservedSession: 1, ObservedGeneration: "old", Region: "uk", Box: "returner", Mode: "cold", NewGeneration: "return"}))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !o.Claim.Holder.Admissions[1].Covered {
+ t.Fatal("returning predecessor uncovered")
+ }
+ if err := f.e.Run(t.Context(), f.w); err != nil {
+ t.Fatal(err)
+ }
+ done, err := hostclaim.Read(t.Context(), f.e.Claims, f.w.Host)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(done.Claim.Obligations) != 0 || !done.Claim.Holder.Admissions[1].Covered || done.Claim.Holder.Admissions[1].PredecessorRegion != "ie" {
+ t.Fatal(done.Claim)
+ }
+}
diff --git a/internal/park/plan13_region_test.go b/internal/park/plan13_region_test.go
new file mode 100644
index 000000000..8f962bb39
--- /dev/null
+++ b/internal/park/plan13_region_test.go
@@ -0,0 +1,51 @@
+package park
+
+import (
+ "github.com/carlosframework/platform/internal/hostclaim"
+ "testing"
+ "time"
+)
+
+func TestPlan13RegionNames(t *testing.T) {
+ for _, tc := range []struct{ home, region, want string }{
+ {"ie", "ie", ""}, {"ie", "", ""}, {"ie", "uk", "uk"}, {"", "ie", "ie"},
+ } {
+ if got := HomePlacementCode(tc.home, tc.region); got != tc.want {
+ t.Fatalf("%+v: %q", tc, got)
+ }
+ }
+ for _, tc := range []struct {
+ home, a, b string
+ want bool
+ }{
+ {"ie", "ie", HomeRegion, true}, {"ie", HomeRegion, "ie", true}, {"ie", "uk", HomeRegion, false},
+ {"", "ie", HomeRegion, false}, {"ie", "", HomeRegion, false}, {"ie", "", "", false},
+ {"ie", "uk", "uk", true}, {"home", "home", HomeRegion, true},
+ } {
+ if got := SameRegion(tc.home, tc.a, tc.b); got != tc.want {
+ t.Fatalf("%+v: %v", tc, got)
+ }
+ }
+ q := map[string]time.Time{"ie": time.Unix(5, 0), HomeRegion: time.Unix(9, 0), "uk": time.Unix(12, 0)}
+ if !QuietUntil(q, "ie", "ie").Equal(q[HomeRegion]) || !QuietUntil(q, "ie", "uk").Equal(q["uk"]) {
+ t.Fatal("quiet aliases")
+ }
+}
+func TestPlan13OldAdmissionLiveSet(t *testing.T) {
+ c := &hostclaim.Claim{Holder: &hostclaim.Holder{Location: "instances/h/i", Generation: "new", Admissions: []hostclaim.AdmissionRecord{
+ {Generation: "new", Predecessor: "old", Region: "ie", PredecessorRegion: "ie", Mode: "cold"},
+ }}, Op: hostclaim.Op{Kind: hostclaim.KindServing}}
+ v := View{HomeRegion: "ie", Region: HomeRegion, AuthoritativeRegion: HomeRegion, MarkerKnown: true, MarkerGeneration: "old", GenerationOf: func(Ref) (string, bool) { return "old", true }}
+ got, err := LiveGenerations(c, v, c.Holder.Location)
+ if err != nil || !got["old"] || !got["new"] {
+ t.Fatalf("%v %v", got, err)
+ }
+ if c.Holder.Admissions[0].Region != "ie" {
+ t.Fatal("rewrote history")
+ }
+ v.Region = "uk"
+ got, err = LiveGenerations(c, v, c.Holder.Location)
+ if err != nil || len(got) != 0 {
+ t.Fatalf("foreign: %v %v", got, err)
+ }
+}
diff --git a/internal/park/region.go b/internal/park/region.go
index 8b680c3e5..2f62067ea 100644
--- a/internal/park/region.go
+++ b/internal/park/region.go
@@ -1,6 +1,6 @@
package park
-import "regexp"
+import "github.com/carlosframework/platform/internal/regionname"
// 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
@@ -17,22 +17,10 @@ import "regexp"
// 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}$`)
+const HomeRegion = regionname.HomeRegion
// 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 ""
-}
+func PlacementRegion(code string) string { return regionname.PlacementRegion(code) }
diff --git a/internal/park/region_names.go b/internal/park/region_names.go
new file mode 100644
index 000000000..f865694db
--- /dev/null
+++ b/internal/park/region_names.go
@@ -0,0 +1,69 @@
+package park
+
+import (
+ "github.com/carlosframework/platform/internal/regionname"
+ "time"
+)
+
+// HomePlacementCode is the console door's home-name rule. It consumes a
+// roster/API name, not an engine region; @home is never a placement code.
+func HomePlacementCode(home, region string) string { return regionname.HomePlacementCode(home, region) }
+
+// SameRegion compares nonempty engine identities under the verified home binding.
+func SameRegion(home, a, b string) bool { return regionname.SameRegion(home, a, b) }
+
+func QuietUntil(bounds map[string]time.Time, home, region string) time.Time {
+ var latest time.Time
+ for r, until := range bounds {
+ if SameRegion(home, r, region) && latest.Before(until) {
+ latest = until
+ }
+ }
+ return latest
+}
+
+func (e Engine) OwnsRegion(region string) bool {
+ return SameRegion(e.HomeRegion, e.Region, region)
+}
+
+func (v View) sameRegion(a, b string) bool { return SameRegion(v.HomeRegion, a, b) }
+
+// PlacementMatches compares a record code with a nonempty engine identity.
+func PlacementMatches(home, code, engine string) bool {
+ return SameRegion(home, PlacementRegion(code), engine)
+}
+
+// BoundHome joins the verified local binding and the admitted operation's
+// binding for history reads. It never changes an engine's ownership.
+func BoundHome(local, operation string) (string, error) {
+ for _, home := range []string{local, operation} {
+ if home != "" && PlacementRegion(home) != home {
+ return "", ErrUnsafe
+ }
+ }
+ if local != "" && operation != "" && local != operation {
+ return "", ErrUnsafe
+ }
+ if local != "" {
+ return local, nil
+ }
+ return operation, nil
+}
+
+// EnginePlacement projects a verified engine identity into record vocabulary.
+func EnginePlacement(home, engine string) (string, bool) {
+ if engine == "" {
+ return "", false
+ }
+ if SameRegion(home, engine, HomeRegion) {
+ return "", true
+ }
+ return engine, PlacementRegion(engine) == engine
+}
+
+func VerifiedHome(fleet, local, region, operation string) (string, error) {
+ if (fleet != "" && PlacementRegion(fleet) != fleet) || (operation != "" && operation != fleet) || (local != "" && (local != fleet || local != region)) || (local == "" && operation != "" && operation == region) {
+ return "", ErrUnsafe
+ }
+ return BoundHome(local, operation)
+}
diff --git a/internal/park/remote.go b/internal/park/remote.go
index 4d033959c..4a63164e7 100644
--- a/internal/park/remote.go
+++ b/internal/park/remote.go
@@ -54,6 +54,7 @@ type Leases interface {
}
type Engine struct {
+ HomeRegion string
Store lease.VersionedStore
Claims release.Store
Bucket, Region string
@@ -325,9 +326,14 @@ func (e Engine) observe(ctx context.Context, w Work, held HeldLease, expected ho
if err != nil {
return obs, v, err
}
- if v.ClaimETag != obs.ETag || v.Region != e.Region {
+ if v.ClaimETag != obs.ETag || !e.OwnsRegion(v.Region) {
return obs, v, ErrChanged
}
+ home, err := BoundHome(e.HomeRegion, obs.Claim.Op.Intent["home_region"])
+ if err != nil {
+ return obs, v, err
+ }
+ v.HomeRegion = home
return obs, v, nil
}
func (e Engine) selected(ctx context.Context, w Work, o hostclaim.Obligation, held HeldLease, vs []lease.ObjectVersion) (hostclaim.Observed, View, []lease.ObjectVersion, error) {
@@ -348,7 +354,7 @@ func (e Engine) selected(ctx context.Context, w Work, o hostclaim.Obligation, he
if isLock(w.Host, o.Park.Location, x.Key) {
return obs, v, nil, ErrUnsafe
}
- if obs.Claim.Holder != nil && obs.Claim.Op.Kind != hostclaim.KindParking && o.Park.Location == obs.Claim.Holder.Location && v.Region == v.AuthoritativeRegion {
+ if obs.Claim.Holder != nil && obs.Claim.Op.Kind != hostclaim.KindParking && o.Park.Location == obs.Claim.Holder.Location && v.sameRegion(v.Region, v.AuthoritativeRegion) {
if x.Key == v.Marker.Key {
if o.Kind == "park-generation" {
continue
@@ -414,7 +420,7 @@ func (e Engine) run(ctx context.Context, w Work, own HeldLease) error {
if err := validateScope(w.Host, o); err != nil {
return err
}
- if o.Region != e.Region {
+ if !e.OwnsRegion(o.Region) {
return ErrUnsafe
}
var held HeldLease
@@ -550,7 +556,17 @@ func (e Engine) run(ctx context.Context, w Work, own HeldLease) error {
if c.Holder == nil {
return nil
}
- return hostclaim.CoverParkedPredecessors(obs.Claim.Op.ID, obs.Claim.Op.Phase, o.Region, generations)(c)
+ if err := hostclaim.CoverParkedPredecessors(obs.Claim.Op.ID, obs.Claim.Op.Phase, o.Region, generations)(c); err != nil {
+ return err
+ }
+ if e.HomeRegion != "" && e.OwnsRegion(HomeRegion) {
+ other := HomeRegion
+ if o.Region == HomeRegion {
+ other = e.HomeRegion
+ }
+ return hostclaim.CoverParkedPredecessors(obs.Claim.Op.ID, obs.Claim.Op.Phase, other, generations)(c)
+ }
+ return nil
}
if len(vs) > 0 {
next := e.NewID()
diff --git a/internal/park/sweep.go b/internal/park/sweep.go
index 6ab70bf84..a772760ff 100644
--- a/internal/park/sweep.go
+++ b/internal/park/sweep.go
@@ -18,6 +18,7 @@ import (
// Pinned includes the exact noncurrent versions needed by the installed root.
// MarkerKnown means its read succeeded; an absent marker may denote @root.
type View struct {
+ HomeRegion string
// ArchivedIncarnations labels proven retired locations independently of
// the moves-sweep proof's incarnation. Missing entries never invent labels.
ArchivedIncarnations map[string]string
@@ -51,7 +52,7 @@ func protected(c *hostclaim.Claim, v View, location string) (map[string]bool, er
if v.AuthoritativeRegion == "" {
return nil, ErrUnsafe
}
- if v.Region != v.AuthoritativeRegion {
+ if !v.sameRegion(v.Region, v.AuthoritativeRegion) {
return live, nil
}
if !v.MarkerKnown || v.GenerationOf == nil {
@@ -82,7 +83,7 @@ func protected(c *hostclaim.Claim, v View, location string) (map[string]bool, er
want, region := current, v.AuthoritativeRegion
for i := len(h.Admissions) - 1; i >= 0; i-- {
a := h.Admissions[i]
- if generation(a.Generation) != want || a.Region != region {
+ if generation(a.Generation) != want || !v.sameRegion(a.Region, region) {
continue
}
if a.Mode != "cold" {
@@ -94,14 +95,14 @@ func protected(c *hostclaim.Claim, v View, location string) (map[string]bool, er
}
want = pred
region = a.PredecessorRegion
- if region == "" || region == v.AuthoritativeRegion {
+ if region == "" || v.sameRegion(region, v.AuthoritativeRegion) {
if a.Covered {
return nil, ErrUnsafe
}
live[want] = true
}
if want == marker {
- if region != "" && region != v.AuthoritativeRegion {
+ if region != "" && !v.sameRegion(region, v.AuthoritativeRegion) {
return nil, ErrUnsafe
}
return live, nil
@@ -141,7 +142,7 @@ func Decide(obs hostclaim.Observed, v View, versions []lease.ObjectVersion, lega
return d, nil
}
for _, o := range c.Obligations {
- if o.Region == v.Region {
+ if v.sameRegion(o.Region, v.Region) {
return d, nil
}
}
@@ -161,7 +162,7 @@ func Decide(obs hostclaim.Observed, v View, versions []lease.ObjectVersion, lega
if isLock(c.Host, loc, x.Key) {
continue
}
- if refusal := v.RefusedLocations[loc]; refusal != nil && !(c.Holder != nil && loc == c.Holder.Location && v.Region == v.AuthoritativeRegion) {
+ if refusal := v.RefusedLocations[loc]; refusal != nil && !(c.Holder != nil && loc == c.Holder.Location && v.sameRegion(v.Region, v.AuthoritativeRegion)) {
if !refused[loc] {
d.Alerts = append(d.Alerts, Alert{loc, refusal.Error()})
refused[loc] = true
@@ -177,7 +178,7 @@ func Decide(obs hostclaim.Observed, v View, versions []lease.ObjectVersion, lega
candidate.Incarnation = v.ArchivedIncarnations[loc]
}
}
- if c.Holder != nil && loc == c.Holder.Location && v.Region == v.AuthoritativeRegion {
+ if c.Holder != nil && loc == c.Holder.Location && v.sameRegion(v.Region, v.AuthoritativeRegion) {
if liveErr != nil {
continue
}
@@ -328,10 +329,18 @@ func (e Engine) sweep(ctx context.Context, expected hostclaim.Observed, expected
if bound && (obs.ETag != expected.ETag || !reflect.DeepEqual(obs.Claim, expected.Claim)) {
return empty, ErrChanged
}
+ if obs.Absent || obs.Claim == nil {
+ return empty, ErrUnsafe
+ }
v, err := e.View(ctx, obs)
if err != nil {
return empty, err
}
+ home, err := BoundHome(e.HomeRegion, obs.Claim.Op.Intent["home_region"])
+ if err != nil {
+ return empty, err
+ }
+ v.HomeRegion = home
listingStarted := e.Now()
vs, err := e.Discover(ctx, host)
listedAt := e.Now()
diff --git a/internal/provision/move_relocation.go b/internal/provision/move_relocation.go
index 707c8351c..7da7131f5 100644
--- a/internal/provision/move_relocation.go
+++ b/internal/provision/move_relocation.go
@@ -44,7 +44,7 @@ func MoveRelocatingDecision(g MoveGuard, box, enrollment, region, fence string,
return err
}
var ref struct{ Enrollment, Key, SHA256 string }
- if len(movingIdentity) != 64 || len(holderIdentity) != 64 || claimGen == 0 || claimETag == "" || box == "" || enrollment == "" || region == "" || now.IsZero() ||
+ if len(movingIdentity) != 64 || len(holderIdentity) != 64 || claimGen == 0 || claimETag == "" || box == "" || enrollment == "" || !ValidRegion(region) || now.IsZero() ||
json.Unmarshal([]byte(fence), &ref) != nil || ref.Enrollment != enrollment || ref.Key != "control/box-fences/"+enrollment+".json" || len(ref.SHA256) != 64 ||
rec.Region != region || rec.Move.From != region {
return fmt.Errorf("%w: the relocation decision does not match this move", ErrMoveConflict)
diff --git a/internal/provision/plan9_b8_census_test.go b/internal/provision/plan9_b8_census_test.go
index b5335c517..cc048c3e8 100644
--- a/internal/provision/plan9_b8_census_test.go
+++ b/internal/provision/plan9_b8_census_test.go
@@ -474,13 +474,13 @@ func Reviewed(ctx context.Context, s Store, k string) error {
var plan9B8CensusInventory = []censusEntry{
{"internal/hostbox/parkleases.go", "ReplicaParkLeaseSet", "key-site", "lit:instances", "0ca8d9b743402fda9d15cc215464423b35e21c1c2b47f91294d76188b7d43fb6"},
{"internal/host/claimretirementcensus.go", "ClaimRetirementCensus", "key-site", "lit:instances", "abc0bd8c5f5d328abfac8821f42f8f23954723ed6506747f33b0197824e2611f"},
- {"internal/host/claimproduction.go", "productionClaimRegional.View", "key-site", "lit:instances", "61ff9ff47054b6a96cb24467c7aaf41c112b7c5129987af5ce27e0b7f862348e"},
- {"internal/hostops/move_loss_admission.go", "moveLossOwner.ValidateLossRecovery", "key-site", "key:RecordKey", "ea2aa35a069ffdc846ecb208c33940245fc865795188818e86ea7a93135dd6a9"},
+ {"internal/host/claimproduction.go", "productionClaimRegional.View", "key-site", "lit:instances", "533203b2aa729801438da4f3a85799a58ea0e67d20eb5625f0de32ea623f1607"},
+ {"internal/hostops/move_loss_admission.go", "moveLossOwner.ValidateLossRecovery", "key-site", "key:RecordKey", "1ad4af4595c764fc5051e50c2b187472cb20ce73c3b3e8245b8527228f326f5f"},
{"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", "13e4506d5825781237b85eb28db5641e25278f4ba9ea2e5475b6adca03d3ee12"},
+ {"internal/hostops/move_fence_release.go", "MoveRecoveryGrant.ReleaseFencedTarget", "record-writer", "key:WriteRecord,key:writeRecord", "f164e9f612ec87ddf02e6eaafd466549ebc2623b33aa1b8aae5122e1d3120a83"},
{"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"},
+ {"internal/hostops/move_recovery_grant.go", "MoveRecoveryGrant.Convert", "record-writer", "key:WriteRecord", "bbb8c3aea28bcd7b3c45e5c4df3951509bb4e21158cdb67cea91a4486f3381da"},
// 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
// app; never a record (Plan 6 Task 3 fix round 3, ruling 1).
@@ -608,11 +608,11 @@ var plan9B8CensusInventory = []censusEntry{
{"internal/console/apiinstancemove.go", "Server.registerMovesPhase1Routes", "key-site", "lit:instances", "5af589d288ba22856f631481c40cc001aac342ea74cee6ee0464ec77d0320bbc"},
{"internal/console/apiinstances.go", "Server.instanceManifest", "key-site", "lit:instances", "e6160ffe84abda9163527ccb33e2ffff2109b35908adb2f3a754534325658fdf"},
{"internal/console/apiinstances.go", "Server.handleAPIInstanceList", "key-site", "lit:instances", "73a40e68abf796870589df197fc72697bfe201fe0b3ca6c14829f1fd5761f758"},
- {"internal/console/apiinstances.go", "Server.handleAPIInstanceCreate", "record-writer", "key:CreateRecord", "20f862446f119f25d7d72eab6edd1b7cfb1528afd5b4c352e0cb8442f5080b13"},
+ {"internal/console/apiinstances.go", "Server.handleAPIInstanceCreate", "record-writer", "key:CreateRecord", "9b185998d430710c3ba14c4ace8c19a487d15b7f391b222c6e6479168a0c7603"},
{"internal/console/apiinstances.go", "Server.handleAPIInstanceUpdate", "record-writer", "key:UpdateRecord", "4b792c410399631eb3b38c78829df85dda045651bbc32df769767cf52f015b6d"},
{"internal/console/apiscan.go", "Server.writeScanRecord", "nonrecord", "sel:Put", "c3c092f4b7be9bf6bf09d0fe1e2c9fe52892ee97e57f6c7ab964b85fe36fb5c6"},
{"internal/console/apisidecars.go", "Server.handleAPISidecarList", "key-site", "lit:sidecars", "6a5e461256c985ea4924bf7ba7c0793ae601a59efe8ef570913138ef8b47441a"},
- {"internal/console/apisidecars.go", "Server.handleAPISidecarCreate", "record-writer", "key:CreateSidecarRecord", "a7d5eb534b540499b9355b455966e733d61c904be5cc5a76a168833df59de1c3"},
+ {"internal/console/apisidecars.go", "Server.handleAPISidecarCreate", "record-writer", "key:CreateSidecarRecord", "f3b049692e3289d9bb3c77e9e6a55dbdfac78bd9ff90af893b6cc753e76a0164"},
{"internal/console/apistatusinstances.go", "Server.handleAPIStatusInstances", "key-site", "lit:instances", "2b4b9a2bea5f2fc5727869a5dcb8ae96c020e72571621f19898b195ca3b61aaa"},
{"internal/console/apisystemupdate.go", "Server.mirrorSystemArtifact", "nonrecord", "sel:Put", "76a8dcb558e3723ad5774788da37c22234c229dc3257ded5a6c7b04746c69c2f"},
{"internal/console/apiupdates.go", "Server.handleAPIUpdatesPut", "record-writer", "key:UpdateRecord", "53044a537795302e7ffd8fa72799b7a77e7bed277f00ad17ed1fd465b7f7cfaa"},
@@ -701,18 +701,18 @@ var plan9B8CensusInventory = []censusEntry{
{"internal/host/movesmeasure.go", "measureS3", "nonrecord", "sel:Put", "3ae9fc093b453e19a5b19be0929533b30a0e2a3e998ba8dc766f41287db2caca"},
{"internal/host/movespass.go", "legacyMoveRound", "record-writer", "key:SetRecordMove", "1e13a3ea099d45318d3b64b067448c87dc4a136cd909348f526e4583ac00150a"},
{"internal/host/movesphase1.go", "writeMoveLedger", "nonrecord", "sel:Put", "597a70ac06ceb4e9eb52bb9f61b28a4937e703fa4a0916f64bd95d9594f0556e"},
- {"internal/host/movesphase1.go", "movesWorker.sourceRound", "nonrecord", "sel:PutIfAbsentVersioned", "c5bc2e6cb57b819ae1da485d37093c9a9ed3cdc9e9e16e8a8e312fd5ad2e2fe3"},
+ {"internal/host/movesphase1.go", "movesWorker.sourceRound", "nonrecord", "sel:PutIfAbsentVersioned", "a2989e948ebe33ceeaaf1be25c8cdb2bdfff4cf5e55b5ed28a03605e1ec81756"},
{"internal/host/movesphase1.go", "movesWorker.refreshCrossRate", "nonrecord", "sel:CopyVersioned", "7e03e680bd61be26dddcf168cacd2427bb535571dfd326c4a3130ebe65ea1968"},
{"internal/host/movesphase1.go", "movesWorker.writeSourceReport", "nonrecord", "sel:PutReplace", "370407cca2c1b91f6e5e7f135238f04c0dce9d7986e6e1ee9bebbf88cad0b9e0"},
{"internal/host/movesphase1.go", "movesWorker.decideReady", "record-writer", "key:SetRecordMove", "d29c22eafacfb894b96cd0c40ec447b6ab02f46ea087201685627861f992085d"},
- {"internal/host/movesreport.go", "movesWorker.targetReport", "nonrecord", "sel:PutReplace", "fce9296957f2a8f91840ddeb5c3553a0a248bc11f7f98a08d0ae7d00ae1c2eb4"},
+ {"internal/host/movesreport.go", "movesWorker.targetReport", "nonrecord", "sel:PutReplace", "9a761daeb0a9018ad409ed353c6da6f9e523c63d46ab2c248000327d5f7fcd4f"},
{"internal/host/movesstore.go", "laneProbe", "nonrecord", "sel:PutIfAbsent", "f4a17a1143e7370b9039b2e66e736606015c46619bab92c553ce7b1ad6b7918b"},
{"internal/host/snapshot_loop.go", "registrySnapshotPass", "nonrecord", "sel:Put", "6b01b46b361c3a2d1d432659a0d234c01975849b9a8ac5571858fdd78d376849"},
{"internal/host/snapshot_loop.go", "putReplacing", "nonrecord", "sel:Put", "11e8639effce142eab707b67cbdd425d0d7cea510ddf95d292bdba8f79c9b510"},
{"internal/hostactors/censuskey.go", "Directory.BindCensusKey", "nonrecord", "sel:Put", "7d9d673435e8c8a6aac3105e3a754bde2adcbc5d603945dd599cef703f334a5b"},
{"internal/hostactors/invitation.go", "Directory.Invite", "nonrecord", "sel:Put", "356618a75a23d0157d8e0402e20187a42956f67a4332d698bb22bf05133ac489"},
{"internal/hostactors/roster.go", "Directory.Replace", "nonrecord", "sel:Put", "99d6830102942188370207b21a87245056b1a56af1f95344b0337091e720823f"},
- {"internal/hostbox/regionalworker.go", "RegionalWorker.Census", "key-site", "lit:instances", "5d554df4562248c6e6467f5009910dc4e5502bc09679baa55f0d2cd1bdc06037"},
+ {"internal/hostbox/regionalworker.go", "RegionalWorker.Census", "key-site", "lit:instances", "477bcb896b5ae8ec80af592d68eceb7992ad8a23cd6cff6043ad03b3ef2a8085"},
{"internal/hostbox/requeststore.go", "RequestStore.CopyVersion", "versioned-transport", "def:CopyVersion,sel:CopyVersion", "3a50049ce7ca7f2cbc770bef46218944cdc7000205a86c20e4932f0b510da28f"},
{"internal/hostbox/requeststore.go", "RequestStore.PutIfAbsent", "versioned-transport", "def:PutIfAbsent,sel:PutIfAbsent", "3666cbe5d2b91c1c652ffb4459c10a374fbd92e325e4e5c574fa3525d0ce2ffe"},
{"internal/hostclaim/adopt.go", "Adopt", "key-site", "lit:instances", "24c06b99cce6c638acb19826985cfadd3c736fd0ace7289bcf7ac3dfe784f05a"},
@@ -742,7 +742,7 @@ var plan9B8CensusInventory = []censusEntry{
{"internal/hostops/lifecycle_grant.go", "authorizeSignedControl", "reference", "str:Put", "cf012aebc7b4e0ea8dfef48ea3817a7466f1dabacd7cf1e5be07b2302bf183ba"},
{"internal/hostops/lifecycle_record.go", "type lifecycleRecordKey", "key-site", "key:lifecycleRecordKey", "09ab1fd62d0be032bd735d1f60488cdb19128f8573b00bbd7a70f582208da75d"},
{"internal/hostops/lifecycle_record.go", "setLifecycleRecordMove", "record-writer", "key:SetRecordMove,key:lifecycleRecordKey", "37691dfd5158c644b12e28383243f92a7868e075eade79be79e7149a18f8d420"},
- {"internal/hostops/lifecycle_record.go", "conditionalRecordStore.putLifecycle", "record-writer", "key:putSequencedRecord", "03ac054bfed5f28429eaaefca22ab940ad71bace1897064e1c770cec361bcc03"},
+ {"internal/hostops/lifecycle_record.go", "conditionalRecordStore.putLifecycle", "record-writer", "key:putSequencedRecord", "75f03db96d509d340fc3edf62a5ff60957ab75d08d6586fea6e342c5a9c3eb8d"},
{"internal/hostops/mail_gate.go", "ControlWriter.AcquireMailGate", "nonrecord", "sel:Put", "f37147b214f176da0565268aba714f15216f3bdd9c5f279d6c404098a499b482"},
{"internal/hostops/mail_gate.go", "ControlWriter.ReleaseMailGate", "nonrecord", "sel:Put", "aea8bc583a0a28cd61e8c81c6d466b02e4fb6a086ba921f01fdd7dfdb286191c"},
{"internal/hostops/mail_gate.go", "ControlWriter.RecordMailGateMember", "nonrecord", "sel:Put", "ed87514c0cfd15664765deb5aa0051c37b14ecebf0ba1a77459da4f8b7e7465f"},
@@ -843,8 +843,8 @@ var plan9B8CensusInventory = []censusEntry{
{"internal/objstore/sidecar.go", "claimRawPutSidecar", "nonrecord", "sel:Put", "233e3d808dd462ff83aecdac5176b180556b948c552aa26f6e0b778df91a24c7"},
{"internal/ops/litestream.go", "FleetDBsWithSkipped", "key-site", "lit:instances", "f62291df0685db15529c4946dca358a29aba66fc31e81aab299de579e37f60f1"},
{"internal/park/remote.go", "locationOf", "key-site", "lit:instances", "83b3efca31b3faadcd5997dc181d81b7bb8fc866cc4a448683bf8423b1bea866"},
- {"internal/park/remote.go", "Engine.run", "versioned-transport", "sel:CopyVersion,sel:PutIfAbsent", "3de5b215e4b7aba67768243891b3f6d8ff8c77a465aefe5168c789cb012b1ea9"},
- {"internal/park/sweep.go", "Decide", "key-site", "lit:instances", "d03c3af1430816d3f9517a3944e553291805f97a317e54b96a02b9fbfbb5efb5"},
+ {"internal/park/remote.go", "Engine.run", "versioned-transport", "sel:CopyVersion,sel:PutIfAbsent", "538230265e42b2d828bc8f842034d200b4fe3b0c908bd369a3d309c1c79af78d"},
+ {"internal/park/sweep.go", "Decide", "key-site", "lit:instances", "5f42dc7646841e2cf6cb8835f9b74710bb02ab5b3e9a31a9639bdea80e5f4573"},
{"internal/park/sweep.go", "Engine.Discover", "key-site", "lit:instances", "69b83e6e859c1c9809ba76d46cb0192888df1ecdcd161b4f2b6435733eecdfa3"},
{"internal/provision/claim_app_writes.go", "UpdateRecord", "record-writer", "key:UpdateRecord,key:claimRawUpdateRecord", "caf714cb78e1280d489c7ad5b3cefb1420e812d541108b1fe13d733abed97229"},
{"internal/provision/provision.go", "ManifestKey", "key-site", "lit:instances", "b8ee0e77bdfade19af79a4012c411c1eb0b6ddca16037e5536fa8597f7cde270"},
diff --git a/internal/regionname/region.go b/internal/regionname/region.go
new file mode 100644
index 000000000..d56e208f8
--- /dev/null
+++ b/internal/regionname/region.go
@@ -0,0 +1,58 @@
+package regionname
+
+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 ""
+}
+
+func HomePlacementCode(home, region string) string {
+ if home != "" && region == home {
+ return ""
+ }
+ return region
+}
+
+// SameRegion preserves exact stored identities. The only alias is a verified
+// home roster name and @home. Empty always means unset at this boundary.
+func SameRegion(home, a, b string) bool {
+ if a == "" || b == "" {
+ return false
+ }
+ if a == b {
+ return true
+ }
+ return home != "" && PlacementRegion(home) == home &&
+ ((a == home && b == HomeRegion) || (a == HomeRegion && b == home))
+}