| index b8b3e5419..9610093ce 100644 |
| --- a/internal/provision/move_test.go |
| +++ b/internal/provision/move_test.go |
| @@ -166,6 +166,11 @@ func TestGuardedCutAndAbortComplete(t *testing.T) { |
| t.Fatalf("cut from preparing: %v", err) |
| } |
| rec.Move.State = MoveReady |
| + // G1: a ready move with no pinned inputs cannot be cut. |
| + if err := MoveGuardedCut(g)(rec); !errors.Is(err, ErrMoveConflict) || rec.Move.State != MoveReady || rec.Move.CutPhase != "" { |
| + t.Fatalf("cut on nil inputs: %v %+v", err, rec.Move) |
| + } |
| + rec.Move.Inputs = &MoveInputs{Release: "v1"} |
| if err := MoveGuardedCut(g)(rec); err != nil || rec.Move.State != MoveCutting || rec.Move.CutPhase != CutRequested { |
| t.Fatalf("cut from ready: %v %+v", err, rec.Move) |
| } |
| @@ -257,3 +262,273 @@ func TestManifestReadyPathDefault(t *testing.T) { |
| t.Fatal("empty declaration keeps the default") |
| } |
| } |
| + |
| +func cutTestGuard() MoveGuard { |
| + return MoveGuard{ID: "mv_abcdefghijklmnopqrstuvwxyz", Incarnation: strings.Repeat("ab", 32)} |
| +} |
| + |
| +func TestCutPhasesAdvanceInOrderUnderTheGuard(t *testing.T) { |
| + g := cutTestGuard() |
| + now := time.Date(2026, 10, 6, 9, 0, 0, 0, time.UTC) |
| + rec := &Record{Region: "ie", Move: &MoveState{ID: g.ID, Incarnation: g.Incarnation, From: "ie", To: "uk", State: MoveReady, Inputs: &MoveInputs{Release: "v1"}}} |
| + if err := MoveGuardedCut(g)(rec); err != nil || rec.Move.State != MoveCutting || rec.Move.CutPhase != CutRequested { |
| + t.Fatalf("auto cut: %v %+v", err, rec.Move) |
| + } |
| + deadline := now.Add(115 * time.Second) |
| + if err := MoveAdmit(g, now, 120, deadline)(rec); err != nil || rec.Move.CutPhase != CutAdmitted || !rec.Move.CutDeadline.Equal(deadline) || !rec.Move.CutAt.Equal(now) || rec.Move.CutBudgetS != 120 || rec.Move.Inputs.Release != "v1" { |
| + t.Fatalf("admit: %v %+v", err, rec.Move) |
| + } |
| + // Wrong incarnation: refused. |
| + if err := MoveCutPhase(MoveGuard{ID: g.ID, Incarnation: strings.Repeat("cd", 32)}, CutAdmitted, CutStopping, nil)(rec); !errors.Is(err, ErrMoveConflict) { |
| + t.Fatalf("wrong guard: %v", err) |
| + } |
| + // Skipping a phase: refused. |
| + if err := MoveCutPhase(g, CutStopping, CutStopped, nil)(rec); !errors.Is(err, ErrMoveConflict) { |
| + t.Fatalf("skip: %v", err) |
| + } |
| + // Source digests are recorded at stopped only. |
| + if err := MoveRecordSource(g, MoveSource{FinalTXID: 1})(rec); !errors.Is(err, ErrMoveConflict) || rec.Move.Source != nil { |
| + t.Fatalf("source before stopped: %v", err) |
| + } |
| + for _, step := range [][2]string{{CutAdmitted, CutStopping}, {CutStopping, CutStopped}} { |
| + if err := MoveCutPhase(g, step[0], step[1], nil)(rec); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + if err := MoveRecordSource(g, MoveSource{FinalTXID: 42, TimersSHA256: "sha256:t"})(rec); err != nil || rec.Move.Source.FinalTXID != 42 { |
| + t.Fatalf("source: %v", err) |
| + } |
| + for _, step := range [][2]string{{CutStopped, CutStaged}, {CutStaged, CutArchived}} { |
| + if err := MoveCutPhase(g, step[0], step[1], nil)(rec); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + // Abort is refused past requested. |
| + if err := MoveGuardedAbort(g)(rec); !errors.Is(err, ErrMoveConflict) { |
| + t.Fatal("abort past admitted") |
| + } |
| + // The cut cannot step to flipped: only MoveFlip writes it. |
| + if err := MoveCutPhase(g, CutArchived, CutFlipped, nil)(rec); !errors.Is(err, ErrMoveConflict) || rec.Move.CutPhase != CutArchived { |
| + t.Fatalf("a phase step to flipped: %v", err) |
| + } |
| + ld := now.Add(10 * time.Minute) |
| + if err := MoveFlip(g, now, ld)(rec); err != nil || rec.Region != "uk" || rec.Move.State != MoveLanding || rec.Move.CutPhase != CutFlipped || rec.Move.LandingPhase != LandChecking || !rec.Move.LandingDeadline.Equal(ld) || !rec.Move.FlippedAt.Equal(now) { |
| + t.Fatalf("flip: %v %+v", err, rec) |
| + } |
| + if err := MoveFlip(g, now, ld)(rec); !errors.Is(err, ErrMoveConflict) { |
| + t.Fatal("a second flip") |
| + } |
| + if err := MoveLandingPhase(g, LandChecking, LandParking, nil)(rec); err != nil || rec.Move.LandingPhase != LandParking { |
| + t.Fatalf("landing phase: %v", err) |
| + } |
| + if err := MoveLandingPhase(g, LandParking, LandPublished, nil)(rec); !errors.Is(err, ErrMoveConflict) { |
| + t.Fatal("a landing phase skipped") |
| + } |
| + stamped := false |
| + if err := MoveLandingPhase(g, LandParking, LandBuilding, func(m *MoveState) { stamped = m.LandingPhase == LandBuilding })(rec); err != nil || !stamped { |
| + t.Fatalf("landing stamp: %v", err) |
| + } |
| + if err := MoveLandingPhase(MoveGuard{ID: g.ID, Incarnation: strings.Repeat("cd", 32)}, LandBuilding, LandPublished, nil)(rec); !errors.Is(err, ErrMoveConflict) { |
| + t.Fatal("landing phase under a stale guard") |
| + } |
| +} |
| + |
| +// Every legal cut and landing step, and only those, under both orders. |
| +func TestCutAndLandingStepsAreTheOrderedPairsOnly(t *testing.T) { |
| + g := cutTestGuard() |
| + cuts := []string{CutRequested, CutAdmitted, CutStopping, CutStopped, CutStaged, CutArchived} |
| + all := append(append([]string{}, cuts...), CutFlipped, "") |
| + for _, from := range all { |
| + for _, to := range all { |
| + rec := &Record{Move: &MoveState{ID: g.ID, Incarnation: g.Incarnation, State: MoveCutting, CutPhase: from, Inputs: &MoveInputs{}}} |
| + err := MoveCutPhase(g, from, to, nil)(rec) |
| + want := false |
| + for i := 0; i+1 < len(cuts); i++ { |
| + if cuts[i] == from && cuts[i+1] == to { |
| + want = true |
| + } |
| + } |
| + // archived to flipped is MoveFlip's, not a phase step's. |
| + if from == CutArchived && to == CutFlipped { |
| + want = false |
| + } |
| + if (err == nil) != want { |
| + t.Fatalf("cut %q to %q: err %v, want ok=%v", from, to, err, want) |
| + } |
| + } |
| + } |
| + lands := []string{LandChecking, LandParking, LandBuilding, LandPublished, LandLocal, LandConfigured, LandRestored, LandProbing, LandProbed} |
| + for i, from := range lands { |
| + for j, to := range lands { |
| + rec := &Record{Move: &MoveState{ID: g.ID, Incarnation: g.Incarnation, State: MoveLanding, LandingPhase: from}} |
| + err := MoveLandingPhase(g, from, to, nil)(rec) |
| + if (err == nil) != (j == i+1) { |
| + t.Fatalf("landing %q to %q: err %v", from, to, err) |
| + } |
| + } |
| + } |
| + // A landing step from a state that is not landing is refused. |
| + rec := &Record{Move: &MoveState{ID: g.ID, Incarnation: g.Incarnation, State: MoveCutting, LandingPhase: LandChecking}} |
| + if err := MoveLandingPhase(g, LandChecking, LandParking, nil)(rec); !errors.Is(err, ErrMoveConflict) { |
| + t.Fatal("landing step in cutting") |
| + } |
| +} |
| + |
| +func TestBackToReadyAndRevert(t *testing.T) { |
| + g := cutTestGuard() |
| + now := time.Now() |
| + rec := &Record{Region: "ie", Move: &MoveState{ID: g.ID, Incarnation: g.Incarnation, From: "ie", To: "uk", State: MoveCutting, CutPhase: CutAdmitted, CutAt: &now, CutBudgetS: 120, CutDeadline: &now, Inputs: &MoveInputs{Release: "v1"}}} |
| + if err := MoveBackToReady(g)(rec); err != nil || rec.Move.State != MoveReady || rec.Move.CutPhase != "" || rec.Move.CutAt != nil || rec.Move.CutBudgetS != 0 || rec.Move.CutDeadline != nil || rec.Move.Inputs != nil { |
| + t.Fatalf("back to ready: %v %+v", err, rec.Move) |
| + } |
| + if err := MoveBackToReady(g)(rec); !errors.Is(err, ErrMoveConflict) { |
| + t.Fatal("back to ready from ready") |
| + } |
| + for _, ph := range []string{CutStopping, CutStopped, CutStaged, CutArchived} { |
| + rec.Move.State, rec.Move.CutPhase = MoveCutting, ph |
| + if err := MoveBackToReady(g)(rec); !errors.Is(err, ErrMoveConflict) || rec.Move.State != MoveCutting { |
| + t.Fatalf("back to ready from %s: %v", ph, err) |
| + } |
| + if err := MoveRevert(g, RevertStopEvidenceBudget)(rec); err != nil || rec.Move.State != MoveReverting || rec.Move.Reason != "stop evidence budget" { |
| + t.Fatalf("revert from %s: %v %+v", ph, err, rec.Move) |
| + } |
| + } |
| + // Not from requested or admitted: nothing ran yet, so back-to-ready is the exit there. |
| + for _, ph := range []string{CutRequested, CutAdmitted, CutFlipped, ""} { |
| + rec2 := &Record{Region: "ie", Move: &MoveState{ID: g.ID, Incarnation: g.Incarnation, State: MoveCutting, CutPhase: ph}} |
| + if err := MoveRevert(g, "x")(rec2); !errors.Is(err, ErrMoveConflict) || rec2.Move.State != MoveCutting { |
| + t.Fatalf("revert from %q: %v", ph, err) |
| + } |
| + } |
| + // Only a recorded sentence can be the reason: anything else is refused |
| + // and nothing is written. |
| + rec4 := &Record{Region: "ie", Move: &MoveState{ID: g.ID, Incarnation: g.Incarnation, State: MoveCutting, CutPhase: CutStopped}} |
| + if err := MoveRevert(g, "some error text")(rec4); err == nil || errors.Is(err, ErrMoveConflict) || rec4.Move.State != MoveCutting || rec4.Move.Reason != "" { |
| + t.Fatalf("an unrecorded reason: %v %+v", err, rec4.Move) |
| + } |
| + // Revert is a cut's recovery only. |
| + rec3 := &Record{Region: "uk", Move: &MoveState{ID: g.ID, Incarnation: g.Incarnation, State: MoveLanding, CutPhase: CutFlipped}} |
| + if err := MoveRevert(g, "x")(rec3); !errors.Is(err, ErrMoveConflict) { |
| + t.Fatal("revert from landing") |
| + } |
| +} |
| + |
| +// G1: the whole loop. Inputs pinned at readiness, drift found at admission, |
| +// back to ready (Inputs nil), repin, cut, admit. |
| +func TestInputsLoopPinDriftBackRepinCutAdmit(t *testing.T) { |
| + g := cutTestGuard() |
| + now := time.Date(2026, 10, 6, 9, 0, 0, 0, time.UTC) |
| + pin := MoveInputs{BundleETags: map[string]string{"app": "e1"}, Release: "v1"} |
| + rec := &Record{Region: "ie", Move: &MoveState{ID: g.ID, Incarnation: g.Incarnation, From: "ie", To: "uk", State: MovePreparing}} |
| + if err := MoveGuardedPromoteReady(g, now, pin)(rec); err != nil || rec.Move.Inputs == nil { |
| + t.Fatalf("pin: %v", err) |
| + } |
| + // Repin is legal only with Inputs nil. |
| + if err := MoveGuardedRepin(g, MoveInputs{Release: "v2"})(rec); !errors.Is(err, ErrMoveConflict) || rec.Move.Inputs.Release != "v1" { |
| + t.Fatalf("repin over a pin: %v", err) |
| + } |
| + if err := MoveGuardedCut(g)(rec); err != nil { |
| + t.Fatal(err) |
| + } |
| + // Drift found: back to ready clears the pin. |
| + if err := MoveBackToReady(g)(rec); err != nil || rec.Move.Inputs != nil { |
| + t.Fatalf("drift, back to ready: %v %+v", err, rec.Move) |
| + } |
| + // A ready move with no pin cannot be cut. |
| + if err := MoveGuardedCut(g)(rec); !errors.Is(err, ErrMoveConflict) || rec.Move.State != MoveReady { |
| + t.Fatalf("cut on nil inputs: %v", err) |
| + } |
| + // Repin: guarded, legal in ready only. |
| + if err := MoveGuardedRepin(MoveGuard{ID: g.ID, Incarnation: strings.Repeat("cd", 32)}, MoveInputs{Release: "v2"})(rec); !errors.Is(err, ErrMoveConflict) || rec.Move.Inputs != nil { |
| + t.Fatalf("repin under a stale guard: %v", err) |
| + } |
| + fresh := MoveInputs{BundleETags: map[string]string{"app": "e2"}, Release: "v2"} |
| + if err := MoveGuardedRepin(g, fresh)(rec); err != nil || rec.Move.Inputs == nil || rec.Move.Inputs.Release != "v2" || rec.Move.State != MoveReady { |
| + t.Fatalf("repin: %v %+v", err, rec.Move) |
| + } |
| + fresh.BundleETags["app"] = "changed" |
| + if rec.Move.Inputs.BundleETags["app"] != "e2" { |
| + t.Fatal("the repin aliases the caller's map") |
| + } |
| + if err := MoveGuardedRepin(g, MoveInputs{Release: "v3"})(rec); !errors.Is(err, ErrMoveConflict) || rec.Move.Inputs.Release != "v2" { |
| + t.Fatalf("second repin: %v", err) |
| + } |
| + if err := MoveGuardedCut(g)(rec); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := MoveGuardedRepin(g, MoveInputs{Release: "v3"})(rec); !errors.Is(err, ErrMoveConflict) { |
| + t.Fatalf("repin in cutting: %v", err) |
| + } |
| + // Admit pins nothing: it stamps the clock fields and keeps the pin. |
| + if err := MoveAdmit(g, now, 120, now.Add(time.Minute))(rec); err != nil || rec.Move.CutPhase != CutAdmitted || rec.Move.Inputs.Release != "v2" { |
| + t.Fatalf("admit: %v %+v", err, rec.Move) |
| + } |
| + // Admit on a cut with no pin is refused and writes nothing. |
| + bare := &Record{Move: &MoveState{ID: g.ID, Incarnation: g.Incarnation, State: MoveCutting, CutPhase: CutRequested}} |
| + if err := MoveAdmit(g, now, 120, now.Add(time.Minute))(bare); !errors.Is(err, ErrMoveConflict) || bare.Move.CutPhase != CutRequested || bare.Move.CutAt != nil || bare.Move.CutBudgetS != 0 { |
| + t.Fatalf("admit with no pin: %v %+v", err, bare.Move) |
| + } |
| +} |
| + |
| +// G18: the flip refuses any state but cutting/archived, so a record decision |
| +// written first (the C7 conversion's pre-flip guard, a revert, an abort) |
| +// wins over a flip that was already in flight. |
| +func TestFlipRefusesAnyStateButCuttingArchived(t *testing.T) { |
| + g := cutTestGuard() |
| + now := time.Now() |
| + for _, c := range []struct{ state, phase string }{ |
| + {MoveReverting, CutArchived}, {MoveAborting, CutArchived}, {MoveRollingBack, CutArchived}, {MoveStuck, CutArchived}, |
| + {MoveReady, ""}, {MoveCutting, CutStaged}, {MoveCutting, CutRequested}, {MoveCutting, CutFlipped}, {MoveLanding, CutFlipped}, |
| + } { |
| + rec := &Record{Region: "ie", Move: &MoveState{ID: g.ID, Incarnation: g.Incarnation, From: "ie", To: "uk", State: c.state, CutPhase: c.phase}} |
| + if err := MoveFlip(g, now, now.Add(time.Minute))(rec); !errors.Is(err, ErrMoveConflict) || rec.Region != "ie" || rec.Move.State != c.state || rec.Move.FlippedAt != nil { |
| + t.Fatalf("flip from %s/%s: %v region %s", c.state, c.phase, err, rec.Region) |
| + } |
| + } |
| + // The sequence that matters: a revert is recorded, then the late flip. |
| + rec := &Record{Region: "ie", Move: &MoveState{ID: g.ID, Incarnation: g.Incarnation, From: "ie", To: "uk", State: MoveCutting, CutPhase: CutArchived}} |
| + if err := MoveRevert(g, RevertStopEvidenceBudget)(rec); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := MoveFlip(g, now, now.Add(time.Minute))(rec); !errors.Is(err, ErrMoveConflict) || rec.Region != "ie" || rec.Move.State != MoveReverting { |
| + t.Fatalf("late flip after a revert: %v %+v", err, rec) |
| + } |
| + // And a stale guard cannot flip an archived cut. |
| + rec = &Record{Region: "ie", Move: &MoveState{ID: g.ID, Incarnation: g.Incarnation, From: "ie", To: "uk", State: MoveCutting, CutPhase: CutArchived}} |
| + if err := MoveFlip(MoveGuard{ID: g.ID, Incarnation: strings.Repeat("cd", 32)}, now, now.Add(time.Minute))(rec); !errors.Is(err, ErrMoveConflict) || rec.Region != "ie" { |
| + t.Fatalf("flip under a stale guard: %v", err) |
| + } |
| +} |
| + |
| +// The transitions run through SetRecordMove end to end, and the flip's |
| +// region change and state change land in one record write. |
| +func TestFlipIsOneRecordWrite(t *testing.T) { |
| + s := release.NewDirStore(t.TempDir()) |
| + rec := moveTestRecord(t, s) |
| + g := cutTestGuard() |
| + now := time.Date(2026, 10, 6, 9, 0, 0, 0, time.UTC) |
| + ctx := context.Background() |
| + seq := []func(*Record) error{ |
| + MoveStartTransition(MoveStart{ID: g.ID, Incarnation: g.Incarnation, From: "ie", To: "uk", Cut: "auto", By: "ops@example.com", Now: now}), |
| + MoveGuardedPromoteReady(g, now, MoveInputs{Release: "v1"}), |
| + MoveGuardedCut(g), |
| + MoveAdmit(g, now, 120, now.Add(115*time.Second)), |
| + MoveCutPhase(g, CutAdmitted, CutStopping, nil), |
| + MoveCutPhase(g, CutStopping, CutStopped, nil), |
| + MoveCutPhase(g, CutStopped, CutStaged, nil), |
| + MoveCutPhase(g, CutStaged, CutArchived, nil), |
| + } |
| + for i, tr := range seq { |
| + if _, err := SetRecordMove(ctx, s, rec.Account, rec.App, rec.Host, tr); err != nil { |
| + t.Fatalf("step %d: %v", i, err) |
| + } |
| + } |
| + got, err := SetRecordMove(ctx, s, rec.Account, rec.App, rec.Host, MoveFlip(g, now, now.Add(10*time.Minute))) |
| + if err != nil || got.Region != "uk" || got.Move.State != MoveLanding { |
| + t.Fatalf("flip: %v %+v", err, got) |
| + } |
| + read, _ := GetRecord(ctx, s, rec.Account, rec.App, rec.Host) |
| + if read.Region != "uk" || read.Move.State != MoveLanding || read.Move.LandingPhase != LandChecking || read.Move.Inputs == nil || read.Move.Inputs.Release != "v1" { |
| + t.Fatalf("persisted: %+v", read.Move) |
| + } |
| +} |