carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1package provision
2
3import (
4 "context"
5 "encoding/json"
6 "errors"
7 "reflect"
8 "testing"
9 "time"
10
11 "github.com/carlosframework/platform/internal/release"
12)
13
14// The pre-copy (offline-staging): OfflineStage creates the move with the
15// instance still serving, OfflineStartStaged takes it to stopping, and abort
16// from staging ends a move that was never offline.
17
18func TestOfflineStageThenStartThenTheWholeMove(t *testing.T) {
19 ctx := context.Background()
20 now := time.Date(2026, 10, 21, 9, 0, 0, 0, time.UTC)
21 s := release.NewDirStore(t.TempDir())
22 rec := offlineRecord(t, s)
23
24 got, err := SetRecordMove(ctx, s, rec.Account, rec.App, rec.Host, OfflineStage(offID, "", "uk", "ops@example.com", stageAt, now))
25 if err != nil {
26 t.Fatal(err)
27 }
28 m := got.Move
29 if !m.Offline() || m.State != OfflineStaging || m.ID != offID || m.To != "uk" || got.Region != "" ||
30 m.StagedAt == nil || !m.StagedAt.Equal(now) || !m.RequestedAt.Equal(now) || m.Stager != "box-a" || m.StageBucket != "data-uk" || m.StageRegion != "eu-west-2" {
31 t.Fatalf("staged record: %+v region %q", m, got.Region)
32 }
33 // Staging is a move in flight: no second move starts beside it.
34 if _, err := SetRecordMove(ctx, s, rec.Account, rec.App, rec.Host, OfflineStart(otherOff, "", "uk", "ops@example.com", now)); !errors.Is(err, ErrMoveConflict) {
35 t.Fatalf("a start beside a staged move: %v", err)
36 }
37 if _, err := SetRecordMove(ctx, s, rec.Account, rec.App, rec.Host, OfflineStage(otherOff, "", "us", "ops@example.com", stageAt, now)); !errors.Is(err, ErrMoveConflict) {
38 t.Fatalf("a stage beside a staged move: %v", err)
39 }
40
41 start := now.Add(10 * time.Minute)
42 got, err = SetRecordMove(ctx, s, rec.Account, rec.App, rec.Host, OfflineStartStaged(offID, "other@example.com", start))
43 if err != nil {
44 t.Fatal(err)
45 }
46 if got.Move.State != OfflineStopping || got.Move.RequestedBy != "other@example.com" || !got.Move.RequestedAt.Equal(start) || !got.Move.StagedAt.Equal(now) || got.Move.Stager != "box-a" {
47 t.Fatalf("started: %+v", got.Move)
48 }
49 // The rest of the move is the ordinary path; offline time counts from
50 // the start, not the stage: start at +10m, open at +30m.
51 got = offlineWalk(t, s, rec, offlineSteps(now)[1:])
52 if got.Move != nil || got.LastMove.Outcome != OfflineOutcomeDone || got.LastMove.OfflineS != 1200 {
53 t.Fatalf("finished: %+v", got.LastMove)
54 }
55}
56
57func TestOfflineStartStagedRefusals(t *testing.T) {
58 ctx := context.Background()
59 now := time.Date(2026, 10, 21, 9, 0, 0, 0, time.UTC)
60
61 // From every state but staging, and with another id.
62 steps := offlineSteps(now)
63 for walked := 1; walked <= 7; walked++ {
64 s := release.NewDirStore(t.TempDir())
65 rec := offlineRecord(t, s)
66 offlineWalk(t, s, rec, steps[:walked])
67 before := offlineGet(t, s, rec)
68 if _, err := SetRecordMove(ctx, s, rec.Account, rec.App, rec.Host, OfflineStartStaged(offID, "ops@example.com", now)); !errors.Is(err, ErrMoveConflict) {
69 t.Fatalf("start-staged from %s: %v", before.Move.State, err)
70 }
71 if after := offlineGet(t, s, rec); !reflect.DeepEqual(after, before) {
72 t.Fatalf("a refused start-staged from %s changed the record", before.Move.State)
73 }
74 }
75 s := release.NewDirStore(t.TempDir())
76 rec := offlineRecord(t, s)
77 if _, err := SetRecordMove(ctx, s, rec.Account, rec.App, rec.Host, OfflineStage(offID, "", "uk", "ops@example.com", stageAt, now)); err != nil {
78 t.Fatal(err)
79 }
80 if _, err := SetRecordMove(ctx, s, rec.Account, rec.App, rec.Host, OfflineStartStaged(otherOff, "ops@example.com", now)); !errors.Is(err, ErrMoveConflict) {
81 t.Fatalf("start-staged with another id: %v", err)
82 }
83 // A region written under the staged move.
84 if _, err := updateRecord(ctx, s, rec.Account, rec.App, rec.Host, func(r *Record) error { r.Region = "us"; return nil }); err != nil {
85 t.Fatal(err)
86 }
87 if _, err := SetRecordMove(ctx, s, rec.Account, rec.App, rec.Host, OfflineStartStaged(offID, "ops@example.com", now)); !errors.Is(err, ErrMoveConflict) {
88 t.Fatalf("start-staged over a moved region: %v", err)
89 }
90
91 // No other step leaves staging: seal through finish all refuse.
92 s = release.NewDirStore(t.TempDir())
93 rec = offlineRecord(t, s)
94 if _, err := SetRecordMove(ctx, s, rec.Account, rec.App, rec.Host, OfflineStage(offID, "", "uk", "ops@example.com", stageAt, now)); err != nil {
95 t.Fatal(err)
96 }
97 before := offlineGet(t, s, rec)
98 for _, st := range steps[1:] {
99 if _, err := SetRecordMove(ctx, s, rec.Account, rec.App, rec.Host, st.mut()); !errors.Is(err, ErrMoveConflict) {
100 t.Errorf("%s from staging: %v", st.name, err)
101 }
102 }
103 if after := offlineGet(t, s, rec); !reflect.DeepEqual(after, before) {
104 t.Fatal("a refused step changed the staged record")
105 }
106}
107
108// A start in the same instant as the stage still lands after it, so the
109// finish can tell the two apart.
110func TestOfflineStartStagedIsAlwaysAfterTheStage(t *testing.T) {
111 ctx := context.Background()
112 now := time.Date(2026, 10, 21, 9, 0, 0, 0, time.UTC)
113 s := release.NewDirStore(t.TempDir())
114 rec := offlineRecord(t, s)
115 if _, err := SetRecordMove(ctx, s, rec.Account, rec.App, rec.Host, OfflineStage(offID, "", "uk", "ops@example.com", stageAt, now)); err != nil {
116 t.Fatal(err)
117 }
118 got, err := SetRecordMove(ctx, s, rec.Account, rec.App, rec.Host, OfflineStartStaged(offID, "ops@example.com", now.Add(-time.Second)))
119 if err != nil {
120 t.Fatal(err)
121 }
122 if !got.Move.RequestedAt.After(*got.Move.StagedAt) || !got.Move.StartedOffline() {
123 t.Fatalf("start %v is not after the stage %v", got.Move.RequestedAt, got.Move.StagedAt)
124 }
125}
126
127// Abort from staging: reverting with the region untouched, then a finish
128// that records the move aborted and never offline. Aborted after its start,
129// a staged move counts its offline time from the start.
130func TestOfflineAbortFromStaging(t *testing.T) {
131 ctx := context.Background()
132 now := time.Date(2026, 10, 21, 9, 0, 0, 0, time.UTC)
133 s := release.NewDirStore(t.TempDir())
134 rec := offlineRecord(t, s)
135 if _, err := SetRecordMove(ctx, s, rec.Account, rec.App, rec.Host, OfflineStage(offID, "", "uk", "ops@example.com", stageAt, now)); err != nil {
136 t.Fatal(err)
137 }
138 got, err := SetRecordMove(ctx, s, rec.Account, rec.App, rec.Host, OfflineAbort(offID))
139 if err != nil || got.Move.State != OfflineReverting || got.Region != "" {
140 t.Fatalf("abort from staging: %v %+v", err, got)
141 }
142 done, err := SetRecordMove(ctx, s, rec.Account, rec.App, rec.Host, OfflineFinish(offID, now.Add(time.Hour)))
143 if err != nil || done.Move != nil || done.LastMove.Outcome != OfflineOutcomeAborted || done.LastMove.OfflineS != 0 {
144 t.Fatalf("finish after abort from staging: %v %+v", err, done.LastMove)
145 }
146
147 s = release.NewDirStore(t.TempDir())
148 rec = offlineRecord(t, s)
149 for _, mut := range []func(*Record) error{
150 OfflineStage(offID, "", "uk", "ops@example.com", stageAt, now),
151 OfflineStartStaged(offID, "ops@example.com", now.Add(20*time.Minute)),
152 OfflineAbort(offID),
153 } {
154 if _, err := SetRecordMove(ctx, s, rec.Account, rec.App, rec.Host, mut); err != nil {
155 t.Fatal(err)
156 }
157 }
158 done, err = SetRecordMove(ctx, s, rec.Account, rec.App, rec.Host, OfflineFinish(offID, now.Add(30*time.Minute)))
159 if err != nil || done.LastMove.Outcome != OfflineOutcomeAborted || done.LastMove.OfflineS != 600 {
160 t.Fatalf("finish after a started staged move: %v %+v", err, done.LastMove)
161 }
162}
163
164// staged_at is additive like the other offline fields: a staged journal
165// round-trips, and every earlier shape still does (TestOfflineFieldsAreAdditiveOnTheWire).
166func TestOfflineStagedAtIsAdditiveOnTheWire(t *testing.T) {
167 const staged = `{"schema":1,"host":"a.go.example.com","slug":"s","app":"app","account":"bab","region":"ie","channel":"stable","created_at":"2026-09-01T08:00:00Z","move":{"id":"mv_abcdefghijklmnopqrstuvwxyz","from":"ie","to":"uk","state":"offline-staging","requested_by":"ops@example.com","requested_at":"2026-10-21T09:00:00Z","updated_at":"2026-10-21T09:00:00Z","kind":"offline","staged_at":"2026-10-21T09:00:00Z","stager":"box-a","stage_bucket":"data-uk","stage_region":"eu-west-2"}}`
168 rec, err := DecodeRecord([]byte(staged))
169 if err != nil {
170 t.Fatal(err)
171 }
172 if rec.Move.State != OfflineStaging || rec.Move.StagedAt == nil || rec.Move.StartedOffline() || rec.Move.Stager != "box-a" {
173 t.Fatalf("staged journal: %+v", rec.Move)
174 }
175 if out, _ := json.Marshal(rec); string(out) != staged {
176 t.Errorf("staged journal did not round-trip:\n got %s\nwant %s", out, staged)
177 }
178}
179
180// A second stage is OfflineRestage: staged_at moves forward, never back,
181// requested_at stays, and the move still reads as never started. Nothing
182// but staging admits it.
183func TestOfflineRestage(t *testing.T) {
184 ctx := context.Background()
185 now := time.Date(2026, 10, 21, 9, 0, 0, 0, time.UTC)
186 s := release.NewDirStore(t.TempDir())
187 rec := offlineRecord(t, s)
188 if _, err := SetRecordMove(ctx, s, rec.Account, rec.App, rec.Host, OfflineRestage(offID, now)); !errors.Is(err, ErrMoveConflict) {
189 t.Fatalf("restage at rest: %v", err)
190 }
191 if _, err := SetRecordMove(ctx, s, rec.Account, rec.App, rec.Host, OfflineStage(offID, "", "uk", "ops@example.com", stageAt, now)); err != nil {
192 t.Fatal(err)
193 }
194 got, err := SetRecordMove(ctx, s, rec.Account, rec.App, rec.Host, OfflineRestage(offID, now.Add(5*time.Minute)))
195 if err != nil || !got.Move.StagedAt.Equal(now.Add(5*time.Minute)) || !got.Move.RequestedAt.Equal(now) || got.Move.StartedOffline() || got.Move.State != OfflineStaging || got.Move.Stager != "box-a" {
196 t.Fatalf("restage: %v %+v", err, got.Move)
197 }
198 // A clock behind the last request still moves it forward.
199 got, err = SetRecordMove(ctx, s, rec.Account, rec.App, rec.Host, OfflineRestage(offID, now))
200 if err != nil || !got.Move.StagedAt.After(now.Add(5*time.Minute)) {
201 t.Fatalf("restage with an earlier clock: %v %+v", err, got.Move)
202 }
203 if _, err := SetRecordMove(ctx, s, rec.Account, rec.App, rec.Host, OfflineRestage(otherOff, now)); !errors.Is(err, ErrMoveConflict) {
204 t.Fatalf("restage with another id: %v", err)
205 }
206 // The start after a restage is still after the last request.
207 got, err = SetRecordMove(ctx, s, rec.Account, rec.App, rec.Host, OfflineStartStaged(offID, "ops@example.com", now.Add(time.Minute)))
208 if err != nil || !got.Move.StartedOffline() || got.Move.State != OfflineStopping {
209 t.Fatalf("start after restage: %v %+v", err, got.Move)
210 }
211 if _, err := SetRecordMove(ctx, s, rec.Account, rec.App, rec.Host, OfflineRestage(offID, now)); !errors.Is(err, ErrMoveConflict) {
212 t.Fatalf("restage after the start: %v", err)
213 }
214}
215
216// stageAt is the stager and staging destination the tests' stage door names.
217var stageAt = StageTarget{Stager: "box-a", Bucket: "data-uk", Region: "eu-west-2"}
218
219// The stager is pinned at the stage: a stage that names none is malformed,
220// and so is one with no staging bucket.
221func TestOfflineStageNamesItsStager(t *testing.T) {
222 s := release.NewDirStore(t.TempDir())
223 rec := offlineRecord(t, s)
224 before := offlineGet(t, s, rec)
225 if _, err := SetRecordMove(context.Background(), s, rec.Account, rec.App, rec.Host, OfflineStage(offID, "", "uk", "ops@example.com", StageTarget{Bucket: "data-uk"}, time.Now())); !errors.Is(err, ErrMoveConflict) {
226 t.Fatalf("a stage with no stager: %v", err)
227 }
228 if _, err := SetRecordMove(context.Background(), s, rec.Account, rec.App, rec.Host, OfflineStage(offID, "", "uk", "ops@example.com", StageTarget{Stager: "box-a"}, time.Now())); !errors.Is(err, ErrMoveConflict) {
229 t.Fatalf("a stage with no staging bucket: %v", err)
230 }
231 if after := offlineGet(t, s, rec); !reflect.DeepEqual(after, before) {
232 t.Fatal("a refused stage changed the record")
233 }
234}
235