fix/home-region-followups
Squashed from fix/home-region-followups into main. Co-Authored-By: codex <codex~paul@keymail.dev> Co-Authored-By: Paul Campbell <paul@tito.io>
30 files changed,
+691
−60
docs/host-claim-restore-runbook.md+8 −0home_region_comparison_test.go+238 −14internal/console/apiinstancemove_claim_test.go+4 −2internal/console/claim_runtime.go+2 −2internal/console/testdata/claim-authority-sites.txt+2 −2internal/host/claimproduction.go+8 −0internal/host/claimregion.go+2 −2internal/host/claimwakestop.go+7 −1internal/host/plan13_region_test.go+20 −0internal/hostactors/plan13_home_test.go+54 −0internal/hostactors/roster.go+7 −2internal/hostclaim/move.go+6 −6internal/hostclaim/move_loss.go+3 −1internal/hostclaim/plan13_followups_test.go+137 −0internal/hostops/console_move_grant.go+3 −0internal/hostops/control_writer.go+10 −5internal/hostops/lifecycle_grant.go+1 −1internal/hostops/lifecycle_record.go+3 −0internal/hostops/lifecycle_record_test.go+12 −2internal/hostops/move_home.go+31 −0internal/hostops/move_loss_grant.go+12 −5internal/hostops/move_recovery_bridge.go+10 −3internal/hostops/move_recovery_bridge_test.go+1 −1internal/hostops/move_recovery_grant.go+4 −1internal/hostops/plan13_conversion_test.go+6 −0internal/hostops/plan13_fence_test.go+2 −0internal/hostops/plan13_followups_test.go+82 −0internal/hostops/plan13_loss_test.go+1 −0internal/hostops/record_writes.go+9 −4internal/provision/plan9_b8_census_test.go+6 −6
CI passed — run details
CI log
=== gate ===
cd launchctx && go vet ./... && go test ./... -count=1
ok amadan.net/carlos/platform.git/launchctx 0.011s
? 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 332.177s
? github.com/carlosframework/platform/cmd/changelog [no test files]
ok github.com/carlosframework/platform/cmd/console 3.287s
ok github.com/carlosframework/platform/cmd/moveprobe 93.201s
ok github.com/carlosframework/platform/internal/activator 207.195s
ok github.com/carlosframework/platform/internal/agentsock 0.016s
ok github.com/carlosframework/platform/internal/alerting 0.003s
ok github.com/carlosframework/platform/internal/analytics 0.019s
ok github.com/carlosframework/platform/internal/appconfig 2.297s
ok github.com/carlosframework/platform/internal/apperr 0.071s
ok github.com/carlosframework/platform/internal/applog 0.123s
ok github.com/carlosframework/platform/internal/awsinv 0.004s
ok github.com/carlosframework/platform/internal/boxchoice 0.005s
ok github.com/carlosframework/platform/internal/boxid 0.569s
? 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.008s
ok github.com/carlosframework/platform/internal/caps 0.012s
ok github.com/carlosframework/platform/internal/certwarm 0.002s
ok github.com/carlosframework/platform/internal/cgroup 0.187s
ok github.com/carlosframework/platform/internal/changelog 0.238s
ok github.com/carlosframework/platform/internal/clamd 0.054s
? github.com/carlosframework/platform/internal/cliflag [no test files]
ok github.com/carlosframework/platform/internal/console 355.560s
? 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.612s
ok github.com/carlosframework/platform/internal/dbdoor/dbdoortest 0.010s
ok github.com/carlosframework/platform/internal/dbverify 0.005s
ok github.com/carlosframework/platform/internal/dnsrec 0.003s
ok github.com/carlosframework/platform/internal/docsite 0.005s
ok github.com/carlosframework/platform/internal/domains 2.017s
ok github.com/carlosframework/platform/internal/doorkey 0.006s
ok github.com/carlosframework/platform/internal/economics 0.004s
ok github.com/carlosframework/platform/internal/edge 25.678s
? github.com/carlosframework/platform/internal/edgeproof [no test files]
ok github.com/carlosframework/platform/internal/edgesfile 0.008s
ok github.com/carlosframework/platform/internal/fleetapi 0.138s
ok github.com/carlosframework/platform/internal/gate 1.657s
ok github.com/carlosframework/platform/internal/genselect 0.017s
ok github.com/carlosframework/platform/internal/geo 0.010s
ok github.com/carlosframework/platform/internal/geoip 0.032s
ok github.com/carlosframework/platform/internal/host 55.466s
ok github.com/carlosframework/platform/internal/hostactors 0.509s
ok github.com/carlosframework/platform/internal/hostbox 1.226s
ok github.com/carlosframework/platform/internal/hostclaim 0.256s
ok github.com/carlosframework/platform/internal/hostevidence 0.020s
ok github.com/carlosframework/platform/internal/hostexec 0.327s
ok github.com/carlosframework/platform/internal/hostfence 0.051s
ok github.com/carlosframework/platform/internal/hostfiles 4.152s
ok github.com/carlosframework/platform/internal/hostfloor 0.025s
ok github.com/carlosframework/platform/internal/hostguard 0.147s
ok github.com/carlosframework/platform/internal/hostops 1.376s
? github.com/carlosframework/platform/internal/hostops/fencerelease [no test files]
ok github.com/carlosframework/platform/internal/hostops/internal/fencerelease 0.002s
ok github.com/carlosframework/platform/internal/hostquarantine 0.002s
ok github.com/carlosframework/platform/internal/hostresolve 0.131s
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.702s
ok github.com/carlosframework/platform/internal/instanceexec 2.686s
ok github.com/carlosframework/platform/internal/instancefs 0.005s
ok github.com/carlosframework/platform/internal/instancekey 0.005s
ok github.com/carlosframework/platform/internal/instanceupdates 0.008s
ok github.com/carlosframework/platform/internal/layout 1.342s
ok github.com/carlosframework/platform/internal/lease 9.441s
ok github.com/carlosframework/platform/internal/lease/leasetest 0.008s
ok github.com/carlosframework/platform/internal/ledger 0.016s
ok github.com/carlosframework/platform/internal/mail 0.169s
ok github.com/carlosframework/platform/internal/maintenance 0.083s
ok github.com/carlosframework/platform/internal/maintenanceexec 5.523s
ok github.com/carlosframework/platform/internal/maintenanceguard 0.079s
ok github.com/carlosframework/platform/internal/metrics 0.005s
ok github.com/carlosframework/platform/internal/moves 21.147s
ok github.com/carlosframework/platform/internal/moves/archive 0.742s
ok github.com/carlosframework/platform/internal/objstore 0.187s
ok github.com/carlosframework/platform/internal/ops 0.169s
ok github.com/carlosframework/platform/internal/park 0.198s
ok github.com/carlosframework/platform/internal/privatefile 0.003s
ok github.com/carlosframework/platform/internal/provision 3.187s
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.692s
ok github.com/carlosframework/platform/internal/relay 7.229s
ok github.com/carlosframework/platform/internal/release 18.547s
ok github.com/carlosframework/platform/internal/rollout 1.491s
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.227s
ok github.com/carlosframework/platform/internal/sim/hostlife 2.381s
ok github.com/carlosframework/platform/internal/sim/litestreamfacts 4.315s
ok github.com/carlosframework/platform/internal/status 0.036s
ok github.com/carlosframework/platform/internal/steering 0.005s
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.005s
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.004s
ok github.com/carlosframework/platform/internal/version 0.002s
ok github.com/carlosframework/platform/internal/vet 1.533s
=== 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 12:46:11 moves: T4: STORE: list: operation error S3: ListObjectsV2, https response error StatusCode: 403, RequestID: 18DDC8F2C11CED80, 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/docs/host-claim-restore-runbook.md b/docs/host-claim-restore-runbook.md| index 27e7b0bc6..3c2fe908d 100644 |
| --- a/docs/host-claim-restore-runbook.md |
| +++ b/docs/host-claim-restore-runbook.md |
| @@ -31,3 +31,11 @@ Retirement journals live under `/var/lib/carlos-claims/retire`, with cold-handov |
| Before the first start on a box with legacy journals, stop the edge and old agent writers and run `carlos system journal-migrate` with the certified binary. Migration requires signed root stop evidence and leaves the originals in place. Once the migration marker exists, later legacy files are ignored with an alert and do not block root startup. Do not delete the marker. |
| Flag-on instance-control uses `MemoryMax=256M` and `GOMEMLIMIT=192MiB`; flag-off limits are unchanged. This is a sizing estimate backed by bounded migration inputs, not a production memory measurement. Measure peak memory during the rollout rehearsal. |
| + |
| +## Home-region activation order |
| + |
| +Upgrade every reader of `control/host-actors.json` to a build containing Plan 13 before publishing `home_region`. This includes consoles, agents, root services, update and rollback tools, and offline or recovery binaries. Old roster documents still parse on the new build; pre-Plan-13 binaries reject a signed roster containing `home_region`. |
| + |
| +After verifying every reader's build, publish the home designation, then enable `CARLOS_HOST_CLAIM`. The designation is write-once: choose the deployment's home roster name before publishing it. Without a designation, a box runs claims under its roster name with no `@home` alias and logs that state once per process. |
| + |
| +The signed fleet data does not identify the running build of every roster reader, so this ordering is an operator prerequisite rather than an automated publication preflight. Do not publish the designation until you have verified the remaining readers outside that data. Publishing it does not authorize a production rollout; the scoped approval requirement above still applies. |
diff --git a/home_region_comparison_test.go b/home_region_comparison_test.go| index 93d43918c..27b2ec0c6 100644 |
| --- a/home_region_comparison_test.go |
| +++ b/home_region_comparison_test.go |
| @@ -24,7 +24,7 @@ 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") |
| + return s == "to" || s == "from" || s == "rec.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"]`)) { |
| @@ -33,6 +33,17 @@ func plan13Engine(s string) bool { |
| if strings.HasSuffix(s, ".Region()") || strings.HasSuffix(s, ".Touched.Regions") || strings.HasSuffix(s, ".QuietAfter") { |
| return true |
| } |
| + if strings.HasPrefix(s, `strings.TrimPrefix(`) && strings.HasSuffix(s, `.Owner, "region:")`) { |
| + return plan13Engine(strings.TrimSuffix(strings.TrimPrefix(s, `strings.TrimPrefix(`), `.Owner, "region:")`) + ".Region") |
| + } |
| + for _, field := range []string{".Region", ".PredecessorRegion"} { |
| + if strings.HasSuffix(s, field) { |
| + switch strings.TrimSuffix(s, field) { |
| + case "ob", "o", "a", "predecessor", "entry", "record", "box", "row", "rr.row": |
| + 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 |
| @@ -40,6 +51,9 @@ func plan13Engine(s string) bool { |
| return false |
| } |
| func plan13Operand(e ast.Expr, classify func(string) bool) bool { |
| + if _, ok := e.(*ast.FuncLit); ok { |
| + return false |
| + } |
| if classify(plan13Expr(e)) { |
| return true |
| } |
| @@ -59,34 +73,187 @@ func plan13Operand(e ast.Expr, classify func(string) bool) bool { |
| } |
| return false |
| } |
| -func plan13MixedRegion(x *ast.BinaryExpr) bool { |
| +func plan13MixedRegion(x *ast.BinaryExpr) bool { return plan13MixedRegionWith(x, plan13Engine) } |
| +func plan13MixedRegionWith(x *ast.BinaryExpr, engine func(string) bool) 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) |
| + return plan13Operand(x.X, plan13Placement) && plan13Operand(x.Y, engine) || plan13Operand(x.Y, plan13Placement) && plan13Operand(x.X, engine) |
| } |
| -func plan13MixedRegionNode(n ast.Node) bool { |
| +func plan13MixedRegionNode(n ast.Node) bool { return plan13MixedRegionNodeWith(n, plan13Engine) } |
| +func plan13MixedRegionNodeWith(n ast.Node, engine func(string) bool) bool { |
| switch x := n.(type) { |
| case *ast.BinaryExpr: |
| - return plan13MixedRegion(x) |
| + return plan13MixedRegionWith(x, engine) |
| + case *ast.AssignStmt: |
| + if len(x.Lhs) == len(x.Rhs) { |
| + for i := range x.Lhs { |
| + if plan13Operand(x.Lhs[i], engine) && plan13Operand(x.Rhs[i], plan13Placement) && !plan13EngineConversion(x.Rhs[i]) { |
| + return true |
| + } |
| + } |
| + } |
| case *ast.IndexExpr: |
| - return plan13Operand(x.X, plan13Engine) && plan13Operand(x.Index, plan13Placement) |
| + return plan13Operand(x.X, engine) && 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 |
| + placement, hasEngine := false, false |
| for _, arg := range x.Args { |
| placement = placement || plan13Operand(arg, plan13Placement) |
| - engine = engine || plan13Operand(arg, plan13Engine) |
| + hasEngine = hasEngine || plan13Operand(arg, engine) |
| } |
| - return placement && engine |
| + return placement && hasEngine |
| + } |
| + return false |
| +} |
| + |
| +// Mapping a placement to an engine is valid at a store boundary, but cannot |
| +// replace alias-aware equality between engine identities. |
| +func plan13EngineConversion(e ast.Expr) bool { |
| + if p, ok := e.(*ast.ParenExpr); ok { |
| + return plan13EngineConversion(p.X) |
| + } |
| + c, ok := e.(*ast.CallExpr) |
| + if !ok { |
| + return false |
| + } |
| + switch plan13Expr(c.Fun) { |
| + case "park.PlacementRegion", "regionname.PlacementRegion", "PlacementRegion": |
| + return true |
| } |
| return false |
| } |
| +// A scope maps scalar regions and records from engine-region collections. |
| +func plan13Collection(e ast.Expr) string { |
| + s := plan13Expr(e) |
| + if strings.HasSuffix(s, ".Touched.Regions") { |
| + return "scalar" |
| + } |
| + if strings.HasSuffix(s, ".Admissions") || strings.HasSuffix(s, ".Obligations") { |
| + return "record" |
| + } |
| + return "" |
| +} |
| +func plan13Scope(scope map[string]string) map[string]string { |
| + next := map[string]string{} |
| + for name, kind := range scope { |
| + next[name] = kind |
| + } |
| + return next |
| +} |
| + |
| +type plan13Parent struct { |
| + switchTag ast.Expr |
| + recordType string |
| +} |
| + |
| +func plan13Inspect(root ast.Node, scope map[string]string, reject func(ast.Node), parents ...plan13Parent) { |
| + var parent plan13Parent |
| + if len(parents) > 0 { |
| + parent = parents[0] |
| + } |
| + engine := func(s string) bool { |
| + if kind, ok := scope[s]; ok { |
| + return kind == "scalar" |
| + } |
| + for _, field := range []string{".Region", ".PredecessorRegion"} { |
| + if strings.HasSuffix(s, field) { |
| + if kind, ok := scope[strings.TrimSuffix(s, field)]; ok { |
| + return kind == "record" |
| + } |
| + } |
| + } |
| + return plan13Engine(s) |
| + } |
| + ast.Inspect(root, func(n ast.Node) bool { |
| + if n == nil { |
| + return false |
| + } |
| + if plan13MixedRegionNodeWith(n, engine) { |
| + reject(n) |
| + } |
| + switch x := n.(type) { |
| + case *ast.RangeStmt: |
| + plan13Inspect(x.X, scope, reject) |
| + next := plan13Scope(scope) |
| + if name, ok := x.Key.(*ast.Ident); ok { |
| + next[name.Name] = "" |
| + } |
| + if name, ok := x.Value.(*ast.Ident); ok { |
| + next[name.Name] = plan13Collection(x.X) |
| + } |
| + plan13Inspect(x.Body, next, reject) |
| + return false |
| + case *ast.CallExpr: |
| + for _, arg := range x.Args { |
| + if fn, ok := arg.(*ast.FuncLit); ok { |
| + next := plan13Scope(scope) |
| + for _, field := range fn.Type.Params.List { |
| + for _, name := range field.Names { |
| + next[name.Name] = "" |
| + } |
| + } |
| + if len(x.Args) > 0 && len(fn.Type.Params.List) > 0 { |
| + for _, name := range fn.Type.Params.List[0].Names { |
| + next[name.Name] = plan13Collection(x.Args[0]) |
| + } |
| + } |
| + plan13Inspect(fn.Body, next, reject) |
| + } else { |
| + plan13Inspect(arg, scope, reject) |
| + } |
| + } |
| + plan13Inspect(x.Fun, scope, reject) |
| + return false |
| + case *ast.FuncLit: |
| + next := plan13Scope(scope) |
| + for _, field := range x.Type.Params.List { |
| + for _, name := range field.Names { |
| + next[name.Name] = "" |
| + } |
| + } |
| + plan13Inspect(x.Body, next, reject) |
| + return false |
| + case *ast.SwitchStmt: |
| + if x.Init != nil { |
| + plan13Inspect(x.Init, scope, reject) |
| + } |
| + if x.Tag != nil { |
| + plan13Inspect(x.Tag, scope, reject) |
| + } |
| + for _, stmt := range x.Body.List { |
| + plan13Inspect(stmt, scope, reject, plan13Parent{switchTag: x.Tag}) |
| + } |
| + return false |
| + case *ast.CaseClause: |
| + for _, value := range x.List { |
| + if parent.switchTag != nil && plan13MixedRegionWith(&ast.BinaryExpr{X: parent.switchTag, Op: token.EQL, Y: value}, engine) { |
| + reject(x) |
| + } |
| + } |
| + case *ast.CompositeLit: |
| + typ := plan13Expr(x.Type) |
| + for _, element := range x.Elts { |
| + plan13Inspect(element, scope, reject, plan13Parent{recordType: typ[strings.LastIndex(typ, ".")+1:]}) |
| + } |
| + return false |
| + case *ast.KeyValueExpr: |
| + switch parent.recordType { |
| + case "Obligation", "Admission", "AdmissionRecord", "Selection": |
| + if (plan13Expr(x.Key) == "Region" || plan13Expr(x.Key) == "PredecessorRegion") && plan13Operand(x.Value, plan13Placement) && !plan13EngineConversion(x.Value) { |
| + reject(x) |
| + } |
| + } |
| + } |
| + return true |
| + }) |
| +} |
| + |
| func TestPlan13NoDirectPlacementEngineComparison(t *testing.T) { |
| err := filepath.WalkDir(".", func(path string, d fs.DirEntry, err error) error { |
| if err != nil { |
| @@ -106,11 +273,8 @@ func TestPlan13NoDirectPlacementEngineComparison(t *testing.T) { |
| 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 |
| + plan13Inspect(f, nil, func(n ast.Node) { |
| + t.Errorf("%s: placement and engine regions need the shared rule: %s", fset.Position(n.Pos()), plan13Expr(n)) |
| }) |
| return nil |
| }) |
| @@ -178,3 +342,63 @@ func TestPlan13RegionCallAndMapLintWitness(t *testing.T) { |
| } |
| } |
| } |
| + |
| +func TestPlan13RegionFlowLintWitness(t *testing.T) { |
| + for _, body := range []string{ |
| + `if park.PlacementRegion(rec.Region) == box.Region {}`, |
| + `if park.PlacementRegion(rec.Region) != row.Region {}`, |
| + `if park.PlacementRegion(rec.Region) == rr.row.Region {}`, |
| + `if ob.Region == park.PlacementRegion(rec.Region) {}`, |
| + `if predecessor.Region == park.PlacementRegion(rec.Region) {}`, |
| + `if entry.PredecessorRegion == park.PlacementRegion(rec.Region) {}`, |
| + `if strings.TrimPrefix(ob.Owner, "region:") == park.PlacementRegion(rec.Region) {}`, |
| + `slices.ContainsFunc(c.Touched.Regions, func(r string) bool { return r == rec.Move.To })`, |
| + `for _, r := range c.Touched.Regions { if r == park.PlacementRegion(rec.Move.To) {} }`, |
| + `switch park.PlacementRegion(rec.Region) { case s.Region: }`, |
| + `_ = hostclaim.Obligation{Region: rec.Move.To}`, |
| + `s.Region = rec.Region`, |
| + `if park.PlacementRegion(rec.Region) == record.Region {}`, |
| + `for _, item := range c.Admissions { if item.PredecessorRegion == rec.Move.From {} }`, |
| + `for _, item := range c.Obligations { if item.Region == rec.Region {} }`, |
| + `slices.ContainsFunc(c.Admissions, func(item hostclaim.AdmissionRecord) bool { return item.Region == rec.Region })`, |
| + `_ = hostclaim.Admission{Region: rec.Region}`, |
| + `_ = hostbox.Selection{Region: rec.Region}`, |
| + `s.Region, other = rec.Region, x`, |
| + `f := func() { s.Region = rec.Region }; f()`, |
| + } { |
| + t.Run(body, func(t *testing.T) { |
| + f, err := parser.ParseFile(token.NewFileSet(), "witness.go", "package p; func f(){"+body+"}", 0) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + found := false |
| + plan13Inspect(f, nil, func(ast.Node) { found = true }) |
| + if !found { |
| + t.Fatal("missed", body) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +func TestPlan13RegionFlowLintAcceptsBoundaries(t *testing.T) { |
| + for _, body := range []string{ |
| + `slices.ContainsFunc(c.Touched.Regions,func(r string) bool { return park.PlacementMatches(home, rec.Move.To,r) })`, |
| + `for _,r:=range c.Touched.Regions { if park.SameRegion(home,r,park.PlacementRegion(rec.Region)) {} }`, |
| + `for _,item:=range c.Admissions { if park.PlacementMatches(home,rec.Region,item.Region) {} }`, |
| + `s.Region = park.PlacementRegion(rec.Region)`, |
| + `_ = hostclaim.Obligation{Region: park.PlacementRegion(rec.Region)}`, |
| + `_ = provision.Record{Region: rec.Region}`, |
| + `switch rec.Region { case cfg.Region: }`, |
| + `for _,r:=range c.Touched.Regions { use(r) }; if r == rec.Region {}`, |
| + `slices.ContainsFunc(c.Touched.Regions,func(r string) bool { return true }); if r == rec.Region {}`, |
| + `slices.ContainsFunc(c.Touched.Regions,func(r string) bool { return func(r string) bool { return r == rec.Region }("placement") })`, |
| + } { |
| + t.Run(body, func(t *testing.T) { |
| + f, err := parser.ParseFile(token.NewFileSet(), "witness.go", "package p; func f(){"+body+"}", 0) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + plan13Inspect(f, nil, func(n ast.Node) { t.Errorf("false positive: %s", plan13Expr(n)) }) |
| + }) |
| + } |
| +} |
diff --git a/internal/console/apiinstancemove_claim_test.go b/internal/console/apiinstancemove_claim_test.go| index 99698a97a..d5a750552 100644 |
| --- a/internal/console/apiinstancemove_claim_test.go |
| +++ b/internal/console/apiinstancemove_claim_test.go |
| @@ -147,7 +147,9 @@ func armClaimMoves(t *testing.T, fx *consoleFixture, bind, admission bool, raw r |
| if raw == nil { |
| raw = fx.rel |
| } |
| - control, err := hostops.NewControlWriter(fx.rel, fx.pub, time.Now) |
| + cfg := fx.srv.cfg |
| + cfg.HostClaim = true |
| + control, err := hostops.NewControlWriter(fx.rel, fx.pub, time.Now, cfg.dbClaimHomeRegion()) |
| if err != nil { |
| t.Fatal(err) |
| } |
| @@ -155,7 +157,7 @@ func armClaimMoves(t *testing.T, fx *consoleFixture, bind, admission bool, raw r |
| if err != nil { |
| t.Fatal(err) |
| } |
| - records, err := hostops.NewConditionalRecordStore(raw, fx.pub) |
| + records, err := hostops.NewConditionalRecordStore(raw, fx.pub, cfg.dbClaimHomeRegion()) |
| if err != nil { |
| t.Fatal(err) |
| } |
diff --git a/internal/console/claim_runtime.go b/internal/console/claim_runtime.go| index 570ae1d52..186476dfd 100644 |
| --- a/internal/console/claim_runtime.go |
| +++ b/internal/console/claim_runtime.go |
| @@ -31,7 +31,7 @@ func (s *Server) ConfigureClaimRuntime(ctx context.Context) error { |
| return hostops.ErrContract |
| } |
| raw := s.cfg.Releases |
| - control, err := hostops.NewControlWriter(raw, s.cfg.ReleasePub, time.Now) |
| + control, err := hostops.NewControlWriter(raw, s.cfg.ReleasePub, time.Now, s.cfg.dbClaimHomeRegion()) |
| if err != nil { |
| return err |
| } |
| @@ -39,7 +39,7 @@ func (s *Server) ConfigureClaimRuntime(ctx context.Context) error { |
| if err != nil { |
| return err |
| } |
| - records, err := hostops.NewConditionalRecordStore(raw, s.cfg.ReleasePub) |
| + records, err := hostops.NewConditionalRecordStore(raw, s.cfg.ReleasePub, s.cfg.dbClaimHomeRegion()) |
| if err != nil { |
| return err |
| } |
diff --git a/internal/console/testdata/claim-authority-sites.txt b/internal/console/testdata/claim-authority-sites.txt| index cf7a27c10..9e1fbf855 100644 |
| --- a/internal/console/testdata/claim-authority-sites.txt |
| +++ b/internal/console/testdata/claim-authority-sites.txt |
| @@ -92,8 +92,8 @@ claim_retire.go|retireClaimHostRecord|c.Control.ResumeExclusive(ctx, ref.Account |
| claim_route_writes.go|appMutation|release.WithAppMutation(ctx, s.cfg.Releases, acct.Sqid, app, action, fn) |
| claim_runtime.go|ConfigureClaimRuntime|a.Control.ReportAutomaticLosses(ctx, a.Signer, host) |
| claim_runtime.go|ConfigureClaimRuntime|hostops.BindMutationRunner(data, control, owner, s.mutationAppID) |
| -claim_runtime.go|ConfigureClaimRuntime|hostops.NewConditionalRecordStore(raw, s.cfg.ReleasePub) |
| -claim_runtime.go|ConfigureClaimRuntime|hostops.NewControlWriter(raw, s.cfg.ReleasePub, time.Now) |
| +claim_runtime.go|ConfigureClaimRuntime|hostops.NewConditionalRecordStore(raw, s.cfg.ReleasePub, s.cfg.dbClaimHomeRegion()) |
| +claim_runtime.go|ConfigureClaimRuntime|hostops.NewControlWriter(raw, s.cfg.ReleasePub, time.Now, s.cfg.dbClaimHomeRegion()) |
| claim_runtime.go|ConfigureClaimRuntime|hostops.NewObservedMutationBoundary(true, records, check, s.claimMutationObserver) |
| claim_runtime.go|ConfigureClaimRuntime|hostops.NewProductionMutationCheck(raw, control) |
| claim_runtime.go|ConfigureClaimRuntime|hostops.NewVersionedBoundary(true, s.cfg.DBVersions, raw, control, s.cfg.Bucket, dataBuckets, s.claimMutationObserver) |
diff --git a/internal/host/claimproduction.go b/internal/host/claimproduction.go| index c5958ae80..eb0bb6850 100644 |
| --- a/internal/host/claimproduction.go |
| +++ b/internal/host/claimproduction.go |
| @@ -5,6 +5,7 @@ import ( |
| "crypto/ecdh" |
| "crypto/ed25519" |
| "errors" |
| + "log" |
| "os" |
| "path/filepath" |
| "runtime" |
| @@ -40,6 +41,8 @@ type ClaimProductionMoveSources interface { |
| Close(context.Context) error |
| } |
| +var claimMissingHomeLog sync.Once |
| + |
| var claimMoveSources struct { |
| sync.Mutex |
| Source ClaimProductionMoveSources |
| @@ -123,6 +126,11 @@ func (ProductionClaimOwnerProvider) OpenClaimOwners(ctx context.Context, store r |
| if err != nil { |
| return out, err |
| } |
| + if rr.home == "" { |
| + claimMissingHomeLog.Do(func() { |
| + log.Printf("host claim: signed roster has no home region; running with region %q and no home alias", row.Region) |
| + }) |
| + } |
| 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" { |
diff --git a/internal/host/claimregion.go b/internal/host/claimregion.go| index 673c391cb..e22ad6c2c 100644 |
| --- a/internal/host/claimregion.go |
| +++ b/internal/host/claimregion.go |
| @@ -13,8 +13,8 @@ func claimRegionBinding(roster, code, fleetHome string) (engine, home string, er |
| if roster == "" || engine == "" || placement == "" || (code != "" && engine != placement) { |
| return "", "", hostbox.ErrChanged |
| } |
| - if code == "" { |
| - if fleetHome == "" || roster != fleetHome { |
| + if code == "" && fleetHome != "" { |
| + if roster != fleetHome { |
| return "", "", hostbox.ErrChanged |
| } |
| home = engine |
diff --git a/internal/host/claimwakestop.go b/internal/host/claimwakestop.go| index 6f4aaf5b3..a66b71cd0 100644 |
| --- a/internal/host/claimwakestop.go |
| +++ b/internal/host/claimwakestop.go |
| @@ -13,6 +13,8 @@ import ( |
| "github.com/carlosframework/platform/internal/hostclaim" |
| "github.com/carlosframework/platform/internal/hostguard" |
| "github.com/carlosframework/platform/internal/hostwrite" |
| + "github.com/carlosframework/platform/internal/park" |
| + "github.com/carlosframework/platform/internal/regionname" |
| "github.com/carlosframework/platform/internal/registry" |
| ) |
| @@ -280,13 +282,17 @@ func (a ClaimAssembly) collectCapturedStop(ctx context.Context, captured hostcla |
| } |
| actor := local.Actor |
| claim, holder := observed.Claim, observed.Claim.Holder |
| + home, err := park.VerifiedHome(actor.FleetHomeRegion, actor.HomeRegion, actor.Region, claim.Op.Intent["home_region"]) |
| + if err != nil { |
| + return StopResult{}, hostbox.ErrChanged |
| + } |
| obligation := "" |
| if id, ok := a.operationWriterStop(claim, actor); (writerSession || zero) && ok { |
| obligation = id |
| } else if claim.Op.Kind != hostclaim.KindServing { |
| for _, ob := range claim.Obligations { |
| park := ob.Kind == "park-local" && ob.Owner == "box:"+actor.Box |
| - release := ob.Kind == "relocation-release" && ob.ID == "relocation-release" && ob.Park == nil && ob.Region == actor.Region && ob.Owner == "region:"+actor.Region |
| + release := ob.Kind == "relocation-release" && ob.ID == "relocation-release" && ob.Park == nil && regionname.SameRegion(home, ob.Region, actor.Region) && strings.HasPrefix(ob.Owner, "region:") && regionname.SameRegion(home, strings.TrimPrefix(ob.Owner, "region:"), actor.Region) |
| if (park || release) && ob.Box == actor.Box && ob.Op == claim.Op.ID { |
| if obligation != "" { |
| return StopResult{}, hostbox.ErrChanged |
diff --git a/internal/host/plan13_region_test.go b/internal/host/plan13_region_test.go| index 2c10da20d..26db34b14 100644 |
| --- a/internal/host/plan13_region_test.go |
| +++ b/internal/host/plan13_region_test.go |
| @@ -76,3 +76,23 @@ func TestPlan13NamedBoxCannotAcknowledgeHomeDelete(t *testing.T) { |
| } |
| } |
| } |
| + |
| +func TestPlan13MissingDesignationRunsNamedOnly(t *testing.T) { |
| + for _, name := range []string{"ie", "ca"} { |
| + for _, code := range []string{"", name} { |
| + engine, home, err := claimRegionBinding(name, code, "") |
| + if err != nil || engine != name || home != "" { |
| + t.Fatalf("%q/%q: %q %q %v", name, code, engine, home, err) |
| + } |
| + p := park.Engine{Region: engine, HomeRegion: home} |
| + if !p.OwnsRegion(name) || p.OwnsRegion(park.HomeRegion) { |
| + t.Fatal("degraded ownership", p) |
| + } |
| + identity := handoverRecoveryIdentity(t, hostbox.Actor{Box: "box", Enrollment: "enrolled", Region: name, Revision: 1}) |
| + identity.HomeRegion = home |
| + if _, err := identity.Verify(t.Context()); err != nil { |
| + t.Fatal("named runtime identity", err) |
| + } |
| + } |
| + } |
| +} |
diff --git a/internal/hostactors/plan13_home_test.go b/internal/hostactors/plan13_home_test.go| index 01509e147..3644e2a64 100644 |
| --- a/internal/hostactors/plan13_home_test.go |
| +++ b/internal/hostactors/plan13_home_test.go |
| @@ -35,3 +35,57 @@ func TestPlan13HomeDesignationIsSignedAndOptional(t *testing.T) { |
| t.Fatal("engine token accepted as designation") |
| } |
| } |
| + |
| +func TestPlan13HomeDesignationWriteOnce(t *testing.T) { |
| + t.Setenv("CARLOS_HOST_CLAIM", "1") |
| + for _, home := range []string{"ie", "uk", ""} { |
| + t.Run(home, func(t *testing.T) { |
| + d, sg, o := fixture(t) |
| + next := Clone(o.Roster) |
| + next.HomeRegion = "ie" |
| + o, err := d.Replace(t.Context(), sg, o, next) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + next = Clone(o.Roster) |
| + next.HomeRegion = home |
| + _, err = d.Replace(t.Context(), sg, o, next) |
| + if (err == nil) != (home == "ie") { |
| + t.Fatalf("change to %q: %v", home, err) |
| + } |
| + current, err := d.Read(t.Context()) |
| + if err != nil || current.Roster.HomeRegion != "ie" { |
| + t.Fatalf("designation changed: %+v %v", current, err) |
| + } |
| + if home != "ie" && current.ETag != o.ETag { |
| + t.Fatal("refusal wrote roster") |
| + } |
| + }) |
| + } |
| +} |
| + |
| +// With host claims off the roster keeps main's behaviour: the designation |
| +// can be changed or cleared like any other field. |
| +func TestPlan13HomeDesignationFlagOffIsUnpinned(t *testing.T) { |
| + t.Setenv("CARLOS_HOST_CLAIM", "") |
| + for _, home := range []string{"uk", ""} { |
| + t.Run(home, func(t *testing.T) { |
| + d, sg, o := fixture(t) |
| + next := Clone(o.Roster) |
| + next.HomeRegion = "ie" |
| + o, err := d.Replace(t.Context(), sg, o, next) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + next = Clone(o.Roster) |
| + next.HomeRegion = home |
| + if _, err := d.Replace(t.Context(), sg, o, next); err != nil { |
| + t.Fatalf("flag-off change to %q refused: %v", home, err) |
| + } |
| + current, err := d.Read(t.Context()) |
| + if err != nil || current.Roster.HomeRegion != home { |
| + t.Fatalf("flag-off designation = %+v %v, want %q", current.Roster.HomeRegion, err, home) |
| + } |
| + }) |
| + } |
| +} |
diff --git a/internal/hostactors/roster.go b/internal/hostactors/roster.go| index 478054861..3d6ff913c 100644 |
| --- a/internal/hostactors/roster.go |
| +++ b/internal/hostactors/roster.go |
| @@ -193,14 +193,19 @@ func (d Directory) Lookup(ctx context.Context, box string) (hostquarantine.Actor |
| return hostquarantine.Actor{Enrollment: b.Enrollment, State: b.State, QuarantineRevision: b.QuarantineRevision}, nil |
| } |
| func (d Directory) Replace(ctx context.Context, sg release.Signer, before Observed, next Roster) (Observed, error) { |
| - if before.ETag == "" || before.Roster.Revision == math.MaxUint64 { |
| + // The home designation is write-once only with host claims on: claim |
| + // operations bind it, so changing it mid-flight would re-point their |
| + // alias. With claims off the roster behaves exactly as it always did. |
| + claimsOn := strings.TrimSpace(os.Getenv("CARLOS_HOST_CLAIM")) != "" |
| + if before.ETag == "" || before.Roster.Revision == math.MaxUint64 || |
| + claimsOn && before.Roster.HomeRegion != "" && next.HomeRegion != before.Roster.HomeRegion { |
| return Observed{}, ErrRoster |
| } |
| // Assign new enrollment times inside the signed roster CAS. Older entries |
| // keep an unknown time; replacing it with today's time would fabricate evidence. |
| next = Clone(next) |
| var now time.Time |
| - if strings.TrimSpace(os.Getenv("CARLOS_HOST_CLAIM")) != "" { |
| + if claimsOn { |
| now = time.Now().UTC() |
| } |
| for ref, b := range next.Boxes { |
diff --git a/internal/hostclaim/move.go b/internal/hostclaim/move.go| index aed39076a..c8b86380b 100644 |
| --- a/internal/hostclaim/move.go |
| +++ b/internal/hostclaim/move.go |
| @@ -227,7 +227,7 @@ func Abort(opID, targetRegion string, scope ParkScope, newID func() string) func |
| } |
| if c.Op.Phase == PhaseAborting { |
| for _, o := range c.Obligations { |
| - if o.Op == opID && o.Kind == "park-replica" && o.Region == targetRegion && o.Park != nil && isStagingLocation(o.Park.Location) && !sameParkScope(*o.Park, scope) { |
| + if o.Op == opID && o.Kind == "park-replica" && regionname.SameRegion(c.Op.Intent["home_region"], o.Region, targetRegion) && o.Park != nil && isStagingLocation(o.Park.Location) && !sameParkScope(*o.Park, scope) { |
| return fmt.Errorf("%w: %s already owes its lane park in %s with another scope", ErrWrongState, opID, targetRegion) |
| } |
| } |
| @@ -406,7 +406,7 @@ func Revert(opID, sourceBox, targetRegion string, stagingScope *ParkScope, newID |
| c.Op.Journal[JournalEvidenceSource] != "" && c.Op.Journal[JournalRevPhase] == RevPhaseEvidence && |
| targetRegion != "" && c.Op.Intent["to"] == targetRegion && stagingScope != nil { |
| for _, o := range c.Obligations { |
| - if o.Op == opID && o.Kind == "park-replica" && o.Region == targetRegion && o.Park != nil && sameParkScope(*o.Park, *stagingScope) { |
| + if o.Op == opID && o.Kind == "park-replica" && regionname.SameRegion(c.Op.Intent["home_region"], o.Region, targetRegion) && o.Park != nil && sameParkScope(*o.Park, *stagingScope) { |
| return nil |
| } |
| } |
| @@ -438,7 +438,7 @@ func Revert(opID, sourceBox, targetRegion string, stagingScope *ParkScope, newID |
| setJournal(c, JournalRevPhase, RevPhaseEvidence) |
| } |
| for _, o := range c.Obligations { |
| - if o.Op == opID && o.Kind == "park-replica" && o.Region == targetRegion { |
| + if o.Op == opID && o.Kind == "park-replica" && regionname.SameRegion(c.Op.Intent["home_region"], o.Region, targetRegion) { |
| return nil |
| } |
| } |
| @@ -508,7 +508,7 @@ func isStagingLocation(location string) bool { return strings.HasPrefix(location |
| // reports whether it appended. |
| func queueReplicaPark(c *Claim, opID, region string, scope ParkScope, newID func() string) (bool, error) { |
| for _, o := range c.Obligations { |
| - if o.Op == opID && o.Kind == "park-replica" && o.Region == region && o.Park != nil && isStagingLocation(o.Park.Location) == isStagingLocation(scope.Location) { |
| + if o.Op == opID && o.Kind == "park-replica" && regionname.SameRegion(c.Op.Intent["home_region"], o.Region, region) && o.Park != nil && isStagingLocation(o.Park.Location) == isStagingLocation(scope.Location) { |
| if !sameParkScope(*o.Park, scope) { |
| return false, fmt.Errorf("%w: %s already owes a park in %s with another scope", ErrWrongState, opID, region) |
| } |
| @@ -747,7 +747,7 @@ func QueueResidueParks(opID, region string, location ParkScope, generations []st |
| } |
| queued := map[string]bool{} |
| for _, o := range c.Obligations { |
| - if o.Op == opID && o.Kind == "park-generation" && o.Region == region { |
| + if o.Op == opID && o.Kind == "park-generation" && regionname.SameRegion(c.Op.Intent["home_region"], o.Region, region) { |
| if o.Park == nil || !sameParkScope(*o.Park, location) { |
| for _, g := range generations { |
| if g == o.Generation { |
| @@ -1138,7 +1138,7 @@ func QueueReturnLanePark(opID, sourceRegion string, scope ParkScope, newID func( |
| } |
| owed := false |
| for _, o := range c.Obligations { |
| - if o.Op == opID && o.Kind == "park-replica" && o.Region == sourceRegion && o.Park != nil && isStagingLocation(o.Park.Location) { |
| + if o.Op == opID && o.Kind == "park-replica" && regionname.SameRegion(c.Op.Intent["home_region"], o.Region, sourceRegion) && o.Park != nil && isStagingLocation(o.Park.Location) { |
| owed = true |
| } |
| } |
diff --git a/internal/hostclaim/move_loss.go b/internal/hostclaim/move_loss.go| index 7a59749d7..3b4988ae4 100644 |
| --- a/internal/hostclaim/move_loss.go |
| +++ b/internal/hostclaim/move_loss.go |
| @@ -3,6 +3,8 @@ package hostclaim |
| import ( |
| "fmt" |
| "strconv" |
| + |
| + "github.com/carlosframework/platform/internal/regionname" |
| ) |
| // journalLostTargetReleased marks the console's release of a declared-lost |
| @@ -77,7 +79,7 @@ func ReleaseLostTarget(r LostTargetRelease) func(*Claim) error { |
| // release of the same loss. |
| func owesStagingPark(c *Claim, opID, region string) bool { |
| for _, o := range c.Obligations { |
| - if o.Op == opID && o.Kind == "park-replica" && o.Region == region && o.Park != nil && isStagingLocation(o.Park.Location) { |
| + if o.Op == opID && o.Kind == "park-replica" && regionname.SameRegion(c.Op.Intent["home_region"], o.Region, region) && o.Park != nil && isStagingLocation(o.Park.Location) { |
| return true |
| } |
| } |
diff --git a/internal/hostclaim/plan13_followups_test.go b/internal/hostclaim/plan13_followups_test.go| new file mode 100644 |
| index 000000000..4fc254651 |
| --- /dev/null |
| +++ b/internal/hostclaim/plan13_followups_test.go |
| @@ -0,0 +1,137 @@ |
| +package hostclaim |
| + |
| +import ( |
| + "errors" |
| + "fmt" |
| + "strings" |
| + "testing" |
| + "time" |
| +) |
| + |
| +func TestPlan13ResidueAliasQueue(t *testing.T) { |
| + for _, home := range []string{"ie", "", "uk"} { |
| + for _, different := range []bool{false, true} { |
| + t.Run(home+"/"+map[bool]string{false: "same", true: "different"}[different], func(t *testing.T) { |
| + c, scope := parkFixture(t) |
| + c.Op.Intent["home_region"] = home |
| + inState(c, KindLanding, PhaseProbing) |
| + id := plan13IDs() |
| + first := scope(c.Holder.Location) |
| + if err := QueueResidueParks(moveA, "ie", first, []string{"g1"}, id)(c); err != nil { |
| + t.Fatal(err) |
| + } |
| + second := first |
| + if different { |
| + second.EvidenceAt = second.EvidenceAt.Add(time.Second) |
| + } |
| + before := string(mustJSON(t, c)) |
| + err := QueueResidueParks(moveA, "@home", second, []string{"g1"}, id)(c) |
| + if home == "ie" { |
| + if different && (!errors.Is(err, ErrWrongState) || !strings.Contains(err.Error(), "another scope")) { |
| + t.Fatalf("scope conflict: %v", err) |
| + } |
| + if !different && err != nil { |
| + t.Fatal(err) |
| + } |
| + if string(mustJSON(t, c)) != before { |
| + t.Fatal("alias replay changed claim") |
| + } |
| + } else if err != nil || len(c.Obligations) != 2 { |
| + t.Fatalf("unbound region: %v %+v", err, c.Obligations) |
| + } |
| + }) |
| + } |
| + } |
| +} |
| + |
| +func TestPlan13ReplicaAliasQueue(t *testing.T) { |
| + for _, home := range []string{"ie", "", "uk"} { |
| + c, scope := parkFixture(t) |
| + c.Op.Intent["home_region"] = home |
| + s := scope("moves/" + c.Host + "/" + moveA) |
| + id := plan13IDs() |
| + if _, err := queueReplicaPark(c, moveA, "ie", s, id); err != nil { |
| + t.Fatal(err) |
| + } |
| + if got := owesStagingPark(c, moveA, "@home"); got != (home == "ie") { |
| + t.Fatalf("home %q: staging owed %v", home, got) |
| + } |
| + if _, err := queueReplicaPark(c, moveA, "@home", s, id); err != nil { |
| + t.Fatal(err) |
| + } |
| + want := 2 |
| + if home == "ie" { |
| + want = 1 |
| + } |
| + if len(c.Obligations) != want { |
| + t.Fatalf("home %q: %+v", home, c.Obligations) |
| + } |
| + } |
| +} |
| + |
| +func plan13IDs() func() string { |
| + n := 0 |
| + return func() string { n++; return fmt.Sprintf("chore%d", n) } |
| +} |
| + |
| +func TestPlan13AbortAndRevertRetainHomeChore(t *testing.T) { |
| + for _, action := range []string{"abort", "revert", "revert-replay"} { |
| + t.Run(action, func(t *testing.T) { |
| + c, scope := parkFixture(t) |
| + c.Op.Intent["home_region"], c.Op.Intent["to"] = "ie", "@home" |
| + s := scope("moves/" + c.Host + "/" + moveA) |
| + id := plan13IDs() |
| + if _, err := queueReplicaPark(c, moveA, "ie", s, id); err != nil { |
| + t.Fatal(err) |
| + } |
| + if action == "abort" { |
| + c.Op.Phase = PhaseAborting |
| + changed := s |
| + changed.EvidenceAt = changed.EvidenceAt.Add(time.Second) |
| + if err := Abort(moveA, "@home", changed, id)(c); !errors.Is(err, ErrWrongState) { |
| + t.Fatalf("abort accepted conflicting alias scope: %v", err) |
| + } |
| + if err := Abort(moveA, "@home", s, id)(c); err != nil { |
| + t.Fatal(err) |
| + } |
| + } else { |
| + c.Op.Exec = "" |
| + setJournal(c, JournalEvidenceSource, "evidence") |
| + if action == "revert-replay" { |
| + c.Op.Kind, c.Op.Exec = KindReverting, "ie-box" |
| + setJournal(c, JournalRevPhase, RevPhaseEvidence) |
| + } |
| + if err := Revert(moveA, "ie-box", "@home", &s, id)(c); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + if len(c.Obligations) != 1 || c.Obligations[0].Region != "ie" { |
| + t.Fatalf("rewrote or duplicated chore: %+v", c.Obligations) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +func TestPlan13ReturnLaneAliasScope(t *testing.T) { |
| + c, scope := parkFixture(t) |
| + c.Op.Intent["home_region"] = "ie" |
| + c.Op.Intent["rollback"] = "rb_abcdefghijklmnopqrstuvwxyz" |
| + inState(c, KindRestoring, PhaseProbed) |
| + s := scope("moves/" + c.Host + "/" + c.Op.Intent["rollback"]) |
| + id := plan13IDs() |
| + if _, err := queueReplicaPark(c, moveA, "ie", s, id); err != nil { |
| + t.Fatal(err) |
| + } |
| + setJournal(c, journalReturnLane, s.Location) |
| + changed := s |
| + changed.EvidenceAt = changed.EvidenceAt.Add(time.Second) |
| + if err := QueueReturnLanePark(moveA, "@home", changed, id)(c); !errors.Is(err, ErrWrongState) { |
| + t.Fatalf("journal masked conflicting alias chore: %v", err) |
| + } |
| + if err := QueueReturnLanePark(moveA, "@home", s, id)(c); err != nil { |
| + t.Fatal(err) |
| + } |
| + if len(c.Obligations) != 1 || c.Obligations[0].Region != "ie" { |
| + t.Fatal(c.Obligations) |
| + } |
| +} |
diff --git a/internal/hostops/console_move_grant.go b/internal/hostops/console_move_grant.go| index 16fd95009..dbfb9a128 100644 |
| --- a/internal/hostops/console_move_grant.go |
| +++ b/internal/hostops/console_move_grant.go |
| @@ -126,6 +126,9 @@ func (w *ControlWriter) consoleMoveClaim(ctx context.Context, o ConsoleMoveObser |
| return hostclaim.Observed{}, ErrIdentity |
| } |
| c, h := obs.Claim, obs.Claim.Holder |
| + if !operationHomeMatches(w.homeRegion, c) { |
| + return hostclaim.Observed{}, ErrIdentity |
| + } |
| if h.Family != "instance" || h.Key != provision.RecordKey(o.Account, o.App, o.Host) || h.Account != o.Account || h.App != o.App || h.Incarnation != o.Incarnation || c.Op.ID != o.OpID || |
| c.Op.Intent["admission_id"] != o.OpID || c.Op.Intent["app_id"] != o.AppID { |
| return hostclaim.Observed{}, ErrIdentity |
diff --git a/internal/hostops/control_writer.go b/internal/hostops/control_writer.go| index cbf98d939..0e7626810 100644 |
| --- a/internal/hostops/control_writer.go |
| +++ b/internal/hostops/control_writer.go |
| @@ -51,18 +51,23 @@ func IsControlOnlyKey(key string) bool { |
| // no mutate callback. Every exported method is one typed transition that |
| // validates its own precondition against a fresh read. |
| type ControlWriter struct { |
| - raw release.Store |
| - pub ed25519.PublicKey |
| - now func() time.Time |
| + homeRegion string |
| + raw release.Store |
| + pub ed25519.PublicKey |
| + now func() time.Time |
| } |
| // NewControlWriter is called once, by ConfigureClaimRuntime. A source census |
| // (TestClaimConsoleMutationInvariant step 5) rejects any other caller. |
| -func NewControlWriter(raw release.Store, pub ed25519.PublicKey, now func() time.Time) (*ControlWriter, error) { |
| +func NewControlWriter(raw release.Store, pub ed25519.PublicKey, now func() time.Time, homeRegion ...string) (*ControlWriter, error) { |
| if raw == nil || len(pub) != ed25519.PublicKeySize || now == nil { |
| return nil, ErrContract |
| } |
| - return &ControlWriter{raw: raw, pub: pub, now: now}, nil |
| + home, err := consoleHomeRegion(homeRegion) |
| + if err != nil { |
| + return nil, err |
| + } |
| + return &ControlWriter{raw: raw, pub: pub, now: now, homeRegion: home}, nil |
| } |
| func (w *ControlWriter) ready() error { |
diff --git a/internal/hostops/lifecycle_grant.go b/internal/hostops/lifecycle_grant.go| index 226a198a0..fb94ded30 100644 |
| --- a/internal/hostops/lifecycle_grant.go |
| +++ b/internal/hostops/lifecycle_grant.go |
| @@ -173,7 +173,7 @@ func authorizeClaimWrite(ctx context.Context, raw release.Store, host string, t |
| return ErrIdentity |
| } |
| for _, g := range grants { |
| - if g.w == nil { |
| + if g.w == nil || !operationHomeMatches(g.w.homeRegion, old) || !operationHomeMatches(g.w.homeRegion, next) { |
| continue |
| } |
| switch g.kind { |
diff --git a/internal/hostops/lifecycle_record.go b/internal/hostops/lifecycle_record.go| index d8be7fa65..a17a962ca 100644 |
| --- a/internal/hostops/lifecycle_record.go |
| +++ b/internal/hostops/lifecycle_record.go |
| @@ -30,6 +30,9 @@ func (s conditionalRecordStore) putLifecycle(ctx context.Context, key string, bo |
| if o.Absent || o.Claim == nil || o.Claim.Holder == nil || o.ETag == "" || etag == "" || key != o.Claim.Holder.Key { |
| return "", ErrIdentity |
| } |
| + if !operationHomeMatches(s.homeRegion, o.Claim) { |
| + return "", ErrIdentity |
| + } |
| switch o.Claim.Op.Kind { |
| case hostclaim.KindMoving, hostclaim.KindLanding, hostclaim.KindRestoring, hostclaim.KindReverting, hostclaim.KindRelocating: |
| default: |
diff --git a/internal/hostops/lifecycle_record_test.go b/internal/hostops/lifecycle_record_test.go| index 6b7c3fcb8..0cdfae7eb 100644 |
| --- a/internal/hostops/lifecycle_record_test.go |
| +++ b/internal/hostops/lifecycle_record_test.go |
| @@ -13,7 +13,7 @@ import ( |
| ) |
| func TestLifecycleRecordProductionWrapper(t *testing.T) { |
| - for _, mode := range []string{"moving", "restoring", "flip", "wrong-op", "wrong-region", "absent", "stale-claim", "metadata", "wrong-kind", "admission-not-op", "unarchived", "restore-region", "restore-stale-source-region", "restore-elsewhere", "restore-no-journal", "restore-committed"} { |
| + for _, mode := range []string{"moving", "restoring", "flip", "foreign-home", "matched-home", "changed-home", "missing-home", "wrong-op", "wrong-region", "absent", "stale-claim", "metadata", "wrong-kind", "admission-not-op", "unarchived", "restore-region", "restore-stale-source-region", "restore-elsewhere", "restore-no-journal", "restore-committed"} { |
| t.Run(mode, func(t *testing.T) { |
| ctx := context.Background() |
| raw := release.NewDirStore(t.TempDir()) |
| @@ -29,6 +29,8 @@ func TestLifecycleRecordProductionWrapper(t *testing.T) { |
| // returns to the record's original Move.From. |
| regID := "move" |
| switch mode { |
| + case "foreign-home", "matched-home", "changed-home": |
| + claim.Op.Intent["home_region"] = "ie" |
| case "admission-not-op": |
| // A move registration under another id is drainable. |
| regID = "registration" |
| @@ -117,9 +119,17 @@ func TestLifecycleRecordProductionWrapper(t *testing.T) { |
| } |
| body, _ = json.Marshal(rec) |
| wrapped := conditionalRecordStore{raw: raw} |
| + if mode == "matched-home" || mode == "missing-home" { |
| + wrapped.homeRegion = "ie" |
| + } |
| + if mode == "changed-home" { |
| + wrapped.homeRegion = "uk" |
| + } |
| permitted := context.WithValue(ctx, lifecycleRecordKey{}, lifecycleRecordPermit{o}) |
| _, err = wrapped.Put(permitted, key, body, tag) |
| - allowed := mode == "moving" || mode == "restoring" || mode == "flip" || mode == "restore-region" || mode == "restore-stale-source-region" |
| + // missing-home is an operation that started before any designation was |
| + // published: it stays alias-free and is not stranded by a later one. |
| + allowed := mode == "matched-home" || mode == "missing-home" || mode == "moving" || mode == "restoring" || mode == "flip" || mode == "restore-region" || mode == "restore-stale-source-region" |
| if (err == nil) != allowed { |
| t.Fatalf("allowed=%v err=%v", allowed, err) |
| } |
diff --git a/internal/hostops/move_home.go b/internal/hostops/move_home.go| new file mode 100644 |
| index 000000000..d0de104b4 |
| --- /dev/null |
| +++ b/internal/hostops/move_home.go |
| @@ -0,0 +1,31 @@ |
| +package hostops |
| + |
| +import ( |
| + "github.com/carlosframework/platform/internal/hostclaim" |
| + "github.com/carlosframework/platform/internal/regionname" |
| +) |
| + |
| +func consoleHomeRegion(names []string) (string, error) { |
| + if len(names) > 1 { |
| + return "", ErrContract |
| + } |
| + home := "" |
| + if len(names) == 1 { |
| + home = names[0] |
| + } |
| + if home != "" && regionname.PlacementRegion(home) != home { |
| + return "", ErrContract |
| + } |
| + return home, nil |
| +} |
| + |
| +// Check the configured designation before interpreting an operation's alias. |
| +// An operation that recorded no home name started alias-free (before any |
| +// designation was published) and stays alias-free, so a later first |
| +// publication does not strand it: its comparisons stay exact. |
| +func operationHomeMatches(home string, c *hostclaim.Claim) bool { |
| + if c == nil || c.Op.Intent["home_region"] == "" { |
| + return true |
| + } |
| + return c.Op.Intent["home_region"] == home |
| +} |
diff --git a/internal/hostops/move_loss_grant.go b/internal/hostops/move_loss_grant.go| index 4b7eaee01..643891ff8 100644 |
| --- a/internal/hostops/move_loss_grant.go |
| +++ b/internal/hostops/move_loss_grant.go |
| @@ -72,6 +72,9 @@ func (w *ControlWriter) moveLossClaim(ctx context.Context, account, app, host st |
| return hostclaim.Observed{}, hostresolve.ErrResolve |
| } |
| h := o.Claim.Holder |
| + if !operationHomeMatches(w.homeRegion, o.Claim) { |
| + return hostclaim.Observed{}, ErrIdentity |
| + } |
| if h.Family != "instance" || h.Account != account || h.App != app || h.Key != provision.RecordKey(account, app, host) { |
| return hostclaim.Observed{}, ErrIdentity |
| } |
| @@ -135,7 +138,7 @@ func (w *ControlWriter) DeclareMoveLoss(ctx context.Context, signer release.Sign |
| return hostclaim.Observed{}, hostresolve.ErrResolve |
| } |
| b := lossBinding{bucket: bucket, lossID: c.ID, host: host, opID: c.Op.ID, kind: c.Op.Kind, box: c.Box, claimETag: c.ClaimETag, incarnation: c.HolderIncarnation, account: account, app: app} |
| - scoped := &lossScopedStore{raw: w.raw, pub: w.pub, now: w.now, b: b} |
| + scoped := &lossScopedStore{raw: w.raw, pub: w.pub, now: w.now, b: b, homeRegion: w.homeRegion} |
| if _, err := moveLossService(scoped, w.pub, signer, bucket, w.now).Declare(ctx, actor, host, in); err != nil { |
| return hostclaim.Observed{}, err |
| } |
| @@ -162,10 +165,11 @@ func (readOnlyStore) Delete(context.Context, string) error { return errMoveLossS |
| // lossScopedStore is one declaration's private store (see above). |
| type lossScopedStore struct { |
| - raw release.Store |
| - pub ed25519.PublicKey |
| - now func() time.Time |
| - b lossBinding |
| + homeRegion string |
| + raw release.Store |
| + pub ed25519.PublicKey |
| + now func() time.Time |
| + b lossBinding |
| } |
| func (s *lossScopedStore) Get(ctx context.Context, key string) ([]byte, string, error) { |
| @@ -229,6 +233,9 @@ func (s *lossScopedStore) registered(ctx context.Context) (hostclaim.Observed, e |
| return hostclaim.Observed{}, ErrIdentity |
| } |
| h := o.Claim.Holder |
| + if !operationHomeMatches(s.homeRegion, o.Claim) { |
| + return hostclaim.Observed{}, ErrIdentity |
| + } |
| if h.Account != s.b.account || h.App != s.b.app || h.Incarnation != s.b.incarnation || o.Claim.Op.ID != s.b.opID { |
| return hostclaim.Observed{}, ErrIdentity |
| } |
diff --git a/internal/hostops/move_recovery_bridge.go b/internal/hostops/move_recovery_bridge.go| index 42e0b6ded..3dbe10fd2 100644 |
| --- a/internal/hostops/move_recovery_bridge.go |
| +++ b/internal/hostops/move_recovery_bridge.go |
| @@ -64,8 +64,9 @@ type oneBoxRegionAuthority interface { |
| } |
| type movingRecordDecision struct { |
| - placement oneBoxRegionAuthority |
| - store release.Store |
| + homeRegion string |
| + placement oneBoxRegionAuthority |
| + store release.Store |
| } |
| var _ hostclaim.PreFlipGuard = movingRecordDecision{} |
| @@ -83,6 +84,9 @@ func (g movingRecordDecision) CheckMovingBeforeFlip(ctx context.Context, o hostc |
| return "", ErrIdentity |
| } |
| c, h := o.Claim, o.Claim.Holder |
| + if !operationHomeMatches(g.homeRegion, c) { |
| + return "", ErrIdentity |
| + } |
| if err := moveAdmission(ctx, g.store, o.Host, c); err != nil { |
| return "", err |
| } |
| @@ -226,7 +230,10 @@ func RecoverTerminatedMovingHost(ctx context.Context, s release.Store, mh *Start |
| if !ok { |
| return ErrContract |
| } |
| - guard := movingRecordDecision{store: s, placement: placement} |
| + if !operationHomeMatches(mh.w.homeRegion, c) { |
| + return ErrIdentity |
| + } |
| + guard := movingRecordDecision{store: s, placement: placement, homeRegion: mh.w.homeRegion} |
| if c.Op.Kind == hostclaim.KindRelocating { |
| source := c.Op.Intent["source_box"] |
| if fence.Box() != source || fence.Region() != c.Op.Intent["region"] { |
diff --git a/internal/hostops/move_recovery_bridge_test.go b/internal/hostops/move_recovery_bridge_test.go| index 7f5f9a055..4d8f27310 100644 |
| --- a/internal/hostops/move_recovery_bridge_test.go |
| +++ b/internal/hostops/move_recovery_bridge_test.go |
| @@ -143,7 +143,7 @@ func (w *c7World) claim(t *testing.T) hostclaim.Observed { |
| // hook on it), checked by the production checker with the real ControlWriter. |
| func (w *c7World) productionData(t *testing.T, under release.Store) release.Store { |
| t.Helper() |
| - records, err := NewConditionalRecordStore(under, w.pub) |
| + records, err := NewConditionalRecordStore(under, w.pub, w.control.homeRegion) |
| if err != nil { |
| t.Fatal(err) |
| } |
diff --git a/internal/hostops/move_recovery_grant.go b/internal/hostops/move_recovery_grant.go| index 74e1fa99c..f6eb9dc91 100644 |
| --- a/internal/hostops/move_recovery_grant.go |
| +++ b/internal/hostops/move_recovery_grant.go |
| @@ -71,6 +71,9 @@ func runMoveRecovery(ctx context.Context, obs hostclaim.Observed, hook func(cont |
| return ErrIdentity |
| } |
| h, c := obs.Claim.Holder, obs.Claim |
| + if !operationHomeMatches(w.homeRegion, c) { |
| + return ErrIdentity |
| + } |
| if err := moveAdmission(ctx, w.raw, obs.Host, c); err != nil { |
| return err |
| } |
| @@ -473,7 +476,7 @@ func (g *MoveRecoveryGrant) Convert(ctx context.Context, ref hostclaim.FenceRef) |
| if !ok { |
| return ErrContract |
| } |
| - guard := recoveryMovingDecision{movingRecordDecision: movingRecordDecision{store: g.w.raw, placement: placement}, observation: g.o} |
| + guard := recoveryMovingDecision{movingRecordDecision: movingRecordDecision{store: g.w.raw, placement: placement, homeRegion: g.w.homeRegion}, observation: g.o} |
| c, h := o.Claim, o.Claim.Holder |
| if c.Op.Kind == hostclaim.KindRelocating { |
| if _, err := guard.CheckMovingBeforeFlip(ctx, o, c.Op.ID, ref); err != nil { |
diff --git a/internal/hostops/plan13_conversion_test.go b/internal/hostops/plan13_conversion_test.go| index 5e3574339..558ef9663 100644 |
| --- a/internal/hostops/plan13_conversion_test.go |
| +++ b/internal/hostops/plan13_conversion_test.go |
| @@ -23,6 +23,12 @@ func TestPlan13PreFlipHomeConversion(t *testing.T) { |
| c.Op.Intent["home_region"] = "uk" |
| } |
| }) |
| + if direction == "source-home" { |
| + w.control.homeRegion = "ie" |
| + } |
| + if direction == "target-home" { |
| + w.control.homeRegion = "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) |
diff --git a/internal/hostops/plan13_fence_test.go b/internal/hostops/plan13_fence_test.go| index a29b085d8..f4cdfe8eb 100644 |
| --- a/internal/hostops/plan13_fence_test.go |
| +++ b/internal/hostops/plan13_fence_test.go |
| @@ -36,9 +36,11 @@ func TestPlan13FenceRecoveryHomeAndNamed(t *testing.T) { |
| }) |
| if which == "source-home" { |
| w.authority.home = "uk" |
| + w.control.homeRegion = "uk" |
| } |
| if which == "target-home" { |
| w.authority.home = "ie" |
| + w.control.homeRegion = "ie" |
| } |
| run := func() error { |
| return w.control.RunRecovery(t.Context(), func(ctx context.Context) error { |
diff --git a/internal/hostops/plan13_followups_test.go b/internal/hostops/plan13_followups_test.go| new file mode 100644 |
| index 000000000..083e60628 |
| --- /dev/null |
| +++ b/internal/hostops/plan13_followups_test.go |
| @@ -0,0 +1,82 @@ |
| +package hostops |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/hostclaim" |
| + "github.com/carlosframework/platform/internal/provision" |
| +) |
| + |
| +func TestPlan13RecoveryRefusesForeignHome(t *testing.T) { |
| + w := newC7World(t, func(c *hostclaim.Claim, _ *provision.Record, _ *AppAdmission) { c.Op.Intent["home_region"] = "ie" }) |
| + o, err := hostclaim.Read(t.Context(), w.raw, w.host) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + called := false |
| + err = w.control.RunRecovery(t.Context(), func(ctx context.Context) error { |
| + return runMoveRecovery(ctx, o, func(context.Context, *MoveRecoveryGrant) error { called = true; return nil }) |
| + }) |
| + if !errors.Is(err, ErrIdentity) || called { |
| + t.Fatalf("foreign recovery invoked=%v err=%v", called, err) |
| + } |
| +} |
| + |
| +func TestPlan13LossRefusesForeignHome(t *testing.T) { |
| + w := newLossWorld(t, "landing", func(c *hostclaim.Claim, _ *provision.Record, _ *AppAdmission) { c.Op.Intent["home_region"] = "ie" }) |
| + _, err := w.control.moveLossClaim(t.Context(), "bab", "app", lossHost) |
| + if !errors.Is(err, ErrIdentity) { |
| + t.Fatalf("foreign loss: %v", err) |
| + } |
| +} |
| + |
| +func TestPlan13RecoveryAndLossHomeAgreement(t *testing.T) { |
| + for _, home := range []string{"ie", "uk", ""} { |
| + t.Run(home, func(t *testing.T) { |
| + w := newC7World(t, func(c *hostclaim.Claim, _ *provision.Record, _ *AppAdmission) { c.Op.Intent["home_region"] = "ie" }) |
| + w.control.homeRegion = home |
| + called := false |
| + err := w.control.RunRecovery(t.Context(), func(ctx context.Context) error { |
| + return runMoveRecovery(ctx, w.claim(t), func(ctx context.Context, g *MoveRecoveryGrant) error { |
| + called = true |
| + _, _, _, err := g.current(ctx) |
| + return err |
| + }) |
| + }) |
| + if (err == nil) != (home == "ie") || called != (home == "ie") { |
| + t.Fatalf("recovery called=%v: %v", called, err) |
| + } |
| + l := newLossWorld(t, "landing", func(c *hostclaim.Claim, _ *provision.Record, _ *AppAdmission) { c.Op.Intent["home_region"] = "ie" }) |
| + l.control.homeRegion = home |
| + _, err = l.control.InspectMoveLoss(t.Context(), l.signer, lossBucket, "operator", "bab", "app", lossHost, "uk-label") |
| + if (err == nil) != (home == "ie") { |
| + t.Fatalf("loss: %v", err) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +// A named move that started before any home designation records no home |
| +// name. Publishing the first designation must not strand it in recovery, |
| +// lifecycle writes or declare-lost replays; one that recorded a different |
| +// home must still refuse. |
| +func TestPlan13AliasFreeOperationSurvivesFirstDesignation(t *testing.T) { |
| + for _, kind := range []string{hostclaim.KindRestoring, hostclaim.KindReverting, "landing", "moving"} { |
| + c := &hostclaim.Claim{} |
| + c.Op.Kind = kind |
| + c.Op.Intent = map[string]string{} |
| + if !operationHomeMatches("ie", c) { |
| + t.Errorf("%s: alias-free op refused after the first designation", kind) |
| + } |
| + c.Op.Intent["home_region"] = "ie" |
| + if !operationHomeMatches("ie", c) { |
| + t.Errorf("%s: matching home refused", kind) |
| + } |
| + c.Op.Intent["home_region"] = "uk" |
| + if operationHomeMatches("ie", c) { |
| + t.Errorf("%s: mismatched home accepted", kind) |
| + } |
| + } |
| +} |
diff --git a/internal/hostops/plan13_loss_test.go b/internal/hostops/plan13_loss_test.go| index de3da639b..c77daa227 100644 |
| --- a/internal/hostops/plan13_loss_test.go |
| +++ b/internal/hostops/plan13_loss_test.go |
| @@ -41,6 +41,7 @@ func TestPlan13HomeLossDeclarationKeepsPlacement(t *testing.T) { |
| r.Move.From = "" |
| c.Op.Intent["home_region"] = "ie" |
| }) |
| + w.control.homeRegion = "ie" |
| inspect := w.inspect(t, "uk-label") |
| after, err := w.declare(w.submission(inspect)) |
| if err != nil { |
diff --git a/internal/hostops/record_writes.go b/internal/hostops/record_writes.go| index bb601381f..129f48d85 100644 |
| --- a/internal/hostops/record_writes.go |
| +++ b/internal/hostops/record_writes.go |
| @@ -21,8 +21,9 @@ type effectPermit struct{ Resource string } |
| // and preserves identity. Authority (which app, which permit) is the |
| // boundary's job, not this layer's. Named private fields; no embedding. |
| type conditionalRecordStore struct { |
| - raw release.Store |
| - pub ed25519.PublicKey |
| + homeRegion string |
| + raw release.Store |
| + pub ed25519.PublicKey |
| } |
| var ( |
| @@ -35,11 +36,15 @@ var ( |
| _ release.BucketListDirs = conditionalRecordStore{} |
| ) |
| -func NewConditionalRecordStore(raw release.Store, pub ed25519.PublicKey) (release.Store, error) { |
| +func NewConditionalRecordStore(raw release.Store, pub ed25519.PublicKey, homeRegion ...string) (release.Store, error) { |
| if raw == nil || len(pub) != ed25519.PublicKeySize { |
| return nil, ErrContract |
| } |
| - return conditionalRecordStore{raw: raw, pub: pub}, nil |
| + home, err := consoleHomeRegion(homeRegion) |
| + if err != nil { |
| + return nil, err |
| + } |
| + return conditionalRecordStore{raw: raw, pub: pub, homeRegion: home}, nil |
| } |
| func provisioningKey(key string) bool { |
diff --git a/internal/provision/plan9_b8_census_test.go b/internal/provision/plan9_b8_census_test.go| index 79e6a5f5f..99f4fac58 100644 |
| --- a/internal/provision/plan9_b8_census_test.go |
| +++ b/internal/provision/plan9_b8_census_test.go |
| @@ -480,7 +480,7 @@ var plan9B8CensusInventory = []censusEntry{ |
| {"internal/host/claimretirementcensus.go", "claimLegacyIncarnation", "key-site", "lit:instances", "31a14ea105c67e00a24f06b9500a221ee5bfc47402b9c33cecfde807e51db530"}, |
| {"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", "bbb8c3aea28bcd7b3c45e5c4df3951509bb4e21158cdb67cea91a4486f3381da"}, |
| + {"internal/hostops/move_recovery_grant.go", "MoveRecoveryGrant.Convert", "record-writer", "key:WriteRecord", "eaf286018241c8d1f02ee37f5e2cac1b97eec657335519febf529ab902c0b511"}, |
| // 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). |
| @@ -488,10 +488,10 @@ var plan9B8CensusInventory = []censusEntry{ |
| {"internal/hostops/move_recovery_grant.go", "MoveRecoveryGrant.Record", "record-writer", "key:WriteRecord", "5f7e2b19846e5994e54e92592ec2b713c18d8b7c43353ad2f3a0b7c864ab3b38"}, |
| // Plan 10: reviewed typed console move and loss writers. |
| {"internal/hostops/console_move_grant.go", "ControlWriter.ConsoleMove", "record-writer", "key:WriteRecord", "21e7a6ddc4dbe5b3bf06e65b39a228e2eb588af1edc43beb09d91dd72570dd44"}, |
| - {"internal/hostops/console_move_grant.go", "ControlWriter.consoleMoveClaim", "key-site", "key:RecordKey", "265291bd356de061663d89e5ebe33b74ea32c086f8238736d6f971a5afcab722"}, |
| + {"internal/hostops/console_move_grant.go", "ControlWriter.consoleMoveClaim", "key-site", "key:RecordKey", "4b1a147d0222690151ae160aad5c983e58cca4a1beebe44bebeda16d35450c53"}, |
| {"internal/hostops/console_move_grant.go", "ObserveConsoleMove", "key-site", "key:RecordKey", "296077e790e210e9f15eabdfb30f4ba09acc46825aee27824ca333bd470664a4"}, |
| - {"internal/hostops/move_loss_grant.go", "ControlWriter.DeclareMoveLoss", "key-site", "key:LossRecordKey", "d70728d98081f746e574939d2e1757934e95b80da237a6e7a102aae409e4abec"}, |
| - {"internal/hostops/move_loss_grant.go", "ControlWriter.moveLossClaim", "key-site", "key:RecordKey", "8f7b71bc353330e7b031befa3e04f90bfb712d9cd7192595aa69c1fcd84a45d2"}, |
| + {"internal/hostops/move_loss_grant.go", "ControlWriter.DeclareMoveLoss", "key-site", "key:LossRecordKey", "b78321f1182124da71b5f03c978293f4fbff5361869565e13621b2b0e5e93579"}, |
| + {"internal/hostops/move_loss_grant.go", "ControlWriter.moveLossClaim", "key-site", "key:RecordKey", "d97870cace01fbbe59514b170de41352afa255272c1834625711e955527d647a"}, |
| {"internal/hostops/move_loss_grant.go", "claimSimulation.Put", "nonrecord", "def:Put", "08f341b2c797aa30cda0e4e56249f2479f1ad032ee086442ba543f84e601c118"}, |
| {"internal/hostops/move_loss_grant.go", "lossScopedStore.Put", "record-transport", "def:Put,key:IsRecordKey,key:LossRecordKey,key:putSequencedRecord,sel:Put", "f6f912bbc4c6c67317cfbb012ea4e87ed2fbbdababa91fb640c89c433738d3d5"}, |
| {"internal/hostops/move_loss_grant.go", "readOnlyStore.Put", "nonrecord", "def:Put", "e5f2d05226614d6a23afe06dea4fda7d45958aeac6ee5f42474872b2cbbc8b8e"}, |
| @@ -715,7 +715,7 @@ var plan9B8CensusInventory = []censusEntry{ |
| {"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/hostactors/roster.go", "Directory.Replace", "nonrecord", "sel:Put", "677bd94746cc5d3a5cd966d221f80b509ae096083a8ff4a0ee817d4c12c4a75f"}, |
| {"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"}, |
| @@ -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", "75f03db96d509d340fc3edf62a5ff60957ab75d08d6586fea6e342c5a9c3eb8d"}, |
| + {"internal/hostops/lifecycle_record.go", "conditionalRecordStore.putLifecycle", "record-writer", "key:putSequencedRecord", "b30ba2a61b2b15af36b464b2f5284923c8e137d5f46bde344f4f55474ebd90cd"}, |
| {"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"}, |