region HA: stage B reads main's home region (park.HomeRegion) through main's helpers wherever it meets it
Main names the home "@home" where moves maps a placement code (intent "to", its parks, its admissions) and keeps the roster name for the claim assembly's engine, aliasing the two only under a verified home binding. Stage B's relocation region stays the roster name, as main's ConvertMovingToRelocating writes it. Where stage B meets the other name: - relocationInstallLocal's Selection carries the work source's HomeRegion and FleetHomeRegion; main's IdentityProvider.Local refused it otherwise on any fleet with a designated home. - RelocationStartGuard takes the home binding and reads a park-replica under either name (regionname.SameRegion), as BeginAdmittedMove's guard does; StartRelocation records RelocationStart.HomeRegion as the op's home_region intent and refuses one that is not a placement code. - The completion's abandoned-P park reuses one owed under the home's other name. - CommittedLandingRecords answers park.PlacementRegion of the record's From/To (moves' commit regions); ConvertCommittedLanding compares under the move's home_region, takes the fence binding (roster identity) as the relocation's region, and queues the commit's parks in engine regions. - The worker holds the box's verified binding (Worker.HomeRegion, checked against the signed roster), converts a landing into its region under park.VerifiedHome + SameRegion, starts under it, and does not step an op bound to another home. - The pinner, the relocation wake (and its fresh publisher) and the release flush select through park.HolderRegionView, as main's serving baseline does. - ExecutorChooser maps a home record's park.HomeRegion to HomeName (main's StartCreate mapping); the console passes dbClaimHomeRegion(), the binding ProductionAuthority holds. - claimcensus: the .moves fallback stays (M-K25: moves removes the lock when the stage finishes; a crash still leaves it). Tests for each site; 20 of 21 mutants killed (the survivor is equivalent).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
20 files changed,
+625
−42
internal/console/hostclaimexecutor.go+3 −1internal/host/claimcensus.go+5 −3internal/host/relocatepass.go+2 −2internal/host/relocation_home_test.go+51 −0internal/host/relocationassembly.go+2 −1internal/host/relocationinstall.go+1 −1internal/host/relocationlanding.go+12 −2internal/host/relocationlanding_test.go+138 −4internal/host/relocationowners.go+2 −1internal/host/relocationwake.go+10 −5internal/hostclaim/relocating.go+21 −7internal/hostclaim/relocation.go+24 −8internal/hostclaim/relocation_home_test.go+188 −0internal/hostclaim/relocation_test.go+1 −1internal/hostops/executor.go+9 −0internal/hostops/executor_test.go+22 −0internal/hostrelocate/pin.go+7 −1internal/hostrelocate/pin_test.go+39 −0internal/hostrelocate/worker.go+32 −5internal/hostrelocate/worker_test.go+56 −0
diff --git a/internal/console/hostclaimexecutor.go b/internal/console/hostclaimexecutor.go| index 8c2dd834e..a1f9c24a6 100644 |
| --- a/internal/console/hostclaimexecutor.go |
| +++ b/internal/console/hostclaimexecutor.go |
| @@ -30,5 +30,7 @@ func (s *Server) executorChooser(d hostactors.Directory) *hostops.ExecutorChoose |
| Loads: func(ctx context.Context) (map[string]boxchoice.Load, error) { |
| return hostops.ClaimLoads(ctx, s.cfg.Releases) |
| }, |
| - SecretsKeyFP: s.expectedSecretsFP(), HomeName: s.cfg.HomeRegion, Now: time.Now} |
| + // HomeName is the create door's home binding, the one |
| + // ProductionAuthority.HomeRegion holds (dbClaimHomeRegion). |
| + SecretsKeyFP: s.expectedSecretsFP(), HomeName: s.cfg.dbClaimHomeRegion(), Now: time.Now} |
| } |
diff --git a/internal/host/claimcensus.go b/internal/host/claimcensus.go| index b5642fd6e..e9c6fe8f3 100644 |
| --- a/internal/host/claimcensus.go |
| +++ b/internal/host/claimcensus.go |
| @@ -58,9 +58,11 @@ func claimDataCensus(dataDir string) []string { |
| staged := claimCensusRel(dataDir, release.HeldStagedDir(dataDir)) |
| // And its held-stage lock (Task 8c fix round 1, I1): every held |
| // publication, root's relay and the relocation install's own, holds |
| - // .moves/held-<host>.lock (withHeldStageLock), which is never removed. |
| - // .moves is a shared tree, not opaque: regular files and directories |
| - // only, and anything naming the host being censused still refuses. |
| + // .moves/held-<host>.lock (withHeldStageLock). Moves removes it when the |
| + // stage finishes (M-K25 on #236), but a holder killed mid-stage leaves |
| + // it, so the fallback stays, as moves asked: .moves is a shared tree, |
| + // not opaque: regular files and directories only, and anything naming |
| + // the host being censused still refuses. |
| return []string{".hostclaims", ".hostexec", "hostguard", "relocate", stage, staged, hostbox.CensusTree(movesDirName), hostbox.CensusFile(reg), hostbox.CensusFile(reg + "-wal"), hostbox.CensusFile(reg + "-shm")} |
| } |
diff --git a/internal/host/relocatepass.go b/internal/host/relocatepass.go| index 3623fc26b..cb27db9b6 100644 |
| --- a/internal/host/relocatepass.go |
| +++ b/internal/host/relocatepass.go |
| @@ -318,7 +318,7 @@ func NewRelocationRuntime(ctx context.Context, getenv func(string) string, store |
| Choose: chooser.Choose, Eligible: chooser.Eligible, |
| Relocatable: relocationRelocatable(cfg.DataDir, store), |
| Leases: relocationLeases(stores), |
| - Pins: hostrelocate.StorePinner{Claims: store, Data: stores.Files, Bucket: stores.Bucket, Region: stores.Region, |
| + Pins: hostrelocate.StorePinner{Claims: store, Data: stores.Files, Bucket: stores.Bucket, Region: stores.Region, HomeRegion: cfg.Source.HomeRegion, |
| SelectDB: func(ctx context.Context, r genselect.Request) (*genselect.Plan, error) { |
| return genselect.Select(ctx, stores.Versions, r) |
| }, |
| @@ -327,7 +327,7 @@ func NewRelocationRuntime(ctx context.Context, getenv func(string) string, store |
| Discharge: volumeDischarge(store), |
| Notify: func(ctx context.Context) { _ = release.BumpSerial(ctx, store, time.Now()) }, |
| Self: termfence.WrittenBy{Box: in.Enrollment.Box, Enrollment: in.Enrollment.Enrollment}, |
| - Region: stores.Region, Controller: newClaimID(), NewID: newClaimID, Now: time.Now} |
| + Region: stores.Region, HomeRegion: cfg.Source.HomeRegion, Controller: newClaimID(), NewID: newClaimID, Now: time.Now} |
| participant := hostrelocate.Participant{Claims: store, Box: in.Enrollment.Box, Enrollment: in.Enrollment.Enrollment, Effects: effects, Now: time.Now} |
| return &RelocationRuntime{Worker: worker, Participant: participant, Box: in.Enrollment.Box, Convert: convertFencedLanding(worker, store), Finish: effects.Finish, DataDir: cfg.DataDir, config: cfg}, nil |
| } |
diff --git a/internal/host/relocation_home_test.go b/internal/host/relocation_home_test.go| new file mode 100644 |
| index 000000000..31036a82f |
| --- /dev/null |
| +++ b/internal/host/relocation_home_test.go |
| @@ -0,0 +1,51 @@ |
| +package host |
| + |
| +import ( |
| + "context" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/hostbox" |
| + "github.com/carlosframework/platform/internal/hostclaim" |
| + "github.com/carlosframework/platform/internal/hostguard" |
| + "github.com/carlosframework/platform/internal/park" |
| +) |
| + |
| +// Main's IdentityProvider.Local refuses a selection whose home binding is |
| +// not the actor's (HomeRegion and FleetHomeRegion), so the install's one |
| +// Selection carries the work source's binding, as main's own selections do |
| +// (WorkSource.Select, claimAdminTokens, claimLaunchAuthority). |
| +func TestRelocationInstallSelectionCarriesTheHomeBinding(t *testing.T) { |
| + var got hostbox.Selection |
| + src := hostbox.WorkSource{HomeRegion: "ie", FleetHomeRegion: "ie", Box: "dst", Region: "ie", |
| + Local: func(_ context.Context, _ *hostclaim.Claim, s hostbox.Selection) error { got = s; return nil }} |
| + c := &hostclaim.Claim{Host: "shop.example", Op: hostclaim.Op{Kind: hostclaim.KindRelocating, ID: "rel-1", Phase: hostclaim.PhaseInstalling}, |
| + Holder: &hostclaim.Holder{Generation: "g1", Session: 2}} |
| + if err := relocationInstallLocal(t.Context(), src, c, hostguard.Captured{Incarnation: "inc", Stamp: 4}); err != nil { |
| + t.Fatal(err) |
| + } |
| + if got.HomeRegion != "ie" || got.FleetHomeRegion != "ie" || got.Region != "ie" || got.Box != "dst" || got.Action != relocationActionInstall { |
| + t.Fatalf("selection %+v", got) |
| + } |
| +} |
| + |
| +// The relocation wake's generation selection and its fresh publisher see |
| +// the holder through the box's home binding (park.HolderRegionView), as |
| +// main's serving owners do: an admission moves recorded under |
| +// park.HomeRegion is this region's. |
| +func TestRelocationWakeSelectsThroughTheHomeBinding(t *testing.T) { |
| + o := hostclaim.Observed{Host: "shop.example", Claim: &hostclaim.Claim{Holder: &hostclaim.Holder{ |
| + Admissions: []hostclaim.AdmissionRecord{{Region: park.HomeRegion, PredecessorRegion: "uk", Generation: "g1"}}}}} |
| + for _, tc := range []struct{ home, want string }{{"ie", "ie"}, {"", park.HomeRegion}} { |
| + r := &RelocationWakeInputs{Bucket: "data", Region: "ie", HomeRegion: tc.home} |
| + req := r.request(o) |
| + if req.Region != "ie" || req.Holder.Admissions[0].Region != tc.want || req.Holder.Admissions[0].PredecessorRegion != "uk" { |
| + t.Fatalf("home %q: request %+v", tc.home, req) |
| + } |
| + if f := r.freshOwners(); f.HomeRegion != tc.home || f.Region != "ie" { |
| + t.Fatalf("home %q: fresh owners %+v", tc.home, f) |
| + } |
| + } |
| + if o.Claim.Holder.Admissions[0].Region != park.HomeRegion { |
| + t.Fatal("the view changed the claim's own holder") |
| + } |
| +} |
diff --git a/internal/host/relocationassembly.go b/internal/host/relocationassembly.go| index b1d48b400..af52d7de2 100644 |
| --- a/internal/host/relocationassembly.go |
| +++ b/internal/host/relocationassembly.go |
| @@ -20,6 +20,7 @@ import ( |
| "github.com/carlosframework/platform/internal/hostrelocate" |
| "github.com/carlosframework/platform/internal/hostwrite" |
| "github.com/carlosframework/platform/internal/lease" |
| + "github.com/carlosframework/platform/internal/park" |
| "github.com/carlosframework/platform/internal/provision" |
| "github.com/carlosframework/platform/internal/registry" |
| "github.com/carlosframework/platform/internal/release" |
| @@ -217,7 +218,7 @@ func (a ClaimAssembly) relocationFlushDB(cfg RelocationAssemblyConfig) func(cont |
| sel := func(ctx context.Context, r genselect.Request) (*genselect.Plan, error) { |
| return genselect.Select(ctx, cfg.Versions, r) |
| } |
| - return releaseFinalTXID(ctx, att.rep, sel, genselect.Request{Bucket: cfg.Bucket, Region: cfg.Region, Host: o.Host, Holder: *o.Claim.Holder}) |
| + return releaseFinalTXID(ctx, att.rep, sel, genselect.Request{Bucket: cfg.Bucket, Region: cfg.Region, Host: o.Host, Holder: park.HolderRegionView(*o.Claim.Holder, cfg.Source.HomeRegion, cfg.Region)}) |
| } |
| } |
diff --git a/internal/host/relocationinstall.go b/internal/host/relocationinstall.go| index f9c682fca..3a493210a 100644 |
| --- a/internal/host/relocationinstall.go |
| +++ b/internal/host/relocationinstall.go |
| @@ -70,7 +70,7 @@ func relocationInstallAuthority(c *hostclaim.Claim, op, box string, phases ...st |
| // action on c at captured: the one Selection both the guard activation |
| // and every build write ask with. |
| func relocationInstallLocal(ctx context.Context, src hostbox.WorkSource, c *hostclaim.Claim, captured hostguard.Captured) error { |
| - sel := hostbox.Selection{Host: c.Host, Action: relocationActionInstall, Box: src.Box, Region: src.Region, Captured: captured, |
| + sel := hostbox.Selection{HomeRegion: src.HomeRegion, FleetHomeRegion: src.FleetHomeRegion, Host: c.Host, Action: relocationActionInstall, Box: src.Box, Region: src.Region, Captured: captured, |
| Kind: c.Op.Kind, Op: c.Op.ID, Phase: c.Op.Phase, Generation: c.Holder.Generation, Session: c.Holder.Session} |
| return src.Local(ctx, c, sel) |
| } |
diff --git a/internal/host/relocationlanding.go b/internal/host/relocationlanding.go| index 29586032f..90be6b5c4 100644 |
| --- a/internal/host/relocationlanding.go |
| +++ b/internal/host/relocationlanding.go |
| @@ -5,13 +5,15 @@ import ( |
| "github.com/carlosframework/platform/internal/hostclaim" |
| "github.com/carlosframework/platform/internal/moves" |
| + "github.com/carlosframework/platform/internal/park" |
| "github.com/carlosframework/platform/internal/provision" |
| ) |
| // CommittedLandingRecords is hostclaim.CommittedLandingGuard over the |
| // authoritative instance record: the move named by the claim's op has |
| // committed for this incarnation (provision.MoveCommitted, no rollback), |
| -// and the regions and park scopes are the commit's (moves.CommitScopes). |
| +// and the regions and park scopes are the commit's (moves.CommitScopes), |
| +// the regions as engine regions. |
| type CommittedLandingRecords struct{ Records MovesRecordReader } |
| var _ hostclaim.CommittedLandingGuard = CommittedLandingRecords{} |
| @@ -36,5 +38,13 @@ func (g CommittedLandingRecords) CheckLandingCommitted(ctx context.Context, o ho |
| if err != nil { |
| return hostclaim.CommittedLanding{}, err |
| } |
| - return hostclaim.CommittedLanding{FromRegion: m.From, ToRegion: m.To, SourceScope: source, StagingScope: staging}, nil |
| + // The record's From and To are placement codes. The commit's parks are |
| + // queued in engine regions, as moves' commitClaim queues them |
| + // (park.PlacementRegion, so the home is park.HomeRegion, never ""); a |
| + // code that is no placement refuses. |
| + from, to := park.PlacementRegion(m.From), park.PlacementRegion(m.To) |
| + if from == "" || to == "" { |
| + return hostclaim.CommittedLanding{}, hostclaim.ErrWrongState |
| + } |
| + return hostclaim.CommittedLanding{FromRegion: from, ToRegion: to, SourceScope: source, StagingScope: staging}, nil |
| } |
diff --git a/internal/host/relocationlanding_test.go b/internal/host/relocationlanding_test.go| index 291c8c2bf..8e33b24cb 100644 |
| --- a/internal/host/relocationlanding_test.go |
| +++ b/internal/host/relocationlanding_test.go |
| @@ -15,6 +15,7 @@ import ( |
| "github.com/carlosframework/platform/internal/hostfence" |
| "github.com/carlosframework/platform/internal/hostrelocate" |
| "github.com/carlosframework/platform/internal/moves" |
| + "github.com/carlosframework/platform/internal/park" |
| "github.com/carlosframework/platform/internal/provision" |
| "github.com/carlosframework/platform/internal/release" |
| "github.com/carlosframework/platform/internal/termfence" |
| @@ -34,6 +35,16 @@ type landingWorld struct { |
| } |
| func newLandingWorld(t *testing.T, committed bool, change func(*hostactors.Roster)) landingWorld { |
| + t.Helper() |
| + return newLandingWorldHome(t, committed, change, "") |
| +} |
| + |
| +// newLandingWorldHome is newLandingWorld with the landing into the home |
| +// when home is the fleet's home region (ie): the signed roster designates |
| +// it, the move is admitted under it (MoveAdmission.HomeRegion, its "to" |
| +// park.HomeRegion, as BeginClaimMove writes it) and the record's To is the |
| +// home's placement code "". |
| +func newLandingWorldHome(t *testing.T, committed bool, change func(*hostactors.Roster), home string) landingWorld { |
| t.Helper() |
| ctx := t.Context() |
| const host, id = "shop.example", "mv_abcdefghijklmnopqrstuvwxyz" |
| @@ -50,6 +61,7 @@ func newLandingWorld(t *testing.T, committed bool, change func(*hostactors.Roste |
| roster.Boxes[box] = hostactors.Box{Enrollment: enr, Namespace: "platform", BoxID: box, Label: box, Region: "ie", Pool: "default", |
| Cloud: hostactors.Cloud{Provider: "aws", Account: "123", Region: "eu-west-1", InstanceID: "i-" + box}, EvidenceSHA256: strings.Repeat("c", 64), State: "active"} |
| } |
| + roster.HomeRegion = home |
| if change != nil { |
| change(&roster) |
| } |
| @@ -61,7 +73,7 @@ func newLandingWorld(t *testing.T, committed bool, change func(*hostactors.Roste |
| t.Fatal(err) |
| } |
| w.directory = hostactors.Directory{Store: w.store, PublicKey: pub, Bucket: "bucket"} |
| - w.obs = landingAtProbed(t, w.store, host, id, w.now, w.enrollment) |
| + w.obs = landingAtProbedHome(t, w.store, host, id, w.now, w.enrollment, home) |
| dst := roster.Boxes["dst"] |
| fence, _ := json.Marshal(termfence.Record{V: 1, Box: "dst", Enrollment: dst.Enrollment, Cloud: dst.Cloud, State: "terminated", ObservedAt: w.now.Add(-time.Minute).Format(time.RFC3339Nano), |
| Via: termfence.ViaDescribe, RequestID: "req", WrittenBy: termfence.WrittenBy{Box: "spa", Enrollment: w.enrollment["spa"]}}) |
| @@ -74,14 +86,25 @@ func newLandingWorld(t *testing.T, committed bool, change func(*hostactors.Roste |
| at := w.now.Add(-30 * time.Minute) |
| state, committedAt = provision.MoveCommitted, &at |
| } |
| - record := provision.Record{Schema: 1, Host: host, Slug: "shop", App: "jam", Account: "bab", Region: "ie", Channel: "stable", CreatedAt: created, |
| - Move: &provision.MoveState{ID: id, Incarnation: w.obs.Claim.Holder.Incarnation, From: "uk", To: "ie", State: state, CommittedAt: committedAt, FlippedAt: &flipped}} |
| + to := "ie" |
| + if home != "" { |
| + to = "" // the home's placement code |
| + } |
| + record := provision.Record{Schema: 1, Host: host, Slug: "shop", App: "jam", Account: "bab", Region: to, Channel: "stable", CreatedAt: created, |
| + Move: &provision.MoveState{ID: id, Incarnation: w.obs.Claim.Holder.Incarnation, From: "uk", To: to, State: state, CommittedAt: committedAt, FlippedAt: &flipped}} |
| if err := provision.CreateRecord(ctx, w.store, record); err != nil { |
| t.Fatal(err) |
| } |
| return w |
| } |
| +// homeWorker is worker with the box's verified home binding. |
| +func (w landingWorld) homeWorker(enrollment, region, home string) hostrelocate.Worker { |
| + x := w.worker(enrollment, region) |
| + x.HomeRegion = home |
| + return x |
| +} |
| + |
| // worker is the converting worker: wrk, running as enrollment, in region. |
| func (w landingWorld) worker(enrollment, region string) hostrelocate.Worker { |
| now := func() time.Time { return w.now } |
| @@ -201,6 +224,13 @@ func TestRelocatePassConversionRefusesAnUnauthorizedWorker(t *testing.T) { |
| // move from src to dst flipped, the target's probe admitted. Its seal is a |
| // real one, so moves.CommitScopes reads it. |
| func landingAtProbed(t *testing.T, store release.Store, host, id string, now time.Time, enrollment map[string]string) hostclaim.Observed { |
| + t.Helper() |
| + return landingAtProbedHome(t, store, host, id, now, enrollment, "") |
| +} |
| + |
| +// landingAtProbedHome is landingAtProbed into the home when home is set: |
| +// the move is admitted under the home binding with "to" park.HomeRegion. |
| +func landingAtProbedHome(t *testing.T, store release.Store, host, id string, now time.Time, enrollment map[string]string, home string) hostclaim.Observed { |
| t.Helper() |
| ctx := t.Context() |
| created := time.Date(2026, 10, 4, 1, 0, 0, 0, time.UTC) |
| @@ -221,7 +251,11 @@ func landingAtProbed(t *testing.T, store release.Store, host, id string, now tim |
| step(hostclaim.BeginCreateOn(nh, body, "c1", "console", created, hostclaim.ExecutorSelection{Box: "src", Enrollment: enrollment["src"]})) |
| step(hostclaim.CompleteCreate("c1", "src")) |
| step(hostclaim.AdmitWriter(hostclaim.Admission{Box: "src", Mode: "cold", NewGeneration: "g1", Region: "ie", KeepStamp: true})) |
| - step(hostclaim.BeginAdmittedMove(id, "ops", "src", "dst", "ie", now.Add(-3*time.Hour), hostclaim.MoveAdmission{AppID: "app-canonical", AdmissionID: id, SourceEnrollment: enrollment["src"], TargetEnrollment: enrollment["dst"]})) |
| + to := "ie" |
| + if home != "" { |
| + to = park.HomeRegion |
| + } |
| + step(hostclaim.BeginAdmittedMove(id, "ops", "src", "dst", to, now.Add(-3*time.Hour), hostclaim.MoveAdmission{HomeRegion: home, AppID: "app-canonical", AdmissionID: id, SourceEnrollment: enrollment["src"], TargetEnrollment: enrollment["dst"]})) |
| step(hostclaim.ExecToNone(id, "src", "moves/"+host+"/"+id+"/stopped-source.json", hostclaim.JournalEvidenceSource)) |
| step(hostclaim.SetJournal(id, map[string]string{hostclaim.JournalSeal: seal.Encode(), hostclaim.JournalCutSession: "1"})) |
| step(hostclaim.SetMovePhase(id, hostclaim.KindMoving, hostclaim.PhaseMoving, hostclaim.PhaseSealed)) |
| @@ -238,3 +272,103 @@ func landingAtProbed(t *testing.T, store release.Store, host, id string, now tim |
| }) |
| return o |
| } |
| + |
| +// Main's home-region parks name a move into the home by park.HomeRegion |
| +// ("to") while the roster, the fence binding and the region worker name it |
| +// by its roster name (ie). Under the binding the box and the move agree on |
| +// (the signed roster's home), the home's worker converts the committed |
| +// landing: the relocation's region is the roster identity (as |
| +// ConvertMovingToRelocating's is), the move's home binding is kept, and the |
| +// commit's parks are queued in engine regions exactly as moves' commit |
| +// queues them (the source's location in uk, the staging lane in |
| +// park.HomeRegion). A box with no binding, or a binding the roster does not |
| +// designate, writes nothing. |
| +func TestRelocatePassConvertsACommittedLandingIntoTheHome(t *testing.T) { |
| + ctx := t.Context() |
| + w := newLandingWorldHome(t, true, nil, "ie") |
| + if err := convertFencedLanding(w.homeWorker(w.enrollment["wrk"], "ie", "ie"), w.store)(ctx, w.store, w.obs); err != nil { |
| + t.Fatalf("convert: %v", err) |
| + } |
| + after, err := hostclaim.Read(ctx, w.store, w.obs.Host) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + c := after.Claim |
| + if c.Op.Kind != hostclaim.KindRelocating || c.Op.Phase != hostclaim.PhaseStarting || c.Op.Intent["source_box"] != "dst" || c.Op.Journal["conversion"] != "committed-landing" { |
| + t.Fatalf("not converted: %+v", c.Op) |
| + } |
| + if c.Op.Intent["region"] != "ie" || c.Op.Intent["home_region"] != "ie" || c.Op.Intent["move_to_region"] != park.HomeRegion || c.Op.Journal["move_from_region"] != "uk" { |
| + t.Fatalf("intent %v journal from %q", c.Op.Intent, c.Op.Journal["move_from_region"]) |
| + } |
| + parks := map[string]int{} |
| + for _, o := range c.Obligations { |
| + if o.Kind == "park-replica" { |
| + parks[o.Region+" "+o.Owner]++ |
| + } |
| + } |
| + if parks["uk region:uk"] != 1 || parks[park.HomeRegion+" region:"+park.HomeRegion] != 1 || len(parks) != 2 { |
| + t.Fatalf("the commit's parks: %v", parks) |
| + } |
| + |
| + for _, tc := range []struct { |
| + name string |
| + change func(*hostactors.Roster) |
| + home string |
| + want error |
| + }{ |
| + {name: "the box holds no home binding", want: park.ErrUnsafe}, |
| + {name: "the roster designates no home", change: func(r *hostactors.Roster) { r.HomeRegion = "" }, home: "ie", want: hostrelocate.ErrNotWorker}, |
| + } { |
| + t.Run(tc.name, func(t *testing.T) { |
| + w := newLandingWorldHome(t, true, tc.change, "ie") |
| + err := convertFencedLanding(w.homeWorker(w.enrollment["wrk"], "ie", tc.home), w.store)(ctx, w.store, w.obs) |
| + if !errors.Is(err, tc.want) { |
| + t.Fatalf("got %v, want %v", err, tc.want) |
| + } |
| + if after, err := hostclaim.Read(ctx, w.store, w.obs.Host); err != nil || after.ETag != w.obs.ETag { |
| + t.Fatalf("the claim was written: %v", err) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +// CommittedLandingRecords answers the record's placement codes as engine |
| +// regions (park.PlacementRegion), the regions moves' commit queues its |
| +// parks in: the home's "" is park.HomeRegion, and a code that is no |
| +// placement refuses. |
| +func TestCommittedLandingRecordsAnswersEngineRegions(t *testing.T) { |
| + ctx := t.Context() |
| + for _, tc := range []struct { |
| + name, from, to string |
| + wantFrom, wantTo string |
| + refused bool |
| + }{ |
| + {name: "named", from: "uk", to: "ie", wantFrom: "uk", wantTo: "ie"}, |
| + {name: "into the home", from: "uk", to: "", wantFrom: "uk", wantTo: park.HomeRegion}, |
| + {name: "out of the home", from: "", to: "uk", wantFrom: park.HomeRegion, wantTo: "uk"}, |
| + {name: "not a placement code", from: "UK", to: "ie", refused: true}, |
| + } { |
| + t.Run(tc.name, func(t *testing.T) { |
| + w := newLandingWorld(t, true, nil) |
| + c := w.obs.Claim |
| + read := func(ctx context.Context, account, app, host string) (*provision.Record, error) { |
| + rec, err := provision.GetRecord(ctx, w.store, account, app, host) |
| + if err != nil { |
| + return nil, err |
| + } |
| + rec.Move.From, rec.Move.To = tc.from, tc.to |
| + return rec, nil |
| + } |
| + got, err := CommittedLandingRecords{Records: read}.CheckLandingCommitted(ctx, w.obs, c.Op.ID) |
| + if tc.refused { |
| + if !errors.Is(err, hostclaim.ErrWrongState) { |
| + t.Fatalf("got %+v, %v", got, err) |
| + } |
| + return |
| + } |
| + if err != nil || got.FromRegion != tc.wantFrom || got.ToRegion != tc.wantTo { |
| + t.Fatalf("got %+v, %v", got, err) |
| + } |
| + }) |
| + } |
| +} |
diff --git a/internal/host/relocationowners.go b/internal/host/relocationowners.go| index 5dad4dd25..dbee77079 100644 |
| --- a/internal/host/relocationowners.go |
| +++ b/internal/host/relocationowners.go |
| @@ -24,6 +24,7 @@ import ( |
| "github.com/carlosframework/platform/internal/hostwrite" |
| "github.com/carlosframework/platform/internal/lease" |
| "github.com/carlosframework/platform/internal/moves" |
| + "github.com/carlosframework/platform/internal/park" |
| "github.com/carlosframework/platform/internal/release" |
| ) |
| @@ -79,7 +80,7 @@ func (s *RelocationSource) Open(ctx context.Context, store release.Store, data l |
| } |
| releases := StopReceiptReleases{Receipts: RelocationReleaseReceipts{Store: store, Directory: s.Directory}} |
| inputs := &RelocationWakeInputs{Launch: src.Launch, FilesManifest: src.Files, Markers: src.Markers, Claims: store, FilesStore: files, |
| - Data: hostbox.RequestStore{Store: data, Check: src.Identity}, Bucket: actor.Bucket, Region: actor.Region, Box: actor.Box, |
| + Data: hostbox.RequestStore{Store: data, Check: src.Identity}, Bucket: actor.Bucket, Region: park.PlacementRegion(actor.Region), HomeRegion: actor.HomeRegion, Box: actor.Box, |
| NewID: newClaimID, Now: time.Now, Releases: releases} |
| attempts := &relocationProbeAttempts{Claims: store, DataDir: s.DataDir, Box: actor.Box, NewID: newClaimID, Now: time.Now} |
| owners := ClaimRelocationOwners{ |
diff --git a/internal/host/relocationwake.go b/internal/host/relocationwake.go| index 5d3ebdde1..d3eb56776 100644 |
| --- a/internal/host/relocationwake.go |
| +++ b/internal/host/relocationwake.go |
| @@ -20,6 +20,7 @@ import ( |
| "github.com/carlosframework/platform/internal/hostrelocate" |
| "github.com/carlosframework/platform/internal/hostwrite" |
| "github.com/carlosframework/platform/internal/moves" |
| + "github.com/carlosframework/platform/internal/park" |
| "github.com/carlosframework/platform/internal/registry" |
| "github.com/carlosframework/platform/internal/release" |
| ) |
| @@ -36,9 +37,13 @@ type RelocationWakeInputs struct { |
| Data genselect.Reader |
| Bucket string |
| Region string |
| - Box string |
| - NewID func() string |
| - Now func() time.Time |
| + // HomeRegion is the box's verified home binding (hostbox.Actor's), or |
| + // "": the selection and the fresh publisher see the holder through it |
| + // (park.HolderRegionView), as main's serving owners do. |
| + HomeRegion string |
| + Box string |
| + NewID func() string |
| + Now func() time.Time |
| // Releases verifies a journaled release at its receipt (stage A's |
| // StopReceiptReleases over main's Complete). |
| Releases hostclaim.ReleaseAuthority |
| @@ -76,13 +81,13 @@ func (r *RelocationWakeInputs) freshOwners() *claimServingOwners { |
| r.mu.Lock() |
| defer r.mu.Unlock() |
| if r.fresh == nil { |
| - r.fresh = &claimServingOwners{Data: r.Data, Bucket: r.Bucket, Region: r.Region, Box: r.Box, NewID: r.NewID, Now: r.Now, Sources: ClaimServingSources{Markers: r.Markers}} |
| + r.fresh = &claimServingOwners{HomeRegion: r.HomeRegion, Data: r.Data, Bucket: r.Bucket, Region: r.Region, Box: r.Box, NewID: r.NewID, Now: r.Now, Sources: ClaimServingSources{Markers: r.Markers}} |
| } |
| return r.fresh |
| } |
| func (r *RelocationWakeInputs) request(o hostclaim.Observed) genselect.Request { |
| - return genselect.Request{Bucket: r.Bucket, Region: r.Region, Host: o.Host, Holder: *o.Claim.Holder} |
| + return genselect.Request{Bucket: r.Bucket, Region: r.Region, Host: o.Host, Holder: park.HolderRegionView(*o.Claim.Holder, r.HomeRegion, r.Region)} |
| } |
| func preparedAuthority(c *hostclaim.Claim) (hostclaim.PreparedAuthority, error) { |
diff --git a/internal/hostclaim/relocating.go b/internal/hostclaim/relocating.go| index 2ff1de3f8..62fc536c3 100644 |
| --- a/internal/hostclaim/relocating.go |
| +++ b/internal/hostclaim/relocating.go |
| @@ -7,6 +7,7 @@ import ( |
| "slices" |
| "strconv" |
| + "github.com/carlosframework/platform/internal/regionname" |
| "github.com/carlosframework/platform/internal/release" |
| ) |
| @@ -168,9 +169,11 @@ func ConvertMovingToRelocating(ctx context.Context, s release.Store, observed Ob |
| } |
| // CommittedLanding is the authoritative instance record's committed move |
| -// as a CommittedLandingGuard read it: the regions it moved between and the |
| -// two parks the commit queues, built by moves.CommitScopes from the |
| -// record's flip time and the claim's seal (the one builder moves' own |
| +// as a CommittedLandingGuard read it: the regions it moved between, as |
| +// engine regions (park.PlacementRegion of the record's placement codes, so |
| +// the home is park.HomeRegion, exactly the regions moves' own commit |
| +// queues its parks in), and the two parks the commit queues, built by |
| +// moves.CommitScopes from the record's flip time and the claim's seal (the one builder moves' own |
| // commit uses). The scopes are used only when the claim has not yet |
| // followed the commit. |
| type CommittedLanding struct { |
| @@ -241,7 +244,12 @@ func ConvertCommittedLanding(ctx context.Context, s release.Store, observed Obse |
| if err != nil { |
| return Observed{}, err |
| } |
| - if committed.FromRegion == "" || committed.ToRegion != in["to"] || committed.FromRegion == committed.ToRegion { |
| + // The committed regions are engine regions, and so is the move's "to" |
| + // (BeginClaimMove's park.PlacementRegion); the home's two names are one |
| + // region only under the move's own home binding (main's hostclaim reads |
| + // it from the intent the same way). |
| + home := in["home_region"] |
| + if committed.FromRegion == "" || !regionname.SameRegion(home, committed.ToRegion, in["to"]) || regionname.SameRegion(home, committed.FromRegion, committed.ToRegion) { |
| return Observed{}, ErrWrongState |
| } |
| fence, err := VerifyFence(ctx, s, authority, ref) |
| @@ -249,8 +257,10 @@ func ConvertCommittedLanding(ctx context.Context, s release.Store, observed Obse |
| return Observed{}, err |
| } |
| // The fence is the target's, bound to the target's region in the |
| - // roster, which is the record's destination. |
| - if fence.record.Box != targetBox || fence.record.Enrollment != in["target_enrollment"] || fence.binding.Region != committed.ToRegion { |
| + // roster, which is the record's destination. The binding is the roster |
| + // name, so a landing in the home matches its park.HomeRegion only under |
| + // the move's home binding. |
| + if fence.record.Box != targetBox || fence.record.Enrollment != in["target_enrollment"] || !regionname.SameRegion(home, fence.binding.Region, committed.ToRegion) { |
| return Observed{}, ErrFence |
| } |
| return Update(ctx, s, observed, func(c *Claim) error { |
| @@ -311,7 +321,11 @@ func ConvertCommittedLanding(ctx context.Context, s release.Store, observed Obse |
| } |
| delete(intent, "target") |
| delete(intent, "target_enrollment") |
| - intent["region"] = committed.ToRegion |
| + // The relocation's region is the verified roster identity, as |
| + // ConvertMovingToRelocating's is (the fence binding, which matched |
| + // the destination above): the region worker's own name, never |
| + // park.HomeRegion. |
| + intent["region"] = fence.binding.Region |
| intent["source_box"] = targetBox |
| intent["source_enrollment"] = in["target_enrollment"] |
| intent["stamp_from"] = strconv.FormatUint(c.Holder.Stamp, 10) |
diff --git a/internal/hostclaim/relocation.go b/internal/hostclaim/relocation.go| index ffe02e47f..d4220a17c 100644 |
| --- a/internal/hostclaim/relocation.go |
| +++ b/internal/hostclaim/relocation.go |
| @@ -10,6 +10,7 @@ import ( |
| "strings" |
| "time" |
| + "github.com/carlosframework/platform/internal/regionname" |
| "github.com/carlosframework/platform/internal/release" |
| ) |
| @@ -55,9 +56,15 @@ var ( |
| // RelocationStart is the start CAS's intent (spec 1 §5.8, claim §4). Fence |
| // is set for a failover only and names the source's termination fence. |
| type RelocationStart struct { |
| - OpID, By string |
| - Now time.Time |
| - Region, Reason string |
| + OpID, By string |
| + Now time.Time |
| + Region, Reason string |
| + // HomeRegion is the starting box's verified home binding (the fleet's |
| + // home roster name when Region is it, hostbox.Actor.HomeRegion), or "" |
| + // with no alias. It lets the start guard see the home's chores under |
| + // either of its names (regionname.SameRegion), and is recorded as the |
| + // op's "home_region" intent, as BeginAdmittedMove records a move's. |
| + HomeRegion string |
| SourceBox, SourceEnrollment string |
| TargetBox, TargetEnrollment string |
| DirectoryRevision uint64 |
| @@ -259,8 +266,11 @@ func failover(op Op) bool { |
| // RelocationStartGuard is §4's start guard with the relocation exception |
| // (L11): a park-generation chore in the region does not block, a |
| // park-replica in the region does, a park-local on the target does, and a |
| -// park-local on a fenced source does not block a failover. |
| -func RelocationStartGuard(c *Claim, region, source, target string, sourceFenced bool) error { |
| +// park-local on a fenced source does not block a failover. home is the |
| +// verified home binding or "": a park-replica queued under the home's |
| +// other name (park.HomeRegion, as moves queues it) is the same region |
| +// (regionname.SameRegion), as BeginAdmittedMove's start guard reads both. |
| +func RelocationStartGuard(c *Claim, home, region, source, target string, sourceFenced bool) error { |
| if c.Holder == nil || c.Op.Kind != KindServing || c.Op.ID != "" { |
| return fmt.Errorf("%w: kind %q", ErrWrongState, c.Op.Kind) |
| } |
| @@ -273,7 +283,7 @@ func RelocationStartGuard(c *Claim, region, source, target string, sourceFenced |
| } |
| for _, o := range c.Obligations { |
| switch { |
| - case o.Kind == "park-replica" && o.Region == region: |
| + case o.Kind == "park-replica" && regionname.SameRegion(home, o.Region, region): |
| return fmt.Errorf("%w: park-replica in %s", ErrPendingChore, region) |
| case o.Kind == "park-local" && o.Box == target: |
| return fmt.Errorf("%w: park-local on %s", ErrPendingChore, target) |
| @@ -460,6 +470,9 @@ func StartRelocation(ctx context.Context, s release.Store, observed Observed, in |
| default: |
| return Observed{}, fmt.Errorf("%w: reason %q", ErrRelocation, in.Reason) |
| } |
| + if in.HomeRegion != "" && regionname.PlacementRegion(in.HomeRegion) != in.HomeRegion { |
| + return Observed{}, fmt.Errorf("%w: home region %q is not a placement code", ErrRelocation, in.HomeRegion) |
| + } |
| if in.OpID == "" || in.Region == "" || in.AdmissionID == "" || in.DirectoryRevision == 0 || in.Now.IsZero() || |
| !validBoxReference(in.SourceBox) || !validBoxReference(in.TargetBox) || in.SourceBox == "none" || in.TargetBox == "none" || |
| in.SourceBox == in.TargetBox || in.SourceEnrollment == "" || in.TargetEnrollment == "" { |
| @@ -480,7 +493,7 @@ func StartRelocation(ctx context.Context, s release.Store, observed Observed, in |
| if c.Op.Exec != in.SourceBox || slices.Contains(c.Lost, in.TargetBox) { |
| return fmt.Errorf("%w: the source is not the executor, or the target is lost", ErrWrongState) |
| } |
| - if err := RelocationStartGuard(c, in.Region, in.SourceBox, in.TargetBox, in.Fence != nil); err != nil { |
| + if err := RelocationStartGuard(c, in.HomeRegion, in.Region, in.SourceBox, in.TargetBox, in.Fence != nil); err != nil { |
| return err |
| } |
| exec, participants := in.SourceBox, []string{in.SourceBox, in.TargetBox} |
| @@ -499,6 +512,9 @@ func StartRelocation(ctx context.Context, s release.Store, observed Observed, in |
| "admission_id": in.AdmissionID, |
| }, |
| Journal: map[string]string{}} |
| + if in.HomeRegion != "" { |
| + c.Op.Intent["home_region"] = in.HomeRegion |
| + } |
| if in.Fence != nil { |
| return fenceBox(c, in.SourceBox, *in.Fence, observedAt, in.Discharge) |
| } |
| @@ -1104,7 +1120,7 @@ func CompleteRelocation(opID string, newID func() string) func(*Claim) error { |
| } |
| queued := false |
| for _, o := range c.Obligations { |
| - if o.Kind == "park-generation" && o.Region == region && o.Generation == g { |
| + if o.Kind == "park-generation" && regionname.SameRegion(c.Op.Intent["home_region"], o.Region, region) && o.Generation == g { |
| queued = true |
| } |
| } |
diff --git a/internal/hostclaim/relocation_home_test.go b/internal/hostclaim/relocation_home_test.go| new file mode 100644 |
| index 000000000..5cbaaf4b4 |
| --- /dev/null |
| +++ b/internal/hostclaim/relocation_home_test.go |
| @@ -0,0 +1,188 @@ |
| +package hostclaim |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "slices" |
| + "strings" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/regionname" |
| +) |
| + |
| +// Main's home-region parks (park.HomeRegion, "@home") name the home's |
| +// chores by that token where moves queues them, while a relocation's region |
| +// is the roster name (ie here). Under a verified home binding the two are |
| +// one region (regionname.SameRegion), so a relocation in the home sees and |
| +// reuses the chores moves queued under the other name. Without a binding |
| +// the comparison stays exact, as main's own comparisons do. |
| + |
| +func TestRelocationStartGuardReadsTheHomesTwoNames(t *testing.T) { |
| + serving := func(obs ...Obligation) *Claim { |
| + return &Claim{Holder: &Holder{Incarnation: "i"}, Op: Op{Kind: KindServing, Exec: relSource}, Obligations: obs} |
| + } |
| + for _, tc := range []struct { |
| + name, home, region string |
| + ob Obligation |
| + want error |
| + }{ |
| + {"park-replica under @home, home bound", "ie", "ie", Obligation{Kind: "park-replica", Region: regionname.HomeRegion}, ErrPendingChore}, |
| + {"park-replica under @home, no binding", "", "ie", Obligation{Kind: "park-replica", Region: regionname.HomeRegion}, nil}, |
| + {"park-replica under the roster name, home bound", "ie", "ie", Obligation{Kind: "park-replica", Region: "ie"}, ErrPendingChore}, |
| + {"park-replica under @home, another region's relocation", "ie", "uk", Obligation{Kind: "park-replica", Region: regionname.HomeRegion}, nil}, |
| + {"park-generation under @home (L11)", "ie", "ie", Obligation{Kind: "park-generation", Region: regionname.HomeRegion}, nil}, |
| + } { |
| + t.Run(tc.name, func(t *testing.T) { |
| + if err := RelocationStartGuard(serving(tc.ob), tc.home, tc.region, relSource, relTarget, false); !errors.Is(err, tc.want) { |
| + t.Fatalf("got %v, want %v", err, tc.want) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +func TestStartRelocationCarriesTheHomeBinding(t *testing.T) { |
| + ctx := context.Background() |
| + t.Run("recorded as the op's home_region", func(t *testing.T) { |
| + s, o := relServing(t) |
| + in := relStart(ReasonDrain, nil) |
| + in.HomeRegion = "ie" |
| + o, err := StartRelocation(ctx, s, o, in, relAuthority{}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if o.Claim.Op.Intent["home_region"] != "ie" || o.Claim.Op.Intent["region"] != "ie" { |
| + t.Fatalf("intent %v", o.Claim.Op.Intent) |
| + } |
| + }) |
| + t.Run("no binding records none", func(t *testing.T) { |
| + s, o := relServing(t) |
| + o, err := StartRelocation(ctx, s, o, relStart(ReasonDrain, nil), relAuthority{}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, ok := o.Claim.Op.Intent["home_region"]; ok { |
| + t.Fatalf("intent %v", o.Claim.Op.Intent) |
| + } |
| + }) |
| + t.Run("a park-replica under @home refuses the start", func(t *testing.T) { |
| + s, o := relServing(t) |
| + o = step(t, s, o, func(c *Claim) error { |
| + c.Obligations = append(c.Obligations, Obligation{ID: "lane", Op: "mv-old", Kind: "park-replica", Region: regionname.HomeRegion, Owner: "region:" + regionname.HomeRegion, Retirement: "r"}) |
| + return nil |
| + }) |
| + in := relStart(ReasonDrain, nil) |
| + in.HomeRegion = "ie" |
| + raw := mustJSON(t, o.Claim) |
| + if _, err := StartRelocation(ctx, s, o, in, relAuthority{}); !errors.Is(err, ErrPendingChore) { |
| + t.Fatalf("got %v", err) |
| + } |
| + if stored, err := Read(ctx, s, o.Host); err != nil || stored.ETag != o.ETag || string(raw) != string(mustJSON(t, o.Claim)) { |
| + t.Fatalf("refusal wrote the claim: %v", err) |
| + } |
| + }) |
| + t.Run("a home that is no placement code refuses", func(t *testing.T) { |
| + s, o := relServing(t) |
| + in := relStart(ReasonDrain, nil) |
| + in.HomeRegion = regionname.HomeRegion |
| + if _, err := StartRelocation(ctx, s, o, in, relAuthority{}); !errors.Is(err, ErrRelocation) { |
| + t.Fatalf("got %v", err) |
| + } |
| + if stored, err := Read(ctx, s, o.Host); err != nil || stored.ETag != o.ETag { |
| + t.Fatalf("refusal wrote the claim: %v", err) |
| + } |
| + }) |
| +} |
| + |
| +// The completion parks an abandoned P in the relocation's region unless a |
| +// park-generation for it is already owed there, under either of the home's |
| +// names when the op is bound to the home. |
| +func TestRelocationCompletionReusesAParkUnderTheHomesOtherName(t *testing.T) { |
| + ctx := context.Background() |
| + for _, home := range []string{"ie", ""} { |
| + t.Run("home "+home, func(t *testing.T) { |
| + s, o := relAtPhase(t, PhaseInstalling) |
| + o, err := ReplaceTarget(ctx, s, o, "rel-1", putFence(t, s, relTarget), nil, Target{relSpare, relEnrollment(relSpare)}, "P2", relAuthority{}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + o = step(t, s, o, func(c *Claim) error { |
| + if home != "" { |
| + c.Op.Intent["home_region"] = home |
| + } |
| + c.Obligations = append(c.Obligations, Obligation{ID: "earlier", Op: "mv-old", Kind: "park-generation", Region: regionname.HomeRegion, Owner: "region:" + regionname.HomeRegion, Generation: "P1", Retirement: "r-earlier"}) |
| + return nil |
| + }) |
| + o = step(t, s, o, ack(relSpare)) |
| + o = step(t, s, o, ToInstalled("rel-1", relPrepared("P2", 40, 7))) |
| + o = step(t, s, o, RecordLocalInstall("rel-1", localInstall(o.Claim))) |
| + o = step(t, s, o, ToProbing("rel-1", relObserved)) |
| + o = step(t, s, o, AdmitWriter(Admission{ObservedOpID: "rel-1", ObservedPhase: PhaseRelocatingProbing, ObservedSession: o.Claim.Holder.Session, ObservedGeneration: o.Claim.Holder.Generation, |
| + Box: relSpare, Mode: "cold", NewGeneration: "P2", KeepStamp: true, PredecessorCertified: true, Region: "ie", PredecessorRegion: "ie"})) |
| + o = step(t, s, o, RecordProbe("rel-1", Probe{Box: relSpare, Session: o.Claim.Holder.Session})) |
| + o = step(t, s, o, RecordProbeStop("rel-1", ProbeStop{Box: relSpare, Session: o.Claim.Holder.Session, Receipt: "fence-1", ReceiptSHA256: strings.Repeat("a", 64)})) |
| + o = step(t, s, o, CompleteRelocation("rel-1", counter())) |
| + var regions []string |
| + for _, ob := range o.Claim.Obligations { |
| + if ob.Kind == "park-generation" && ob.Generation == "P1" { |
| + regions = append(regions, ob.Region) |
| + } |
| + } |
| + want := []string{regionname.HomeRegion} |
| + if home == "" { |
| + want = append(want, "ie") // exact without a binding: a second chore in the roster name |
| + } |
| + slices.Sort(regions) |
| + slices.Sort(want) |
| + if !slices.Equal(regions, want) { |
| + t.Fatalf("P1's parks: %v, want %v", regions, want) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +// J3 into the home: the move names its destination park.HomeRegion and |
| +// the fence binding names the roster's home (uk here). They are one region |
| +// only under the move's home binding; the relocation's region is then the |
| +// roster name, and the commit's lane park is queued in park.HomeRegion, as |
| +// moves' own commit queues it. |
| +func TestJ3CommittedLandingIntoTheHomeNeedsTheMovesHomeBinding(t *testing.T) { |
| + ctx := context.Background() |
| + for _, bound := range []bool{true, false} { |
| + name := "bound" |
| + if !bound { |
| + name = "unbound" |
| + } |
| + t.Run(name, func(t *testing.T) { |
| + s, o := j3Landing(t, PhaseLandingProbed) |
| + o = step(t, s, o, func(c *Claim) error { |
| + c.Op.Intent["to"] = regionname.HomeRegion |
| + if bound { |
| + c.Op.Intent["home_region"] = "uk" |
| + } |
| + return nil |
| + }) |
| + ref := j3Fence(t, s) |
| + s.puts = 0 |
| + after, err := ConvertCommittedLanding(ctx, s, o, moveA, j3Target, ref, j3Authority{}, &j3Guard{to: regionname.HomeRegion}, nil, counter()) |
| + if !bound { |
| + if !errors.Is(err, ErrFence) || s.puts != 0 { |
| + t.Fatalf("got %v, writes %d", err, s.puts) |
| + } |
| + return |
| + } |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + c := after.Claim |
| + if c.Op.Intent["region"] != "uk" || c.Op.Intent["home_region"] != "uk" || c.Op.Intent["move_to_region"] != regionname.HomeRegion { |
| + t.Fatalf("intent %v", c.Op.Intent) |
| + } |
| + if owner, err := RelocationRecoveryOwner(c); err != nil || owner != "region:uk" { |
| + t.Fatalf("owner=%q error=%v", owner, err) |
| + } |
| + if k := obligationKinds(c); k["park-replica/ie"] != 1 || k["park-replica/"+regionname.HomeRegion] != 1 { |
| + t.Fatalf("the commit's chores: %v", k) |
| + } |
| + }) |
| + } |
| +} |
diff --git a/internal/hostclaim/relocation_test.go b/internal/hostclaim/relocation_test.go| index 0aa35ddc8..17adfdf96 100644 |
| --- a/internal/hostclaim/relocation_test.go |
| +++ b/internal/hostclaim/relocation_test.go |
| @@ -896,7 +896,7 @@ func TestRelocationStartGuard(t *testing.T) { |
| {"park-local on a fenced source", Obligation{Kind: "park-local", Box: relSource}, true, nil}, |
| } { |
| t.Run(tc.name, func(t *testing.T) { |
| - if err := RelocationStartGuard(serving(tc.ob), "ie", relSource, relTarget, tc.fenced); !errors.Is(err, tc.want) { |
| + if err := RelocationStartGuard(serving(tc.ob), "", "ie", relSource, relTarget, tc.fenced); !errors.Is(err, tc.want) { |
| t.Fatalf("got %v, want %v", err, tc.want) |
| } |
| }) |
diff --git a/internal/hostops/executor.go b/internal/hostops/executor.go| index d948393a1..d34440d20 100644 |
| --- a/internal/hostops/executor.go |
| +++ b/internal/hostops/executor.go |
| @@ -9,6 +9,7 @@ import ( |
| "github.com/carlosframework/platform/internal/boxchoice" |
| "github.com/carlosframework/platform/internal/hostactors" |
| "github.com/carlosframework/platform/internal/hostclaim" |
| + "github.com/carlosframework/platform/internal/park" |
| ) |
| // ErrNoEligibleBox is a create refused because no box in the placement is |
| @@ -86,6 +87,14 @@ func (x ExecutorChooser) Permits(ctx context.Context, obs hostclaim.Observed, r |
| // reduced to boxchoice.Eligible's candidates. |
| func (x ExecutorChooser) eligible(ctx context.Context, obs hostclaim.Observed, r Record) (hostactors.Observed, []boxchoice.Candidate, error) { |
| placement := r.Holder.Region |
| + // A record placed in the home names it park.HomeRegion when no home |
| + // name is configured on the console (main's claimPlacementRegion); the |
| + // roster, its boxes and their edges files name it by the home's roster |
| + // name. The one mapping is main's StartCreate's: park.HomeRegion is |
| + // HomeName when HomeName is a placement code. |
| + if placement == park.HomeRegion && x.HomeName != "" && park.PlacementRegion(x.HomeName) == x.HomeName { |
| + placement = x.HomeName |
| + } |
| if x.Edges == nil || x.Loads == nil || x.Now == nil || placement == "" { |
| return hostactors.Observed{}, nil, ErrContract |
| } |
diff --git a/internal/hostops/executor_test.go b/internal/hostops/executor_test.go| index fc61916d4..c874eac2f 100644 |
| --- a/internal/hostops/executor_test.go |
| +++ b/internal/hostops/executor_test.go |
| @@ -15,6 +15,7 @@ import ( |
| "github.com/carlosframework/platform/internal/boxchoice" |
| "github.com/carlosframework/platform/internal/hostactors" |
| "github.com/carlosframework/platform/internal/hostclaim" |
| + "github.com/carlosframework/platform/internal/park" |
| "github.com/carlosframework/platform/internal/release" |
| "github.com/carlosframework/platform/internal/termfence" |
| ) |
| @@ -394,3 +395,24 @@ func TestProductionCreateWithAChooserPlacesWithinPlan5sScope(t *testing.T) { |
| } |
| }) |
| } |
| + |
| +// Main's home-region parks: a record placed in the home with no home name |
| +// configured names it park.HomeRegion. With the home binding (HomeName, |
| +// the create door's ProductionAuthority.HomeRegion) the chooser places it |
| +// in the home's roster region, as main's StartCreate maps it; with none, |
| +// park.HomeRegion is no roster region and the create is refused. |
| +func TestExecutorChooserPlacesAHomeRecordUnderTheHomeBinding(t *testing.T) { |
| + ctx := context.Background() |
| + r := chooserRecord() |
| + r.Holder.Region = park.HomeRegion |
| + absent := hostclaim.Observed{Host: r.Holder.Host, Absent: true} |
| + x, _, _, _ := chooserFixture(t, "box-a") |
| + x.HomeName = "ie" |
| + if got, err := x.Choose(ctx, absent, r); err != nil || got.Box != "box-a" { |
| + t.Fatalf("bound: %+v %v", got, err) |
| + } |
| + x.HomeName = "" |
| + if _, err := x.Choose(ctx, absent, r); !errors.Is(err, ErrContract) { |
| + t.Fatalf("unbound: %v", err) |
| + } |
| +} |
diff --git a/internal/hostrelocate/pin.go b/internal/hostrelocate/pin.go| index 24d5ab6f9..01c7cacb2 100644 |
| --- a/internal/hostrelocate/pin.go |
| +++ b/internal/hostrelocate/pin.go |
| @@ -16,6 +16,7 @@ import ( |
| "github.com/carlosframework/platform/internal/hostclaim" |
| "github.com/carlosframework/platform/internal/hostfiles" |
| "github.com/carlosframework/platform/internal/instancekey" |
| + "github.com/carlosframework/platform/internal/park" |
| "github.com/carlosframework/platform/internal/release" |
| ) |
| @@ -91,6 +92,11 @@ type StorePinner struct { |
| SelectDB func(context.Context, genselect.Request) (*genselect.Plan, error) |
| Bucket string // the region's data bucket name, for genselect |
| Region string |
| + // HomeRegion is the box's verified home binding, or "": genselect sees |
| + // the holder's admissions through it (park.HolderRegionView), as main's |
| + // serving baseline does, so an admission recorded under the home's |
| + // other name is this region's. |
| + HomeRegion string |
| // Release is the app release version the host's relocation installs, |
| // read from the region controller's authoritative registry route for |
| // the host (registry.Route.Version, the route's deployed version) at |
| @@ -135,7 +141,7 @@ func (p StorePinner) Pin(ctx context.Context, obs hostclaim.Observed, now time.T |
| return hostclaim.RecoveryPin{}, fmt.Errorf("%w: the registry route for %s names no release", ErrRefused, c.Host) |
| } |
| obj := PinObject{V: 1, Host: c.Host, Op: c.Op.ID, ClaimGen: c.Gen, PinnedAt: now.UTC(), Release: version} |
| - plan, err := p.SelectDB(ctx, genselect.Request{Bucket: p.Bucket, Region: p.Region, Host: c.Host, Holder: *c.Holder}) |
| + plan, err := p.SelectDB(ctx, genselect.Request{Bucket: p.Bucket, Region: p.Region, Host: c.Host, Holder: park.HolderRegionView(*c.Holder, p.HomeRegion, p.Region)}) |
| switch { |
| case err == nil: |
| obj.DB = plan |
diff --git a/internal/hostrelocate/pin_test.go b/internal/hostrelocate/pin_test.go| index d9d8cc786..2c491498a 100644 |
| --- a/internal/hostrelocate/pin_test.go |
| +++ b/internal/hostrelocate/pin_test.go |
| @@ -15,6 +15,7 @@ import ( |
| "github.com/carlosframework/platform/internal/genselect" |
| "github.com/carlosframework/platform/internal/hostclaim" |
| "github.com/carlosframework/platform/internal/hostfiles" |
| + "github.com/carlosframework/platform/internal/park" |
| "github.com/carlosframework/platform/internal/release" |
| ) |
| @@ -750,3 +751,41 @@ func TestStorePinnerRefusesAManifestWithoutTheEnvOrWakeState(t *testing.T) { |
| }) |
| } |
| } |
| + |
| +// Main's home-region parks: an admission moves recorded in the home names |
| +// it park.HomeRegion. The pinner's selection sees the holder through the |
| +// box's home binding (park.HolderRegionView), as main's serving baseline |
| +// does, so that admission is this region's; with no binding the holder is |
| +// read exactly as stored. |
| +func TestStorePinnerSelectsThroughTheHomeBinding(t *testing.T) { |
| + ctx := context.Background() |
| + for _, home := range []string{"ie", ""} { |
| + t.Run("home "+home, func(t *testing.T) { |
| + claims, data := release.NewDirStore(t.TempDir()), release.NewDirStore(t.TempDir()) |
| + putManifest(t, data, "g1", keyed(hostfiles.Manifest{V: hostfiles.ManifestVersion, Seq: 9, Entries: map[string]hostfiles.Entry{}, Platform: map[string]hostfiles.Entry{}})) |
| + var seen []string |
| + p := StorePinner{Claims: claims, Data: data, Release: routeRelease, Bucket: "data", Region: "ie", HomeRegion: home, |
| + SelectDB: func(ctx context.Context, req genselect.Request) (*genselect.Plan, error) { |
| + for _, a := range req.Holder.Admissions { |
| + seen = append(seen, a.Region+"/"+a.PredecessorRegion) |
| + } |
| + return selectAt(41)(ctx, req) |
| + }} |
| + obs := pinClaim() |
| + obs.Claim.Holder.Admissions[1].Region, obs.Claim.Holder.Admissions[1].PredecessorRegion = park.HomeRegion, "uk" |
| + if _, err := p.Pin(ctx, obs, time.Date(2026, 10, 7, 9, 0, 0, 0, time.UTC)); err != nil { |
| + t.Fatal(err) |
| + } |
| + want := "@home/uk" |
| + if home != "" { |
| + want = "ie/uk" |
| + } |
| + if len(seen) != 2 || seen[1] != want { |
| + t.Fatalf("selection saw %v, want second %s", seen, want) |
| + } |
| + if obs.Claim.Holder.Admissions[1].Region != park.HomeRegion { |
| + t.Fatal("the view changed the claim's own holder") |
| + } |
| + }) |
| + } |
| +} |
diff --git a/internal/hostrelocate/worker.go b/internal/hostrelocate/worker.go| index 669261f7b..00bc62e79 100644 |
| --- a/internal/hostrelocate/worker.go |
| +++ b/internal/hostrelocate/worker.go |
| @@ -26,6 +26,7 @@ import ( |
| "github.com/carlosframework/platform/internal/hostactors" |
| "github.com/carlosframework/platform/internal/hostclaim" |
| "github.com/carlosframework/platform/internal/hostfence" |
| + "github.com/carlosframework/platform/internal/park" |
| "github.com/carlosframework/platform/internal/release" |
| "github.com/carlosframework/platform/internal/termfence" |
| ) |
| @@ -133,9 +134,15 @@ type Worker struct { |
| Discharge func(context.Context, hostactors.Box, termfence.Record) *hostclaim.LocalDischarge |
| // Notify bumps the control serial after an executor CAS (§5.3, |
| // "Prompt changes"). A failed bump costs cadence, never correctness. |
| - Notify func(context.Context) |
| - Self termfence.WrittenBy |
| - Region string |
| + Notify func(context.Context) |
| + Self termfence.WrittenBy |
| + Region string |
| + // HomeRegion is this box's verified home binding (hostbox.Actor's, the |
| + // fleet's home roster name when Region is it), or "" with no alias. It |
| + // is the one binding under which the home's two names, Region and |
| + // park.HomeRegion, are one region (park.SameRegion), as main's claim |
| + // owners read it (park.VerifiedHome against the op's "home_region"). |
| + HomeRegion string |
| Controller string |
| NewID func() string |
| Now func() time.Time |
| @@ -162,6 +169,11 @@ func (w Worker) self(ctx context.Context) (hostactors.Observed, error) { |
| if !ok || b.State != "active" || b.Enrollment != w.Self.Enrollment || b.Region != w.Region { |
| return hostactors.Observed{}, ErrNotWorker |
| } |
| + // A home binding is the signed roster's home, and only the home's own |
| + // boxes hold it (hostbox.IdentityProvider.Verify's rule). |
| + if w.HomeRegion != "" && (w.HomeRegion != o.Roster.HomeRegion || w.HomeRegion != w.Region) { |
| + return hostactors.Observed{}, ErrNotWorker |
| + } |
| fenced, err := w.Directory.TerminationFence(ctx, o, w.Self.Box, w.Now()) |
| if err != nil || fenced { |
| return hostactors.Observed{}, errors.Join(ErrNotWorker, err) |
| @@ -219,7 +231,17 @@ func (w Worker) ConvertLanding(ctx context.Context, obs hostclaim.Observed, guar |
| } |
| c := obs.Claim |
| box := c.Op.Exec |
| - if c.Op.Kind != hostclaim.KindLanding || c.Op.Intent["to"] != w.Region || box == "" || box == "none" { |
| + if c.Op.Kind != hostclaim.KindLanding || box == "" || box == "none" { |
| + return obs, nil |
| + } |
| + // The landing's "to" is an engine region (park.HomeRegion for the |
| + // home); this worker's Region is its roster name. They are one region |
| + // only under a home binding this box and the move agree on. |
| + home, err := park.VerifiedHome(o.Roster.HomeRegion, w.HomeRegion, w.Region, c.Op.Intent["home_region"]) |
| + if err != nil { |
| + return obs, err |
| + } |
| + if !park.SameRegion(home, c.Op.Intent["to"], w.Region) { |
| return obs, nil |
| } |
| ref, record, err := w.fence(ctx, o, box) |
| @@ -331,7 +353,7 @@ func (w Worker) Start(ctx context.Context, host string, req Request) (hostclaim. |
| if err := w.Admission.Recheck(ctx, admission); err != nil { |
| return hostclaim.Observed{}, err |
| } |
| - in := hostclaim.RelocationStart{OpID: id, By: "box:" + w.Self.Box, Now: w.Now(), Region: w.Region, Reason: req.Reason, |
| + in := hostclaim.RelocationStart{OpID: id, By: "box:" + w.Self.Box, Now: w.Now(), Region: w.Region, HomeRegion: w.HomeRegion, Reason: req.Reason, |
| SourceBox: req.Source, SourceEnrollment: source.Enrollment, TargetBox: req.Target, TargetEnrollment: target.Enrollment, |
| DirectoryRevision: revision, AdmissionID: admission, Fence: ref} |
| if ref != nil { |
| @@ -365,6 +387,11 @@ func (w Worker) Step(ctx context.Context, host string) (hostclaim.Observed, erro |
| if c.Op.Kind != hostclaim.KindRelocating || c.Op.Intent["region"] != w.Region || c.Op.Journal["stuck"] != "" { |
| return obs, nil |
| } |
| + // An op bound to a home names it in "home_region"; this box steps it |
| + // only under the same binding (park.VerifiedHome, main's rule). |
| + if _, err := park.VerifiedHome(o.Roster.HomeRegion, w.HomeRegion, w.Region, c.Op.Intent["home_region"]); err != nil { |
| + return obs, err |
| + } |
| id := c.Op.ID |
| // Evidence first: a fenced participant changes what every phase may do. |
| // A pass that refuses here checks the release deadline first (refused). |
diff --git a/internal/hostrelocate/worker_test.go b/internal/hostrelocate/worker_test.go| index 1c2908800..af20b469e 100644 |
| --- a/internal/hostrelocate/worker_test.go |
| +++ b/internal/hostrelocate/worker_test.go |
| @@ -17,6 +17,7 @@ import ( |
| "github.com/carlosframework/platform/internal/hostactors" |
| "github.com/carlosframework/platform/internal/hostclaim" |
| "github.com/carlosframework/platform/internal/hostfence" |
| + "github.com/carlosframework/platform/internal/park" |
| "github.com/carlosframework/platform/internal/release" |
| "github.com/carlosframework/platform/internal/termfence" |
| ) |
| @@ -977,3 +978,58 @@ func TestWorkerNamesAConvertedLandingsTargetBeforeItsPin(t *testing.T) { |
| t.Fatalf("the failover changed an obligation:\n%s\n%s", after, chores) |
| } |
| } |
| + |
| +// Main's home-region parks: a worker in the home holds the box's verified |
| +// home binding. It starts a relocation under it (the op's "home_region", |
| +// which lets the claim see the home's chores under park.HomeRegion too), |
| +// and only when the signed roster designates that home and the worker's |
| +// region is it. |
| +func TestWorkerStartsUnderItsHomeBinding(t *testing.T) { |
| + ctx := context.Background() |
| + t.Run("designated", func(t *testing.T) { |
| + f := newFixture(t) |
| + f.roster.HomeRegion = "ie" |
| + f.writeRoster() |
| + f.w.HomeRegion = "ie" |
| + o, err := f.w.Start(ctx, host, Request{Reason: hostclaim.ReasonDrain, Source: "src", Target: "dst"}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if o.Claim.Op.Intent["home_region"] != "ie" || o.Claim.Op.Intent["region"] != "ie" { |
| + t.Fatalf("intent %v", o.Claim.Op.Intent) |
| + } |
| + }) |
| + t.Run("not designated", func(t *testing.T) { |
| + f := newFixture(t) |
| + f.w.HomeRegion = "ie" |
| + before := f.claim() |
| + if _, err := f.w.Start(ctx, host, Request{Reason: hostclaim.ReasonDrain, Source: "src", Target: "dst"}); !errors.Is(err, ErrNotWorker) { |
| + t.Fatalf("got %v", err) |
| + } |
| + if after := f.claim(); after.ETag != before.ETag { |
| + t.Fatal("a refused start wrote the claim") |
| + } |
| + }) |
| + t.Run("an op bound to another home is not stepped", func(t *testing.T) { |
| + f := newFixture(t) |
| + o, err := f.w.Start(ctx, host, Request{Reason: hostclaim.ReasonDrain, Source: "src", Target: "dst"}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + f.ack("src") |
| + f.ack("dst") |
| + o, err = hostclaim.Update(ctx, f.s, f.claim(), func(c *hostclaim.Claim) error { |
| + c.Op.Intent["home_region"] = "uk" |
| + return nil |
| + }) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := f.w.Step(ctx, host); !errors.Is(err, park.ErrUnsafe) { |
| + t.Fatalf("got %v", err) |
| + } |
| + if after := f.claim(); after.ETag != o.ETag { |
| + t.Fatal("the step wrote the claim") |
| + } |
| + }) |
| +} |