host: the moves write policy for the claim (hostbox.Policy.Moves) and the held timers import through SelectedTimers (Task 10b)
MovesClaimPolicy is the value for ClaimOwnerBindings.MovesPolicy, which becomes hostbox.Policy.Moves: it grants held-config, held-release, held-timers and the landing's restore, spawn, seed, writer and install rights only to the target box after the flip, under a landing op whose phase permits the action (held-timers at local, held-config and held-release at configured, restore and install at restored), with the selection's capture, kind, phase, op and host equal to the claim's and an identity that fits the action; everything else is a typed MovesPolicyRefusal (ErrMovesRefused). Nothing registers it in production yet: RegisterProductionClaimOwners needs the Plan 3 and Plan 5 owners too, so G16's refusal at checking under CARLOS_HOST_CLAIM stands, with the comment saying what lifts it. The landing's timers import goes through schedule.SelectedTimers with a held-timers Work selected from the agent's hostwrite.Config (heldTimersReplacer); with the flag off that is the registry's own ReplaceTimers, as before. The hook takes the pass's context; armMovesPass and armPhase1 take the claim wiring's Writes.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
9 files changed,
+647
−22
approved_moves_copy_test.go+1 −0internal/host/agent.go+1 −1internal/host/movesland.go+13 −3internal/host/movesland_test.go+3 −2internal/host/movespass.go+21 −13internal/host/movespass_test.go+3 −2internal/host/movesphase1_test.go+2 −1internal/host/movespolicy.go+197 −0internal/host/movespolicy_test.go+406 −0
diff --git a/approved_moves_copy_test.go b/approved_moves_copy_test.go| index 8bf6ace78..cbc522e43 100644 |
| --- a/approved_moves_copy_test.go |
| +++ b/approved_moves_copy_test.go |
| @@ -565,6 +565,7 @@ var movesScanFiles = []string{ |
| "internal/host/movesmeasure_other.go", |
| "internal/host/movespass.go", |
| "internal/host/movesphase1.go", |
| + "internal/host/movespolicy.go", |
| "internal/host/movesprobe.go", |
| "internal/host/movesreport.go", |
| "internal/host/movesstage.go", |
diff --git a/internal/host/agent.go b/internal/host/agent.go| index 35eaa126f..632c641d3 100644 |
| --- a/internal/host/agent.go |
| +++ b/internal/host/agent.go |
| @@ -407,7 +407,7 @@ func runAgent(args []string, buildClaims ClaimFactory) { |
| } else { |
| eb.Mirror = m |
| } |
| - armMovesPass(*cfg, dd, reg, svc, eb) |
| + armMovesPass(*cfg, dd, reg, svc, eb, claims.Writes) |
| // 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/movesland.go b/internal/host/movesland.go| index 4ec659e2f..62ef09889 100644 |
| --- a/internal/host/movesland.go |
| +++ b/internal/host/movesland.go |
| @@ -69,8 +69,13 @@ import ( |
| // production and the pass logs a standing notice instead of landing; the |
| // tests arm everything with fakes and the stand-ins below. Under |
| // CARLOS_HOST_CLAIM the landing refuses at checking with a recorded reason |
| -// (ruling G16): the held writes it makes have no claim selection until Task |
| -// 10b supplies the adapters. |
| +// (ruling G16). The policy its held writes need, hostbox.Policy.Moves, is |
| +// MovesClaimPolicy (movespolicy.go) and the timers import already selects |
| +// its Work as held-timers; the refusal stands because the production claim |
| +// registration (RegisterProductionClaimOwners) requires every owner and the |
| +// Plan 3 (Inputs, PublicationEvidence, AdmissionEvidence) and Plan 5 (View, |
| +// Authorize, Evidence, LeaseSet) owners do not exist yet. It lifts when |
| +// those owners are registered. |
| // landingStepsPerPass bounds the phases one pass runs. |
| const landingStepsPerPass = 8 |
| @@ -432,6 +437,11 @@ func (l *landingRun) ensureLocalDirs() error { |
| func (l *landingRun) checking(ctx context.Context) (bool, error) { |
| cfg := l.cfg() |
| host, id := l.rec.Host, l.g.ID |
| + // Ruling G16: with host claims on, this box's held writes would need |
| + // the claim's selection, and the production registration does not |
| + // carry the Plan 3 and Plan 5 owners yet, so no selector exists here. |
| + // MovesClaimPolicy (movespolicy.go) is the Policy.Moves half, ready; |
| + // this refusal lifts when RegisterProductionClaimOwners has every owner. |
| if hostwrite.Enabled(os.Getenv) { |
| return l.markRollback(ctx, provision.RollbackClaimWired) |
| } |
| @@ -895,7 +905,7 @@ func (l *landingRun) local_(ctx context.Context) (bool, error) { |
| if err != nil { |
| return false, err |
| } |
| - if err := cfg.ReplaceTimers(host, rows); err != nil { |
| + if err := cfg.ReplaceTimers(ctx, host, rows); err != nil { |
| return false, fmt.Errorf("replace the timers: %w", err) |
| } |
| if err := mark(landStepTimers, strconv.Itoa(len(rows))); err != nil { |
diff --git a/internal/host/movesland_test.go b/internal/host/movesland_test.go| index 60b729d10..726fda813 100644 |
| --- a/internal/host/movesland_test.go |
| +++ b/internal/host/movesland_test.go |
| @@ -24,6 +24,7 @@ import ( |
| "github.com/carlosframework/platform/internal/hostbox" |
| "github.com/carlosframework/platform/internal/hostclaim" |
| "github.com/carlosframework/platform/internal/hostguard" |
| + "github.com/carlosframework/platform/internal/hostwrite" |
| "github.com/carlosframework/platform/internal/lease" |
| "github.com/carlosframework/platform/internal/moves" |
| "github.com/carlosframework/platform/internal/moves/archive" |
| @@ -337,7 +338,7 @@ func (h *landingHarness) landingCfg(t *testing.T) moveCfg { |
| return h.render() |
| } |
| c.ReleaseReady = func(context.Context, release.Store, provision.Record, string) moves.Check { return h.release } |
| - c.ReplaceTimers = func(host string, rows []registry.Timer) error { |
| + c.ReplaceTimers = func(_ context.Context, host string, rows []registry.Timer) error { |
| h.replaced = append(h.replaced, append([]registry.Timer(nil), rows...)) |
| return nil |
| } |
| @@ -1213,7 +1214,7 @@ func TestLandingWaitsUnarmedInProduction(t *testing.T) { |
| // Production's own wiring: everything but the park engine's hooks. |
| var prod moveCfg |
| hib := activator.Config{Bucket: "data-uk", Region: "eu-west-2", Prefix: "instances"} |
| - armPhase1(&prod, &movesWorker{mirrors: map[string]lease.MirrorStore{}}, hib, h.tdir, nil, nil, nil, func(string) string { return "" }) |
| + armPhase1(&prod, &movesWorker{mirrors: map[string]lease.MirrorStore{}}, hib, h.tdir, nil, nil, nil, hostwrite.Config{}, func(string) string { return "" }) |
| if prod.Guard.DataDir != h.tdir || prod.Local.DataDir != h.tdir || prod.OwnLease == nil || prod.RenderHeld == nil || prod.RemoveVerifyEntry == nil || prod.WriteGen == nil { |
| t.Fatalf("production wiring: %+v", prod) |
| } |
diff --git a/internal/host/movespass.go b/internal/host/movespass.go| index b7d21ee68..b570dd677 100644 |
| --- a/internal/host/movespass.go |
| +++ b/internal/host/movespass.go |
| @@ -21,6 +21,7 @@ import ( |
| "github.com/carlosframework/platform/internal/hostbox" |
| "github.com/carlosframework/platform/internal/hostclaim" |
| "github.com/carlosframework/platform/internal/hostguard" |
| + "github.com/carlosframework/platform/internal/hostwrite" |
| "github.com/carlosframework/platform/internal/layout" |
| "github.com/carlosframework/platform/internal/lease" |
| "github.com/carlosframework/platform/internal/moves" |
| @@ -184,7 +185,7 @@ func (w *movesWorker) mirror(ctx context.Context, dstRegion string) (lease.Mirro |
| // 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) { |
| +func armMovesPass(hib activator.Config, dataDir string, reg *registry.Registry, svc *activator.Service, eb *activator.ExecBackend, writes hostwrite.Config) { |
| 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) |
| @@ -233,7 +234,7 @@ func armMovesPass(hib activator.Config, dataDir string, reg *registry.Registry, |
| } |
| w := &movesWorker{mirrors: map[string]lease.MirrorStore{}} |
| if cfg.Enabled { |
| - armPhase1(&cfg, w, hib, dataDir, reg, svc, eb, os.Getenv) |
| + 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 |
| } |
| @@ -242,8 +243,10 @@ func armMovesPass(hib activator.Config, dataDir string, reg *registry.Registry, |
| } |
| // armPhase1 wires the Phase 1 hooks (moves design §7.3). Only with the |
| -// switch on: off, none is set and eb.Strict stays nil. |
| -func armPhase1(cfg *moveCfg, w *movesWorker, hib activator.Config, dataDir string, reg *registry.Registry, svc *activator.Service, eb *activator.ExecBackend, getenv func(string) string) { |
| +// switch on: off, none is set and eb.Strict stays nil. writes is the |
| +// agent's host-write configuration (the claim wiring's): the landing's |
| +// held timers import selects its Work through it. |
| +func armPhase1(cfg *moveCfg, w *movesWorker, hib activator.Config, dataDir string, reg *registry.Registry, svc *activator.Service, eb *activator.ExecBackend, writes hostwrite.Config, getenv func(string) string) { |
| cfg.Box = metricsBoxLabel(getenv, readMachineID()) |
| cfg.OptDir = release.OptDir() |
| cfg.DeployBucket = strings.TrimSpace(getenv("CARLOS_DEPLOYMENT_BUCKET")) |
| @@ -292,11 +295,14 @@ func armPhase1(cfg *moveCfg, w *movesWorker, hib activator.Config, dataDir strin |
| // ParkLeases, ParkView and ParkAuthorize are not (ruling G17: the |
| // residue parks need claim Plan 6b's regional runner for their |
| // acknowledgement and Plan 5's view and authorizer), so landingArmed is |
| - // false in production and the landing pass only logs. Under |
| - // CARLOS_HOST_CLAIM the held timers write would go through |
| - // schedule.SelectedTimers with a held-timers Work (ruling G16); until |
| - // Task 10b supplies hostbox.Policy.Moves the landing refuses at checking, |
| - // so the registry's own ReplaceTimers is the pass-through here. |
| + // false in production and the landing pass only logs. The held timers |
| + // write goes through schedule.SelectedTimers with a held-timers Work |
| + // selected from writes (ruling G16, heldTimersReplacer): with |
| + // CARLOS_HOST_CLAIM off that is the registry's own ReplaceTimers, as |
| + // before; with it on, the claim's selection and hostbox.Policy.Moves |
| + // (MovesClaimPolicy) decide, once the production registration carries |
| + // the Plan 3 and Plan 5 owners. Until then the landing refuses at |
| + // checking under the flag (movesland.go). |
| cfg.Guard = hostguard.Guard{DataDir: dataDir} |
| cfg.Local = park.Local{DataDir: dataDir, Box: cfg.Box} |
| cfg.OwnLease = hostbox.RenewingLeases{Region: hib.Region}.Acquire |
| @@ -306,7 +312,7 @@ func armPhase1(cfg *moveCfg, w *movesWorker, hib activator.Config, dataDir strin |
| cfg.RemoveVerifyEntry = dbverify.RemoveHost |
| cfg.WriteGen = hostbox.WriteGenerationMarker |
| if reg != nil { |
| - cfg.ReplaceTimers = reg.ReplaceTimers |
| + cfg.ReplaceTimers = heldTimersReplacer(writes, reg) |
| cfg.SetReplication = reg.SetReplication |
| cfg.SetNextDue = reg.SetNextDue |
| } |
| @@ -518,8 +524,10 @@ type moveCfg struct { |
| // Authorize with ParkLeases set gets the landing's stand-ins. OwnLease |
| // takes the location's lease in the own bucket for the build |
| // (hostbox.RenewingLeases over lease.Acquire). RenderHeld is |
| - // appconfig.RenderHeld for the held row; ReplaceTimers, SetReplication |
| - // and SetNextDue are the registry's; RemoveVerifyEntry is |
| + // appconfig.RenderHeld for the held row; ReplaceTimers is the |
| + // registry's through schedule.SelectedTimers with a held-timers Work |
| + // (heldTimersReplacer); SetReplication and SetNextDue are the |
| + // registry's; RemoveVerifyEntry is |
| // dbverify.RemoveHost; WriteGen is hostbox.WriteGenerationMarker, the |
| // one <dbPath>.gen writer (ruling G2). |
| Guard hostguard.Guard |
| @@ -529,7 +537,7 @@ type moveCfg struct { |
| 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(host string, rows []registry.Timer) 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 |
diff --git a/internal/host/movespass_test.go b/internal/host/movespass_test.go| index 93f74b5dd..7a4161b49 100644 |
| --- a/internal/host/movespass_test.go |
| +++ b/internal/host/movespass_test.go |
| @@ -12,6 +12,7 @@ import ( |
| "time" |
| "github.com/carlosframework/platform/internal/activator" |
| + "github.com/carlosframework/platform/internal/hostwrite" |
| "github.com/carlosframework/platform/internal/lease" |
| "github.com/carlosframework/platform/internal/moves" |
| "github.com/carlosframework/platform/internal/provision" |
| @@ -925,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) |
| + armMovesPass(activator.Config{}, t.TempDir(), nil, nil, nil, hostwrite.Config{}) |
| 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) |
| } |
| @@ -944,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) |
| + armMovesPass(activator.Config{}, t.TempDir(), nil, nil, nil, hostwrite.Config{}) |
| 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 5d3c95b2a..4c1fb6e5a 100644 |
| --- a/internal/host/movesphase1_test.go |
| +++ b/internal/host/movesphase1_test.go |
| @@ -14,6 +14,7 @@ import ( |
| "github.com/carlosframework/platform/internal/activator" |
| "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/moves/archive" |
| @@ -143,7 +144,7 @@ func TestPhase1IsInertWithTheSwitchOff(t *testing.T) { |
| } |
| t.Setenv(moves.SwitchEnv, v) |
| eb := &activator.ExecBackend{} |
| - armMovesPass(activator.Config{}, t.TempDir(), nil, nil, eb) |
| + armMovesPass(activator.Config{}, t.TempDir(), nil, nil, eb, hostwrite.Config{}) |
| if (eb.Strict != nil) != on { |
| t.Fatalf("switch %v: eb.Strict set = %v", on, eb.Strict != nil) |
| } |
diff --git a/internal/host/movespolicy.go b/internal/host/movespolicy.go| new file mode 100644 |
| index 000000000..a83754cb3 |
| --- /dev/null |
| +++ b/internal/host/movespolicy.go |
| @@ -0,0 +1,197 @@ |
| +package host |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "fmt" |
| + |
| + "github.com/carlosframework/platform/internal/hostbox" |
| + "github.com/carlosframework/platform/internal/hostclaim" |
| + "github.com/carlosframework/platform/internal/hostwrite" |
| + "github.com/carlosframework/platform/internal/provision" |
| + "github.com/carlosframework/platform/internal/registry" |
| + "github.com/carlosframework/platform/internal/schedule" |
| +) |
| + |
| +// The moves write policy (moves Phase 1 Plan 2 Task 10b; ruling T10b-scope). |
| +// |
| +// hostbox.Policy.Check separates ordinary host writes from the moves-owned |
| +// ones: under a move kind it checks the registry hold and the selection's |
| +// op and box, and then asks Policy.Moves. MovesClaimPolicy is that answer, |
| +// the value for ClaimOwnerBindings.MovesPolicy. It grants a write only when |
| +// every one of these holds, and refuses everything else with a |
| +// *MovesPolicyRefusal (fail closed): |
| +// |
| +// - the claim's op is the landing of a move (hostclaim.KindLanding). The |
| +// other move kinds grant nothing here: the source writes nothing held |
| +// while moving, and the recoveries (reverting, restoring) are Plan 4's, |
| +// which extends this table when it builds them; |
| +// - the selection is the claim's: the same host, op, kind, phase and |
| +// capture (holder incarnation and stamp); |
| +// - this box is the target after the flip: the executor, the second |
| +// participant, intent.target, and not intent.source; in the move's |
| +// target region (intent.to); and not in lost; |
| +// - the landing's phase permits the action (movesPhaseRights): held-timers |
| +// from local, held-config and held-release from configured, restore and |
| +// install from restored. These are the phases Plan 2 built (checking to |
| +// restored). The probe's rights at probing (spawn, seed, writer, and the |
| +// held render before every probe, §7.7) and the ordinary wake rights at |
| +// committed (§19.1) are Plan 3's rows, added when the probe is built; |
| +// - the identity fits the action: a held-config write names the rendered |
| +// config and nothing else, a held-release write names the release and |
| +// nothing else, and every other action carries no identity. |
| +// |
| +// Nothing here reads the record: the landing pins the record's inputs |
| +// itself at configured (the etag comparison against Move.Inputs), and the |
| +// claim says everything the policy needs about who may write what. |
| +// |
| +// Production registration. RegisterProductionClaimOwners requires every |
| +// owner, and the Inputs, PublicationEvidence and AdmissionEvidence owners |
| +// (Plan 3) and View, Authorize, Evidence and LeaseSet (moves Plan 5) do not |
| +// exist yet, so nothing registers this in production: it is tested and |
| +// ready to plug in. Until that registration, the landing keeps refusing at |
| +// checking while CARLOS_HOST_CLAIM is on (ruling G16, movesland.go). |
| + |
| +// ErrMovesRefused is what every MovesPolicyRefusal unwraps to. |
| +var ErrMovesRefused = errors.New("host: the moves policy refuses the write") |
| + |
| +// MovesPolicyRefusal is MovesClaimPolicy's typed refusal: which action was |
| +// asked under which op, kind and phase, by which box, and why. |
| +type MovesPolicyRefusal struct { |
| + Host, Op, Action, Kind, Phase, Box, Reason string |
| +} |
| + |
| +func (e *MovesPolicyRefusal) Error() string { |
| + return fmt.Sprintf("host: the moves policy refuses %s for %s under %s/%q on %s: %s", e.Action, e.Host, e.Kind, e.Phase, e.Box, e.Reason) |
| +} |
| + |
| +func (e *MovesPolicyRefusal) Unwrap() error { return ErrMovesRefused } |
| + |
| +// The held actions and the landing's rights hostbox.Policy.Check asks |
| +// Policy.Moves about (wakepolicy.go). |
| +const ( |
| + movesActionHeldConfig = "held-config" |
| + movesActionHeldRelease = "held-release" |
| + movesActionHeldTimers = "held-timers" |
| + movesActionRestore = "restore" |
| + movesActionSpawn = "spawn" |
| + movesActionSeed = "seed" |
| + movesActionWriter = "writer" |
| + movesActionInstall = "install" |
| +) |
| + |
| +// movesPhaseRights is the landing's table: the actions each phase permits |
| +// (§7.6). A phase or action not listed is refused. |
| +var movesPhaseRights = map[string]map[string]bool{ |
| + provision.LandLocal: {movesActionHeldTimers: true}, |
| + provision.LandConfigured: {movesActionHeldConfig: true, movesActionHeldRelease: true}, |
| + provision.LandRestored: {movesActionRestore: true, movesActionInstall: true}, |
| +} |
| + |
| +// movesPolicyActions is every action the policy knows. Anything else is |
| +// refused before the table is consulted. |
| +var movesPolicyActions = map[string]bool{ |
| + movesActionHeldConfig: true, movesActionHeldRelease: true, movesActionHeldTimers: true, |
| + movesActionRestore: true, movesActionSpawn: true, movesActionSeed: true, movesActionWriter: true, movesActionInstall: true, |
| +} |
| + |
| +// MovesClaimPolicy is hostbox.Policy.Moves for the moves plans: see the |
| +// file comment for what it grants. |
| +func MovesClaimPolicy(ctx context.Context, c *hostclaim.Claim, s hostbox.Selection, id hostwrite.Identity) error { |
| + refuse := func(reason string) error { |
| + r := &MovesPolicyRefusal{Host: s.Host, Op: s.Op, Action: s.Action, Box: s.Box, Reason: reason} |
| + if c != nil { |
| + r.Kind, r.Phase = c.Op.Kind, c.Op.Phase |
| + } |
| + return r |
| + } |
| + if ctx == nil { |
| + return refuse("no context") |
| + } |
| + if err := ctx.Err(); err != nil { |
| + return err |
| + } |
| + if c == nil || c.Holder == nil { |
| + return refuse("the claim has no holder") |
| + } |
| + if !movesPolicyActions[s.Action] { |
| + return refuse("not a moves action") |
| + } |
| + if s.Host == "" || s.Box == "" || s.Region == "" || s.Op == "" { |
| + return refuse("the selection names no host, box, region or op") |
| + } |
| + if c.Host != s.Host { |
| + return refuse("the claim is another host's") |
| + } |
| + if c.Op.ID != s.Op { |
| + return refuse("the claim is under another op") |
| + } |
| + if c.Op.Kind != s.Kind || c.Op.Phase != s.Phase { |
| + return refuse("the claim's kind or phase is not the selection's") |
| + } |
| + if s.Captured.Stamp == 0 || c.Holder.Stamp != s.Captured.Stamp || c.Holder.Incarnation != s.Captured.Incarnation { |
| + return refuse("the capture is not the holder's") |
| + } |
| + switch c.Op.Kind { |
| + case hostclaim.KindLanding: |
| + case hostclaim.KindMoving, hostclaim.KindReverting, hostclaim.KindRestoring: |
| + return refuse("only the landing writes held state") |
| + default: |
| + return refuse("not a move") |
| + } |
| + if c.Op.Exec != s.Box { |
| + return refuse("this box is not the executor") |
| + } |
| + if len(c.Op.Participants) != 2 || c.Op.Participants[1] != s.Box || c.Op.Intent["target"] != s.Box || c.Op.Intent["source"] == s.Box { |
| + return refuse("this box is not the move's target") |
| + } |
| + if c.Op.Intent["to"] != s.Region { |
| + return refuse("this region is not the move's target region") |
| + } |
| + for _, lost := range c.Lost { |
| + if lost == s.Box { |
| + return refuse("this box is declared lost") |
| + } |
| + } |
| + if !movesPhaseRights[c.Op.Phase][s.Action] { |
| + return refuse("the phase does not permit the action") |
| + } |
| + if !movesIdentityFits(s.Action, id) { |
| + return refuse("the identity does not fit the action") |
| + } |
| + return nil |
| +} |
| + |
| +// movesIdentityFits: a held-config write names the rendered config and no |
| +// release, a held-release write the release and no config, and every other |
| +// action carries no identity at all. |
| +func movesIdentityFits(action string, id hostwrite.Identity) bool { |
| + switch action { |
| + case movesActionHeldConfig: |
| + return id.Config != "" && id.Release == "" |
| + case movesActionHeldRelease: |
| + return id.Release != "" && id.Config == "" |
| + default: |
| + return id == hostwrite.Identity{} |
| + } |
| +} |
| + |
| +// heldTimersReplacer is the landing's timers import (ruling G16): |
| +// schedule.SelectedTimers over the registry, its Work selected as |
| +// held-timers for the host, so under CARLOS_HOST_CLAIM the write runs |
| +// inside the claim's selection and Policy.Moves is asked first. With the |
| +// flag off hostwrite.Config.Select answers the legacy pass-through and the |
| +// registry's own ReplaceTimers runs exactly as before. With the flag on and |
| +// no selector it refuses (hostwrite.ErrUnavailable) and writes nothing. |
| +func heldTimersReplacer(writes hostwrite.Config, reg *registry.Registry) func(ctx context.Context, host string, rows []registry.Timer) error { |
| + return func(ctx context.Context, host string, rows []registry.Timer) error { |
| + if ctx == nil || reg == nil { |
| + return hostwrite.ErrUnavailable |
| + } |
| + work, err := writes.Select(ctx, host, movesActionHeldTimers) |
| + if err != nil { |
| + return err |
| + } |
| + return schedule.SelectedTimers{Store: reg, Work: work, Context: ctx, Host: host}.ReplaceTimers(host, rows) |
| + } |
| +} |
diff --git a/internal/host/movespolicy_test.go b/internal/host/movespolicy_test.go| new file mode 100644 |
| index 000000000..ac35bd185 |
| --- /dev/null |
| +++ b/internal/host/movespolicy_test.go |
| @@ -0,0 +1,406 @@ |
| +package host |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "path/filepath" |
| + "reflect" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/activator" |
| + "github.com/carlosframework/platform/internal/hostbox" |
| + "github.com/carlosframework/platform/internal/hostclaim" |
| + "github.com/carlosframework/platform/internal/hostguard" |
| + "github.com/carlosframework/platform/internal/hostwrite" |
| + "github.com/carlosframework/platform/internal/lease" |
| + "github.com/carlosframework/platform/internal/provision" |
| + "github.com/carlosframework/platform/internal/registry" |
| +) |
| + |
| +const ( |
| + mpHost = "shop.example" |
| + mpOp = "mv_policy" |
| + mpInc = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" |
| + mpTarget = "uk-box" |
| + mpSource = "ie-box" |
| +) |
| + |
| +// mpClaim is the claim after the flip: landing for mpOp at phase, the |
| +// target executing, the holder at stamp 7. |
| +func mpClaim(kind, phase string) *hostclaim.Claim { |
| + return &hostclaim.Claim{V: 1, Host: mpHost, Gen: 9, |
| + Holder: &hostclaim.Holder{Family: "instance", Incarnation: mpInc, Stamp: 7, Generation: "g-src", Session: 3, Location: "instances/" + mpHost + "/" + mpInc}, |
| + Op: hostclaim.Op{Kind: kind, ID: mpOp, Phase: phase, Exec: mpTarget, Participants: []string{mpSource, mpTarget}, |
| + Intent: map[string]string{"to": "uk", "source": mpSource, "target": mpTarget, "source_enrollment": "enr-ie"}}} |
| +} |
| + |
| +// mpSelection is the selection hostbox.WorkSource would capture for that |
| +// claim on the target. |
| +func mpSelection(c *hostclaim.Claim, action string) hostbox.Selection { |
| + return hostbox.Selection{Host: mpHost, Action: action, Box: mpTarget, Region: "uk", |
| + Captured: hostguard.Captured{Incarnation: c.Holder.Incarnation, Stamp: c.Holder.Stamp}, |
| + Kind: c.Op.Kind, Op: c.Op.ID, Phase: c.Op.Phase, Generation: c.Holder.Generation, Session: c.Holder.Session} |
| +} |
| + |
| +// mpIdentity is the identity that fits the action. |
| +func mpIdentity(action string) hostwrite.Identity { |
| + switch action { |
| + case "held-config": |
| + return hostwrite.Identity{Config: "sha256:abc;default=true;environment=false;instance=false"} |
| + case "held-release": |
| + return hostwrite.Identity{Release: "v1.2.3"} |
| + } |
| + return hostwrite.Identity{} |
| +} |
| + |
| +var mpActions = []string{"held-config", "held-release", "held-timers", "restore", "spawn", "seed", "writer", "install"} |
| + |
| +// Every action against every landing phase, including the probe's and the |
| +// commit's: granted exactly where the table says, refused with the typed |
| +// error everywhere else. The same cells through hostbox.Policy.Check with |
| +// the registry hold the landing has. |
| +func TestMovesClaimPolicyGrantsByPhase(t *testing.T) { |
| + granted := map[string]map[string]bool{ |
| + provision.LandLocal: {"held-timers": true}, |
| + provision.LandConfigured: {"held-config": true, "held-release": true}, |
| + provision.LandRestored: {"restore": true, "install": true}, |
| + } |
| + phases := append(provision.LandingOrder(), "committed") |
| + policy := hostbox.Policy{Moves: MovesClaimPolicy, Held: func(context.Context, string) (string, error) { return mpOp, nil }} |
| + grants := 0 |
| + for _, phase := range phases { |
| + for _, action := range mpActions { |
| + c := mpClaim(hostclaim.KindLanding, phase) |
| + s := mpSelection(c, action) |
| + err := MovesClaimPolicy(t.Context(), c, s, mpIdentity(action)) |
| + want := granted[phase][action] |
| + if (err == nil) != want { |
| + t.Errorf("%s/%s: err %v, want granted %v", phase, action, err, want) |
| + continue |
| + } |
| + if err != nil { |
| + var r *MovesPolicyRefusal |
| + if !errors.As(err, &r) || !errors.Is(err, ErrMovesRefused) || r.Action != action || r.Phase != phase || r.Kind != hostclaim.KindLanding || r.Reason == "" { |
| + t.Errorf("%s/%s: not the typed refusal: %#v", phase, action, err) |
| + } |
| + } |
| + if chk := policy.Check(t.Context(), c, s, mpIdentity(action)); (chk == nil) != want { |
| + t.Errorf("%s/%s through Policy.Check: %v, want granted %v", phase, action, chk, want) |
| + } |
| + if want { |
| + grants++ |
| + } |
| + } |
| + } |
| + if grants != 5 { |
| + t.Fatalf("%d cells granted, want 5", grants) |
| + } |
| +} |
| + |
| +// The claim's role and the selection's shape: a non-move op, the other move |
| +// kinds, a mismatched op, box or region, the source after the flip, a lost |
| +// box, a stale capture, a mismatched kind or phase and a wrong identity are |
| +// all refused, and nothing but the exact landing cell passes. |
| +func TestMovesClaimPolicyRefusesOtherRoles(t *testing.T) { |
| + base := func() (*hostclaim.Claim, hostbox.Selection) { |
| + c := mpClaim(hostclaim.KindLanding, provision.LandLocal) |
| + return c, mpSelection(c, "held-timers") |
| + } |
| + cases := map[string]func(c *hostclaim.Claim, s *hostbox.Selection, id *hostwrite.Identity){ |
| + "serving op": func(c *hostclaim.Claim, s *hostbox.Selection, _ *hostwrite.Identity) { |
| + c.Op = hostclaim.Op{Kind: hostclaim.KindServing, Exec: mpTarget} |
| + s.Kind, s.Op, s.Phase = c.Op.Kind, "", "" |
| + }, |
| + "creating op": func(c *hostclaim.Claim, s *hostbox.Selection, _ *hostwrite.Identity) { |
| + c.Op.Kind = hostclaim.KindCreating |
| + s.Kind = c.Op.Kind |
| + }, |
| + "moving, the source executing": func(c *hostclaim.Claim, s *hostbox.Selection, _ *hostwrite.Identity) { |
| + c.Op.Kind, c.Op.Phase, c.Op.Exec = hostclaim.KindMoving, hostclaim.PhaseSealed, mpSource |
| + s.Kind, s.Phase, s.Box, s.Region = c.Op.Kind, c.Op.Phase, mpSource, "ie" |
| + }, |
| + "moving, the target asking": func(c *hostclaim.Claim, s *hostbox.Selection, _ *hostwrite.Identity) { |
| + c.Op.Kind, c.Op.Phase = hostclaim.KindMoving, provision.LandLocal |
| + s.Kind = c.Op.Kind |
| + }, |
| + "reverting": func(c *hostclaim.Claim, s *hostbox.Selection, _ *hostwrite.Identity) { |
| + c.Op.Kind = hostclaim.KindReverting |
| + s.Kind = c.Op.Kind |
| + }, |
| + "restoring": func(c *hostclaim.Claim, s *hostbox.Selection, _ *hostwrite.Identity) { |
| + c.Op.Kind = hostclaim.KindRestoring |
| + s.Kind = c.Op.Kind |
| + }, |
| + "another op selected": func(_ *hostclaim.Claim, s *hostbox.Selection, _ *hostwrite.Identity) { s.Op = "mv_other" }, |
| + "the claim under another op": func(c *hostclaim.Claim, _ *hostbox.Selection, _ *hostwrite.Identity) { |
| + c.Op.ID = "mv_other" |
| + }, |
| + "another host's claim": func(c *hostclaim.Claim, _ *hostbox.Selection, _ *hostwrite.Identity) { c.Host = "other.example" }, |
| + "the source box after the flip": func(_ *hostclaim.Claim, s *hostbox.Selection, _ *hostwrite.Identity) { |
| + s.Box, s.Region = mpSource, "ie" |
| + }, |
| + "the source named executor": func(c *hostclaim.Claim, s *hostbox.Selection, _ *hostwrite.Identity) { |
| + c.Op.Exec = mpSource |
| + s.Box, s.Region = mpSource, "ie" |
| + }, |
| + "exec none": func(c *hostclaim.Claim, _ *hostbox.Selection, _ *hostwrite.Identity) { c.Op.Exec = "" }, |
| + "a third box executing": func(c *hostclaim.Claim, s *hostbox.Selection, _ *hostwrite.Identity) { |
| + c.Op.Exec = "de-box" |
| + s.Box = "de-box" |
| + }, |
| + "the target not the second participant": func(c *hostclaim.Claim, _ *hostbox.Selection, _ *hostwrite.Identity) { |
| + c.Op.Participants = []string{mpTarget, mpSource} |
| + }, |
| + "the intent names another target": func(c *hostclaim.Claim, _ *hostbox.Selection, _ *hostwrite.Identity) { |
| + c.Op.Intent["target"] = "de-box" |
| + }, |
| + "the intent names this box as source too": func(c *hostclaim.Claim, _ *hostbox.Selection, _ *hostwrite.Identity) { |
| + c.Op.Intent["source"] = mpTarget |
| + }, |
| + "another region": func(_ *hostclaim.Claim, s *hostbox.Selection, _ *hostwrite.Identity) { s.Region = "ie" }, |
| + "no region": func(_ *hostclaim.Claim, s *hostbox.Selection, _ *hostwrite.Identity) { s.Region = "" }, |
| + "the box is lost": func(c *hostclaim.Claim, _ *hostbox.Selection, _ *hostwrite.Identity) { |
| + c.Lost = []string{mpTarget} |
| + }, |
| + "a stale capture": func(_ *hostclaim.Claim, s *hostbox.Selection, _ *hostwrite.Identity) { s.Captured.Stamp = 6 }, |
| + "an empty capture": func(_ *hostclaim.Claim, s *hostbox.Selection, _ *hostwrite.Identity) { |
| + s.Captured = hostguard.Captured{} |
| + }, |
| + "another incarnation captured": func(_ *hostclaim.Claim, s *hostbox.Selection, _ *hostwrite.Identity) { |
| + s.Captured.Incarnation = "fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210" |
| + }, |
| + "the claim moved on since the selection": func(c *hostclaim.Claim, _ *hostbox.Selection, _ *hostwrite.Identity) { |
| + c.Op.Phase = provision.LandConfigured |
| + }, |
| + "the selection's kind is not the claim's": func(_ *hostclaim.Claim, s *hostbox.Selection, _ *hostwrite.Identity) { |
| + s.Kind = hostclaim.KindServing |
| + }, |
| + "no holder": func(c *hostclaim.Claim, _ *hostbox.Selection, _ *hostwrite.Identity) { c.Holder = nil }, |
| + "no action": func(_ *hostclaim.Claim, s *hostbox.Selection, _ *hostwrite.Identity) { s.Action = "" }, |
| + "an ordinary action": func(_ *hostclaim.Claim, s *hostbox.Selection, _ *hostwrite.Identity) { |
| + s.Action = "timer-delivery" |
| + }, |
| + "a config identity on the timers": func(_ *hostclaim.Claim, _ *hostbox.Selection, id *hostwrite.Identity) { |
| + id.Config = "sha256:abc" |
| + }, |
| + "a release identity on the timers": func(_ *hostclaim.Claim, _ *hostbox.Selection, id *hostwrite.Identity) { |
| + id.Release = "v1" |
| + }, |
| + } |
| + for name, mutate := range cases { |
| + t.Run(name, func(t *testing.T) { |
| + c, s := base() |
| + id := hostwrite.Identity{} |
| + mutate(c, &s, &id) |
| + err := MovesClaimPolicy(t.Context(), c, s, id) |
| + if err == nil { |
| + t.Fatal("granted") |
| + } |
| + if !errors.Is(err, ErrMovesRefused) { |
| + t.Fatalf("not the typed refusal: %v", err) |
| + } |
| + }) |
| + } |
| + t.Run("the base cell is granted", func(t *testing.T) { |
| + c, s := base() |
| + if err := MovesClaimPolicy(t.Context(), c, s, hostwrite.Identity{}); err != nil { |
| + t.Fatal(err) |
| + } |
| + }) |
| + t.Run("held-config needs the rendered identity and no release", func(t *testing.T) { |
| + c := mpClaim(hostclaim.KindLanding, provision.LandConfigured) |
| + s := mpSelection(c, "held-config") |
| + for _, id := range []hostwrite.Identity{{}, {Release: "v1"}, {Config: "sha256:abc", Release: "v1"}} { |
| + if err := MovesClaimPolicy(t.Context(), c, s, id); !errors.Is(err, ErrMovesRefused) { |
| + t.Fatalf("%+v: %v", id, err) |
| + } |
| + } |
| + if err := MovesClaimPolicy(t.Context(), c, s, hostwrite.Identity{Config: "sha256:abc"}); err != nil { |
| + t.Fatal(err) |
| + } |
| + }) |
| + t.Run("held-release needs the release and no config", func(t *testing.T) { |
| + c := mpClaim(hostclaim.KindLanding, provision.LandConfigured) |
| + s := mpSelection(c, "held-release") |
| + for _, id := range []hostwrite.Identity{{}, {Config: "sha256:abc"}, {Config: "sha256:abc", Release: "v1"}} { |
| + if err := MovesClaimPolicy(t.Context(), c, s, id); !errors.Is(err, ErrMovesRefused) { |
| + t.Fatalf("%+v: %v", id, err) |
| + } |
| + } |
| + if err := MovesClaimPolicy(t.Context(), c, s, hostwrite.Identity{Release: "v1"}); err != nil { |
| + t.Fatal(err) |
| + } |
| + }) |
| + t.Run("a nil claim or a dead context", func(t *testing.T) { |
| + c, s := base() |
| + if err := MovesClaimPolicy(t.Context(), nil, s, hostwrite.Identity{}); !errors.Is(err, ErrMovesRefused) { |
| + t.Fatal(err) |
| + } |
| + ctx, cancel := context.WithCancel(t.Context()) |
| + cancel() |
| + if err := MovesClaimPolicy(ctx, c, s, hostwrite.Identity{}); !errors.Is(err, context.Canceled) { |
| + t.Fatal(err) |
| + } |
| + //lint:ignore SA1012 the nil context is the case under test |
| + if err := MovesClaimPolicy(nil, c, s, hostwrite.Identity{}); !errors.Is(err, ErrMovesRefused) { //nolint:staticcheck |
| + t.Fatal(err) |
| + } |
| + }) |
| + t.Run("the refusal says what and why", func(t *testing.T) { |
| + c, s := base() |
| + s.Region = "ie" |
| + err := MovesClaimPolicy(t.Context(), c, s, hostwrite.Identity{}) |
| + var r *MovesPolicyRefusal |
| + if !errors.As(err, &r) || r.Host != mpHost || r.Op != mpOp || r.Action != "held-timers" || r.Box != mpTarget || r.Kind != hostclaim.KindLanding || r.Phase != provision.LandLocal || r.Reason == "" || err.Error() == "" { |
| + t.Fatalf("%#v", err) |
| + } |
| + }) |
| +} |
| + |
| +// The held-timers import with CARLOS_HOST_CLAIM off is the registry's own |
| +// ReplaceTimers: the rows land exactly as before, bookkeeping included, |
| +// another host's rows untouched. With the flag on, the write runs inside the |
| +// selected Work, or not at all. |
| +func TestHeldTimersReplacerPassesThroughWithTheFlagOff(t *testing.T) { |
| + open := func(t *testing.T) *registry.Registry { |
| + t.Helper() |
| + reg, err := registry.Open(filepath.Join(t.TempDir(), "registry.db")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + t.Cleanup(func() { reg.Close() }) |
| + for _, tm := range []registry.Timer{ |
| + {Host: mpHost, Name: "old", Kind: "schedule", Spec: "@every 1m", Path: "/t", NextAt: 100, CreatedAt: 1}, |
| + {Host: "other.example", Name: "other", Kind: "schedule", Spec: "@every 1m", Path: "/t", NextAt: 100, CreatedAt: 1}, |
| + } { |
| + if err := reg.UpsertTimer(tm); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + return reg |
| + } |
| + rows := []registry.Timer{ |
| + {Host: mpHost, Name: "nightly", Kind: "schedule", Spec: "0 3 * * *", Path: "/n", NextAt: 5000, LastAt: 4000, LastStatus: "ok", LastMs: 12, Attempts: 2, CreatedAt: 10, DueAt: 4900, Registration: "reg-nightly"}, |
| + {Host: mpHost, Name: "once-1", Kind: "once", Spec: "2026-10-06T00:00:00Z", Path: "/o", NextAt: 6000, CreatedAt: 11, DueAt: 6000, Registration: "reg-once"}, |
| + } |
| + unchanged := func(t *testing.T, reg *registry.Registry) { |
| + t.Helper() |
| + got, _ := reg.TimersFor(mpHost) |
| + if len(got) != 1 || got[0].Name != "old" { |
| + t.Fatalf("the rows changed: %+v", got) |
| + } |
| + } |
| + t.Run("flag off: the registry's replacement, exactly", func(t *testing.T) { |
| + reg, want := open(t), open(t) |
| + if err := heldTimersReplacer(hostwrite.Config{}, reg)(t.Context(), mpHost, rows); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := want.ReplaceTimers(mpHost, rows); err != nil { |
| + t.Fatal(err) |
| + } |
| + got, _ := reg.TimersFor(mpHost) |
| + ref, _ := want.TimersFor(mpHost) |
| + if len(got) != 2 || !reflect.DeepEqual(got, ref) { |
| + t.Fatalf("through the replacer:\n%+v\nthrough the registry:\n%+v", got, ref) |
| + } |
| + if other, _ := reg.TimersFor("other.example"); len(other) != 1 { |
| + t.Fatalf("another host's rows: %+v", other) |
| + } |
| + }) |
| + t.Run("flag off: a row for another host writes nothing", func(t *testing.T) { |
| + reg := open(t) |
| + bad := append([]registry.Timer{{Host: "other.example", Name: "x", Kind: "schedule", Spec: "@every 1m", Path: "/t", NextAt: 1, CreatedAt: 1}}, rows...) |
| + if err := heldTimersReplacer(hostwrite.Config{}, reg)(t.Context(), mpHost, bad); err == nil { |
| + t.Fatal("accepted") |
| + } |
| + unchanged(t, reg) |
| + }) |
| + t.Run("flag on, no selector: refused, nothing written", func(t *testing.T) { |
| + reg := open(t) |
| + if err := heldTimersReplacer(hostwrite.Config{Enabled: true}, reg)(t.Context(), mpHost, rows); !errors.Is(err, hostwrite.ErrUnavailable) { |
| + t.Fatal(err) |
| + } |
| + unchanged(t, reg) |
| + }) |
| + t.Run("flag on: the write runs inside the selected held-timers Work", func(t *testing.T) { |
| + reg := open(t) |
| + sel := &mpSelector{} |
| + if err := heldTimersReplacer(hostwrite.Config{Enabled: true, Selector: sel}, reg)(t.Context(), mpHost, rows); err != nil { |
| + t.Fatal(err) |
| + } |
| + if sel.host != mpHost || sel.action != "held-timers" || sel.with != 1 || sel.identity != (hostwrite.Identity{}) { |
| + t.Fatalf("selection: %+v", sel) |
| + } |
| + if got, _ := reg.TimersFor(mpHost); len(got) != 2 { |
| + t.Fatalf("rows: %+v", got) |
| + } |
| + }) |
| + t.Run("flag on: a refused selection writes nothing", func(t *testing.T) { |
| + reg := open(t) |
| + sel := &mpSelector{deny: errors.New("refused")} |
| + if err := heldTimersReplacer(hostwrite.Config{Enabled: true, Selector: sel}, reg)(t.Context(), mpHost, rows); err == nil || !errors.Is(err, sel.deny) { |
| + t.Fatal(err) |
| + } |
| + unchanged(t, reg) |
| + }) |
| + t.Run("no registry: refused", func(t *testing.T) { |
| + if err := heldTimersReplacer(hostwrite.Config{}, nil)(t.Context(), mpHost, rows); !errors.Is(err, hostwrite.ErrUnavailable) { |
| + t.Fatal(err) |
| + } |
| + }) |
| +} |
| + |
| +// mpSelector records the selection the replacer asks for and runs, or |
| +// refuses, the write. |
| +type mpSelector struct { |
| + host, action string |
| + with int |
| + identity hostwrite.Identity |
| + deny error |
| +} |
| + |
| +func (s *mpSelector) Select(_ context.Context, host, action string) (hostwrite.Work, error) { |
| + s.host, s.action = host, action |
| + return s, nil |
| +} |
| +func (s *mpSelector) With(_ context.Context, id hostwrite.Identity, fn func() error) error { |
| + s.with++ |
| + s.identity = id |
| + if s.deny != nil { |
| + return s.deny |
| + } |
| + return fn() |
| +} |
| + |
| +// armPhase1 wires the landing's timers import through the replacer: with |
| +// the flag off it is the registry's replacement, as before. |
| +func TestArmPhase1WiresTheHeldTimersImport(t *testing.T) { |
| + reg, err := registry.Open(filepath.Join(t.TempDir(), "registry.db")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer reg.Close() |
| + var cfg moveCfg |
| + hib := activator.Config{Bucket: "data-uk", Region: "eu-west-2", Prefix: "instances"} |
| + armPhase1(&cfg, &movesWorker{mirrors: map[string]lease.MirrorStore{}}, hib, t.TempDir(), reg, nil, nil, hostwrite.Config{}, func(string) string { return "" }) |
| + if cfg.ReplaceTimers == nil { |
| + t.Fatal("no timers import") |
| + } |
| + rows := []registry.Timer{{Host: mpHost, Name: "nightly", Kind: "schedule", Spec: "0 3 * * *", Path: "/n", NextAt: 5000, CreatedAt: 10, DueAt: 4900, Registration: "reg-nightly"}} |
| + if err := cfg.ReplaceTimers(t.Context(), mpHost, rows); err != nil { |
| + t.Fatal(err) |
| + } |
| + if got, _ := reg.TimersFor(mpHost); len(got) != 1 || got[0].Name != "nightly" || got[0].Registration != "reg-nightly" { |
| + t.Fatalf("rows: %+v", got) |
| + } |
| + // The flag on without a selector: the import refuses rather than |
| + // writing outside the claim. |
| + var on moveCfg |
| + armPhase1(&on, &movesWorker{mirrors: map[string]lease.MirrorStore{}}, hib, t.TempDir(), reg, nil, nil, hostwrite.Config{Enabled: true}, func(string) string { return "" }) |
| + if err := on.ReplaceTimers(t.Context(), mpHost, nil); !errors.Is(err, hostwrite.ErrUnavailable) { |
| + t.Fatal(err) |
| + } |
| + if got, _ := reg.TimersFor(mpHost); len(got) != 1 { |
| + t.Fatalf("a refused import wrote: %+v", got) |
| + } |
| +} |