carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1package console
2
3import (
4 "context"
5 "net/http"
6 "slices"
7 "testing"
8 "time"
9
10 "github.com/carlosframework/platform/internal/moves"
11 "github.com/carlosframework/platform/internal/provision"
12)
13
14// The pre-copy door (offline-staging), in-process against the offline
15// doors' console fixture: the boxes' side is played by the reports they
16// write, the record moves only through provision's offline transitions.
17
18func msgO179(to string) string {
19 return offlineHost + " is staged for " + to + ". abort the move to move it to another region"
20}
21
22func msgO180(box string) string {
23 return box + " cannot stage an offline move. it runs an older release"
24}
25
26// stage runs the stage door on a record at rest and keeps the id.
27func (w *offlineWorld) stage() offlineView {
28 w.t.Helper()
29 before := serialBytes(w.t, w.fx.rel)
30 v := w.ok("/stage", `{"region":"uk"}`, provision.OfflineStaging)
31 if v.ID == "" {
32 w.t.Fatalf("stage answered no id: %+v", v)
33 }
34 if serialBytes(w.t, w.fx.rel) == before {
35 w.t.Fatal("stage did not bump the serial")
36 }
37 w.id = v.ID
38 return v
39}
40
41// staged writes the stager's report as the stager writes it after a round.
42func (w *offlineWorld) staged(box string, edit func(*moves.OfflineReport)) {
43 w.t.Helper()
44 w.report(moves.OfflineRoleStager, box, moves.OfflinePhaseStaged, func(r *moves.OfflineReport) {
45 r.Held, r.Fenced, r.Quiesced = false, false, false
46 r.ReplicaObjects, r.ReplicaBytes, r.StagedObjects, r.StagedBytes, r.RoundObjects, r.RoundBytes = 305, 1_580_000_000, 300, 1_570_000_000, 12, 9_000_000
47 if edit != nil {
48 edit(r)
49 }
50 })
51}
52
53func TestOfflineStageThenStartThenSeal(t *testing.T) {
54 us := offlineBox{Box: "us-box", Placement: "us", Bucket: "data-us"}
55 w := newOfflineWorld(t, ieOff("ie-box"), ukOff("uk-box"), us)
56 v := w.stage()
57 rec := w.record()
58 if rec.Region != "ie" || rec.Move.State != provision.OfflineStaging || rec.Move.To != "uk" || rec.Move.StagedAt == nil || rec.Move.Stager != "ie-box" {
59 t.Fatalf("staged record: %q %+v", rec.Region, rec.Move)
60 }
61 if !v.CanAbort || v.Next != offlineVerbStart || v.Stage != nil {
62 t.Fatalf("staged view: next %q, can abort %v, stage %+v", v.Next, v.CanAbort, v.Stage)
63 }
64 if l, ok := w.lock(); !ok || l.Host != offlineHost || l.Op != w.id {
65 t.Fatalf("lock %+v %v, want this move's", l, ok)
66 }
67
68 if !v.NeverOffline || v.StagedAt != rec.Move.StagedAt.UnixNano() {
69 t.Fatalf("staged view: never offline %v, staged at %d (record %v)", v.NeverOffline, v.StagedAt, rec.Move.StagedAt)
70 }
71
72 // Again: one more round. The request moves forward, the rest of the
73 // move does not, and the serial moves.
74 serial := serialBytes(t, w.fx.rel)
75 again := w.ok("/stage", `{"region":"uk"}`, provision.OfflineStaging)
76 if again.ID != w.id || again.StagedAt <= v.StagedAt {
77 t.Fatalf("a second stage answered move %s staged at %d, want %s after %d", again.ID, again.StagedAt, w.id, v.StagedAt)
78 }
79 if serialBytes(t, w.fx.rel) == serial {
80 t.Fatal("a second stage did not bump the serial")
81 }
82 if after := w.record(); after.Move.State != provision.OfflineStaging || !after.Move.RequestedAt.Equal(rec.Move.RequestedAt) || after.Move.ID != w.id {
83 t.Fatalf("a second stage changed the move: %+v", after.Move)
84 }
85 v = again
86
87 // Another region: refused for stage and for start, nothing written.
88 w.refused(http.MethodPost, "/stage", `{"region":"us"}`, http.StatusConflict, msgO179("uk"))
89 w.refused(http.MethodPost, "", `{"region":"us"}`, http.StatusConflict, msgO179("uk"))
90 // The other verbs wait for start.
91 for _, verb := range []string{"/seal", "/flip", "/open", "/finish"} {
92 w.refused(http.MethodPost, verb, "", http.StatusConflict, msgO25(provision.OfflineStaging))
93 }
94
95 // The status shows the stager's progress, and which request it answered.
96 w.staged("ie-box", func(r *moves.OfflineReport) { r.StageRequest = time.Unix(0, v.StagedAt).UTC() })
97 st := w.status()
98 if st.Stage == nil || st.Stage.Box != "ie-box" || st.Stage.Phase != moves.OfflinePhaseStaged || st.Stage.StagedObjects != 300 ||
99 st.Stage.ReplicaObjects != 305 || st.Stage.StagedBytes != 1_570_000_000 || st.Stage.RoundBytes != 9_000_000 || st.Stage.At == 0 ||
100 st.Stage.Request != v.StagedAt {
101 t.Fatalf("stage view %+v (request %d)", st.Stage, v.StagedAt)
102 }
103
104 // Start: staging to stopping, the same move and lock, offline from now.
105 started := w.ok("", `{"region":"uk"}`, provision.OfflineStopping)
106 if started.ID != w.id {
107 t.Fatalf("start answered move %s, want the staged %s", started.ID, w.id)
108 }
109 rec = w.record()
110 if !rec.Move.RequestedAt.After(*rec.Move.StagedAt) || rec.Move.RequestedBy == "" || started.NeverOffline {
111 t.Fatalf("started record %+v (never offline %v)", rec.Move, started.NeverOffline)
112 }
113 if l, _ := w.lock(); l.Op != w.id {
114 t.Fatalf("lock %+v after start", l)
115 }
116 if st := w.status(); st.Stage == nil || st.Stage.StagedObjects != 300 {
117 t.Fatalf("the stage's progress is gone from the status after start: %+v", st.Stage)
118 }
119 // From here it is the ordinary move: stage and start cannot be posted
120 // again, and seal follows the stop.
121 w.refused(http.MethodPost, "/stage", `{"region":"uk"}`, http.StatusConflict, "a move to uk is already "+provision.OfflineStopping)
122 w.stopped(nil, "ie-box")
123 w.stagerStopped("ie-box", time.Now())
124 w.ok("/seal", "", provision.OfflineSealing)
125}
126
127// The stage door's own rule: every box in both regions can stage. A box
128// that does not publish offline-stage-1 would leave a staging record to
129// nobody, so the door refuses before anything is written. The start door
130// does not ask it.
131func TestOfflineStageRefusesABoxThatCannotStage(t *testing.T) {
132 for _, c := range []struct {
133 name string
134 boxes []offlineBox
135 box string
136 }{
137 {"source", []offlineBox{{Box: "ie-box", Placement: "ie", Bucket: "data-ie", NoStageCap: true}, ukOff("uk-box")}, "ie-box"},
138 {"target", []offlineBox{ieOff("ie-box"), {Box: "uk-box", Placement: "uk", Bucket: "data-uk", NoStageCap: true}}, "uk-box"},
139 } {
140 t.Run(c.name, func(t *testing.T) {
141 w := newOfflineWorld(t, c.boxes...)
142 w.refused(http.MethodPost, "/stage", `{"region":"uk"}`, http.StatusConflict, msgO180(c.box))
143 if rec := w.record(); rec.Move != nil {
144 t.Fatalf("a refused stage wrote %+v", rec.Move)
145 }
146 if _, ok := w.lock(); ok {
147 t.Fatal("a refused stage took the lock")
148 }
149 w.start()
150 })
151 }
152 // The start door's refusals hold for stage too (one of them here).
153 w := newOfflineWorld(t, ieOff("ie-box"), offlineBox{Box: "uk-box", Placement: "uk", Bucket: "data-uk", NoCap: true})
154 w.refused(http.MethodPost, "/stage", `{"region":"uk"}`, http.StatusConflict, msgO21("uk-box"))
155}
156
157// Abort from staging: reverting, the region untouched; finish waits for
158// every source box's reverted (the stager's comes after it has deleted the
159// staged copy), then ends a move that was never offline and frees the lock.
160func TestOfflineAbortFromStagingDoor(t *testing.T) {
161 w := newOfflineWorld(t, oneEach...)
162 w.stage()
163 w.staged("ie-box", nil)
164 if v := w.ok("/abort", "", provision.OfflineReverting); !v.NeverOffline {
165 t.Fatal("an abort from staging does not say the instance was never offline")
166 }
167 if rec := w.record(); rec.Region != "ie" || rec.Move.State != provision.OfflineReverting {
168 t.Fatalf("after abort: %q %+v", rec.Region, rec.Move)
169 }
170 w.refused(http.MethodPost, "/finish", "", http.StatusConflict, msgO25(provision.OfflineReverting))
171 w.report(moves.OfflineRoleSource, "ie-box", moves.OfflinePhaseReverted, nil)
172 // Every source box has reverted, but the pinned stager has not said
173 // the staged copy is gone (Astra round 1, findings 1 and 2): no finish,
174 // whatever its last round reported, or another box's stager report.
175 w.refused(http.MethodPost, "/finish", "", http.StatusConflict, msgO25(provision.OfflineReverting))
176 w.report(moves.OfflineRoleStager, "ie-other", moves.OfflinePhaseReverted, nil)
177 w.refused(http.MethodPost, "/finish", "", http.StatusConflict, msgO25(provision.OfflineReverting))
178 w.report(moves.OfflineRoleStager, "ie-box", moves.OfflinePhaseReverted, nil)
179 v := w.ok("/finish", "", "none")
180 if v.Last == nil || v.Last.Outcome != provision.OfflineOutcomeAborted || v.Last.OfflineS != 0 {
181 t.Fatalf("last = %+v", v.Last)
182 }
183 if _, ok := w.lock(); ok {
184 t.Fatal("the lock outlived the aborted stage")
185 }
186 // The instance can be staged again.
187 w.stage()
188}
189
190// A failed round shows in the status with the box's own diagnostic.
191func TestOfflineStageViewShowsAFailedRound(t *testing.T) {
192 w := newOfflineWorld(t, oneEach...)
193 w.stage()
194 w.staged("ie-box", func(r *moves.OfflineReport) {
195 r.Phase, r.StageError, r.At = moves.OfflinePhaseFailed, "mirror: copy instances/x: boom", time.Now().UTC()
196 })
197 st := w.status()
198 if st.Stage == nil || st.Stage.Phase != moves.OfflinePhaseFailed || st.Stage.Error != "mirror: copy instances/x: boom" || st.Stage.StagedObjects != 300 {
199 t.Fatalf("stage view %+v", st.Stage)
200 }
201}
202
203// The stage door pins the stager: the lexically first eligible source box,
204// whatever order the fleet lists them in.
205func TestOfflineStagePinsTheStager(t *testing.T) {
206 w := newOfflineWorld(t, ieOff("ie-c"), ieOff("ie-a"), ukOff("uk-box"))
207 v := w.stage()
208 if rec := w.record(); rec.Move.Stager != "ie-a" || v.Stager != "ie-a" {
209 t.Fatalf("stager %q (view %q), want ie-a", rec.Move.Stager, v.Stager)
210 }
211}
212
213// ---- Astra round 2 ----
214
215func msgO190(box string) string {
216 return box + ", which staged " + offlineHost + ", no longer publishes an edges file in ie. bring it back or abort the move"
217}
218
219const (
220 msgO191 = "uk publishes the same data bucket as ie. a staged copy there would replace the serving one"
221 msgO192 = "uk now publishes a different data bucket or region from what was recorded when " + offlineHost + " was staged. abort the move and stage it again"
222)
223
224// unpublish removes a box from the fleet's evidence, its edges file and
225// its status report: the box left the fleet (it may still be running).
226func (w *offlineWorld) unpublish(box string) {
227 w.t.Helper()
228 for _, key := range []string{edgesFilePrefix + box + ".json", "status/boxes/" + box + ".json"} {
229 if err := w.fx.rel.Delete(context.Background(), key); err != nil {
230 w.t.Fatal(err)
231 }
232 }
233}
234
235// stagerStopped writes the pinned stager's stop acknowledgement.
236func (w *offlineWorld) stagerStopped(box string, at time.Time) {
237 w.t.Helper()
238 w.report(moves.OfflineRoleStager, box, moves.OfflinePhaseStopped, func(r *moves.OfflineReport) {
239 r.Held, r.Fenced, r.Quiesced, r.At = false, false, false, at
240 })
241}
242
243// Finding 1: seal waits for the pinned stager's fresh acknowledgement that
244// no round is in flight and none will start, beside every box's stop, and
245// names the stager among the boxes it captures.
246func TestOfflineSealWaitsForThePinnedStager(t *testing.T) {
247 w := newOfflineWorld(t, ieOff("ie-a"), ieOff("ie-b"), ukOff("uk-box"))
248 w.stage()
249 w.ok("", `{"region":"uk"}`, provision.OfflineStopping)
250 w.stopped(nil, "ie-a", "ie-b")
251 w.refused(http.MethodPost, "/seal", "", http.StatusConflict, "Waiting for ie-a in ie to confirm it has stopped "+offlineHost+". "+offlineHost+" is offline.")
252 w.stagerStopped("ie-a", time.Now().Add(-10*time.Minute))
253 w.refused(http.MethodPost, "/seal", "", http.StatusConflict, msgO178("ie-a"))
254 w.stagerStopped("ie-a", time.Now())
255 w.ok("/seal", "", provision.OfflineSealing)
256 if rec := w.record(); !slices.Contains(rec.Move.Boxes, "ie-a") {
257 t.Fatalf("the seal's boxes %v leave out the stager", rec.Move.Boxes)
258 }
259}
260
261// Finding 1: a pinned stager that no longer publishes an edges file is never
262// taken to have stopped. start and seal refuse with MV-O190.
263func TestOfflineStagedMoveRefusesAMissingStager(t *testing.T) {
264 w := newOfflineWorld(t, ieOff("ie-a"), ieOff("ie-b"), ukOff("uk-box"))
265 w.stage()
266 w.unpublish("ie-a")
267 w.refused(http.MethodPost, "", `{"region":"uk"}`, http.StatusConflict, msgO190("ie-a"))
268 if st := w.status(); st.Next != "" || st.Waiting != msgO190("ie-a") {
269 t.Fatalf("status next %q waiting %q", st.Next, st.Waiting)
270 }
271
272 w2 := newOfflineWorld(t, ieOff("ie-a"), ieOff("ie-b"), ukOff("uk-box"))
273 w2.stage()
274 w2.ok("", `{"region":"uk"}`, provision.OfflineStopping)
275 w2.stopped(nil, "ie-a", "ie-b")
276 w2.stagerStopped("ie-a", time.Now())
277 w2.unpublish("ie-a")
278 w2.refused(http.MethodPost, "/seal", "", http.StatusConflict, msgO190("ie-a"))
279}
280
281// Finding 2: the stage fixes its destination on the record, and refuses a
282// target that publishes a source box's own bucket.
283func TestOfflineStageFixesItsDestination(t *testing.T) {
284 w := newOfflineWorld(t, oneEach...)
285 w.stage()
286 if m := w.record().Move; m.StageBucket != "data-uk" || m.StageRegion != "eu-west-2" {
287 t.Fatalf("staging destination %q %q", m.StageBucket, m.StageRegion)
288 }
289 w2 := newOfflineWorld(t, ieOff("ie-box"), offlineBox{Box: "uk-box", Placement: "uk", Bucket: "data-ie"})
290 w2.refused(http.MethodPost, "/stage", `{"region":"uk"}`, http.StatusConflict, msgO191)
291}
292
293// Finding 2: once staged, a target that publishes other coordinates is
294// refused by stage, start and seal, never followed.
295func TestOfflineStagedMoveRefusesChangedCoordinates(t *testing.T) {
296 for _, c := range []struct {
297 name string
298 target offlineBox
299 }{
300 {"another bucket", offlineBox{Box: "uk-box", Placement: "uk", Bucket: "data-uk-new"}},
301 {"another box", offlineBox{Box: "uk-new", Placement: "uk", Bucket: "data-uk-new"}},
302 } {
303 t.Run(c.name, func(t *testing.T) {
304 w := newOfflineWorld(t, oneEach...)
305 w.stage()
306 if c.target.Box != "uk-box" {
307 w.unpublish("uk-box")
308 }
309 w.fleet(c.target)
310 w.refused(http.MethodPost, "/stage", `{"region":"uk"}`, http.StatusConflict, msgO192)
311 w.refused(http.MethodPost, "", `{"region":"uk"}`, http.StatusConflict, msgO192)
312
313 w2 := newOfflineWorld(t, oneEach...)
314 w2.stage()
315 w2.ok("", `{"region":"uk"}`, provision.OfflineStopping)
316 w2.stopped(nil, "ie-box")
317 w2.stagerStopped("ie-box", time.Now())
318 if c.target.Box != "uk-box" {
319 w2.unpublish("uk-box")
320 }
321 w2.fleet(c.target)
322 w2.refused(http.MethodPost, "/seal", "", http.StatusConflict, msgO192)
323 })
324 }
325}
326