moves: the park view's region is the claim's verified roster row's (Task 9a fix round 1, region source)
Registration took the view's region from CARLOS_REGION and refused a box without it. The view's region now comes from the claim's own verified identity, so view region == claim region (M17) by construction: - claimproduction.go: OpenClaimOwners' roster read (release key, host-actor enrollment, signed directory, the box's row with matching enrollment and instance) moves into readProductionRosterRow; OpenClaimOwners calls it unchanged. - movessources.go: productionRosterRegion answers that row's region; movesParkViewAt resolves it on first use (cachedRegion keeps the first success; a failure refuses that call and is asked again). registerProductionMovesParks takes the resolver and no longer reads CARLOS_REGION. - The home region, "", is a region like any other: the view no longer refuses it, nor a move whose from or to is home (a move with from == to still refuses). Tests: TestHomeBoxArms (the view seen from "", a move from home, arming an engine whose region is ""), TestParkViewRegionIsTheRosters (asked once, cached, a failure retried), TestRegisterProductionMovesParksNeedsBothSwitches (both switches on, no CARLOS_REGION: registers; the roster is not read at registration).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
4 files changed,
+182
−42
internal/host/agent.go+1 −1internal/host/claimproduction.go+39 −14internal/host/movessources.go+72 −17internal/host/movessources_test.go+70 −10
diff --git a/internal/host/agent.go b/internal/host/agent.go| index a03febbea..e71dd6fb0 100644 |
| --- a/internal/host/agent.go |
| +++ b/internal/host/agent.go |
| @@ -283,7 +283,7 @@ func runAgent(args []string, buildClaims ClaimFactory) { |
| // Task 5's expected refusals are quiet in the regional worker's log and |
| // its anomalies page once (movesrefusals.go), with both switches on. |
| movesRefusalLog := newMovesRefusals(metricsBoxLabel(os.Getenv, readMachineID()), PageOperator, log.Printf, time.Now) |
| - if err := registerProductionMovesParks(claimCtx, claimEntryEnv(claimCtx), claimStore(claimCtx), *cfg, movesRefusalLog); err != nil { |
| + if err := registerProductionMovesParks(claimCtx, claimEntryEnv(claimCtx), claimStore(claimCtx), *cfg, productionRosterRegion(claimStore(claimCtx), os.Getenv), movesRefusalLog); err != nil { |
| log.Printf("agent: moves: the park providers are not registered: %v", err) |
| } |
| claims, err := newClaimWiring(claimCtx, claimEntryEnv(claimCtx), buildClaims) |
diff --git a/internal/host/claimproduction.go b/internal/host/claimproduction.go| index 82cdfa57c..9f903b913 100644 |
| --- a/internal/host/claimproduction.go |
| +++ b/internal/host/claimproduction.go |
| @@ -10,6 +10,7 @@ import ( |
| "time" |
| "crypto/ecdh" |
| + "crypto/ed25519" |
| "github.com/carlosframework/platform/internal/activator" |
| "github.com/carlosframework/platform/internal/appconfig" |
| "github.com/carlosframework/platform/internal/boxid" |
| @@ -59,32 +60,56 @@ func RegisterProductionClaimMoveSources(s ClaimProductionMoveSources) error { |
| return nil |
| } |
| -// ProductionClaimOwnerProvider constructs claim-owned authority directly. Only |
| -// C8 and the moves regional snapshot/lease inventory come from the moves owner. |
| -type ProductionClaimOwnerProvider struct{} |
| +// productionRosterRow is this box's verified roster row and what read it: |
| +// the release key, the box's host-actor enrollment, the signed directory, |
| +// and the directory's row for the box, whose enrollment and cloud instance |
| +// are the enrollment's. |
| +type productionRosterRow struct { |
| + pub ed25519.PublicKey |
| + enrollment hostfloor.Enrollment |
| + directory hostactors.Directory |
| + row hostactors.Box |
| +} |
| -func (ProductionClaimOwnerProvider) OpenClaimOwners(ctx context.Context, store release.Store, r ClaimRuntimeResources) (ClaimOwnerBindings, error) { |
| - var out ClaimOwnerBindings |
| - if store == nil || r.Registry == nil || (r.Mode != "agent" && r.Mode != "adopt" && r.Mode != "instance-control") { |
| - return out, hostwrite.ErrUnavailable |
| - } |
| - pub, err := release.ParsePublicKey(os.Getenv("CARLOS_RELEASE_PUBKEY"), "CARLOS_RELEASE_PUBKEY") |
| +// readProductionRosterRow is OpenClaimOwners' roster read, shared with the |
| +// moves park view's region (productionRosterRegion, Plan 5 Task 9a fix |
| +// round 1) so the two cannot disagree. |
| +func readProductionRosterRow(ctx context.Context, store release.Store, getenv func(string) string) (productionRosterRow, error) { |
| + var rr productionRosterRow |
| + pub, err := release.ParsePublicKey(getenv("CARLOS_RELEASE_PUBKEY"), "CARLOS_RELEASE_PUBKEY") |
| if err != nil { |
| - return out, err |
| + return rr, err |
| } |
| enrollment, err := hostfloor.ReadEnrollment("/etc/carlos/host-actor.json") |
| if err != nil { |
| - return out, err |
| + return rr, err |
| } |
| - directory := hostactors.Directory{Store: store, PublicKey: pub, Bucket: os.Getenv("CARLOS_DEPLOYMENT_BUCKET")} |
| + directory := hostactors.Directory{Store: store, PublicKey: pub, Bucket: getenv("CARLOS_DEPLOYMENT_BUCKET")} |
| roster, err := directory.Read(ctx) |
| if err != nil { |
| - return out, err |
| + return rr, err |
| } |
| row, ok := roster.Roster.Boxes[enrollment.Box] |
| if !ok || row.Enrollment != enrollment.Enrollment || row.Cloud.InstanceID != enrollment.InstanceID { |
| - return out, hostbox.ErrChanged |
| + return rr, hostbox.ErrChanged |
| + } |
| + return productionRosterRow{pub: pub, enrollment: enrollment, directory: directory, row: row}, nil |
| +} |
| + |
| +// ProductionClaimOwnerProvider constructs claim-owned authority directly. Only |
| +// C8 and the moves regional snapshot/lease inventory come from the moves owner. |
| +type ProductionClaimOwnerProvider struct{} |
| + |
| +func (ProductionClaimOwnerProvider) OpenClaimOwners(ctx context.Context, store release.Store, r ClaimRuntimeResources) (ClaimOwnerBindings, error) { |
| + var out ClaimOwnerBindings |
| + if store == nil || r.Registry == nil || (r.Mode != "agent" && r.Mode != "adopt" && r.Mode != "instance-control") { |
| + return out, hostwrite.ErrUnavailable |
| + } |
| + rr, err := readProductionRosterRow(ctx, store, os.Getenv) |
| + if err != nil { |
| + return out, err |
| } |
| + pub, enrollment, directory, row := rr.pub, rr.enrollment, rr.directory, rr.row |
| certs, err := boxid.EmbeddedCerts() |
| if err != nil { |
| return out, err |
diff --git a/internal/host/movessources.go b/internal/host/movessources.go| index c56663ef6..e3caad3ed 100644 |
| --- a/internal/host/movessources.go |
| +++ b/internal/host/movessources.go |
| @@ -39,6 +39,7 @@ import ( |
| "os" |
| "sort" |
| "strings" |
| + "sync" |
| "time" |
| "github.com/carlosframework/platform/internal/activator" |
| @@ -98,8 +99,8 @@ func movesPlan12Parks() (movesClaimPlan12, error) { |
| } |
| // movesParkView is the park view the claim's engine and the moves arms read |
| -// (park.View): bound to the observation's ETag, seen from region (this box's |
| -// placement code, which M17 requires to be the claim's region too), naming |
| +// (park.View): bound to the observation's ETag, seen from region (in |
| +// production the claim's own region, movesParkViewAt), naming |
| // the authoritative region by the spec's table (§5.1, movesAuthoritative). |
| // In the authoritative region it reads the location's generation.json by |
| // exact version (MarkerKnown, MarkerGeneration, Marker; absent is the origin) |
| @@ -107,13 +108,53 @@ func movesPlan12Parks() (movesClaimPlan12, error) { |
| // GenerationOf is the landing's classifier over the holder's location. |
| // Anything it cannot read or place is an error, never a smaller live set. |
| func movesParkView(records MovesRecordReader, data genselect.Reader, bucket, region string) func(context.Context, hostclaim.Observed) (park.View, error) { |
| + return movesParkViewAt(records, data, bucket, fixedRegion(region)) |
| +} |
| + |
| +// fixedRegion is a region resolver that always answers region. |
| +func fixedRegion(region string) func(context.Context) (string, error) { |
| + return func(context.Context) (string, error) { return region, nil } |
| +} |
| + |
| +// cachedRegion resolves once and keeps the first success; a failure is |
| +// returned and asked again next time. A box's roster region does not change |
| +// under a running agent; if it did, the engine's own check (the view's |
| +// region against its own) refuses. |
| +func cachedRegion(resolve func(context.Context) (string, error)) func(context.Context) (string, error) { |
| + var mu sync.Mutex |
| + var cached *string |
| + return func(ctx context.Context) (string, error) { |
| + mu.Lock() |
| + defer mu.Unlock() |
| + if cached != nil { |
| + return *cached, nil |
| + } |
| + r, err := resolve(ctx) |
| + if err != nil { |
| + return "", err |
| + } |
| + cached = &r |
| + return r, nil |
| + } |
| +} |
| + |
| +// movesParkViewAt is movesParkView seen from the region regionOf answers. |
| +// Production's is the claim's verified roster row's region |
| +// (productionRosterRegion), the region the claim's engine runs in, so the |
| +// view's region is the claim's by construction (M17; fix round 1). The home |
| +// region, "", is a region like any other. |
| +func movesParkViewAt(records MovesRecordReader, data genselect.Reader, bucket string, regionOf func(context.Context) (string, error)) func(context.Context, hostclaim.Observed) (park.View, error) { |
| return func(ctx context.Context, obs hostclaim.Observed) (park.View, error) { |
| - if ctx == nil || records == nil || data == nil || bucket == "" || region == "" { |
| + if ctx == nil || records == nil || data == nil || bucket == "" || regionOf == nil { |
| return park.View{}, hostwrite.ErrUnavailable |
| } |
| if obs.Absent || obs.Claim == nil || obs.ETag == "" || obs.Host == "" || obs.Claim.Host != obs.Host { |
| return park.View{}, park.ErrUnsafe |
| } |
| + region, err := regionOf(ctx) |
| + if err != nil { |
| + return park.View{}, fmt.Errorf("this box's region: %w", err) |
| + } |
| auth, err := movesAuthoritative(ctx, records, obs, region) |
| if err != nil { |
| return park.View{}, err |
| @@ -357,7 +398,7 @@ func movesAuthoritative(ctx context.Context, records MovesRecordReader, obs host |
| return "", err |
| } |
| m := rec.Move |
| - if m == nil || m.ID != c.Op.ID || m.Legacy() || m.From == "" || m.To == "" { |
| + if m == nil || m.ID != c.Op.ID || m.Legacy() || m.From == m.To { |
| if l := rec.LastMove; m == nil && l != nil && l.ID == c.Op.ID && known { |
| return admitted, nil // the record closed first (M4); the claim's history decides |
| } |
| @@ -504,11 +545,11 @@ type movesParkSources struct { |
| // newMovesParkSources builds the three providers. plan12 is the guard |
| // (movesPlan12Parks in production): while it refuses, the view and the |
| // lease set refuse with its error, so nothing is queued or run through them. |
| -func newMovesParkSources(records MovesRecordReader, data genselect.Reader, bucket, region string, evidence hostbox.RegionalEvidence, plan12 func() (movesClaimPlan12, error)) (movesParkSources, error) { |
| - if records == nil || data == nil || bucket == "" || region == "" || evidence == nil || plan12 == nil { |
| +func newMovesParkSources(records MovesRecordReader, data genselect.Reader, bucket string, regionOf func(context.Context) (string, error), evidence hostbox.RegionalEvidence, plan12 func() (movesClaimPlan12, error)) (movesParkSources, error) { |
| + if records == nil || data == nil || bucket == "" || regionOf == nil || evidence == nil || plan12 == nil { |
| return movesParkSources{}, hostwrite.ErrUnavailable |
| } |
| - view, leases := movesParkView(records, data, bucket, region), movesLeaseSet(bucket) |
| + view, leases := movesParkViewAt(records, data, bucket, regionOf), movesLeaseSet(bucket) |
| return movesParkSources{ |
| View: func(ctx context.Context, obs hostclaim.Observed) (park.View, error) { |
| if _, err := plan12(); err != nil { |
| @@ -566,18 +607,18 @@ func armMovesParks(ctx context.Context, cfg *moveCfg, providers ClaimProviders, |
| // registerProductionMovesParks is the agent's registration, before |
| // newClaimWiring: only with both CARLOS_HOST_CLAIM and CARLOS_MOVES_PHASE1 |
| // on (ruling H1), over the deployment store's records, this box's data |
| -// bucket read by exact version (lease.NewVersionedS3, through rootaws) and |
| -// its placement region, behind the claim Plan 12 guard. Off, it registers |
| -// nothing and the claim keeps its named refusals. |
| -// Task 5's evidence goes through refusals, which pages its anomalies once |
| -// (movesrefusals.go). |
| -func registerProductionMovesParks(ctx context.Context, getenv func(string) string, store release.Store, hib activator.Config, refusals *movesRefusals) error { |
| +// bucket read by exact version (lease.NewVersionedS3, through rootaws), and |
| +// the region regionOf answers, behind the claim Plan 12 guard. Production's |
| +// regionOf is productionRosterRegion: the claim's verified roster row, the |
| +// home region "" included (fix round 1). Off, it registers nothing and the |
| +// claim keeps its named refusals. Task 5's evidence goes through refusals, |
| +// which pages its anomalies once (movesrefusals.go). |
| +func registerProductionMovesParks(ctx context.Context, getenv func(string) string, store release.Store, hib activator.Config, regionOf func(context.Context) (string, error), refusals *movesRefusals) error { |
| if !hostwrite.Enabled(getenv) || !moves.Enabled(getenv) { |
| return nil |
| } |
| - region := strings.TrimSpace(getenv("CARLOS_REGION")) |
| - if ctx == nil || store == nil || hib.Bucket == "" || hib.Region == "" || region == "" || refusals == nil { |
| - return errors.Join(hostwrite.ErrUnavailable, errors.New("the moves park view needs the deployment store, the data bucket and CARLOS_REGION")) |
| + if ctx == nil || store == nil || hib.Bucket == "" || hib.Region == "" || regionOf == nil || refusals == nil { |
| + return errors.Join(hostwrite.ErrUnavailable, errors.New("the moves park view needs the deployment store, the data bucket and this box's roster region")) |
| } |
| data, err := lease.NewVersionedS3(ctx, map[string]string{hib.Bucket: hib.Region}) |
| if err != nil { |
| @@ -585,9 +626,23 @@ func registerProductionMovesParks(ctx context.Context, getenv func(string) strin |
| } |
| records := MovesRecordsFrom(store) |
| evidence := refusals.evidence(movesRetirementEvidence{Records: records, Now: time.Now}) |
| - src, err := newMovesParkSources(records, data, hib.Bucket, region, evidence, movesPlan12Parks) |
| + src, err := newMovesParkSources(records, data, hib.Bucket, cachedRegion(regionOf), evidence, movesPlan12Parks) |
| if err != nil { |
| return err |
| } |
| return registerMovesParks(src) |
| } |
| + |
| +// productionRosterRegion is this box's region as the claim knows it: the |
| +// region of its verified roster row (readProductionRosterRow), the same row |
| +// OpenClaimOwners builds the claim's identity, and so its engine's region, |
| +// from. |
| +func productionRosterRegion(store release.Store, getenv func(string) string) func(context.Context) (string, error) { |
| + return func(ctx context.Context) (string, error) { |
| + rr, err := readProductionRosterRow(ctx, store, getenv) |
| + if err != nil { |
| + return "", err |
| + } |
| + return rr.row.Region, nil |
| + } |
| +} |
diff --git a/internal/host/movessources_test.go b/internal/host/movessources_test.go| index 3e314e326..dcef3995a 100644 |
| --- a/internal/host/movessources_test.go |
| +++ b/internal/host/movessources_test.go |
| @@ -394,7 +394,7 @@ func TestArmMovesPassRefusesAMismatchedBucketOrRegion(t *testing.T) { |
| func TestMovesParksRefuseUntilClaimPlan12(t *testing.T) { |
| f := newVwFixture(t) |
| evidence := movesRetirementEvidence{Records: f.records(), Now: time.Now} |
| - src, err := newMovesParkSources(f.records(), f.bucket, "data", "ie", evidence, movesPlan12Parks) |
| + src, err := newMovesParkSources(f.records(), f.bucket, "data", fixedRegion("ie"), evidence, movesPlan12Parks) |
| if err != nil { |
| t.Fatal(err) |
| } |
| @@ -413,7 +413,7 @@ func TestMovesParksRefuseUntilClaimPlan12(t *testing.T) { |
| t.Fatal("the registered evidence is not the sweep verifier") |
| } |
| // With claim Plan 12 the same sources answer. |
| - src, err = newMovesParkSources(f.records(), f.bucket, "data", "ie", evidence, func() (movesClaimPlan12, error) { return &fakePlan12{}, nil }) |
| + src, err = newMovesParkSources(f.records(), f.bucket, "data", fixedRegion("ie"), evidence, func() (movesClaimPlan12, error) { return &fakePlan12{}, nil }) |
| if err != nil { |
| t.Fatal(err) |
| } |
| @@ -516,8 +516,9 @@ func TestCommittedLaneViewNamesTheTargetWithItsMarker(t *testing.T) { |
| } |
| // TestRegisterProductionMovesParksNeedsBothSwitches (ruling H1): with either |
| -// switch off the agent registers nothing; with both on it needs this box's |
| -// placement region. |
| +// switch off the agent registers nothing. With both on it registers without |
| +// CARLOS_REGION: the view's region is the claim's verified roster row's |
| +// (fix round 1, region source), resolved when the view is first asked. |
| func TestRegisterProductionMovesParksNeedsBothSwitches(t *testing.T) { |
| claimMoveSources.Lock() |
| oldSource, oldParks := claimMoveSources.Source, claimMoveSources.Parks |
| @@ -531,20 +532,79 @@ func TestRegisterProductionMovesParksNeedsBothSwitches(t *testing.T) { |
| env := func(vals map[string]string) func(string) string { return func(k string) string { return vals[k] } } |
| hib := activator.Config{Bucket: "data-ie", Region: "eu-west-1"} |
| store := release.NewDirStore(t.TempDir()) |
| + asked := 0 |
| + roster := func(context.Context) (string, error) { asked++; return "ie", nil } |
| for name, vals := range map[string]map[string]string{ |
| - "claims off": {moves.SwitchEnv: "on", "CARLOS_REGION": "ie"}, |
| - "moves off": {"CARLOS_HOST_CLAIM": "1", moves.SwitchEnv: "off", "CARLOS_REGION": "ie"}, |
| + "claims off": {moves.SwitchEnv: "on"}, |
| + "moves off": {"CARLOS_HOST_CLAIM": "1", moves.SwitchEnv: "off"}, |
| } { |
| - if err := registerProductionMovesParks(t.Context(), env(vals), store, hib, newMovesRefusals("ie-box", nil, t.Logf, time.Now)); err != nil { |
| + if err := registerProductionMovesParks(t.Context(), env(vals), store, hib, roster, newMovesRefusals("ie-box", nil, t.Logf, time.Now)); err != nil { |
| t.Fatalf("%s: %v", name, err) |
| } |
| if claimMoveSources.Parks != nil { |
| t.Fatalf("%s: registered", name) |
| } |
| } |
| - err := registerProductionMovesParks(t.Context(), env(map[string]string{"CARLOS_HOST_CLAIM": "1", moves.SwitchEnv: "on"}), store, hib, newMovesRefusals("ie-box", nil, t.Logf, time.Now)) |
| - if !errors.Is(err, hostwrite.ErrUnavailable) || claimMoveSources.Parks != nil { |
| - t.Fatalf("no placement region: %v", err) |
| + if err := registerProductionMovesParks(t.Context(), env(map[string]string{"CARLOS_HOST_CLAIM": "1", moves.SwitchEnv: "on"}), store, hib, roster, newMovesRefusals("ie-box", nil, t.Logf, time.Now)); err != nil { |
| + t.Fatalf("both switches on, no CARLOS_REGION: %v", err) |
| + } |
| + if claimMoveSources.Parks == nil || asked != 0 { |
| + t.Fatalf("registered %v, roster asked %d times at registration", claimMoveSources.Parks != nil, asked) |
| + } |
| +} |
| + |
| +// TestHomeBoxArms (fix round 1, region source): the home region, "", is a |
| +// region like any other. A home box's view is seen from "" and its arms take |
| +// an engine whose region is "". |
| +func TestHomeBoxArms(t *testing.T) { |
| + f := newVwFixture(t) |
| + f.rec.Region = "" |
| + view := movesParkViewAt(f.records(), f.bucket, "data", fixedRegion("")) |
| + obs := vwObs(&hostclaim.Claim{Holder: f.holder(""), Op: hostclaim.Op{Kind: hostclaim.KindMoving, ID: vwMove, Phase: hostclaim.PhaseMoving, Exec: "box"}}) |
| + f.rec.Move = &provision.MoveState{ID: vwMove, Incarnation: f.inc, From: "", To: "uk", State: provision.MoveCutting} |
| + v, err := view(t.Context(), obs) |
| + if err != nil || v.Region != "" || v.AuthoritativeRegion != "" || !v.MarkerKnown { |
| + t.Fatalf("the home view %+v, %v", v, err) |
| + } |
| + auth := func(context.Context, hostclaim.Observed, hostclaim.Obligation) error { return nil } |
| + providers := ClaimProviders{moveParks: &claimMoveParks{ |
| + engine: park.Engine{Bucket: "data-home", Region: "", Leases: &fakeParkLeases{}, View: view, Authorize: auth}, |
| + leases: movesLeaseSet("data-home"), check: func(context.Context) error { return nil }}} |
| + c := moveCfg{OwnBucket: "data-home", Region: ""} |
| + if err := armMovesParks(t.Context(), &c, providers, func() (movesClaimPlan12, error) { return &fakePlan12{}, nil }); err != nil { |
| + t.Fatalf("a home box did not arm: %v", err) |
| + } |
| + if !parkHooksArmed(c) { |
| + t.Fatal("hooks unset") |
| + } |
| +} |
| + |
| +// TestParkViewRegionIsTheRosters (fix round 1, region source): the view |
| +// asks the roster resolver once, caches a success, and a failure refuses |
| +// that call and asks again on the next. |
| +func TestParkViewRegionIsTheRosters(t *testing.T) { |
| + f := newVwFixture(t) |
| + calls, fail := 0, true |
| + resolve := cachedRegion(func(context.Context) (string, error) { |
| + calls++ |
| + if fail { |
| + return "", hostbox.ErrChanged |
| + } |
| + return "uk", nil |
| + }) |
| + view := movesParkViewAt(f.records(), f.bucket, "data", resolve) |
| + obs := vwObs(&hostclaim.Claim{Holder: f.holder("cur", "cur@ie"), Op: hostclaim.Op{Kind: hostclaim.KindServing, Exec: "box"}}) |
| + if _, err := view(t.Context(), obs); !errors.Is(err, hostbox.ErrChanged) { |
| + t.Fatalf("a roster failure: %v", err) |
| + } |
| + fail = false |
| + for i := 0; i < 3; i++ { |
| + if v, err := view(t.Context(), obs); err != nil || v.Region != "uk" { |
| + t.Fatalf("view %+v, %v", v, err) |
| + } |
| + } |
| + if calls != 2 { |
| + t.Fatalf("the roster was asked %d times, want 2", calls) |
| } |
| } |