hostclaim: Plan 13 fix round 1 (home alias only from the signed fleet home designation; named boxes use their own identity, a move's home name only for verified remote history; readiness, coverage and lost-target parked checks through the binding; reasons show the roster name; lint catches call and map comparisons)
Answers Astra's P1/P2/P2/P3 and Opus's P2-1..3 and P3s on d94d8b73.
Co-Authored-By: Codex <noreply@openai.com> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
46 files changed,
+780
−133
home_region_comparison_test.go+108 −23internal/console/apiinstancemove.go+11 −4internal/console/apiinstancemove_claim_test.go+11 −0internal/console/claim_invariant_world_test.go+12 −0internal/console/claim_runtime.go+3 −0internal/console/movesrecovery.go+1 −1internal/console/plan13_region_test.go+27 −0internal/host/claimassembly.go+1 −1internal/host/claimcleanup_identity.go+1 −1internal/host/claimhandover_recovery.go+1 −1internal/host/claimlaunch_authority.go+1 −1internal/host/claimproduction.go+6 −5internal/host/claimrecoveryreceipt.go+1 −1internal/host/claimregion.go+6 −3internal/host/movesland_probe_test.go+4 −1internal/host/movesphase1.go+4 −1internal/host/movespolicy.go+9 −3internal/host/movesprobeowners.go+54 −35internal/host/movesreport.go+23 −6internal/host/movessources.go+4 −9internal/host/plan12_handover_test.go+5 −2internal/host/plan13_amendments_test.go+2 −2internal/host/plan13_fix1_test.go+112 −0internal/host/plan13_policy_test.go+16 −1internal/host/plan13_receipt_test.go+2 −1internal/host/plan13_region_test.go+37 −3internal/host/plan13_views_test.go+32 −3internal/hostactors/plan13_home_test.go+37 −0internal/hostactors/roster.go+9 −0internal/hostbox/identity.go+3 −2internal/hostbox/work.go+8 −6internal/hostclaim/fence.go+6 −3internal/hostclaim/generation.go+4 −4internal/hostclaim/move_target_fence.go+4 −2internal/hostclaim/plan13_amendments_test.go+69 −0internal/hostfence/claim_fence.go+1 −1internal/hostops/move_enrollment.go+4 −1internal/hostops/move_fence_regions.go+1 −1internal/hostops/move_recovery_bridge_test.go+2 −1internal/hostops/plan13_enrollment_test.go+17 −1internal/hostops/plan13_fence_test.go+42 −0internal/hostops/plan13_fix1_test.go+29 −0internal/hostops/production_create.go+3 −0internal/park/plan13_observe_test.go+37 −0internal/park/region_names.go+7 −0internal/provision/plan9_b8_census_test.go+3 −3
CI passed — run details
CI log
=== gate ===
cd launchctx && go vet ./... && go test ./... -count=1
ok amadan.net/carlos/platform.git/launchctx 0.012s
? 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 335.908s
? github.com/carlosframework/platform/cmd/changelog [no test files]
ok github.com/carlosframework/platform/cmd/console 3.168s
ok github.com/carlosframework/platform/cmd/moveprobe 92.329s
ok github.com/carlosframework/platform/internal/activator 210.316s
ok github.com/carlosframework/platform/internal/agentsock 0.018s
ok github.com/carlosframework/platform/internal/alerting 0.011s
ok github.com/carlosframework/platform/internal/analytics 0.030s
ok github.com/carlosframework/platform/internal/appconfig 2.442s
ok github.com/carlosframework/platform/internal/apperr 0.104s
ok github.com/carlosframework/platform/internal/applog 0.183s
ok github.com/carlosframework/platform/internal/awsinv 0.015s
ok github.com/carlosframework/platform/internal/boxchoice 0.010s
ok github.com/carlosframework/platform/internal/boxid 0.850s
? 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.013s
ok github.com/carlosframework/platform/internal/caps 0.021s
ok github.com/carlosframework/platform/internal/certwarm 0.005s
ok github.com/carlosframework/platform/internal/cgroup 0.208s
ok github.com/carlosframework/platform/internal/changelog 0.290s
ok github.com/carlosframework/platform/internal/clamd 0.058s
? github.com/carlosframework/platform/internal/cliflag [no test files]
ok github.com/carlosframework/platform/internal/console 357.259s
? 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.084s
ok github.com/carlosframework/platform/internal/dbdoor/dbdoortest 0.009s
ok github.com/carlosframework/platform/internal/dbverify 0.004s
ok github.com/carlosframework/platform/internal/dnsrec 0.003s
ok github.com/carlosframework/platform/internal/docsite 0.006s
ok github.com/carlosframework/platform/internal/domains 2.026s
ok github.com/carlosframework/platform/internal/doorkey 0.006s
ok github.com/carlosframework/platform/internal/economics 0.004s
ok github.com/carlosframework/platform/internal/edge 25.773s
? github.com/carlosframework/platform/internal/edgeproof [no test files]
ok github.com/carlosframework/platform/internal/edgesfile 0.007s
ok github.com/carlosframework/platform/internal/fleetapi 0.125s
ok github.com/carlosframework/platform/internal/gate 1.654s
ok github.com/carlosframework/platform/internal/genselect 0.015s
ok github.com/carlosframework/platform/internal/geo 0.019s
ok github.com/carlosframework/platform/internal/geoip 0.030s
ok github.com/carlosframework/platform/internal/host 55.887s
ok github.com/carlosframework/platform/internal/hostactors 0.384s
ok github.com/carlosframework/platform/internal/hostbox 1.279s
ok github.com/carlosframework/platform/internal/hostclaim 0.250s
ok github.com/carlosframework/platform/internal/hostevidence 0.012s
ok github.com/carlosframework/platform/internal/hostexec 0.317s
ok github.com/carlosframework/platform/internal/hostfence 0.029s
ok github.com/carlosframework/platform/internal/hostfiles 4.142s
ok github.com/carlosframework/platform/internal/hostfloor 0.012s
ok github.com/carlosframework/platform/internal/hostguard 0.134s
ok github.com/carlosframework/platform/internal/hostops 1.411s
? github.com/carlosframework/platform/internal/hostops/fencerelease [no test files]
ok github.com/carlosframework/platform/internal/hostops/internal/fencerelease 0.012s
ok github.com/carlosframework/platform/internal/hostquarantine 0.013s
ok github.com/carlosframework/platform/internal/hostresolve 0.131s
ok github.com/carlosframework/platform/internal/hostwrite 0.013s
ok github.com/carlosframework/platform/internal/infraview 0.012s
ok github.com/carlosframework/platform/internal/instancectl 1.704s
ok github.com/carlosframework/platform/internal/instanceexec 2.697s
ok github.com/carlosframework/platform/internal/instancefs 0.016s
ok github.com/carlosframework/platform/internal/instancekey 0.015s
ok github.com/carlosframework/platform/internal/instanceupdates 0.019s
ok github.com/carlosframework/platform/internal/layout 1.376s
ok github.com/carlosframework/platform/internal/lease 9.453s
ok github.com/carlosframework/platform/internal/lease/leasetest 0.005s
ok github.com/carlosframework/platform/internal/ledger 0.012s
ok github.com/carlosframework/platform/internal/mail 0.159s
ok github.com/carlosframework/platform/internal/maintenance 0.083s
ok github.com/carlosframework/platform/internal/maintenanceexec 5.540s
ok github.com/carlosframework/platform/internal/maintenanceguard 0.062s
ok github.com/carlosframework/platform/internal/metrics 0.005s
ok github.com/carlosframework/platform/internal/moves 21.237s
ok github.com/carlosframework/platform/internal/moves/archive 0.746s
ok github.com/carlosframework/platform/internal/objstore 0.164s
ok github.com/carlosframework/platform/internal/ops 0.159s
ok github.com/carlosframework/platform/internal/park 0.185s
ok github.com/carlosframework/platform/internal/privatefile 0.003s
ok github.com/carlosframework/platform/internal/provision 3.190s
ok github.com/carlosframework/platform/internal/publicrelease 0.007s
? github.com/carlosframework/platform/internal/regionname [no test files]
ok github.com/carlosframework/platform/internal/registry 3.731s
ok github.com/carlosframework/platform/internal/relay 7.227s
ok github.com/carlosframework/platform/internal/release 18.292s
ok github.com/carlosframework/platform/internal/rollout 1.505s
ok github.com/carlosframework/platform/internal/rolloutwords 0.005s
ok github.com/carlosframework/platform/internal/rootaws 0.007s
ok github.com/carlosframework/platform/internal/s3compat 0.007s
ok github.com/carlosframework/platform/internal/safefile 0.004s
ok github.com/carlosframework/platform/internal/schedule 2.241s
ok github.com/carlosframework/platform/internal/sim/hostlife 2.494s
ok github.com/carlosframework/platform/internal/sim/litestreamfacts 3.623s
ok github.com/carlosframework/platform/internal/status 0.037s
ok github.com/carlosframework/platform/internal/steering 0.005s
ok github.com/carlosframework/platform/internal/stopevidence 0.173s
ok github.com/carlosframework/platform/internal/svcrole 0.002s
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.004s
ok github.com/carlosframework/platform/internal/unixdial 0.003s
ok github.com/carlosframework/platform/internal/updatewords 0.002s
ok github.com/carlosframework/platform/internal/usage 0.004s
ok github.com/carlosframework/platform/internal/version 0.002s
ok github.com/carlosframework/platform/internal/vet 1.558s
=== 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: downloading https://github.com/minio/minio/releases/download/RELEASE.2025-09-07T16-13-09Z/minio.linux-arm64.RELEASE.2025-09-07T16-13-09Z
minio: downloaded, digest verified
mc: downloading https://github.com/minio/mc/releases/download/RELEASE.2025-08-13T08-35-41Z/mc.linux-arm64.RELEASE.2025-08-13T08-35-41Z
mc: downloaded, 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.025s
=== RUN TestT4StorageProbeMinio
2026/10/12 11:26:53 moves: T4: STORE: list: operation error S3: ListObjectsV2, https response error StatusCode: 403, RequestID: 18DDC49EF6B29873, 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.016s
minio contract: PASS
diff --git a/home_region_comparison_test.go b/home_region_comparison_test.go| index 799a41282..93d43918c 100644 |
| --- a/home_region_comparison_test.go |
| +++ b/home_region_comparison_test.go |
| @@ -13,32 +13,78 @@ import ( |
| ) |
| // String fields cannot encode the region vocabulary in their Go types. This |
| -// lint catches direct comparisons at the recognizable record/engine boundary; |
| -// runtime witnesses cover aliases carried through local variables and calls. |
| -func plan13MixedRegion(x *ast.BinaryExpr) bool { |
| - if x.Op != token.EQL && x.Op != token.NEQ { |
| - return false |
| +// lint checks scalar comparisons, call arguments and map keys at recognizable |
| +// record/engine boundaries. Runtime witnesses cover local variable aliases. |
| +func plan13Expr(e ast.Node) string { |
| + var b bytes.Buffer |
| + _ = printer.Fprint(&b, token.NewFileSet(), e) |
| + return b.String() |
| +} |
| +func plan13Placement(s string) bool { |
| + if strings.Contains(s, "PlacementRegion(") { |
| + return true |
| } |
| - render := func(e ast.Expr) string { |
| - var b bytes.Buffer |
| - _ = printer.Fprint(&b, token.NewFileSet(), e) |
| - return b.String() |
| + return s == "to" || s == "from" || s == "rec.Region" || s == "record.Region" || s == "recRegion" || s == "mv.To" || s == "mv.From" || s == "m.To" || s == "m.From" || strings.HasSuffix(s, ".Move.To") || strings.HasSuffix(s, ".Move.From") |
| +} |
| +func plan13Engine(s string) bool { |
| + if strings.Contains(s, ".Intent[") && (strings.HasSuffix(s, `["to"]`) || strings.HasSuffix(s, `["region"]`)) { |
| + return true |
| } |
| - placement := func(s string) bool { |
| - return s == "rec.Region" || s == "record.Region" || s == "recRegion" || s == "mv.To" || s == "mv.From" || s == "m.To" || s == "m.From" || strings.HasSuffix(s, ".Move.To") || strings.HasSuffix(s, ".Move.From") |
| + if strings.HasSuffix(s, ".Region()") || strings.HasSuffix(s, ".Touched.Regions") || strings.HasSuffix(s, ".QuietAfter") { |
| + return true |
| } |
| - engine := func(s string) bool { |
| - if strings.Contains(s, ".Intent[") && (strings.HasSuffix(s, `["to"]`) || strings.HasSuffix(s, `["region"]`)) { |
| - return true |
| + switch s { |
| + case "actor.Region", "src.Region", "dst.Region", "b.Region", "engine.Region", "e.Region", "r.Holder.Region", "in.SourceRegion", "in.TargetRegion", "s.Region", "a.Region", "o.Region", "m.Region", "t.Region", "fence.Region", "verified.Region", "regions": |
| + return true |
| + } |
| + return false |
| +} |
| +func plan13Operand(e ast.Expr, classify func(string) bool) bool { |
| + if classify(plan13Expr(e)) { |
| + return true |
| + } |
| + switch x := e.(type) { |
| + case *ast.ParenExpr: |
| + return plan13Operand(x.X, classify) |
| + case *ast.CallExpr: |
| + switch plan13Expr(x.Fun) { |
| + case "park.SameRegion", "regionname.SameRegion", "SameRegion", "park.PlacementMatches", "PlacementMatches", "park.QuietUntil", "QuietUntil": |
| + return false |
| } |
| - switch s { |
| - case "actor.Region", "src.Region", "dst.Region", "b.Region", "engine.Region", "e.Region", "r.Holder.Region", "in.SourceRegion", "in.TargetRegion": |
| - return true |
| + for _, arg := range x.Args { |
| + if plan13Operand(arg, classify) { |
| + return true |
| + } |
| } |
| + } |
| + return false |
| +} |
| +func plan13MixedRegion(x *ast.BinaryExpr) bool { |
| + if x.Op != token.EQL && x.Op != token.NEQ { |
| return false |
| } |
| - a, b := render(x.X), render(x.Y) |
| - return placement(a) && engine(b) || placement(b) && engine(a) |
| + return plan13Operand(x.X, plan13Placement) && plan13Operand(x.Y, plan13Engine) || plan13Operand(x.Y, plan13Placement) && plan13Operand(x.X, plan13Engine) |
| +} |
| +func plan13MixedRegionNode(n ast.Node) bool { |
| + switch x := n.(type) { |
| + case *ast.BinaryExpr: |
| + return plan13MixedRegion(x) |
| + case *ast.IndexExpr: |
| + return plan13Operand(x.X, plan13Engine) && plan13Operand(x.Index, plan13Placement) |
| + case *ast.CallExpr: |
| + name := plan13Expr(x.Fun) |
| + switch name { |
| + case "park.SameRegion", "regionname.SameRegion", "SameRegion", "park.PlacementMatches", "PlacementMatches", "park.QuietUntil", "QuietUntil": |
| + return false |
| + } |
| + placement, engine := false, false |
| + for _, arg := range x.Args { |
| + placement = placement || plan13Operand(arg, plan13Placement) |
| + engine = engine || plan13Operand(arg, plan13Engine) |
| + } |
| + return placement && engine |
| + } |
| + return false |
| } |
| func TestPlan13NoDirectPlacementEngineComparison(t *testing.T) { |
| @@ -61,8 +107,8 @@ func TestPlan13NoDirectPlacementEngineComparison(t *testing.T) { |
| return err |
| } |
| ast.Inspect(f, func(n ast.Node) bool { |
| - if b, ok := n.(*ast.BinaryExpr); ok && plan13MixedRegion(b) { |
| - t.Errorf("%s: placement and engine regions need the shared rule", fset.Position(b.Pos())) |
| + if plan13MixedRegionNode(n) { |
| + t.Errorf("%s: placement and engine regions need the shared rule: %s", fset.Position(n.Pos()), plan13Expr(n)) |
| } |
| return true |
| }) |
| @@ -74,7 +120,7 @@ func TestPlan13NoDirectPlacementEngineComparison(t *testing.T) { |
| } |
| func TestPlan13RegionComparisonLintWitness(t *testing.T) { |
| - for _, expr := range []string{`rec.Region != src.Region`, `rec.Move.To == c.Op.Intent["to"]`, `in.SourceRegion == mv.From`} { |
| + for _, expr := range []string{`rec.Region != src.Region`, `rec.Move.To == c.Op.Intent["to"]`, `in.SourceRegion == mv.From`, `park.PlacementRegion(rec.Region) != src.Region`, `rec.Move.To == s.Region`, `m.From == m.Region`, `rec.Region == fence.Region()`, `to == c.Op.Intent["to"]`, `canonical(rec.Region) == canonical(actor.Region)`} { |
| e, err := parser.ParseExpr(expr) |
| if err != nil { |
| t.Fatal(err) |
| @@ -83,7 +129,7 @@ func TestPlan13RegionComparisonLintWitness(t *testing.T) { |
| t.Fatal("missed", expr) |
| } |
| } |
| - for _, expr := range []string{`rec.Region != cfg.Region`, `rec.Move.To == rec.Region`, `park.PlacementRegion(rec.Region) != src.Region`, `receipt.Region != box.Region`} { |
| + for _, expr := range []string{`rec.Region != cfg.Region`, `rec.Move.To == rec.Region`, `receipt.Region != box.Region`, `canonical(rec.Region) != canonical(cfg.Region)`} { |
| e, err := parser.ParseExpr(expr) |
| if err != nil { |
| t.Fatal(err) |
| @@ -93,3 +139,42 @@ func TestPlan13RegionComparisonLintWitness(t *testing.T) { |
| } |
| } |
| } |
| + |
| +func TestPlan13RegionCallAndMapLintWitness(t *testing.T) { |
| + for _, expr := range []string{ |
| + `containsString(c.Touched.Regions, rec.Move.To)`, |
| + `slices.Contains(c.Touched.Regions, rec.Move.To)`, |
| + `strings.EqualFold(rec.Region, actor.Region)`, |
| + `same(rec.Move.From, verified.Region())`, |
| + `same(canonical(rec.Move.From), canonical(verified.Region()))`, |
| + `c.QuietAfter[rec.Move.To]`, |
| + `c.QuietAfter[canonical(rec.Move.To)]`, |
| + `regions[rec.Region]`, |
| + `slices.Contains(c.Touched.Regions, park.PlacementRegion(rec.Move.To))`, |
| + `c.QuietAfter[park.PlacementRegion(rec.Move.To)]`, |
| + } { |
| + e, err := parser.ParseExpr(expr) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if !plan13MixedRegionNode(e) { |
| + t.Fatal("missed", expr) |
| + } |
| + } |
| + for _, expr := range []string{ |
| + `containsString(c.Touched.Boxes, box)`, |
| + `strings.EqualFold(rec.Region, cfg.Region)`, |
| + `park.PlacementMatches(home, rec.Region, actor.Region)`, |
| + `park.SameRegion(home, park.PlacementRegion(rec.Region), actor.Region)`, |
| + `park.QuietUntil(c.QuietAfter, home, park.PlacementRegion(rec.Move.To))`, |
| + `fleet.targets[rec.Region]`, |
| + } { |
| + e, err := parser.ParseExpr(expr) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if plan13MixedRegionNode(e) { |
| + t.Fatal("false positive", expr) |
| + } |
| + } |
| +} |
diff --git a/internal/console/apiinstancemove.go b/internal/console/apiinstancemove.go| index 11f42b8cb..ca41bae39 100644 |
| --- a/internal/console/apiinstancemove.go |
| +++ b/internal/console/apiinstancemove.go |
| @@ -251,7 +251,11 @@ func stagingCodes(caps map[string]placementCapability) string { |
| // record's "" (the unconstrained value the fleet's reconcile has always |
| // scoped by). The record NEVER stores the code; the API never shows "". |
| func (s *Server) homeRegionIn(region string) string { |
| - return homePlacementCode(s.cfg.HomeRegion, region) |
| + home := s.cfg.HomeRegion |
| + if s.cfg.HostClaim { |
| + home = s.cfg.dbClaimHomeRegion() |
| + } |
| + return homePlacementCode(home, region) |
| } |
| // homePlacementCode is homeRegionIn's rule for a given home name: the |
| @@ -264,6 +268,9 @@ func homePlacementCode(home, region string) string { |
| func (s *Server) homeRegionOut(region string) string { |
| if region == "" { |
| + if s.cfg.HostClaim { |
| + return s.cfg.dbClaimHomeRegion() |
| + } |
| return s.cfg.HomeRegion |
| } |
| return region |
| @@ -767,7 +774,7 @@ func (s *Server) startClaimModeMove(w http.ResponseWriter, r *http.Request, acct |
| ctx := r.Context() |
| a := s.cfg.ClaimAuthority |
| to := ms.To |
| - if !claimMoveRosterPlaces(roster.Roster, obs.Claim.Op.Exec, target, recRegion, to, s.cfg.HomeRegion) { |
| + if !roster.Roster.MatchesHome(s.cfg.dbClaimHomeRegion()) || !claimMoveRosterPlaces(roster.Roster, obs.Claim.Op.Exec, target, recRegion, to, s.cfg.dbClaimHomeRegion()) { |
| jsonError(w, http.StatusConflict, "the host roster does not allow this move. the source and target must be active boxes in "+from+" and "+to) |
| return |
| } |
| @@ -804,7 +811,7 @@ func (s *Server) startClaimModeMove(w http.ResponseWriter, r *http.Request, acct |
| } |
| return |
| } |
| - moving, err := hostops.BeginClaimMove(ctx, s.cfg.Releases, a.Directory, h, obs, ms.ID, ms.By, target, to, ms.Now, s.cfg.HomeRegion) |
| + moving, err := hostops.BeginClaimMove(ctx, s.cfg.Releases, a.Directory, h, obs, ms.ID, ms.By, target, to, ms.Now, s.cfg.dbClaimHomeRegion()) |
| if err != nil { |
| switch { |
| case errors.Is(err, hostops.ErrIdentity), errors.Is(err, hostactors.ErrRoster): |
| @@ -1568,7 +1575,7 @@ func (s *Server) regionHasLiveBox(ctx context.Context, region string) (live, kno |
| aws = c.AWSRegion |
| } |
| } |
| - return len(eligibleBoxes(snap, roster, region, aws, s.cfg.HomeRegion, s.expectedSecretsFP(), nil)) > 0, true |
| + return len(eligibleBoxes(snap, roster, region, aws, s.cfg.dbClaimHomeRegion(), s.expectedSecretsFP(), nil)) > 0, true |
| } |
| // ---- the delete door during a move (Plan 6 Task 2b, preflight L6) ---- |
diff --git a/internal/console/apiinstancemove_claim_test.go b/internal/console/apiinstancemove_claim_test.go| index 0604c3427..99698a97a 100644 |
| --- a/internal/console/apiinstancemove_claim_test.go |
| +++ b/internal/console/apiinstancemove_claim_test.go |
| @@ -2,6 +2,7 @@ package console |
| import ( |
| "context" |
| + "crypto/ed25519" |
| "crypto/sha256" |
| "encoding/hex" |
| "encoding/json" |
| @@ -571,6 +572,16 @@ func TestMoveDoorHostClaimPlacesAHomeSource(t *testing.T) { |
| if rec, err := provision.GetRecord(ctx, fx.rel, "bab", "titogo", "acme.go.tito.io"); err != nil || rec.Region != "" { |
| t.Fatalf("fixture: the record is not home's: %+v %v", rec, err) |
| } |
| + d := hostactors.Directory{Store: fx.rel, PublicKey: fx.priv.Public().(ed25519.PublicKey), Bucket: moveTestBucket} |
| + roster, err := d.Read(ctx) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + next := roster.Roster |
| + next.HomeRegion = "ie" |
| + if _, err := d.Replace(ctx, release.RootSigner(fx.priv), roster, next); err != nil { |
| + t.Fatal(err) |
| + } |
| armClaimMoves(t, fx, true, true, nil) |
| code, _, out := rawReq(t, http.MethodPost, u+"/acme.go.tito.io/move", token, `{"region":"uk"}`) |
| if code != http.StatusAccepted { |
diff --git a/internal/console/claim_invariant_world_test.go b/internal/console/claim_invariant_world_test.go| index e7afb98fd..ac9e74b26 100644 |
| --- a/internal/console/claim_invariant_world_test.go |
| +++ b/internal/console/claim_invariant_world_test.go |
| @@ -545,6 +545,18 @@ func newClaimCaseWith(t *testing.T, opts ...func(*Config)) *claimCase { |
| for _, opt := range opts { |
| opt(&cfg) |
| } |
| + if home := cfg.dbClaimHomeRegion(); home != "" { |
| + d := hostactors.Directory{Store: rawStore, PublicKey: pub, Bucket: claimFxBucket} |
| + o, e := d.Read(ctx) |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + r := o.Roster |
| + r.HomeRegion = home |
| + if _, e = d.Replace(ctx, release.RootSigner(priv), o, r); e != nil { |
| + t.Fatal(e) |
| + } |
| + } |
| srv, err = NewServer(cfg, st) |
| if err != nil { |
| t.Fatalf("NewServer with HostClaim: %v", err) |
diff --git a/internal/console/claim_runtime.go b/internal/console/claim_runtime.go| index 6192b81f0..570ae1d52 100644 |
| --- a/internal/console/claim_runtime.go |
| +++ b/internal/console/claim_runtime.go |
| @@ -62,6 +62,9 @@ func (s *Server) ConfigureClaimRuntime(ctx context.Context) error { |
| if err != nil { |
| return err |
| } |
| + if !roster.Roster.MatchesHome(s.cfg.dbClaimHomeRegion()) { |
| + return hostops.ErrContract |
| + } |
| self, ok := roster.Roster.Consoles[s.cfg.HostConsoleID] |
| if !ok || len(self.Namespaces) != 1 { |
| return hostops.ErrContract |
diff --git a/internal/console/movesrecovery.go b/internal/console/movesrecovery.go| index 0d97102cd..2b60f6496 100644 |
| --- a/internal/console/movesrecovery.go |
| +++ b/internal/console/movesrecovery.go |
| @@ -366,7 +366,7 @@ func (s *Server) movesRecovery(ctx context.Context, g *hostops.MoveRecoveryGrant |
| s.pageMove(ctx, movePageCloseOverdue, o, c, rec, now) |
| return nil |
| case moveActAbortOverdue: |
| - if s.moveAbortOverdue(o, park.QuietUntil(c.QuietAfter, s.cfg.HomeRegion, park.PlacementRegion(rec.Move.To)), now) { |
| + if s.moveAbortOverdue(o, park.QuietUntil(c.QuietAfter, s.cfg.dbClaimHomeRegion(), park.PlacementRegion(rec.Move.To)), now) { |
| s.pageMove(ctx, movePageAbortOverdue, o, c, rec, now) |
| } |
| return nil |
diff --git a/internal/console/plan13_region_test.go b/internal/console/plan13_region_test.go| index 0f3ad3048..5a0ed2e3f 100644 |
| --- a/internal/console/plan13_region_test.go |
| +++ b/internal/console/plan13_region_test.go |
| @@ -1,6 +1,7 @@ |
| package console |
| import ( |
| + "github.com/carlosframework/platform/internal/hostactors" |
| "github.com/carlosframework/platform/internal/park" |
| "testing" |
| ) |
| @@ -34,3 +35,29 @@ func TestPlan13ConsoleClaimRegion(t *testing.T) { |
| } |
| } |
| } |
| + |
| +func TestPlan13ConsoleResolvedHomeAgreement(t *testing.T) { |
| + for _, cfg := range []Config{ |
| + {HostClaim: true, HomeRegion: "ie", HibernateRegion: "eu-west-1", HibernateBucket: "data"}, |
| + {HostClaim: true, HibernateRegion: "eu-west-1", HibernateBucket: "data"}, |
| + } { |
| + home := cfg.dbClaimHomeRegion() |
| + if !(hostactors.Roster{HomeRegion: home}).MatchesHome(home) { |
| + t.Fatal(cfg) |
| + } |
| + if (hostactors.Roster{HomeRegion: "ca"}).MatchesHome(home) { |
| + t.Fatal("conflicting console home accepted") |
| + } |
| + } |
| +} |
| + |
| +func TestPlan13UnnamedHomeMoveNames(t *testing.T) { |
| + s := &Server{cfg: Config{HostClaim: true, HibernateRegion: "eu-west-1", HibernateBucket: "data"}} |
| + if s.homeRegionOut("") != "eu-west-1" || s.homeRegionIn("eu-west-1") != "" { |
| + t.Fatal("move door used a second home binding") |
| + } |
| + s.cfg.HostClaim = false |
| + if s.homeRegionOut("") != "" || s.homeRegionIn("eu-west-1") != "eu-west-1" { |
| + t.Fatal("flag-off changed") |
| + } |
| +} |
diff --git a/internal/host/claimassembly.go b/internal/host/claimassembly.go| index 9c51e3d7f..0e6e41ae1 100644 |
| --- a/internal/host/claimassembly.go |
| +++ b/internal/host/claimassembly.go |
| @@ -156,7 +156,7 @@ func (a ClaimAssembly) Build(ctx context.Context) (ClaimProviders, error) { |
| } |
| a.Guard.Claims = hostguard.StoreReader{Store: a.Claims} |
| // Continue is the moves C1 session table, nil unless both flags are on (D1). |
| - source := hostbox.WorkSource{HomeRegion: actor.HomeRegion, Guard: a.Guard, Box: actor.Box, Region: park.PlacementRegion(actor.Region), Now: time.Now, Policy: a.Policy.Check, Local: a.Identity.Local(a.Local), Continue: a.Wake.Continue} |
| + source := hostbox.WorkSource{FleetHomeRegion: actor.FleetHomeRegion, HomeRegion: actor.HomeRegion, Guard: a.Guard, Box: actor.Box, Region: park.PlacementRegion(actor.Region), Now: time.Now, Policy: a.Policy.Check, Local: a.Identity.Local(a.Local), Continue: a.Wake.Continue} |
| a.Reconcile.Reconciler.Claims = a.Claims |
| a.Reconcile.Reconciler.Guard = a.Guard |
| a.Reconcile.Reconciler.Writes = hostwrite.Config{Enabled: true, Selector: source} |
diff --git a/internal/host/claimcleanup_identity.go b/internal/host/claimcleanup_identity.go| index 750032d1d..5b2d8cca3 100644 |
| --- a/internal/host/claimcleanup_identity.go |
| +++ b/internal/host/claimcleanup_identity.go |
| @@ -35,7 +35,7 @@ func verifyClaimCleanupEnrollment(ctx context.Context, identity hostbox.Identity |
| if !ok || roster.Roster.RequestLimitNS != int64(hostbox.RequestLimit) { |
| return nil, ErrClaimStopIdentity |
| } |
| - actor := hostbox.Actor{ControlBucket: directory.Bucket, Box: row.BoxID, Enrollment: row.Enrollment, Revision: roster.Roster.Revision, Region: row.Region, Bucket: identity.Bucket, Namespace: row.Namespace, Cloud: hostbox.CloudIdentity{Provider: row.Cloud.Provider, Account: row.Cloud.Account, Region: row.Cloud.Region, InstanceID: row.Cloud.InstanceID}} |
| + actor := hostbox.Actor{FleetHomeRegion: roster.Roster.HomeRegion, ControlBucket: directory.Bucket, Box: row.BoxID, Enrollment: row.Enrollment, Revision: roster.Roster.Revision, Region: row.Region, Bucket: identity.Bucket, Namespace: row.Namespace, Cloud: hostbox.CloudIdentity{Provider: row.Cloud.Provider, Account: row.Cloud.Account, Region: row.Cloud.Region, InstanceID: row.Cloud.InstanceID}} |
| identity.Roster = cleanupCloudRoster{actor} |
| if _, err := identity.Verify(ctx); err != nil { |
| return nil, err |
diff --git a/internal/host/claimhandover_recovery.go b/internal/host/claimhandover_recovery.go| index 70bd9c624..38a190dbb 100644 |
| --- a/internal/host/claimhandover_recovery.go |
| +++ b/internal/host/claimhandover_recovery.go |
| @@ -73,7 +73,7 @@ func (h claimCrossBoxHandover) recoveryEvidence(ctx context.Context, o hostclaim |
| if err != nil { |
| return err |
| } |
| - if verified.Box() != p.Box || !park.SameRegion(op.Intent["home_region"], verified.Region(), op.Intent["to"]) { |
| + if (op.Intent["home_region"] != "" && op.Intent["home_region"] != verified.FleetHomeRegion()) || verified.Box() != p.Box || !park.SameRegion(op.Intent["home_region"], verified.Region(), op.Intent["to"]) { |
| return hostclaim.ErrFence |
| } |
| return nil |
diff --git a/internal/host/claimlaunch_authority.go b/internal/host/claimlaunch_authority.go| index 7e7bb5e3d..e7258733b 100644 |
| --- a/internal/host/claimlaunch_authority.go |
| +++ b/internal/host/claimlaunch_authority.go |
| @@ -106,7 +106,7 @@ func (h *ClaimLaunchRoot) authorize(ctx context.Context, peer StopPeer, q hostbo |
| if err := h.Kernel.VerifyDigest(ctx, q.Executable, r.Executable, q.Spec.Digest); err != nil { |
| return err |
| } |
| - selection := hostbox.Selection{Host: t.Host, Action: claimSpawnAction(c), Box: t.Box, Region: t.Region, Captured: hostguard.Captured{Incarnation: t.Incarnation, Stamp: t.Stamp}, Kind: c.Op.Kind, Op: c.Op.ID, Phase: c.Op.Phase, Generation: c.Holder.Generation, Session: t.Session} |
| + selection := hostbox.Selection{HomeRegion: actor.HomeRegion, FleetHomeRegion: actor.FleetHomeRegion, Host: t.Host, Action: claimSpawnAction(c), Box: t.Box, Region: t.Region, Captured: hostguard.Captured{Incarnation: t.Incarnation, Stamp: t.Stamp}, Kind: c.Op.Kind, Op: c.Op.ID, Phase: c.Op.Phase, Generation: c.Holder.Generation, Session: t.Session} |
| if err := h.Local.Check(ctx, actor, c, selection); err != nil { |
| return err |
| } |
diff --git a/internal/host/claimproduction.go b/internal/host/claimproduction.go| index cdfd3ff8a..c5958ae80 100644 |
| --- a/internal/host/claimproduction.go |
| +++ b/internal/host/claimproduction.go |
| @@ -64,6 +64,7 @@ func RegisterProductionClaimMoveSources(s ClaimProductionMoveSources) error { |
| // and the directory's row for the box, whose enrollment and cloud instance |
| // are the enrollment's. |
| type productionRosterRow struct { |
| + home string |
| pub ed25519.PublicKey |
| enrollment hostfloor.Enrollment |
| directory hostactors.Directory |
| @@ -92,7 +93,7 @@ func readProductionRosterRow(ctx context.Context, store release.Store, getenv fu |
| if !ok || row.Enrollment != enrollment.Enrollment || row.Cloud.InstanceID != enrollment.InstanceID { |
| return rr, hostbox.ErrChanged |
| } |
| - return productionRosterRow{pub: pub, enrollment: enrollment, directory: directory, row: row}, nil |
| + return productionRosterRow{home: roster.Roster.HomeRegion, pub: pub, enrollment: enrollment, directory: directory, row: row}, nil |
| } |
| // ProductionClaimOwnerProvider constructs claim-owned authority directly. Only |
| @@ -118,7 +119,7 @@ func (ProductionClaimOwnerProvider) OpenClaimOwners(ctx context.Context, store r |
| return out, hostwrite.ErrUnavailable |
| } |
| authority := productionClaimRoster{Directory: directory, DataBucket: dataBucket, FloorDir: filepath.Join(r.OptDir, "system", "guard")} |
| - _, homeRegion, err := claimRegionBinding(row.Region, os.Getenv("CARLOS_REGION")) |
| + _, homeRegion, err := claimRegionBinding(row.Region, os.Getenv("CARLOS_REGION"), rr.home) |
| if err != nil { |
| return out, err |
| } |
| @@ -345,7 +346,7 @@ func (p productionClaimRoster) Actor(ctx context.Context, box string) (hostbox.A |
| if local.Digest != remote.Digest || o.Roster.Publication != "published" || o.Roster.FloorSHA256 != remote.Digest { |
| return a, hostfloor.ErrFloor |
| } |
| - return hostbox.Actor{ControlBucket: p.Directory.Bucket, Box: box, Enrollment: b.Enrollment, Revision: o.Roster.Revision, Region: b.Region, Bucket: p.DataBucket, Namespace: b.Namespace, Eligible: true, Cloud: hostbox.CloudIdentity{Provider: b.Cloud.Provider, Account: b.Cloud.Account, Region: b.Cloud.Region, InstanceID: b.Cloud.InstanceID}}, nil |
| + return hostbox.Actor{FleetHomeRegion: o.Roster.HomeRegion, ControlBucket: p.Directory.Bucket, Box: box, Enrollment: b.Enrollment, Revision: o.Roster.Revision, Region: b.Region, Bucket: p.DataBucket, Namespace: b.Namespace, Eligible: true, Cloud: hostbox.CloudIdentity{Provider: b.Cloud.Provider, Account: b.Cloud.Account, Region: b.Cloud.Region, InstanceID: b.Cloud.InstanceID}}, nil |
| } |
| type productionClaimRegional struct { |
| @@ -366,7 +367,7 @@ func (p productionClaimRegional) View(ctx context.Context, o hostclaim.Observed) |
| if o.Claim == nil { |
| return park.View{}, park.ErrUnsafe |
| } |
| - home, err := park.BoundHome(actor.HomeRegion, o.Claim.Op.Intent["home_region"]) |
| + home, err := park.VerifiedHome(actor.FleetHomeRegion, actor.HomeRegion, actor.Region, o.Claim.Op.Intent["home_region"]) |
| if err != nil { |
| return park.View{}, err |
| } |
| @@ -458,7 +459,7 @@ func (t claimAdminTokens) With(ctx context.Context, host string, effect func() e |
| return err |
| } |
| c, h := o.Claim, o.Claim.Holder |
| - sel := hostbox.Selection{Host: host, Action: "spawn", Box: actor.Box, Region: actor.Region, Captured: hostguard.Captured{Incarnation: h.Incarnation, Stamp: h.Stamp}, Kind: c.Op.Kind, Op: c.Op.ID, Phase: c.Op.Phase, Generation: h.Generation, Session: h.Session} |
| + sel := hostbox.Selection{HomeRegion: actor.HomeRegion, FleetHomeRegion: actor.FleetHomeRegion, Host: host, Action: "spawn", Box: actor.Box, Region: actor.Region, Captured: hostguard.Captured{Incarnation: h.Incarnation, Stamp: h.Stamp}, Kind: c.Op.Kind, Op: c.Op.ID, Phase: c.Op.Phase, Generation: h.Generation, Session: h.Session} |
| check := func(c *hostclaim.Claim) error { |
| if c.Op.Kind != sel.Kind || c.Op.ID != sel.Op || c.Op.Phase != sel.Phase || c.Holder.Incarnation != sel.Captured.Incarnation || c.Holder.Stamp != sel.Captured.Stamp || c.Holder.Session != sel.Session || c.Holder.Generation != sel.Generation { |
| return hostbox.ErrChanged |
diff --git a/internal/host/claimrecoveryreceipt.go b/internal/host/claimrecoveryreceipt.go| index b9d9c26d5..5d6052101 100644 |
| --- a/internal/host/claimrecoveryreceipt.go |
| +++ b/internal/host/claimrecoveryreceipt.go |
| @@ -46,7 +46,7 @@ func (h claimCrossBoxHandover) recoveryStopReceipt(ctx context.Context, o hostcl |
| return err |
| } |
| box, ok := roster.Roster.Boxes[p.Box] |
| - if !ok || box.Enrollment != r.Enrollment || !park.SameRegion("", box.Region, r.Region) { |
| + if (c.Op.Intent["home_region"] != "" && !roster.Roster.MatchesHome(c.Op.Intent["home_region"])) || !ok || box.Enrollment != r.Enrollment || !park.SameRegion("", box.Region, r.Region) { |
| return ErrClaimStopIdentity |
| } |
| sig, _, err := h.Evidence.Get(ctx, key+".sig") |
diff --git a/internal/host/claimregion.go b/internal/host/claimregion.go| index 7fb2e8a41..673c391cb 100644 |
| --- a/internal/host/claimregion.go |
| +++ b/internal/host/claimregion.go |
| @@ -5,15 +5,18 @@ import ( |
| "github.com/carlosframework/platform/internal/park" |
| ) |
| -// The signed roster retains the claim's engine name. CARLOS_REGION supplies |
| -// this box's placement code; only its empty home placement binds an alias. |
| -func claimRegionBinding(roster, code string) (engine, home string, err error) { |
| +// The signed roster supplies both the engine name and the fleet home. |
| +// An empty local placement binds an alias only when those names agree. |
| +func claimRegionBinding(roster, code, fleetHome string) (engine, home string, err error) { |
| engine = park.PlacementRegion(roster) |
| placement := park.PlacementRegion(code) |
| if roster == "" || engine == "" || placement == "" || (code != "" && engine != placement) { |
| return "", "", hostbox.ErrChanged |
| } |
| if code == "" { |
| + if fleetHome == "" || roster != fleetHome { |
| + return "", "", hostbox.ErrChanged |
| + } |
| home = engine |
| } |
| return engine, home, nil |
diff --git a/internal/host/movesland_probe_test.go b/internal/host/movesland_probe_test.go| index aae06d1fd..467141f59 100644 |
| --- a/internal/host/movesland_probe_test.go |
| +++ b/internal/host/movesland_probe_test.go |
| @@ -66,7 +66,10 @@ func (p *probeLog) wake(ctx context.Context, cfg activator.ProbeConfig) (activat |
| } |
| // The predecessor's region is the probe owners' own rule: the last |
| // admission's region, or the move's source's engine region. |
| - from := predecessorRegion(h, m.From) |
| + from, err := predecessorRegion(h, m.From) |
| + if err != nil { |
| + return activator.ProbeResult{}, err |
| + } |
| admitted, err := hostclaim.Update(ctx, p.h.s, obs, hostclaim.AdmitWriter(hostclaim.Admission{ObservedOpID: p1ID, ObservedPhase: provision.LandProbing, |
| ObservedSession: h.Session, ObservedGeneration: h.Generation, Box: "uk-box", Mode: "cold", NewGeneration: gen, KeepStamp: h.Stamp > h.Session, Region: "uk", PredecessorRegion: from})) |
| if err != nil { |
diff --git a/internal/host/movesphase1.go b/internal/host/movesphase1.go| index 192deae17..ba94c7068 100644 |
| --- a/internal/host/movesphase1.go |
| +++ b/internal/host/movesphase1.go |
| @@ -11,6 +11,7 @@ import ( |
| "log" |
| "os" |
| "path/filepath" |
| + "slices" |
| "strings" |
| "time" |
| @@ -319,7 +320,9 @@ func (w *movesWorker) sourceRound(ctx context.Context, s release.Store, rec prov |
| // K12 and T9: the target region and box are in touched before the |
| // first staging write. |
| targetBox := obs.Claim.Op.Intent["target"] |
| - if !containsString(obs.Claim.Touched.Regions, park.PlacementRegion(rec.Move.To)) || (targetBox != "" && !containsString(obs.Claim.Touched.Boxes, targetBox)) { |
| + if !slices.ContainsFunc(obs.Claim.Touched.Regions, func(region string) bool { |
| + return park.PlacementMatches(obs.Claim.Op.Intent["home_region"], rec.Move.To, region) |
| + }) || (targetBox != "" && !containsString(obs.Claim.Touched.Boxes, targetBox)) { |
| var boxes []string |
| if targetBox != "" { |
| boxes = []string{targetBox} |
diff --git a/internal/host/movespolicy.go b/internal/host/movespolicy.go| index e60ba6afd..0d0df30fb 100644 |
| --- a/internal/host/movespolicy.go |
| +++ b/internal/host/movespolicy.go |
| @@ -297,7 +297,10 @@ func MovesClaimPolicyWithInputs(read MovesRecordReader, committed func(context.C |
| if len(c.Op.Participants) != 2 || c.Op.Participants[1] != s.Box || c.Op.Intent["target"] != s.Box || c.Op.Intent["source"] == s.Box { |
| return refuse("this box is not the move's target", nil) |
| } |
| - if !park.SameRegion(c.Op.Intent["home_region"], c.Op.Intent["to"], s.Region) { |
| + if _, err := park.VerifiedHome(s.FleetHomeRegion, s.HomeRegion, s.Region, c.Op.Intent["home_region"]); err != nil { |
| + return refuse("this region is not the move's target region", err) |
| + } |
| + if !park.SameRegion(s.HomeRegion, c.Op.Intent["to"], s.Region) { |
| return refuse("this region is not the move's target region", nil) |
| } |
| for _, lost := range c.Lost { |
| @@ -416,7 +419,10 @@ func movesRecoveryPolicy(ctx context.Context, read MovesRecordReader, c *hostcla |
| if len(c.Op.Participants) == 0 || c.Op.Participants[0] != s.Box || c.Op.Intent["source"] != s.Box || c.Op.Intent["target"] == s.Box { |
| return refuse("this box is not the move's source", nil) |
| } |
| - if park.SameRegion(c.Op.Intent["home_region"], c.Op.Intent["to"], s.Region) { |
| + if _, err := park.VerifiedHome(s.FleetHomeRegion, s.HomeRegion, s.Region, c.Op.Intent["home_region"]); err != nil { |
| + return refuse("this region is the move's target region", err) |
| + } |
| + if park.SameRegion(s.HomeRegion, c.Op.Intent["to"], s.Region) { |
| return refuse("this region is the move's target region", nil) |
| } |
| for _, lost := range c.Lost { |
| @@ -437,7 +443,7 @@ func movesRecoveryPolicy(ctx context.Context, read MovesRecordReader, c *hostcla |
| if rec == nil || rec.Host != c.Host || rec.Move == nil || rec.Move.ID != s.Op || rec.Move.Incarnation != c.Holder.Incarnation { |
| return refuse("the record is not this move", nil) |
| } |
| - if !park.PlacementMatches(c.Op.Intent["home_region"], rec.Move.From, s.Region) { |
| + if !park.PlacementMatches(s.HomeRegion, rec.Move.From, s.Region) { |
| return refuse("this region is not the move's source region", nil) |
| } |
| var ( |
diff --git a/internal/host/movesprobeowners.go b/internal/host/movesprobeowners.go| index ac9f2a678..ae575bcfb 100644 |
| --- a/internal/host/movesprobeowners.go |
| +++ b/internal/host/movesprobeowners.go |
| @@ -92,6 +92,7 @@ import ( |
| // Production: nothing registers this owner set yet. The probe is unarmed in |
| // production until Plan 5 Task 9 arms the parks; the tests use fakes. |
| type movesProbeOwners struct { |
| + HomeRegion, FleetHomeRegion string |
| // Claims is the claim store (the deployment store in production). |
| Claims release.Store |
| Records MovesRecordReader |
| @@ -203,7 +204,11 @@ func (m *movesProbeOwners) landing(ctx context.Context, o hostclaim.Observed) (p |
| return v, hostbox.ErrUnsupported |
| } |
| c := o.Claim |
| - v.homeRegion = c.Op.Intent["home_region"] |
| + var bindingErr error |
| + v.homeRegion, bindingErr = park.VerifiedHome(m.FleetHomeRegion, m.HomeRegion, m.Region, c.Op.Intent["home_region"]) |
| + if bindingErr != nil { |
| + return v, bindingErr |
| + } |
| switch c.Op.Phase { |
| case hostclaim.PhaseLandingProbing: |
| case hostclaim.PhaseLandingCommitted: |
| @@ -212,7 +217,7 @@ func (m *movesProbeOwners) landing(ctx context.Context, o hostclaim.Observed) (p |
| return v, hostbox.ErrChanged |
| } |
| if c.Host != o.Host || c.Op.ID == "" || c.Op.Exec != m.Box || c.Op.Intent["target"] != m.Box || |
| - c.Op.Intent["source"] == m.Box || !park.SameRegion(c.Op.Intent["home_region"], c.Op.Intent["to"], m.Region) || len(c.Op.Participants) != 2 || c.Op.Participants[1] != m.Box { |
| + c.Op.Intent["source"] == m.Box || !park.SameRegion(m.HomeRegion, c.Op.Intent["to"], m.Region) || len(c.Op.Participants) != 2 || c.Op.Participants[1] != m.Box { |
| return v, hostbox.ErrChanged |
| } |
| for _, lost := range c.Lost { |
| @@ -229,7 +234,7 @@ func (m *movesProbeOwners) landing(ctx context.Context, o hostclaim.Observed) (p |
| return v, hostbox.ErrChanged |
| } |
| mv := rec.Move |
| - if park.PlacementRegion(mv.From) == "" || mv.ID != v.op || mv.Incarnation != v.holder.Incarnation || !park.PlacementMatches(c.Op.Intent["home_region"], mv.To, m.Region) || mv.Inputs == nil || mv.Inputs.Release == "" { |
| + if park.PlacementRegion(mv.From) == "" || mv.ID != v.op || mv.Incarnation != v.holder.Incarnation || !park.PlacementMatches(m.HomeRegion, mv.To, m.Region) || mv.Inputs == nil || mv.Inputs.Release == "" { |
| return v, hostbox.ErrChanged |
| } |
| if v.committed { |
| @@ -366,7 +371,7 @@ func (m *movesProbeOwners) descendsFromMove(h *hostclaim.Holder, g, op string) b |
| if a.Generation != g || a.Mode != "cold" { |
| continue |
| } |
| - if a.Box != m.Box || a.Region != m.Region { |
| + if a.Box != m.Box || !park.SameRegion(m.HomeRegion, a.Region, m.Region) { |
| return false |
| } |
| g, found = a.Predecessor, true |
| @@ -380,7 +385,7 @@ func (m *movesProbeOwners) descendsFromMove(h *hostclaim.Holder, g, op string) b |
| return false |
| } |
| for _, a := range h.Admissions { |
| - if a.Generation == op && a.Box == m.Box && a.Region == m.Region && a.Mode == "cold" { |
| + if a.Generation == op && a.Box == m.Box && park.SameRegion(m.HomeRegion, a.Region, m.Region) && a.Mode == "cold" { |
| return true |
| } |
| } |
| @@ -389,24 +394,18 @@ func (m *movesProbeOwners) descendsFromMove(h *hostclaim.Holder, g, op string) b |
| // predecessorRegion is AdmitWriter's predecessor region: the last |
| // admission's, or the move's source region before any. |
| -func predecessorRegion(h *hostclaim.Holder, from string) string { |
| +func predecessorRegion(h *hostclaim.Holder, from string) (string, error) { |
| + if !provision.ValidRegion(from) { |
| + return "", hostbox.ErrChanged |
| + } |
| if n := len(h.Admissions); n > 0 { |
| - return h.Admissions[n-1].Region |
| - } |
| - // from is the move's source placement code. Its engine region is the |
| - // predecessor's (park.HomeRegion for the home's ""): the claim reads a |
| - // predecessor region of "" as holding nothing anywhere and covers it |
| - // with no chore, which a home generation does not (home-region parks, |
| - // part 1). A code that is no placement is kept as it is: it names no |
| - // region, so nothing covers it by mistake. |
| - if r := park.PlacementRegion(from); r != "" { |
| - return r |
| - } |
| - return from |
| + return h.Admissions[n-1].Region, nil |
| + } |
| + return park.PlacementRegion(from), nil |
| } |
| -func (m *movesProbeOwners) request(host string, h *hostclaim.Holder, home string) genselect.Request { |
| - return genselect.Request{Bucket: m.Bucket, Region: m.Region, Host: host, Holder: park.HolderRegionView(*h, home, m.Region)} |
| +func (m *movesProbeOwners) request(host string, h *hostclaim.Holder) genselect.Request { |
| + return genselect.Request{Bucket: m.Bucket, Region: m.Region, Host: host, Holder: park.HolderRegionView(*h, m.HomeRegion, m.Region)} |
| } |
| func preparedPin(v probeLanding) genselect.PreparedPin { |
| @@ -444,7 +443,7 @@ func (m *movesProbeOwners) currentMarker(ctx context.Context, h *hostclaim.Holde |
| // certified remote once it is. |
| func (m *movesProbeOwners) baseline(ctx context.Context, host string, v probeLanding, prepared bool) (*genselect.Plan, error) { |
| if prepared { |
| - return genselect.SelectPrepared(ctx, m.Data, m.request(host, v.holder, v.homeRegion), preparedPin(v)) |
| + return genselect.SelectPrepared(ctx, m.Data, m.request(host, v.holder), preparedPin(v)) |
| } |
| digest, err := manifestDigest(v.manifest) |
| if err != nil { |
| @@ -455,9 +454,9 @@ func (m *movesProbeOwners) baseline(ctx context.Context, host string, v probeLan |
| return nil, err |
| } |
| if !m.landingMarker(marker, host, v, digest) { |
| - return genselect.SelectPrepared(ctx, m.Data, m.request(host, v.holder, v.homeRegion), preparedPin(v)) |
| + return genselect.SelectPrepared(ctx, m.Data, m.request(host, v.holder), preparedPin(v)) |
| } |
| - return genselect.Select(ctx, m.Data, m.request(host, v.holder, v.homeRegion)) |
| + return genselect.Select(ctx, m.Data, m.request(host, v.holder)) |
| } |
| // localMarkers reads .files-gen (nil when absent) and <dbPath>.gen ("" when |
| @@ -534,8 +533,12 @@ func (m *movesProbeOwners) Select(ctx context.Context, rt registry.Route, o host |
| } |
| h := v.holder |
| prepared := firstAdmission(h, m.Box) |
| + predecessor, err := predecessorRegion(h, v.rec.Move.From) |
| + if err != nil { |
| + return ClaimLaunchInputs{}, err |
| + } |
| admission := hostclaim.Admission{ObservedOpID: v.op, ObservedPhase: o.Claim.Op.Phase, ObservedSession: h.Session, ObservedGeneration: h.Generation, Box: m.Box, Mode: "cold", |
| - KeepStamp: h.Stamp > h.Session, Region: m.Region, PredecessorRegion: predecessorRegion(h, v.rec.Move.From)} |
| + KeepStamp: h.Stamp > h.Session, Region: m.Region, PredecessorRegion: predecessor} |
| if prepared { |
| files, db, err := m.localMarkers(o.Host, rt.DBPath) |
| if err != nil { |
| @@ -597,9 +600,13 @@ func (m *movesProbeOwners) AdmissionEvidence(ctx context.Context, o hostclaim.Ob |
| return err |
| } |
| h := v.holder |
| - if in.ObservedOpID != v.op || in.ObservedPhase != o.Claim.Op.Phase || in.ObservedSession != h.Session || in.ObservedGeneration != h.Generation || in.Box != m.Box || in.Region != m.Region || |
| + predecessor, err := predecessorRegion(h, v.rec.Move.From) |
| + if err != nil { |
| + return err |
| + } |
| + if in.ObservedOpID != v.op || in.ObservedPhase != o.Claim.Op.Phase || in.ObservedSession != h.Session || in.ObservedGeneration != h.Generation || in.Box != m.Box || !park.SameRegion(m.HomeRegion, in.Region, m.Region) || |
| in.Mode != "cold" || in.PredecessorCertified || in.LossID != "" || len(in.AdditionalWritesLost) != 0 || in.KeepStamp != (h.Stamp > h.Session) || |
| - in.PredecessorRegion != predecessorRegion(h, v.rec.Move.From) || baseline == nil || in.RemoteMaxTXID != baseline.MaxTXID { |
| + !park.SameRegion(v.homeRegion, in.PredecessorRegion, predecessor) || baseline == nil || in.RemoteMaxTXID != baseline.MaxTXID { |
| return hostbox.ErrChanged |
| } |
| prepared := firstAdmission(h, m.Box) |
| @@ -988,7 +995,7 @@ func (m *movesProbeOwners) PublishFenced(ctx context.Context, o hostclaim.Observ |
| } |
| bounded, cancel := context.WithTimeout(ctx, hostbox.RequestLimit) |
| defer cancel() |
| - plan, err := genselect.Select(bounded, m.Data, m.request(o.Host, h, o.Claim.Op.Intent["home_region"])) |
| + plan, err := genselect.Select(bounded, m.Data, m.request(o.Host, h)) |
| if err != nil { |
| return ClaimPublicationReceipt{}, err |
| } |
| @@ -1215,7 +1222,7 @@ func (m *movesProbeOwners) PublicationEvidence(ctx context.Context, o hostclaim. |
| if !ok || err != nil || receipt.MarkerVersion == "" || receipt.Root.VersionID == "" || !strings.HasPrefix(receipt.Root.Key, prefix) { |
| return hostbox.ErrChanged |
| } |
| - plan, err := genselect.Select(ctx, m.Data, m.request(o.Host, h, o.Claim.Op.Intent["home_region"])) |
| + plan, err := genselect.Select(ctx, m.Data, m.request(o.Host, h)) |
| if err != nil { |
| return err |
| } |
| @@ -1286,10 +1293,14 @@ func withoutProbeVars(env []string) []string { |
| return out |
| } |
| -func (m *movesProbeOwners) committedAdmission(o hostclaim.Observed, v probeLanding, gen string, plan *genselect.Plan) hostclaim.Admission { |
| +func (m *movesProbeOwners) committedAdmission(o hostclaim.Observed, v probeLanding, gen string, plan *genselect.Plan) (hostclaim.Admission, error) { |
| h := v.holder |
| + predecessor, err := predecessorRegion(h, v.rec.Move.From) |
| + if err != nil { |
| + return hostclaim.Admission{}, err |
| + } |
| return hostclaim.Admission{ObservedOpID: v.op, ObservedPhase: o.Claim.Op.Phase, ObservedSession: h.Session, ObservedGeneration: h.Generation, Box: m.Box, Mode: "cold", |
| - NewGeneration: gen, KeepStamp: h.Stamp > h.Session, Region: m.Region, PredecessorRegion: predecessorRegion(h, v.rec.Move.From), RemoteMaxTXID: plan.MaxTXID} |
| + NewGeneration: gen, KeepStamp: h.Stamp > h.Session, Region: m.Region, PredecessorRegion: predecessor, RemoteMaxTXID: plan.MaxTXID}, nil |
| } |
| func (m *movesProbeOwners) selectCommitted(ctx context.Context, rt registry.Route, o hostclaim.Observed, v probeLanding) (ClaimLaunchInputs, error) { |
| @@ -1304,7 +1315,7 @@ func (m *movesProbeOwners) selectCommitted(ctx context.Context, rt registry.Rout |
| if gen == v.op || !claimFreshGeneration(h, gen) { |
| return ClaimLaunchInputs{}, hostwrite.ErrUnavailable |
| } |
| - plan, err := genselect.Select(ctx, m.Data, m.request(o.Host, h, o.Claim.Op.Intent["home_region"])) |
| + plan, err := genselect.Select(ctx, m.Data, m.request(o.Host, h)) |
| if err != nil { |
| return ClaimLaunchInputs{}, err |
| } |
| @@ -1322,7 +1333,11 @@ func (m *movesProbeOwners) selectCommitted(ctx context.Context, rt registry.Rout |
| if pins.EnvReport == "" || id != pinnedLaunchIdentity(pins) { |
| return ClaimLaunchInputs{}, fmt.Errorf("%w: the launch is not the move's pinned inputs", hostbox.ErrChanged) |
| } |
| - return ClaimLaunchInputs{Roles: []string{"main"}, Identity: id, Baseline: plan, Admission: m.committedAdmission(o, v, gen, plan), |
| + admission, err := m.committedAdmission(o, v, gen, plan) |
| + if err != nil { |
| + return ClaimLaunchInputs{}, err |
| + } |
| + return ClaimLaunchInputs{Roles: []string{"main"}, Identity: id, Baseline: plan, Admission: admission, |
| Executable: l.Executable, ExecutableSHA256: l.ExecutableSHA256, Args: append([]string(nil), l.Args...), Env: withoutProbeVars(l.Env), |
| DBPath: rt.DBPath, Version: l.Version, Limit: l.Limit}, nil |
| } |
| @@ -1332,10 +1347,14 @@ func (m *movesProbeOwners) committedEvidence(ctx context.Context, o hostclaim.Ob |
| if baseline == nil || h.WriterBox != m.Box || in.NewGeneration == v.op || !claimFreshGeneration(h, in.NewGeneration) || in.PredecessorCertified || in.LossID != "" || len(in.AdditionalWritesLost) != 0 { |
| return hostbox.ErrChanged |
| } |
| - if !reflect.DeepEqual(in, m.committedAdmission(o, v, in.NewGeneration, baseline)) { |
| + want, err := m.committedAdmission(o, v, in.NewGeneration, baseline) |
| + if err != nil { |
| + return err |
| + } |
| + if !reflect.DeepEqual(in, want) { |
| return hostbox.ErrChanged |
| } |
| - fresh, err := genselect.Select(ctx, m.Data, m.request(o.Host, h, o.Claim.Op.Intent["home_region"])) |
| + fresh, err := genselect.Select(ctx, m.Data, m.request(o.Host, h)) |
| if err != nil { |
| return err |
| } |
| @@ -1509,7 +1528,7 @@ func (m *movesProbeOwners) unpublishedPath(h *hostclaim.Holder, from, to string) |
| break |
| } |
| } |
| - if found == nil || found.Box != m.Box || found.Region != m.Region || found.Predecessor == "" { |
| + if found == nil || found.Box != m.Box || !park.SameRegion(m.HomeRegion, found.Region, m.Region) || found.Predecessor == "" { |
| return nil, false |
| } |
| g = found.Predecessor |
diff --git a/internal/host/movesreport.go b/internal/host/movesreport.go| index abf3362ea..97a47cf4c 100644 |
| --- a/internal/host/movesreport.go |
| +++ b/internal/host/movesreport.go |
| @@ -12,6 +12,7 @@ import ( |
| "log" |
| "os" |
| "path/filepath" |
| + "slices" |
| "strings" |
| "time" |
| @@ -243,7 +244,7 @@ func (w *movesWorker) targetReport(ctx context.Context, s release.Store, rec pro |
| } |
| rep.Checks[moves.CheckDisk] = w.checkDisk(max(staged, dbBytes), replicaErr, art) |
| rep.Checks[moves.CheckRelease] = w.checkRelease(ctx, s, rec, source) |
| - rep.Checks[moves.CheckOneOwner] = checkOneOwner(fleet, rec.Move.To) |
| + rep.Checks[moves.CheckOneOwner] = checkOneOwner(fleet, rec.Move.To, moveRegionName(obs.Claim, park.PlacementRegion(rec.Move.To))) |
| rep.Checks[moves.CheckClaim] = w.checkClaim(ctx, obs, rec) |
| rep.Checks[moves.CheckLane] = w.checkLane(ctx, rec, fleet) |
| rep.Checks[moves.CheckQuiet] = checkQuiet(obs, park.PlacementRegion(rec.Move.To), now) |
| @@ -705,9 +706,13 @@ func (w *movesWorker) checkRelease(ctx context.Context, s release.Store, rec pro |
| // checkOneOwner is T8: exactly one hosting box publishes the target |
| // placement. |
| -func checkOneOwner(fleet moveFleet, to string) moves.Check { |
| +func checkOneOwner(fleet moveFleet, to string, names ...string) moves.Check { |
| + name := moves.RegionText(to) |
| + if len(names) == 1 && names[0] != "" { |
| + name = names[0] |
| + } |
| if n := len(fleet.boxes[to]); n != 1 { |
| - return moves.Check{Reason: t8Count(n, to)} |
| + return moves.Check{Reason: t8Count(n, name)} |
| } |
| return moves.Check{OK: true} |
| } |
| @@ -724,8 +729,10 @@ func (w *movesWorker) checkClaim(ctx context.Context, obs hostclaim.Observed, re |
| if c.Holder == nil || c.Holder.Incarnation != rec.Move.Incarnation { |
| return moves.Check{Reason: t9Incarnation} |
| } |
| - if !containsString(c.Touched.Regions, rec.Move.To) { |
| - return moves.Check{Reason: t9Untouched(rec.Move.To)} |
| + if !slices.ContainsFunc(c.Touched.Regions, func(region string) bool { |
| + return park.PlacementMatches(c.Op.Intent["home_region"], rec.Move.To, region) |
| + }) { |
| + return moves.Check{Reason: t9Untouched(moveRegionName(c, park.PlacementRegion(rec.Move.To)))} |
| } |
| if !containsString(c.Touched.Boxes, w.cfg.Box) { |
| return moves.Check{Reason: t9Untouched(w.cfg.Box)} |
| @@ -773,7 +780,7 @@ func (w *movesWorker) checkLane(ctx context.Context, rec provision.Record, fleet |
| // checkQuiet is T12: the claim's quiet_after for the target region passed. |
| func checkQuiet(obs hostclaim.Observed, region string, now time.Time) moves.Check { |
| if until := park.QuietUntil(obs.Claim.QuietAfter, obs.Claim.Op.Intent["home_region"], region); !until.IsZero() && now.Before(until) { |
| - return moves.Check{Reason: t12NotQuiet(region, until)} |
| + return moves.Check{Reason: t12NotQuiet(moveRegionName(obs.Claim, region), until)} |
| } |
| return moves.Check{OK: true} |
| } |
| @@ -811,3 +818,13 @@ func (w *movesWorker) measure(ctx context.Context, rec provision.Record) moves.M |
| w.mu.Unlock() |
| return m |
| } |
| + |
| +func moveRegionName(c *hostclaim.Claim, region string) string { |
| + if park.SameRegion(c.Op.Intent["home_region"], region, park.HomeRegion) { |
| + if home := c.Op.Intent["home_region"]; home != "" { |
| + return home |
| + } |
| + return moves.HomeRegionText |
| + } |
| + return region |
| +} |
diff --git a/internal/host/movessources.go b/internal/host/movessources.go| index 64907a727..73c975bbc 100644 |
| --- a/internal/host/movessources.go |
| +++ b/internal/host/movessources.go |
| @@ -71,13 +71,8 @@ const movesNoLiveRegion = "@none" |
| // which every engine entry refuses. |
| func (c moveCfg) parkRegion() string { return park.PlacementRegion(c.Region) } |
| -// movesBoxRegion is the park view's region resolver in production: this box's |
| -// region as the claim knows it (resolve, the verified roster row), accepted |
| -// only when it is the engine region of this box's own placement code. A home |
| -// box (placement "") whose claim names it anything but park.HomeRegion, the |
| -// roster's own name for home among them, refuses: the view would otherwise |
| -// read a home record's "" as HomeRegion while this box's engine runs under |
| -// another name, and protect nothing of home's live set here. |
| +// movesBoxRegion refuses a placement that disagrees with the verified roster. |
| +// productionRosterRegion maps home only after checking the signed designation. |
| func movesBoxRegion(placement string, resolve func(context.Context) (string, error)) func(context.Context) (string, error) { |
| return func(ctx context.Context) (string, error) { |
| want := park.PlacementRegion(placement) |
| @@ -706,10 +701,10 @@ func productionRosterRegion(store release.Store, getenv func(string) string) fun |
| if err != nil { |
| return "", err |
| } |
| - _, _, err = claimRegionBinding(rr.row.Region, getenv("CARLOS_REGION")) |
| + engine, home, err := claimRegionBinding(rr.row.Region, getenv("CARLOS_REGION"), rr.home) |
| if err != nil { |
| return "", err |
| } |
| - return park.PlacementRegion(getenv("CARLOS_REGION")), nil |
| + return park.PlacementRegion(park.HomePlacementCode(home, engine)), nil |
| } |
| } |
diff --git a/internal/host/plan12_handover_test.go b/internal/host/plan12_handover_test.go| index e4a6d1012..484ea9329 100644 |
| --- a/internal/host/plan12_handover_test.go |
| +++ b/internal/host/plan12_handover_test.go |
| @@ -22,13 +22,16 @@ import ( |
| "time" |
| ) |
| -type plan12FenceAuthority struct{ reject bool } |
| +type plan12FenceAuthority struct { |
| + reject bool |
| + home string |
| +} |
| func (a plan12FenceAuthority) ValidateFence(context.Context, termfence.Record) (hostclaim.FenceBinding, error) { |
| if a.reject { |
| return hostclaim.FenceBinding{}, hostclaim.ErrFence |
| } |
| - return hostclaim.FenceBinding{Region: "uk", RosterRevision: 1, RosterETag: "roster"}, nil |
| + return hostclaim.FenceBinding{FleetHomeRegion: a.home, Region: "uk", RosterRevision: 1, RosterETag: "roster"}, nil |
| } |
| func plan12Handover(t *testing.T, kind, mode string) (*recoveryFixture, hostclaim.Observed, claimCrossBoxHandover) { |
| t.Helper() |
diff --git a/internal/host/plan13_amendments_test.go b/internal/host/plan13_amendments_test.go| index 30aae691d..2e266831a 100644 |
| --- a/internal/host/plan13_amendments_test.go |
| +++ b/internal/host/plan13_amendments_test.go |
| @@ -18,8 +18,8 @@ func TestPlan13LandingInvalidSourceWithoutHistory(t *testing.T) { |
| if _, err := f.owners.landing(t.Context(), o); err == nil { |
| t.Fatal("invalid source accepted", from) |
| } |
| - if got := predecessorRegion(c.Holder, from); got != from || got == "" { |
| - t.Fatal("invalid predecessor became chore-free", got) |
| + if got, err := predecessorRegion(c.Holder, from); err == nil || got != "" { |
| + t.Fatal("invalid predecessor accepted", got, err) |
| } |
| } |
| } |
diff --git a/internal/host/plan13_fix1_test.go b/internal/host/plan13_fix1_test.go| new file mode 100644 |
| index 000000000..13383954b |
| --- /dev/null |
| +++ b/internal/host/plan13_fix1_test.go |
| @@ -0,0 +1,112 @@ |
| +package host |
| + |
| +import ( |
| + "encoding/json" |
| + "github.com/carlosframework/platform/internal/hostclaim" |
| + "github.com/carlosframework/platform/internal/lease" |
| + "github.com/carlosframework/platform/internal/moves" |
| + "github.com/carlosframework/platform/internal/park" |
| + "strings" |
| + "testing" |
| + "time" |
| +) |
| + |
| +func TestPlan13HomeTargetReadiness(t *testing.T) { |
| + for _, stored := range []string{park.HomeRegion, "ie", "ca"} { |
| + t.Run(stored, func(t *testing.T) { |
| + h := newPhase1Harness(t) |
| + h.start(t, "manual") |
| + rec := h.read(t, p1Host) |
| + rec.Move.To = "" |
| + obs, err := hostclaim.Read(t.Context(), h.s, p1Host) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + obs.Claim.Op.Intent["home_region"] = "ie" |
| + obs.Claim.Touched.Regions = []string{stored} |
| + obs.Claim.Touched.Boxes = append(obs.Claim.Touched.Boxes, "uk-box") |
| + raw, err := json.Marshal(moves.MoveJSON{MoveID: rec.Move.ID, Incarnation: rec.Move.Incarnation, Host: rec.Host, From: rec.Move.From, To: rec.Move.To}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + h.f.store("data-uk", moves.MoveJSONKey(rec.Host, rec.Move.ID), fakeMoveObject{lease.MirrorObject{ETag: "x", Size: int64(len(raw))}, string(raw)}) |
| + w := &movesWorker{cfg: h.targetCfg(), mirrors: map[string]lease.MirrorStore{}} |
| + got := w.checkClaim(t.Context(), obs, rec) |
| + if got.OK != (stored != "ca") { |
| + t.Fatalf("%s: %+v", stored, got) |
| + } |
| + if !got.OK && got.Reason != t9Untouched("ie") { |
| + t.Fatal(got.Reason) |
| + } |
| + }) |
| + } |
| +} |
| +func TestPlan13HomeQuietReason(t *testing.T) { |
| + now := time.Unix(1000, 0) |
| + o := hostclaim.Observed{Claim: &hostclaim.Claim{Op: hostclaim.Op{Intent: map[string]string{"home_region": "ie"}}, QuietAfter: map[string]time.Time{park.HomeRegion: now.Add(time.Minute)}}} |
| + got := checkQuiet(o, park.HomeRegion, now) |
| + if got.OK || strings.Contains(got.Reason, park.HomeRegion) || got.Reason != t12NotQuiet("ie", now.Add(time.Minute)) { |
| + t.Fatal(got) |
| + } |
| +} |
| + |
| +func TestPlan13ProbeHomeHistory(t *testing.T) { |
| + h := &hostclaim.Holder{Admissions: []hostclaim.AdmissionRecord{ |
| + {Generation: "move", Region: "ie", Box: "box", Mode: "cold"}, |
| + {Generation: "next", Predecessor: "move", Region: "@home", Box: "box", Mode: "cold"}, |
| + }} |
| + for _, home := range []string{"ie", ""} { |
| + m := &movesProbeOwners{Region: "ie", HomeRegion: home, Box: "box"} |
| + if got := m.descendsFromMove(h, "next", "move"); got != (home != "") { |
| + t.Fatal("descent", home, got) |
| + } |
| + _, ok := m.unpublishedPath(h, "next", "move") |
| + if ok != (home != "") { |
| + t.Fatal("unpublished path", home, ok) |
| + } |
| + } |
| +} |
| + |
| +func TestPlan13LandingUsesLocalBinding(t *testing.T) { |
| + for _, tc := range []struct { |
| + local, fleet, operation string |
| + ok bool |
| + }{ |
| + {"", "ie", "uk", false}, {"", "uk", "uk", false}, {"uk", "uk", "uk", true}, {"uk", "ie", "uk", false}, |
| + } { |
| + f := newPOFixture(t) |
| + c := f.poFlipClaim() |
| + c.Op.Intent["to"], c.Op.Intent["home_region"] = park.HomeRegion, tc.operation |
| + f.rec.Move.To = "" |
| + f.owners.HomeRegion, f.owners.FleetHomeRegion = tc.local, tc.fleet |
| + _, err := f.owners.landing(t.Context(), hostclaim.Observed{Host: poHost, Claim: c}) |
| + if (err == nil) != tc.ok { |
| + t.Fatalf("%+v: %v", tc, err) |
| + } |
| + } |
| +} |
| +func TestPlan13OwnStorageProjectionIgnoresOperationHome(t *testing.T) { |
| + h := &hostclaim.Holder{Admissions: []hostclaim.AdmissionRecord{{Region: park.HomeRegion, PredecessorRegion: park.HomeRegion}}} |
| + m := &movesProbeOwners{Region: "uk"} |
| + r := m.request("test.example", h) |
| + if r.Holder.Admissions[0].Region != park.HomeRegion { |
| + t.Fatal("named storage borrowed a home alias") |
| + } |
| + m.HomeRegion = "uk" |
| + r = m.request("test.example", h) |
| + if r.Holder.Admissions[0].Region != "uk" || h.Admissions[0].Region != park.HomeRegion { |
| + t.Fatal("home view lost binding or rewrote history") |
| + } |
| +} |
| + |
| +func TestPlan13HomeOwnerReason(t *testing.T) { |
| + got := checkOneOwner(moveFleet{}, "", "ie") |
| + if got.OK || got.Reason != t8Count(0, "ie") { |
| + t.Fatal(got) |
| + } |
| + o := hostclaim.Observed{Claim: &hostclaim.Claim{QuietAfter: map[string]time.Time{park.HomeRegion: time.Unix(2, 0)}}} |
| + got = checkQuiet(o, park.HomeRegion, time.Unix(1, 0)) |
| + if strings.Contains(got.Reason, park.HomeRegion) || strings.Contains(got.Reason, "left recently") { |
| + t.Fatal(got) |
| + } |
| +} |
diff --git a/internal/host/plan13_policy_test.go b/internal/host/plan13_policy_test.go| index 7c36f0bfe..e8988990d 100644 |
| --- a/internal/host/plan13_policy_test.go |
| +++ b/internal/host/plan13_policy_test.go |
| @@ -15,8 +15,23 @@ func TestPlan13HomeMovePolicy(t *testing.T) { |
| s := mpSelection(c, "restore") |
| policy, _ := mpPolicy() |
| err := policy(t.Context(), c, s, mpIdentity("restore")) |
| - if (err == nil) != (home != "") { |
| + if err == nil { |
| t.Fatalf("home=%q: %v", home, err) |
| } |
| } |
| } |
| + |
| +func TestPlan13VerifiedHomeMovePolicy(t *testing.T) { |
| + c := mpClaim(hostclaim.KindLanding, provision.LandConfigured) |
| + c.Op.Intent["to"], c.Op.Intent["home_region"] = park.HomeRegion, "uk" |
| + s := mpSelection(c, "restore") |
| + s.HomeRegion, s.FleetHomeRegion = "uk", "uk" |
| + policy, _ := mpPolicy() |
| + if err := policy(t.Context(), c, s, mpIdentity("restore")); err != nil { |
| + t.Fatal(err) |
| + } |
| + s.FleetHomeRegion = "ie" |
| + if err := policy(t.Context(), c, s, mpIdentity("restore")); err == nil { |
| + t.Fatal("conflicting fleet home accepted") |
| + } |
| +} |
diff --git a/internal/host/plan13_receipt_test.go b/internal/host/plan13_receipt_test.go| index 850aa3c5f..085992ef8 100644 |
| --- a/internal/host/plan13_receipt_test.go |
| +++ b/internal/host/plan13_receipt_test.go |
| @@ -26,7 +26,7 @@ func TestPlan13RecoveryHomeReceipt(t *testing.T) { |
| key := ed25519.NewKeyFromSeed([]byte(strings.Repeat("b", 32))) |
| en := strings.Repeat("a", 32) |
| d := hostactors.Directory{Store: f.claims, PublicKey: f.signer.Public().(ed25519.PublicKey), Bucket: "bucket"} |
| - roster := hostactors.Roster{V: 1, Revision: 1, DeploymentBucket: "bucket", Boxes: map[string]hostactors.Box{"box-2": {Enrollment: en, BoxID: "box-2", Label: "box-2", Namespace: "platform", Region: "uk", Pool: "default", State: "active", Cloud: hostactors.Cloud{Provider: "aws", Account: "123", Region: "eu-west-2", InstanceID: "i-target"}, EvidenceSHA256: strings.Repeat("d", 64), CensusPublicKey: key.Public().(ed25519.PublicKey)}}, Consoles: map[string]hostactors.Console{}, Holds: map[string]hostactors.Reservation{}, Activations: map[string]hostactors.Reservation{}} |
| + roster := hostactors.Roster{HomeRegion: "uk", V: 1, Revision: 1, DeploymentBucket: "bucket", Boxes: map[string]hostactors.Box{"box-2": {Enrollment: en, BoxID: "box-2", Label: "box-2", Namespace: "platform", Region: "uk", Pool: "default", State: "active", Cloud: hostactors.Cloud{Provider: "aws", Account: "123", Region: "eu-west-2", InstanceID: "i-target"}, EvidenceSHA256: strings.Repeat("d", 64), CensusPublicKey: key.Public().(ed25519.PublicKey)}}, Consoles: map[string]hostactors.Console{}, Holds: map[string]hostactors.Reservation{}, Activations: map[string]hostactors.Reservation{}} |
| if bad == "roster-name" { |
| b := roster.Boxes["box-2"] |
| b.Region = "ie" |
| @@ -85,6 +85,7 @@ func TestPlan13HomeRecoveryFenceBinding(t *testing.T) { |
| _, o, h := plan12Handover(t, hostclaim.KindRestoring, "fence") |
| o.Claim.Op.Intent["to"] = "@home" |
| o.Claim.Op.Intent["home_region"] = home |
| + h.FenceAuthority = plan12FenceAuthority{home: "uk"} |
| err := h.recoveryEvidence(t.Context(), o) |
| if (err == nil) != (home == "uk") { |
| t.Fatal(home, err) |
diff --git a/internal/host/plan13_region_test.go b/internal/host/plan13_region_test.go| index 51916c9e0..2c10da20d 100644 |
| --- a/internal/host/plan13_region_test.go |
| +++ b/internal/host/plan13_region_test.go |
| @@ -3,6 +3,7 @@ package host |
| import ( |
| "errors" |
| "github.com/carlosframework/platform/internal/hostbox" |
| + "github.com/carlosframework/platform/internal/hostclaim" |
| "github.com/carlosframework/platform/internal/park" |
| "testing" |
| ) |
| @@ -13,9 +14,9 @@ func TestPlan13ClaimRegionBinding(t *testing.T) { |
| bad bool |
| }{ |
| {"ie", "", "ie", "ie", false}, {"uk", "uk", "uk", "", false}, |
| - {"uk", "ie", "", "", true}, {"", "", "", "", true}, {"@home", "", "", "", true}, {"ie", "@home", "", "", true}, |
| + {"ca", "", "", "", true}, {"uk", "ie", "", "", true}, {"", "", "", "", true}, {"@home", "", "", "", true}, {"ie", "@home", "", "", true}, |
| } { |
| - e, h, err := claimRegionBinding(tc.roster, tc.code) |
| + e, h, err := claimRegionBinding(tc.roster, tc.code, "ie") |
| if e != tc.engine || h != tc.home || (err != nil) != tc.bad { |
| t.Fatalf("%+v: %q %q %v", tc, e, h, err) |
| } |
| @@ -23,7 +24,7 @@ func TestPlan13ClaimRegionBinding(t *testing.T) { |
| } |
| func TestPlan13VerifyRegionalHomeNames(t *testing.T) { |
| for _, home := range []string{"region", ""} { |
| - identity := handoverRecoveryIdentity(t, hostbox.Actor{Box: "box", Enrollment: "enrolled", Region: "region", ControlBucket: "control", Revision: 1}) |
| + identity := handoverRecoveryIdentity(t, hostbox.Actor{Box: "box", Enrollment: "enrolled", Region: "region", FleetHomeRegion: "region", ControlBucket: "control", Revision: 1}) |
| identity.HomeRegion = home |
| actor, err := identity.Verify(t.Context()) |
| if err != nil { |
| @@ -42,3 +43,36 @@ func TestPlan13VerifyRegionalHomeNames(t *testing.T) { |
| } |
| } |
| } |
| + |
| +func TestPlan13UnverifiedAliasRefused(t *testing.T) { |
| + for _, fleet := range []string{"", "ie"} { |
| + identity := handoverRecoveryIdentity(t, hostbox.Actor{Box: "box", Enrollment: "enrolled", Region: "ca", FleetHomeRegion: fleet, Revision: 1}) |
| + identity.HomeRegion = "ca" |
| + if _, err := identity.Verify(t.Context()); err == nil { |
| + t.Fatal("unverified home alias accepted", fleet) |
| + } |
| + } |
| +} |
| + |
| +func TestPlan13NamedBoxCannotAcknowledgeHomeDelete(t *testing.T) { |
| + if _, _, err := claimRegionBinding("ca", "", "ie"); err == nil { |
| + t.Fatal("missing CARLOS_REGION granted alias") |
| + } |
| + identity := handoverRecoveryIdentity(t, hostbox.Actor{Box: "ca-box", Enrollment: "enrolled", Region: "ca", FleetHomeRegion: "ie", Revision: 1}) |
| + p := productionClaimRegional{Identity: identity, Local: &productionClaimLocal{Identity: identity, Resources: ClaimRuntimeResources{Mode: "agent", DataDir: t.TempDir()}, assembly: &ClaimAssembly{}, lifetime: t.Context()}} |
| + for _, kind := range []string{hostclaim.KindDeleting, hostclaim.KindParking, hostclaim.KindPurging} { |
| + ob := hostclaim.Obligation{ID: "chore", Op: "delete", Kind: "park-replica", Owner: "region:ca", Region: "ca", Retirement: "retirement"} |
| + o := hostclaim.Observed{Host: "test.example", Claim: &hostclaim.Claim{Holder: &hostclaim.Holder{}, Op: hostclaim.Op{ID: "delete", Kind: kind}, Obligations: []hostclaim.Obligation{ob}}} |
| + if err := p.Authorize(t.Context(), o, ob); err != nil { |
| + t.Fatal("named control", err) |
| + } |
| + ob.Region, ob.Owner = park.HomeRegion, "region:@home" |
| + o.Claim.Obligations[0] = ob |
| + if err := p.Authorize(t.Context(), o, ob); err == nil { |
| + t.Fatal("acknowledged home delete", kind) |
| + } |
| + if (park.Engine{Region: "ca"}).OwnsRegion(park.HomeRegion) { |
| + t.Fatal("named engine owns home") |
| + } |
| + } |
| +} |
diff --git a/internal/host/plan13_views_test.go b/internal/host/plan13_views_test.go| index a065a7895..d58ff900b 100644 |
| --- a/internal/host/plan13_views_test.go |
| +++ b/internal/host/plan13_views_test.go |
| @@ -7,6 +7,7 @@ import ( |
| "github.com/carlosframework/platform/internal/lease" |
| "github.com/carlosframework/platform/internal/park" |
| "github.com/carlosframework/platform/internal/provision" |
| + "github.com/carlosframework/platform/internal/release" |
| "testing" |
| ) |
| @@ -16,8 +17,8 @@ func TestPlan13ProductionRemoteHomeView(t *testing.T) { |
| f.rec.Move = &provision.MoveState{ID: vwMove, Incarnation: f.inc, From: "", To: "uk", State: provision.MoveLanding} |
| h := f.holder("src", "src@ie") |
| o := hostclaim.Observed{Host: vwHost, ETag: "tag", Claim: &hostclaim.Claim{Host: vwHost, Holder: h, Op: hostclaim.Op{ID: vwMove, Kind: hostclaim.KindLanding, Phase: "installing", Intent: map[string]string{"home_region": "ie"}}}} |
| - identity := handoverRecoveryIdentity(t, hostbox.Actor{Box: "box", Enrollment: "enrolled", Region: "uk", ControlBucket: "control", Revision: 1}) |
| - p := productionClaimRegional{Identity: identity, Parks: &claimMoveParkSources{View: f.view("uk")}} |
| + identity := handoverRecoveryIdentity(t, hostbox.Actor{Box: "box", Enrollment: "enrolled", Region: "uk", FleetHomeRegion: "ie", ControlBucket: "control", Revision: 1}) |
| + p := productionClaimRegional{Identity: identity, Local: &productionClaimLocal{Identity: identity, Resources: ClaimRuntimeResources{Mode: "agent", DataDir: t.TempDir()}, assembly: &ClaimAssembly{}, lifetime: t.Context()}, Parks: &claimMoveParkSources{View: f.view("uk")}} |
| if _, err := p.View(t.Context(), hostclaim.Observed{}); err == nil { |
| t.Fatal("missing claim accepted") |
| } |
| @@ -31,7 +32,35 @@ func TestPlan13ProductionRemoteHomeView(t *testing.T) { |
| if h.Admissions[0].Region != "ie" { |
| t.Fatal("history rewritten") |
| } |
| - if err := p.Authorize(t.Context(), o, hostclaim.Obligation{Op: vwMove, Owner: "region:@home", Region: park.HomeRegion}); err == nil { |
| + ob := hostclaim.Obligation{ID: "persisted", Kind: "park-generation", Op: vwMove, Owner: "region:uk", Region: "uk", Generation: "old", Retirement: "retirement"} |
| + store := release.NewDirStore(t.TempDir()) |
| + op := o.Claim.Op |
| + o, err = hostclaim.Update(t.Context(), store, hostclaim.Observed{Host: vwHost, Absent: true}, func(c *hostclaim.Claim) error { |
| + c.Holder = h |
| + c.Op = op |
| + c.Obligations = []hostclaim.Obligation{ob} |
| + return nil |
| + }) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + o, err = hostclaim.Read(t.Context(), store, vwHost) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := p.Authorize(t.Context(), o, ob); err != nil { |
| + t.Fatal("authorized control", err) |
| + } |
| + ob.Owner, ob.Region = "region:@home", park.HomeRegion |
| + o, err = hostclaim.Update(t.Context(), store, o, func(c *hostclaim.Claim) error { c.Obligations = []hostclaim.Obligation{ob}; return nil }) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + o, err = hostclaim.Read(t.Context(), store, vwHost) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := p.Authorize(t.Context(), o, ob); err == nil { |
| t.Fatal("named worker owns home") |
| } |
| } |
diff --git a/internal/hostactors/plan13_home_test.go b/internal/hostactors/plan13_home_test.go| new file mode 100644 |
| index 000000000..01509e147 |
| --- /dev/null |
| +++ b/internal/hostactors/plan13_home_test.go |
| @@ -0,0 +1,37 @@ |
| +package hostactors |
| + |
| +import "testing" |
| + |
| +func TestPlan13SignedHomeAgreement(t *testing.T) { |
| + for _, home := range []string{"", "ie", "ca", "@home", "IE"} { |
| + r := Roster{HomeRegion: "ie"} |
| + if r.MatchesHome(home) != (home == "ie") { |
| + t.Fatal(home) |
| + } |
| + } |
| + if !(Roster{}).MatchesHome("") || (Roster{}).MatchesHome("ie") { |
| + t.Fatal("legacy roster grants alias") |
| + } |
| +} |
| + |
| +func TestPlan13HomeDesignationIsSignedAndOptional(t *testing.T) { |
| + d, sg, o := fixture(t) |
| + if _, err := d.Read(t.Context()); err != nil { |
| + t.Fatal("legacy roster", err) |
| + } |
| + next := Clone(o.Roster) |
| + next.HomeRegion = "ie" |
| + updated, err := d.Replace(t.Context(), sg, o, next) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + read, err := d.Read(t.Context()) |
| + if err != nil || !read.Roster.MatchesHome("ie") { |
| + t.Fatal(read, err) |
| + } |
| + next = Clone(updated.Roster) |
| + next.HomeRegion = "@home" |
| + if _, err := d.Replace(t.Context(), sg, updated, next); err == nil { |
| + t.Fatal("engine token accepted as designation") |
| + } |
| +} |
diff --git a/internal/hostactors/roster.go b/internal/hostactors/roster.go| index 70114f142..478054861 100644 |
| --- a/internal/hostactors/roster.go |
| +++ b/internal/hostactors/roster.go |
| @@ -15,6 +15,7 @@ import ( |
| "github.com/carlosframework/platform/internal/boxid" |
| "github.com/carlosframework/platform/internal/hostquarantine" |
| + "github.com/carlosframework/platform/internal/regionname" |
| "github.com/carlosframework/platform/internal/release" |
| ) |
| @@ -61,6 +62,7 @@ type Reservation struct { |
| Hashes map[string][]string `json:"hashes,omitempty"` |
| } |
| type Roster struct { |
| + HomeRegion string `json:"home_region,omitempty"` |
| RequestLimitNS int64 `json:"request_limit_ns,omitempty"` |
| EnrollmentAttempts map[string]EnrollmentAttempt `json:"enrollment_attempts,omitempty"` |
| Publication string `json:"publication,omitempty"` |
| @@ -91,6 +93,9 @@ func (r Roster) Validate(bucket string) error { |
| if r.V != 1 || bucket == "" || r.DeploymentBucket != bucket || r.Revision == 0 || r.Boxes == nil || r.Consoles == nil || r.Holds == nil || r.Activations == nil { |
| return ErrRoster |
| } |
| + if r.HomeRegion != "" && regionname.PlacementRegion(r.HomeRegion) != r.HomeRegion { |
| + return ErrRoster |
| + } |
| if r.Publication != "" && r.Publication != "preparing" && r.Publication != "published" { |
| return ErrRoster |
| } |
| @@ -323,3 +328,7 @@ func (d Directory) Initialize(ctx context.Context, sg release.Signer) error { |
| } |
| return release.CreateExact(ctx, d.Store, Key, wire) |
| } |
| + |
| +func (r Roster) MatchesHome(home string) bool { |
| + return home == r.HomeRegion && (home == "" || regionname.PlacementRegion(home) == home) |
| +} |
diff --git a/internal/hostbox/identity.go b/internal/hostbox/identity.go| index 90473432b..97f268ca5 100644 |
| --- a/internal/hostbox/identity.go |
| +++ b/internal/hostbox/identity.go |
| @@ -13,6 +13,7 @@ import ( |
| // Actor is the verified projection of the sole control/host-actors.json roster. |
| // Roster authenticates that document and its revision before returning a row. |
| type Actor struct { |
| + FleetHomeRegion string |
| HomeRegion string |
| ControlBucket string |
| Box, Enrollment string |
| @@ -70,7 +71,7 @@ func (p IdentityProvider) Verify(ctx context.Context) (Actor, error) { |
| if ev.Box != p.Box || ev.Cloud != cloud { |
| return Actor{}, ErrChanged |
| } |
| - if p.HomeRegion != "" && (p.HomeRegion != a.Region || park.PlacementRegion(p.HomeRegion) != p.HomeRegion) { |
| + if p.HomeRegion != "" && (p.HomeRegion != a.FleetHomeRegion || p.HomeRegion != a.Region || park.PlacementRegion(p.HomeRegion) != p.HomeRegion) { |
| return Actor{}, ErrChanged |
| } |
| a.HomeRegion = p.HomeRegion |
| @@ -97,7 +98,7 @@ func (p IdentityProvider) Local(authority LocalAuthority) func(context.Context, |
| if err != nil { |
| return err |
| } |
| - if s.Box != actor.Box || s.Region != actor.Region { |
| + if s.Box != actor.Box || s.Region != actor.Region || s.HomeRegion != actor.HomeRegion || s.FleetHomeRegion != actor.FleetHomeRegion { |
| return ErrChanged |
| } |
| return authority.Check(ctx, actor, c, s) |
diff --git a/internal/hostbox/work.go b/internal/hostbox/work.go| index caf015968..14831fbf9 100644 |
| --- a/internal/hostbox/work.go |
| +++ b/internal/hostbox/work.go |
| @@ -16,6 +16,7 @@ var ErrChanged = errors.New("hostbox: selected authority changed") |
| var ErrUnsupported = errors.New("hostbox: unresolved lifecycle contract") |
| type Selection struct { |
| + HomeRegion, FleetHomeRegion string |
| Host, Action, Box, Region string |
| Captured hostguard.Captured |
| Kind, Op, Phase, Generation string |
| @@ -23,11 +24,12 @@ type Selection struct { |
| } |
| type WorkSource struct { |
| - HomeRegion string |
| - Prepare func(context.Context, hostclaim.Observed) error |
| - Guard hostguard.Guard |
| - Box, Region string |
| - Now func() time.Time |
| + FleetHomeRegion string |
| + HomeRegion string |
| + Prepare func(context.Context, hostclaim.Observed) error |
| + Guard hostguard.Guard |
| + Box, Region string |
| + Now func() time.Time |
| // Policy checks the action's role/phase and the selected config/release. |
| Policy func(context.Context, *hostclaim.Claim, Selection, hostwrite.Identity) error |
| // Local checks quarantine, execution deadlines and failed cleanup under flock. |
| @@ -76,7 +78,7 @@ func (s WorkSource) Select(ctx context.Context, host, action string) (hostwrite. |
| if c.Holder.Incarnation != captured.Incarnation || c.Holder.Stamp != captured.Stamp || captured.Stamp == 0 { |
| return nil, ErrChanged |
| } |
| - w := selectedWork{source: s, selection: Selection{Host: host, Action: action, Box: s.Box, Region: s.Region, Captured: captured, Kind: c.Op.Kind, Op: c.Op.ID, Phase: c.Op.Phase, Generation: c.Holder.Generation, Session: c.Holder.Session}, checked: &checkedPhase{phase: c.Op.Phase}} |
| + w := selectedWork{source: s, selection: Selection{HomeRegion: s.HomeRegion, FleetHomeRegion: s.FleetHomeRegion, Host: host, Action: action, Box: s.Box, Region: s.Region, Captured: captured, Kind: c.Op.Kind, Op: c.Op.ID, Phase: c.Op.Phase, Generation: c.Holder.Generation, Session: c.Holder.Session}, checked: &checkedPhase{phase: c.Op.Phase}} |
| return w, nil |
| } |
diff --git a/internal/hostclaim/fence.go b/internal/hostclaim/fence.go| index bb90dd9bf..e8d050b76 100644 |
| --- a/internal/hostclaim/fence.go |
| +++ b/internal/hostclaim/fence.go |
| @@ -21,9 +21,10 @@ type FenceRef struct { |
| } |
| type FenceBinding struct { |
| - Region string |
| - RosterRevision uint64 |
| - RosterETag string |
| + FleetHomeRegion string |
| + Region string |
| + RosterRevision uint64 |
| + RosterETag string |
| } |
| // FenceAuthority checks and identifies one authenticated roster observation. |
| @@ -107,3 +108,5 @@ func VerifyFenceForBox(ctx context.Context, s release.Store, authority FenceAuth |
| } |
| return nil |
| } |
| + |
| +func (f VerifiedFence) FleetHomeRegion() string { return f.binding.FleetHomeRegion } |
diff --git a/internal/hostclaim/generation.go b/internal/hostclaim/generation.go| index ff6173146..351462b6b 100644 |
| --- a/internal/hostclaim/generation.go |
| +++ b/internal/hostclaim/generation.go |
| @@ -115,7 +115,7 @@ func AdmitWriter(in Admission) func(*Claim) error { |
| if len(h.Admissions) > 0 { |
| predecessor = h.Admissions[len(h.Admissions)-1] |
| } |
| - if in.Mode == "warm" && in.Region != predecessor.Region { |
| + if in.Mode == "warm" && !regionname.SameRegion(c.Op.Intent["home_region"], in.Region, predecessor.Region) { |
| return fmt.Errorf("%w: a warm admission stays in its predecessor's region", ErrWrongState) |
| } |
| type writerSession struct { |
| @@ -145,7 +145,7 @@ func AdmitWriter(in Admission) func(*Claim) error { |
| // Only a park of the holder's whole location retires the |
| // predecessor; a scoped park of another location (an old |
| // incarnation's residue, a staging prefix) does not. |
| - if ob.Kind == "park-replica" && ob.Region == entry.PredecessorRegion && |
| + if ob.Kind == "park-replica" && regionname.SameRegion(c.Op.Intent["home_region"], ob.Region, entry.PredecessorRegion) && |
| (ob.Park == nil || ob.Park.Location == h.Location) { |
| entry.Covered = true |
| break |
| @@ -227,12 +227,12 @@ func PublishGeneration(observedOpID, observedPhase string, observedSession uint6 |
| record.Covered = true |
| continue |
| } |
| - if record.PredecessorRegion != region { |
| + if !regionname.SameRegion(c.Op.Intent["home_region"], record.PredecessorRegion, region) { |
| continue |
| } |
| queued := false |
| for _, ob := range c.Obligations { |
| - if ob.Kind == "park-generation" && ob.Region == region && ob.Generation == predecessor { |
| + if ob.Kind == "park-generation" && regionname.SameRegion(c.Op.Intent["home_region"], ob.Region, region) && ob.Generation == predecessor { |
| queued = true |
| break |
| } |
diff --git a/internal/hostclaim/move_target_fence.go b/internal/hostclaim/move_target_fence.go| index 514943b72..799aa5841 100644 |
| --- a/internal/hostclaim/move_target_fence.go |
| +++ b/internal/hostclaim/move_target_fence.go |
| @@ -4,7 +4,9 @@ import ( |
| "context" |
| "encoding/json" |
| "github.com/carlosframework/platform/internal/hostops/fencerelease" |
| + "github.com/carlosframework/platform/internal/regionname" |
| "github.com/carlosframework/platform/internal/release" |
| + "strings" |
| "time" |
| ) |
| @@ -156,7 +158,7 @@ func ConsumedMoveTargetFence(c *Claim) (FenceRef, bool) { |
| // LostMoveTargetParked requires revocation and completion of every target chore. |
| func LostMoveTargetParked(c *Claim, region string) bool { |
| - if c == nil || c.Holder == nil || c.Op.Kind != KindRestoring || region == "" || region != c.Op.Intent["to"] { |
| + if c == nil || c.Holder == nil || c.Op.Kind != KindRestoring || region == "" || !regionname.SameRegion(c.Op.Intent["home_region"], region, c.Op.Intent["to"]) { |
| return false |
| } |
| target := c.Op.Intent["target"] |
| @@ -164,7 +166,7 @@ func LostMoveTargetParked(c *Claim, region string) bool { |
| return false |
| } |
| for _, o := range c.Obligations { |
| - if o.Op == c.Op.ID && (o.Box == target || o.Owner == "box:"+target || o.Region == region || o.Owner == "region:"+region) { |
| + if o.Op == c.Op.ID && (o.Box == target || o.Owner == "box:"+target || regionname.SameRegion(c.Op.Intent["home_region"], o.Region, region) || (strings.HasPrefix(o.Owner, "region:") && regionname.SameRegion(c.Op.Intent["home_region"], strings.TrimPrefix(o.Owner, "region:"), region))) { |
| return false |
| } |
| } |
diff --git a/internal/hostclaim/plan13_amendments_test.go b/internal/hostclaim/plan13_amendments_test.go| index f9bb655a7..6a8477fd8 100644 |
| --- a/internal/hostclaim/plan13_amendments_test.go |
| +++ b/internal/hostclaim/plan13_amendments_test.go |
| @@ -66,3 +66,72 @@ func TestPlan13WholeLocationCoversHomeWithoutObjects(t *testing.T) { |
| } |
| } |
| } |
| + |
| +func TestPlan13ReturningWriterHomeCoverage(t *testing.T) { |
| + for _, bound := range []bool{false, true} { |
| + c := targetWroteLanding(t) |
| + if err := RollBack(moveA, rbA)(c); err != nil { |
| + t.Fatal(err) |
| + } |
| + c.Op.Intent["to"] = "@home" |
| + if bound { |
| + c.Op.Intent["home_region"] = "uk" |
| + } |
| + n := 0 |
| + ids := func() string { n++; return fmt.Sprint("park", n) } |
| + if err := TargetReleased(moveA, "uk-box", "@home", stoppedTarget, releaseScope(c), ids)(c); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := ReturnToSource(moveA, "ie-box", false, ReturnFence{})(c); err != nil { |
| + t.Fatal(err) |
| + } |
| + h := c.Holder |
| + in := Admission{ObservedOpID: c.Op.ID, ObservedPhase: c.Op.Phase, ObservedSession: h.Session, ObservedGeneration: h.Generation, Box: c.Op.Exec, Mode: "cold", NewGeneration: "return", Region: "ie"} |
| + if err := AdmitWriter(in)(c); err != nil { |
| + t.Fatal(err) |
| + } |
| + last := c.Holder.Admissions[len(c.Holder.Admissions)-1] |
| + if last.Covered != bound || last.PredecessorRegion != "uk" { |
| + t.Fatalf("bound=%v: %+v", bound, last) |
| + } |
| + for _, ob := range append([]Obligation(nil), c.Obligations...) { |
| + if err := CompleteObligation(ob.Op, ob.ID)(c); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + if c.Holder.Admissions[len(c.Holder.Admissions)-1].Covered != bound { |
| + t.Fatal("empty cleanup lost coverage") |
| + } |
| + } |
| +} |
| +func TestPlan13RetainedHomeChoreBlocksLostTargetParked(t *testing.T) { |
| + for _, ob := range []Obligation{ |
| + {ID: "pending", Op: "move", Kind: "park-generation", Region: "ie", Owner: "region:ie"}, |
| + {ID: "pending", Op: "move", Kind: "park-generation", Region: "other", Owner: "region:ie"}, |
| + } { |
| + c := &Claim{Holder: &Holder{}, Op: Op{ID: "move", Kind: KindRestoring, Exec: "source", Intent: map[string]string{"target": "target", "to": "@home", "home_region": "ie"}}, Lost: []string{"target"}, Obligations: []Obligation{ob}} |
| + if LostMoveTargetParked(c, "@home") { |
| + t.Fatal("pending retained-name chore ignored", ob) |
| + } |
| + c.Obligations = nil |
| + if !LostMoveTargetParked(c, "ie") { |
| + t.Fatal("completed bound target refused") |
| + } |
| + } |
| +} |
| + |
| +func TestPlan13WarmAndPublishedHomeCoverage(t *testing.T) { |
| + for _, home := range []string{"ie", ""} { |
| + c := &Claim{Gen: 2, Holder: &Holder{Session: 2, Stamp: 2, Generation: "new", WriterBox: "box", Admissions: []AdmissionRecord{{Session: 2, Region: "ie", PredecessorRegion: "ie", Generation: "new", Predecessor: "old", Mode: "cold"}}}, Op: Op{Kind: KindServing, Exec: "box", Intent: map[string]string{"home_region": home}}} |
| + if err := PublishGeneration("", "", 2, "new", "@home", counter())(c); err != nil { |
| + t.Fatal(err) |
| + } |
| + if c.Holder.Admissions[0].Covered != (home != "") { |
| + t.Fatal("publication misses bound predecessor", home) |
| + } |
| + in := Admission{ObservedSession: 2, ObservedGeneration: "new", Box: "box", Mode: "warm", PredecessorCertified: true, Region: "@home"} |
| + if err := AdmitWriter(in)(c); (err == nil) != (home != "") { |
| + t.Fatal("warm admission", home, err) |
| + } |
| + } |
| +} |
diff --git a/internal/hostfence/claim_fence.go b/internal/hostfence/claim_fence.go| index 3a45e34dc..cda241eb3 100644 |
| --- a/internal/hostfence/claim_fence.go |
| +++ b/internal/hostfence/claim_fence.go |
| @@ -29,7 +29,7 @@ func (a ClaimFenceAuthority) ValidateFence(ctx context.Context, f termfence.Reco |
| if err := a.Directory.ValidateTerminationFenceAuthority(ctx, o, f.Box, f, a.Now()); err != nil { |
| return hostclaim.FenceBinding{}, err |
| } |
| - return hostclaim.FenceBinding{Region: o.Roster.Boxes[f.Box].Region, RosterRevision: o.Roster.Revision, RosterETag: o.ETag}, nil |
| + return hostclaim.FenceBinding{FleetHomeRegion: o.Roster.HomeRegion, Region: o.Roster.Boxes[f.Box].Region, RosterRevision: o.Roster.Revision, RosterETag: o.ETag}, nil |
| } |
| func ClaimFenceRef(ref EvidenceRef) hostclaim.FenceRef { |
diff --git a/internal/hostops/move_enrollment.go b/internal/hostops/move_enrollment.go| index 7c646ac46..84ff45882 100644 |
| --- a/internal/hostops/move_enrollment.go |
| +++ b/internal/hostops/move_enrollment.go |
| @@ -83,9 +83,12 @@ func BeginClaimMove(ctx context.Context, s release.Store, d hostactors.Directory |
| if err != nil { |
| return hostclaim.Observed{}, err |
| } |
| + if !roster.Roster.MatchesHome(home) { |
| + return hostclaim.Observed{}, ErrIdentity |
| + } |
| src, sourceOK := roster.Roster.Boxes[source] |
| dst, targetOK := roster.Roster.Boxes[target] |
| - if !sourceOK || !targetOK || source == target || src.State != "active" || dst.State != "active" || src.Region == "" || dst.Region == "" || park.PlacementRegion(park.HomePlacementCode(home, dst.Region)) != park.PlacementRegion(to) || park.PlacementRegion(park.HomePlacementCode(home, src.Region)) == park.PlacementRegion(to) { |
| + if !sourceOK || !targetOK || source == target || src.State != "active" || dst.State != "active" || src.Region == "" || dst.Region == "" { |
| return hostclaim.Observed{}, ErrIdentity |
| } |
| // Lookup also refuses a box with a termination fence. |
diff --git a/internal/hostops/move_fence_regions.go b/internal/hostops/move_fence_regions.go| index 869122619..9b7499f1d 100644 |
| --- a/internal/hostops/move_fence_regions.go |
| +++ b/internal/hostops/move_fence_regions.go |
| @@ -23,7 +23,7 @@ func (a MoveFenceRegions) ValidateFence(ctx context.Context, f termfence.Record) |
| if err != nil { |
| return hostclaim.FenceBinding{}, err |
| } |
| - if binding.Region == "" || (a.HomeRegion != "" && park.PlacementRegion(a.HomeRegion) != a.HomeRegion) { |
| + if binding.Region == "" || (a.HomeRegion != "" && (a.HomeRegion != binding.FleetHomeRegion || park.PlacementRegion(a.HomeRegion) != a.HomeRegion)) { |
| return hostclaim.FenceBinding{}, hostclaim.ErrFence |
| } |
| binding.Region = park.PlacementRegion(park.HomePlacementCode(a.HomeRegion, binding.Region)) |
diff --git a/internal/hostops/move_recovery_bridge_test.go b/internal/hostops/move_recovery_bridge_test.go| index 79a092493..7f5f9a055 100644 |
| --- a/internal/hostops/move_recovery_bridge_test.go |
| +++ b/internal/hostops/move_recovery_bridge_test.go |
| @@ -20,6 +20,7 @@ import ( |
| ) |
| type c7ReplayAuthority struct { |
| + home string |
| record termfence.Record |
| region string |
| } |
| @@ -35,7 +36,7 @@ func (a c7ReplayAuthority) ValidateFence(_ context.Context, f termfence.Record) |
| if f != a.record { |
| return hostclaim.FenceBinding{}, hostclaim.ErrFence |
| } |
| - return hostclaim.FenceBinding{Region: a.region, RosterRevision: 1, RosterETag: "roster"}, nil |
| + return hostclaim.FenceBinding{FleetHomeRegion: a.home, Region: a.region, RosterRevision: 1, RosterETag: "roster"}, nil |
| } |
| type c7DecisionHook struct { |
diff --git a/internal/hostops/plan13_enrollment_test.go b/internal/hostops/plan13_enrollment_test.go| index 43d9fd2f6..ad558ccdc 100644 |
| --- a/internal/hostops/plan13_enrollment_test.go |
| +++ b/internal/hostops/plan13_enrollment_test.go |
| @@ -2,6 +2,7 @@ package hostops |
| import ( |
| "encoding/json" |
| + "github.com/carlosframework/platform/internal/hostactors" |
| "github.com/carlosframework/platform/internal/hostclaim" |
| "github.com/carlosframework/platform/internal/park" |
| "github.com/carlosframework/platform/internal/provision" |
| @@ -15,7 +16,7 @@ func TestPlan13BeginClaimMoveHomeAndNamed(t *testing.T) { |
| {"named", "", "ie", "uk", "uk-box", "uk"}, |
| } { |
| t.Run(tc.name, func(t *testing.T) { |
| - f := newMoveStartFixture(t, nil) |
| + f := newMoveStartFixture(t, func(r *hostactors.Roster) { r.HomeRegion = tc.home }) |
| rec, etag, err := provision.GetRecordETag(t.Context(), f.raw, "bab", "app", f.host) |
| if err != nil { |
| t.Fatal(err) |
| @@ -48,3 +49,18 @@ func TestPlan13BeginClaimMoveHomeAndNamed(t *testing.T) { |
| }) |
| } |
| } |
| + |
| +func TestPlan13MoveStartRefusesFalseHome(t *testing.T) { |
| + f := newMoveStartFixture(t, func(r *hostactors.Roster) { r.HomeRegion = "ie" }) |
| + o := f.read(t) |
| + h, err := f.authority.RegisterMove(t.Context(), o, "canonical", moveStartID) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := BeginClaimMove(t.Context(), f.raw, f.authority.Directory, h, o, moveStartID, "operator", "uk-box", "", f.now, "uk"); err == nil { |
| + t.Fatal("operation home overrode signed fleet home") |
| + } |
| + if f.read(t).ETag != o.ETag { |
| + t.Fatal("refusal mutated claim") |
| + } |
| +} |
diff --git a/internal/hostops/plan13_fence_test.go b/internal/hostops/plan13_fence_test.go| index 1ac1e2e2b..a29b085d8 100644 |
| --- a/internal/hostops/plan13_fence_test.go |
| +++ b/internal/hostops/plan13_fence_test.go |
| @@ -34,6 +34,12 @@ func TestPlan13FenceRecoveryHomeAndNamed(t *testing.T) { |
| key := "control/box-fences/" + strings.Repeat("a", 32) + ".json" |
| r.Move.Rollback = &provision.MoveRollback{ID: "rb_abcdefghijklmnopqrstuvwxyz", Reason: provision.RollbackTargetFenced, Evidence: key, RequestedAt: &at, TargetPhase: provision.RbTargetLost, LossRef: key, LostAt: &at, SourcePhase: provision.RbSourceWaiting} |
| }) |
| + if which == "source-home" { |
| + w.authority.home = "uk" |
| + } |
| + if which == "target-home" { |
| + w.authority.home = "ie" |
| + } |
| run := func() error { |
| return w.control.RunRecovery(t.Context(), func(ctx context.Context) error { |
| return runMoveRecovery(ctx, w.claim(t), func(ctx context.Context, g *MoveRecoveryGrant) error { return g.ReleaseFencedTarget(ctx, w.ref) }, w.authority) |
| @@ -68,6 +74,42 @@ func TestPlan13FenceRecoveryHomeAndNamed(t *testing.T) { |
| if which == "target-home" && rec.Move.To != "" { |
| t.Fatal("target placement rewritten") |
| } |
| + if which == "target-home" { |
| + o := w.claim(t) |
| + for _, ob := range o.Claim.Obligations { |
| + var e error |
| + o, e = hostclaim.Update(t.Context(), w.raw, o, hostclaim.CompleteObligation(ob.Op, ob.ID)) |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + } |
| + o, err = hostclaim.Update(t.Context(), w.raw, o, func(c *hostclaim.Claim) error { |
| + c.Obligations = append(c.Obligations, hostclaim.Obligation{ID: "retained", Op: c.Op.ID, Kind: "park-generation", Region: "ie", Owner: "region:ie", Generation: "old", Retirement: "retirement"}) |
| + return nil |
| + }) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + stamp := func() error { |
| + return w.control.RunRecovery(t.Context(), func(ctx context.Context) error { |
| + return runMoveRecovery(ctx, w.claim(t), func(ctx context.Context, g *MoveRecoveryGrant) error { return g.StampLostMoveTargetParked(ctx) }, w.authority) |
| + }) |
| + } |
| + if err := stamp(); err == nil { |
| + t.Fatal("parked stamp ignored retained-name chore") |
| + } |
| + rec, err = provision.GetRecord(t.Context(), w.raw, "bab", "app", w.host) |
| + if err != nil || rec.Move.Rollback.TargetParkedAt != nil { |
| + t.Fatal("premature parked stamp", err) |
| + } |
| + if _, err = hostclaim.Update(t.Context(), w.raw, o, hostclaim.CompleteObligation(o.Claim.Op.ID, "retained")); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := stamp(); err != nil { |
| + t.Fatal("completed target refused", err) |
| + } |
| + } |
| + |
| }) |
| } |
| } |
diff --git a/internal/hostops/plan13_fix1_test.go b/internal/hostops/plan13_fix1_test.go| new file mode 100644 |
| index 000000000..99eefd2b9 |
| --- /dev/null |
| +++ b/internal/hostops/plan13_fix1_test.go |
| @@ -0,0 +1,29 @@ |
| +package hostops |
| + |
| +import ( |
| + "context" |
| + "github.com/carlosframework/platform/internal/hostclaim" |
| + "github.com/carlosframework/platform/internal/termfence" |
| + "testing" |
| +) |
| + |
| +type plan13BoundFence struct{ home, region string } |
| + |
| +func (a plan13BoundFence) ValidateFence(context.Context, termfence.Record) (hostclaim.FenceBinding, error) { |
| + return hostclaim.FenceBinding{FleetHomeRegion: a.home, Region: a.region, RosterRevision: 1, RosterETag: "signed"}, nil |
| +} |
| +func TestPlan13FenceRequiresSignedHome(t *testing.T) { |
| + for _, home := range []string{"ie", "ca", ""} { |
| + a := MoveFenceRegions{Authority: plan13BoundFence{home: "ie", region: "ca"}, HomeRegion: home} |
| + got, err := a.ValidateFence(t.Context(), termfence.Record{}) |
| + if home == "ca" { |
| + if err == nil { |
| + t.Fatal("named fence projected to home", got) |
| + } |
| + continue |
| + } |
| + if err != nil || got.Region != "ca" { |
| + t.Fatal(got, err) |
| + } |
| + } |
| +} |
diff --git a/internal/hostops/production_create.go b/internal/hostops/production_create.go| index 01d09bd6e..36b7f6776 100644 |
| --- a/internal/hostops/production_create.go |
| +++ b/internal/hostops/production_create.go |
| @@ -122,6 +122,9 @@ func (a ProductionAuthority) selectExecutor(ctx context.Context, r Record) (stri |
| if err != nil { |
| return "", "", "", err |
| } |
| + if !roster.Roster.MatchesHome(a.HomeRegion) { |
| + return "", "", "", ErrIdentity |
| + } |
| candidates := map[string]*executorCandidate{} |
| servingScope := 0 |
| for ref, b := range roster.Roster.Boxes { |
diff --git a/internal/park/plan13_observe_test.go b/internal/park/plan13_observe_test.go| index 8f7e7c283..ede6df026 100644 |
| --- a/internal/park/plan13_observe_test.go |
| +++ b/internal/park/plan13_observe_test.go |
| @@ -95,3 +95,40 @@ func TestPlan13SweepAbsentClaimRefuses(t *testing.T) { |
| t.Fatal("absent claim accepted") |
| } |
| } |
| + |
| +func TestPlan13ReturningWriterEmptyHomePark(t *testing.T) { |
| + f := newFixture(t) |
| + choreIn(t, f, HomeRegion) |
| + f.e.HomeRegion = "ie" |
| + engineIn(f, HomeRegion, HomeRegion) |
| + o, err := hostclaim.Read(t.Context(), f.e.Claims, f.w.Host) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + o, err = hostclaim.Update(t.Context(), f.e.Claims, o, func(c *hostclaim.Claim) error { |
| + c.Op = hostclaim.Op{ID: "op", Kind: hostclaim.KindRestoring, Phase: hostclaim.PhaseClaimed, Exec: "returner", Intent: map[string]string{"home_region": "ie"}} |
| + c.Holder.Generation, c.Holder.Session, c.Holder.WriterBox = "old", 1, "target" |
| + c.Holder.Admissions = []hostclaim.AdmissionRecord{{Generation: "old", Region: "ie", Session: 1, Box: "target", Mode: "cold"}} |
| + return nil |
| + }) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + o, err = hostclaim.Update(t.Context(), f.e.Claims, o, hostclaim.AdmitWriter(hostclaim.Admission{ObservedOpID: "op", ObservedPhase: hostclaim.PhaseClaimed, ObservedSession: 1, ObservedGeneration: "old", Region: "uk", Box: "returner", Mode: "cold", NewGeneration: "return"})) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if !o.Claim.Holder.Admissions[1].Covered { |
| + t.Fatal("returning predecessor uncovered") |
| + } |
| + if err := f.e.Run(t.Context(), f.w); err != nil { |
| + t.Fatal(err) |
| + } |
| + done, err := hostclaim.Read(t.Context(), f.e.Claims, f.w.Host) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if len(done.Claim.Obligations) != 0 || !done.Claim.Holder.Admissions[1].Covered || done.Claim.Holder.Admissions[1].PredecessorRegion != "ie" { |
| + t.Fatal(done.Claim) |
| + } |
| +} |
diff --git a/internal/park/region_names.go b/internal/park/region_names.go| index 3faa5ee25..f865694db 100644 |
| --- a/internal/park/region_names.go |
| +++ b/internal/park/region_names.go |
| @@ -60,3 +60,10 @@ func EnginePlacement(home, engine string) (string, bool) { |
| } |
| return engine, PlacementRegion(engine) == engine |
| } |
| + |
| +func VerifiedHome(fleet, local, region, operation string) (string, error) { |
| + if (fleet != "" && PlacementRegion(fleet) != fleet) || (operation != "" && operation != fleet) || (local != "" && (local != fleet || local != region)) || (local == "" && operation != "" && operation == region) { |
| + return "", ErrUnsafe |
| + } |
| + return BoundHome(local, operation) |
| +} |
diff --git a/internal/provision/plan9_b8_census_test.go b/internal/provision/plan9_b8_census_test.go| index 6cbe9dc46..79e6a5f5f 100644 |
| --- a/internal/provision/plan9_b8_census_test.go |
| +++ b/internal/provision/plan9_b8_census_test.go |
| @@ -474,7 +474,7 @@ func Reviewed(ctx context.Context, s Store, k string) error { |
| var plan9B8CensusInventory = []censusEntry{ |
| {"internal/hostbox/parkleases.go", "ReplicaParkLeaseSet", "key-site", "lit:instances", "0ca8d9b743402fda9d15cc215464423b35e21c1c2b47f91294d76188b7d43fb6"}, |
| {"internal/host/claimretirementcensus.go", "ClaimRetirementCensus", "key-site", "lit:instances", "abc0bd8c5f5d328abfac8821f42f8f23954723ed6506747f33b0197824e2611f"}, |
| - {"internal/host/claimproduction.go", "productionClaimRegional.View", "key-site", "lit:instances", "85fa91931c06b2fb1f8e53512a219190fcb146b9919b1e625b8562c299209840"}, |
| + {"internal/host/claimproduction.go", "productionClaimRegional.View", "key-site", "lit:instances", "533203b2aa729801438da4f3a85799a58ea0e67d20eb5625f0de32ea623f1607"}, |
| {"internal/hostops/move_loss_admission.go", "moveLossOwner.ValidateLossRecovery", "key-site", "key:RecordKey", "1ad4af4595c764fc5051e50c2b187472cb20ce73c3b3e8245b8527228f326f5f"}, |
| {"internal/host/claimretirementcensus.go", "claimArchivedLocations", "key-site", "lit:instances", "9ba8ea4ecb5ad63621c1322467b9a80f8f5703896ba1fc3f7cb1addd56418950"}, |
| {"internal/host/claimretirementcensus.go", "claimLegacyIncarnation", "key-site", "lit:instances", "31a14ea105c67e00a24f06b9500a221ee5bfc47402b9c33cecfde807e51db530"}, |
| @@ -705,11 +705,11 @@ var plan9B8CensusInventory = []censusEntry{ |
| {"internal/host/movesmeasure.go", "measureS3", "nonrecord", "sel:Put", "3ae9fc093b453e19a5b19be0929533b30a0e2a3e998ba8dc766f41287db2caca"}, |
| {"internal/host/movespass.go", "legacyMoveRound", "record-writer", "key:SetRecordMove", "1e13a3ea099d45318d3b64b067448c87dc4a136cd909348f526e4583ac00150a"}, |
| {"internal/host/movesphase1.go", "writeMoveLedger", "nonrecord", "sel:Put", "597a70ac06ceb4e9eb52bb9f61b28a4937e703fa4a0916f64bd95d9594f0556e"}, |
| - {"internal/host/movesphase1.go", "movesWorker.sourceRound", "nonrecord", "sel:PutIfAbsentVersioned", "810e5a9abac7dea9220f83eae924cea99b0eca0f002764c88ec35de26a356dff"}, |
| + {"internal/host/movesphase1.go", "movesWorker.sourceRound", "nonrecord", "sel:PutIfAbsentVersioned", "a2989e948ebe33ceeaaf1be25c8cdb2bdfff4cf5e55b5ed28a03605e1ec81756"}, |
| {"internal/host/movesphase1.go", "movesWorker.refreshCrossRate", "nonrecord", "sel:CopyVersioned", "7e03e680bd61be26dddcf168cacd2427bb535571dfd326c4a3130ebe65ea1968"}, |
| {"internal/host/movesphase1.go", "movesWorker.writeSourceReport", "nonrecord", "sel:PutReplace", "370407cca2c1b91f6e5e7f135238f04c0dce9d7986e6e1ee9bebbf88cad0b9e0"}, |
| {"internal/host/movesphase1.go", "movesWorker.decideReady", "record-writer", "key:SetRecordMove", "d29c22eafacfb894b96cd0c40ec447b6ab02f46ea087201685627861f992085d"}, |
| - {"internal/host/movesreport.go", "movesWorker.targetReport", "nonrecord", "sel:PutReplace", "7a8eaabb9c56b2352969ea49124481a1d3687376b1f9ab6b9ce9ae49d0573843"}, |
| + {"internal/host/movesreport.go", "movesWorker.targetReport", "nonrecord", "sel:PutReplace", "9a761daeb0a9018ad409ed353c6da6f9e523c63d46ab2c248000327d5f7fcd4f"}, |
| {"internal/host/movesstore.go", "laneProbe", "nonrecord", "sel:PutIfAbsent", "f4a17a1143e7370b9039b2e66e736606015c46619bab92c553ce7b1ad6b7918b"}, |
| {"internal/host/snapshot_loop.go", "registrySnapshotPass", "nonrecord", "sel:Put", "6b01b46b361c3a2d1d432659a0d234c01975849b9a8ac5571858fdd78d376849"}, |
| {"internal/host/snapshot_loop.go", "putReplacing", "nonrecord", "sel:Put", "11e8639effce142eab707b67cbdd425d0d7cea510ddf95d292bdba8f79c9b510"}, |