Merge origin/main into settings-4-root-applies
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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 failed — run details
CI log
=== gate ===
go: downloading github.com/benbjohnson/litestream v0.5.14
go: downloading modernc.org/sqlite v1.55.0
go: downloading github.com/aws/aws-sdk-go-v2 v1.44.0
go: downloading github.com/aws/aws-sdk-go-v2/config v1.32.6
go: downloading github.com/aws/aws-sdk-go-v2/service/route53 v1.64.1
go: downloading github.com/superfly/ltx v0.5.1
go: downloading golang.org/x/crypto v0.52.0
go: downloading github.com/aws/aws-sdk-go-v2/service/ec2 v1.324.0
go: downloading github.com/oschwald/maxminddb-golang v1.13.1
go: downloading github.com/aws/aws-sdk-go-v2/service/acm v1.45.0
go: downloading github.com/aws/aws-sdk-go-v2/service/iam v1.58.2
go: downloading github.com/aws/aws-sdk-go-v2/service/s3 v1.105.0
go: downloading github.com/aws/aws-sdk-go-v2/service/cloudwatch v1.68.0
go: downloading github.com/aws/aws-sdk-go-v2/service/resourcegroupstaggingapi v1.37.0
go: downloading github.com/aws/aws-sdk-go-v2/service/sts v1.45.6
go: downloading github.com/aws/smithy-go v1.28.1
go: downloading golang.org/x/sys v0.46.0
go: downloading github.com/aws/aws-sdk-go-v2/service/sns v1.43.0
go: downloading github.com/klauspost/compress v1.18.0
go: downloading github.com/aws/aws-sdk-go-v2/credentials v1.19.6
go: downloading golang.org/x/net v0.55.0
go: downloading github.com/pion/logging v0.2.4
go: downloading github.com/pion/stun/v3 v3.0.1
go: downloading github.com/pion/turn/v4 v4.1.4
go: downloading github.com/aws/aws-sdk-go-v2/service/sesv2 v1.66.6
go: downloading github.com/aws/aws-sdk-go-v2/service/costexplorer v1.67.5
go: downloading github.com/pierrec/lz4/v4 v4.1.22
go: downloading github.com/pion/dtls/v3 v3.0.7
go: downloading github.com/pion/transport/v3 v3.0.8
go: downloading github.com/pion/randutil v0.1.0
go: downloading github.com/pion/transport/v4 v4.0.1
go: downloading github.com/prometheus/client_golang v1.17.0
go: downloading golang.org/x/sync v0.21.0
go: downloading github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.20.18
go: downloading github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.16
go: downloading github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4
go: downloading github.com/aws/aws-sdk-go-v2/service/signin v1.0.4
go: downloading github.com/aws/aws-sdk-go-v2/service/sso v1.30.8
go: downloading github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.12
go: downloading github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.40
go: downloading github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.38
go: downloading github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.19
go: downloading github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.40
go: downloading github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.14
go: downloading github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.23
go: downloading github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.31
go: downloading github.com/lmittmann/tint v1.1.3
go: downloading github.com/mattn/go-isatty v0.0.20
go: downloading github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.40
go: downloading github.com/wlynxg/anet v0.0.5
go: downloading github.com/prometheus/common v0.45.0
go: downloading github.com/prometheus/client_model v0.5.0
go: downloading github.com/beorn7/perks v1.0.1
go: downloading github.com/cespare/xxhash/v2 v2.3.0
go: downloading github.com/prometheus/procfs v0.12.0
go: downloading google.golang.org/protobuf v1.36.10
go: downloading github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0
go: downloading golang.org/x/text v0.37.0
go: downloading modernc.org/libc v1.74.1
go: downloading modernc.org/memory v1.11.0
go: downloading modernc.org/mathutil v1.7.1
go: downloading github.com/dustin/go-humanize v1.0.1
go: downloading github.com/google/uuid v1.6.0
go: downloading github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec
cd launchctx && go vet ./... && go test ./... -count=1
ok amadan.net/carlos/platform.git/launchctx 0.013s
? amadan.net/carlos/platform.git/launchctx/controlname [no test files]
? amadan.net/carlos/platform.git/launchctx/wireid [no test files]
go vet ./...
gofmt -l .: clean
go test ./... -count=1 -timeout 30m
ok github.com/carlosframework/platform 343.564s
? github.com/carlosframework/platform/cmd/changelog [no test files]
ok github.com/carlosframework/platform/cmd/console 4.085s
ok github.com/carlosframework/platform/cmd/moveprobe 93.981s
ok github.com/carlosframework/platform/internal/activator 224.615s
ok github.com/carlosframework/platform/internal/agentsock 0.027s
ok github.com/carlosframework/platform/internal/alerting 0.012s
ok github.com/carlosframework/platform/internal/analytics 0.032s
ok github.com/carlosframework/platform/internal/appconfig 2.697s
ok github.com/carlosframework/platform/internal/apperr 0.232s
ok github.com/carlosframework/platform/internal/applog 0.353s
ok github.com/carlosframework/platform/internal/awsinv 0.008s
ok github.com/carlosframework/platform/internal/boxchoice 0.005s
ok github.com/carlosframework/platform/internal/boxid 1.443s
? 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.027s
ok github.com/carlosframework/platform/internal/caps 0.027s
ok github.com/carlosframework/platform/internal/certwarm 0.020s
ok github.com/carlosframework/platform/internal/cgroup 0.200s
ok github.com/carlosframework/platform/internal/changelog 0.505s
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 368.209s
? github.com/carlosframework/platform/internal/controlname [no test files]
? github.com/carlosframework/platform/internal/controlsock [no test files]
ok github.com/carlosframework/platform/internal/dbdoor 6.434s
ok github.com/carlosframework/platform/internal/dbdoor/dbdoortest 0.023s
ok github.com/carlosframework/platform/internal/dbverify 0.013s
ok github.com/carlosframework/platform/internal/dnsrec 0.003s
ok github.com/carlosframework/platform/internal/docsite 0.007s
ok github.com/carlosframework/platform/internal/domains 2.870s
ok github.com/carlosframework/platform/internal/doorkey 0.118s
ok github.com/carlosframework/platform/internal/economics 0.010s
ok github.com/carlosframework/platform/internal/edge 27.736s
? 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.380s
ok github.com/carlosframework/platform/internal/gate 2.543s
ok github.com/carlosframework/platform/internal/genselect 0.112s
ok github.com/carlosframework/platform/internal/geo 0.147s
ok github.com/carlosframework/platform/internal/geoip 0.132s
ok github.com/carlosframework/platform/internal/host 62.321s
ok github.com/carlosframework/platform/internal/hostactors 0.821s
ok github.com/carlosframework/platform/internal/hostbox 1.908s
ok github.com/carlosframework/platform/internal/hostclaim 0.610s
ok github.com/carlosframework/platform/internal/hostevidence 0.154s
ok github.com/carlosframework/platform/internal/hostexec 0.324s
ok github.com/carlosframework/platform/internal/hostfence 0.090s
ok github.com/carlosframework/platform/internal/hostfiles 4.293s
ok github.com/carlosframework/platform/internal/hostfloor 0.011s
ok github.com/carlosframework/platform/internal/hostguard 0.159s
ok github.com/carlosframework/platform/internal/hostops 2.285s
? github.com/carlosframework/platform/internal/hostops/fencerelease [no test files]
ok github.com/carlosframework/platform/internal/hostops/internal/fencerelease 0.008s
ok github.com/carlosframework/platform/internal/hostquarantine 0.003s
ok github.com/carlosframework/platform/internal/hostresolve 0.292s
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.723s
ok github.com/carlosframework/platform/internal/instanceexec 2.873s
ok github.com/carlosframework/platform/internal/instancefs 0.008s
ok github.com/carlosframework/platform/internal/instancekey 0.007s
ok github.com/carlosframework/platform/internal/instanceupdates 0.011s
ok github.com/carlosframework/platform/internal/layout 2.108s
ok github.com/carlosframework/platform/internal/lease 9.659s
ok github.com/carlosframework/platform/internal/lease/leasetest 0.006s
ok github.com/carlosframework/platform/internal/ledger 0.011s
ok github.com/carlosframework/platform/internal/mail 0.470s
ok github.com/carlosframework/platform/internal/maintenance 0.100s
ok github.com/carlosframework/platform/internal/maintenanceexec 5.710s
ok github.com/carlosframework/platform/internal/maintenanceguard 0.215s
ok github.com/carlosframework/platform/internal/metrics 0.006s
ok github.com/carlosframework/platform/internal/moves 23.510s
ok github.com/carlosframework/platform/internal/moves/archive 1.246s
ok github.com/carlosframework/platform/internal/objstore 0.306s
ok github.com/carlosframework/platform/internal/ops 0.169s
ok github.com/carlosframework/platform/internal/park 0.455s
ok github.com/carlosframework/platform/internal/privatefile 0.004s
ok github.com/carlosframework/platform/internal/provision 3.999s
ok github.com/carlosframework/platform/internal/publicrelease 0.010s
? github.com/carlosframework/platform/internal/regionname [no test files]
ok github.com/carlosframework/platform/internal/registry 4.106s
ok github.com/carlosframework/platform/internal/relay 7.316s
ok github.com/carlosframework/platform/internal/release 22.480s
ok github.com/carlosframework/platform/internal/rollout 1.950s
ok github.com/carlosframework/platform/internal/rolloutwords 0.010s
ok github.com/carlosframework/platform/internal/rootaws 0.015s
ok github.com/carlosframework/platform/internal/s3compat 0.006s
ok github.com/carlosframework/platform/internal/safefile 0.005s
ok github.com/carlosframework/platform/internal/schedule 2.719s
ok github.com/carlosframework/platform/internal/sim/hostlife 6.091s
ok github.com/carlosframework/platform/internal/sim/litestreamfacts 4.371s
ok github.com/carlosframework/platform/internal/status 0.041s
ok github.com/carlosframework/platform/internal/steering 0.013s
ok github.com/carlosframework/platform/internal/stopevidence 0.163s
ok github.com/carlosframework/platform/internal/svcrole 0.002s
ok github.com/carlosframework/platform/internal/telemetry 0.006s
ok github.com/carlosframework/platform/internal/termfence 0.010s
ok github.com/carlosframework/platform/internal/tunnelclient 0.006s
ok github.com/carlosframework/platform/internal/unixdial 0.006s
--- FAIL: TestApprovedCopyIsApplied (0.00s)
words_approved_test.go:61: open ../../docs/superpowers/plans/2026-10-07-instance-settings-4-copy.json: no such file or directory
FAIL
FAIL github.com/carlosframework/platform/internal/updatewords 0.011s
ok github.com/carlosframework/platform/internal/usage 0.007s
ok github.com/carlosframework/platform/internal/version 0.003s
ok github.com/carlosframework/platform/internal/vet 1.811s
FAIL
make: *** [Makefile:18: test] Error 1
amadan: step gate failed: exit status 2
amadan: job failed at step gate — its output is above, not at the tail of this log
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 d9837f822..017c329b9 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 738b756ff..b20a03075 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"}, |
| @@ -727,7 +727,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"}, |
| @@ -758,7 +758,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"}, |