moves: register the park view, lease set and evidence; arm from MoveParks (Plan 5 Task 9a, step 1)
The moves park providers in production, built and registered behind the claim Plan 12 guard: - movesParkView: the spec's authoritative-region table (§5.1), the exact-version generation.json marker and the installed root's pin, the landing classifier as GenerationOf. Kinds with no table row refuse. - movesLeaseSet (M6): the scope's own lock for moves/<host>[/<op>] and instances/<host>[/<inc>], this box's data bucket only. - registerProductionMovesParks: at agent start, before newClaimWiring, only with CARLOS_HOST_CLAIM and CARLOS_MOVES_PHASE1 both on; registers the view, Task 5's movesRetirementEvidence and the lease set. - armMovesParks (M17): armMovesPass takes claims.Providers and sets ParkLeases, ParkView and ParkAuthorize from MoveParks only when its bucket and region are this box's own, plus claim Plan 12's RunHeld (M5) and local authorizer (M9) through movesClaimPlan12. Production stays unarmed: movesPlan12Parks refuses by name while claim Plan 12 is missing, so the registered view and lease set refuse every call and the arms are never armed. Census: two new key-site rows (holderRecord, movesLeaseScope).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
8 files changed,
+1055
−18
approved_moves_copy_test.go+1 −0internal/host/agent.go+7 −1internal/host/movespass.go+32 −14internal/host/movespass_test.go+2 −2internal/host/movesphase1_test.go+1 −1internal/host/movessources.go+459 −0internal/host/movessources_test.go+549 −0internal/provision/plan9_b8_census_test.go+4 −0
diff --git a/approved_moves_copy_test.go b/approved_moves_copy_test.go| index f0e8f1820..34c1f3ba9 100644 |
| --- a/approved_moves_copy_test.go |
| +++ b/approved_moves_copy_test.go |
| @@ -544,6 +544,7 @@ func TestMovesTextAppliedPlaceholders(t *testing.T) { |
| // copy" task appends the files it touched; TestMovesScanFilesCoverTheDiff |
| // fails when the list falls behind. |
| var movesScanFiles = []string{ |
| + "internal/host/movessources.go", |
| "internal/console/movesrecovery.go", |
| "internal/console/claim_runtime.go", |
| "internal/alerting/alerting.go", |
diff --git a/internal/host/agent.go b/internal/host/agent.go| index 7457ed444..f994918e1 100644 |
| --- a/internal/host/agent.go |
| +++ b/internal/host/agent.go |
| @@ -276,6 +276,12 @@ func runAgent(args []string, buildClaims ClaimFactory) { |
| log.Fatalf("agent: %v", err) |
| } |
| claimCtx = withClaimRuntimeResources(claimCtx, ClaimRuntimeResources{Mode: "agent", Registry: reg, Manager: cgm, DataDir: dd, OptDir: release.OptDir(), UnitsDir: "/etc/systemd/system"}) |
| + // The moves park providers (Plan 5 Task 9a) register before the claim |
| + // assembly reads them; only with both switches on, and behind the claim |
| + // Plan 12 guard, so the parks still refuse by name until it lands. |
| + if err := registerProductionMovesParks(claimCtx, claimEntryEnv(claimCtx), claimStore(claimCtx), *cfg); err != nil { |
| + log.Printf("agent: moves: the park providers are not registered: %v", err) |
| + } |
| claims, err := newClaimWiring(claimCtx, claimEntryEnv(claimCtx), buildClaims) |
| if err != nil { |
| log.Fatalf("agent: host claims: %v", err) |
| @@ -447,7 +453,7 @@ func runAgent(args []string, buildClaims ClaimFactory) { |
| } else { |
| eb.Mirror = m |
| } |
| - armMovesPass(*cfg, dd, reg, svc, eb, claims.Writes) |
| + armMovesPass(*cfg, dd, reg, svc, eb, claims.Writes, claims.Providers) |
| // The startup sweep (design §6.7): a leftover instance unit from a |
| // previous agent process, one this box's registry no longer holds |
| // a live wake for, is stopped before this process starts spawning |
diff --git a/internal/host/movespass.go b/internal/host/movespass.go| index 4edda2f80..34c5ad974 100644 |
| --- a/internal/host/movespass.go |
| +++ b/internal/host/movespass.go |
| @@ -184,8 +184,11 @@ func (w *movesWorker) mirror(ctx context.Context, dstRegion string) (lease.Mirro |
| // service that answers whether a child is down, and the exec backend (the |
| // Phase 1 launch environment and S2). Called once, before svc.Start: with |
| // the Phase 1 switch on it sets eb.Strict, which the spawn path reads; with |
| -// it off eb.Strict stays nil, so every spawn is main's. |
| -func armMovesPass(hib activator.Config, dataDir string, reg *registry.Registry, svc *activator.Service, eb *activator.ExecBackend, writes hostwrite.Config) { |
| +// it off eb.Strict stays nil, so every spawn is main's. providers is the |
| +// claim wiring's (newClaimWiring builds it before this): with both switches |
| +// on, the park arms take their engine from its MoveParks (armMovesParks, |
| +// Plan 5 Task 9a), behind the claim Plan 12 guard. |
| +func armMovesPass(hib activator.Config, dataDir string, reg *registry.Registry, svc *activator.Service, eb *activator.ExecBackend, writes hostwrite.Config, providers ClaimProviders) { |
| readyDelta, ok := moveReadyDeltaFromEnv(os.Getenv) |
| if !ok { |
| log.Printf("agent: %s=%q ignored: want a whole number of bytes, 0 or more — keeping the built-in default of %d", moveReadyDeltaEnv, os.Getenv(moveReadyDeltaEnv), defaultMoveReadyDelta) |
| @@ -239,6 +242,13 @@ func armMovesPass(hib activator.Config, dataDir string, reg *registry.Registry, |
| armPhase1(&cfg, w, hib, dataDir, reg, svc, eb, writes, os.Getenv) |
| w.holds = newMovesHoldView(cfg.Box, cfg.Region, hostclaim.Read, holdStageRequester(dataDir)) |
| cfg.TimersDrained = w.holds.drainTimers |
| + if cfg.claimRecords { |
| + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) |
| + if err := armMovesParks(ctx, &cfg, providers, movesPlan12Parks); err != nil { |
| + log.Printf("agent: moves: the park arms stay unarmed: %v", err) |
| + } |
| + cancel() |
| + } |
| } |
| w.cfg = cfg |
| armedMovesWorker.Store(w) |
| @@ -563,18 +573,26 @@ type moveCfg struct { |
| // registry's; RemoveVerifyEntry is |
| // dbverify.RemoveHost; WriteGen is hostbox.WriteGenerationMarker, the |
| // one <dbPath>.gen writer (ruling G2). |
| - Guard hostguard.Guard |
| - Local park.Local |
| - ParkLeases park.Leases |
| - ParkView func(context.Context, hostclaim.Observed) (park.View, error) |
| - ParkAuthorize func(context.Context, hostclaim.Observed, hostclaim.Obligation) error |
| - OwnLease func(ctx context.Context, bucket, location string) (park.HeldLease, error) |
| - RenderHeld func(ctx context.Context, s release.Store, rt registry.Route, op string) (appconfig.HeldRender, error) |
| - ReplaceTimers func(ctx context.Context, host string, rows []registry.Timer) error |
| - SetReplication func(host, class string) error |
| - SetNextDue func(host string, due int64) error |
| - RemoveVerifyEntry func(ctx context.Context, s release.Store, host string) error |
| - WriteGen func(path, generation string) error |
| + Guard hostguard.Guard |
| + Local park.Local |
| + ParkLeases park.Leases |
| + ParkView func(context.Context, hostclaim.Observed) (park.View, error) |
| + ParkAuthorize func(context.Context, hostclaim.Observed, hostclaim.Obligation) error |
| + // ParkRunHeld and ParkLocalAuthorize are claim Plan 12's pieces of the |
| + // MoveParks capability (movesClaimPlan12, movessources.go): RunHeld runs |
| + // a chore under a lease the caller holds (M5: the landing's residue park |
| + // under its own-location lease), AuthorizeLocal is the owner authorizer |
| + // for this box's park.Local chores (M9). Nil in production until claim |
| + // Plan 12 lands; armMovesParks sets all five or none. |
| + ParkRunHeld func(ctx context.Context, e park.Engine, w park.Work, held park.HeldLease) error |
| + ParkLocalAuthorize func(ctx context.Context, c *hostclaim.Claim, o hostclaim.Obligation) error |
| + OwnLease func(ctx context.Context, bucket, location string) (park.HeldLease, error) |
| + RenderHeld func(ctx context.Context, s release.Store, rt registry.Route, op string) (appconfig.HeldRender, error) |
| + ReplaceTimers func(ctx context.Context, host string, rows []registry.Timer) error |
| + SetReplication func(host, class string) error |
| + SetNextDue func(host string, due int64) error |
| + RemoveVerifyEntry func(ctx context.Context, s release.Store, host string) error |
| + WriteGen func(path, generation string) error |
| // Probe is the landing's probe wake (Plan 3 Task 6): Task 5's |
| // activator.Service.ProbeWake. Nil in production, with a standing |
| // notice, until claim Plan 11's launcher (R10) and Plan 5 Task 9's |
diff --git a/internal/host/movespass_test.go b/internal/host/movespass_test.go| index 7a4161b49..e8b6ab3ac 100644 |
| --- a/internal/host/movespass_test.go |
| +++ b/internal/host/movespass_test.go |
| @@ -926,7 +926,7 @@ func TestArmMovesPassReadsTheReadyDelta(t *testing.T) { |
| {"lots", defaultMoveReadyDelta}, |
| } { |
| t.Setenv("CARLOS_MOVE_READY_DELTA_BYTES", tc.raw) |
| - armMovesPass(activator.Config{}, t.TempDir(), nil, nil, nil, hostwrite.Config{}) |
| + armMovesPass(activator.Config{}, t.TempDir(), nil, nil, nil, hostwrite.Config{}, ClaimProviders{}) |
| if got := armedMovesWorker.Load().cfg.ReadyDelta; got != tc.want { |
| t.Errorf("CARLOS_MOVE_READY_DELTA_BYTES=%q armed a bar of %d, want %d", tc.raw, got, tc.want) |
| } |
| @@ -945,7 +945,7 @@ func TestArmMovesPassReadsTheSwitch(t *testing.T) { |
| {"", false}, {"on", true}, {"1", true}, {"true", true}, {"off", false}, {"yes", false}, |
| } { |
| t.Setenv(moves.SwitchEnv, tc.raw) |
| - armMovesPass(activator.Config{}, t.TempDir(), nil, nil, nil, hostwrite.Config{}) |
| + armMovesPass(activator.Config{}, t.TempDir(), nil, nil, nil, hostwrite.Config{}, ClaimProviders{}) |
| if got := armedMovesWorker.Load().cfg.Enabled; got != tc.want { |
| t.Errorf("%s=%q armed Enabled=%v, want %v", moves.SwitchEnv, tc.raw, got, tc.want) |
| } |
diff --git a/internal/host/movesphase1_test.go b/internal/host/movesphase1_test.go| index a6efc9a4c..c1b301a55 100644 |
| --- a/internal/host/movesphase1_test.go |
| +++ b/internal/host/movesphase1_test.go |
| @@ -144,7 +144,7 @@ func TestPhase1IsInertWithTheSwitchOff(t *testing.T) { |
| } |
| t.Setenv(moves.SwitchEnv, v) |
| eb := &activator.ExecBackend{} |
| - armMovesPass(activator.Config{}, t.TempDir(), nil, nil, eb, hostwrite.Config{}) |
| + armMovesPass(activator.Config{}, t.TempDir(), nil, nil, eb, hostwrite.Config{}, ClaimProviders{}) |
| if (eb.Strict != nil) != on { |
| t.Fatalf("switch %v: eb.Strict set = %v", on, eb.Strict != nil) |
| } |
diff --git a/internal/host/movessources.go b/internal/host/movessources.go| new file mode 100644 |
| index 000000000..e08001fa6 |
| --- /dev/null |
| +++ b/internal/host/movessources.go |
| @@ -0,0 +1,459 @@ |
| +package host |
| + |
| +// The moves park providers in production (moves Plan 5 Task 9a; the delta's |
| +// findings M5, M6, M9, M10 and M17, and the Task 9a rulings). Three things |
| +// are built here and registered with the claim at agent start, before |
| +// newClaimWiring: |
| +// |
| +// - the park view (movesParkView): the spec's authoritative-region table |
| +// (§5.1), the exact-version generation marker, and the pins; |
| +// - the lease set (movesLeaseSet): the scope's own lock, for moves/<host> |
| +// scopes and for instances/<host>[/<inc>] locations alike (M6); |
| +// - Task 5's retirement evidence (movesRetirementEvidence), which also |
| +// answers VerifyMovesSweep. |
| +// |
| +// The moves arms then take the claim's own regional engine through |
| +// ClaimProviders.MoveParks (armMovesParks): its leases, view and authorizer, |
| +// after checking that its bucket and region are this box's own (M17). |
| +// |
| +// Production stays unarmed. Registration and arming happen only with both |
| +// CARLOS_MOVES_PHASE1 and CARLOS_HOST_CLAIM on. Even then, three pieces are |
| +// claim Plan 12's and are not on main: Engine.RunHeld (M5), the lease-set |
| +// contract for moves scopes (M6) and the MoveParks local authorizer (M9). |
| +// Each sits behind movesClaimPlan12, and the production guard |
| +// (movesPlan12Parks) refuses by name while it is missing. Until it lands: |
| +// |
| +// - the registered view and lease set refuse every call with a |
| +// *ClaimDependencyError naming claim Plan 12, so the claim's regional |
| +// worker queues and runs nothing through them; |
| +// - armMovesParks leaves ParkLeases, ParkView and ParkAuthorize unset, so |
| +// every moves arm stays unarmed and logs its standing notice. |
| +// |
| +// The tests inject fakes for the Plan 12 pieces. |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "errors" |
| + "fmt" |
| + "strings" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/activator" |
| + "github.com/carlosframework/platform/internal/genselect" |
| + "github.com/carlosframework/platform/internal/hostbox" |
| + "github.com/carlosframework/platform/internal/hostclaim" |
| + "github.com/carlosframework/platform/internal/hostwrite" |
| + "github.com/carlosframework/platform/internal/lease" |
| + "github.com/carlosframework/platform/internal/moves" |
| + "github.com/carlosframework/platform/internal/park" |
| + "github.com/carlosframework/platform/internal/provision" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +// movesNoLiveRegion is the authoritative region the view names when the |
| +// spec's table says none: a claim with no holder, or a creating, deleting, |
| +// parking or purging op. No region code is "none", so the engine protects |
| +// nothing in any region, which is what "nothing is live" means. |
| +const movesNoLiveRegion = "none" |
| + |
| +// movesClaimPlan12 is what claim Plan 12 adds to the MoveParks capability. |
| +// Nothing on main implements it. |
| +// |
| +// - RunHeld (M5) runs one chore under a lease the caller already holds, |
| +// the landing's own-location lease, which the landing keeps from |
| +// checking through published (spec §7.6). Engine.Run would acquire the |
| +// same lock again and wait on itself. |
| +// - AuthorizeLocal (M9) is the owner authorizer for this box's own |
| +// park.Local chores. The MoveParks engine's authorizer sends every chore |
| +// without a bound Park to VerifyBound, which refuses a park-local. |
| +// |
| +// The lease-set contract for moves scopes (M6) is agreed with platform-1e and |
| +// arrives with the same plan; it has no method of its own. |
| +type movesClaimPlan12 interface { |
| + RunHeld(ctx context.Context, e park.Engine, w park.Work, held park.HeldLease) error |
| + AuthorizeLocal(ctx context.Context, c *hostclaim.Claim, o hostclaim.Obligation) error |
| +} |
| + |
| +// movesPlan12Contracts names the claim Plan 12 pieces the guard waits on. |
| +var movesPlan12Contracts = []string{ |
| + "claim Plan 12 Engine.RunHeld for the landing's own-lease residue park (M5)", |
| + "claim Plan 12 lease-set contract for moves scopes (M6)", |
| + "claim Plan 12 MoveParks local authorizer (M9)", |
| +} |
| + |
| +// productionMovesPlan12 is claim Plan 12's implementation once it lands. |
| +var productionMovesPlan12 movesClaimPlan12 |
| + |
| +// movesPlan12Parks is the production guard: claim Plan 12's pieces, or a |
| +// refusal that names each one. |
| +func movesPlan12Parks() (movesClaimPlan12, error) { |
| + if productionMovesPlan12 == nil { |
| + return nil, &ClaimDependencyError{Contracts: movesPlan12Contracts} |
| + } |
| + return productionMovesPlan12, nil |
| +} |
| + |
| +// 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 |
| +// 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) |
| +// and pins the snapshot version the marker names, the installed root. |
| +// 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 func(ctx context.Context, obs hostclaim.Observed) (park.View, error) { |
| + if ctx == nil || records == nil || data == nil || bucket == "" || region == "" { |
| + 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 |
| + } |
| + auth, err := movesAuthoritative(ctx, records, obs) |
| + if err != nil { |
| + return park.View{}, err |
| + } |
| + v := park.View{ClaimETag: obs.ETag, Region: region, AuthoritativeRegion: auth} |
| + h := obs.Claim.Holder |
| + if h == nil { |
| + return v, nil |
| + } |
| + location := strings.TrimSuffix(h.Location, "/") |
| + v.GenerationOf = func(r park.Ref) (string, bool) { return landingGenerationOf(location, r.Key) } |
| + if auth != region { |
| + return v, nil |
| + } |
| + listing, err := data.ListVersions(ctx, bucket, location+"/") |
| + if err != nil { |
| + return park.View{}, fmt.Errorf("list %s: %w", location, err) |
| + } |
| + if err := readMarker(ctx, data, bucket, obs.Host, location, *h, listing, &v); err != nil { |
| + return park.View{}, err |
| + } |
| + return v, nil |
| + } |
| +} |
| + |
| +// readMarker fills the marker and the installed root's pin from the |
| +// location's generation.json, read by the exact version the listing names. |
| +func readMarker(ctx context.Context, data genselect.Reader, bucket, host, location string, h hostclaim.Holder, listing []lease.ObjectVersion, v *park.View) error { |
| + key := location + "/generation.json" |
| + var latest *lease.ObjectVersion |
| + for i := range listing { |
| + if listing[i].Key == key && listing[i].IsLatest { |
| + if latest != nil { |
| + return fmt.Errorf("%w: two current versions of %s", park.ErrUnsafe, key) |
| + } |
| + latest = &listing[i] |
| + } |
| + } |
| + if latest == nil { |
| + // Absent everywhere means origin (§5.1). |
| + v.MarkerKnown = true |
| + return nil |
| + } |
| + if latest.DeleteMarker || latest.VersionID == "" || latest.VersionID == "null" { |
| + return fmt.Errorf("%w: %s is not a readable version", park.ErrUnsafe, key) |
| + } |
| + raw, err := data.GetVersion(ctx, bucket, key, latest.VersionID) |
| + if err != nil { |
| + return fmt.Errorf("read %s: %w", key, err) |
| + } |
| + var m genselect.Marker |
| + if err := json.Unmarshal(raw, &m); err != nil { |
| + return fmt.Errorf("%w: %s: %v", park.ErrUnsafe, key, err) |
| + } |
| + if m.Op == "" || m.Host != host || m.Incarnation != h.Incarnation { |
| + return fmt.Errorf("%w: %s names %s/%s generation %q, not the holder's", park.ErrUnsafe, key, m.Host, m.Incarnation, m.Op) |
| + } |
| + v.MarkerKnown, v.MarkerGeneration, v.Marker = true, m.Op, park.Ref{Key: key, VersionID: latest.VersionID} |
| + if m.SnapshotKey != "" || m.SnapshotVersion != "" { |
| + root := park.Ref{Key: m.SnapshotKey, VersionID: m.SnapshotVersion} |
| + if root.Key == "" || root.VersionID == "" { |
| + return fmt.Errorf("%w: %s pins %q at %q", park.ErrUnsafe, key, root.Key, root.VersionID) |
| + } |
| + if _, ok := v.GenerationOf(root); !ok { |
| + return fmt.Errorf("%w: %s pins %s outside %s", park.ErrUnsafe, key, root.Key, location) |
| + } |
| + v.Pinned = map[park.Ref]bool{root: true} |
| + } |
| + return nil |
| +} |
| + |
| +// movesAuthoritative is the spec's authoritative-region table (§5.1, claim |
| +// rev 6.5 §6.2a, R4-3): the region of the holder's current installation. |
| +// |
| +// - no holder, creating, deleting, parking, purging: none (nothing live); |
| +// - serving, pushing: op.exec's region, which is the region of the newest |
| +// admission into holder.generation, or, for a holder with no such |
| +// admission (a legacy installation), the record's region; |
| +// - moving (before the flip), reverting: the source's, move.from; |
| +// - landing: the source's until the target's probe is admitted into |
| +// holder.generation in move.to, the target's from then on, committed or |
| +// not; a committed landing with no such admission refuses; |
| +// - restoring: the region of the newest admission into holder.generation, |
| +// the source's or the target's (a clean return's CAS names the source's |
| +// generation again, a dirty return keeps the target's until the source's |
| +// probe is admitted); no admission is the source's pre-move generation. |
| +// |
| +// For a move kind the record must carry the op's move, or have closed it |
| +// (the record-first close, M4), when the newest admission decides alone. A |
| +// region the claim's history contradicts refuses. So does any other kind |
| +// (placing, relocating): it has no row in the table. |
| +func movesAuthoritative(ctx context.Context, records MovesRecordReader, obs hostclaim.Observed) (string, error) { |
| + c := obs.Claim |
| + h := c.Holder |
| + if h == nil { |
| + return movesNoLiveRegion, nil |
| + } |
| + switch c.Op.Kind { |
| + case hostclaim.KindCreating, hostclaim.KindDeleting, hostclaim.KindParking, hostclaim.KindPurging: |
| + return movesNoLiveRegion, nil |
| + } |
| + admitted, known := admittedRegion(*h) |
| + switch c.Op.Kind { |
| + case hostclaim.KindServing, hostclaim.KindPushing: |
| + if known { |
| + return admitted, nil |
| + } |
| + rec, err := holderRecord(ctx, records, obs) |
| + if err != nil { |
| + return "", err |
| + } |
| + if rec.Region == "" { |
| + return "", fmt.Errorf("%w: %s's record names no region and the claim no admission", park.ErrUnsafe, obs.Host) |
| + } |
| + return rec.Region, nil |
| + case hostclaim.KindMoving, hostclaim.KindReverting, hostclaim.KindLanding, hostclaim.KindRestoring: |
| + default: |
| + return "", fmt.Errorf("%w: the authoritative-region table has no row for a %s op", park.ErrUnsafe, c.Op.Kind) |
| + } |
| + rec, err := holderRecord(ctx, records, obs) |
| + if err != nil { |
| + return "", err |
| + } |
| + m := rec.Move |
| + 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 |
| + } |
| + return "", fmt.Errorf("%w: the record does not carry move %s", park.ErrUnsafe, c.Op.ID) |
| + } |
| + contradicts := func(want ...string) error { |
| + if !known { |
| + return nil |
| + } |
| + for _, w := range want { |
| + if admitted == w { |
| + return nil |
| + } |
| + } |
| + return fmt.Errorf("%w: holder.generation was admitted in %s, which a %s op of %s cannot have", park.ErrUnsafe, admitted, c.Op.Kind, m.ID) |
| + } |
| + switch c.Op.Kind { |
| + case hostclaim.KindMoving, hostclaim.KindReverting: |
| + if err := contradicts(m.From); err != nil { |
| + return "", err |
| + } |
| + return m.From, nil |
| + case hostclaim.KindLanding: |
| + if err := contradicts(m.From, m.To); err != nil { |
| + return "", err |
| + } |
| + if known && admitted == m.To { |
| + return m.To, nil |
| + } |
| + if c.Op.Phase == hostclaim.PhaseCommitted || m.State == provision.MoveCommitted { |
| + return "", fmt.Errorf("%w: move %s committed with no admission in %s", park.ErrUnsafe, m.ID, m.To) |
| + } |
| + return m.From, nil |
| + default: // restoring |
| + if err := contradicts(m.From, m.To); err != nil { |
| + return "", err |
| + } |
| + if known { |
| + return admitted, nil |
| + } |
| + return m.From, nil |
| + } |
| +} |
| + |
| +// admittedRegion is the region of the newest admission into |
| +// holder.generation, the origin included. |
| +func admittedRegion(h hostclaim.Holder) (string, bool) { |
| + want := h.Generation |
| + if want == "" { |
| + want = hostclaim.RootGeneration |
| + } |
| + for i := len(h.Admissions) - 1; i >= 0; i-- { |
| + g := h.Admissions[i].Generation |
| + if g == "" { |
| + g = hostclaim.RootGeneration |
| + } |
| + if g == want { |
| + r := h.Admissions[i].Region |
| + return r, r != "" |
| + } |
| + } |
| + return "", false |
| +} |
| + |
| +// holderRecord reads the holder's own record: an instance holder's key, the |
| +// same account, app and host. |
| +func holderRecord(ctx context.Context, records MovesRecordReader, obs hostclaim.Observed) (*provision.Record, error) { |
| + h := obs.Claim.Holder |
| + if h.Family != "instance" || h.Key != provision.RecordKey(h.Account, h.App, obs.Host) { |
| + return nil, fmt.Errorf("%w: the holder of %s is not an instance record", park.ErrUnsafe, obs.Host) |
| + } |
| + rec, err := records(ctx, h.Account, h.App, obs.Host) |
| + if err != nil { |
| + return nil, fmt.Errorf("read %s's record: %w", obs.Host, err) |
| + } |
| + if rec == nil || rec.Account != h.Account || rec.App != h.App || rec.Host != obs.Host { |
| + return nil, fmt.Errorf("%w: %s's record is not the holder's", park.ErrUnsafe, obs.Host) |
| + } |
| + return rec, nil |
| +} |
| + |
| +// movesLeaseSet is the lease set for the scopes moves parks (M6, platform-1e's |
| +// answer): the scope's own lock, and nothing else, for each of |
| +// |
| +// - moves/<host> and moves/<host>/<op>: the staging writers' bound is the |
| +// scope's evidence_at (seal, abort, revert or return seal bound), which |
| +// every staging signature already respects (moves/lease.go), so the |
| +// staging lease in the deployment bucket is not named; |
| +// - instances/<host> and instances/<host>/<incarnation>: the location's |
| +// replica lease is the lock at the location itself, the writer's lease |
| +// (lease.Acquire on the holder location), so the scope's own lock is it. |
| +// |
| +// Only this box's own data bucket is answered; any other scope refuses. |
| +func movesLeaseSet(bucket string) hostbox.LeaseSet { |
| + return func(ctx context.Context, b, scope string) ([]string, error) { |
| + if ctx == nil || bucket == "" || b != bucket { |
| + return nil, hostbox.ErrChanged |
| + } |
| + if !movesLeaseScope(scope) { |
| + return nil, fmt.Errorf("%w: %q is not a scope moves names a lease set for", park.ErrUnsafe, scope) |
| + } |
| + return []string{scope}, nil |
| + } |
| +} |
| + |
| +// movesLeaseScope reports whether scope is one movesLeaseSet answers. |
| +func movesLeaseScope(scope string) bool { |
| + parts := strings.Split(scope, "/") |
| + if len(parts) < 2 || len(parts) > 3 { |
| + return false |
| + } |
| + if _, err := hostclaim.Key(parts[1]); err != nil { |
| + return false |
| + } |
| + switch parts[0] { |
| + case "instances": |
| + if len(parts) == 2 { |
| + return true |
| + } |
| + return len(parts[2]) == 64 && isHexString(parts[2]) && strings.ToLower(parts[2]) == parts[2] |
| + case "moves": |
| + return len(parts) == 2 || moves.ValidOpID(parts[2]) |
| + } |
| + return false |
| +} |
| + |
| +// movesParkSources is what registerMovesParks registers: the view and the |
| +// lease set behind the claim Plan 12 guard, and Task 5's evidence. |
| +type movesParkSources struct { |
| + View func(context.Context, hostclaim.Observed) (park.View, error) |
| + Evidence hostbox.RegionalEvidence |
| + Leases hostbox.LeaseSet |
| +} |
| + |
| +// 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 { |
| + return movesParkSources{}, hostwrite.ErrUnavailable |
| + } |
| + view, leases := movesParkView(records, data, bucket, region), movesLeaseSet(bucket) |
| + return movesParkSources{ |
| + View: func(ctx context.Context, obs hostclaim.Observed) (park.View, error) { |
| + if _, err := plan12(); err != nil { |
| + return park.View{}, err |
| + } |
| + return view(ctx, obs) |
| + }, |
| + Evidence: evidence, |
| + Leases: func(ctx context.Context, b, scope string) ([]string, error) { |
| + if _, err := plan12(); err != nil { |
| + return nil, err |
| + } |
| + return leases(ctx, b, scope) |
| + }, |
| + }, nil |
| +} |
| + |
| +// registerMovesParks registers the moves park providers with the claim |
| +// (RegisterProductionClaimMoveParks), at agent start before newClaimWiring. |
| +func registerMovesParks(src movesParkSources) error { |
| + return RegisterProductionClaimMoveParks(src.View, src.Evidence, src.Leases) |
| +} |
| + |
| +// armMovesParks sets the moves arms' park hooks from the claim's MoveParks |
| +// capability (M17): its engine's leases, view and authorizer, only when the |
| +// engine's bucket and region are this box's own bucket and placement |
| +// region, and claim Plan 12's RunHeld and local authorizer (M5, M9). Any |
| +// refusal leaves every hook unset, so every arm stays unarmed. |
| +func armMovesParks(ctx context.Context, cfg *moveCfg, providers ClaimProviders, plan12 func() (movesClaimPlan12, error)) error { |
| + if ctx == nil || cfg == nil || plan12 == nil { |
| + return hostwrite.ErrUnavailable |
| + } |
| + engine, _, err := providers.MoveParks(ctx) |
| + if err != nil { |
| + return fmt.Errorf("the claim's MoveParks: %w", err) |
| + } |
| + if engine.Bucket != cfg.OwnBucket || engine.Region != cfg.Region { |
| + return fmt.Errorf("%w: the claim's park engine is %s in %q, this box's moves use %s in %q", hostbox.ErrChanged, engine.Bucket, engine.Region, cfg.OwnBucket, cfg.Region) |
| + } |
| + if engine.Leases == nil || engine.View == nil || engine.Authorize == nil { |
| + return errors.Join(hostwrite.ErrUnavailable, errors.New("the claim's park engine lacks its leases, view or authorizer")) |
| + } |
| + p12, err := plan12() |
| + if err != nil { |
| + return err |
| + } |
| + if p12 == nil { |
| + return &ClaimDependencyError{Contracts: movesPlan12Contracts} |
| + } |
| + cfg.ParkLeases, cfg.ParkView, cfg.ParkAuthorize = engine.Leases, engine.View, engine.Authorize |
| + cfg.ParkRunHeld, cfg.ParkLocalAuthorize = p12.RunHeld, p12.AuthorizeLocal |
| + return nil |
| +} |
| + |
| +// 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. |
| +func registerProductionMovesParks(ctx context.Context, getenv func(string) string, store release.Store, hib activator.Config) 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 == "" { |
| + return errors.Join(hostwrite.ErrUnavailable, errors.New("the moves park view needs the deployment store, the data bucket and CARLOS_REGION")) |
| + } |
| + data, err := lease.NewVersionedS3(ctx, map[string]string{hib.Bucket: hib.Region}) |
| + if err != nil { |
| + return err |
| + } |
| + records := MovesRecordsFrom(store) |
| + src, err := newMovesParkSources(records, data, hib.Bucket, region, movesRetirementEvidence{Records: records, Now: time.Now}, movesPlan12Parks) |
| + if err != nil { |
| + return err |
| + } |
| + return registerMovesParks(src) |
| +} |
diff --git a/internal/host/movessources_test.go b/internal/host/movessources_test.go| new file mode 100644 |
| index 000000000..59d4c139a |
| --- /dev/null |
| +++ b/internal/host/movessources_test.go |
| @@ -0,0 +1,549 @@ |
| +package host |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "errors" |
| + "strings" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/activator" |
| + "github.com/carlosframework/platform/internal/hostbox" |
| + "github.com/carlosframework/platform/internal/hostclaim" |
| + "github.com/carlosframework/platform/internal/hostwrite" |
| + "github.com/carlosframework/platform/internal/lease" |
| + "github.com/carlosframework/platform/internal/lease/leasetest" |
| + "github.com/carlosframework/platform/internal/moves" |
| + "github.com/carlosframework/platform/internal/park" |
| + "github.com/carlosframework/platform/internal/provision" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +// The moves park providers (Plan 5 Task 9a): the view, the lease set, the |
| +// claim Plan 12 guard, the registration and the arming. |
| + |
| +const ( |
| + vwHost = "vw.example" |
| + vwAcct = "acct" |
| + vwApp = "app" |
| + vwMove = "mv_vwvwvwvwvwvwvwvwvwvwvwvwvw" |
| + vwRb = "rb_vwvwvwvwvwvwvwvwvwvwvwvwvw" |
| +) |
| + |
| +var vwCreated = time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC) |
| + |
| +// vwFixture is one host's claim observation, its record and its data |
| +// bucket, for the view. |
| +type vwFixture struct { |
| + inc, location string |
| + rec *provision.Record |
| + bucket *leasetest.VersionedBucket |
| + reads, lists int |
| +} |
| + |
| +func newVwFixture(t *testing.T) *vwFixture { |
| + t.Helper() |
| + inc, err := hostclaim.Incarnation(vwAcct, vwApp, vwHost, vwCreated) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + f := &vwFixture{inc: inc, location: "instances/" + vwHost + "/" + inc, bucket: leasetest.NewVersionedBucket()} |
| + f.bucket.AddBucket("data") |
| + f.rec = &provision.Record{Schema: 1, Host: vwHost, App: vwApp, Account: vwAcct, CreatedAt: vwCreated, Region: "ie"} |
| + return f |
| +} |
| + |
| +func (f *vwFixture) records() MovesRecordReader { |
| + return func(_ context.Context, acct, app, host string) (*provision.Record, error) { |
| + f.reads++ |
| + if acct != vwAcct || app != vwApp || host != vwHost || f.rec == nil { |
| + return nil, errors.New("no such record") |
| + } |
| + cp := *f.rec |
| + return &cp, nil |
| + } |
| +} |
| + |
| +// countingReader counts the view's listings, to show a non-authoritative |
| +// region reads nothing. |
| +type countingReader struct { |
| + *leasetest.VersionedBucket |
| + f *vwFixture |
| +} |
| + |
| +func (r countingReader) ListVersions(ctx context.Context, bucket, prefix string) ([]lease.ObjectVersion, error) { |
| + r.f.lists++ |
| + return r.VersionedBucket.ListVersions(ctx, bucket, prefix) |
| +} |
| + |
| +func (f *vwFixture) view(region string) func(context.Context, hostclaim.Observed) (park.View, error) { |
| + return movesParkView(f.records(), countingReader{f.bucket, f}, "data", region) |
| +} |
| + |
| +// holder is the fixture's holder at generation gen with admissions, each |
| +// "generation@region". |
| +func (f *vwFixture) holder(gen string, admissions ...string) *hostclaim.Holder { |
| + h := &hostclaim.Holder{Family: "instance", Key: provision.RecordKey(vwAcct, vwApp, vwHost), Account: vwAcct, App: vwApp, |
| + Incarnation: f.inc, CreatedAt: vwCreated, Location: f.location, Generation: gen, Stamp: 3, Session: 3} |
| + for i, a := range admissions { |
| + g, r, _ := strings.Cut(a, "@") |
| + h.Admissions = append(h.Admissions, hostclaim.AdmissionRecord{Session: uint64(i + 1), Box: r + "-box", Generation: g, Mode: "cold", Region: r}) |
| + } |
| + return h |
| +} |
| + |
| +func vwObs(c *hostclaim.Claim) hostclaim.Observed { |
| + c.Host = vwHost |
| + return hostclaim.Observed{Host: vwHost, ETag: "etag-1", Claim: c} |
| +} |
| + |
| +func (f *vwFixture) move(state string) { |
| + f.rec.Move = &provision.MoveState{ID: vwMove, Incarnation: f.inc, From: "ie", To: "uk", State: state} |
| +} |
| + |
| +// marker writes generation.json at the location naming gen, pinning a |
| +// snapshot of it, and returns the marker's version and the snapshot's. |
| +func (f *vwFixture) marker(t *testing.T, gen, inc string) (markerVersion, snapshot string) { |
| + t.Helper() |
| + snapKey := f.location + "/g-" + gen + "/0009/0000000000000001-0000000000000005.ltx" |
| + snapshot = f.bucket.Put("data", snapKey, []byte("snapshot")) |
| + body, err := json.Marshal(map[string]any{"op": gen, "incarnation": inc, "host": vwHost, "box": "uk-box", "installed_at": vwCreated, |
| + "manifest_sha256": "x", "snapshot_key": snapKey, "snapshot_version": snapshot, "snapshot_txid": 5, "snapshot_crc64": 1}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return f.bucket.Put("data", f.location+"/generation.json", body), snapshot |
| +} |
| + |
| +// TestMoveParkViewFollowsTheAuthoritativeRegionTable: one row per claim |
| +// state of the spec's table (§5.1), seen from a region that is neither, so |
| +// only the authoritative region is asked of the view, plus the refusals. |
| +func TestMoveParkViewFollowsTheAuthoritativeRegionTable(t *testing.T) { |
| + type row struct { |
| + name string |
| + claim func(f *vwFixture) *hostclaim.Claim |
| + rec func(f *vwFixture) |
| + want string // "" wants a refusal |
| + } |
| + op := func(kind, phase string) hostclaim.Op { |
| + return hostclaim.Op{Kind: kind, ID: vwMove, Phase: phase, Exec: "box"} |
| + } |
| + rows := []row{ |
| + {"free", func(f *vwFixture) *hostclaim.Claim { return &hostclaim.Claim{} }, nil, movesNoLiveRegion}, |
| + {"creating", func(f *vwFixture) *hostclaim.Claim { |
| + return &hostclaim.Claim{Holder: f.holder(""), Op: hostclaim.Op{Kind: hostclaim.KindCreating, ID: "op"}} |
| + }, nil, movesNoLiveRegion}, |
| + {"deleting", func(f *vwFixture) *hostclaim.Claim { |
| + return &hostclaim.Claim{Holder: f.holder("cur", "cur@ie"), Op: hostclaim.Op{Kind: hostclaim.KindDeleting, ID: "op"}} |
| + }, nil, movesNoLiveRegion}, |
| + {"parking", func(f *vwFixture) *hostclaim.Claim { |
| + return &hostclaim.Claim{Holder: f.holder("cur", "cur@ie"), Op: hostclaim.Op{Kind: hostclaim.KindParking, ID: "op"}} |
| + }, nil, movesNoLiveRegion}, |
| + {"purging", func(f *vwFixture) *hostclaim.Claim { |
| + return &hostclaim.Claim{Holder: f.holder("cur", "cur@ie"), Op: hostclaim.Op{Kind: hostclaim.KindPurging, ID: "op"}} |
| + }, nil, movesNoLiveRegion}, |
| + {"serving: the admission's region", func(f *vwFixture) *hostclaim.Claim { |
| + return &hostclaim.Claim{Holder: f.holder("cur", "old@ie", "cur@uk"), Op: hostclaim.Op{Kind: hostclaim.KindServing, Exec: "uk-box"}} |
| + }, nil, "uk"}, |
| + {"serving: a legacy holder takes the record's region", func(f *vwFixture) *hostclaim.Claim { |
| + return &hostclaim.Claim{Holder: f.holder(""), Op: hostclaim.Op{Kind: hostclaim.KindServing, Exec: "box"}} |
| + }, nil, "ie"}, |
| + {"serving: no admission and no record region", func(f *vwFixture) *hostclaim.Claim { |
| + return &hostclaim.Claim{Holder: f.holder(""), Op: hostclaim.Op{Kind: hostclaim.KindServing, Exec: "box"}} |
| + }, func(f *vwFixture) { f.rec.Region = "" }, ""}, |
| + {"pushing", func(f *vwFixture) *hostclaim.Claim { |
| + return &hostclaim.Claim{Holder: f.holder("cur", "cur@uk"), Op: hostclaim.Op{Kind: hostclaim.KindPushing, ID: "push"}} |
| + }, nil, "uk"}, |
| + {"moving before the flip", func(f *vwFixture) *hostclaim.Claim { |
| + return &hostclaim.Claim{Holder: f.holder("cur", "cur@ie"), Op: op(hostclaim.KindMoving, hostclaim.PhaseMoving)} |
| + }, func(f *vwFixture) { f.move(provision.MoveCutting) }, "ie"}, |
| + {"reverting", func(f *vwFixture) *hostclaim.Claim { |
| + return &hostclaim.Claim{Holder: f.holder("cur", "cur@ie"), Op: op(hostclaim.KindReverting, "")} |
| + }, func(f *vwFixture) { f.move(provision.MoveReverting) }, "ie"}, |
| + {"landing before the target's probe is admitted", func(f *vwFixture) *hostclaim.Claim { |
| + return &hostclaim.Claim{Holder: f.holder("cur", "cur@ie"), Op: op(hostclaim.KindLanding, provision.LandBuilding)} |
| + }, func(f *vwFixture) { f.move(provision.MoveLanding) }, "ie"}, |
| + {"landing from the target's probe's admission", func(f *vwFixture) *hostclaim.Claim { |
| + return &hostclaim.Claim{Holder: f.holder(vwMove, "cur@ie", vwMove+"@uk"), Op: op(hostclaim.KindLanding, provision.LandProbing)} |
| + }, func(f *vwFixture) { f.move(provision.MoveLanding) }, "uk"}, |
| + {"landing committed", func(f *vwFixture) *hostclaim.Claim { |
| + return &hostclaim.Claim{Holder: f.holder("fresh2", "cur@ie", vwMove+"@uk", "fresh2@uk"), Op: op(hostclaim.KindLanding, hostclaim.PhaseCommitted)} |
| + }, func(f *vwFixture) { f.move(provision.MoveCommitted) }, "uk"}, |
| + {"landing committed with no admission in the target", func(f *vwFixture) *hostclaim.Claim { |
| + return &hostclaim.Claim{Holder: f.holder("cur", "cur@ie"), Op: op(hostclaim.KindLanding, hostclaim.PhaseCommitted)} |
| + }, func(f *vwFixture) { f.move(provision.MoveCommitted) }, ""}, |
| + {"landing closed record first (M4)", func(f *vwFixture) *hostclaim.Claim { |
| + return &hostclaim.Claim{Holder: f.holder(vwMove, "cur@ie", vwMove+"@uk"), Op: op(hostclaim.KindLanding, hostclaim.PhaseCommitted)} |
| + }, func(f *vwFixture) { |
| + f.rec.LastMove = &provision.MoveOutcome{ID: vwMove, Outcome: "done", At: vwCreated} |
| + }, "uk"}, |
| + {"restoring, clean, from the return CAS", func(f *vwFixture) *hostclaim.Claim { |
| + return &hostclaim.Claim{Holder: f.holder("cur", "cur@ie", vwMove+"@uk"), Op: op(hostclaim.KindRestoring, hostclaim.PhaseClaimed)} |
| + }, func(f *vwFixture) { f.move(provision.MoveRollingBack) }, "ie"}, |
| + {"restoring, dirty, before the source's probe", func(f *vwFixture) *hostclaim.Claim { |
| + return &hostclaim.Claim{Holder: f.holder(vwMove, "cur@ie", vwMove+"@uk"), Op: op(hostclaim.KindRestoring, hostclaim.PhaseClaimed)} |
| + }, func(f *vwFixture) { f.move(provision.MoveRollingBack) }, "uk"}, |
| + {"restoring, dirty, from the source's probe's admission", func(f *vwFixture) *hostclaim.Claim { |
| + return &hostclaim.Claim{Holder: f.holder(vwRb, "cur@ie", vwMove+"@uk", vwRb+"@ie"), Op: op(hostclaim.KindRestoring, hostclaim.PhaseProbing)} |
| + }, func(f *vwFixture) { f.move(provision.MoveRollingBack) }, "ie"}, |
| + {"restoring before any admission of the generation", func(f *vwFixture) *hostclaim.Claim { |
| + return &hostclaim.Claim{Holder: f.holder(""), Op: op(hostclaim.KindRestoring, hostclaim.PhaseReleasing)} |
| + }, func(f *vwFixture) { f.move(provision.MoveRollingBack) }, "ie"}, |
| + {"moving with the generation admitted in the target", func(f *vwFixture) *hostclaim.Claim { |
| + return &hostclaim.Claim{Holder: f.holder("cur", "cur@uk"), Op: op(hostclaim.KindMoving, hostclaim.PhaseMoving)} |
| + }, func(f *vwFixture) { f.move(provision.MoveCutting) }, ""}, |
| + {"a move op the record does not carry", func(f *vwFixture) *hostclaim.Claim { |
| + return &hostclaim.Claim{Holder: f.holder("cur", "cur@ie"), Op: op(hostclaim.KindLanding, provision.LandBuilding)} |
| + }, nil, ""}, |
| + {"placing", func(f *vwFixture) *hostclaim.Claim { |
| + return &hostclaim.Claim{Holder: f.holder("cur", "cur@ie"), Op: hostclaim.Op{Kind: hostclaim.KindPlacing, ID: "op"}} |
| + }, nil, ""}, |
| + {"relocating", func(f *vwFixture) *hostclaim.Claim { |
| + return &hostclaim.Claim{Holder: f.holder("cur", "cur@ie"), Op: hostclaim.Op{Kind: "relocating", ID: "op"}} |
| + }, nil, ""}, |
| + } |
| + for _, r := range rows { |
| + t.Run(r.name, func(t *testing.T) { |
| + f := newVwFixture(t) |
| + if r.rec != nil { |
| + r.rec(f) |
| + } |
| + obs := vwObs(r.claim(f)) |
| + v, err := f.view("us")(t.Context(), obs) |
| + if r.want == "" { |
| + if err == nil || !errors.Is(err, park.ErrUnsafe) { |
| + t.Fatalf("view %+v, %v; want a refusal", v, err) |
| + } |
| + return |
| + } |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if v.AuthoritativeRegion != r.want || v.Region != "us" || v.ClaimETag != obs.ETag { |
| + t.Fatalf("view %+v; want %s authoritative, seen from us", v, r.want) |
| + } |
| + if v.MarkerKnown || f.lists != 0 { |
| + t.Fatalf("a non-authoritative region read the marker (%d listings)", f.lists) |
| + } |
| + if obs.Claim.Holder != nil && v.GenerationOf == nil { |
| + t.Fatal("no classifier for the holder's location") |
| + } |
| + }) |
| + } |
| +} |
| + |
| +// TestMoveParkViewReadsTheMarkerByVersion: in the authoritative region the |
| +// view reads generation.json by the version the listing names and pins the |
| +// snapshot it names (the installed root); absent is the origin; a marker of |
| +// another incarnation refuses. |
| +func TestMoveParkViewReadsTheMarkerByVersion(t *testing.T) { |
| + serving := func(f *vwFixture) hostclaim.Observed { |
| + return vwObs(&hostclaim.Claim{Holder: f.holder("cur", "cur@uk"), Op: hostclaim.Op{Kind: hostclaim.KindServing, Exec: "uk-box"}}) |
| + } |
| + t.Run("present", func(t *testing.T) { |
| + f := newVwFixture(t) |
| + mv, snap := f.marker(t, "cur", f.inc) |
| + v, err := f.view("uk")(t.Context(), serving(f)) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + root := park.Ref{Key: f.location + "/g-cur/0009/0000000000000001-0000000000000005.ltx", VersionID: snap} |
| + if !v.MarkerKnown || v.MarkerGeneration != "cur" || v.Marker != (park.Ref{Key: f.location + "/generation.json", VersionID: mv}) || !v.Pinned[root] || len(v.Pinned) != 1 { |
| + t.Fatalf("view %+v", v) |
| + } |
| + if g, ok := v.GenerationOf(root); !ok || g != "cur" { |
| + t.Fatalf("the root classifies as %q %v", g, ok) |
| + } |
| + }) |
| + t.Run("absent is the origin", func(t *testing.T) { |
| + f := newVwFixture(t) |
| + v, err := f.view("uk")(t.Context(), serving(f)) |
| + if err != nil || !v.MarkerKnown || v.MarkerGeneration != "" || v.Marker != (park.Ref{}) || len(v.Pinned) != 0 { |
| + t.Fatalf("view %+v, %v", v, err) |
| + } |
| + }) |
| + t.Run("another incarnation's marker", func(t *testing.T) { |
| + f := newVwFixture(t) |
| + f.marker(t, "cur", strings.Repeat("e", 64)) |
| + if v, err := f.view("uk")(t.Context(), serving(f)); !errors.Is(err, park.ErrUnsafe) { |
| + t.Fatalf("view %+v, %v", v, err) |
| + } |
| + }) |
| + t.Run("a deleted marker", func(t *testing.T) { |
| + f := newVwFixture(t) |
| + f.marker(t, "cur", f.inc) |
| + f.bucket.BareDelete("data", f.location+"/generation.json") |
| + if v, err := f.view("uk")(t.Context(), serving(f)); !errors.Is(err, park.ErrUnsafe) { |
| + t.Fatalf("view %+v, %v", v, err) |
| + } |
| + }) |
| +} |
| + |
| +// TestMovesLeaseSetCoversTheScope (M6): the scope's own lock, for the four |
| +// scope shapes moves parks, in this box's own bucket only; acquired through |
| +// the real AggregateLeases it takes exactly that lock. |
| +func TestMovesLeaseSetCoversTheScope(t *testing.T) { |
| + inc := strings.Repeat("a", 64) |
| + set := movesLeaseSet("data-ie") |
| + for _, scope := range []string{"instances/" + vwHost, "instances/" + vwHost + "/" + inc, "moves/" + vwHost, "moves/" + vwHost + "/" + vwMove} { |
| + names, err := set(t.Context(), "data-ie", scope) |
| + if err != nil || len(names) != 1 || names[0] != scope { |
| + t.Fatalf("%s: %v %v", scope, names, err) |
| + } |
| + leases := &fakeParkLeases{} |
| + held, err := hostbox.AggregateLeases{Leases: leases, Resolve: set}.Acquire(t.Context(), "data-ie", scope) |
| + if err != nil { |
| + t.Fatalf("%s: %v", scope, err) |
| + } |
| + if got := leases.holding(); len(got) != 1 || got[0] != "data-ie/"+scope { |
| + t.Fatalf("%s holds %v", scope, got) |
| + } |
| + held.Close() |
| + } |
| + if _, err := set(t.Context(), "data-uk", "instances/"+vwHost); !errors.Is(err, hostbox.ErrChanged) { |
| + t.Fatalf("another bucket: %v", err) |
| + } |
| + for _, scope := range []string{"", "instances", "instances/" + vwHost + "/g-cur", "instances/" + vwHost + "/" + strings.ToUpper(inc), "moves/" + vwHost + "/x/y", |
| + "moves/" + vwHost + "/not an op", "parked/" + vwHost, "instances/../" + vwHost} { |
| + if names, err := set(t.Context(), "data-ie", scope); !errors.Is(err, park.ErrUnsafe) { |
| + t.Fatalf("%q answered %v %v", scope, names, err) |
| + } |
| + } |
| +} |
| + |
| +// fakePlan12 stands in for claim Plan 12's pieces in the arming tests. |
| +type fakePlan12 struct{ runs, locals int } |
| + |
| +func (p *fakePlan12) RunHeld(ctx context.Context, e park.Engine, w park.Work, held park.HeldLease) error { |
| + p.runs++ |
| + return testRunHeld(ctx, e, w, held) |
| +} |
| +func (p *fakePlan12) AuthorizeLocal(context.Context, *hostclaim.Claim, hostclaim.Obligation) error { |
| + p.locals++ |
| + return nil |
| +} |
| + |
| +// TestArmMovesPassRefusesAMismatchedBucketOrRegion (M17): the arms take the |
| +// claim's engine only when its bucket and region are this box's own, and |
| +// only with claim Plan 12's pieces; any refusal sets none of the hooks. |
| +func TestArmMovesPassRefusesAMismatchedBucketOrRegion(t *testing.T) { |
| + view := func(context.Context, hostclaim.Observed) (park.View, error) { return park.View{}, nil } |
| + auth := func(context.Context, hostclaim.Observed, hostclaim.Obligation) error { return nil } |
| + providers := func(bucket, region string) ClaimProviders { |
| + return ClaimProviders{moveParks: &claimMoveParks{ |
| + engine: park.Engine{Bucket: bucket, Region: region, Leases: &fakeParkLeases{}, View: view, Authorize: auth}, |
| + leases: movesLeaseSet(bucket), check: func(context.Context) error { return nil }}} |
| + } |
| + plan12 := &fakePlan12{} |
| + ok := func() (movesClaimPlan12, error) { return plan12, nil } |
| + unarmed := func(t *testing.T, c moveCfg) { |
| + t.Helper() |
| + if c.ParkLeases != nil || c.ParkView != nil || c.ParkAuthorize != nil || c.ParkRunHeld != nil || c.ParkLocalAuthorize != nil { |
| + t.Fatal("a refused arming set a hook") |
| + } |
| + } |
| + for name, p := range map[string]ClaimProviders{"another bucket": providers("data-uk", "ie"), "another region": providers("data-ie", "uk")} { |
| + t.Run(name, func(t *testing.T) { |
| + c := moveCfg{OwnBucket: "data-ie", Region: "ie"} |
| + if err := armMovesParks(t.Context(), &c, p, ok); !errors.Is(err, hostbox.ErrChanged) { |
| + t.Fatalf("armed: %v", err) |
| + } |
| + unarmed(t, c) |
| + }) |
| + } |
| + t.Run("no MoveParks", func(t *testing.T) { |
| + c := moveCfg{OwnBucket: "data-ie", Region: "ie"} |
| + if err := armMovesParks(t.Context(), &c, ClaimProviders{}, ok); !errors.Is(err, hostwrite.ErrUnavailable) { |
| + t.Fatalf("armed: %v", err) |
| + } |
| + unarmed(t, c) |
| + }) |
| + t.Run("without claim Plan 12", func(t *testing.T) { |
| + c := moveCfg{OwnBucket: "data-ie", Region: "ie"} |
| + err := armMovesParks(t.Context(), &c, providers("data-ie", "ie"), movesPlan12Parks) |
| + var dep *ClaimDependencyError |
| + if !errors.As(err, &dep) { |
| + t.Fatalf("armed: %v", err) |
| + } |
| + for _, piece := range []string{"RunHeld", "lease-set contract", "local authorizer"} { |
| + if !strings.Contains(err.Error(), piece) || !strings.Contains(err.Error(), "claim Plan 12") { |
| + t.Fatalf("the refusal does not name %s: %v", piece, err) |
| + } |
| + } |
| + unarmed(t, c) |
| + }) |
| + t.Run("this box's own engine", func(t *testing.T) { |
| + c := moveCfg{OwnBucket: "data-ie", Region: "ie"} |
| + if err := armMovesParks(t.Context(), &c, providers("data-ie", "ie"), ok); err != nil { |
| + t.Fatal(err) |
| + } |
| + if c.ParkLeases == nil || c.ParkView == nil || c.ParkAuthorize == nil || c.ParkRunHeld == nil || c.ParkLocalAuthorize == nil { |
| + t.Fatalf("hooks unset: %+v", c) |
| + } |
| + if err := c.ParkLocalAuthorize(t.Context(), nil, hostclaim.Obligation{}); err != nil || plan12.locals != 1 { |
| + t.Fatal("the local authorizer is not claim Plan 12's") |
| + } |
| + }) |
| +} |
| + |
| +// TestMovesParksRefuseUntilClaimPlan12: the registered view and lease set |
| +// refuse by name while claim Plan 12 is missing, so the claim's regional |
| +// worker queues and runs nothing through them; Task 5's evidence is |
| +// registered as it is, reachable as the sweep verifier. |
| +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) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + obs := vwObs(&hostclaim.Claim{Holder: f.holder("cur", "cur@ie"), Op: hostclaim.Op{Kind: hostclaim.KindServing, Exec: "box"}}) |
| + var dep *ClaimDependencyError |
| + if _, err := src.View(t.Context(), obs); !errors.As(err, &dep) || !strings.Contains(err.Error(), "claim Plan 12") { |
| + t.Fatalf("the view answered without claim Plan 12: %v", err) |
| + } |
| + if _, err := src.Leases(t.Context(), "data", "instances/"+vwHost); !errors.As(err, &dep) || !strings.Contains(err.Error(), "claim Plan 12") { |
| + t.Fatalf("the lease set answered without claim Plan 12: %v", err) |
| + } |
| + if f.reads != 0 || f.lists != 0 { |
| + t.Fatal("a refused view read the record or the bucket") |
| + } |
| + if _, ok := src.Evidence.(park.MoveSweepVerifier); !ok { |
| + 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 }) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if v, err := src.View(t.Context(), obs); err != nil || v.AuthoritativeRegion != "ie" { |
| + t.Fatalf("view %+v, %v", v, err) |
| + } |
| + if names, err := src.Leases(t.Context(), "data", "instances/"+vwHost); err != nil || len(names) != 1 { |
| + t.Fatalf("leases %v, %v", names, err) |
| + } |
| + // Registration is the claim's one-shot slot. |
| + claimMoveSources.Lock() |
| + oldSource, oldParks := claimMoveSources.Source, claimMoveSources.Parks |
| + claimMoveSources.Source, claimMoveSources.Parks = nil, nil |
| + claimMoveSources.Unlock() |
| + t.Cleanup(func() { |
| + claimMoveSources.Lock() |
| + claimMoveSources.Source, claimMoveSources.Parks = oldSource, oldParks |
| + claimMoveSources.Unlock() |
| + }) |
| + if err := registerMovesParks(src); err != nil { |
| + t.Fatal(err) |
| + } |
| + if p := claimMoveSources.Parks; p == nil || p.View == nil || p.Leases == nil || p.Evidence == nil { |
| + t.Fatalf("registered %+v", p) |
| + } |
| + if err := registerMovesParks(src); !errors.Is(err, hostwrite.ErrUnavailable) { |
| + t.Fatalf("a second registration: %v", err) |
| + } |
| +} |
| + |
| +// testRunHeld is the agreed shape of claim Plan 12's Engine.RunHeld (M5): |
| +// the chore runs under the lease the caller holds, which must be the |
| +// scope's own lock (the lease set, M6); it is neither acquired again, nor |
| +// released, nor closed by the run. |
| +func testRunHeld(ctx context.Context, e park.Engine, w park.Work, held park.HeldLease) error { |
| + if held == nil { |
| + return park.ErrUnsafe |
| + } |
| + e.Leases = borrowedLeases{held} |
| + return e.Run(ctx, w) |
| +} |
| + |
| +type borrowedLeases struct{ held park.HeldLease } |
| + |
| +func (b borrowedLeases) Acquire(_ context.Context, bucket, scope string) (park.HeldLease, error) { |
| + if f, ok := b.held.(*fakeHeldLease); ok && f.key != bucket+"/"+scope { |
| + return nil, errors.New("the held lease is " + f.key + ", not " + bucket + "/" + scope) |
| + } |
| + return borrowedLease{b.held}, nil |
| +} |
| + |
| +type borrowedLease struct{ park.HeldLease } |
| + |
| +func (borrowedLease) Release(context.Context) error { return nil } |
| +func (borrowedLease) Close() {} |
| + |
| +// TestCommittedLaneViewNamesTheTargetWithItsMarker (the Task 3 carry): after |
| +// the commit the target's lane park runs under the real view, which names |
| +// the target's region authoritative and supplies the marker the target's |
| +// probe published, read by its exact version; the lane is parked. |
| +func TestCommittedLaneViewNamesTheTargetWithItsMarker(t *testing.T) { |
| + h := newCloseHarness(t) |
| + ctx := context.Background() |
| + location := strings.TrimSuffix(h.claim(t).Holder.Location, "/") // the cut's holder, at the bare location |
| + body, err := json.Marshal(map[string]any{"op": p1ID, "incarnation": h.inc, "host": p1Host, "box": "uk-box", "installed_at": h.clock.Now(), |
| + "manifest_sha256": "x", "snapshot_txid": 1}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + markerVersion, err := h.vs.PutIfAbsent(ctx, "data-uk", location+"/generation.json", body) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + real := movesParkView(MovesRecordsFrom(h.s), h.vs, "data-uk", "uk") |
| + var committed []park.View |
| + h.parkView = func(ctx context.Context, obs hostclaim.Observed) (park.View, error) { |
| + v, err := real(ctx, obs) |
| + if err == nil && obs.Claim.Op.Kind == hostclaim.KindLanding && obs.Claim.Op.Phase == hostclaim.PhaseCommitted { |
| + committed = append(committed, v) |
| + } |
| + return v, err |
| + } |
| + h.land(t) |
| + h.committedAndOpen(t) |
| + if len(committed) == 0 { |
| + t.Fatal("the committed lane park never asked the view") |
| + } |
| + for _, v := range committed { |
| + if v.Region != "uk" || v.AuthoritativeRegion != "uk" || !v.MarkerKnown || v.MarkerGeneration != p1ID || |
| + v.Marker != (park.Ref{Key: location + "/generation.json", VersionID: markerVersion}) { |
| + t.Fatalf("the committed view %+v; want uk authoritative with the marker at %s", v, markerVersion) |
| + } |
| + } |
| + lane := moves.ScopeLocation(p1Host, p1ID) + "/" |
| + for k := range h.f.buckets["data-uk"] { |
| + if strings.HasPrefix(k, lane) { |
| + t.Fatalf("the lane still holds %s after the committed park", k) |
| + } |
| + } |
| +} |
| + |
| +// TestRegisterProductionMovesParksNeedsBothSwitches (ruling H1): with either |
| +// switch off the agent registers nothing; with both on it needs this box's |
| +// placement region. |
| +func TestRegisterProductionMovesParksNeedsBothSwitches(t *testing.T) { |
| + claimMoveSources.Lock() |
| + oldSource, oldParks := claimMoveSources.Source, claimMoveSources.Parks |
| + claimMoveSources.Source, claimMoveSources.Parks = nil, nil |
| + claimMoveSources.Unlock() |
| + t.Cleanup(func() { |
| + claimMoveSources.Lock() |
| + claimMoveSources.Source, claimMoveSources.Parks = oldSource, oldParks |
| + claimMoveSources.Unlock() |
| + }) |
| + 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()) |
| + 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"}, |
| + } { |
| + if err := registerProductionMovesParks(t.Context(), env(vals), store, hib); 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) |
| + if !errors.Is(err, hostwrite.ErrUnavailable) || claimMoveSources.Parks != nil { |
| + t.Fatalf("no placement region: %v", err) |
| + } |
| +} |
diff --git a/internal/provision/plan9_b8_census_test.go b/internal/provision/plan9_b8_census_test.go| index 7b38a2d85..94d068b69 100644 |
| --- a/internal/provision/plan9_b8_census_test.go |
| +++ b/internal/provision/plan9_b8_census_test.go |
| @@ -519,6 +519,10 @@ var plan9B8CensusInventory = []censusEntry{ |
| {"internal/host/movesevidence.go", "movesInstancesLocation", "key-site", "lit:instances", "4f79083f445c5892fb4933a696bc322eb081d0dd0c0b53c2134e8544c5a9df3e"}, |
| // Reads the record through MovesRecordReader (GetRecord); RecordKey only checks the claim holder's key, and the literals only compare data-bucket locations. No write. |
| {"internal/host/movesevidence.go", "movesRetirementEvidence.retired", "key-site", "key:RecordKey,lit:instances", "d6a42cfd64728e581bb123d5743d3c74b1b6eb5abddb8972571f15cb42d84cb6"}, |
| + // The moves park view's record read (Plan 5 Task 9a): reads through MovesRecordReader (GetRecord); RecordKey only checks the claim holder's key. No write. |
| + {"internal/host/movessources.go", "holderRecord", "key-site", "key:RecordKey", "84e840552703cd9863c9b27786baa864109e94d15b9e6f3e035c0da18622eb2e"}, |
| + // The moves lease set's scope shapes (Plan 5 Task 9a, M6): instances/<host>[/<incarnation>] and moves/<host>[/<op>] are data-bucket lock prefixes; a pure string check. |
| + {"internal/host/movessources.go", "movesLeaseScope", "key-site", "lit:instances", "7a5b9e07b4bb754c9edf0bb5c3e828f79262d195d764e6d51608518e86ed6183"}, |
| // Copies config acknowledgements to moves/<account>/<app>/<host>/<op>/config-ack-source.json. |
| {"internal/host/movesfinal.go", "sourceFinal.configAck", "nonrecord", "sel:Put", "cca53109e6945a73b7f04705d8965b3b0997f2b7d2a82fa6d86cffa4b4174e93"}, |
| // Stamps source_parked_at through SetRecordMove, updateRecord and WriteRecord. |