moves-home-parks
Squashed from moves-home-parks into main.
33 files changed,
+1027
−94
internal/console/apiinstancemove.go+26 −5internal/console/apiinstancemove_claim_test.go+75 −0internal/console/movesrecovery.go+3 −2internal/host/movesabort.go+2 −1internal/host/movescut.go+2 −1internal/host/movescut_test.go+2 −2internal/host/movesfinal.go+3 −3internal/host/movesfinal_test.go+2 −2internal/host/moveshome_region_test.go+46 −0internal/host/moveshome_test.go+227 −0internal/host/movesinstall.go+2 −2internal/host/movesland_probe_test.go+5 −5internal/host/movesland_test.go+2 −2internal/host/moveslandcommit.go+2 −1internal/host/moveslandpark.go+1 −1internal/host/movesparkfakes_test.go+2 −2internal/host/movesparks_test.go+2 −2internal/host/movespass.go+19 −1internal/host/movesphase1_harness_test.go+23 −7internal/host/movespolicy.go+2 −1internal/host/movesprobeowners.go+10 −0internal/host/movesrecover.go+1 −1internal/host/movesrevert.go+9 −7internal/host/movesrollback.go+5 −5internal/host/movessources.go+84 −25internal/host/movessources_test.go+29 −11internal/hostops/move_fence_release.go+6 −2internal/hostops/move_recovery_commit_test.go+53 −0internal/hostops/move_recovery_grant.go+5 −1internal/hostops/plan12_fence_home_test.go+75 −0internal/park/home_region_test.go+262 −0internal/park/region.go+38 −0internal/provision/plan9_b8_census_test.go+2 −2
CI passed — run details
CI log
=== gate ===
cd launchctx && go vet ./... && go test ./... -count=1
ok amadan.net/carlos/platform.git/launchctx 0.013s
? amadan.net/carlos/platform.git/launchctx/controlname [no test files]
? amadan.net/carlos/platform.git/launchctx/wireid [no test files]
go vet ./...
gofmt -l .: clean
go test ./... -count=1 -timeout 30m
ok github.com/carlosframework/platform 345.500s
? github.com/carlosframework/platform/cmd/changelog [no test files]
ok github.com/carlosframework/platform/cmd/console 4.360s
ok github.com/carlosframework/platform/cmd/moveprobe 94.261s
ok github.com/carlosframework/platform/internal/activator 212.632s
ok github.com/carlosframework/platform/internal/agentsock 0.031s
ok github.com/carlosframework/platform/internal/alerting 0.009s
ok github.com/carlosframework/platform/internal/analytics 0.053s
ok github.com/carlosframework/platform/internal/appconfig 2.814s
ok github.com/carlosframework/platform/internal/apperr 0.120s
ok github.com/carlosframework/platform/internal/applog 0.252s
ok github.com/carlosframework/platform/internal/awsinv 0.005s
ok github.com/carlosframework/platform/internal/boxchoice 0.012s
ok github.com/carlosframework/platform/internal/boxid 0.580s
? github.com/carlosframework/platform/internal/boxid/boxidtest [no test files]
? github.com/carlosframework/platform/internal/boxid/internal/signtest [no test files]
ok github.com/carlosframework/platform/internal/boxvolumes 0.009s
ok github.com/carlosframework/platform/internal/caps 0.014s
ok github.com/carlosframework/platform/internal/certwarm 0.005s
ok github.com/carlosframework/platform/internal/cgroup 0.205s
ok github.com/carlosframework/platform/internal/changelog 0.446s
ok github.com/carlosframework/platform/internal/clamd 0.061s
? github.com/carlosframework/platform/internal/cliflag [no test files]
ok github.com/carlosframework/platform/internal/console 362.720s
? github.com/carlosframework/platform/internal/controlname [no test files]
? github.com/carlosframework/platform/internal/controlsock [no test files]
ok github.com/carlosframework/platform/internal/dbdoor 6.056s
ok github.com/carlosframework/platform/internal/dbdoor/dbdoortest 0.021s
ok github.com/carlosframework/platform/internal/dbverify 0.010s
ok github.com/carlosframework/platform/internal/dnsrec 0.007s
ok github.com/carlosframework/platform/internal/docsite 0.007s
ok github.com/carlosframework/platform/internal/domains 2.708s
ok github.com/carlosframework/platform/internal/doorkey 0.007s
ok github.com/carlosframework/platform/internal/economics 0.005s
ok github.com/carlosframework/platform/internal/edge 27.044s
? github.com/carlosframework/platform/internal/edgeproof [no test files]
ok github.com/carlosframework/platform/internal/edgesfile 0.012s
ok github.com/carlosframework/platform/internal/fleetapi 0.290s
ok github.com/carlosframework/platform/internal/gate 2.144s
ok github.com/carlosframework/platform/internal/genselect 0.030s
ok github.com/carlosframework/platform/internal/geo 0.229s
ok github.com/carlosframework/platform/internal/geoip 0.050s
ok github.com/carlosframework/platform/internal/host 57.357s
ok github.com/carlosframework/platform/internal/hostactors 0.791s
ok github.com/carlosframework/platform/internal/hostbox 1.729s
ok github.com/carlosframework/platform/internal/hostclaim 0.830s
ok github.com/carlosframework/platform/internal/hostevidence 0.007s
ok github.com/carlosframework/platform/internal/hostexec 0.407s
ok github.com/carlosframework/platform/internal/hostfence 0.206s
ok github.com/carlosframework/platform/internal/hostfiles 4.376s
ok github.com/carlosframework/platform/internal/hostfloor 0.012s
ok github.com/carlosframework/platform/internal/hostguard 0.240s
ok github.com/carlosframework/platform/internal/hostops 2.380s
? github.com/carlosframework/platform/internal/hostops/fencerelease [no test files]
ok github.com/carlosframework/platform/internal/hostops/internal/fencerelease 0.008s
ok github.com/carlosframework/platform/internal/hostquarantine 0.004s
ok github.com/carlosframework/platform/internal/hostresolve 0.431s
ok github.com/carlosframework/platform/internal/hostwrite 0.008s
ok github.com/carlosframework/platform/internal/infraview 0.002s
ok github.com/carlosframework/platform/internal/instancectl 1.714s
ok github.com/carlosframework/platform/internal/instanceexec 2.794s
ok github.com/carlosframework/platform/internal/instancefs 0.007s
ok github.com/carlosframework/platform/internal/instancekey 0.006s
ok github.com/carlosframework/platform/internal/instanceupdates 0.013s
ok github.com/carlosframework/platform/internal/layout 2.077s
ok github.com/carlosframework/platform/internal/lease 9.520s
ok github.com/carlosframework/platform/internal/lease/leasetest 0.007s
ok github.com/carlosframework/platform/internal/ledger 0.016s
ok github.com/carlosframework/platform/internal/mail 0.451s
ok github.com/carlosframework/platform/internal/maintenance 0.097s
ok github.com/carlosframework/platform/internal/maintenanceexec 5.741s
ok github.com/carlosframework/platform/internal/maintenanceguard 0.311s
ok github.com/carlosframework/platform/internal/metrics 0.066s
ok github.com/carlosframework/platform/internal/moves 22.777s
ok github.com/carlosframework/platform/internal/moves/archive 1.233s
ok github.com/carlosframework/platform/internal/objstore 0.448s
ok github.com/carlosframework/platform/internal/ops 0.275s
ok github.com/carlosframework/platform/internal/park 0.283s
ok github.com/carlosframework/platform/internal/privatefile 0.003s
ok github.com/carlosframework/platform/internal/provision 4.005s
ok github.com/carlosframework/platform/internal/publicrelease 0.012s
ok github.com/carlosframework/platform/internal/registry 4.152s
ok github.com/carlosframework/platform/internal/relay 7.292s
ok github.com/carlosframework/platform/internal/release 20.854s
ok github.com/carlosframework/platform/internal/rollout 1.944s
ok github.com/carlosframework/platform/internal/rolloutwords 0.004s
ok github.com/carlosframework/platform/internal/rootaws 0.008s
ok github.com/carlosframework/platform/internal/s3compat 0.005s
ok github.com/carlosframework/platform/internal/safefile 0.004s
ok github.com/carlosframework/platform/internal/schedule 2.681s
ok github.com/carlosframework/platform/internal/sim/hostlife 6.636s
ok github.com/carlosframework/platform/internal/sim/litestreamfacts 3.845s
ok github.com/carlosframework/platform/internal/status 0.054s
ok github.com/carlosframework/platform/internal/steering 0.008s
ok github.com/carlosframework/platform/internal/stopevidence 0.269s
ok github.com/carlosframework/platform/internal/svcrole 0.004s
ok github.com/carlosframework/platform/internal/telemetry 0.004s
ok github.com/carlosframework/platform/internal/termfence 0.004s
ok github.com/carlosframework/platform/internal/tunnelclient 0.006s
ok github.com/carlosframework/platform/internal/unixdial 0.006s
ok github.com/carlosframework/platform/internal/updatewords 0.003s
ok github.com/carlosframework/platform/internal/usage 0.016s
ok github.com/carlosframework/platform/internal/version 0.002s
ok github.com/carlosframework/platform/internal/vet 1.905s
=== cross ===
--- shard 0 of 5 ---
cross-compiling darwin/arm64
--- shard 1 of 5 ---
cross-compiling darwin/amd64
--- shard 2 of 5 ---
cross-compiling linux/arm64
--- shard 3 of 5 ---
cross-compiling windows/amd64
--- shard 4 of 5 ---
cross-compiling windows/arm64
=== minio-contract ===
minio contract: arm64, minio RELEASE.2025-09-07T16-13-09Z, mc RELEASE.2025-08-13T08-35-41Z
minio: cached, digest verified
mc: cached, digest verified
minio healthy on 127.0.0.1:20999
Added `local` successfully.
Bucket created successfully `local/contract-bucket`.
Bucket created successfully `local/denied-bucket`.
Created policy `contract-only` successfully.
Added user `scopeduser` successfully.
Attached Policies: [contract-only]
To User: scopeduser
buckets and scoped user ready
=== RUN TestDeliveredContractMinio
=== RUN TestDeliveredContractMinio/put
=== RUN TestDeliveredContractMinio/get_round-trips_the_body
=== RUN TestDeliveredContractMinio/list_shows_the_key
=== RUN TestDeliveredContractMinio/head_and_list_agree_on_the_version
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_its_own_object
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/not_by_PutObjectTagging
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/and_not_by_x-amz-tagging_on_upload
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CreateMultipartUpload
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CopyObject_with_a_REPLACE_tagging_directive
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/but_multipart_itself_is_allowed
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_copy_itself_is_allowed
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/a_POST-policy_forged_tag_with_a_matching_version_is_still_not_a_verdict
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_an_ordinary_tagged-with-something-else_upload_is_allowed
=== RUN TestDeliveredContractMinio/delete_removes_it
=== RUN TestDeliveredContractMinio/cross-bucket_denial
--- PASS: TestDeliveredContractMinio (0.02s)
--- PASS: TestDeliveredContractMinio/put (0.00s)
--- PASS: TestDeliveredContractMinio/get_round-trips_the_body (0.00s)
--- PASS: TestDeliveredContractMinio/list_shows_the_key (0.00s)
--- PASS: TestDeliveredContractMinio/head_and_list_agree_on_the_version (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_its_own_object (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/not_by_PutObjectTagging (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/and_not_by_x-amz-tagging_on_upload (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route (0.01s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CreateMultipartUpload (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CopyObject_with_a_REPLACE_tagging_directive (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/but_multipart_itself_is_allowed (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_copy_itself_is_allowed (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/a_POST-policy_forged_tag_with_a_matching_version_is_still_not_a_verdict (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_an_ordinary_tagged-with-something-else_upload_is_allowed (0.00s)
--- PASS: TestDeliveredContractMinio/delete_removes_it (0.00s)
--- PASS: TestDeliveredContractMinio/cross-bucket_denial (0.00s)
PASS
ok github.com/carlosframework/platform/internal/objstore 0.024s
=== RUN TestT4StorageProbeMinio
2026/10/12 09:22:26 moves: T4: STORE: list: operation error S3: ListObjectsV2, https response error StatusCode: 403, RequestID: 18DDBDD45BCB13B6, HostID: 7c420053e0fbcd7f41653fdd92ffa2802c903d05303b1aa15adc2e8bb65d183f, api error SignatureDoesNotMatch: The request signature we calculated does not match the signature you provided. Check your key and signing method.
--- PASS: TestT4StorageProbeMinio (0.01s)
PASS
ok github.com/carlosframework/platform/internal/host 0.017s
minio contract: PASS
diff --git a/internal/console/apiinstancemove.go b/internal/console/apiinstancemove.go| index 9f44c8790..3f6a0af3b 100644 |
| --- a/internal/console/apiinstancemove.go |
| +++ b/internal/console/apiinstancemove.go |
| @@ -250,7 +250,15 @@ func stagingCodes(caps map[string]placementCapability) string { |
| // record's "" (the unconstrained value the fleet's reconcile has always |
| // scoped by). The record NEVER stores the code; the API never shows "". |
| func (s *Server) homeRegionIn(region string) string { |
| - if s.cfg.HomeRegion != "" && region == s.cfg.HomeRegion { |
| + return homePlacementCode(s.cfg.HomeRegion, region) |
| +} |
| + |
| +// homePlacementCode is homeRegionIn's rule for a given home name: the |
| +// home's name is the record's "", any other region is itself, and with no |
| +// home named nothing is the home. claimMoveRosterPlaces reads roster |
| +// regions through it too, so the door and the API translate home one way. |
| +func homePlacementCode(home, region string) string { |
| + if home != "" && region == home { |
| return "" |
| } |
| return region |
| @@ -761,7 +769,7 @@ func (s *Server) startClaimModeMove(w http.ResponseWriter, r *http.Request, acct |
| ctx := r.Context() |
| a := s.cfg.ClaimAuthority |
| to := ms.To |
| - if !claimMoveRosterPlaces(roster.Roster, obs.Claim.Op.Exec, target, recRegion, to) { |
| + if !claimMoveRosterPlaces(roster.Roster, obs.Claim.Op.Exec, target, recRegion, to, s.cfg.HomeRegion) { |
| jsonError(w, http.StatusConflict, "the host roster does not allow this move. the source and target must be active boxes in "+from+" and "+to) |
| return |
| } |
| @@ -1252,11 +1260,24 @@ func phase1Conflict(err error) string { |
| // the door's own observation: both boxes have active, enrolled entries |
| // under their box references, they differ, the source is in the record's |
| // region and the target in to, which is not the source's region. |
| -func claimMoveRosterPlaces(r hostactors.Roster, source, target, recRegion, to string) bool { |
| +// |
| +// The roster and the record name regions differently for one region, the |
| +// home: the roster refuses an empty region, so a home box is enrolled under |
| +// the home's name (CONSOLE_HOME_REGION, as boxchoice reads it), while the |
| +// record keeps "". Both boxes' roster regions are read as record codes |
| +// through homePlacementCode with home, the deployment's home name (the rule |
| +// homeRegionIn applies), so a move out of home is placed and a named region |
| +// is compared as it is. With no home named nothing translates, and a home |
| +// source is refused as before (home-region parks, part 1). |
| +func claimMoveRosterPlaces(r hostactors.Roster, source, target, recRegion, to, home string) bool { |
| src, sourceOK := r.Boxes[source] |
| dst, targetOK := r.Boxes[target] |
| - return sourceOK && targetOK && source != target && src.State == "active" && dst.State == "active" && src.Enrollment != "" && dst.Enrollment != "" && |
| - src.Region != "" && src.Region == recRegion && dst.Region == to && src.Region != to |
| + if !sourceOK || !targetOK { |
| + return false |
| + } |
| + from, into := homePlacementCode(home, src.Region), homePlacementCode(home, dst.Region) |
| + return source != target && src.State == "active" && dst.State == "active" && src.Enrollment != "" && dst.Enrollment != "" && |
| + src.Region != "" && dst.Region != "" && from == recRegion && into == to && from != to |
| } |
| // sourceEnrollment is the enrollment of the box that executes the claim, and |
diff --git a/internal/console/apiinstancemove_claim_test.go b/internal/console/apiinstancemove_claim_test.go| index 809bdf01a..c50af8914 100644 |
| --- a/internal/console/apiinstancemove_claim_test.go |
| +++ b/internal/console/apiinstancemove_claim_test.go |
| @@ -550,3 +550,78 @@ func TestMoveDoorHostClaimRetryAfterAFailedRecordStart(t *testing.T) { |
| t.Fatal("the failed attempt's registration is still held") |
| } |
| } |
| + |
| +// Home-region parks, part 1 (Paul's ruling on concern 2): the host-claim |
| +// door places a move out of home. The record names home "" and the roster |
| +// names the home box by the home's name (CONSOLE_HOME_REGION, "ie" here: |
| +// the roster refuses an empty region); the door compares them through |
| +// homeRegionIn, the one translation between the two, so the roster check |
| +// passes and the move registers. It then fails closed, writing nothing to |
| +// the claim or the record, at hostops.BeginClaimMove's own roster check, |
| +// which compares the record's "" with the roster's "ie" literally (claim |
| +// side, platform-1e, #236). |
| +func TestMoveDoorHostClaimPlacesAHomeSource(t *testing.T) { |
| + ctx := context.Background() |
| + fx, token := operatorInstanceFixture(t, "code-inst-move-home") |
| + fx.srv.cfg.HomeRegion = "ie" |
| + m := titoManifest() |
| + m.Regions = nil |
| + m.Pool = "" |
| + enableInstances(t, fx, "titogo", m) |
| + u := instancesURL(fx, "bab", "titogo") |
| + if c, _, out := rawReq(t, http.MethodPost, u, token, `{"host":"acme.go.tito.io","region":"ie"}`); c != http.StatusCreated { |
| + t.Fatalf("create = %d %s", c, out) |
| + } |
| + armMoveFleet(t, fx, ieBox("ie-box"), ukBox("uk-box")) |
| + claimFor(t, fx, "acme.go.tito.io", "ie-box", "ie") |
| + if rec, err := provision.GetRecord(ctx, fx.rel, "bab", "titogo", "acme.go.tito.io"); err != nil || rec.Region != "" { |
| + t.Fatalf("fixture: the record is not home's: %+v %v", rec, err) |
| + } |
| + armClaimMoves(t, fx, true, true, nil) |
| + claim := claimBytes(t, fx, "acme.go.tito.io") |
| + code, _, out := rawReq(t, http.MethodPost, u+"/acme.go.tito.io/move", token, `{"region":"uk"}`) |
| + if msg := errorText(out); strings.HasPrefix(msg, "the host roster does not allow this move") { |
| + t.Fatalf("the door's roster check refused a home source: %d %s", code, out) |
| + } |
| + if code != http.StatusConflict || errorText(out) != "the instance or its boxes changed under this request. read its state and try again" { |
| + t.Fatalf("request = %d %s, want BeginClaimMove's fail-closed refusal", code, out) |
| + } |
| + a, err := hostops.ReadAdmission(ctx, fx.rel, "bab", "titogo", claimMoveAppID) |
| + if err != nil || len(a.Value.Registrations) != 1 { |
| + t.Fatalf("the move did not register past the door's roster check: %+v %v", a.Value.Registrations, err) |
| + } |
| + if claimBytes(t, fx, "acme.go.tito.io") != claim { |
| + t.Fatal("the refused move wrote the claim") |
| + } |
| + if rec, err := provision.GetRecord(ctx, fx.rel, "bab", "titogo", "acme.go.tito.io"); err != nil || rec.Move != nil { |
| + t.Fatalf("the refused move journaled a move: %+v %v", rec, err) |
| + } |
| +} |
| + |
| +// claimMoveRosterPlaces reads roster regions through the console's home |
| +// translation (homePlacementCode, homeRegionIn's rule): the home's name is |
| +// the record's "", a named region is itself, and with no home named |
| +// nothing is home. |
| +func TestClaimMoveRosterPlacesTranslatesHome(t *testing.T) { |
| + box := func(b fleetBox) hostactors.Box { return moveRosterBox(b) } |
| + r := hostactors.Roster{Boxes: map[string]hostactors.Box{"ie-box": box(ieBox("ie-box")), "uk-box": box(ukBox("uk-box"))}} |
| + home, unnamed := "ie", "" |
| + for _, tc := range []struct { |
| + name string |
| + in string |
| + recRegion string |
| + to string |
| + ok bool |
| + }{ |
| + {"home source, home named", home, "", "uk", true}, |
| + {"home source, no home named", unnamed, "", "uk", false}, |
| + {"named source, home named elsewhere", "de", "ie", "uk", true}, |
| + {"named source, no home named", unnamed, "ie", "uk", true}, |
| + {"the record says ie but ie is home", home, "ie", "uk", false}, |
| + {"target in the source's region", home, "", "", false}, |
| + } { |
| + if got := claimMoveRosterPlaces(r, "ie-box", "uk-box", tc.recRegion, tc.to, tc.in); got != tc.ok { |
| + t.Fatalf("%s: %v, want %v", tc.name, got, tc.ok) |
| + } |
| + } |
| +} |
diff --git a/internal/console/movesrecovery.go b/internal/console/movesrecovery.go| index 2a04b2192..7ea56e4d3 100644 |
| --- a/internal/console/movesrecovery.go |
| +++ b/internal/console/movesrecovery.go |
| @@ -17,6 +17,7 @@ import ( |
| "github.com/carlosframework/platform/internal/hostfence" |
| "github.com/carlosframework/platform/internal/hostops" |
| "github.com/carlosframework/platform/internal/moves" |
| + "github.com/carlosframework/platform/internal/park" |
| "github.com/carlosframework/platform/internal/provision" |
| ) |
| @@ -197,7 +198,7 @@ func decideMoveRecovery(in moveRecoveryInput) moveRecoveryDecision { |
| case m.State == provision.MoveLanding && op.Kind == hostclaim.KindLanding && op.Phase != hostclaim.PhaseCommitted && moveDeadlinePassed(m, in.Now): |
| d.Action, d.Record = moveActRecord, hostops.RecoveryMoveDeadlineRollback |
| case m.State == provision.MoveRollingBack && rb != nil && rb.TargetPhase == provision.RbTargetReleased && !rb.Dirty && rb.TargetParkedAt == nil && |
| - hostclaim.LostMoveTargetParked(c, m.To): |
| + hostclaim.LostMoveTargetParked(c, park.PlacementRegion(m.To)): |
| d.Action = moveActStampLost |
| case owed > 0: |
| case m.State == provision.MoveCommitted && m.OpenedAt != nil && m.SourceParkedAt != nil && rec.Region == m.To && op.Kind == hostclaim.KindLanding && op.Phase == hostclaim.PhaseCommitted: |
| @@ -365,7 +366,7 @@ func (s *Server) movesRecovery(ctx context.Context, g *hostops.MoveRecoveryGrant |
| s.pageMove(ctx, movePageCloseOverdue, o, c, rec, now) |
| return nil |
| case moveActAbortOverdue: |
| - if s.moveAbortOverdue(o, c.QuietAfter[rec.Move.To], now) { |
| + if s.moveAbortOverdue(o, c.QuietAfter[park.PlacementRegion(rec.Move.To)], now) { |
| s.pageMove(ctx, movePageAbortOverdue, o, c, rec, now) |
| } |
| return nil |
diff --git a/internal/host/movesabort.go b/internal/host/movesabort.go| index 98742ef20..6e3b0097a 100644 |
| --- a/internal/host/movesabort.go |
| +++ b/internal/host/movesabort.go |
| @@ -12,6 +12,7 @@ import ( |
| "github.com/carlosframework/platform/internal/hostclaim" |
| "github.com/carlosframework/platform/internal/lease" |
| "github.com/carlosframework/platform/internal/moves" |
| + "github.com/carlosframework/platform/internal/park" |
| "github.com/carlosframework/platform/internal/provision" |
| "github.com/carlosframework/platform/internal/release" |
| ) |
| @@ -234,7 +235,7 @@ func (w *movesWorker) abortClaim(ctx context.Context, s release.Store, rec provi |
| } |
| } |
| scope := moves.AbortScope(host, id, obs.Claim.Holder.Incarnation, bound) |
| - if _, err := hostclaim.Update(ctx, s, obs, hostclaim.Abort(id, rec.Move.To, scope, obligationID)); err != nil { |
| + if _, err := hostclaim.Update(ctx, s, obs, hostclaim.Abort(id, park.PlacementRegion(rec.Move.To), scope, obligationID)); err != nil { |
| return false, fmt.Errorf("abort the claim: %w", err) |
| } |
| log.Printf("moves: %s: the claim aborts move %s; the staging lane in %s is queued for its park there", host, id, rec.Move.To) |
diff --git a/internal/host/movescut.go b/internal/host/movescut.go| index 3251bc138..c0d6f8c8b 100644 |
| --- a/internal/host/movescut.go |
| +++ b/internal/host/movescut.go |
| @@ -20,6 +20,7 @@ import ( |
| "github.com/carlosframework/platform/internal/hostclaim" |
| "github.com/carlosframework/platform/internal/lease" |
| "github.com/carlosframework/platform/internal/moves" |
| + "github.com/carlosframework/platform/internal/park" |
| "github.com/carlosframework/platform/internal/provision" |
| "github.com/carlosframework/platform/internal/registry" |
| "github.com/carlosframework/platform/internal/release" |
| @@ -1291,7 +1292,7 @@ func (w *movesWorker) revertClaim(ctx context.Context, s release.Store, rec prov |
| } |
| } |
| scope := moves.StagingScope(rec.Host, id, obs.Claim.Holder.Incarnation, bound) |
| - if _, err := hostclaim.Update(ctx, s, obs, hostclaim.Revert(id, cfg.Box, rec.Move.To, &scope, obligationID)); err != nil { |
| + if _, err := hostclaim.Update(ctx, s, obs, hostclaim.Revert(id, cfg.Box, park.PlacementRegion(rec.Move.To), &scope, obligationID)); err != nil { |
| return fmt.Errorf("revert the claim: %w", err) |
| } |
| log.Printf("moves: %s: the claim reverts to this box; the staging lane in %s is queued for its park", rec.Host, rec.Move.To) |
diff --git a/internal/host/movescut_test.go b/internal/host/movescut_test.go| index 0d8226ce0..fb10f2292 100644 |
| --- a/internal/host/movescut_test.go |
| +++ b/internal/host/movescut_test.go |
| @@ -91,9 +91,9 @@ var _ lease.FilePutIfAbsent = (*fakeVersioned)(nil) |
| const lastTXKey = "0000/0000000000000003-0000000000000003.ltx" |
| -func newCutHarness(t *testing.T) *cutHarness { |
| +func newCutHarness(t *testing.T, opts ...p1Option) *cutHarness { |
| t.Helper() |
| - h := &cutHarness{phase1Harness: newPhase1Harness(t), ev: &fakeEvidence{stopped: true}, localPos: 3, |
| + h := &cutHarness{phase1Harness: newPhase1Harness(t, opts...), ev: &fakeEvidence{stopped: true}, localPos: 3, |
| bundle: map[string]string{"default": "e1", "environment": ""}} |
| h.timers = []registry.Timer{{Host: p1Host, Name: "nightly", Kind: "cron", Spec: "0 3 * * *", NextAt: 1000}} |
| last := ltxForTest(t, 4096, 4, 3, 3) |
diff --git a/internal/host/movesfinal.go b/internal/host/movesfinal.go| index c6401c950..0689533fe 100644 |
| --- a/internal/host/movesfinal.go |
| +++ b/internal/host/movesfinal.go |
| @@ -387,12 +387,12 @@ func (f *sourceFinal) engine(vs lease.VersionedStore) park.Engine { |
| if err := f.sourceOf(obs); err != nil { |
| return err |
| } |
| - if o.Op != f.g.ID || o.Region != cfg.Region { |
| + if o.Op != f.g.ID || o.Region != cfg.parkRegion() { |
| return fmt.Errorf("%w: not this finalization's chore", park.ErrUnsafe) |
| } |
| return nil |
| } |
| - return park.Engine{Store: vs, Claims: f.s, Bucket: cfg.OwnBucket, Region: cfg.Region, Leases: cfg.ParkLeases, |
| + return park.Engine{Store: vs, Claims: f.s, Bucket: cfg.OwnBucket, Region: cfg.parkRegion(), Leases: cfg.ParkLeases, |
| Now: f.now, NewID: obligationID, View: cfg.ParkView, Authorize: movesArmAuthorize(cfg.ParkAuthorize, own), After: cfg.parkFault} |
| } |
| @@ -406,7 +406,7 @@ func (f *sourceFinal) remote(ctx context.Context, obs hostclaim.Observed) (bool, |
| var chore *hostclaim.Obligation |
| for i := range obs.Claim.Obligations { |
| o := &obs.Claim.Obligations[i] |
| - if o.Op == id && o.Kind == "park-replica" && o.Region == cfg.Region && o.Park != nil && !isStagingLocation(o.Park.Location) { |
| + if o.Op == id && o.Kind == "park-replica" && o.Region == cfg.parkRegion() && o.Park != nil && !isStagingLocation(o.Park.Location) { |
| chore = o |
| } |
| } |
diff --git a/internal/host/movesfinal_test.go b/internal/host/movesfinal_test.go| index c80997ab3..08014d0bb 100644 |
| --- a/internal/host/movesfinal_test.go |
| +++ b/internal/host/movesfinal_test.go |
| @@ -45,9 +45,9 @@ type finalHarness struct { |
| deletes int // DeleteHeld calls that deleted |
| } |
| -func newFinalHarness(t *testing.T) *finalHarness { |
| +func newFinalHarness(t *testing.T, opts ...p1Option) *finalHarness { |
| t.Helper() |
| - h := &finalHarness{landingHarness: newLandingHarness(t)} |
| + h := &finalHarness{landingHarness: newLandingHarness(t, opts...)} |
| h.flip(t) |
| h.land(t) |
| h.installed(t) |
diff --git a/internal/host/moveshome_region_test.go b/internal/host/moveshome_region_test.go| new file mode 100644 |
| index 000000000..cbafb5a28 |
| --- /dev/null |
| +++ b/internal/host/moveshome_region_test.go |
| @@ -0,0 +1,46 @@ |
| +package host |
| + |
| +import ( |
| + "errors" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/hostclaim" |
| + "github.com/carlosframework/platform/internal/park" |
| + "github.com/carlosframework/platform/internal/provision" |
| +) |
| + |
| +// TestMovesBoxRegionIsThePlacements: the park view's region is this box's |
| +// as the claim knows it, and it must be the engine region of this box's own |
| +// placement code. A home box whose claim names home any other way (the |
| +// roster's region, a named code, the unset region) refuses rather than |
| +// reading a home record from a region that is not HomeRegion. |
| +func TestMovesBoxRegionIsThePlacements(t *testing.T) { |
| + for _, tc := range []struct { |
| + placement, claims string |
| + ok bool |
| + }{ |
| + {"", park.HomeRegion, true}, |
| + {"uk", "uk", true}, |
| + {"", "ie", false}, |
| + {"", "", false}, |
| + {"uk", "ie", false}, |
| + {"uk", park.HomeRegion, false}, |
| + {"IE", "IE", false}, |
| + {park.HomeRegion, park.HomeRegion, false}, |
| + } { |
| + f := newVwFixture(t) |
| + f.rec.Region = "" |
| + f.rec.Move = &provision.MoveState{ID: vwMove, Incarnation: f.inc, From: "", To: "uk", State: provision.MoveCutting} |
| + obs := vwObs(&hostclaim.Claim{Holder: f.holder(""), Op: hostclaim.Op{Kind: hostclaim.KindMoving, ID: vwMove, Phase: hostclaim.PhaseMoving, Exec: "box"}}) |
| + v, err := movesParkViewAt(f.records(), f.bucket, "data", movesBoxRegion(tc.placement, fixedRegion(tc.claims)))(t.Context(), obs) |
| + if tc.ok != (err == nil) { |
| + t.Fatalf("placement %q, claim region %q: view %+v, %v", tc.placement, tc.claims, v, err) |
| + } |
| + if tc.ok && v.Region != tc.claims { |
| + t.Fatalf("placement %q: the view's region %q", tc.placement, v.Region) |
| + } |
| + if !tc.ok && !errors.Is(err, park.ErrUnsafe) { |
| + t.Fatalf("placement %q, claim region %q: %v, want ErrUnsafe", tc.placement, tc.claims, err) |
| + } |
| + } |
| +} |
diff --git a/internal/host/moveshome_test.go b/internal/host/moveshome_test.go| new file mode 100644 |
| index 000000000..67923a1c5 |
| --- /dev/null |
| +++ b/internal/host/moveshome_test.go |
| @@ -0,0 +1,227 @@ |
| +package host |
| + |
| +import ( |
| + "errors" |
| + "strings" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/hostclaim" |
| + "github.com/carlosframework/platform/internal/moves" |
| + "github.com/carlosframework/platform/internal/park" |
| + "github.com/carlosframework/platform/internal/provision" |
| +) |
| + |
| +// Home-region parks, part 1 (Paul, 2026-10-11): a move out of the home |
| +// placement finishes its cleanup on its source box. The home's placement |
| +// code is ""; its engine region is park.HomeRegion, mapped once where a |
| +// placement code becomes an engine region (park.PlacementRegion). |
| + |
| +// TestMoveFromHomeClosesThroughTheSourcePark: the whole move out of home, |
| +// through the real paths. The target commits through Plan 3's committed arm, |
| +// which queues the source's location park in HomeRegion; the source box, |
| +// whose placement is "", runs it through the real park engine and the real |
| +// park view seen from home, retiring the pre-move generation from its |
| +// bucket; and the move closes. |
| +func TestMoveFromHomeClosesThroughTheSourcePark(t *testing.T) { |
| + h := newCloseHarness(t, fromHome) |
| + h.cfg.ParkView = movesParkView(MovesRecordsFrom(h.s), h.vs, "data-ie", park.HomeRegion) |
| + h.cfg.ParkAuthorize = allowParkAuthorize |
| + if rec := h.read(t, p1Host); rec.Move.From != "" || h.cfg.Region != "" { |
| + t.Fatalf("fixture: not a move out of home: from %q, source region %q", rec.Move.From, h.cfg.Region) |
| + } |
| + h.land(t) |
| + h.committedAndOpen(t) |
| + src := h.chore(t, park.HomeRegion, false) |
| + if src == nil || src.Owner != "region:"+park.HomeRegion || src.Park.Location != cutRoot { |
| + t.Fatalf("the commit queued the source's location park as %+v, want it in %s", src, park.HomeRegion) |
| + } |
| + for _, o := range h.claim(t).Obligations { |
| + if o.Region == "" || (o.Kind == "park-replica" && o.Region != park.HomeRegion && o.Region != "uk") { |
| + t.Fatalf("the commit queued %+v", o) |
| + } |
| + } |
| + // The target's probe recorded the home generation's region as home's, |
| + // and the commit's location park covered it. |
| + covered := false |
| + for _, a := range h.claim(t).Holder.Admissions { |
| + if a.Region == "uk" && a.Predecessor == "cur" { |
| + if a.PredecessorRegion != park.HomeRegion || !a.Covered { |
| + t.Fatalf("the probe's admission %+v: the home generation is not home's, or not covered", a) |
| + } |
| + covered = true |
| + } |
| + } |
| + if !covered { |
| + t.Fatalf("no admission retires the home generation: %+v", h.claim(t).Holder.Admissions) |
| + } |
| + h.clock.Advance(24 * time.Hour) |
| + h.land(t) |
| + if h.chore(t, "uk", true) != nil { |
| + t.Fatal("the staging park was not acknowledged") |
| + } |
| + h.notClosed(t, "the source has not parked") |
| + h.sourcePass(t) |
| + h.done(t) |
| + for k := range h.f.buckets["data-ie"] { |
| + if strings.HasPrefix(k, cutRoot+"/") { |
| + t.Fatalf("still at the home location: %s", k) |
| + } |
| + } |
| + pre := 0 |
| + for k := range h.f.buckets["data-ie"] { |
| + if strings.HasPrefix(k, "parked/"+h.inc+"/"+p1Host+"/"+src.Retirement+"/instances/") && strings.Contains(k, "/g-cur/") { |
| + pre++ |
| + } |
| + } |
| + if pre == 0 { |
| + t.Fatal("the pre-move generation was not archived under the location park's retirement") |
| + } |
| + q := h.claim(t).QuietAfter |
| + if _, ok := q[park.HomeRegion]; !ok { |
| + t.Fatalf("quiet_after %v names no %s", q, park.HomeRegion) |
| + } |
| + if _, ok := q[""]; ok { |
| + t.Fatalf("quiet_after %v names the unset region", q) |
| + } |
| + if m := h.read(t, p1Host).LastMove; m == nil || m.Outcome != "done" { |
| + t.Fatalf("the move did not close: %+v", m) |
| + } |
| +} |
| + |
| +// TestHomeParkViewNamesHomeAsARegion: the real park view for a move out of |
| +// home names HomeRegion where the spec's table names the source, seen from |
| +// home and from elsewhere; before the commit, home stays authoritative on |
| +// home (J2/F4). An unset region, a named region spelled "home" and a legacy |
| +// home holder with no admission still refuse. |
| +func TestHomeParkViewNamesHomeAsARegion(t *testing.T) { |
| + type row struct { |
| + name, seen string |
| + claim func(f *vwFixture) *hostclaim.Claim |
| + state string |
| + want string // "" wants a refusal |
| + } |
| + op := func(kind, phase string) hostclaim.Op { |
| + return hostclaim.Op{Kind: kind, ID: vwMove, Phase: phase, Exec: "box"} |
| + } |
| + home := park.HomeRegion |
| + rows := []row{ |
| + {"moving, a legacy holder, seen from home", home, func(f *vwFixture) *hostclaim.Claim { |
| + return &hostclaim.Claim{Holder: f.holder(""), Op: op(hostclaim.KindMoving, hostclaim.PhaseMoving)} |
| + }, provision.MoveCutting, home}, |
| + {"moving, admitted at home, seen from us", "us", func(f *vwFixture) *hostclaim.Claim { |
| + return &hostclaim.Claim{Holder: f.holder("cur", "cur@"+home), Op: op(hostclaim.KindMoving, hostclaim.PhaseMoving)} |
| + }, provision.MoveCutting, home}, |
| + {"reverting, seen from home", home, func(f *vwFixture) *hostclaim.Claim { |
| + return &hostclaim.Claim{Holder: f.holder("cur", "cur@"+home), Op: op(hostclaim.KindReverting, "")} |
| + }, provision.MoveReverting, home}, |
| + {"landing, the target's probe admitted, seen from home", home, func(f *vwFixture) *hostclaim.Claim { |
| + return &hostclaim.Claim{Holder: f.holder(vwMove, "cur@"+home, vwMove+"@uk"), Op: op(hostclaim.KindLanding, provision.LandProbing)} |
| + }, provision.MoveLanding, home}, |
| + {"landing, the target's probe admitted, seen from us", "us", func(f *vwFixture) *hostclaim.Claim { |
| + return &hostclaim.Claim{Holder: f.holder(vwMove, "cur@"+home, vwMove+"@uk"), Op: op(hostclaim.KindLanding, provision.LandProbing)} |
| + }, provision.MoveLanding, "uk"}, |
| + {"landing committed, seen from home", home, func(f *vwFixture) *hostclaim.Claim { |
| + return &hostclaim.Claim{Holder: f.holder(vwMove, "cur@"+home, vwMove+"@uk"), Op: op(hostclaim.KindLanding, hostclaim.PhaseCommitted)} |
| + }, provision.MoveCommitted, "uk"}, |
| + {"restoring before any admission", home, func(f *vwFixture) *hostclaim.Claim { |
| + return &hostclaim.Claim{Holder: f.holder(""), Op: op(hostclaim.KindRestoring, hostclaim.PhaseReleasing)} |
| + }, provision.MoveRollingBack, home}, |
| + {"moving, admitted in a named region spelled home", home, func(f *vwFixture) *hostclaim.Claim { |
| + return &hostclaim.Claim{Holder: f.holder("cur", "cur@home"), Op: op(hostclaim.KindMoving, hostclaim.PhaseMoving)} |
| + }, provision.MoveCutting, ""}, |
| + {"moving, seen from the unset region", "", func(f *vwFixture) *hostclaim.Claim { |
| + return &hostclaim.Claim{Holder: f.holder(""), Op: op(hostclaim.KindMoving, hostclaim.PhaseMoving)} |
| + }, provision.MoveCutting, ""}, |
| + {"serving, a legacy home holder with no admission", home, func(f *vwFixture) *hostclaim.Claim { |
| + return &hostclaim.Claim{Holder: f.holder(""), Op: hostclaim.Op{Kind: hostclaim.KindServing, Exec: "box"}} |
| + }, "", ""}, |
| + } |
| + for _, r := range rows { |
| + t.Run(r.name, func(t *testing.T) { |
| + f := newVwFixture(t) |
| + f.rec.Region = "" |
| + if r.state != "" { |
| + f.rec.Move = &provision.MoveState{ID: vwMove, Incarnation: f.inc, From: "", To: "uk", State: r.state} |
| + } |
| + obs := vwObs(r.claim(f)) |
| + v, err := movesParkView(f.records(), f.bucket, "data", r.seen)(t.Context(), obs) |
| + if r.want == "" { |
| + if !errors.Is(err, park.ErrUnsafe) { |
| + t.Fatalf("view %+v, %v; want a refusal", v, err) |
| + } |
| + return |
| + } |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if v.AuthoritativeRegion != r.want || v.Region != r.seen { |
| + t.Fatalf("view seen from %q names %q authoritative, want %q", v.Region, v.AuthoritativeRegion, r.want) |
| + } |
| + if (r.want == r.seen) != v.MarkerKnown { |
| + t.Fatalf("the marker was read %v, seen from %s with %s authoritative", v.MarkerKnown, r.seen, r.want) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +// TestHomeLaneIsArmed: the lane park's arming takes the home's "" as a |
| +// region (the lane predicate), and refuses a code that is no placement. |
| +func TestHomeLaneIsArmed(t *testing.T) { |
| + c := newRevertHarness(t).laneCfg() |
| + for code, armed := range map[string]bool{"uk": true, "": true, "home": true, "IE": false, park.HomeRegion: false, "a/b": false} { |
| + c.Region = code |
| + if revertLaneArmed(c) != armed { |
| + t.Fatalf("lane armed for %q: %v, want %v", code, !armed, armed) |
| + } |
| + } |
| +} |
| + |
| +// TestMovesClaimPolicyRecoveryFromHome: a rollback's or a revert's recovery |
| +// on a home source is granted to the source's selection in park.HomeRegion, |
| +// the claim's engine region for home, and refused to one in a named region, |
| +// one named "home" among them. |
| +func TestMovesClaimPolicyRecoveryFromHome(t *testing.T) { |
| + type base struct { |
| + claim func() *hostclaim.Claim |
| + record func() *provision.Record |
| + } |
| + for name, b := range map[string]base{ |
| + "restoring probing": {func() *hostclaim.Claim { return rpRestoringClaim(rpClaimPhase(provision.RbSourceProbing)) }, |
| + func() *provision.Record { return rpRestoringRecord(provision.RbSourceProbing) }}, |
| + "reverting probing": {func() *hostclaim.Claim { return rpRevertingClaim(hostclaim.PhaseProbing) }, rpRevertingRecord}, |
| + } { |
| + for region, granted := range map[string]bool{park.HomeRegion: true, "home": false, "ie": false, "uk": false} { |
| + c, rec := b.claim(), b.record() |
| + rec.Move.From = "" |
| + s := rpSelection(c, "spawn") |
| + s.Region = region |
| + err := MovesClaimPolicy((&mpReader{rec: rec}).read)(t.Context(), c, s, rpIdentity("spawn")) |
| + if (err == nil) != granted { |
| + t.Fatalf("%s from home, selection in %q: %v, want granted %v", name, region, err, granted) |
| + } |
| + } |
| + } |
| +} |
| + |
| +// TestHomeLaneStagingLeaseKey: the staging lease of a lane in home (a move |
| +// out of home's return lane, taken with the record's From) is keyed by |
| +// park.HomeRegion, never an empty segment, and apart from every named |
| +// region, one named "home" among them; a code that is no placement refuses. |
| +func TestHomeLaneStagingLeaseKey(t *testing.T) { |
| + const host = "a.go.example.com" |
| + home, err := stagingLeaseKey("", host) |
| + if err != nil || home != "control/staging-leases/"+park.HomeRegion+"/"+host || strings.Contains(home, "//") { |
| + t.Fatalf("the home lane's staging lease is %q, %v", home, err) |
| + } |
| + for _, code := range []string{"uk", "home", "none"} { |
| + if got, err := stagingLeaseKey(code, host); err != nil || got == home || got != moves.StagingLeasePrefix(code, host) { |
| + t.Fatalf("%s's staging lease is %q, %v (home's %q)", code, got, err, home) |
| + } |
| + } |
| + for _, code := range []string{"IE", park.HomeRegion, "a/b"} { |
| + if got, err := stagingLeaseKey(code, host); !errors.Is(err, park.ErrUnsafe) { |
| + t.Fatalf("invalid code %q: %q, %v", code, got, err) |
| + } |
| + } |
| +} |
diff --git a/internal/host/movesinstall.go b/internal/host/movesinstall.go| index 78f2aedb2..e9259a026 100644 |
| --- a/internal/host/movesinstall.go |
| +++ b/internal/host/movesinstall.go |
| @@ -479,7 +479,7 @@ func (ir *installRun) parking(ctx context.Context) (bool, error) { |
| return false, err |
| } |
| scope := moves.ResidueScope(holder, *ir.rec.Move.FlippedAt) |
| - if obs, err = hostclaim.Update(ctx, ir.s, obs, hostclaim.QueueResidueParks(id, cfg.Region, scope, gens, obligationID)); err != nil { |
| + if obs, err = hostclaim.Update(ctx, ir.s, obs, hostclaim.QueueResidueParks(id, cfg.parkRegion(), scope, gens, obligationID)); err != nil { |
| return false, fmt.Errorf("queue the residue parks: %w", err) |
| } |
| if err := ir.fault("after-residue-queued"); err != nil { |
| @@ -488,7 +488,7 @@ func (ir *installRun) parking(ctx context.Context) (bool, error) { |
| eng := ir.engine(vs) |
| pending := 0 |
| for _, o := range obs.Claim.Obligations { |
| - if o.Op != id || o.Kind != "park-generation" || o.Region != cfg.Region { |
| + if o.Op != id || o.Kind != "park-generation" || o.Region != cfg.parkRegion() { |
| continue |
| } |
| w := park.Work{Host: host, ID: o.ID, Op: id} |
diff --git a/internal/host/movesland_probe_test.go b/internal/host/movesland_probe_test.go| index 6896b4a21..aae06d1fd 100644 |
| --- a/internal/host/movesland_probe_test.go |
| +++ b/internal/host/movesland_probe_test.go |
| @@ -53,7 +53,8 @@ func (p *probeLog) wake(ctx context.Context, cfg activator.ProbeConfig) (activat |
| if p.before != nil { |
| p.before(n) |
| } |
| - k := p.h.read(p.t, p1Host).Move.Landing.Attempts |
| + m := p.h.read(p.t, p1Host).Move |
| + k := m.Landing.Attempts |
| obs, err := hostclaim.Read(ctx, p.h.s, p1Host) |
| if err != nil { |
| return activator.ProbeResult{}, err |
| @@ -63,10 +64,9 @@ func (p *probeLog) wake(ctx context.Context, cfg activator.ProbeConfig) (activat |
| if h.WriterBox == "uk-box" { |
| gen = "fresh" + strconv.Itoa(n) |
| } |
| - from := "ie" |
| - if len(h.Admissions) > 0 { |
| - from = h.Admissions[len(h.Admissions)-1].Region |
| - } |
| + // The predecessor's region is the probe owners' own rule: the last |
| + // admission's region, or the move's source's engine region. |
| + from := predecessorRegion(h, m.From) |
| admitted, err := hostclaim.Update(ctx, p.h.s, obs, hostclaim.AdmitWriter(hostclaim.Admission{ObservedOpID: p1ID, ObservedPhase: provision.LandProbing, |
| ObservedSession: h.Session, ObservedGeneration: h.Generation, Box: "uk-box", Mode: "cold", NewGeneration: gen, KeepStamp: h.Stamp > h.Session, Region: "uk", PredecessorRegion: from})) |
| if err != nil { |
diff --git a/internal/host/movesland_test.go b/internal/host/movesland_test.go| index e4c13a06d..688694ed3 100644 |
| --- a/internal/host/movesland_test.go |
| +++ b/internal/host/movesland_test.go |
| @@ -273,9 +273,9 @@ func countRowsForTest(t *testing.T, path string) int { |
| return n |
| } |
| -func newLandingHarness(t *testing.T) *landingHarness { |
| +func newLandingHarness(t *testing.T, opts ...p1Option) *landingHarness { |
| t.Helper() |
| - h := &landingHarness{cutHarness: newCutHarness(t), leases: &fakeParkLeases{}, release: moves.Check{OK: true}} |
| + h := &landingHarness{cutHarness: newCutHarness(t, opts...), leases: &fakeParkLeases{}, release: moves.Check{OK: true}} |
| // The real replica replaces the harness's fake level files under the |
| // source's lineage; the final checkpoint adds nothing (the database is |
| // quiet at N). |
diff --git a/internal/host/moveslandcommit.go b/internal/host/moveslandcommit.go| index 60f6b17cb..a8e63313c 100644 |
| --- a/internal/host/moveslandcommit.go |
| +++ b/internal/host/moveslandcommit.go |
| @@ -8,6 +8,7 @@ import ( |
| "github.com/carlosframework/platform/internal/hostclaim" |
| "github.com/carlosframework/platform/internal/moves" |
| + "github.com/carlosframework/platform/internal/park" |
| "github.com/carlosframework/platform/internal/provision" |
| "github.com/carlosframework/platform/internal/registry" |
| "github.com/carlosframework/platform/internal/release" |
| @@ -162,7 +163,7 @@ func (l *landingRun) commitClaim(ctx context.Context) error { |
| if err != nil { |
| return err |
| } |
| - if _, err := hostclaim.Update(ctx, l.s, obs, hostclaim.KeepWriterSession(id, hostclaim.Commit(id, l.rec.Move.From, obs.Claim.Op.Intent["source"], cfg.Region, source, staging, obligationID))); err != nil { |
| + if _, err := hostclaim.Update(ctx, l.s, obs, hostclaim.KeepWriterSession(id, hostclaim.Commit(id, park.PlacementRegion(l.rec.Move.From), obs.Claim.Op.Intent["source"], cfg.parkRegion(), source, staging, obligationID))); err != nil { |
| return fmt.Errorf("move %s: the claim's commit: %w", id, err) |
| } |
| log.Printf("moves: %s: the claim of landing %s is committed", l.rec.Host, id) |
diff --git a/internal/host/moveslandpark.go b/internal/host/moveslandpark.go| index 76244f0b9..0c6890a52 100644 |
| --- a/internal/host/moveslandpark.go |
| +++ b/internal/host/moveslandpark.go |
| @@ -135,7 +135,7 @@ func isHexString(s string) bool { |
| // check (the arming requires all three). |
| func (ir *installRun) engine(vs lease.VersionedStore) park.Engine { |
| cfg := ir.cfg() |
| - return park.Engine{Store: vs, Claims: ir.s, Bucket: cfg.OwnBucket, Region: cfg.Region, Leases: cfg.ParkLeases, |
| + return park.Engine{Store: vs, Claims: ir.s, Bucket: cfg.OwnBucket, Region: cfg.parkRegion(), Leases: cfg.ParkLeases, |
| Now: cfg.Now, NewID: obligationID, View: cfg.ParkView, Authorize: movesArmAuthorize(cfg.ParkAuthorize, ir.authorizeObligation), After: cfg.parkFault} |
| } |
diff --git a/internal/host/movesparkfakes_test.go b/internal/host/movesparkfakes_test.go| index fbaca8928..09db434e7 100644 |
| --- a/internal/host/movesparkfakes_test.go |
| +++ b/internal/host/movesparkfakes_test.go |
| @@ -29,9 +29,9 @@ func standInView(region, role string, m *provision.MoveState) func(context.Conte |
| var authoritative string |
| switch role { |
| case roleTarget: |
| - authoritative = m.From |
| + authoritative = park.PlacementRegion(m.From) |
| case roleSource: |
| - authoritative = m.To |
| + authoritative = park.PlacementRegion(m.To) |
| default: |
| return park.View{}, park.ErrUnsafe |
| } |
diff --git a/internal/host/movesparks_test.go b/internal/host/movesparks_test.go| index ec4613b75..3f2051ac1 100644 |
| --- a/internal/host/movesparks_test.go |
| +++ b/internal/host/movesparks_test.go |
| @@ -26,9 +26,9 @@ import ( |
| // source region's acknowledgement yet (C4's regional runner), so |
| // regionalAck stands in for it. |
| -func newCloseHarness(t *testing.T) *finalHarness { |
| +func newCloseHarness(t *testing.T, opts ...p1Option) *finalHarness { |
| t.Helper() |
| - h := newFinalHarness(t) |
| + h := newFinalHarness(t, opts...) |
| if _, err := provision.SetRecordMove(context.Background(), h.s, "bab", "app", p1Host, func(rec *provision.Record) error { |
| rec.Move.CutAttempts = 2 |
| return nil |
diff --git a/internal/host/movespass.go b/internal/host/movespass.go| index ad6e3589f..0cb58ef87 100644 |
| --- a/internal/host/movespass.go |
| +++ b/internal/host/movespass.go |
| @@ -283,6 +283,20 @@ func movesDataDir(dd string) string { |
| return abs |
| } |
| +// stagingLeaseKey is the staging lease of a lane in targetRegion, the |
| +// lane's placement code (the record's To, or its From for a return lane): |
| +// moves.StagingLeasePrefix keyed by the lane's engine region, so a lane in |
| +// home is park.HomeRegion and never an empty segment |
| +// (control/staging-leases//<host>; home-region parks, part 1). Named codes |
| +// keep their keys. A code that is no placement refuses. |
| +func stagingLeaseKey(targetRegion, host string) (string, error) { |
| + r := park.PlacementRegion(targetRegion) |
| + if r == "" { |
| + return "", fmt.Errorf("%w: %q is not a placement code", park.ErrUnsafe, targetRegion) |
| + } |
| + return moves.StagingLeasePrefix(r, host), nil |
| +} |
| + |
| // armPhase1 wires the Phase 1 hooks (moves design §7.3). Only with the |
| // switch on: off, none is set and eb.Strict stays nil. writes is the |
| // agent's host-write configuration (the claim wiring's): the landing's |
| @@ -295,7 +309,11 @@ func armPhase1(cfg *moveCfg, w *movesWorker, hib activator.Config, dataDir strin |
| cfg.DeployBucketRegion = edgesFileRegion(getenv, readPlacementRegion()) |
| bucket, region := cfg.DeployBucket, cfg.DeployBucketRegion |
| cfg.StagingLease = func(ctx context.Context, targetRegion, host string) (moves.SafeUntilGuard, func(context.Context) error, error) { |
| - h, err := lease.AcquireTTL(ctx, bucket, moves.StagingLeasePrefix(targetRegion, host), region, moves.StagingLeaseTTL) |
| + key, err := stagingLeaseKey(targetRegion, host) |
| + if err != nil { |
| + return nil, nil, err |
| + } |
| + h, err := lease.AcquireTTL(ctx, bucket, key, region, moves.StagingLeaseTTL) |
| if err != nil { |
| return nil, nil, err |
| } |
diff --git a/internal/host/movesphase1_harness_test.go b/internal/host/movesphase1_harness_test.go| index 75eb0a29c..a4fc8aeee 100644 |
| --- a/internal/host/movesphase1_harness_test.go |
| +++ b/internal/host/movesphase1_harness_test.go |
| @@ -19,6 +19,7 @@ import ( |
| "github.com/carlosframework/platform/internal/hostclaim" |
| "github.com/carlosframework/platform/internal/lease" |
| "github.com/carlosframework/platform/internal/moves" |
| + "github.com/carlosframework/platform/internal/park" |
| "github.com/carlosframework/platform/internal/provision" |
| "github.com/carlosframework/platform/internal/registry" |
| "github.com/carlosframework/platform/internal/release" |
| @@ -329,11 +330,26 @@ type phase1Harness struct { |
| // worker1 is the source box's worker and worker2 the target's, each |
| // kept across passes as one agent keeps its own. |
| worker1, worker2 *movesWorker |
| + // src is the source box's placement code: "ie", or the home's "" for a |
| + // move out of home (fromHome). |
| + src string |
| } |
| -func newPhase1Harness(t *testing.T) *phase1Harness { |
| +// p1Option adjusts a Phase 1 harness before it is built. |
| +type p1Option func(*phase1Harness) |
| + |
| +// fromHome builds the harness for a move out of the home placement: the |
| +// source box, its record and its move name the home's code, "" (home-region |
| +// parks, part 1). The source box keeps its name and bucket. |
| +func fromHome(h *phase1Harness) { h.src = "" } |
| + |
| +func newPhase1Harness(t *testing.T, opts ...p1Option) *phase1Harness { |
| t.Helper() |
| - h := &phase1Harness{moveHarness: newMoveHarness(t), lease: &fakeStagingLease{}, clock: &stepClock{now: time.Date(2026, 10, 7, 9, 0, 0, 0, time.UTC)}} |
| + h := &phase1Harness{moveHarness: newMoveHarness(t), lease: &fakeStagingLease{}, clock: &stepClock{now: time.Date(2026, 10, 7, 9, 0, 0, 0, time.UTC)}, src: "ie"} |
| + for _, o := range opts { |
| + o(h) |
| + } |
| + h.cfg.Region = h.src |
| h.vs = &fakeVersioned{f: h.f} |
| h.lease.until = h.clock.now.Add(moves.StagingLeaseTTL) |
| inc, err := hostclaim.Incarnation("bab", "app", p1Host, p1Created) |
| @@ -352,7 +368,7 @@ func newPhase1Harness(t *testing.T) *phase1Harness { |
| h.optDir = t.TempDir() |
| h.route = registry.Route{Host: p1Host, Kind: "instance", App: "app", Account: "bab", Hibernate: true, Status: "ready", |
| Socket: provision.DefaultSocket(p1Host), DBPath: provision.DefaultDBPath(h.cfg.DataDir, p1Host), Provisioned: true} |
| - h.env = map[string]string{"CARLOS_REGION": "ie", "CARLOS_CONTROL_SOCKET": "/run/carlos/" + p1Host + ".sock.control", "APP_MODE": "prod"} |
| + h.env = map[string]string{"CARLOS_REGION": h.src, "CARLOS_CONTROL_SOCKET": "/run/carlos/" + p1Host + ".sock.control", "APP_MODE": "prod"} |
| h.probe, h.strict = moves.Check{OK: true}, moves.Check{OK: true} |
| ctx := context.Background() |
| @@ -417,7 +433,7 @@ func newPhase1Harness(t *testing.T) *phase1Harness { |
| // The source's edges file: ie publishes its data bucket too (T10). |
| if _, err := h.s.Put(ctx, "control/edges/ie-box.json", |
| - []byte(`{"v":1,"box":"ie-box","eip":"1.2.3.5","placement":"ie","data_bucket":"data-ie","data_bucket_region":"eu-west-1","hosts":{}}`), ""); err != nil { |
| + []byte(`{"v":1,"box":"ie-box","eip":"1.2.3.5","placement":"`+h.src+`","data_bucket":"data-ie","data_bucket_region":"eu-west-1","hosts":{}}`), ""); err != nil { |
| t.Fatal(err) |
| } |
| // The app's instances manifest: it declares no readiness path. |
| @@ -455,19 +471,19 @@ func pointLiveForTest(t *testing.T, optDir, host, v string) { |
| func (h *phase1Harness) start(t *testing.T, cut string) provision.Record { |
| t.Helper() |
| ctx := context.Background() |
| - rec := provision.Record{Schema: 1, Host: p1Host, Slug: "s", App: "app", Account: "bab", Region: "ie", Channel: "stable", CreatedAt: p1Created} |
| + rec := provision.Record{Schema: 1, Host: p1Host, Slug: "s", App: "app", Account: "bab", Region: h.src, Channel: "stable", CreatedAt: p1Created} |
| if err := provision.CreateRecord(ctx, h.s, rec); err != nil { |
| t.Fatal(err) |
| } |
| if _, err := provision.SetRecordMove(ctx, h.s, "bab", "app", p1Host, provision.MoveStartTransition(provision.MoveStart{ |
| - ID: p1ID, Incarnation: h.inc, From: "ie", To: "uk", Cut: cut, By: "op", Now: h.clock.Now()})); err != nil { |
| + ID: p1ID, Incarnation: h.inc, From: h.src, To: "uk", Cut: cut, By: "op", Now: h.clock.Now()})); err != nil { |
| t.Fatal(err) |
| } |
| obs, err := hostclaim.Update(ctx, h.s, hostclaim.Observed{Host: p1Host, Absent: true}, func(c *hostclaim.Claim) error { |
| c.Holder = &hostclaim.Holder{Family: "instance", Key: provision.RecordKey("bab", "app", p1Host), Account: "bab", App: "app", |
| Incarnation: h.inc, CreatedAt: p1Created, Location: "instances/" + p1Host + "/" + h.inc, Generation: "cur", Stamp: 1} |
| c.Op = hostclaim.Op{Kind: hostclaim.KindServing, Exec: "ie-box"} |
| - c.Touched = hostclaim.Touched{Complete: true, Regions: []string{"ie"}, Boxes: []string{"ie-box"}} |
| + c.Touched = hostclaim.Touched{Complete: true, Regions: []string{park.PlacementRegion(h.src)}, Boxes: []string{"ie-box"}} |
| return nil |
| }) |
| if err != nil { |
diff --git a/internal/host/movespolicy.go b/internal/host/movespolicy.go| index b5eaa465b..de2b1fc22 100644 |
| --- a/internal/host/movespolicy.go |
| +++ b/internal/host/movespolicy.go |
| @@ -10,6 +10,7 @@ import ( |
| "github.com/carlosframework/platform/internal/hostbox" |
| "github.com/carlosframework/platform/internal/hostclaim" |
| "github.com/carlosframework/platform/internal/hostwrite" |
| + "github.com/carlosframework/platform/internal/park" |
| "github.com/carlosframework/platform/internal/provision" |
| "github.com/carlosframework/platform/internal/registry" |
| "github.com/carlosframework/platform/internal/release" |
| @@ -436,7 +437,7 @@ func movesRecoveryPolicy(ctx context.Context, read MovesRecordReader, c *hostcla |
| if rec == nil || rec.Host != c.Host || rec.Move == nil || rec.Move.ID != s.Op || rec.Move.Incarnation != c.Holder.Incarnation { |
| return refuse("the record is not this move", nil) |
| } |
| - if rec.Move.From != s.Region { |
| + if park.PlacementRegion(rec.Move.From) != s.Region { |
| return refuse("this region is not the move's source region", nil) |
| } |
| var ( |
diff --git a/internal/host/movesprobeowners.go b/internal/host/movesprobeowners.go| index 137d32f57..c147a9eb2 100644 |
| --- a/internal/host/movesprobeowners.go |
| +++ b/internal/host/movesprobeowners.go |
| @@ -26,6 +26,7 @@ import ( |
| "github.com/carlosframework/platform/internal/hostwrite" |
| "github.com/carlosframework/platform/internal/moves" |
| "github.com/carlosframework/platform/internal/moves/archive" |
| + "github.com/carlosframework/platform/internal/park" |
| "github.com/carlosframework/platform/internal/provision" |
| "github.com/carlosframework/platform/internal/registry" |
| "github.com/carlosframework/platform/internal/release" |
| @@ -390,6 +391,15 @@ func predecessorRegion(h *hostclaim.Holder, from string) string { |
| if n := len(h.Admissions); n > 0 { |
| return h.Admissions[n-1].Region |
| } |
| + // from is the move's source placement code. Its engine region is the |
| + // predecessor's (park.HomeRegion for the home's ""): the claim reads a |
| + // predecessor region of "" as holding nothing anywhere and covers it |
| + // with no chore, which a home generation does not (home-region parks, |
| + // part 1). A code that is no placement is kept as it is: it names no |
| + // region, so nothing covers it by mistake. |
| + if r := park.PlacementRegion(from); r != "" { |
| + return r |
| + } |
| return from |
| } |
diff --git a/internal/host/movesrecover.go b/internal/host/movesrecover.go| index 74bc04486..52fced74a 100644 |
| --- a/internal/host/movesrecover.go |
| +++ b/internal/host/movesrecover.go |
| @@ -672,7 +672,7 @@ func (r *recoverRun) probed(ctx context.Context) (bool, error) { |
| return false, err |
| } |
| scope := moves.ReturnLaneScope(host, r.rb, obs.Claim.Holder.Incarnation, seal.Bound()) |
| - lane = hostclaim.QueueReturnLanePark(id, cfg.Region, scope, obligationID) |
| + lane = hostclaim.QueueReturnLanePark(id, cfg.parkRegion(), scope, obligationID) |
| } |
| switch obs.Claim.Op.Phase { |
| case hostclaim.PhaseProbing: |
diff --git a/internal/host/movesrevert.go b/internal/host/movesrevert.go| index 0a0346de1..91ac4b200 100644 |
| --- a/internal/host/movesrevert.go |
| +++ b/internal/host/movesrevert.go |
| @@ -158,11 +158,13 @@ func revertSourceArmed(cfg moveCfg) bool { |
| } |
| // revertLaneArmed reports whether this box can run the target region's lane |
| -// park: the claim's park leases, view and authorizer, and this box's own |
| -// bucket. In production armMovesParks sets the three only with claim Plan |
| -// 12, so this answers false there until it lands. |
| +// park: an engine region (parkRegion, the home's "" included as |
| +// park.HomeRegion; a code that is no placement is not one), the claim's |
| +// park leases, view and authorizer, and this box's own bucket. In |
| +// production armMovesParks sets the three only with claim Plan 12, so this |
| +// answers false there until it lands. |
| func revertLaneArmed(cfg moveCfg) bool { |
| - return cfg.Box != "" && cfg.Region != "" && cfg.OwnBucket != "" && parkHooksArmed(cfg) && cfg.Versioned != nil |
| + return cfg.Box != "" && cfg.parkRegion() != "" && cfg.OwnBucket != "" && parkHooksArmed(cfg) && cfg.Versioned != nil |
| } |
| // restatusHold is moveCfg.RestatusHold in production: an op's own hold |
| @@ -899,7 +901,7 @@ func (w *movesWorker) laneParkTarget(ctx context.Context, s release.Store, rec p |
| lane := moves.ScopeLocation(host, id) |
| var chores []hostclaim.Obligation |
| for _, o := range obs.Claim.Obligations { |
| - if o.Op == id && o.Kind == "park-replica" && o.Region == cfg.Region && o.Park != nil && o.Park.Location == lane { |
| + if o.Op == id && o.Kind == "park-replica" && o.Region == cfg.parkRegion() && o.Park != nil && o.Park.Location == lane { |
| chores = append(chores, o) |
| } |
| } |
| @@ -917,12 +919,12 @@ func (w *movesWorker) laneParkTarget(ctx context.Context, s release.Store, rec p |
| own := func(_ context.Context, obs hostclaim.Observed, o hostclaim.Obligation) error { |
| c := obs.Claim |
| if obs.Absent || c == nil || !inOp(c.Op) || (!committed && c.Op.Exec == cfg.Box) || |
| - o.Op != id || o.Kind != "park-replica" || o.Region != cfg.Region || o.Park == nil || o.Park.Location != lane { |
| + o.Op != id || o.Kind != "park-replica" || o.Region != cfg.parkRegion() || o.Park == nil || o.Park.Location != lane { |
| return fmt.Errorf("%w: not this move's lane park", park.ErrUnsafe) |
| } |
| return nil |
| } |
| - e := park.Engine{Store: vs, Claims: s, Bucket: cfg.OwnBucket, Region: cfg.Region, Leases: cfg.ParkLeases, |
| + e := park.Engine{Store: vs, Claims: s, Bucket: cfg.OwnBucket, Region: cfg.parkRegion(), Leases: cfg.ParkLeases, |
| Now: w.now, NewID: obligationID, View: cfg.ParkView, Authorize: movesArmAuthorize(cfg.ParkAuthorize, own), After: cfg.parkFault} |
| for _, o := range chores { |
| err := e.Run(ctx, park.Work{Host: host, ID: o.ID, Op: id}) |
diff --git a/internal/host/movesrollback.go b/internal/host/movesrollback.go| index 326a9ae91..2093e8405 100644 |
| --- a/internal/host/movesrollback.go |
| +++ b/internal/host/movesrollback.go |
| @@ -556,7 +556,7 @@ func (r *rollbackRun) claimReleased(ctx context.Context, evidence string) error |
| } |
| holder := obs.Claim.Holder |
| scope := moves.TargetLocationScope(*holder, report.CheckedAt) |
| - if _, err := hostclaim.Update(ctx, r.s, obs, hostclaim.TargetReleased(r.g.ID, cfg.Box, cfg.Region, evidence, scope, obligationID)); err != nil { |
| + if _, err := hostclaim.Update(ctx, r.s, obs, hostclaim.TargetReleased(r.g.ID, cfg.Box, cfg.parkRegion(), evidence, scope, obligationID)); err != nil { |
| return fmt.Errorf("the claim half of the release: %w", err) |
| } |
| log.Printf("moves: %s: the target gave execution up for rollback %s, citing %s", r.rec.Host, r.rb, evidence) |
| @@ -1103,7 +1103,7 @@ func (r *rollbackRun) remote(ctx context.Context, obs hostclaim.Observed, j *mov |
| return false, err |
| } |
| scope := moves.StagingScope(host, id, obs.Claim.Holder.Incarnation, staged.Bound()) |
| - if _, err := hostclaim.Update(ctx, r.s, obs, hostclaim.QueueStagingPark(id, cfg.Region, scope, obligationID)); err != nil { |
| + if _, err := hostclaim.Update(ctx, r.s, obs, hostclaim.QueueStagingPark(id, cfg.parkRegion(), scope, obligationID)); err != nil { |
| return false, fmt.Errorf("queue the move's lane park: %w", err) |
| } |
| if err := mark(rbStepLaneQueued, ""); err != nil { |
| @@ -1121,7 +1121,7 @@ func (r *rollbackRun) remote(ctx context.Context, obs hostclaim.Observed, j *mov |
| e := r.engine(vs) |
| waiting := 0 |
| for _, o := range obs.Claim.Obligations { |
| - if o.Op != id || o.Kind != "park-replica" || o.Region != cfg.Region || o.Park == nil { |
| + if o.Op != id || o.Kind != "park-replica" || o.Region != cfg.parkRegion() || o.Park == nil { |
| continue |
| } |
| work := park.Work{Host: host, ID: o.ID, Op: id} |
| @@ -1158,7 +1158,7 @@ func (r *rollbackRun) engine(vs lease.VersionedStore) park.Engine { |
| } |
| return r.authorizeChore(obs.Claim, o) |
| } |
| - return park.Engine{Store: vs, Claims: r.s, Bucket: cfg.OwnBucket, Region: cfg.Region, Leases: cfg.ParkLeases, |
| + return park.Engine{Store: vs, Claims: r.s, Bucket: cfg.OwnBucket, Region: cfg.parkRegion(), Leases: cfg.ParkLeases, |
| Now: r.now, NewID: obligationID, View: cfg.ParkView, Authorize: movesArmAuthorize(cfg.ParkAuthorize, own), After: cfg.parkFault} |
| } |
| @@ -1173,7 +1173,7 @@ func (r *rollbackRun) authorizeChore(c *hostclaim.Claim, o hostclaim.Obligation) |
| return fmt.Errorf("%w: not this rollback's chore", park.ErrUnsafe) |
| } |
| switch { |
| - case o.Kind == "park-replica" && o.Region == cfg.Region: |
| + case o.Kind == "park-replica" && o.Region == cfg.parkRegion(): |
| case o.Kind == "park-local" && o.Box == cfg.Box: |
| default: |
| return fmt.Errorf("%w: not this target's chore", park.ErrUnsafe) |
diff --git a/internal/host/movessources.go b/internal/host/movessources.go| index 72a23bda7..fa02f2353 100644 |
| --- a/internal/host/movessources.go |
| +++ b/internal/host/movessources.go |
| @@ -57,9 +57,43 @@ import ( |
| // movesNoLiveRegion is the authoritative region the view names when the |
| // spec's table says none: a claim with no holder, or a creating, deleting, |
| -// parking or purging op. No region code is "none", so the engine protects |
| +// parking or purging op. The "@" keeps it outside the placement-code |
| +// alphabet (provision.ValidRegion; "none" itself is a valid code) and apart |
| +// from park.HomeRegion, so no box's region is ever it and the engine protects |
| // nothing in any region, which is what "nothing is live" means. |
| -const movesNoLiveRegion = "none" |
| +const movesNoLiveRegion = "@none" |
| + |
| +// parkRegion is this box's engine region: its placement code through |
| +// park.PlacementRegion, so the home's "" is park.HomeRegion (home-region |
| +// parks, part 1). Every park engine a moves arm builds, every chore it looks |
| +// for or queues in its own region, and the region its claim's engine must |
| +// have go through it. An invalid code maps to "", which arms nothing and |
| +// which every engine entry refuses. |
| +func (c moveCfg) parkRegion() string { return park.PlacementRegion(c.Region) } |
| + |
| +// movesBoxRegion is the park view's region resolver in production: this box's |
| +// region as the claim knows it (resolve, the verified roster row), accepted |
| +// only when it is the engine region of this box's own placement code. A home |
| +// box (placement "") whose claim names it anything but park.HomeRegion, the |
| +// roster's own name for home among them, refuses: the view would otherwise |
| +// read a home record's "" as HomeRegion while this box's engine runs under |
| +// another name, and protect nothing of home's live set here. |
| +func movesBoxRegion(placement string, resolve func(context.Context) (string, error)) func(context.Context) (string, error) { |
| + return func(ctx context.Context) (string, error) { |
| + want := park.PlacementRegion(placement) |
| + if want == "" { |
| + return "", fmt.Errorf("%w: this box's placement %q is not a placement code", park.ErrUnsafe, placement) |
| + } |
| + got, err := resolve(ctx) |
| + if err != nil { |
| + return "", err |
| + } |
| + if got != want { |
| + return "", fmt.Errorf("%w: the claim names this box's region %q, its placement's engine region is %q", park.ErrUnsafe, got, want) |
| + } |
| + return got, nil |
| + } |
| +} |
| // movesClaimPlan12 is what claim Plan 12 adds to the MoveParks capability. |
| // Nothing on main implements it. |
| @@ -141,8 +175,11 @@ func cachedRegion(resolve func(context.Context) (string, error)) func(context.Co |
| // movesParkViewAt is movesParkView seen from the region regionOf answers. |
| // Production's is the claim's verified roster row's region |
| // (productionRosterRegion), the region the claim's engine runs in, so the |
| -// view's region is the claim's by construction (M17; fix round 1). The home |
| -// region, "", is a region like any other. |
| +// view's region is the claim's by construction (M17; fix round 1), held to |
| +// this box's placement by movesBoxRegion. Every region it names is an engine |
| +// region: the home is park.HomeRegion, never "", and a region of "" (unset) |
| +// refuses. The record's placement codes are mapped once, in |
| +// movesAuthoritative. |
| func movesParkViewAt(records MovesRecordReader, data genselect.Reader, bucket string, regionOf func(context.Context) (string, error)) func(context.Context, hostclaim.Observed) (park.View, error) { |
| return func(ctx context.Context, obs hostclaim.Observed) (park.View, error) { |
| if ctx == nil || records == nil || data == nil || bucket == "" || regionOf == nil { |
| @@ -155,6 +192,9 @@ func movesParkViewAt(records MovesRecordReader, data genselect.Reader, bucket st |
| if err != nil { |
| return park.View{}, fmt.Errorf("this box's region: %w", err) |
| } |
| + if region == "" { |
| + return park.View{}, fmt.Errorf("%w: this box's region is unset", park.ErrUnsafe) |
| + } |
| auth, err := movesAuthoritative(ctx, records, obs, region) |
| if err != nil { |
| return park.View{}, err |
| @@ -365,6 +405,14 @@ func readMarker(ctx context.Context, data genselect.Reader, bucket, host, locati |
| // (the record-first close, M4), when the newest admission decides alone. A |
| // region the claim's history contradicts refuses. So does any other kind |
| // (placing, relocating): it has no row in the table. |
| +// |
| +// The move's From and To are placement codes; they are compared and |
| +// answered as engine regions (park.PlacementRegion), so a move out of home |
| +// names park.HomeRegion, which no named region equals, and a code that is no |
| +// placement refuses. region is already an engine region. A serving holder |
| +// with no admission still takes the record's region as it is, and a record |
| +// with the home's "" there still refuses: that row is the claim's sweep of |
| +// every legacy home host, not a move's, and is left as it was. |
| func movesAuthoritative(ctx context.Context, records MovesRecordReader, obs hostclaim.Observed, region string) (string, error) { |
| c := obs.Claim |
| h := c.Holder |
| @@ -404,6 +452,10 @@ func movesAuthoritative(ctx context.Context, records MovesRecordReader, obs host |
| } |
| return "", fmt.Errorf("%w: the record does not carry move %s", park.ErrUnsafe, c.Op.ID) |
| } |
| + from, to := park.PlacementRegion(m.From), park.PlacementRegion(m.To) |
| + if from == "" || to == "" { |
| + return "", fmt.Errorf("%w: move %s names %q and %q, which are not both placement codes", park.ErrUnsafe, m.ID, m.From, m.To) |
| + } |
| contradicts := func(want ...string) error { |
| if !known { |
| return nil |
| @@ -417,38 +469,38 @@ func movesAuthoritative(ctx context.Context, records MovesRecordReader, obs host |
| } |
| switch c.Op.Kind { |
| case hostclaim.KindMoving, hostclaim.KindReverting: |
| - if err := contradicts(m.From); err != nil { |
| + if err := contradicts(from); err != nil { |
| return "", err |
| } |
| - return m.From, nil |
| + return from, nil |
| case hostclaim.KindLanding: |
| - if err := contradicts(m.From, m.To); err != nil { |
| + if err := contradicts(from, to); err != nil { |
| return "", err |
| } |
| - if known && admitted == m.To { |
| - if region == m.From && c.Op.Phase != hostclaim.PhaseCommitted && m.State != provision.MoveCommitted { |
| + if known && admitted == to { |
| + if region == from && c.Op.Phase != hostclaim.PhaseCommitted && m.State != provision.MoveCommitted { |
| // J2/F4 (Paul's #236 ruling): the source's live set, its |
| // files live set among it, stays protected on the source |
| // until the record commits. Seen from the source's region the |
| // source stays authoritative, so the engine protects there |
| // too (and refuses a park of the location, whose current |
| // generation the source's marker does not name). |
| - return m.From, nil |
| + return from, nil |
| } |
| - return m.To, nil |
| + return to, nil |
| } |
| if c.Op.Phase == hostclaim.PhaseCommitted || m.State == provision.MoveCommitted { |
| - return "", fmt.Errorf("%w: move %s committed with no admission in %s", park.ErrUnsafe, m.ID, m.To) |
| + return "", fmt.Errorf("%w: move %s committed with no admission in %s", park.ErrUnsafe, m.ID, to) |
| } |
| - return m.From, nil |
| + return from, nil |
| default: // restoring |
| - if err := contradicts(m.From, m.To); err != nil { |
| + if err := contradicts(from, to); err != nil { |
| return "", err |
| } |
| if known { |
| return admitted, nil |
| } |
| - return m.From, nil |
| + return from, nil |
| } |
| } |
| @@ -575,9 +627,11 @@ func registerMovesParks(src movesParkSources) error { |
| // armMovesParks sets the moves arms' park hooks from the claim's MoveParks |
| // capability (M17): its engine's leases, view and authorizer, only when the |
| -// engine's bucket and region are this box's own bucket and placement |
| -// region, and claim Plan 12's RunHeld and local authorizer (M5, M9). Any |
| -// refusal leaves every hook unset, so every arm stays unarmed. |
| +// engine's bucket and region are this box's own bucket and its placement's |
| +// engine region (parkRegion: park.HomeRegion for the home's ""), and claim |
| +// Plan 12's RunHeld and local authorizer (M5, M9). An engine region of "" |
| +// never arms. Any refusal leaves every hook unset, so every arm stays |
| +// unarmed. |
| func armMovesParks(ctx context.Context, cfg *moveCfg, providers ClaimProviders, plan12 func() (movesClaimPlan12, error)) error { |
| if ctx == nil || cfg == nil || plan12 == nil { |
| return hostwrite.ErrUnavailable |
| @@ -586,8 +640,8 @@ func armMovesParks(ctx context.Context, cfg *moveCfg, providers ClaimProviders, |
| if err != nil { |
| return fmt.Errorf("the claim's MoveParks: %w", err) |
| } |
| - if engine.Bucket != cfg.OwnBucket || engine.Region != cfg.Region { |
| - return fmt.Errorf("%w: the claim's park engine is %s in %q, this box's moves use %s in %q", hostbox.ErrChanged, engine.Bucket, engine.Region, cfg.OwnBucket, cfg.Region) |
| + if want := cfg.parkRegion(); want == "" || engine.Bucket != cfg.OwnBucket || engine.Region != want { |
| + return fmt.Errorf("%w: the claim's park engine is %s in %q, this box's moves use %s in %q (placement %q)", hostbox.ErrChanged, engine.Bucket, engine.Region, cfg.OwnBucket, want, cfg.Region) |
| } |
| if engine.Leases == nil || engine.View == nil || engine.Authorize == nil { |
| return errors.Join(hostwrite.ErrUnavailable, errors.New("the claim's park engine lacks its leases, view or authorizer")) |
| @@ -612,10 +666,14 @@ func armMovesParks(ctx context.Context, cfg *moveCfg, providers ClaimProviders, |
| // on (ruling H1), over the deployment store's records, this box's data |
| // bucket read by exact version (lease.NewVersionedS3, through rootaws), and |
| // the region regionOf answers, behind the claim Plan 12 guard. Production's |
| -// regionOf is productionRosterRegion: the claim's verified roster row, the |
| -// home region "" included (fix round 1). Off, it registers nothing and the |
| -// claim keeps its named refusals. Task 5's evidence goes through refusals, |
| -// which pages its anomalies once (movesrefusals.go). |
| +// regionOf is productionRosterRegion: the claim's verified roster row (fix |
| +// round 1), held by movesBoxRegion to the engine region of this box's own |
| +// placement, CARLOS_REGION. The roster refuses a row with region "", so on |
| +// a home box it names home otherwise than park.HomeRegion, and the view |
| +// refuses there until the claim names home as the engine does (home-region |
| +// parks, part 1). Off, it registers nothing and the claim keeps its named |
| +// refusals. Task 5's evidence goes through refusals, which pages its |
| +// anomalies once (movesrefusals.go). |
| func registerProductionMovesParks(ctx context.Context, getenv func(string) string, store release.Store, hib activator.Config, regionOf func(context.Context) (string, error), refusals *movesRefusals) error { |
| if !hostwrite.Enabled(getenv) || !moves.Enabled(getenv) { |
| return nil |
| @@ -629,7 +687,8 @@ func registerProductionMovesParks(ctx context.Context, getenv func(string) strin |
| } |
| records := MovesRecordsFrom(store) |
| evidence := refusals.evidence(movesRetirementEvidence{Records: records, Now: time.Now}) |
| - src, err := newMovesParkSources(records, data, hib.Bucket, cachedRegion(regionOf), evidence, movesPlan12Parks) |
| + region := movesBoxRegion(strings.TrimSpace(getenv("CARLOS_REGION")), cachedRegion(regionOf)) |
| + src, err := newMovesParkSources(records, data, hib.Bucket, region, evidence, movesPlan12Parks) |
| if err != nil { |
| return err |
| } |
diff --git a/internal/host/movessources_test.go b/internal/host/movessources_test.go| index b46336d09..10289f532 100644 |
| --- a/internal/host/movessources_test.go |
| +++ b/internal/host/movessources_test.go |
| @@ -558,31 +558,49 @@ func TestRegisterProductionMovesParksNeedsBothSwitches(t *testing.T) { |
| } |
| } |
| -// TestHomeBoxArms (fix round 1, region source): the home region, "", is a |
| -// region like any other. A home box's view is seen from "" and its arms take |
| -// an engine whose region is "". |
| +// TestHomeBoxArms (fix round 1, region source; home-region parks, part 1): |
| +// a home box, whose placement code is "", is seen from park.HomeRegion and |
| +// its arms take an engine whose region is park.HomeRegion. An engine region |
| +// of "" (unset) never arms, and neither does a box whose code is no |
| +// placement, even when the engine's region spells the same string. |
| func TestHomeBoxArms(t *testing.T) { |
| f := newVwFixture(t) |
| f.rec.Region = "" |
| - view := movesParkViewAt(f.records(), f.bucket, "data", fixedRegion("")) |
| + view := movesParkViewAt(f.records(), f.bucket, "data", fixedRegion(park.HomeRegion)) |
| obs := vwObs(&hostclaim.Claim{Holder: f.holder(""), Op: hostclaim.Op{Kind: hostclaim.KindMoving, ID: vwMove, Phase: hostclaim.PhaseMoving, Exec: "box"}}) |
| f.rec.Move = &provision.MoveState{ID: vwMove, Incarnation: f.inc, From: "", To: "uk", State: provision.MoveCutting} |
| v, err := view(t.Context(), obs) |
| - if err != nil || v.Region != "" || v.AuthoritativeRegion != "" || !v.MarkerKnown { |
| + if err != nil || v.Region != park.HomeRegion || v.AuthoritativeRegion != park.HomeRegion || !v.MarkerKnown { |
| t.Fatalf("the home view %+v, %v", v, err) |
| } |
| auth := func(context.Context, hostclaim.Observed, hostclaim.Obligation) error { return nil } |
| - providers := ClaimProviders{moveParks: &claimMoveParks{ |
| - local: func(context.Context, *hostclaim.Claim, hostclaim.Obligation) error { return nil }, |
| - engine: park.Engine{Bucket: "data-home", Region: "", Leases: &fakeParkLeases{}, View: view, Authorize: auth}, |
| - leases: movesLeaseSet("data-home"), check: func(context.Context) error { return nil }}} |
| - c := moveCfg{OwnBucket: "data-home", Region: ""} |
| - if err := armMovesParks(t.Context(), &c, providers, func() (movesClaimPlan12, error) { return &fakePlan12{}, nil }); err != nil { |
| + arm := func(engineRegion, placement string) (moveCfg, error) { |
| + providers := ClaimProviders{moveParks: &claimMoveParks{ |
| + local: func(context.Context, *hostclaim.Claim, hostclaim.Obligation) error { return nil }, |
| + engine: park.Engine{Bucket: "data-home", Region: engineRegion, Leases: &fakeParkLeases{}, View: view, Authorize: auth}, |
| + leases: movesLeaseSet("data-home"), check: func(context.Context) error { return nil }}} |
| + c := moveCfg{OwnBucket: "data-home", Region: placement} |
| + err := armMovesParks(t.Context(), &c, providers, func() (movesClaimPlan12, error) { return &fakePlan12{}, nil }) |
| + return c, err |
| + } |
| + c, err := arm(park.HomeRegion, "") |
| + if err != nil { |
| t.Fatalf("a home box did not arm: %v", err) |
| } |
| if !parkHooksArmed(c) { |
| t.Fatal("hooks unset") |
| } |
| + if c, err := arm("uk", "uk"); err != nil || !parkHooksArmed(c) { |
| + t.Fatalf("a named box did not arm: %v", err) |
| + } |
| + for _, tc := range [][2]string{{"", ""}, {"ie", ""}, {"home", ""}, {park.HomeRegion, "home"}, {park.HomeRegion, park.HomeRegion}, {"IE", "IE"}, {"", "uk"}} { |
| + if c, err := arm(tc[0], tc[1]); !errors.Is(err, hostbox.ErrChanged) || parkHooksArmed(c) { |
| + t.Fatalf("engine in %q, placement %q: armed (%v)", tc[0], tc[1], err) |
| + } |
| + } |
| + if provision.ValidRegion(movesNoLiveRegion) || movesNoLiveRegion == park.HomeRegion { |
| + t.Fatalf("the no-live sentinel %q can be a box's region", movesNoLiveRegion) |
| + } |
| } |
| // TestParkViewRegionIsTheRosters (fix round 1, region source): the view |
diff --git a/internal/hostops/move_fence_release.go b/internal/hostops/move_fence_release.go| index 939bf2c21..2a7063128 100644 |
| --- a/internal/hostops/move_fence_release.go |
| +++ b/internal/hostops/move_fence_release.go |
| @@ -7,6 +7,7 @@ import ( |
| "github.com/carlosframework/platform/internal/hostclaim" |
| "github.com/carlosframework/platform/internal/hostops/internal/fencerelease" |
| "github.com/carlosframework/platform/internal/moves" |
| + "github.com/carlosframework/platform/internal/park" |
| "github.com/carlosframework/platform/internal/provision" |
| "github.com/carlosframework/platform/internal/release" |
| ) |
| @@ -94,7 +95,10 @@ func (g *MoveRecoveryGrant) ReleaseFencedTarget(ctx context.Context, ref hostcla |
| return writeRecord() |
| } |
| h := o.Claim.Holder |
| - r := hostclaim.FencedTargetRelease{OpID: m.ID, RollbackID: rb.ID, Target: o.Claim.Op.Intent["target"], TargetRegion: m.To, SourceRegion: m.From, |
| + // The record's codes become engine regions (park.PlacementRegion): a |
| + // move out of home names its source park.HomeRegion, never "", which |
| + // the claim refuses (home-region parks, part 1). |
| + r := hostclaim.FencedTargetRelease{OpID: m.ID, RollbackID: rb.ID, Target: o.Claim.Op.Intent["target"], TargetRegion: park.PlacementRegion(m.To), SourceRegion: park.PlacementRegion(m.From), |
| Location: moves.TargetLocationScope(*h, fence.ObservedAt()), Lane: moves.StagingScope(o.Host, m.ID, h.Incarnation, fence.ObservedAt()), NewID: moveLossNewID(ref.SHA256), Fence: fence} |
| if err := CheckCreateFreeze(ctx, g.w.raw, g.w.pub); err != nil { |
| return err |
| @@ -159,7 +163,7 @@ func (g *MoveRecoveryGrant) StampLostMoveTargetParked(ctx context.Context) error |
| return err |
| } |
| m := rec.Move |
| - if m == nil || m.ID != g.o.OpID || m.Incarnation != g.o.Incarnation || m.Rollback == nil || !hostclaim.LostMoveTargetParked(o.Claim, m.To) { |
| + if m == nil || m.ID != g.o.OpID || m.Incarnation != g.o.Incarnation || m.Rollback == nil || !hostclaim.LostMoveTargetParked(o.Claim, park.PlacementRegion(m.To)) { |
| return ErrPending |
| } |
| if m.Rollback.TargetParkedAt != nil { |
diff --git a/internal/hostops/move_recovery_commit_test.go b/internal/hostops/move_recovery_commit_test.go| index e2045a9bc..36cc707b1 100644 |
| --- a/internal/hostops/move_recovery_commit_test.go |
| +++ b/internal/hostops/move_recovery_commit_test.go |
| @@ -6,6 +6,7 @@ import ( |
| "github.com/carlosframework/platform/internal/hostclaim" |
| "github.com/carlosframework/platform/internal/moves" |
| + "github.com/carlosframework/platform/internal/park" |
| "github.com/carlosframework/platform/internal/provision" |
| ) |
| @@ -59,3 +60,55 @@ func TestMoveRecoveryCommitUsesSharedSealValidation(t *testing.T) { |
| }) |
| } |
| } |
| + |
| +// Home-region parks, part 1: the console's commit of a move out of home |
| +// queues the source's location park in park.HomeRegion, exactly as the |
| +// box's commit does (K9), and covers the home generation's admission. The |
| +// record names home by its code, "", which the claim would refuse. |
| +func TestMoveRecoveryCommitFromHome(t *testing.T) { |
| + w := newLossWorld(t, "landing", func(c *hostclaim.Claim, r *provision.Record, _ *AppAdmission) { |
| + c.Op.Phase = hostclaim.PhaseLandingProbed |
| + r.Move.State = provision.MoveCommitted |
| + r.Move.CommittedAt = r.Move.FlippedAt |
| + r.Move.From = "" |
| + for i := range c.Holder.Admissions { |
| + a := &c.Holder.Admissions[i] |
| + if a.Region == "ie" { |
| + a.Region = park.HomeRegion |
| + } |
| + if a.PredecessorRegion == "ie" { |
| + a.PredecessorRegion = park.HomeRegion |
| + } |
| + } |
| + }) |
| + before := w.claim(t) |
| + err := w.control.RunRecovery(context.Background(), func(ctx context.Context) error { |
| + return runMoveRecovery(ctx, before, func(ctx context.Context, g *MoveRecoveryGrant) error { |
| + return g.Claim(ctx, RecoveryMoveCommit) |
| + }) |
| + }) |
| + after := w.claim(t) |
| + if err != nil || after.Claim.Op.Phase != hostclaim.PhaseCommitted { |
| + t.Fatalf("commit: %v, claim=%+v", err, after.Claim.Op) |
| + } |
| + found := false |
| + for _, o := range after.Claim.Obligations { |
| + if o.Region == "" { |
| + t.Fatalf("a chore in the unset region: %+v", o) |
| + } |
| + if o.Kind == "park-replica" && o.Park != nil && o.Park.Location == "instances/"+lossHost { |
| + if o.Region != park.HomeRegion || o.Owner != "region:"+park.HomeRegion { |
| + t.Fatalf("the source's location park %+v, want it in %s", o, park.HomeRegion) |
| + } |
| + found = true |
| + } |
| + } |
| + if !found { |
| + t.Fatalf("no location park: %+v", after.Claim.Obligations) |
| + } |
| + for _, a := range after.Claim.Holder.Admissions { |
| + if a.PredecessorRegion == park.HomeRegion && !a.Covered { |
| + t.Fatalf("the home generation's admission is not covered: %+v", a) |
| + } |
| + } |
| +} |
diff --git a/internal/hostops/move_recovery_grant.go b/internal/hostops/move_recovery_grant.go| index 47ad94212..284c96077 100644 |
| --- a/internal/hostops/move_recovery_grant.go |
| +++ b/internal/hostops/move_recovery_grant.go |
| @@ -11,6 +11,7 @@ import ( |
| "github.com/carlosframework/platform/internal/hostclaim" |
| "github.com/carlosframework/platform/internal/moves" |
| + "github.com/carlosframework/platform/internal/park" |
| "github.com/carlosframework/platform/internal/provision" |
| "github.com/carlosframework/platform/internal/release" |
| ) |
| @@ -418,7 +419,10 @@ func (g *MoveRecoveryGrant) Claim(ctx context.Context, action MoveRecoveryClaimA |
| return err |
| } |
| n := 0 |
| - transition = hostclaim.Commit(g.o.OpID, m.From, c.Op.Intent["source"], m.To, source, staging, func() string { n++; return id + strconv.Itoa(n) }) |
| + // The record's codes become engine regions as the box's commit |
| + // makes them (park.PlacementRegion; K9): a move out of home queues |
| + // its location park in park.HomeRegion, never "". |
| + transition = hostclaim.Commit(g.o.OpID, park.PlacementRegion(m.From), c.Op.Intent["source"], park.PlacementRegion(m.To), source, staging, func() string { n++; return id + strconv.Itoa(n) }) |
| default: |
| return ErrIdentity |
| } |
diff --git a/internal/hostops/plan12_fence_home_test.go b/internal/hostops/plan12_fence_home_test.go| new file mode 100644 |
| index 000000000..57e51a797 |
| --- /dev/null |
| +++ b/internal/hostops/plan12_fence_home_test.go |
| @@ -0,0 +1,75 @@ |
| +package hostops |
| + |
| +import ( |
| + "context" |
| + "strings" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/hostclaim" |
| + "github.com/carlosframework/platform/internal/park" |
| + "github.com/carlosframework/platform/internal/provision" |
| +) |
| + |
| +// Home-region parks, part 1, after claim Plan 12: the recovery grant's |
| +// release of a fenced target (ReleaseFencedTarget) for a move out of home. |
| +// The record names the source home by its code, ""; the release hands the |
| +// claim the source's engine region, park.HomeRegion, which the claim |
| +// requires to be a region and not the target's. Every chore it queues |
| +// names a region, and the lost target's parks are found under the |
| +// target's engine region (LostMoveTargetParked). |
| +func TestPlan12RecoveryFenceGrantFromHome(t *testing.T) { |
| + w := newC7World(t, func(c *hostclaim.Claim, r *provision.Record, _ *AppAdmission) { |
| + c.Op.Kind, c.Op.Phase, c.Op.Exec = hostclaim.KindLanding, hostclaim.PhaseProbing, "source" |
| + c.Op.Intent["source"], c.Op.Intent["target"], c.Op.Intent["to"], c.Op.Intent["target_enrollment"] = "target", "source", "ie", strings.Repeat("a", 32) |
| + c.Op.Participants = []string{"target", "source"} |
| + c.Obligations = nil |
| + c.Op.Journal = map[string]string{hostclaim.JournalSourceStamp: "1", hostclaim.JournalSrcGen: "src"} |
| + r.Region = "" |
| + r.Move.From, r.Move.To, r.Move.State = "", "ie", provision.MoveRollingBack |
| + at := r.CreatedAt |
| + r.Move.Rollback = &provision.MoveRollback{ID: "rb_abcdefghijklmnopqrstuvwxyz", Reason: provision.RollbackTargetFenced, Evidence: "control/box-fences/" + strings.Repeat("a", 32) + ".json", RequestedAt: &at, TargetPhase: provision.RbTargetLost, LossRef: "control/box-fences/" + strings.Repeat("a", 32) + ".json", LostAt: &at, SourcePhase: provision.RbSourceWaiting} |
| + }) |
| + authority := &plan12BumpFence{c7ReplayAuthority: w.authority} |
| + act := func(fn func(context.Context, *MoveRecoveryGrant) error) error { |
| + return w.control.RunRecovery(t.Context(), func(ctx context.Context) error { return runMoveRecovery(ctx, w.claim(t), fn, authority) }) |
| + } |
| + if err := act(func(ctx context.Context, g *MoveRecoveryGrant) error { return g.ReleaseFencedTarget(ctx, w.ref) }); err != nil { |
| + t.Fatalf("the fenced target of a move out of home was not released: %v", err) |
| + } |
| + o := w.claim(t) |
| + if o.Claim.Op.Phase != hostclaim.PhaseReleased || len(o.Claim.Obligations) == 0 { |
| + t.Fatalf("the release: %+v", o.Claim) |
| + } |
| + for _, ob := range o.Claim.Obligations { |
| + if ob.Region == "" && ob.Kind != "park-local" { |
| + t.Fatalf("a chore in the unset region: %+v", ob) |
| + } |
| + if ob.Region == park.HomeRegion { |
| + t.Fatalf("a target's chore in home: %+v", ob) |
| + } |
| + } |
| + // The replay writes the record's half (the target released). |
| + if err := act(func(ctx context.Context, g *MoveRecoveryGrant) error { return g.ReleaseFencedTarget(ctx, w.ref) }); err != nil { |
| + t.Fatalf("the release's replay: %v", err) |
| + } |
| + if rec, err := provision.GetRecord(t.Context(), w.raw, "bab", "app", w.host); err != nil || rec.Move.Rollback.TargetPhase != provision.RbTargetReleased { |
| + t.Fatalf("the record after the replay: %+v %v", rec, err) |
| + } |
| + o = w.claim(t) |
| + if hostclaim.LostMoveTargetParked(o.Claim, park.PlacementRegion("ie")) { |
| + t.Fatal("parked with the target's parks owed") |
| + } |
| + current := o |
| + for _, ob := range o.Claim.Obligations { |
| + var err error |
| + if current, err = hostclaim.Update(t.Context(), w.raw, current, hostclaim.CompleteObligation(ob.Op, ob.ID)); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + if err := act(func(ctx context.Context, g *MoveRecoveryGrant) error { return g.StampLostMoveTargetParked(ctx) }); err != nil { |
| + t.Fatalf("the lost target's parked stamp: %v", err) |
| + } |
| + if rec, err := provision.GetRecord(t.Context(), w.raw, "bab", "app", w.host); err != nil || rec.Move.Rollback.TargetParkedAt == nil { |
| + t.Fatalf("not stamped: %+v %v", rec, err) |
| + } |
| +} |
diff --git a/internal/park/home_region_test.go b/internal/park/home_region_test.go| new file mode 100644 |
| index 000000000..256e8b6fd |
| --- /dev/null |
| +++ b/internal/park/home_region_test.go |
| @@ -0,0 +1,262 @@ |
| +package park |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "strings" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/hostclaim" |
| + "github.com/carlosframework/platform/internal/provision" |
| +) |
| + |
| +// Home-region parks, part 1 (Paul, 2026-10-11). The home placement's code is |
| +// "", which the engine has always read as unset and refused. Home gets its |
| +// own engine region, HomeRegion, outside the placement-code alphabet, mapped |
| +// once from the placement code (PlacementRegion); "" keeps its meaning. |
| + |
| +// TestHomeRegionIsReserved: HomeRegion is not a placement code, so no named |
| +// region can ever equal it, and PlacementRegion agrees with the placement |
| +// validator on what a code is. |
| +func TestHomeRegionIsReserved(t *testing.T) { |
| + if HomeRegion == "" || provision.ValidRegion(HomeRegion) { |
| + t.Fatalf("HomeRegion %q is a placement code", HomeRegion) |
| + } |
| + if got := PlacementRegion(""); got != HomeRegion { |
| + t.Fatalf("the home's code maps to %q, want %q", got, HomeRegion) |
| + } |
| + for _, code := range []string{"ie", "uk", "home", "none", "0", "a-b", "eu-west-1", strings.Repeat("a", 16)} { |
| + if got := PlacementRegion(code); got != code || got == HomeRegion { |
| + t.Fatalf("named code %q maps to %q", code, got) |
| + } |
| + } |
| + for _, code := range []string{HomeRegion, hostclaim.RootGeneration, "IE", "ie/", "/", "a b", "ie\x00", "..", "région", strings.Repeat("a", 17)} { |
| + if got := PlacementRegion(code); got != "" { |
| + t.Fatalf("invalid code %q maps to %q, want unset", code, got) |
| + } |
| + } |
| + // The local alphabet is the validator's, never wider. |
| + for _, code := range []string{"", "a", "-", "z9", "ie", "IE", "@home", "@", "a_b", "a.b", strings.Repeat("x", 16), strings.Repeat("x", 17)} { |
| + if (PlacementRegion(code) != "") != provision.ValidRegion(code) { |
| + t.Fatalf("PlacementRegion(%q) = %q disagrees with provision.ValidRegion = %v", code, PlacementRegion(code), provision.ValidRegion(code)) |
| + } |
| + } |
| +} |
| + |
| +// choreIn rewrites the fixture's one chore (and the claim's touched region) |
| +// to region. |
| +func choreIn(t *testing.T, f *fixture, region string) { |
| + t.Helper() |
| + obs, err := hostclaim.Read(t.Context(), f.e.Claims, f.w.Host) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + f.ob.Region, f.ob.Owner = region, "region:"+region |
| + if _, err := hostclaim.Update(t.Context(), f.e.Claims, obs, func(c *hostclaim.Claim) error { |
| + c.Obligations = []hostclaim.Obligation{f.ob} |
| + c.Touched.Regions = []string{region} |
| + return nil |
| + }); err != nil { |
| + t.Fatal(err) |
| + } |
| +} |
| + |
| +// engineIn points the fixture's engine at region, its view seen from seen. |
| +func engineIn(f *fixture, region, seen string) { |
| + f.e.Region = region |
| + f.e.View = func(_ context.Context, o hostclaim.Observed) (View, error) { |
| + return View{ClaimETag: o.ETag, Region: seen, AuthoritativeRegion: "uk"}, nil |
| + } |
| +} |
| + |
| +// TestHomeAndNamedEnginesNeverRunEachOthersChores: through Engine.Run, an |
| +// engine for home never acts on a named region's chore, a named region's |
| +// engine (one named "home" among them) never acts on home's, and a home |
| +// engine whose view is seen from another region acts on nothing. Each |
| +// refusal leaves the bucket and the claim as they were. |
| +func TestHomeAndNamedEnginesNeverRunEachOthersChores(t *testing.T) { |
| + for _, tc := range []struct { |
| + name string |
| + chore, engine, view string |
| + want error |
| + }{ |
| + {"home engine, named chore", "ie", HomeRegion, HomeRegion, ErrUnsafe}, |
| + {"named engine, home chore", HomeRegion, "ie", "ie", ErrUnsafe}, |
| + {"engine named home, home chore", HomeRegion, "home", "home", ErrUnsafe}, |
| + {"home engine, home chore, view from ie", HomeRegion, HomeRegion, "ie", ErrChanged}, |
| + {"unset engine, unset view, home chore", HomeRegion, "", "", ErrUnsafe}, |
| + } { |
| + t.Run(tc.name, func(t *testing.T) { |
| + f := newFixture(t) |
| + f.seed() |
| + choreIn(t, f, tc.chore) |
| + engineIn(f, tc.engine, tc.view) |
| + before := f.b.Snapshot() |
| + if err := f.e.Run(t.Context(), f.w); !errors.Is(err, tc.want) { |
| + t.Fatalf("run: %v, want %v", err, tc.want) |
| + } |
| + if f.b.Snapshot() != before { |
| + t.Fatal("the bucket changed") |
| + } |
| + obs, err := hostclaim.Read(t.Context(), f.e.Claims, f.w.Host) |
| + if err != nil || len(obs.Claim.Obligations) != 1 || obs.Claim.Obligations[0].Region != tc.chore || len(obs.Claim.QuietAfter) != 0 { |
| + t.Fatalf("the claim changed: %+v %v", obs.Claim, err) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +// TestHomeEngineParksUnderItsOwnRegion: a home engine runs a home chore to |
| +// its acknowledgement. The archive's keys carry no region and no empty |
| +// segment; the manifest, the quiet window and the coverage name HomeRegion, |
| +// never "" and never a named region. |
| +func TestHomeEngineParksUnderItsOwnRegion(t *testing.T) { |
| + f := newFixture(t) |
| + source := f.seed() |
| + choreIn(t, f, HomeRegion) |
| + engineIn(f, HomeRegion, HomeRegion) |
| + if err := f.e.Run(t.Context(), f.w); err != nil { |
| + t.Fatal(err) |
| + } |
| + obs, err := hostclaim.Read(t.Context(), f.e.Claims, f.w.Host) |
| + if err != nil || len(obs.Claim.Obligations) != 0 { |
| + t.Fatalf("still owed: %+v %v", obs.Claim, err) |
| + } |
| + if got, want := obs.Claim.QuietAfter[HomeRegion], f.ob.Park.EvidenceAt.Add(SignatureAge+time.Second); !got.Equal(want) || len(obs.Claim.QuietAfter) != 1 { |
| + t.Fatalf("quiet_after %v, want only %s at %v", obs.Claim.QuietAfter, HomeRegion, want) |
| + } |
| + m, err := f.e.manifest(t.Context(), f.w, f.ob) |
| + if err != nil || m == nil || m.Region != HomeRegion || len(m.Delete) != len(source) { |
| + t.Fatalf("manifest %+v %v", m, err) |
| + } |
| + base := "parked/" + f.ob.Park.Incarnation + "/" + f.w.Host + "/" + f.ob.Retirement + "/" |
| + for _, a := range m.Archive { |
| + k := a.Destination.Key |
| + if !strings.HasPrefix(k, base+"instances/") || strings.Contains(k, "//") || strings.Contains(k, HomeRegion) { |
| + t.Fatalf("archive key %q is not under %sinstances/ or carries a region", k, base) |
| + } |
| + } |
| + if vs, err := f.e.list(t.Context(), f.w.Host, f.ob.Park.Location); err != nil || len(vs) != 0 { |
| + t.Fatalf("residue %v %v", vs, err) |
| + } |
| +} |
| + |
| +// TestHomeLiveSetIsRegionQualified: the live set the engine protects is |
| +// qualified by HomeRegion. Admissions recorded in HomeRegion form the home's |
| +// unfinished chain; the same admissions recorded in a named region spelled |
| +// "home" do not, and a view seen from a named region protects nothing of |
| +// home's. A pending chore in a named region never stands for home's. |
| +func TestHomeLiveSetIsRegionQualified(t *testing.T) { |
| + inRegion := func(t *testing.T, region string) (*fixture, hostclaim.Observed, View) { |
| + f, obs, v, _ := sweepFixture(t) |
| + obs, err := hostclaim.Update(t.Context(), f.e.Claims, obs, func(c *hostclaim.Claim) error { |
| + for i := range c.Holder.Admissions { |
| + c.Holder.Admissions[i].Region, c.Holder.Admissions[i].PredecessorRegion = region, region |
| + } |
| + return nil |
| + }) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + v.ClaimETag, v.Region, v.AuthoritativeRegion = obs.ETag, HomeRegion, HomeRegion |
| + return f, obs, v |
| + } |
| + _, obs, v := inRegion(t, HomeRegion) |
| + live, err := LiveGenerations(obs.Claim, v, "") |
| + if err != nil || !live["g3"] || !live["g2"] || !live["g1"] { |
| + t.Fatalf("home's own chain: %v %v", live, err) |
| + } |
| + _, obs, v = inRegion(t, "home") |
| + if live, err := LiveGenerations(obs.Claim, v, ""); !errors.Is(err, ErrUnsafe) { |
| + t.Fatalf("a chain recorded in the named region home stood for HomeRegion's: %v %v", live, err) |
| + } |
| + _, obs, v = inRegion(t, HomeRegion) |
| + v.Region = "ie" |
| + if live, err := LiveGenerations(obs.Claim, v, ""); err != nil || len(live) != 0 { |
| + t.Fatalf("seen from ie, home's live set: %v %v", live, err) |
| + } |
| + |
| + // A pending chore blocks Decide only in its own region. |
| + f, obs, v := inRegion(t, HomeRegion) |
| + vs, err := f.e.Discover(t.Context(), f.w.Host) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, region := range []string{"ie", "home", HomeRegion} { |
| + ob := f.ob |
| + ob.Region, ob.Owner = region, "region:"+region |
| + c := *obs.Claim |
| + c.Obligations = []hostclaim.Obligation{ob} |
| + d, err := Decide(hostclaim.Observed{Host: obs.Host, Claim: &c, ETag: obs.ETag}, v, vs, f.ob.Park.Incarnation) |
| + if err != nil { |
| + t.Fatalf("%s: %v", region, err) |
| + } |
| + if blocked := len(d.Candidates) == 0; blocked != (region == HomeRegion) { |
| + t.Fatalf("a pending chore in %s: candidates %+v", region, d.Candidates) |
| + } |
| + } |
| +} |
| + |
| +// TestHomeSweepQueuesUnderHomeRegion: a home engine's sweep queues its |
| +// chores as HomeRegion's, owned by region:HomeRegion, runs them and keys the |
| +// quiet window by HomeRegion. |
| +func TestHomeSweepQueuesUnderHomeRegion(t *testing.T) { |
| + f := newFixture(t) |
| + f.seed() |
| + obs, _ := hostclaim.Read(t.Context(), f.e.Claims, f.w.Host) |
| + if _, err := hostclaim.Update(t.Context(), f.e.Claims, obs, func(c *hostclaim.Claim) error { |
| + c.Obligations = nil |
| + return hostclaim.FreeAfterPark("op")(c) |
| + }); err != nil { |
| + t.Fatal(err) |
| + } |
| + engineIn(f, HomeRegion, HomeRegion) |
| + var queued []hostclaim.Obligation |
| + f.e.Authorize = func(_ context.Context, _ hostclaim.Observed, o hostclaim.Obligation) error { |
| + queued = append(queued, o) |
| + return nil |
| + } |
| + d, err := f.e.Sweep(t.Context(), f.w.Host, f.ob.Park.Incarnation, f.ob.Park.EvidenceAt, time.Second) |
| + if err != nil || len(d.Candidates) != 1 { |
| + t.Fatalf("%+v %v", d, err) |
| + } |
| + if len(queued) == 0 { |
| + t.Fatal("nothing was queued") |
| + } |
| + for _, o := range queued { |
| + if o.Region != HomeRegion || o.Owner != "region:"+HomeRegion { |
| + t.Fatalf("queued %+v, want region %s", o, HomeRegion) |
| + } |
| + } |
| + obs, err = hostclaim.Read(t.Context(), f.e.Claims, f.w.Host) |
| + if err != nil || !obs.Claim.Free() || len(obs.Claim.Obligations) != 0 { |
| + t.Fatalf("%+v %v", obs, err) |
| + } |
| + if _, ok := obs.Claim.QuietAfter[HomeRegion]; !ok || len(obs.Claim.QuietAfter) != 1 { |
| + t.Fatalf("quiet_after %v", obs.Claim.QuietAfter) |
| + } |
| +} |
| + |
| +// TestUnsetRegionStillRefuses: "" is still unset at every engine entry, and |
| +// an invalid placement code maps to it. |
| +func TestUnsetRegionStillRefuses(t *testing.T) { |
| + f, obs, v, vs := sweepFixture(t) |
| + v.Region = "" |
| + if _, err := Decide(obs, v, vs, f.ob.Park.Incarnation); !errors.Is(err, ErrUnsafe) { |
| + t.Fatalf("decide from an unset region: %v", err) |
| + } |
| + v.Region, v.AuthoritativeRegion = "ie", "" |
| + if _, err := LiveGenerations(obs.Claim, v, ""); !errors.Is(err, ErrUnsafe) { |
| + t.Fatalf("an unset authoritative region: %v", err) |
| + } |
| + g := newFixture(t) |
| + g.seed() |
| + for _, code := range []string{"IE", "@home", "a/b"} { |
| + engineIn(g, PlacementRegion(code), PlacementRegion(code)) |
| + before := g.b.Snapshot() |
| + if err := g.e.Run(t.Context(), g.w); !errors.Is(err, ErrUnsafe) || g.b.Snapshot() != before { |
| + t.Fatalf("an engine for invalid code %q: %v", code, err) |
| + } |
| + } |
| +} |
diff --git a/internal/park/region.go b/internal/park/region.go| new file mode 100644 |
| index 000000000..8b680c3e5 |
| --- /dev/null |
| +++ b/internal/park/region.go |
| @@ -0,0 +1,38 @@ |
| +package park |
| + |
| +import "regexp" |
| + |
| +// HomeRegion is the engine region of the home placement (home-region parks, |
| +// part 1, Paul 2026-10-11). The home's placement code is "", and "" already |
| +// means unset to the engine and to the claim: an engine, view, chore or |
| +// admission with region "" is refused or read as "holds nothing anywhere". |
| +// So home is never "" here. It is this token, mapped once from the placement |
| +// code by PlacementRegion where a box's or a record's code becomes an engine |
| +// region. |
| +// |
| +// The "@" is outside the placement-code alphabet (provision.ValidRegion: |
| +// lowercase a-z, 0-9 and hyphen), so no named region, one named "home" |
| +// included, can ever equal it. It reaches the claim's obligation region, its |
| +// "region:" owner, its quiet_after key and one control key, the home lane's |
| +// staging lease (control/staging-leases/@home/<host>, keyed in the host's |
| +// wiring). It never reaches an archive path: the archive is |
| +// parked/<incarnation>/<host>/<retirement>/. |
| +const HomeRegion = "@home" |
| + |
| +// placementCode is a named placement code: provision.ValidRegion without the |
| +// empty code. TestHomeRegionIsReserved holds the two together. |
| +var placementCode = regexp.MustCompile(`^[a-z0-9-]{1,16}$`) |
| + |
| +// PlacementRegion is the engine region of a placement code: HomeRegion for |
| +// the home's "", a named code unchanged, and "" (unset, which every engine |
| +// entry refuses) for anything that is not a placement code, HomeRegion |
| +// itself included. |
| +func PlacementRegion(code string) string { |
| + switch { |
| + case code == "": |
| + return HomeRegion |
| + case placementCode.MatchString(code): |
| + return code |
| + } |
| + return "" |
| +} |
diff --git a/internal/provision/plan9_b8_census_test.go b/internal/provision/plan9_b8_census_test.go| index b8f4ca651..91cdd9cd5 100644 |
| --- a/internal/provision/plan9_b8_census_test.go |
| +++ b/internal/provision/plan9_b8_census_test.go |
| @@ -478,8 +478,8 @@ var plan9B8CensusInventory = []censusEntry{ |
| {"internal/hostops/move_loss_admission.go", "moveLossOwner.ValidateLossRecovery", "key-site", "key:RecordKey", "ea2aa35a069ffdc846ecb208c33940245fc865795188818e86ea7a93135dd6a9"}, |
| {"internal/host/claimretirementcensus.go", "claimArchivedLocations", "key-site", "lit:instances", "9ba8ea4ecb5ad63621c1322467b9a80f8f5703896ba1fc3f7cb1addd56418950"}, |
| {"internal/host/claimretirementcensus.go", "claimLegacyIncarnation", "key-site", "lit:instances", "31a14ea105c67e00a24f06b9500a221ee5bfc47402b9c33cecfde807e51db530"}, |
| - {"internal/hostops/move_fence_release.go", "MoveRecoveryGrant.ReleaseFencedTarget", "record-writer", "key:WriteRecord,key:writeRecord", "031a3a8169a92775bff8a1a1b08cfe82a5a613156a1bce45dc9cfcb34c56befe"}, |
| - {"internal/hostops/move_fence_release.go", "MoveRecoveryGrant.StampLostMoveTargetParked", "record-writer", "key:WriteRecord", "b082db5634a2f4d9a5b45cc4a9cb725964e9bb24b7aeb53a6346167d7737227c"}, |
| + {"internal/hostops/move_fence_release.go", "MoveRecoveryGrant.ReleaseFencedTarget", "record-writer", "key:WriteRecord,key:writeRecord", "13e4506d5825781237b85eb28db5641e25278f4ba9ea2e5475b6adca03d3ee12"}, |
| + {"internal/hostops/move_fence_release.go", "MoveRecoveryGrant.StampLostMoveTargetParked", "record-writer", "key:WriteRecord", "911ab90f0786d646b494a62c2d061852c35273dc538408636ebf2ec613fa2198"}, |
| {"internal/hostops/move_recovery_grant.go", "MoveRecoveryGrant.Convert", "record-writer", "key:WriteRecord", "794963a24a0a50d980505f83ac912973a10267b21f74bd41c56584ab9b0b25a1"}, |
| // The recovery pass's move log writer: one create-only entry of a move's |
| // log under its report prefix, for a host whose claim still names the |