| 1 | package provision |
| 2 | |
| 3 | import ( |
| 4 | "errors" |
| 5 | "strings" |
| 6 | "testing" |
| 7 | "time" |
| 8 | ) |
| 9 | |
| 10 | func c7RelocationFixture() (*Record, MoveGuard, string, string) { |
| 11 | g := MoveGuard{ID: "mv_abcdefghijklmnopqrstuvwxyz", Incarnation: strings.Repeat("a", 64)} |
| 12 | en := strings.Repeat("b", 32) |
| 13 | fence := `{"enrollment":"` + en + `","key":"control/box-fences/` + en + `.json","sha256":"` + strings.Repeat("c", 64) + `"}` |
| 14 | rec := &Record{Region: "ie", Move: &MoveState{ID: g.ID, Incarnation: g.Incarnation, From: "ie", To: "uk", State: MoveCutting, CutPhase: CutArchived}} |
| 15 | return rec, g, en, fence |
| 16 | } |
| 17 | |
| 18 | func c7Decide(g MoveGuard, en, region, fence string, now time.Time) func(*Record) error { |
| 19 | return MoveRelocatingDecision(g, "source", en, region, fence, 7, "claim-etag", strings.Repeat("d", 64), strings.Repeat("e", 64), now) |
| 20 | } |
| 21 | |
| 22 | // The decision and the flip exclude each other under one record CAS, in |
| 23 | // both orders. MoveComplete is the flip that exists on main today; Moves |
| 24 | // Plan 2's MoveFlip must add the same negative case. |
| 25 | func TestC7RecordDecisionAndFlipExcludeEachOther(t *testing.T) { |
| 26 | now := time.Now().UTC() |
| 27 | rec, g, en, fence := c7RelocationFixture() |
| 28 | if err := c7Decide(g, en, "ie", fence, now)(rec); err != nil { |
| 29 | t.Fatal(err) |
| 30 | } |
| 31 | if rec.Move.State != MoveRelocating || rec.Move.Relocation.PreviousState != MoveCutting || rec.Move.Relocation.PreviousCutPhase != CutArchived || rec.Move.To != "uk" || rec.Region != "ie" { |
| 32 | t.Fatalf("lost source or move history: %+v", rec.Move) |
| 33 | } |
| 34 | for name, tr := range map[string]func(*Record) error{ |
| 35 | "complete": MoveComplete(), |
| 36 | "guarded-cut": MoveGuardedCut(g), |
| 37 | "guarded-abort": MoveGuardedAbort(g), |
| 38 | "abort": MoveAbort(), |
| 39 | "abort-complete": MoveGuardedAbortComplete(g, now), |
| 40 | "promote": MoveGuardedPromoteReady(g, now, MoveInputs{}), |
| 41 | "mark-cutting": MoveMarkCutting(), |
| 42 | "start": MoveStartTransition(MoveStart{ID: g.ID, Incarnation: g.Incarnation, From: "ie", To: "uk", Cut: "auto", Now: now}), |
| 43 | } { |
| 44 | probe := *rec |
| 45 | m := *rec.Move |
| 46 | probe.Move = &m |
| 47 | if err := tr(&probe); !errors.Is(err, ErrMoveConflict) || probe.Move.State != MoveRelocating || probe.Region != "ie" { |
| 48 | t.Fatalf("%s crossed a durable relocation decision: %v", name, err) |
| 49 | } |
| 50 | } |
| 51 | if err := c7Decide(g, en, "ie", fence, now.Add(time.Hour))(rec); err != nil || !rec.Move.Relocation.DecidedAt.Equal(now) { |
| 52 | t.Fatal("replay refreshed decision", err) |
| 53 | } |
| 54 | other := MoveRelocatingDecision(g, "source", en, "ie", fence, 7, "claim-etag", strings.Repeat("f", 64), strings.Repeat("e", 64), now) |
| 55 | if err := other(rec); !errors.Is(err, ErrMoveConflict) { |
| 56 | t.Fatal("a different moving operation replayed the decision", err) |
| 57 | } |
| 58 | |
| 59 | rec, g, en, fence = c7RelocationFixture() |
| 60 | if err := MoveComplete()(rec); err != nil { |
| 61 | t.Fatal(err) |
| 62 | } |
| 63 | if err := c7Decide(g, en, "ie", fence, now)(rec); !errors.Is(err, ErrMoveConflict) { |
| 64 | t.Fatal("conversion crossed flip", err) |
| 65 | } |
| 66 | } |
| 67 | |
| 68 | func TestC7RecordDecisionRefusals(t *testing.T) { |
| 69 | now := time.Now().UTC() |
| 70 | for _, tc := range []string{"wrong-region", "record-region", "flipped", "rollback", "aborting", "landing", "wrong-move", "fence-enrollment", "fence-key", "zero-time"} { |
| 71 | t.Run(tc, func(t *testing.T) { |
| 72 | rec, g, en, fence := c7RelocationFixture() |
| 73 | region, now := "ie", now |
| 74 | switch tc { |
| 75 | case "wrong-region": |
| 76 | region = "uk" |
| 77 | case "record-region": |
| 78 | rec.Region = "uk" |
| 79 | case "flipped": |
| 80 | at := now |
| 81 | rec.Move.FlippedAt = &at |
| 82 | case "rollback": |
| 83 | rec.Move.Rollback = &MoveRollback{ID: "rb"} |
| 84 | case "aborting": |
| 85 | rec.Move.State = MoveAborting |
| 86 | case "landing": |
| 87 | rec.Move.State = MoveLanding |
| 88 | case "wrong-move": |
| 89 | g.ID = "mv_bcdefghijklmnopqrstuvwxyza" |
| 90 | case "fence-enrollment": |
| 91 | fence = strings.Replace(fence, `"enrollment":"`+en, `"enrollment":"`+strings.Repeat("9", 32), 1) |
| 92 | case "fence-key": |
| 93 | fence = strings.Replace(fence, "control/box-fences/", "control/other/", 1) |
| 94 | case "zero-time": |
| 95 | now = time.Time{} |
| 96 | } |
| 97 | before := *rec.Move |
| 98 | if err := c7Decide(g, en, region, fence, now)(rec); !errors.Is(err, ErrMoveConflict) { |
| 99 | t.Fatalf("accepted: %v", err) |
| 100 | } |
| 101 | if rec.Move.State != before.State || rec.Move.Relocation != nil { |
| 102 | t.Fatal("a refused decision changed the record") |
| 103 | } |
| 104 | }) |
| 105 | } |
| 106 | } |
| 107 | |
| 108 | func TestC7LostTargetParkStampIsOriginalAndConditional(t *testing.T) { |
| 109 | now := time.Now().UTC() |
| 110 | g := MoveGuard{ID: "mv_abcdefghijklmnopqrstuvwxyz", Incarnation: strings.Repeat("a", 64)} |
| 111 | rec := &Record{Region: "ie", Move: &MoveState{ID: g.ID, Incarnation: g.Incarnation, State: MoveRollingBack, Rollback: &MoveRollback{ID: "rb-1", TargetPhase: RbTargetReleased}}} |
| 112 | if err := MoveLostTargetParked(g, "other", now)(rec); err == nil { |
| 113 | t.Fatal("other rollback stamped") |
| 114 | } |
| 115 | rec.Move.Rollback.Dirty = true |
| 116 | if err := MoveLostTargetParked(g, "rb-1", now)(rec); err == nil { |
| 117 | t.Fatal("uncommitted dirty loss stamped") |
| 118 | } |
| 119 | rec.Move.Rollback.Dirty = false |
| 120 | if err := MoveLostTargetParked(g, "rb-1", now)(rec); err != nil { |
| 121 | t.Fatal(err) |
| 122 | } |
| 123 | if err := MoveLostTargetParked(g, "rb-1", now.Add(time.Hour))(rec); err != nil || !rec.Move.Rollback.TargetParkedAt.Equal(now) || rec.Move.Rollback.TargetPhase != RbTargetCleaned { |
| 124 | t.Fatal("replay changed stamp", err) |
| 125 | } |
| 126 | } |
| 127 | |