carlos / platform Public

host: source-role write-policy rows for the return and the revert (J12)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 8a748eaa770a40c9848e45dec28004ccf9e0341b parent 44a3a50
2 files changed, +702 −6
  • internal/host/movespolicy.go +193 −6
  • internal/host/movespolicy_test.go +509 −0
diff --git a/internal/host/movespolicy.go b/internal/host/movespolicy.go
index 90781a060..3de593846 100644
--- a/internal/host/movespolicy.go
+++ b/internal/host/movespolicy.go
@@ -5,6 +5,7 @@ import (
"encoding/json"
"errors"
"fmt"
+ "strconv"
"github.com/carlosframework/platform/internal/hostbox"
"github.com/carlosframework/platform/internal/hostclaim"
@@ -24,10 +25,9 @@ import (
// 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 claim's op is the landing of a move (hostclaim.KindLanding), or a
+// recovery (below). A moving op grants nothing: the source writes
+// nothing held while moving;
// - 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
@@ -54,6 +54,41 @@ import (
// is not this landing, a read error or no reader refuse. The record is
// read for these two actions only.
//
+// The recoveries (moves Plan 4 Task 3b, ruling J12) are the source's rows,
+// granted only when every one of these holds:
+//
+// - this box is the claim op's executor, as Policy.Check requires; the
+// move's source (the first participant and intent.source, not
+// intent.target); not in the target region (intent.to) but in the
+// record's From; and not in lost;
+// - the record, read for every recovery action, is this move and this
+// holder incarnation: rolling_back for this rollback (Rollback.ID is
+// intent.rollback) under restoring, reverting under reverting;
+// - the row permits the action. restoring is keyed by the record's
+// Rollback.SourcePhase: local grants held-timers, configured held-config
+// and held-release, restored restore and install, each under the claim
+// at claimed, for a dirty rollback, with the holder at the source's
+// pre-move stamp S (op.journal.source_stamp); probing grants the held
+// config render before the probe (§7.7) and spawn, under the claim at
+// probing. reverting is keyed by the claim's rev_phase: probing grants
+// the held config render and spawn, with op.phase at probing (J1);
+// - the identity fits the action, a spawn carrying the launch identity
+// (Plan 3 H4): the config and the release it launches with;
+// - the held config and release and the launch name the inputs the
+// recovery rendered, Rollback.RecoveryInputs as the CAS that ends
+// configured records them, never Move.Inputs (§7.7, J2-R2-5). Before
+// they are recorded the render is granted on the rows above: configured
+// is the render the record takes them from, and a clean return renders
+// before its probe with no configured. A dirty return's probe, and every
+// spawn, need them recorded. A revert records none: its render and
+// launch are bound by shape only.
+//
+// The target's cleaning (ReplaceTimers(host, nil) at cleaning) and the
+// source's revert close are not rows here: their callers use plain registry
+// calls under WithHostHoldLock, as Plan 3 H5(d) ruled for the source's
+// finalization, and this policy refuses the target any write under a
+// recovery.
+//
// 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
@@ -205,8 +240,10 @@ func MovesClaimPolicy(read MovesRecordReader) func(context.Context, *hostclaim.C
}
switch c.Op.Kind {
case hostclaim.KindLanding:
- case hostclaim.KindMoving, hostclaim.KindReverting, hostclaim.KindRestoring:
- return refuse("only the landing writes held state", nil)
+ case hostclaim.KindReverting, hostclaim.KindRestoring:
+ return movesRecoveryPolicy(ctx, read, c, s, id, refuse)
+ case hostclaim.KindMoving:
+ return refuse("a moving op writes nothing held", nil)
default:
return refuse("not a move", nil)
}
@@ -273,6 +310,156 @@ func MovesClaimPolicy(read MovesRecordReader) func(context.Context, *hostclaim.C
}
}
+// recoveryRow is one row of a recovery's table: the claim phase it runs
+// under, whether it is the dirty return's install, and the actions it
+// grants.
+type recoveryRow struct {
+ claimPhase string
+ install bool
+ actions map[string]bool
+}
+
+// restoringRights is the source's table during a rollback, keyed by the
+// record's Rollback.SourcePhase (§7.8 source table). The claim stays at
+// claimed through the dirty return's install (Task 3) and mirrors probing
+// (J1). A source phase not listed grants nothing.
+var restoringRights = map[string]recoveryRow{
+ provision.RbSourceLocal: {claimPhase: hostclaim.PhaseClaimed, install: true, actions: map[string]bool{movesActionHeldTimers: true}},
+ provision.RbSourceConfigured: {claimPhase: hostclaim.PhaseClaimed, install: true, actions: map[string]bool{movesActionHeldConfig: true, movesActionHeldRelease: true}},
+ provision.RbSourceRestored: {claimPhase: hostclaim.PhaseClaimed, install: true, actions: map[string]bool{movesActionRestore: true, movesActionInstall: true}},
+ provision.RbSourceProbing: {claimPhase: hostclaim.PhaseProbing, actions: map[string]bool{movesActionHeldConfig: true, movesActionSpawn: true}},
+}
+
+// revertingRights is the source's table during a revert, keyed by the
+// claim's rev_phase (§7.4), which op.phase mirrors from probing on (J1).
+var revertingRights = map[string]recoveryRow{
+ hostclaim.PhaseProbing: {claimPhase: hostclaim.PhaseProbing, actions: map[string]bool{movesActionHeldConfig: true, movesActionSpawn: true}},
+}
+
+// movesRecoveryPolicy is MovesClaimPolicy's reverting and restoring rows:
+// see the file comment. The claim, the selection and the capture have
+// already been matched.
+func movesRecoveryPolicy(ctx context.Context, read MovesRecordReader, c *hostclaim.Claim, s hostbox.Selection, id hostwrite.Identity, refuse func(string, error) error) error {
+ if c.Op.Exec != s.Box {
+ return refuse("this box is not the executor", nil)
+ }
+ if len(c.Op.Participants) == 0 || c.Op.Participants[0] != s.Box || c.Op.Intent["source"] != s.Box || c.Op.Intent["target"] == s.Box {
+ return refuse("this box is not the move's source", nil)
+ }
+ if c.Op.Intent["to"] == s.Region {
+ return refuse("this region is the move's target region", nil)
+ }
+ for _, lost := range c.Lost {
+ if lost == s.Box {
+ return refuse("this box is declared lost", nil)
+ }
+ }
+ if read == nil {
+ return refuse("no record reader", nil)
+ }
+ if c.Holder.Account == "" || c.Holder.App == "" {
+ return refuse("the holder names no account or app", nil)
+ }
+ rec, err := read(ctx, c.Holder.Account, c.Holder.App, c.Host)
+ if err != nil {
+ return refuse("the record cannot be read", err)
+ }
+ if rec == nil || rec.Host != c.Host || rec.Move == nil || rec.Move.ID != s.Op || rec.Move.Incarnation != c.Holder.Incarnation {
+ return refuse("the record is not this move", nil)
+ }
+ if rec.Move.From != s.Region {
+ return refuse("this region is not the move's source region", nil)
+ }
+ var (
+ row recoveryRow
+ ok bool
+ rb *provision.MoveRollback
+ recovery *provision.MoveInputs
+ )
+ switch c.Op.Kind {
+ case hostclaim.KindRestoring:
+ rb = rec.Move.Rollback
+ if rec.Move.State != provision.MoveRollingBack || rb == nil || rb.ID == "" || rb.ID != c.Op.Intent["rollback"] {
+ return refuse("the record is not this rollback", nil)
+ }
+ row, ok = restoringRights[rb.SourcePhase]
+ recovery = rb.RecoveryInputs
+ default: // hostclaim.KindReverting
+ if rec.Move.State != provision.MoveReverting {
+ return refuse("the record is not this revert", nil)
+ }
+ row, ok = revertingRights[c.Op.Journal[hostclaim.JournalRevPhase]]
+ }
+ if !ok || row.claimPhase != c.Op.Phase || !row.actions[s.Action] {
+ return refuse("the phase does not permit the action", nil)
+ }
+ if row.install {
+ if !rb.Dirty {
+ return refuse("only a dirty return installs", nil)
+ }
+ stamp, err := strconv.ParseUint(c.Op.Journal[hostclaim.JournalSourceStamp], 10, 64)
+ if err != nil || stamp == 0 || c.Holder.Stamp != stamp {
+ return refuse("the holder is not at the source's pre-move stamp", nil)
+ }
+ }
+ if !recoveryIdentityFits(s.Action, id) {
+ return refuse("the identity does not fit the action", nil)
+ }
+ if rb == nil {
+ // A revert records no recovery inputs: its render and its launch
+ // are bound by shape, never by Move.Inputs.
+ return nil
+ }
+ switch s.Action {
+ case movesActionHeldConfig, movesActionHeldRelease:
+ if recovery == nil {
+ // configured renders the inputs its closing CAS records, and a
+ // clean return's render before its probe precedes the record.
+ if rb.SourcePhase == provision.RbSourceConfigured || !rb.Dirty {
+ return nil
+ }
+ return refuse("no recorded recovery inputs", nil)
+ }
+ case movesActionSpawn:
+ if recovery == nil {
+ return refuse("no recorded recovery inputs", nil)
+ }
+ default:
+ return nil
+ }
+ switch s.Action {
+ case movesActionHeldConfig:
+ if id.Config != HeldConfigIdentity(recovery.BundleETags, recovery.OverlayETag).Config {
+ return refuse("the config is not the recovery's", nil)
+ }
+ case movesActionHeldRelease:
+ if recovery.Release == "" {
+ return refuse("no recorded recovery release", nil)
+ }
+ if id.Release != recovery.Release {
+ return refuse("the release is not the recovery's", nil)
+ }
+ case movesActionSpawn:
+ if recovery.Release == "" {
+ return refuse("no recorded recovery release", nil)
+ }
+ if id.Config != HeldConfigIdentity(recovery.BundleETags, recovery.OverlayETag).Config || id.Release != recovery.Release {
+ return refuse("the launch is not the recovery's", nil)
+ }
+ }
+ return nil
+}
+
+// recoveryIdentityFits is movesIdentityFits for a recovery, with Plan 3
+// H4's launch identity: a spawn names both the config and the release it
+// launches with.
+func recoveryIdentityFits(action string, id hostwrite.Identity) bool {
+ if action == movesActionSpawn {
+ return id.Config != "" && id.Release != ""
+ }
+ return movesIdentityFits(action, id)
+}
+
// movesIdentityFits: a held-config write names the config and no release,
// a held-release write the release and no config, and every other action
// carries no identity at all. Which config and release is the record's
diff --git a/internal/host/movespolicy_test.go b/internal/host/movespolicy_test.go
index f4b28e030..39982b4e9 100644
--- a/internal/host/movespolicy_test.go
+++ b/internal/host/movespolicy_test.go
@@ -684,3 +684,512 @@ func TestArmPhase1WiresTheHeldTimersImport(t *testing.T) {
t.Fatalf("a refused import wrote: %+v", got)
}
}
+
+// The recovery fixtures (Plan 4 Task 3b, ruling J12): the source executing
+// again under the move's op, its row held by the rollback id (restoring) or
+// the move id (reverting).
+const (
+ rpRB = "rb_abcdefghijklmnopqrstuvwxyz"
+ rpStamp = 7 // the source installation's stamp S the flip journalled
+)
+
+// rpRecovery are the inputs the return rendered at configured: another
+// bundle etag and another release than the move pinned.
+func rpRecovery() *provision.MoveInputs {
+ return &provision.MoveInputs{BundleETags: map[string]string{"default": "e-default-2", "environment": "e-env"}, OverlayETag: "e-overlay", Release: "v1.3.0"}
+}
+
+// rpRestoringClaim is the claim of a rollback after the return: restoring at
+// phase, the source executing at S, the rollback id in the intent.
+func rpRestoringClaim(phase string) *hostclaim.Claim {
+ c := mpClaim(hostclaim.KindRestoring, phase)
+ c.Op.Exec = mpSource
+ c.Op.Intent["rollback"] = rpRB
+ c.Op.Journal = map[string]string{hostclaim.JournalSourceStamp: "7", hostclaim.JournalSrcGen: "g-src"}
+ c.Holder.Stamp = rpStamp
+ return c
+}
+
+// rpRevertingClaim is the claim of a revert: reverting, the source
+// executing, rev_phase in the journal and op.phase mirrored from probing on
+// (J1); before it, the sealed moving phase the revert began from.
+func rpRevertingClaim(revPhase string) *hostclaim.Claim {
+ phase := hostclaim.PhaseSealed
+ switch revPhase {
+ case hostclaim.PhaseProbing, hostclaim.PhaseProbed, hostclaim.PhaseOpened:
+ phase = revPhase
+ }
+ c := mpClaim(hostclaim.KindReverting, phase)
+ c.Op.Exec = mpSource
+ c.Op.Journal = map[string]string{hostclaim.JournalRevPhase: revPhase, hostclaim.JournalEvidenceSource: "moves/ev"}
+ return c
+}
+
+// rpSelection is the selection the source captures for that claim.
+func rpSelection(c *hostclaim.Claim, action string) hostbox.Selection {
+ s := mpSelection(c, action)
+ s.Box, s.Region = mpSource, "ie"
+ return s
+}
+
+// rpRestoringRecord is the dirty rollback with the source at phase; the
+// recovery inputs are recorded from the CAS that ends configured on.
+func rpRestoringRecord(phase string) *provision.Record {
+ rec := mpRecord()
+ rec.Move.State, rec.Move.LandingPhase = provision.MoveRollingBack, provision.LandRestored
+ rec.Move.Rollback = &provision.MoveRollback{ID: rpRB, Dirty: true, TargetPhase: provision.RbTargetReturned, SourcePhase: phase}
+ switch phase {
+ case provision.RbSourceRestored, provision.RbSourceProbing, provision.RbSourceProbed, provision.RbSourceRecovered, provision.RbSourceOpened:
+ rec.Move.Rollback.RecoveryInputs = rpRecovery()
+ }
+ return rec
+}
+
+// rpRevertingRecord is the revert's record: reverting, the region still the
+// source's.
+func rpRevertingRecord() *provision.Record {
+ rec := mpRecord()
+ rec.Region, rec.Move.State, rec.Move.LandingPhase = "ie", provision.MoveReverting, ""
+ return rec
+}
+
+// rpLaunch is the launch identity a probe's spawn carries (Plan 3 H4).
+func rpLaunch(in *provision.MoveInputs) hostwrite.Identity {
+ return hostwrite.Identity{Config: HeldConfigIdentity(in.BundleETags, in.OverlayETag).Config, Release: in.Release}
+}
+
+// rpIdentity fits the action against the recovery inputs.
+func rpIdentity(action string) hostwrite.Identity {
+ in := rpRecovery()
+ switch action {
+ case "held-config":
+ return HeldConfigIdentity(in.BundleETags, in.OverlayETag)
+ case "held-release":
+ return hostwrite.Identity{Release: in.Release}
+ case "spawn":
+ return rpLaunch(in)
+ }
+ return hostwrite.Identity{}
+}
+
+// rpClaimPhase is the claim phase a rollback's source phase runs under: the
+// claim stays at claimed through the source's install (Task 3) and mirrors
+// probing, probed and opened (J1).
+func rpClaimPhase(sourcePhase string) string {
+ switch sourcePhase {
+ case provision.RbSourceWaiting:
+ return hostclaim.PhaseReleased
+ case provision.RbSourceProbing, provision.RbSourceProbed, provision.RbSourceOpened:
+ return sourcePhase
+ case provision.RbSourceRecovered:
+ return hostclaim.PhaseProbed
+ }
+ return hostclaim.PhaseClaimed
+}
+
+var rpSourcePhases = []string{
+ provision.RbSourceWaiting, provision.RbSourceClaimed, provision.RbSourceRestoring, provision.RbSourceVerified,
+ provision.RbSourceParking, provision.RbSourceBuilding, provision.RbSourcePublished, provision.RbSourceLocal,
+ provision.RbSourceConfigured, provision.RbSourceRestored, provision.RbSourceProbing, provision.RbSourceProbed,
+ provision.RbSourceRecovered, provision.RbSourceOpened,
+}
+
+func rpRefused(t *testing.T, err error, reason string) {
+ t.Helper()
+ var r *MovesPolicyRefusal
+ if err == nil || !errors.As(err, &r) || !errors.Is(err, ErrMovesRefused) {
+ t.Fatalf("not the typed refusal: %v", err)
+ }
+ if reason != "" && r.Reason != reason {
+ t.Fatalf("reason %q, want %q", r.Reason, reason)
+ }
+}
+
+// restoring, the source's return (J12): every action against every source
+// phase of the rollback, under the claim phase that phase runs under. The
+// dirty install's held writes are granted at their phases (timers at local,
+// config and release at configured, restore and install at restored), and
+// the held config render and the spawn at probing; nothing else. A record
+// phase under the wrong claim phase is refused.
+func TestMovesClaimPolicyRestoringRows(t *testing.T) {
+ granted := map[string]map[string]bool{
+ provision.RbSourceLocal: {"held-timers": true},
+ provision.RbSourceConfigured: {"held-config": true, "held-release": true},
+ provision.RbSourceRestored: {"restore": true, "install": true},
+ provision.RbSourceProbing: {"held-config": true, "spawn": true},
+ }
+ grants := 0
+ for _, phase := range rpSourcePhases {
+ reader := &mpReader{rec: rpRestoringRecord(phase)}
+ moves := MovesClaimPolicy(reader.read)
+ for _, action := range mpActions {
+ c := rpRestoringClaim(rpClaimPhase(phase))
+ err := moves(t.Context(), c, rpSelection(c, action), rpIdentity(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) || r.Kind != hostclaim.KindRestoring || r.Box != mpSource || r.Reason == "" {
+ t.Errorf("%s/%s: not the typed refusal: %#v", phase, action, err)
+ }
+ } else {
+ grants++
+ }
+ }
+ if reader.reads == 0 || !reflect.DeepEqual(reader.args, []string{"bab", "titogo", mpHost}) {
+ t.Errorf("%s: the rows are keyed by the record: %d reads %v", phase, reader.reads, reader.args)
+ }
+ }
+ if grants != 7 {
+ t.Fatalf("%d cells granted, want 7", grants)
+ }
+ t.Run("the wrong claim phase", func(t *testing.T) {
+ for _, tc := range []struct{ record, claim, action string }{
+ {provision.RbSourceLocal, hostclaim.PhaseProbing, "held-timers"},
+ {provision.RbSourceConfigured, hostclaim.PhaseReleased, "held-config"},
+ {provision.RbSourceRestored, hostclaim.PhaseProbed, "install"},
+ {provision.RbSourceProbing, hostclaim.PhaseClaimed, "spawn"},
+ {provision.RbSourceProbing, hostclaim.PhaseProbed, "held-config"},
+ } {
+ moves := MovesClaimPolicy((&mpReader{rec: rpRestoringRecord(tc.record)}).read)
+ c := rpRestoringClaim(tc.claim)
+ rpRefused(t, moves(t.Context(), c, rpSelection(c, tc.action), rpIdentity(tc.action)), "")
+ }
+ })
+}
+
+// reverting, the source's pre-flip recovery (J12): the rows are keyed by the
+// claim's rev_phase. Only probing grants: the held config render before each
+// probe and the spawn. The same cells through hostbox.Policy.Check with the
+// revert's hold, the move id.
+func TestMovesClaimPolicyRevertingRows(t *testing.T) {
+ reader := &mpReader{rec: rpRevertingRecord()}
+ moves := MovesClaimPolicy(reader.read)
+ policy := hostbox.Policy{Moves: moves, Held: func(context.Context, string) (string, error) { return mpOp, nil }}
+ grants := 0
+ for _, rev := range []string{hostclaim.RevPhaseEvidence, hostclaim.RevPhaseVerify, hostclaim.PhaseProbing, hostclaim.PhaseProbed, hostclaim.PhaseOpened} {
+ for _, action := range mpActions {
+ c := rpRevertingClaim(rev)
+ s := rpSelection(c, action)
+ err := moves(t.Context(), c, s, rpIdentity(action))
+ want := rev == hostclaim.PhaseProbing && (action == "held-config" || action == "spawn")
+ if (err == nil) != want {
+ t.Errorf("%s/%s: err %v, want granted %v", rev, action, err, want)
+ continue
+ }
+ if err != nil && !errors.Is(err, ErrMovesRefused) {
+ t.Errorf("%s/%s: not the typed refusal: %v", rev, action, err)
+ }
+ if chk := policy.Check(t.Context(), c, s, rpIdentity(action)); (chk == nil) != want {
+ t.Errorf("%s/%s through Policy.Check: %v, want granted %v", rev, action, chk, want)
+ }
+ if want {
+ grants++
+ }
+ }
+ }
+ if grants != 2 {
+ t.Fatalf("%d cells granted, want 2", grants)
+ }
+ t.Run("rev_phase and op.phase disagree", func(t *testing.T) {
+ c := rpRevertingClaim(hostclaim.PhaseProbing)
+ c.Op.Phase = hostclaim.PhaseSealed
+ rpRefused(t, moves(t.Context(), c, rpSelection(c, "spawn"), rpIdentity("spawn")), "")
+ c = rpRevertingClaim(hostclaim.RevPhaseVerify)
+ c.Op.Phase = hostclaim.PhaseProbing
+ rpRefused(t, moves(t.Context(), c, rpSelection(c, "spawn"), rpIdentity("spawn")), "")
+ })
+}
+
+// Only the claim op's executor, the move's source, in the source's region,
+// for this rollback's record: the target (the cleaning's ReplaceTimers
+// included, which is a plain registry call and never a policy row), a third
+// box, a lost source and every record that is not this recovery are refused.
+func TestMovesClaimPolicyRecoveryRefusesOtherRoles(t *testing.T) {
+ type cell struct {
+ claim func() *hostclaim.Claim
+ record func() *provision.Record
+ action string
+ }
+ restoring := func(sourcePhase, action string) cell {
+ return cell{func() *hostclaim.Claim { return rpRestoringClaim(rpClaimPhase(sourcePhase)) },
+ func() *provision.Record { return rpRestoringRecord(sourcePhase) }, action}
+ }
+ reverting := cell{func() *hostclaim.Claim { return rpRevertingClaim(hostclaim.PhaseProbing) }, rpRevertingRecord, "spawn"}
+ bases := map[string]cell{
+ "restoring local": restoring(provision.RbSourceLocal, "held-timers"),
+ "restoring configured": restoring(provision.RbSourceConfigured, "held-config"),
+ "restoring restored": restoring(provision.RbSourceRestored, "install"),
+ "restoring probing": restoring(provision.RbSourceProbing, "spawn"),
+ "reverting probing": reverting,
+ }
+ type mutation func(c *hostclaim.Claim, s *hostbox.Selection, rec *provision.Record)
+ cases := map[string]mutation{
+ "the target asking, the source executing": func(_ *hostclaim.Claim, s *hostbox.Selection, _ *provision.Record) {
+ s.Box, s.Region = mpTarget, "uk"
+ },
+ "the target executing and asking": func(c *hostclaim.Claim, s *hostbox.Selection, _ *provision.Record) {
+ c.Op.Exec = mpTarget
+ s.Box, s.Region = mpTarget, "uk"
+ },
+ "the target executing, the source asking": func(c *hostclaim.Claim, _ *hostbox.Selection, _ *provision.Record) {
+ c.Op.Exec = mpTarget
+ },
+ "exec none": func(c *hostclaim.Claim, _ *hostbox.Selection, _ *provision.Record) { c.Op.Exec = "" },
+ "a third box executing and asking": func(c *hostclaim.Claim, s *hostbox.Selection, _ *provision.Record) {
+ c.Op.Exec, s.Box = "de-box", "de-box"
+ },
+ "the source not the first participant": func(c *hostclaim.Claim, _ *hostbox.Selection, _ *provision.Record) {
+ c.Op.Participants = []string{mpTarget, mpSource}
+ },
+ "no participants": func(c *hostclaim.Claim, _ *hostbox.Selection, _ *provision.Record) { c.Op.Participants = nil },
+ "the intent names another source": func(c *hostclaim.Claim, _ *hostbox.Selection, _ *provision.Record) {
+ c.Op.Intent["source"] = "de-box"
+ },
+ "the intent names this box as target too": func(c *hostclaim.Claim, _ *hostbox.Selection, _ *provision.Record) {
+ c.Op.Intent["target"] = mpSource
+ },
+ "the source is lost": func(c *hostclaim.Claim, _ *hostbox.Selection, _ *provision.Record) { c.Lost = []string{mpSource} },
+ "the target's region": func(_ *hostclaim.Claim, s *hostbox.Selection, _ *provision.Record) { s.Region = "uk" },
+ "a third region": func(_ *hostclaim.Claim, s *hostbox.Selection, _ *provision.Record) { s.Region = "de" },
+ "the record names another source region": func(_ *hostclaim.Claim, _ *hostbox.Selection, rec *provision.Record) {
+ rec.Move.From = "de"
+ },
+ "a stale capture": func(_ *hostclaim.Claim, s *hostbox.Selection, _ *provision.Record) { s.Captured.Stamp = 6 },
+ "another move's record": func(_ *hostclaim.Claim, _ *hostbox.Selection, rec *provision.Record) {
+ rec.Move.ID = "mv_other"
+ },
+ "another incarnation's record": func(_ *hostclaim.Claim, _ *hostbox.Selection, rec *provision.Record) {
+ rec.Move.Incarnation = "0"
+ },
+ "another host's record": func(_ *hostclaim.Claim, _ *hostbox.Selection, rec *provision.Record) { rec.Host = "other.example" },
+ "no move": func(_ *hostclaim.Claim, _ *hostbox.Selection, rec *provision.Record) { rec.Move = nil },
+ "a stuck record": func(_ *hostclaim.Claim, _ *hostbox.Selection, rec *provision.Record) {
+ rec.Move.State = provision.MoveStuck
+ },
+ "a landing record": func(_ *hostclaim.Claim, _ *hostbox.Selection, rec *provision.Record) {
+ rec.Move.State = provision.MoveLanding
+ },
+ }
+ for baseName, base := range bases {
+ for name, mutate := range cases {
+ t.Run(baseName+"/"+name, func(t *testing.T) {
+ c, rec := base.claim(), base.record()
+ s := rpSelection(c, base.action)
+ mutate(c, &s, rec)
+ moves := MovesClaimPolicy((&mpReader{rec: rec}).read)
+ rpRefused(t, moves(t.Context(), c, s, rpIdentity(base.action)), "")
+ })
+ }
+ t.Run(baseName+"/the base cell is granted", func(t *testing.T) {
+ c := base.claim()
+ moves := MovesClaimPolicy((&mpReader{rec: base.record()}).read)
+ if err := moves(t.Context(), c, rpSelection(c, base.action), rpIdentity(base.action)); err != nil {
+ t.Fatal(err)
+ }
+ })
+ }
+ restoringOnly := map[string]mutation{
+ "another rollback's record": func(_ *hostclaim.Claim, _ *hostbox.Selection, rec *provision.Record) {
+ rec.Move.Rollback.ID = "rb_zzzzzzzzzzzzzzzzzzzzzzzzzz"
+ },
+ "the claim names no rollback": func(c *hostclaim.Claim, _ *hostbox.Selection, _ *provision.Record) {
+ delete(c.Op.Intent, "rollback")
+ },
+ "no rollback journal": func(_ *hostclaim.Claim, _ *hostbox.Selection, rec *provision.Record) { rec.Move.Rollback = nil },
+ "a reverting record": func(_ *hostclaim.Claim, _ *hostbox.Selection, rec *provision.Record) {
+ rec.Move.State = provision.MoveReverting
+ },
+ }
+ for name, mutate := range restoringOnly {
+ for _, phase := range []string{provision.RbSourceLocal, provision.RbSourceProbing} {
+ t.Run("restoring "+phase+"/"+name, func(t *testing.T) {
+ base := restoring(phase, map[string]string{provision.RbSourceLocal: "held-timers", provision.RbSourceProbing: "spawn"}[phase])
+ c, rec := base.claim(), base.record()
+ s := rpSelection(c, base.action)
+ mutate(c, &s, rec)
+ moves := MovesClaimPolicy((&mpReader{rec: rec}).read)
+ rpRefused(t, moves(t.Context(), c, s, rpIdentity(base.action)), "")
+ })
+ }
+ }
+ t.Run("a reverting claim over a rolling_back record", func(t *testing.T) {
+ c := rpRevertingClaim(hostclaim.PhaseProbing)
+ moves := MovesClaimPolicy((&mpReader{rec: rpRestoringRecord(provision.RbSourceProbing)}).read)
+ rpRefused(t, moves(t.Context(), c, rpSelection(c, "spawn"), rpIdentity("spawn")), "")
+ })
+ t.Run("the dirty install rows need a dirty rollback", func(t *testing.T) {
+ for phase, action := range map[string]string{provision.RbSourceLocal: "held-timers", provision.RbSourceConfigured: "held-config", provision.RbSourceRestored: "restore"} {
+ rec := rpRestoringRecord(phase)
+ rec.Move.Rollback.Dirty = false
+ c := rpRestoringClaim(hostclaim.PhaseClaimed)
+ moves := MovesClaimPolicy((&mpReader{rec: rec}).read)
+ rpRefused(t, moves(t.Context(), c, rpSelection(c, action), rpIdentity(action)), "")
+ }
+ })
+ t.Run("the install runs at the source's pre-move stamp", func(t *testing.T) {
+ for _, mutate := range []func(*hostclaim.Claim){
+ func(c *hostclaim.Claim) { c.Op.Journal[hostclaim.JournalSourceStamp] = "8" },
+ func(c *hostclaim.Claim) { delete(c.Op.Journal, hostclaim.JournalSourceStamp) },
+ } {
+ c := rpRestoringClaim(hostclaim.PhaseClaimed)
+ mutate(c)
+ moves := MovesClaimPolicy((&mpReader{rec: rpRestoringRecord(provision.RbSourceLocal)}).read)
+ rpRefused(t, moves(t.Context(), c, rpSelection(c, "held-timers"), hostwrite.Identity{}), "")
+ }
+ // The probe's cold admission issues a new stamp (J14): probing is not
+ // bound to S.
+ c := rpRestoringClaim(hostclaim.PhaseProbing)
+ c.Holder.Stamp = 12
+ s := rpSelection(c, "spawn")
+ moves := MovesClaimPolicy((&mpReader{rec: rpRestoringRecord(provision.RbSourceProbing)}).read)
+ if err := moves(t.Context(), c, s, rpIdentity("spawn")); err != nil {
+ t.Fatal(err)
+ }
+ })
+ t.Run("the target's cleaning timers are no policy row", func(t *testing.T) {
+ for _, phase := range []string{hostclaim.PhaseReleasing, hostclaim.PhaseReleased, hostclaim.PhaseClaimed, hostclaim.PhaseProbing, hostclaim.PhaseOpened} {
+ for _, exec := range []string{mpTarget, "", mpSource} {
+ c := rpRestoringClaim(phase)
+ c.Op.Exec = exec
+ s := rpSelection(c, "held-timers")
+ s.Box, s.Region = mpTarget, "uk"
+ moves := MovesClaimPolicy((&mpReader{rec: rpRestoringRecord(provision.RbSourceLocal)}).read)
+ rpRefused(t, moves(t.Context(), c, s, hostwrite.Identity{}), "")
+ }
+ }
+ })
+ t.Run("no reader, a read error, no account", func(t *testing.T) {
+ for _, b := range []cell{restoring(provision.RbSourceLocal, "held-timers"), reverting} {
+ c := b.claim()
+ rpRefused(t, MovesClaimPolicy(nil)(t.Context(), c, rpSelection(c, b.action), rpIdentity(b.action)), "no record reader")
+ cause := errors.New("the store is down")
+ err := MovesClaimPolicy((&mpReader{err: cause}).read)(t.Context(), c, rpSelection(c, b.action), rpIdentity(b.action))
+ rpRefused(t, err, "the record cannot be read")
+ if !errors.Is(err, cause) {
+ t.Fatalf("the cause is not carried: %v", err)
+ }
+ c.Holder.Account = ""
+ rpRefused(t, MovesClaimPolicy((&mpReader{rec: b.record()}).read)(t.Context(), c, rpSelection(c, b.action), rpIdentity(b.action)), "the holder names no account or app")
+ }
+ })
+ t.Run("a role refusal reads no record", func(t *testing.T) {
+ reader := &mpReader{rec: rpRestoringRecord(provision.RbSourceLocal)}
+ c := rpRestoringClaim(hostclaim.PhaseClaimed)
+ s := rpSelection(c, "held-timers")
+ s.Box, s.Region = mpTarget, "uk"
+ rpRefused(t, MovesClaimPolicy(reader.read)(t.Context(), c, s, hostwrite.Identity{}), "")
+ s = rpSelection(c, "held-timers")
+ s.Region = "uk"
+ rpRefused(t, MovesClaimPolicy(reader.read)(t.Context(), c, s, hostwrite.Identity{}), "this region is the move's target region")
+ c.Lost = []string{mpSource}
+ rpRefused(t, MovesClaimPolicy(reader.read)(t.Context(), c, rpSelection(c, "held-timers"), hostwrite.Identity{}), "this box is declared lost")
+ if reader.reads != 0 {
+ t.Fatalf("%d reads", reader.reads)
+ }
+ })
+}
+
+// A recovery renders the current inputs and records them as
+// Rollback.RecoveryInputs; it is never bound to Move.Inputs (spec §7.7,
+// J2-R2-5). The held config and release at configured are that render
+// (nothing is recorded until the CAS that ends configured); from then on
+// every held render and the spawn name the recorded recovery inputs. A
+// revert has no recorded recovery inputs: its render and spawn are bound by
+// shape and never to Move.Inputs. The same difference on a landing is
+// refused.
+func TestMovesClaimPolicyBindsRecoveryInputs(t *testing.T) {
+ pinned, recovered := mpPins(), rpRecovery()
+ try := func(phase, action string, rec *provision.Record, id hostwrite.Identity) error {
+ c := rpRestoringClaim(rpClaimPhase(phase))
+ return MovesClaimPolicy((&mpReader{rec: rec}).read)(t.Context(), c, rpSelection(c, action), id)
+ }
+ cfgOf := func(in *provision.MoveInputs) hostwrite.Identity {
+ return HeldConfigIdentity(in.BundleETags, in.OverlayETag)
+ }
+ t.Run("configured: the render that records them", func(t *testing.T) {
+ rec := rpRestoringRecord(provision.RbSourceConfigured)
+ for _, in := range []*provision.MoveInputs{recovered, pinned} {
+ if err := try(provision.RbSourceConfigured, "held-config", rec, cfgOf(in)); err != nil {
+ t.Fatalf("%+v: %v", in, err)
+ }
+ if err := try(provision.RbSourceConfigured, "held-release", rec, hostwrite.Identity{Release: in.Release}); err != nil {
+ t.Fatalf("%+v: %v", in, err)
+ }
+ }
+ rpRefused(t, try(provision.RbSourceConfigured, "held-config", rec, hostwrite.Identity{Config: cfgOf(recovered).Config, Release: "v1.3.0"}), "the identity does not fit the action")
+ rpRefused(t, try(provision.RbSourceConfigured, "held-release", rec, hostwrite.Identity{}), "the identity does not fit the action")
+ // A replay of configured that finds them recorded is bound to them.
+ rec.Move.Rollback.RecoveryInputs = rpRecovery()
+ if err := try(provision.RbSourceConfigured, "held-config", rec, cfgOf(recovered)); err != nil {
+ t.Fatal(err)
+ }
+ rpRefused(t, try(provision.RbSourceConfigured, "held-config", rec, cfgOf(pinned)), "the config is not the recovery's")
+ rpRefused(t, try(provision.RbSourceConfigured, "held-release", rec, hostwrite.Identity{Release: pinned.Release}), "the release is not the recovery's")
+ })
+ t.Run("probing, dirty: the recorded recovery inputs, not the pins", func(t *testing.T) {
+ rec := rpRestoringRecord(provision.RbSourceProbing)
+ if err := try(provision.RbSourceProbing, "held-config", rec, cfgOf(recovered)); err != nil {
+ t.Fatal(err)
+ }
+ if err := try(provision.RbSourceProbing, "spawn", rec, rpLaunch(recovered)); err != nil {
+ t.Fatal(err)
+ }
+ rpRefused(t, try(provision.RbSourceProbing, "held-config", rec, cfgOf(pinned)), "the config is not the recovery's")
+ rpRefused(t, try(provision.RbSourceProbing, "spawn", rec, rpLaunch(pinned)), "the launch is not the recovery's")
+ rpRefused(t, try(provision.RbSourceProbing, "spawn", rec, hostwrite.Identity{Config: cfgOf(recovered).Config, Release: pinned.Release}), "the launch is not the recovery's")
+ for _, id := range []hostwrite.Identity{{}, {Config: cfgOf(recovered).Config}, {Release: recovered.Release}} {
+ rpRefused(t, try(provision.RbSourceProbing, "spawn", rec, id), "the identity does not fit the action")
+ }
+ rec.Move.Rollback.RecoveryInputs = nil
+ rpRefused(t, try(provision.RbSourceProbing, "held-config", rec, cfgOf(recovered)), "no recorded recovery inputs")
+ rpRefused(t, try(provision.RbSourceProbing, "spawn", rec, rpLaunch(recovered)), "no recorded recovery inputs")
+ rec.Move.Rollback.RecoveryInputs = &provision.MoveInputs{BundleETags: recovered.BundleETags}
+ rpRefused(t, try(provision.RbSourceProbing, "spawn", rec, rpLaunch(recovered)), "no recorded recovery release")
+ })
+ t.Run("probing, clean: the render before the record, the spawn after it", func(t *testing.T) {
+ rec := rpRestoringRecord(provision.RbSourceProbing)
+ rec.Move.Rollback.Dirty, rec.Move.Rollback.RecoveryInputs = false, nil
+ if err := try(provision.RbSourceProbing, "held-config", rec, cfgOf(recovered)); err != nil {
+ t.Fatal(err)
+ }
+ rpRefused(t, try(provision.RbSourceProbing, "spawn", rec, rpLaunch(recovered)), "no recorded recovery inputs")
+ rec.Move.Rollback.RecoveryInputs = rpRecovery()
+ if err := try(provision.RbSourceProbing, "spawn", rec, rpLaunch(recovered)); err != nil {
+ t.Fatal(err)
+ }
+ rpRefused(t, try(provision.RbSourceProbing, "held-config", rec, cfgOf(pinned)), "the config is not the recovery's")
+ })
+ t.Run("reverting: never the pins", func(t *testing.T) {
+ moves := MovesClaimPolicy((&mpReader{rec: rpRevertingRecord()}).read)
+ c := rpRevertingClaim(hostclaim.PhaseProbing)
+ for _, in := range []*provision.MoveInputs{recovered, pinned} {
+ if err := moves(t.Context(), c, rpSelection(c, "held-config"), cfgOf(in)); err != nil {
+ t.Fatal(err)
+ }
+ if err := moves(t.Context(), c, rpSelection(c, "spawn"), rpLaunch(in)); err != nil {
+ t.Fatal(err)
+ }
+ }
+ for _, id := range []hostwrite.Identity{{}, {Config: cfgOf(recovered).Config}, {Release: "v1.3.0"}} {
+ rpRefused(t, moves(t.Context(), c, rpSelection(c, "spawn"), id), "the identity does not fit the action")
+ }
+ rpRefused(t, moves(t.Context(), c, rpSelection(c, "held-config"), rpLaunch(recovered)), "the identity does not fit the action")
+ rpRefused(t, moves(t.Context(), c, rpSelection(c, "held-release"), hostwrite.Identity{Release: "v1.3.0"}), "the phase does not permit the action")
+ })
+ t.Run("a landing: the same difference is refused", func(t *testing.T) {
+ moves, _ := mpPolicy()
+ c := mpClaim(hostclaim.KindLanding, provision.LandConfigured)
+ rpRefused(t, moves(t.Context(), c, mpSelection(c, "held-config"), cfgOf(recovered)), "the config is not the pinned one")
+ rpRefused(t, moves(t.Context(), c, mpSelection(c, "held-release"), hostwrite.Identity{Release: recovered.Release}), "the release is not the pinned one")
+ // A landing record carrying a rollback's recovery inputs still binds
+ // to the pins.
+ rec := mpRecord()
+ rec.Move.Rollback = &provision.MoveRollback{ID: rpRB, RecoveryInputs: rpRecovery()}
+ moves = MovesClaimPolicy((&mpReader{rec: rec}).read)
+ rpRefused(t, moves(t.Context(), c, mpSelection(c, "held-config"), cfgOf(recovered)), "the config is not the pinned one")
+ })
+}