| index ab5636b57..08bec8262 100644 |
| --- a/internal/provision/move_test.go |
| +++ b/internal/provision/move_test.go |
| @@ -1496,3 +1496,69 @@ func TestRollbackIDShape(t *testing.T) { |
| } |
| } |
| } |
| + |
| +// A recovery probe records the inputs it renders before it renders them |
| +// (Plan 4 Task 5; §7.7, J2-R2-5; Task 3b concern 3): MoveRecordRecoveryInputs |
| +// writes Rollback.RecoveryInputs on this rollback while its source is at |
| +// probing, and nothing else. It replaces what configured recorded (a bundle |
| +// published since is the probe's to render, never compared with |
| +// move.inputs), and is refused at any other phase, on another rollback, on |
| +// a stuck record and without a release. |
| +func TestRecoveryInputsRecordedAtProbing(t *testing.T) { |
| + g := cutTestGuard() |
| + now := time.Date(2026, 10, 6, 9, 0, 0, 0, time.UTC) |
| + at := func(phase string, dirty bool) *Record { |
| + rec := rollingBackRecord(t, g, now) |
| + rec.Move.Rollback.TargetPhase, rec.Move.Rollback.SourcePhase, rec.Move.Rollback.Dirty = RbTargetReturned, phase, dirty |
| + rec.Move.Inputs = &MoveInputs{BundleETags: map[string]string{"default": "e1"}, Release: "v7"} |
| + return rec |
| + } |
| + first := MoveInputs{BundleETags: map[string]string{"default": "e2"}, OverlayETag: "o2", Release: "v8"} |
| + later := MoveInputs{BundleETags: map[string]string{"default": "e3"}, Release: "v8"} |
| + for _, dirty := range []bool{false, true} { |
| + rec := at(RbSourceProbing, dirty) |
| + if dirty { |
| + rec.Move.Rollback.RecoveryInputs = &MoveInputs{BundleETags: map[string]string{"default": "e2"}, Release: "v8"} |
| + } |
| + if err := MoveRecordRecoveryInputs(g, rbTestID, first)(rec); err != nil { |
| + t.Fatalf("dirty %v: %v", dirty, err) |
| + } |
| + rb := rec.Move.Rollback |
| + if rb.RecoveryInputs == nil || rb.RecoveryInputs.BundleETags["default"] != "e2" || rb.RecoveryInputs.OverlayETag != "o2" || rb.RecoveryInputs.Release != "v8" { |
| + t.Fatalf("dirty %v: recorded %+v", dirty, rb.RecoveryInputs) |
| + } |
| + if rec.Move.Inputs.BundleETags["default"] != "e1" || rec.Move.Inputs.Release != "v7" || rb.SourcePhase != RbSourceProbing || rec.Move.State != MoveRollingBack { |
| + t.Fatalf("dirty %v: the record changed beyond the inputs: %+v %+v", dirty, rec.Move, rb) |
| + } |
| + // The caller's map is copied, not aliased. |
| + first.BundleETags["default"] = "mutated" |
| + if rb.RecoveryInputs.BundleETags["default"] != "e2" { |
| + t.Fatalf("dirty %v: the record aliases the caller's map", dirty) |
| + } |
| + first.BundleETags["default"] = "e2" |
| + // A restarted probe records what it renders now. |
| + if err := MoveRecordRecoveryInputs(g, rbTestID, later)(rec); err != nil || rb.RecoveryInputs.BundleETags["default"] != "e3" { |
| + t.Fatalf("dirty %v: a later probe: %v %+v", dirty, err, rb.RecoveryInputs) |
| + } |
| + } |
| + for _, phase := range []string{RbSourceWaiting, RbSourceClaimed, RbSourceRestoring, RbSourceVerified, RbSourceConfigured, RbSourceRestored, RbSourceProbed, RbSourceRecovered, RbSourceOpened} { |
| + rec := at(phase, phase == RbSourceConfigured || phase == RbSourceRestored) |
| + if err := MoveRecordRecoveryInputs(g, rbTestID, first)(rec); !errors.Is(err, ErrMoveConflict) || rec.Move.Rollback.RecoveryInputs != nil { |
| + t.Fatalf("at %s: %v %+v", phase, err, rec.Move.Rollback.RecoveryInputs) |
| + } |
| + } |
| + rec := at(RbSourceProbing, false) |
| + if err := MoveRecordRecoveryInputs(g, "rb_bbbbbbbbbbbbbbbbbbbbbbbbbb", first)(rec); !errors.Is(err, ErrMoveConflict) || rec.Move.Rollback.RecoveryInputs != nil { |
| + t.Fatalf("another rollback: %v", err) |
| + } |
| + if err := MoveRecordRecoveryInputs(g, rbTestID, MoveInputs{BundleETags: map[string]string{"default": "e2"}})(rec); err == nil || rec.Move.Rollback.RecoveryInputs != nil { |
| + t.Fatalf("no release: %v", err) |
| + } |
| + if err := MoveRecordRecoveryInputs(MoveGuard{ID: g.ID, Incarnation: strings.Repeat("cd", 32)}, rbTestID, first)(rec); !errors.Is(err, ErrMoveConflict) { |
| + t.Fatalf("another guard: %v", err) |
| + } |
| + rec.Move.State = MoveStuck |
| + if err := MoveRecordRecoveryInputs(g, rbTestID, first)(rec); !errors.Is(err, ErrMoveConflict) || rec.Move.Rollback.RecoveryInputs != nil { |
| + t.Fatalf("stuck: %v", err) |
| + } |
| +} |