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