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 "crypto/rand"
6 "encoding/json"
7 "errors"
8 "fmt"
9 "net/http"
10 "os"
11 "path/filepath"
12 "strings"
13 "sync"
14 "testing"
15 "time"
16
17 "github.com/carlosframework/platform/internal/alerting"
18 "github.com/carlosframework/platform/internal/hostclaim"
19 "github.com/carlosframework/platform/internal/hostfence"
20 "github.com/carlosframework/platform/internal/hostfloor"
21 "github.com/carlosframework/platform/internal/hostops"
22 "github.com/carlosframework/platform/internal/moves"
23 "github.com/carlosframework/platform/internal/provision"
24 "github.com/carlosframework/platform/internal/release"
25 "github.com/carlosframework/platform/internal/svcrole"
26)
27
28// The console's move pass (moves Plan 5 Task 7; claim Plan 10's
29// Recovery.Moves hook under the move-recovery grant). The decision table is
30// tested on values; every action is then driven end to end through the real
31// recovery pass (ControlWriter.RecoveryPass over hostops.Recovery.Pass) on a
32// host-claim console whose move the door started.
33
34// ---- the decision, on values ----
35
36var mvT0 = time.Date(2026, 10, 10, 12, 0, 0, 0, time.UTC)
37
38func mvClaim(kind, phase string, mut ...func(*hostclaim.Claim)) *hostclaim.Claim {
39 c := &hostclaim.Claim{Holder: &hostclaim.Holder{Incarnation: "inc"},
40 Op: hostclaim.Op{Kind: kind, Phase: phase, ID: "mv", Exec: "ie-box", Participants: []string{"ie-box", "uk-box"}, StartedAt: mvT0,
41 Intent: map[string]string{"source": "ie-box", "target": "uk-box", "to": "uk"}}}
42 for _, m := range mut {
43 m(c)
44 }
45 return c
46}
47
48func mvRecord(state string, mut ...func(*provision.Record)) provision.Record {
49 at := mvT0
50 r := provision.Record{Region: "ie", Move: &provision.MoveState{ID: "mv", Incarnation: "inc", From: "ie", To: "uk", State: state, RequestedAt: mvT0}}
51 switch state {
52 case provision.MoveLanding, provision.MoveCommitted:
53 r.Region = "uk"
54 r.Move.FlippedAt = &at
55 }
56 if state == provision.MoveCommitted {
57 r.Move.CommittedAt = &at
58 }
59 if state == provision.MoveRollingBack {
60 r.Move.FlippedAt = &at
61 r.Move.Rollback = &provision.MoveRollback{ID: "rb", TargetPhase: provision.RbTargetReleasing, SourcePhase: provision.RbSourceWaiting}
62 }
63 for _, m := range mut {
64 m(&r)
65 }
66 return r
67}
68
69func mvAt(d time.Duration) *time.Time { t := mvT0.Add(d); return &t }
70
71func TestRecoveryPassWritesOnlyDecidedHalves(t *testing.T) {
72 now := mvT0.Add(time.Minute)
73 for _, c := range []struct {
74 name string
75 claim *hostclaim.Claim
76 rec provision.Record
77 action moveRecoveryAction
78 half hostops.MoveRecoveryClaimAction
79 }{
80 {"record landing, claim moving/sealed: the flip half", mvClaim(hostclaim.KindMoving, hostclaim.PhaseSealed), mvRecord(provision.MoveLanding), moveActClaim, hostops.RecoveryMoveFlip},
81 {"record committed, claim landing/probed: the commit half", mvClaim(hostclaim.KindLanding, hostclaim.PhaseLandingProbed), mvRecord(provision.MoveCommitted), moveActClaim, hostops.RecoveryMoveCommit},
82 {"record rolling_back, claim landing: the rollback half", mvClaim(hostclaim.KindLanding, "probing"), mvRecord(provision.MoveRollingBack), moveActClaim, hostops.RecoveryMoveRollback},
83 {"record rolling_back, claim moving/sealed: RollBackFlipped (K22)", mvClaim(hostclaim.KindMoving, hostclaim.PhaseSealed), mvRecord(provision.MoveRollingBack), moveActClaim, hostops.RecoveryMoveRollback},
84 {"record landing, claim landing: in step", mvClaim(hostclaim.KindLanding, "probing"), mvRecord(provision.MoveLanding), moveActNone, ""},
85 {"record cutting, claim moving/sealed: no decision", mvClaim(hostclaim.KindMoving, hostclaim.PhaseSealed), mvRecord(provision.MoveCutting), moveActNone, ""},
86 {"record committed, claim landing/probing: the claim is behind the probe", mvClaim(hostclaim.KindLanding, "probing"), mvRecord(provision.MoveCommitted), moveActNone, ""},
87 {"record committed, claim landing/committed, stamps missing", mvClaim(hostclaim.KindLanding, hostclaim.PhaseCommitted), mvRecord(provision.MoveCommitted), moveActNone, ""},
88 {"record landing without its flip stamp", mvClaim(hostclaim.KindMoving, hostclaim.PhaseSealed), mvRecord(provision.MoveLanding, func(r *provision.Record) { r.Move.FlippedAt = nil }), moveActNone, ""},
89 {"record rolling_back in the target's region", mvClaim(hostclaim.KindLanding, "probing"), mvRecord(provision.MoveRollingBack, func(r *provision.Record) { r.Region = "uk" }), moveActNone, ""},
90 {"record preparing, claim moving: nothing yet", mvClaim(hostclaim.KindMoving, hostclaim.PhaseMoving), mvRecord(provision.MovePreparing), moveActNone, ""},
91 {"another move's record", mvClaim(hostclaim.KindMoving, hostclaim.PhaseSealed), mvRecord(provision.MoveLanding, func(r *provision.Record) { r.Move.ID = "other" }), moveActNone, ""},
92 {"another incarnation's record", mvClaim(hostclaim.KindMoving, hostclaim.PhaseSealed), mvRecord(provision.MoveLanding, func(r *provision.Record) { r.Move.Incarnation = "old" }), moveActNone, ""},
93 {"a relocating claim is region HA's", mvClaim(hostclaim.KindRelocating, "pinning"), mvRecord(provision.MoveRelocating), moveActNone, ""},
94 } {
95 t.Run(c.name, func(t *testing.T) {
96 got := decideMoveRecovery(moveRecoveryInput{Now: now, Claim: c.claim, Rec: c.rec})
97 if got.Action != c.action || got.Claim != c.half {
98 t.Fatalf("decided %+v, want %s %s", got, c.action, c.half)
99 }
100 })
101 }
102}
103
104// One eligible action per observation, in the brief's order: a pending
105// claim half comes before the deadline, the deadline before a close, the
106// lost target's stamp before the close it enables.
107func TestRecoveryPassOneActionPerObservation(t *testing.T) {
108 past := mvT0.Add(-time.Hour)
109 // Flip half pending and the landing deadline passed: the half first.
110 d := decideMoveRecovery(moveRecoveryInput{Now: mvT0, Claim: mvClaim(hostclaim.KindMoving, hostclaim.PhaseSealed),
111 Rec: mvRecord(provision.MoveLanding, func(r *provision.Record) { r.Move.LandingDeadline = &past })})
112 if d.Action != moveActClaim || d.Claim != hostops.RecoveryMoveFlip {
113 t.Fatalf("flip and deadline: %+v", d)
114 }
115 // With the claim in step, the deadline.
116 d = decideMoveRecovery(moveRecoveryInput{Now: mvT0, Claim: mvClaim(hostclaim.KindLanding, "probing"),
117 Rec: mvRecord(provision.MoveLanding, func(r *provision.Record) { r.Move.LandingDeadline = &past })})
118 if d.Action != moveActRecord || d.Record != hostops.RecoveryMoveDeadlineRollback {
119 t.Fatalf("the deadline: %+v", d)
120 }
121 // The earlier of the cut and landing deadlines (J11).
122 d = decideMoveRecovery(moveRecoveryInput{Now: mvT0, Claim: mvClaim(hostclaim.KindLanding, "probing"),
123 Rec: mvRecord(provision.MoveLanding, func(r *provision.Record) { r.Move.CutDeadline = &past; r.Move.LandingDeadline = mvAt(time.Hour) })})
124 if d.Action != moveActRecord {
125 t.Fatalf("the cut deadline: %+v", d)
126 }
127 // A lost, released target: the stamp, never the close it enables.
128 lost := func(c *hostclaim.Claim) { c.Lost = []string{"uk-box"}; c.Op.Participants = []string{"ie-box"} }
129 rb := func(r *provision.Record) {
130 r.Move.Rollback.TargetPhase, r.Move.Rollback.SourcePhase = provision.RbTargetReleased, provision.RbSourceOpened
131 r.Move.Rollback.RecoveredAt, r.Move.Rollback.OpenedAt = mvAt(0), mvAt(0)
132 }
133 d = decideMoveRecovery(moveRecoveryInput{Now: mvT0, Claim: mvClaim(hostclaim.KindRestoring, hostclaim.PhaseOpened, lost), Rec: mvRecord(provision.MoveRollingBack, rb)})
134 if d.Action != moveActStampLost {
135 t.Fatalf("the lost target: %+v", d)
136 }
137 // A chore of the target's region still owed: neither.
138 owed := func(c *hostclaim.Claim) {
139 c.Obligations = []hostclaim.Obligation{{ID: "o", Op: "mv", Kind: "park-replica", Region: "uk", Owner: "region:uk"}}
140 }
141 d = decideMoveRecovery(moveRecoveryInput{Now: mvT0, Claim: mvClaim(hostclaim.KindRestoring, hostclaim.PhaseOpened, lost, owed), Rec: mvRecord(provision.MoveRollingBack, rb)})
142 if d.Action != moveActNone {
143 t.Fatalf("the lost target with its region's chore owed: %+v", d)
144 }
145 // Every close waits for the op's chores (the grant's rule, M4).
146 stamped := func(r *provision.Record) { r.Move.OpenedAt, r.Move.SourceParkedAt = mvAt(0), mvAt(0) }
147 d = decideMoveRecovery(moveRecoveryInput{Now: mvT0, Claim: mvClaim(hostclaim.KindLanding, hostclaim.PhaseCommitted, owed), Rec: mvRecord(provision.MoveCommitted, stamped)})
148 if d.Action != moveActNone {
149 t.Fatalf("a close with a chore owed: %+v", d)
150 }
151 d = decideMoveRecovery(moveRecoveryInput{Now: mvT0, Claim: mvClaim(hostclaim.KindLanding, hostclaim.PhaseCommitted), Rec: mvRecord(provision.MoveCommitted, stamped)})
152 if d.Action != moveActClose || d.Record != hostops.RecoveryMoveClose {
153 t.Fatalf("the committed close: %+v", d)
154 }
155 for _, c := range []struct {
156 claim *hostclaim.Claim
157 rec provision.Record
158 want hostops.MoveRecoveryRecordAction
159 }{
160 {mvClaim(hostclaim.KindRestoring, hostclaim.PhaseOpened), mvRecord(provision.MoveRollingBack, rb, func(r *provision.Record) { r.Move.Rollback.TargetParkedAt = mvAt(0) }), hostops.RecoveryMoveRolledBackClose},
161 {mvClaim(hostclaim.KindReverting, hostclaim.PhaseOpened), mvRecord(provision.MoveReverting, func(r *provision.Record) { r.Move.ReopenedAt = mvAt(0) }), hostops.RecoveryMoveRevertClose},
162 } {
163 if d := decideMoveRecovery(moveRecoveryInput{Now: mvT0, Claim: c.claim, Rec: c.rec}); d.Action != moveActClose || d.Record != c.want {
164 t.Fatalf("%s/%s: %+v, want close %s", c.claim.Op.Kind, c.claim.Op.Phase, d, c.want)
165 }
166 }
167 // An abort the console never closes (fix round 1, ruling 1): only the
168 // source closes it, after it retired its own state. Due, it is at most
169 // a page.
170 if d := decideMoveRecovery(moveRecoveryInput{Now: mvT0, Claim: mvClaim(hostclaim.KindMoving, hostclaim.PhaseAborting), Rec: mvRecord(provision.MoveAborting)}); d.Action != moveActAbortOverdue || d.Record != "" {
171 t.Fatalf("a due abort: %+v, want only the overdue check", d)
172 }
173 // The record closed, the claim not released: the release.
174 closed := func(r *provision.Record) {
175 r.Move, r.LastMove = nil, &provision.MoveOutcome{ID: "mv", Outcome: "done", At: mvT0}
176 }
177 d = decideMoveRecovery(moveRecoveryInput{Now: mvT0, Claim: mvClaim(hostclaim.KindLanding, hostclaim.PhaseCommitted), Rec: mvRecord(provision.MoveCommitted, closed)})
178 if d.Action != moveActClaim || d.Claim != hostops.RecoveryMoveRelease {
179 t.Fatalf("the release from LastMove: %+v", d)
180 }
181}
182
183// ---- the pass, end to end ----
184
185// movePassWorld is a host-claim console whose move the request door
186// started (j17StartedMove), with the console's own move pass bound as the
187// Recovery.Moves hook, pages captured from the armed alert sinks, and the
188// pass's clock under the test's control.
189type movePassWorld struct {
190 fx *consoleFixture
191 id string
192 auth hostclaim.FenceAuthority
193 clock time.Time
194 mu sync.Mutex
195 pages []pagePayload
196}
197
198type pagePayload struct{ Kind, Level, Subject, Body string }
199
200func newMovePassWorld(t *testing.T, fx *consoleFixture, id string) *movePassWorld {
201 t.Helper()
202 w := &movePassWorld{fx: fx, id: id, clock: time.Now().UTC()}
203 fx.srv.movesClock = func() time.Time { w.mu.Lock(); defer w.mu.Unlock(); return w.clock }
204 alerting.Arm(&alerting.Sinks{WebhookURL: "http://pages.test/hook", Post: func(_ string, body []byte) error {
205 var p pagePayload
206 if err := json.Unmarshal(body, &p); err != nil {
207 return err
208 }
209 w.mu.Lock()
210 w.pages = append(w.pages, p)
211 w.mu.Unlock()
212 return nil
213 }})
214 t.Cleanup(func() { alerting.Arm(nil) })
215 return w
216}
217
218func (w *movePassWorld) advance(d time.Duration) {
219 w.mu.Lock()
220 w.clock = w.clock.Add(d)
221 w.mu.Unlock()
222}
223
224// pass runs one console recovery pass with only the moves hook live.
225func (w *movePassWorld) pass(t *testing.T) {
226 t.Helper()
227 a := w.fx.srv.cfg.ClaimAuthority
228 nop := func(context.Context) error { return nil }
229 nopObs := func(context.Context, hostclaim.Observed) error { return nil }
230 r := hostops.Recovery{Moves: w.fx.srv.movesRecovery, MoveLogs: w.fx.srv.drainMoveLog, MoveFenceAuthority: w.auth,
231 Controller: hostops.Controller{Store: w.fx.srv.cfg.Releases, Control: a.Control, Authority: a, Now: time.Now, NewID: claimOperationID},
232 Purge: nopObs, Placement: nopObs, SweepRecords: nop, SweepPurges: nop, SweepAdmissions: nop}
233 if err := a.Control.RecoveryPass(context.Background(), r); err != nil {
234 t.Fatalf("the recovery pass: %v", err)
235 }
236}
237
238// passErr is one recovery pass, returning its error.
239func (w *movePassWorld) passErr() error {
240 a := w.fx.srv.cfg.ClaimAuthority
241 nop := func(context.Context) error { return nil }
242 nopObs := func(context.Context, hostclaim.Observed) error { return nil }
243 r := hostops.Recovery{Moves: w.fx.srv.movesRecovery, MoveLogs: w.fx.srv.drainMoveLog, MoveFenceAuthority: w.auth,
244 Controller: hostops.Controller{Store: w.fx.srv.cfg.Releases, Control: a.Control, Authority: a, Now: time.Now, NewID: claimOperationID},
245 Purge: nopObs, Placement: nopObs, SweepRecords: nop, SweepPurges: nop, SweepAdmissions: nop}
246 return a.Control.RecoveryPass(context.Background(), r)
247}
248
249func (w *movePassWorld) claim(t *testing.T) *hostclaim.Claim {
250 t.Helper()
251 o, err := hostclaim.Read(context.Background(), w.fx.rel, "acme.go.tito.io")
252 if err != nil || o.Absent {
253 t.Fatalf("read the claim: %v", err)
254 }
255 return o.Claim
256}
257
258func (w *movePassWorld) record(t *testing.T) provision.Record {
259 t.Helper()
260 rec, err := provision.GetRecord(context.Background(), w.fx.rel, "bab", "titogo", "acme.go.tito.io")
261 if err != nil || rec == nil {
262 t.Fatalf("read the record: %v", err)
263 }
264 return *rec
265}
266
267// setClaim rewrites the claim under the raw bucket, as a box would.
268func (w *movePassWorld) setClaim(t *testing.T, mut func(*hostclaim.Claim)) {
269 t.Helper()
270 ctx := context.Background()
271 obs, err := hostclaim.Read(ctx, w.fx.rel, "acme.go.tito.io")
272 if err != nil {
273 t.Fatal(err)
274 }
275 if _, err := hostclaim.Update(ctx, w.fx.rel, obs, func(c *hostclaim.Claim) error { mut(c); return nil }); err != nil {
276 t.Fatalf("rewrite the claim: %v", err)
277 }
278}
279
280func (w *movePassWorld) startEffect(t *testing.T) hostops.Effect {
281 t.Helper()
282 a, err := hostops.ReadAdmission(context.Background(), w.fx.rel, "bab", "titogo", claimMoveAppID)
283 if err != nil {
284 t.Fatal(err)
285 }
286 return a.Value.Effects[w.id]
287}
288
289// setStartEffect rewrites the move-start effect of the admission object.
290func (w *movePassWorld) setStartEffect(t *testing.T, mut func(*hostops.Effect)) {
291 t.Helper()
292 ctx := context.Background()
293 body, etag, err := w.fx.rel.Get(ctx, "apps/bab/titogo/admission.json")
294 if err != nil {
295 t.Fatal(err)
296 }
297 var a hostops.AppAdmission
298 if err := json.Unmarshal(body, &a); err != nil {
299 t.Fatal(err)
300 }
301 e := a.Effects[w.id]
302 mut(&e)
303 a.Effects[w.id] = e
304 if body, err = json.Marshal(a); err != nil {
305 t.Fatal(err)
306 }
307 if _, err := w.fx.rel.Put(ctx, "apps/bab/titogo/admission.json", body, etag); err != nil {
308 t.Fatal(err)
309 }
310}
311
312func (w *movePassWorld) paged(t *testing.T) []pagePayload {
313 t.Helper()
314 w.mu.Lock()
315 defer w.mu.Unlock()
316 return append([]pagePayload(nil), w.pages...)
317}
318
319func startedMovePass(t *testing.T, code string) *movePassWorld {
320 t.Helper()
321 fx, _, _, id := j17StartedMove(t, code)
322 return newMovePassWorld(t, fx, id)
323}
324
325func landingMovePass(t *testing.T, code string) *movePassWorld {
326 t.Helper()
327 fx, _, _ := j4LandingMove(t, code)
328 return newMovePassWorld(t, fx, j17MoveState(t, fx).ID)
329}
330
331func newRollbackID(t *testing.T) string {
332 t.Helper()
333 id, err := moves.NewRollbackID(rand.Reader)
334 if err != nil {
335 t.Fatal(err)
336 }
337 return id
338}
339
340// TestRecoveryPassRollsBackAtTheLandingDeadline: a landing past the earlier
341// of its deadlines is rolled back by the record (one pass), and the claim
342// follows on the next (the rollback half). Each pass writes one object.
343func TestRecoveryPassRollsBackAtTheLandingDeadline(t *testing.T) {
344 w := landingMovePass(t, "code-mvpass-deadline")
345 claim := claimBytes(t, w.fx, "acme.go.tito.io")
346 w.pass(t)
347 if m := w.record(t).Move; m.State != provision.MoveLanding {
348 t.Fatalf("rolled back before the deadline: %+v", m)
349 }
350 past := time.Now().UTC().Add(-time.Minute)
351 j17SetRecord(t, w.fx, func(r *provision.Record) { r.Move.LandingDeadline = &past })
352 w.pass(t)
353 rec := w.record(t)
354 if rec.Move.State != provision.MoveRollingBack || rec.Move.Rollback == nil || rec.Move.Rollback.Reason != provision.RollbackLandingDeadline || rec.Region != "ie" {
355 t.Fatalf("the deadline's rollback: region %s %+v", rec.Region, rec.Move)
356 }
357 if claimBytes(t, w.fx, "acme.go.tito.io") != claim {
358 t.Fatal("the pass that wrote the record wrote the claim too")
359 }
360 w.pass(t)
361 c := w.claim(t)
362 if c.Op.Kind != hostclaim.KindRestoring || c.Op.ID != w.id {
363 t.Fatalf("the claim did not follow the rollback: %+v", c.Op)
364 }
365 if after := w.record(t); after.Move.Rollback.ID != rec.Move.Rollback.ID || after.RecordSeq != rec.RecordSeq {
366 t.Fatalf("the claim's pass wrote the record: %+v", after.Move)
367 }
368}
369
370// TestRecoveryPassSettlesAnOrphanStartThenReleases (K3): the request door's
371// record write was lost, so the claim is moving/"" and the record carries no
372// move, with the start effect uncertain. Under ten minutes the pass leaves
373// it. After, it settles the start not-applied from record_seq (no handle),
374// and on the next pass releases the claim to serving under the source. A
375// landing orphan is paged once and never released.
376func TestRecoveryPassSettlesAnOrphanStartThenReleases(t *testing.T) {
377 w := startedMovePass(t, "code-mvpass-orphan")
378 j17SetRecord(t, w.fx, func(r *provision.Record) { r.Move = nil })
379 w.setStartEffect(t, func(e *hostops.Effect) { e.State, e.ResultReference = "uncertain", "" })
380 w.pass(t)
381 if c := w.claim(t); c.Op.Kind != hostclaim.KindMoving || w.startEffect(t).State != "uncertain" {
382 t.Fatalf("acted inside ten minutes: claim %+v effect %+v", c.Op, w.startEffect(t))
383 }
384 w.setClaim(t, func(c *hostclaim.Claim) { c.Op.StartedAt = time.Now().UTC().Add(-11 * time.Minute) })
385 w.pass(t)
386 if e := w.startEffect(t); e.State != "complete" || e.ResultReference != "not-applied" {
387 t.Fatalf("the start was not settled: %+v", e)
388 }
389 if c := w.claim(t); c.Op.Kind != hostclaim.KindMoving {
390 t.Fatalf("the settling pass released too: %+v", c.Op)
391 }
392 w.pass(t)
393 c := w.claim(t)
394 if c.Op.Kind != hostclaim.KindServing || c.Op.Exec != "ie-box" || hostclaim.RequireReleasedMove(w.id)(c) != nil {
395 t.Fatalf("the orphan was not released: %+v", c.Op)
396 }
397 if p := w.paged(t); len(p) != 0 {
398 t.Fatalf("a moving orphan paged: %+v", p)
399 }
400
401 // A landing orphan pages once, and is left.
402 l := landingMovePass(t, "code-mvpass-orphan-landing")
403 j17SetRecord(t, l.fx, func(r *provision.Record) { r.Move = nil })
404 before := claimBytes(t, l.fx, "acme.go.tito.io")
405 l.pass(t)
406 l.pass(t)
407 if claimBytes(t, l.fx, "acme.go.tito.io") != before {
408 t.Fatal("a landing orphan's claim was written")
409 }
410 p := l.paged(t)
411 if len(p) != 1 || p[0].Level != alerting.LevelIncident || !strings.Contains(p[0].Subject, "acme.go.tito.io") || !strings.Contains(p[0].Body, l.id) {
412 t.Fatalf("the landing orphan's pages: %+v", p)
413 }
414}
415
416// oneBoxFenceAuthority is c7MoveAuthority with the one-box-region check the
417// grant's Convert needs (the console's hostfence.ClaimFenceAuthority has
418// both).
419type oneBoxFenceAuthority struct{ c7MoveAuthority }
420
421func (oneBoxFenceAuthority) CheckOneBoxRegion(context.Context, string, string) error { return nil }
422
423// TestRecoveryPassConvertsAFencedSourceInTwoPasses (K13): a source
424// terminated before the flip. The first pass writes the record's relocation
425// decision, the second the claim's conversion to relocating.
426func TestRecoveryPassConvertsAFencedSourceInTwoPasses(t *testing.T) {
427 w := startedMovePass(t, "code-mvpass-convert")
428 w.auth = oneBoxFenceAuthority{c7MoveAuthority{region: "ie"}}
429 w.pass(t)
430 if m := w.record(t).Move; m.State != provision.MovePreparing {
431 t.Fatalf("converted with no fence: %+v", m)
432 }
433 terminatedSourceFence(t, w.fx, "ie-box", "uk-box")
434 w.pass(t)
435 if m := w.record(t).Move; m.State != provision.MoveRelocating {
436 t.Fatalf("the decision pass: %+v", m)
437 }
438 if c := w.claim(t); c.Op.Kind != hostclaim.KindMoving {
439 t.Fatalf("the decision pass converted the claim: %+v", c.Op)
440 }
441 w.pass(t)
442 if c := w.claim(t); c.Op.Kind != hostclaim.KindRelocating || c.Op.Phase != "pinning" || c.Op.ID != w.id {
443 t.Fatalf("the conversion pass: %+v", c.Op)
444 }
445 w.pass(t) // a relocating claim is region HA's: nothing
446 if c := w.claim(t); c.Op.Kind != hostclaim.KindRelocating {
447 t.Fatalf("after the conversion: %+v", c.Op)
448 }
449}
450
451// lostTargetRollback puts the landing world into a rollback whose target
452// box was lost and released: the source recovered and opened, the claim
453// restoring/opened under the source.
454func lostTargetRollback(t *testing.T, w *movePassWorld) {
455 t.Helper()
456 w.setClaim(t, func(c *hostclaim.Claim) {
457 c.Op.Kind, c.Op.Phase, c.Op.Exec = hostclaim.KindRestoring, hostclaim.PhaseOpened, "ie-box"
458 c.Lost = []string{"uk-box"}
459 c.Op.Participants = []string{"ie-box"}
460 c.Op.Intent["to"] = "uk"
461 c.Obligations = nil
462 })
463 rb, at := newRollbackID(t), time.Now().UTC().Add(-time.Hour)
464 deadline := at.Add(15 * time.Minute)
465 j17SetRecord(t, w.fx, func(r *provision.Record) {
466 r.Region = "ie"
467 r.Move.State = provision.MoveRollingBack
468 r.Move.Rollback = &provision.MoveRollback{ID: rb, RequestedAt: &at, Reason: provision.RollbackDeclaredLost, TargetPhase: provision.RbTargetReleased,
469 SourcePhase: provision.RbSourceOpened, RecoveredAt: &at, OpenedAt: &at, ReleaseDeadline: &deadline}
470 })
471}
472
473// TestRecoveryPassStampsALostTargetThenCloses (p10 Task G): with the target
474// lost and released and nothing of its box or region owed, the pass stamps
475// target_parked_at through the grant; the close follows once overdue, then
476// the release.
477func TestRecoveryPassStampsALostTargetThenCloses(t *testing.T) {
478 w := landingMovePass(t, "code-mvpass-lost")
479 lostTargetRollback(t, w)
480 w.pass(t)
481 rb := w.record(t).Move.Rollback
482 if rb.TargetParkedAt == nil || rb.TargetPhase != provision.RbTargetCleaned {
483 t.Fatalf("the lost target was not stamped: %+v", rb)
484 }
485 w.pass(t)
486 if w.record(t).Move == nil {
487 t.Fatal("closed inside the boxes' grace")
488 }
489 w.advance(moveCloseGrace + time.Minute)
490 w.pass(t)
491 rec := w.record(t)
492 if rec.Move != nil || rec.LastMove == nil || rec.LastMove.Outcome != "rolled_back" {
493 t.Fatalf("not closed: %+v %+v", rec.Move, rec.LastMove)
494 }
495 if c := w.claim(t); c.Op.Kind != hostclaim.KindRestoring {
496 t.Fatalf("the close's pass released too: %+v", c.Op)
497 }
498 w.pass(t)
499 if c := w.claim(t); c.Op.Kind != hostclaim.KindServing || c.Op.Exec != "ie-box" {
500 t.Fatalf("not released: %+v", c.Op)
501 }
502}
503
504// committedAndOpened puts the landing world at a committed, opened and
505// source-parked move with nothing owed: the close is due.
506func committedAndOpened(t *testing.T, w *movePassWorld) {
507 t.Helper()
508 w.setClaim(t, func(c *hostclaim.Claim) { c.Op.Phase, c.Obligations = hostclaim.PhaseCommitted, nil })
509 at := time.Now().UTC().Add(-time.Hour)
510 j17SetRecord(t, w.fx, func(r *provision.Record) {
511 r.Move.State, r.Move.LandingPhase = provision.MoveCommitted, provision.LandProbed
512 r.Move.CommittedAt, r.Move.OpenedAt, r.Move.SourceParkedAt = &at, &at, &at
513 })
514}
515
516// TestRecoveryPassFinishesTheClose: a close the boxes did not write within
517// moveCloseGrace is overdue. The pass pages it once, closes the record
518// (LastMove done) and, on the next pass, releases under the target.
519func TestRecoveryPassFinishesTheClose(t *testing.T) {
520 w := landingMovePass(t, "code-mvpass-close")
521 committedAndOpened(t, w)
522 w.pass(t)
523 if w.record(t).Move == nil || len(w.paged(t)) != 0 {
524 t.Fatalf("closed or paged inside the grace: %+v", w.paged(t))
525 }
526 w.advance(moveCloseGrace + time.Minute)
527 w.pass(t)
528 rec := w.record(t)
529 if rec.Move != nil || rec.LastMove == nil || rec.LastMove.ID != w.id || rec.LastMove.Outcome != "done" {
530 t.Fatalf("not closed: %+v %+v", rec.Move, rec.LastMove)
531 }
532 if c := w.claim(t); c.Op.Kind != hostclaim.KindLanding || c.Op.Phase != hostclaim.PhaseCommitted {
533 t.Fatalf("released in the close's pass: %+v", c.Op)
534 }
535 if p := w.paged(t); len(p) != 1 || p[0].Level != alerting.LevelWarning || !strings.Contains(p[0].Body, w.id) {
536 t.Fatalf("the overdue close's page: %+v", p)
537 }
538 w.pass(t)
539 c := w.claim(t)
540 if c.Op.Kind != hostclaim.KindServing || c.Op.Exec != "uk-box" || hostclaim.RequireReleasedMove(w.id)(c) != nil {
541 t.Fatalf("not released under the target: %+v", c.Op)
542 }
543 if len(w.paged(t)) != 1 {
544 t.Fatalf("paged again: %+v", w.paged(t))
545 }
546}
547
548// TestRecordFirstCloseCrashIsFinishedByThePass (ruling M4): a box closed the
549// record and crashed before the claim's release. The record carries
550// LastMove and no move; the claim is still in the move's closing op. The
551// console's pass releases from LastMove, under the executor the outcome
552// names, with no grace (the release is the record's decision).
553func TestRecordFirstCloseCrashIsFinishedByThePass(t *testing.T) {
554 for _, c := range []struct {
555 name, outcome, exec string
556 setup func(*testing.T, *movePassWorld)
557 }{
558 {"committed", "done", "uk-box", func(t *testing.T, w *movePassWorld) {
559 w.setClaim(t, func(c *hostclaim.Claim) { c.Op.Phase, c.Obligations = hostclaim.PhaseCommitted, nil })
560 }},
561 {"rolled back", "rolled_back", "ie-box", func(t *testing.T, w *movePassWorld) {
562 w.setClaim(t, func(c *hostclaim.Claim) {
563 c.Op.Kind, c.Op.Phase, c.Op.Exec, c.Obligations = hostclaim.KindRestoring, hostclaim.PhaseOpened, "ie-box", nil
564 })
565 }},
566 } {
567 t.Run(c.name, func(t *testing.T) {
568 w := landingMovePass(t, "code-mvpass-crash-"+strings.ReplaceAll(c.name, " ", "-"))
569 c.setup(t, w)
570 j17SetRecord(t, w.fx, func(r *provision.Record) {
571 if c.outcome == "rolled_back" {
572 r.Region = "ie"
573 }
574 r.Move, r.LastMove = nil, &provision.MoveOutcome{ID: w.id, Outcome: c.outcome, At: time.Now().UTC()}
575 })
576 w.pass(t)
577 got := w.claim(t)
578 if got.Op.Kind != hostclaim.KindServing || got.Op.Exec != c.exec || hostclaim.RequireReleasedMove(w.id)(got) != nil {
579 t.Fatalf("not released: %+v", got.Op)
580 }
581 })
582 }
583 t.Run("reverted", func(t *testing.T) {
584 w := startedMovePass(t, "code-mvpass-crash-reverted")
585 w.setClaim(t, func(c *hostclaim.Claim) {
586 c.Op.Kind, c.Op.Phase, c.Obligations = hostclaim.KindReverting, hostclaim.PhaseOpened, nil
587 if c.Op.Journal == nil {
588 c.Op.Journal = map[string]string{}
589 }
590 c.Op.Journal[hostclaim.JournalRevPhase] = hostclaim.PhaseOpened
591 })
592 j17SetRecord(t, w.fx, func(r *provision.Record) {
593 r.Move, r.LastMove = nil, &provision.MoveOutcome{ID: w.id, Outcome: "rolled_back", At: time.Now().UTC()}
594 })
595 w.pass(t)
596 got := w.claim(t)
597 if got.Op.Kind != hostclaim.KindServing || got.Op.Exec != "ie-box" || hostclaim.RequireReleasedMove(w.id)(got) != nil {
598 t.Fatalf("not released: %+v", got.Op)
599 }
600 })
601 t.Run("aborted", func(t *testing.T) {
602 w := startedMovePass(t, "code-mvpass-crash-aborted")
603 w.setClaim(t, func(c *hostclaim.Claim) { c.Op.Phase, c.Obligations = hostclaim.PhaseAborting, nil })
604 j17SetRecord(t, w.fx, func(r *provision.Record) {
605 r.Move, r.LastMove = nil, &provision.MoveOutcome{ID: w.id, Outcome: "aborted", At: time.Now().UTC()}
606 })
607 w.pass(t)
608 got := w.claim(t)
609 if got.Op.Kind != hostclaim.KindServing || got.Op.Exec != "ie-box" || hostclaim.RequireReleasedMove(w.id)(got) != nil {
610 t.Fatalf("not released: %+v", got.Op)
611 }
612 })
613}
614
615// TestRecoveryPassAbortsAfterSixHours (K18): a move still preparing
616// moves.AbortAfter after its request is aborted through ConsoleMove; before
617// that the pass pages once at an hour and writes nothing.
618// Preflight L29: the 60-minute preparing page and the six-hour abort run
619// from one start, moves.PreparingSince (the later of the request and the
620// readiness), so the two can never disagree about how long a move prepared.
621func TestPreparingPageRunsFromPreparingSince(t *testing.T) {
622 ready := mvAt(30 * time.Minute)
623 for name, c := range map[string]struct {
624 ready *time.Time
625 at time.Duration
626 want bool
627 }{
628 "an hour after the request": {nil, time.Hour, true},
629 "short of it": {nil, time.Hour - time.Second, false},
630 "an hour after the request, ready 30": {ready, time.Hour, false},
631 "an hour after the readiness": {ready, 90 * time.Minute, true},
632 } {
633 rec := mvRecord(provision.MovePreparing, func(r *provision.Record) { r.Move.ReadyAt = c.ready })
634 now := mvT0.Add(c.at)
635 d := decideMoveRecovery(moveRecoveryInput{Now: now, Claim: mvClaim(hostclaim.KindMoving, hostclaim.PhaseMoving), Rec: rec})
636 paged := false
637 for _, k := range d.Pages {
638 paged = paged || k == movePagePreparing
639 }
640 if paged != c.want {
641 t.Errorf("%s: preparing page %v, want %v (pages %v)", name, paged, c.want, d.Pages)
642 }
643 if since := moves.PreparingSince(rec.Move); paged != !now.Before(since.Add(movePreparingPage)) {
644 t.Errorf("%s: the page does not run from moves.PreparingSince (%v)", name, since)
645 }
646 }
647}
648
649func TestRecoveryPassAbortsAfterSixHours(t *testing.T) {
650 w := startedMovePass(t, "code-mvpass-abort")
651 claim := claimBytes(t, w.fx, "acme.go.tito.io")
652 requested := w.record(t).Move.RequestedAt
653 w.advance(30 * time.Minute)
654 w.pass(t)
655 if len(w.paged(t)) != 0 {
656 t.Fatalf("paged at 30 minutes: %+v", w.paged(t))
657 }
658 w.advance(time.Hour)
659 w.pass(t)
660 w.pass(t)
661 if m := w.record(t).Move; m.State != provision.MovePreparing {
662 t.Fatalf("aborted early: %+v", m)
663 }
664 p := w.paged(t)
665 if len(p) != 1 || p[0].Level != alerting.LevelWarning || !strings.Contains(p[0].Subject, "acme.go.tito.io") {
666 t.Fatalf("the hour's page: %+v", p)
667 }
668 w.mu.Lock()
669 w.clock = requested.Add(moves.AbortAfter + time.Minute)
670 w.mu.Unlock()
671 w.pass(t)
672 if m := w.record(t).Move; m.State != provision.MoveAborting {
673 t.Fatalf("not aborted at six hours: %+v", m)
674 }
675 if claimBytes(t, w.fx, "acme.go.tito.io") != claim {
676 t.Fatal("the abort wrote the claim (the source's abort takes it to aborting)")
677 }
678}
679
680// TestRecoveryPassNeverClosesAnAbort (fix round 1, ruling 1): the source's
681// retirement of its own state is blocked, so it never closes the aborting
682// record, though nothing is owed under the op. The console does not close
683// it either: once the abort has been due past the lane's horizon (the
684// claim's quiet_after for the target region) plus moveCloseGrace, it pages
685// once, and the record stays aborting.
686func TestRecoveryPassNeverClosesAnAbort(t *testing.T) {
687 w := startedMovePass(t, "code-mvpass-abort-blocked")
688 horizon := w.clock.Add(20 * time.Minute)
689 w.setClaim(t, func(c *hostclaim.Claim) {
690 c.Op.Phase, c.Obligations = hostclaim.PhaseAborting, nil
691 if c.QuietAfter == nil {
692 c.QuietAfter = map[string]time.Time{}
693 }
694 c.QuietAfter["uk"] = horizon
695 })
696 j17SetRecord(t, w.fx, func(r *provision.Record) { r.Move.State = provision.MoveAborting })
697 claim := claimBytes(t, w.fx, "acme.go.tito.io")
698 w.pass(t)
699 w.advance(moveCloseGrace + time.Minute)
700 w.pass(t)
701 if len(w.paged(t)) != 0 {
702 t.Fatalf("paged before the lane's horizon plus the grace: %+v", w.paged(t))
703 }
704 w.advance(20 * time.Minute) // past quiet_after + the grace
705 w.pass(t)
706 w.advance(2 * time.Hour)
707 w.pass(t)
708 w.pass(t)
709 if m := w.record(t).Move; m == nil || m.State != provision.MoveAborting {
710 t.Fatalf("the console closed the abort: %+v %+v", m, w.record(t).LastMove)
711 }
712 if claimBytes(t, w.fx, "acme.go.tito.io") != claim {
713 t.Fatal("the console wrote the aborting claim")
714 }
715 p := w.paged(t)
716 if len(p) != 1 || p[0].Level != alerting.LevelWarning || !strings.Contains(p[0].Body, w.id) || !strings.Contains(p[0].Body, "ie-box") {
717 t.Fatalf("the overdue abort's pages: %+v", p)
718 }
719}
720
721// TestConsolePagesThroughAlerting (§13, M14; fix round 1, ruling 3): the
722// pass pages through alerting.Page on the armed sinks. A rollback waiting
723// past its release deadline is an incident, paged again only a full hour
724// after the last attempt: two passes either side of a whole hour since the
725// deadline do not both page. The last attempt's time is durable, in the
726// console's store, so a restarted console keeps the spacing.
727func TestConsolePagesThroughAlerting(t *testing.T) {
728 w := landingMovePass(t, "code-mvpass-pages")
729 rb, at := newRollbackID(t), w.clock.Add(-74*time.Minute)
730 deadline := at.Add(15 * time.Minute) // 59 minutes ago
731 j17SetRecord(t, w.fx, func(r *provision.Record) {
732 r.Region = "ie"
733 r.Move.State = provision.MoveRollingBack
734 r.Move.Rollback = &provision.MoveRollback{ID: rb, RequestedAt: &at, Reason: provision.RollbackLandingDeadline, TargetPhase: provision.RbTargetReleasing,
735 SourcePhase: provision.RbSourceWaiting, ReleaseDeadline: &deadline}
736 })
737 w.setClaim(t, func(c *hostclaim.Claim) { c.Op.Kind, c.Op.Phase = hostclaim.KindRestoring, hostclaim.PhaseClaimed })
738 first := w.clock
739 w.pass(t)
740 p := w.paged(t)
741 if len(p) != 1 || p[0].Kind != "page" || p[0].Level != alerting.LevelIncident || !strings.Contains(p[0].Subject, "uk") || !strings.Contains(p[0].Body, w.id) {
742 t.Fatalf("the release deadline's page: %+v", p)
743 }
744 // Across the whole hour since the deadline (59 to 61 minutes): no page.
745 w.advance(2 * time.Minute)
746 w.pass(t)
747 if len(w.paged(t)) != 1 {
748 t.Fatalf("paged again two minutes later, across the hour since the deadline: %+v", w.paged(t))
749 }
750 // A restarted console: the in-memory state is gone, the store is not.
751 w.fx.srv.moveCloseSeen = nil
752 if last, ok, err := w.fx.st.MovePageAttempt("release-deadline acme.go.tito.io " + w.id); err != nil || !ok || !last.Equal(first.Truncate(time.Second)) {
753 t.Fatalf("the last attempt's durable time: %v %v %v, want %v", last, ok, err, first)
754 }
755 w.advance(56 * time.Minute) // 58 minutes after the first page
756 w.pass(t)
757 if len(w.paged(t)) != 1 {
758 t.Fatalf("paged again inside the hour: %+v", w.paged(t))
759 }
760 w.advance(3 * time.Minute) // 61 minutes after the first page
761 w.pass(t)
762 if len(w.paged(t)) != 2 {
763 t.Fatalf("not paged again a full hour after the last attempt: %+v", w.paged(t))
764 }
765}
766
767// moveLogOf is the move's log as written to the deployment bucket.
768func moveLogOf(t *testing.T, w *movePassWorld) map[string]moves.LogEvent {
769 t.Helper()
770 got, err := moves.ReadLog(context.Background(), w.fx.rel, moves.ReportPrefix("bab", "titogo", "acme.go.tito.io", w.id))
771 if err != nil {
772 t.Fatal(err)
773 }
774 out := map[string]moves.LogEvent{}
775 for _, ev := range got {
776 out[ev.Identity] = ev
777 }
778 return out
779}
780
781// Plan 6 Task 3 fix round 1, ruling 3, and fix round 3: the console's move
782// pass logs the transitions it makes: the close and the claim's release after
783// it, the landing deadline's rollback, and the six-hour abort. Each entry is
784// the console's, under the identity the box would give it, queued in the
785// console's outbox and delivered by the pass's drainer (drainMoveLog).
786func TestRecoveryPassWritesTheMoveLog(t *testing.T) {
787 t.Run("close and release", func(t *testing.T) {
788 w := landingMovePass(t, "code-mvpass-log-close")
789 committedAndOpened(t, w)
790 w.pass(t) // the close's grace runs from the pass's first sighting
791 w.advance(moveCloseGrace + time.Minute)
792 w.pass(t) // the close
793 w.pass(t) // the claim's release
794 log := moveLogOf(t, w)
795 done, released := log["done"], log["claim-released"]
796 if done.Event != "done" || done.Actor != "console" || done.Op != w.id || released.Event != "claim_released" || released.Actor != "console" {
797 t.Fatalf("log %+v", log)
798 }
799 if done.Fields["offline_s"] == "" || done.Fields["from"] != "ie" {
800 t.Fatalf("the close's entry lacks its evidence: %+v", done.Fields)
801 }
802 noPendingLog(t, w)
803 })
804 t.Run("deadline rollback", func(t *testing.T) {
805 w := landingMovePass(t, "code-mvpass-log-deadline")
806 past := time.Now().UTC().Add(-time.Minute)
807 j17SetRecord(t, w.fx, func(r *provision.Record) { r.Move.LandingDeadline = &past })
808 w.pass(t)
809 if ev := moveLogOf(t, w)["rolling_back"]; ev.Event != "rolling_back" || ev.Actor != "console" || ev.Fields["reason"] == "" {
810 t.Fatalf("log %+v", moveLogOf(t, w))
811 }
812 })
813 t.Run("six-hour abort", func(t *testing.T) {
814 w := startedMovePass(t, "code-mvpass-log-abort")
815 requested := w.record(t).Move.RequestedAt
816 w.mu.Lock()
817 w.clock = requested.Add(moves.AbortAfter + time.Minute)
818 w.mu.Unlock()
819 w.pass(t)
820 if ev := moveLogOf(t, w)["aborting"]; ev.Event != "aborting" || ev.Actor != "console" {
821 t.Fatalf("log %+v", moveLogOf(t, w))
822 }
823 })
824}
825
826// blockMoveLog makes every write under the move's log fail (a file where
827// its directory goes) until the returned func is called.
828func blockMoveLog(t *testing.T, w *movePassWorld) func() {
829 t.Helper()
830 root := w.fx.rel.(*deletableStore).root
831 p := filepath.Join(root, filepath.FromSlash(moves.ReportPrefix("bab", "titogo", "acme.go.tito.io", w.id)+"log"))
832 if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
833 t.Fatal(err)
834 }
835 if err := os.WriteFile(p, nil, 0o644); err != nil {
836 t.Fatal(err)
837 }
838 return func() {
839 if err := os.Remove(p); err != nil {
840 t.Fatal(err)
841 }
842 }
843}
844
845// blockMoveLogEntry makes the write of one entry fail (a directory where its
846// object goes) until the returned func is called.
847func blockMoveLogEntry(t *testing.T, w *movePassWorld, name string) func() {
848 t.Helper()
849 root := w.fx.rel.(*deletableStore).root
850 p := filepath.Join(root, filepath.FromSlash(moves.LogKeyFor(moves.ReportPrefix("bab", "titogo", "acme.go.tito.io", w.id), name)))
851 if err := os.MkdirAll(p, 0o755); err != nil {
852 t.Fatal(err)
853 }
854 return func() {
855 if err := os.Remove(p); err != nil {
856 t.Fatal(err)
857 }
858 }
859}
860
861func pendingLog(t *testing.T, w *movePassWorld) []string {
862 t.Helper()
863 got, err := w.fx.st.MoveLogPending("acme.go.tito.io", w.id)
864 if err != nil {
865 t.Fatal(err)
866 }
867 var names []string
868 for _, e := range got {
869 n := e.Name
870 if !e.Confirmed {
871 n += "(unconfirmed)"
872 }
873 if e.Held {
874 n += "(held)"
875 }
876 names = append(names, n)
877 }
878 return names
879}
880
881func noPendingLog(t *testing.T, w *movePassWorld) {
882 t.Helper()
883 if p := pendingLog(t, w); len(p) != 0 {
884 t.Fatalf("entries still pending: %v", p)
885 }
886}
887
888// Fix round 3, ruling 1: the console's log delivery is the pass's drainer,
889// independent of any move's state or grant, and no release waits for it. A
890// row whose write fails is retried with backoff, indefinitely, and warned of
891// once in the journal when it is an hour old; it is still delivered after
892// the claim's release.
893func TestRecoveryPassLogOutboxRetries(t *testing.T) {
894 journal := captureLog(t)
895 w := landingMovePass(t, "code-mvpass-outbox-close")
896 committedAndOpened(t, w)
897 w.pass(t)
898 w.advance(moveCloseGrace + time.Minute)
899 unblock := blockMoveLog(t, w)
900 w.pass(t) // the close; its entry is queued and its delivery fails
901 if rec := w.record(t); rec.Move != nil {
902 t.Fatalf("not closed: %+v", rec.Move)
903 }
904 w.pass(t) // the release does not wait for the log
905 if c := w.claim(t); c.Op.Kind != hostclaim.KindServing {
906 t.Fatalf("the release waited for the log: %+v", c.Op)
907 }
908 if p := pendingLog(t, w); strings.Join(p, " ") != "done claim-released" {
909 t.Fatalf("pending %v, want the close's and the release's entries", p)
910 }
911 for i := 0; i < 8; i++ { // over an hour of failing passes
912 w.advance(10 * time.Minute)
913 w.pass(t)
914 }
915 for i := 0; i < 6; i++ { // and more failed attempts after the warning
916 w.advance(30 * time.Minute)
917 w.pass(t)
918 }
919 if n := strings.Count(journal.String(), "unwritten for over an hour"); n != 2 {
920 t.Fatalf("%d hour-old warnings, want one per row:\n%s", n, journal.String())
921 }
922 unblock()
923 w.advance(2 * time.Hour) // past any backoff
924 w.pass(t)
925 log := moveLogOf(t, w)
926 if log["done"].Actor != "console" || log["claim-released"].Event != "claim_released" {
927 t.Fatalf("log %+v", log)
928 }
929 noPendingLog(t, w)
930}
931
932// Fix round 3, ruling 2: claim_released is published only from a successful
933// release. The pass queues it unconfirmed before the release and confirms it
934// after; the drainer delivers confirmed rows only, and settles an unconfirmed
935// one by the claim.
936func TestClaimReleasedIsPublishedOnlyAfterTheRelease(t *testing.T) {
937 closed := func(t *testing.T, code string) *movePassWorld {
938 w := landingMovePass(t, code)
939 committedAndOpened(t, w)
940 w.pass(t)
941 w.advance(moveCloseGrace + time.Minute)
942 w.pass(t) // the close
943 return w
944 }
945 t.Run("a release that fails publishes nothing", func(t *testing.T) {
946 w := closed(t, "code-mvpass-release-fails")
947 w.fx.srv.moveReleaseFault = func() error { return hostops.ErrPending }
948 w.pass(t)
949 if c := w.claim(t); c.Op.Kind == hostclaim.KindServing {
950 t.Fatal("released despite the fault")
951 }
952 if _, ok := moveLogOf(t, w)["claim-released"]; ok {
953 t.Fatal("claim_released published for a release that failed")
954 }
955 if p := pendingLog(t, w); strings.Join(p, " ") != "claim-released(unconfirmed)" {
956 t.Fatalf("pending %v", p)
957 }
958 w.pass(t) // the claim is still held: the row is left for the release
959 if _, ok := moveLogOf(t, w)["claim-released"]; ok {
960 t.Fatal("claim_released published while the claim is held")
961 }
962 w.fx.srv.moveReleaseFault = nil
963 w.pass(t)
964 if ev := moveLogOf(t, w)["claim-released"]; ev.Event != "claim_released" {
965 t.Fatalf("not published after the release: %+v", moveLogOf(t, w))
966 }
967 noPendingLog(t, w)
968 })
969 t.Run("a crash between the release and its confirmation", func(t *testing.T) {
970 w := closed(t, "code-mvpass-release-crash")
971 unblock := blockMoveLogEntry(t, w, "claim-released")
972 w.pass(t) // the release; its entry is confirmed, its delivery fails
973 if c := w.claim(t); c.Op.Kind != hostclaim.KindServing {
974 t.Fatalf("not released: %+v", c.Op)
975 }
976 // As if the console stopped after the release and before the confirm.
977 if _, err := w.fx.st.db.Exec(`UPDATE move_log_outbox SET confirmed = 0 WHERE op = ? AND name = 'claim-released'`, w.id); err != nil {
978 t.Fatal(err)
979 }
980 unblock()
981 w.advance(2 * time.Hour)
982 w.pass(t)
983 if ev := moveLogOf(t, w)["claim-released"]; ev.Event != "claim_released" {
984 t.Fatalf("the released claim's entry was not confirmed and published: %+v", moveLogOf(t, w))
985 }
986 noPendingLog(t, w)
987 })
988 t.Run("an unconfirmed row whose claim another move holds", func(t *testing.T) {
989 journal := captureLog(t)
990 w := closed(t, "code-mvpass-release-other")
991 body := []byte(`{"v":1}`)
992 if err := w.fx.st.QueueMoveLog("acme.go.tito.io", "mv_another", "claim-released", w.claim(t).Holder.Incarnation, body, false, w.clock); err != nil {
993 t.Fatal(err)
994 }
995 w.pass(t)
996 got, err := w.fx.st.MoveLogPending("acme.go.tito.io", "mv_another")
997 if err != nil || len(got) != 0 {
998 t.Fatalf("pending %+v %v", got, err)
999 }
1000 if !strings.Contains(journal.String(), "mv_another") {
1001 t.Fatalf("no journal line for the dropped row:\n%s", journal.String())
1002 }
1003 })
1004}
1005
1006// Fix round 4, ruling 2: a row the drainer cannot settle yet (unconfirmed,
1007// its claim still the move's) takes the same backoff and one-hour warning as
1008// a failed delivery, and the batch picks rows by their next attempt, so a
1009// full batch of them never starves a confirmed row.
1010func TestUnresolvedMoveLogRowsDoNotStarveTheBatch(t *testing.T) {
1011 journal := captureLog(t)
1012 w := landingMovePass(t, "code-mvpass-starve")
1013 body := func(name string) []byte {
1014 b, err := json.Marshal(moves.LogEvent{V: 1, Op: w.id, Host: "acme.go.tito.io", Event: "x", Identity: name, Actor: "console", At: w.clock})
1015 if err != nil {
1016 t.Fatal(err)
1017 }
1018 return b
1019 }
1020 inc := w.claim(t).Holder.Incarnation
1021 for i := 0; i < moveLogDrainBatch; i++ {
1022 name := fmt.Sprintf("held-%03d", i)
1023 if err := w.fx.st.QueueMoveLog("acme.go.tito.io", w.id, name, inc, body(name), false, w.clock); err != nil {
1024 t.Fatal(err)
1025 }
1026 }
1027 if err := w.fx.st.QueueMoveLog("acme.go.tito.io", w.id, "late", inc, body("late"), true, w.clock); err != nil {
1028 t.Fatal(err)
1029 }
1030 for i := 0; i < 2 && moveLogOf(t, w)["late"].Identity == ""; i++ {
1031 w.pass(t)
1032 }
1033 if moveLogOf(t, w)["late"].Identity != "late" {
1034 t.Fatal("the confirmed row was starved by a full batch of unresolved rows")
1035 }
1036 // The unresolved rows are warned of once, an hour on.
1037 for i := 0; i < 10; i++ {
1038 w.advance(30 * time.Minute)
1039 w.pass(t)
1040 }
1041 if n := strings.Count(journal.String(), "unwritten for over an hour"); n != moveLogDrainBatch {
1042 t.Fatalf("%d hour-old warnings, want one per unresolved row (%d)", n, moveLogDrainBatch)
1043 }
1044}
1045
1046// Fix round 4, ruling (b): a real CAS conflict inside g.Claim. Another writer
1047// updates the claim between the grant's observation and the release's CAS,
1048// so the release fails: nothing is published, the row stays unconfirmed,
1049// and a later pass releases, confirms and publishes it.
1050func TestReleaseCASConflictPublishesNothing(t *testing.T) {
1051 w := landingMovePass(t, "code-mvpass-release-cas")
1052 committedAndOpened(t, w)
1053 w.pass(t)
1054 w.advance(moveCloseGrace + time.Minute)
1055 w.pass(t) // the close
1056 d := w.fx.rel.(*deletableStore)
1057 claimKey, err := hostclaim.Key("acme.go.tito.io")
1058 if err != nil {
1059 t.Fatal(err)
1060 }
1061 conflicts := 0
1062 d.putHook = func(ctx context.Context, key string) {
1063 if key != claimKey || conflicts > 0 {
1064 return
1065 }
1066 conflicts++
1067 body, etag, err := d.Store.Get(ctx, key)
1068 if err != nil {
1069 t.Fatal(err)
1070 }
1071 // The concurrent writer: the same claim, rewritten, so its version
1072 // moves on under the release's CAS.
1073 if _, err := d.Store.Put(ctx, key, append(body, '\n'), etag); err != nil {
1074 t.Fatal(err)
1075 }
1076 }
1077 err = w.passErr()
1078 d.putHook = nil
1079 if conflicts != 1 || !errors.Is(err, release.ErrCASConflict) {
1080 t.Fatalf("the release's claim CAS: conflicts=%d err=%v, want a CAS conflict", conflicts, err)
1081 }
1082 if c := w.claim(t); c.Op.Kind == hostclaim.KindServing {
1083 t.Fatal("released despite the conflict")
1084 }
1085 if _, ok := moveLogOf(t, w)["claim-released"]; ok {
1086 t.Fatal("claim_released published for a release that lost its CAS")
1087 }
1088 if p := pendingLog(t, w); strings.Join(p, " ") != "claim-released(unconfirmed)" {
1089 t.Fatalf("pending %v", p)
1090 }
1091 w.advance(2 * time.Hour) // past the unresolved row's backoff
1092 w.pass(t)
1093 if c := w.claim(t); c.Op.Kind != hostclaim.KindServing {
1094 t.Fatalf("not released on the next pass: %+v", c.Op)
1095 }
1096 if ev := moveLogOf(t, w)["claim-released"]; ev.Event != "claim_released" {
1097 t.Fatalf("not published after the release: %+v", moveLogOf(t, w))
1098 }
1099 noPendingLog(t, w)
1100}
1101
1102// Fix round 4, ruling 3: a row is bound to the instance incarnation its
1103// move's evidence carried, and the writer takes the account and app from the
1104// host's claim only when its holder is that incarnation, for a move its
1105// record names. A row for another app's host, a row whose instance was
1106// recreated under the same names, and the rows of a retired instance are
1107// never written, and are dropped.
1108func TestMoveLogRowsAreBoundToTheirIncarnation(t *testing.T) {
1109 w := landingMovePass(t, "code-mvpass-tenancy")
1110 inc := w.claim(t).Holder.Incarnation
1111 entry := func(host, op, name string) []byte {
1112 b, err := json.Marshal(moves.LogEvent{V: 1, Op: op, Host: host, Event: "x", Identity: name, Actor: "console", At: w.clock})
1113 if err != nil {
1114 t.Fatal(err)
1115 }
1116 return b
1117 }
1118 queue := func(host, op, name, inc string) {
1119 t.Helper()
1120 if err := w.fx.st.QueueMoveLog(host, op, name, inc, entry(host, op, name), true, w.clock); err != nil {
1121 t.Fatal(err)
1122 }
1123 }
1124 // Another app's host (the fixture's kass instance, which has no claim
1125 // here), under this instance's incarnation.
1126 queue("kass.carlosapp.dev", w.id, "elsewhere", inc)
1127 // This host, after a recreation under the same names: another incarnation.
1128 queue("acme.go.tito.io", w.id, "stale", strings.Repeat("ef", 32))
1129 // This host and incarnation, but a move its record never named.
1130 queue("acme.go.tito.io", "mv_forged", "forged", inc)
1131 // The instance's own row, written.
1132 queue("acme.go.tito.io", w.id, "genuine", inc)
1133 w.pass(t)
1134 log := moveLogOf(t, w)
1135 if log["genuine"].Identity != "genuine" || log["stale"].Identity != "" {
1136 t.Fatalf("log %+v", log)
1137 }
1138 if forged, err := moves.ReadLog(context.Background(), w.fx.rel, moves.ReportPrefix("bab", "titogo", "acme.go.tito.io", "mv_forged")); err != nil || len(forged) != 0 {
1139 t.Fatalf("a move the record never named was logged: %+v %v", forged, err)
1140 }
1141 // Final fix, ruling 1: a confirmed row the writer refuses is never
1142 // deleted; it is held, with its bytes, until its incarnation's purge.
1143 held := func(host, op string) []string {
1144 t.Helper()
1145 got, err := w.fx.st.MoveLogPending(host, op)
1146 if err != nil {
1147 t.Fatal(err)
1148 }
1149 var names []string
1150 for _, e := range got {
1151 if !e.Held {
1152 t.Fatalf("%s %s %s refused but not held: %+v", host, op, e.Name, e)
1153 }
1154 names = append(names, e.Name)
1155 }
1156 return names
1157 }
1158 if h := held("acme.go.tito.io", w.id); strings.Join(h, " ") != "stale" {
1159 t.Fatalf("held %v, want the stale incarnation's row", h)
1160 }
1161 if h := held("acme.go.tito.io", "mv_forged"); strings.Join(h, " ") != "forged" {
1162 t.Fatalf("held %v, want the forged move's row", h)
1163 }
1164 if h := held("kass.carlosapp.dev", w.id); strings.Join(h, " ") != "elsewhere" {
1165 t.Fatalf("held %v, want the other app's host's row", h)
1166 }
1167 // A claim gone (retired outside the console's door): the instance's own
1168 // row is held too, never written.
1169 queue("acme.go.tito.io", w.id, "retired", inc)
1170 claimKey, err := hostclaim.Key("acme.go.tito.io")
1171 if err != nil {
1172 t.Fatal(err)
1173 }
1174 if err := w.fx.rel.Delete(context.Background(), claimKey); err != nil {
1175 t.Fatal(err)
1176 }
1177 if err := w.passErr(); err != nil {
1178 t.Fatal(err)
1179 }
1180 if h := held("acme.go.tito.io", w.id); strings.Join(h, " ") != "stale retired" {
1181 t.Fatalf("held %v", h)
1182 }
1183 if moveLogOf(t, w)["retired"].Identity != "" {
1184 t.Fatal("a row whose claim is gone was written")
1185 }
1186}
1187
1188// Final fix, ruling 1: a confirmed row whose delivery failed, and whose move
1189// a later move then replaced as the record's last, is refused by the writer
1190// (the record names neither) and held: never written, never retried, warned
1191// of once, its bytes kept until its incarnation's purge.
1192func TestARefusedConfirmedRowIsHeldNotDeleted(t *testing.T) {
1193 journal := captureLog(t)
1194 w := landingMovePass(t, "code-mvpass-held")
1195 committedAndOpened(t, w)
1196 w.pass(t)
1197 w.advance(moveCloseGrace + time.Minute)
1198 unblock := blockMoveLog(t, w)
1199 w.pass(t) // the close; its entry's delivery fails
1200 w.pass(t) // the release
1201 inc := w.claim(t).Holder.Incarnation
1202 j17SetRecord(t, w.fx, func(r *provision.Record) {
1203 r.LastMove = &provision.MoveOutcome{ID: "mv_later", Outcome: "done", At: time.Now().UTC()}
1204 })
1205 unblock()
1206 for i := 0; i < 3; i++ {
1207 w.advance(2 * time.Hour)
1208 w.pass(t)
1209 }
1210 if _, ok := moveLogOf(t, w)["done"]; ok {
1211 t.Fatal("a row whose move the record no longer names was written")
1212 }
1213 got, err := w.fx.st.MoveLogPending("acme.go.tito.io", w.id)
1214 if err != nil {
1215 t.Fatal(err)
1216 }
1217 var done *MoveLogPendingEntry
1218 for i := range got {
1219 if got[i].Name == "done" {
1220 done = &got[i]
1221 }
1222 }
1223 if done == nil || !done.Held || !done.Confirmed || len(done.Body) == 0 {
1224 t.Fatalf("the refused row was not kept and held: %+v", got)
1225 }
1226 if n := strings.Count(journal.String(), "the log of move "+w.id+" at done is held"); n != 1 {
1227 t.Fatalf("%d held warnings, want one:\n%s", n, journal.String())
1228 }
1229 if err := w.fx.st.PurgeMoveLogIncarnation("acme.go.tito.io", inc); err != nil {
1230 t.Fatal(err)
1231 }
1232 if got, _ := w.fx.st.MoveLogPending("acme.go.tito.io", w.id); len(got) != 0 {
1233 t.Fatalf("the incarnation's purge left %+v", got)
1234 }
1235}
1236
1237// declaredLostEntries counts the move's log objects named
1238// rolling_back-target-lost.
1239func declaredLostEntries(t *testing.T, w *movePassWorld) int {
1240 t.Helper()
1241 got, err := moves.ReadLog(context.Background(), w.fx.rel, moves.ReportPrefix("bab", "titogo", "acme.go.tito.io", w.id))
1242 if err != nil {
1243 t.Fatal(err)
1244 }
1245 n := 0
1246 for _, ev := range got {
1247 if ev.Identity == "rolling_back-target-lost" {
1248 n++
1249 }
1250 }
1251 return n
1252}
1253
1254// Final review I4: declared_lost is logged by the console that performs the
1255// declaration, as claim_released is: queued unconfirmed before the
1256// declaration, confirmed once it succeeds, and a row left unconfirmed is
1257// settled by a later pass from the loss the claim records. The declaration
1258// moves the target on to released within the request, so no box pass ever
1259// sees it lost; the event is still logged, once.
1260func TestDeclaredLostIsLoggedByTheConsole(t *testing.T) {
1261 declare := func(t *testing.T, code string) (*movePassWorld, string, string, hostfence.Submission) {
1262 t.Helper()
1263 fx, token, moveURL := j4LandingMove(t, code)
1264 w := newMovePassWorld(t, fx, j17MoveState(t, fx).ID)
1265 c, _, out := rawReq(t, http.MethodPost, moveURL+"/resolve", token, `{"declare_lost":"uk-box"}`)
1266 if c != http.StatusOK {
1267 t.Fatalf("inspect = %d %s", c, out)
1268 }
1269 return w, token, moveURL, j4Submission(t, fx, out)
1270 }
1271 t.Run("the declaration completes before any box pass", func(t *testing.T) {
1272 w, token, moveURL, sub := declare(t, "code-mvlog-declared")
1273 body := j4Body(t, map[string]any{"declare_lost": "uk-box", "submission": sub})
1274 if c, _, out := rawReq(t, http.MethodPost, moveURL+"/resolve", token, body); c != http.StatusAccepted {
1275 t.Fatalf("declare = %d %s", c, out)
1276 }
1277 if rb := w.record(t).Move.Rollback; rb == nil || rb.TargetPhase != provision.RbTargetReleased {
1278 t.Fatalf("the declaration did not move the target on: %+v", rb)
1279 }
1280 w.pass(t)
1281 ev := moveLogOf(t, w)["rolling_back-target-lost"]
1282 if ev.Event != "declared_lost" || ev.Actor != "console" || ev.Op != w.id || ev.Fields["from"] != "ie" || ev.Fields["to"] != "uk" {
1283 t.Fatalf("declared_lost %+v in %+v", ev, moveLogOf(t, w))
1284 }
1285 // A replay of the same declaration, and more passes, log nothing more.
1286 if c, _, out := rawReq(t, http.MethodPost, moveURL+"/resolve", token, body); c != http.StatusAccepted {
1287 t.Fatalf("replay = %d %s", c, out)
1288 }
1289 w.pass(t)
1290 w.pass(t)
1291 if n := declaredLostEntries(t, w); n != 1 {
1292 t.Fatalf("%d declared_lost entries, want one", n)
1293 }
1294 noPendingLog(t, w)
1295 })
1296 t.Run("a crash between the declaration and its confirmation", func(t *testing.T) {
1297 w, token, moveURL, sub := declare(t, "code-mvlog-declared-crash")
1298 if c, _, out := rawReq(t, http.MethodPost, moveURL+"/resolve", token, j4Body(t, map[string]any{"declare_lost": "uk-box", "submission": sub})); c != http.StatusAccepted {
1299 t.Fatalf("declare = %d %s", c, out)
1300 }
1301 // As if the console stopped after the declaration and before the
1302 // confirm.
1303 res, err := w.fx.st.db.Exec(`UPDATE move_log_outbox SET confirmed = 0 WHERE op = ? AND name = 'rolling_back-target-lost'`, w.id)
1304 if err != nil {
1305 t.Fatal(err)
1306 }
1307 if n, _ := res.RowsAffected(); n != 1 {
1308 t.Fatalf("the declaration queued %d rows, want one", n)
1309 }
1310 w.pass(t)
1311 if ev := moveLogOf(t, w)["rolling_back-target-lost"]; ev.Event != "declared_lost" {
1312 t.Fatalf("the committed loss's entry was not settled and published: %+v", moveLogOf(t, w))
1313 }
1314 noPendingLog(t, w)
1315 })
1316 t.Run("a declaration that is refused publishes nothing", func(t *testing.T) {
1317 w, token, moveURL, sub := declare(t, "code-mvlog-declared-stale")
1318 j17SetRecord(t, w.fx, func(r *provision.Record) { r.Move.LandingPhase = provision.LandProbed })
1319 if c, _, out := rawReq(t, http.MethodPost, moveURL+"/resolve", token, j4Body(t, map[string]any{"declare_lost": "uk-box", "submission": sub})); c != http.StatusConflict {
1320 t.Fatalf("stale = %d %s", c, out)
1321 }
1322 if p := pendingLog(t, w); strings.Join(p, " ") != "rolling_back-target-lost(unconfirmed)" {
1323 t.Fatalf("pending %v, want the declaration's row unconfirmed", p)
1324 }
1325 // Final re-review 1, I4: the move can still be declared lost, so the
1326 // row is kept, unpublished, however many passes run.
1327 w.pass(t)
1328 w.advance(2 * time.Hour)
1329 w.pass(t)
1330 if n := declaredLostEntries(t, w); n != 0 {
1331 t.Fatalf("a refused declaration published %d entries", n)
1332 }
1333 if p := pendingLog(t, w); strings.Join(p, " ") != "rolling_back-target-lost(unconfirmed)" {
1334 t.Fatalf("pending %v, want the row kept while the move can still be declared lost", p)
1335 }
1336 // The claim gone (the incarnation retired): the row is dropped.
1337 key, err := hostclaim.Key("acme.go.tito.io")
1338 if err != nil {
1339 t.Fatal(err)
1340 }
1341 if err := w.fx.rel.Delete(context.Background(), key); err != nil {
1342 t.Fatal(err)
1343 }
1344 w.advance(2 * time.Hour)
1345 w.pass(t)
1346 noPendingLog(t, w)
1347 })
1348 // Final re-review 1, I4: the drainer reads the claim with no loss in it;
1349 // the declaration then commits and confirms its row; the drainer, going on
1350 // from its earlier read, must not drop the row. It survives and is
1351 // published once.
1352 t.Run("a declaration that commits while the drainer settles its row", func(t *testing.T) {
1353 w, token, moveURL, sub := declare(t, "code-mvlog-declared-race")
1354 rec := w.record(t)
1355 e, ok := moves.DeclaredLost(rec.Move)
1356 if !ok {
1357 t.Fatal("no entry for the fixture's move")
1358 }
1359 // A declaration in flight: its row queued, its claim CAS not yet.
1360 o := hostops.ConsoleMoveObservation{Host: rec.Host, OpID: rec.Move.ID, Incarnation: rec.Move.Incarnation}
1361 w.fx.srv.queueMoveLogAs(o, e, w.fx.srv.movesNow(), false)
1362 declared := false
1363 w.fx.srv.moveLogSettleHook = func() {
1364 if declared {
1365 return
1366 }
1367 declared = true
1368 if c, _, out := rawReq(t, http.MethodPost, moveURL+"/resolve", token, j4Body(t, map[string]any{"declare_lost": "uk-box", "submission": sub})); c != http.StatusAccepted {
1369 t.Errorf("declare = %d %s", c, out)
1370 }
1371 }
1372 w.pass(t)
1373 if !declared {
1374 t.Fatal("the drainer never settled the row")
1375 }
1376 if p := pendingLog(t, w); strings.Join(p, " ") != "rolling_back-target-lost" {
1377 t.Fatalf("pending %v: the row the declaration confirmed was dropped", p)
1378 }
1379 w.advance(2 * time.Hour)
1380 w.pass(t)
1381 w.pass(t)
1382 if n := declaredLostEntries(t, w); n != 1 {
1383 t.Fatalf("%d declared_lost entries, want one", n)
1384 }
1385 noPendingLog(t, w)
1386 })
1387 // Final re-review 1, I4: a declaration whose row cannot be queued is
1388 // refused, through either door, and nothing is declared.
1389 t.Run("a row that cannot be queued refuses the declaration", func(t *testing.T) {
1390 for _, door := range []string{"move", "host"} {
1391 t.Run(door, func(t *testing.T) {
1392 w, token, moveURL, sub := declare(t, "code-mvlog-declared-noqueue-"+door)
1393 url, body := moveURL+"/resolve", j4Body(t, map[string]any{"declare_lost": "uk-box", "submission": sub})
1394 if door == "host" {
1395 url, body = j4HostDoor(t, w.fx), j4Body(t, map[string]any{"action": "declare-lost", "submission": sub})
1396 }
1397 before := j4World(t, w.fx)
1398 if _, err := w.fx.st.db.Exec(`DROP TABLE move_log_outbox`); err != nil {
1399 t.Fatal(err)
1400 }
1401 if c, _, out := rawReq(t, http.MethodPost, url, token, body); c == http.StatusAccepted || c == http.StatusOK {
1402 t.Fatalf("declare with no outbox = %d %s, want a refusal", c, out)
1403 }
1404 if j4World(t, w.fx) != before {
1405 t.Fatal("a declaration whose log row could not be queued wrote")
1406 }
1407 })
1408 }
1409 })
1410}
1411
1412// j4HostDoor registers the host operator door on fx (NewServer registers it
1413// only with the host claim on at build) and returns its resolve URL for the
1414// j4 host.
1415func j4HostDoor(t *testing.T, fx *consoleFixture) string {
1416 t.Helper()
1417 fx.srv.cfg.Bucket = moveTestBucket
1418 fx.srv.cliRoute("POST /api/cli/ops/hosts/{host}/resolve/inspect", svcrole.Set{}, fx.srv.handleAPIHostResolveInspect)
1419 fx.srv.cliRoute("POST /api/cli/ops/hosts/{host}/resolve", svcrole.Set{}, fx.srv.handleAPIHostResolve)
1420 if err := hostfloor.Publish(context.Background(), fx.rel, release.RootSigner(fx.priv), moveTestBucket, hostfloor.Floor{Min: 1, MinRelease: "v1.0.0"}); err != nil {
1421 t.Fatal(err)
1422 }
1423 return fx.ts.URL + "/api/cli/ops/hosts/" + j4Host + "/resolve"
1424}
1425
1426// Final re-review 1, N1: only a declaration whose signed consequence is the
1427// loss of the move's target (the rollback path, move-target-lost) is
1428// declared_lost. A destroyed-local-only declaration, here for a committed
1429// move's retired source, changes no move state and logs no move event,
1430// through either door.
1431func TestDestroyedLocalOnlyLogsNoMoveEvent(t *testing.T) {
1432 for _, door := range []string{"move", "host"} {
1433 t.Run(door, func(t *testing.T) {
1434 ctx := context.Background()
1435 fx, token, moveURL := j4LandingMove(t, "code-mvlog-localonly-"+door)
1436 w := newMovePassWorld(t, fx, j17MoveState(t, fx).ID)
1437 obs, err := hostclaim.Read(ctx, fx.rel, j4Host)
1438 if err != nil {
1439 t.Fatal(err)
1440 }
1441 if _, err := hostclaim.Update(ctx, fx.rel, obs, func(c *hostclaim.Claim) error {
1442 c.Op.Phase = hostclaim.PhaseCommitted
1443 c.Obligations = append(c.Obligations, hostclaim.Obligation{ID: "local", Op: w.id, Kind: "park-local", Box: "ie-box", Retirement: "ret-local", Owner: "box:ie-box"})
1444 return nil
1445 }); err != nil {
1446 t.Fatal(err)
1447 }
1448 committed := time.Now().UTC()
1449 j17SetRecord(t, fx, func(r *provision.Record) { r.Move.State, r.Move.CommittedAt = provision.MoveCommitted, &committed })
1450 inspectURL, inspectBody, declURL := moveURL+"/resolve", `{"declare_lost":"ie-box"}`, moveURL+"/resolve"
1451 if door == "host" {
1452 base := j4HostDoor(t, fx)
1453 inspectURL, inspectBody, declURL = base+"/inspect", `{"action":"declare-lost","box":"ie-box"}`, base
1454 }
1455 c, _, out := rawReq(t, http.MethodPost, inspectURL, token, inspectBody)
1456 if c != http.StatusOK || !strings.Contains(out, "destroyed-local-only") {
1457 t.Fatalf("inspect = %d %s, want a destroyed-local-only challenge", c, out)
1458 }
1459 sub := j4Submission(t, fx, out)
1460 body := j4Body(t, map[string]any{"declare_lost": "ie-box", "submission": sub})
1461 if door == "host" {
1462 body = j4Body(t, map[string]any{"action": "declare-lost", "submission": sub})
1463 }
1464 if c, _, out := rawReq(t, http.MethodPost, declURL, token, body); c != http.StatusAccepted && c != http.StatusOK {
1465 t.Fatalf("declare = %d %s", c, out)
1466 }
1467 if m := w.record(t).Move; m.State != provision.MoveCommitted {
1468 t.Fatalf("a local-only declaration changed the move: %+v", m)
1469 }
1470 if p := pendingLog(t, w); len(p) != 0 {
1471 t.Fatalf("a local-only declaration queued %v", p)
1472 }
1473 w.pass(t)
1474 if n := declaredLostEntries(t, w); n != 0 {
1475 t.Fatalf("a local-only declaration logged %d declared_lost entries", n)
1476 }
1477 })
1478 }
1479}
1480
1481// ---- the target fence (moves after claim Plan 12, Task 5, ruling Q8) ----
1482
1483// mvTargetFence is a target fence reference, on values.
1484var mvTargetFence = hostclaim.FenceRef{Enrollment: strings.Repeat("b", 32), Key: "control/box-fences/" + strings.Repeat("b", 32) + ".json", SHA256: strings.Repeat("c", 64)}
1485
1486// TestRecoveryPassTargetFenceDecision: on values, when the target's fence has
1487// a step left for the grant, and where it sits in the order (Q8): after the
1488// flip, ahead of the claim's rollback half and the deadline's rollback, and
1489// never once the record carries the consumed fence as its evidence. A dirty
1490// target's fence comes after the rollback half, since the grant takes
1491// neither half of it.
1492func TestRecoveryPassTargetFenceDecision(t *testing.T) {
1493 ref := mvTargetFence
1494 past := mvT0.Add(-time.Hour)
1495 target := func(phase string, dirty bool) func(*provision.Record) {
1496 return func(r *provision.Record) { r.Move.Rollback.TargetPhase, r.Move.Rollback.Dirty = phase, dirty }
1497 }
1498 lost := func(key string) func(*provision.Record) {
1499 return func(r *provision.Record) {
1500 r.Move.Rollback.TargetPhase, r.Move.Rollback.LossRef = provision.RbTargetLost, key
1501 }
1502 }
1503 committedLoss := func(r *provision.Record) {
1504 r.Move.Rollback.TargetPhase, r.Move.Rollback.LossRef, r.Move.Rollback.Evidence = provision.RbTargetReleased, ref.Key, ref.Key
1505 }
1506 restoring := func(phase, exec string) *hostclaim.Claim {
1507 return mvClaim(hostclaim.KindRestoring, phase, func(c *hostclaim.Claim) { c.Op.Exec = exec })
1508 }
1509 for _, c := range []struct {
1510 name string
1511 claim *hostclaim.Claim
1512 rec provision.Record
1513 consumed bool
1514 action moveRecoveryAction
1515 half hostops.MoveRecoveryClaimAction
1516 }{
1517 {"moving/sealed, record landing: the flip, never the fence", mvClaim(hostclaim.KindMoving, hostclaim.PhaseSealed), mvRecord(provision.MoveLanding), false, moveActClaim, hostops.RecoveryMoveFlip},
1518 {"a landing: the fence rolls the record back", mvClaim(hostclaim.KindLanding, "probing"), mvRecord(provision.MoveLanding), false, moveActFence, ""},
1519 {"a landing past its deadline: the fence, not the deadline's rollback", mvClaim(hostclaim.KindLanding, "probing"),
1520 mvRecord(provision.MoveLanding, func(r *provision.Record) { r.Move.LandingDeadline = &past }), false, moveActFence, ""},
1521 {"rolling back, the claim landing: the fence, not the rollback half", mvClaim(hostclaim.KindLanding, "probing"), mvRecord(provision.MoveRollingBack), false, moveActFence, ""},
1522 {"rolling back, the target lost under this fence: the fence (its claim half)", mvClaim(hostclaim.KindLanding, "probing"), mvRecord(provision.MoveRollingBack, lost(ref.Key)), false, moveActFence, ""},
1523 {"rolling back, the target lost under another loss (J4): not the fence", restoring(hostclaim.PhaseReleased, "none"), mvRecord(provision.MoveRollingBack, lost("control/host-loss/other")), false, moveActNone, ""},
1524 {"released clean, the claim restoring/releasing under the target: the fence", restoring(hostclaim.PhaseReleasing, "uk-box"), mvRecord(provision.MoveRollingBack, target(provision.RbTargetReleased, false)), false, moveActFence, ""},
1525 {"released clean, the claim past releasing: not the fence", restoring(hostclaim.PhaseClaimed, "ie-box"), mvRecord(provision.MoveRollingBack, target(provision.RbTargetReleased, false)), false, moveActNone, ""},
1526 {"cleaned: nothing left for the fence", restoring(hostclaim.PhaseOpened, "ie-box"), mvRecord(provision.MoveRollingBack, target(provision.RbTargetCleaned, false)), false, moveActNone, ""},
1527 {"dirty, the claim landing: the rollback half first", mvClaim(hostclaim.KindLanding, "probing"), mvRecord(provision.MoveRollingBack, target(provision.RbTargetReleased, true)), false, moveActClaim, hostops.RecoveryMoveRollback},
1528 {"dirty, the claim restoring: the fence (which leaves it to J4)", restoring(hostclaim.PhaseReleasing, "uk-box"), mvRecord(provision.MoveRollingBack, target(provision.RbTargetReleased, true)), false, moveActFence, ""},
1529 {"a committed landing: never the fence", mvClaim(hostclaim.KindLanding, hostclaim.PhaseCommitted), mvRecord(provision.MoveLanding), false, moveActNone, ""},
1530 {"consumed, the loss not yet on the record: the fence", restoring(hostclaim.PhaseReleased, "none"), mvRecord(provision.MoveRollingBack, lost(ref.Key)), true, moveActFence, ""},
1531 {"consumed, LossRef and Evidence the fence: stop", restoring(hostclaim.PhaseReleased, "none"), mvRecord(provision.MoveRollingBack, committedLoss), true, moveActNone, ""},
1532 {"consumed, the target released on its own evidence: stop", restoring(hostclaim.PhaseReleased, "none"), mvRecord(provision.MoveRollingBack, target(provision.RbTargetReleased, false)), true, moveActNone, ""},
1533 } {
1534 t.Run(c.name, func(t *testing.T) {
1535 got := decideMoveRecovery(moveRecoveryInput{Now: mvT0, Claim: c.claim, Rec: c.rec, TargetFence: &ref, TargetFenceConsumed: c.consumed})
1536 if got.Action != c.action || got.Claim != c.half || got.Record != "" {
1537 t.Fatalf("decided %+v, want %s %s", got, c.action, c.half)
1538 }
1539 })
1540 }
1541 // With no fence, a landing past its deadline is the deadline's rollback,
1542 // as before.
1543 d := decideMoveRecovery(moveRecoveryInput{Now: mvT0, Claim: mvClaim(hostclaim.KindLanding, "probing"),
1544 Rec: mvRecord(provision.MoveLanding, func(r *provision.Record) { r.Move.LandingDeadline = &past })})
1545 if d.Action != moveActRecord || d.Record != hostops.RecoveryMoveDeadlineRollback {
1546 t.Fatalf("no fence, past the deadline: %+v", d)
1547 }
1548}
1549
1550// fencedTargetWorld is the landing world (j4LandingMove) with its target,
1551// uk-box, terminated: the regional worker's fence for it in the bucket, and
1552// the pass's fence authority binding it to uk. calls lists the fences the
1553// pass's fence calls named.
1554func fencedTargetWorld(t *testing.T, code string) (*movePassWorld, hostclaim.FenceRef, func() []hostclaim.FenceRef) {
1555 t.Helper()
1556 w := landingMovePass(t, code)
1557 w.auth = oneBoxFenceAuthority{c7MoveAuthority{region: "uk"}}
1558 ref := terminatedSourceFence(t, w.fx, "uk-box", "ie-box")
1559 if c := w.claim(t); c.Op.Intent["target_enrollment"] != ref.Enrollment {
1560 t.Fatalf("the fence is not the target's: %s, claim %s", ref.Enrollment, c.Op.Intent["target_enrollment"])
1561 }
1562 var mu sync.Mutex
1563 var seen []hostclaim.FenceRef
1564 w.fx.srv.moveFenceCall = func(r hostclaim.FenceRef) { mu.Lock(); seen = append(seen, r); mu.Unlock() }
1565 return w, ref, func() []hostclaim.FenceRef {
1566 mu.Lock()
1567 defer mu.Unlock()
1568 return append([]hostclaim.FenceRef(nil), seen...)
1569 }
1570}
1571
1572// TestRecoveryPassReleasesAFencedTargetInThreePasses (Q8): a landing whose
1573// target was terminated. The grant takes one step a pass, record first: the
1574// record's rollback with the target lost under the fence, then the claim's
1575// release that consumes it, then the record's loss commit (released, the
1576// fence as its evidence). Each pass writes one object; the fourth writes
1577// nothing. The console logs its writes: the rollback, the claim's rollback
1578// and the target's release.
1579func TestRecoveryPassReleasesAFencedTargetInThreePasses(t *testing.T) {
1580 w, ref, calls := fencedTargetWorld(t, "code-mvpass-fence")
1581 claim := claimBytes(t, w.fx, j4Host)
1582
1583 w.pass(t)
1584 rec := w.record(t)
1585 rb := rec.Move.Rollback
1586 if rec.Move.State != provision.MoveRollingBack || rec.Region != "ie" || rb == nil || rb.Reason != provision.RollbackTargetFenced || rb.TargetPhase != provision.RbTargetLost || rb.LossRef != ref.Key {
1587 t.Fatalf("the first pass: region %s %+v %+v", rec.Region, rec.Move, rb)
1588 }
1589 if claimBytes(t, w.fx, j4Host) != claim {
1590 t.Fatal("the record's pass wrote the claim too")
1591 }
1592
1593 w.pass(t)
1594 c := w.claim(t)
1595 if got, ok := hostclaim.ConsumedMoveTargetFence(c); !ok || got != ref || c.Op.Kind != hostclaim.KindRestoring {
1596 t.Fatalf("the second pass did not consume the fence: %+v %v %+v", got, ok, c.Op)
1597 }
1598 if after := w.record(t); after.RecordSeq != rec.RecordSeq {
1599 t.Fatalf("the claim's pass wrote the record: %d after %d", after.RecordSeq, rec.RecordSeq)
1600 }
1601
1602 claim = claimBytes(t, w.fx, j4Host)
1603 w.pass(t)
1604 rec = w.record(t)
1605 rb = rec.Move.Rollback
1606 if rb.TargetPhase != provision.RbTargetReleased || rb.Dirty || rb.Evidence != ref.Key || rb.LossRef != ref.Key {
1607 t.Fatalf("the third pass did not commit the loss on the record: %+v", rb)
1608 }
1609 if claimBytes(t, w.fx, j4Host) != claim {
1610 t.Fatal("the record's loss commit wrote the claim")
1611 }
1612
1613 w.pass(t)
1614 if after := w.record(t); after.RecordSeq != rec.RecordSeq || claimBytes(t, w.fx, j4Host) != claim {
1615 t.Fatalf("the fourth pass wrote: record %d after %d", after.RecordSeq, rec.RecordSeq)
1616 }
1617 for i, r := range calls() {
1618 if r != ref {
1619 t.Fatalf("call %d named %+v, not the target's fence %+v", i, r, ref)
1620 }
1621 }
1622 log := moveLogOf(t, w)
1623 if ev := log["rolling_back"]; ev.Actor != "console" || ev.Fields["reason"] != provision.RollbackTargetFenced {
1624 t.Fatalf("the fenced rollback's entry: %+v in %+v", ev, log)
1625 }
1626 if ev := log["rolling_back-target-released"]; ev.Actor != "console" || ev.Event != provision.RbTargetReleased {
1627 t.Fatalf("the loss commit's entry: %+v in %+v", ev, log)
1628 }
1629 if ev := log["claim-rollback"]; ev.Actor != "console" || ev.Event != "claim_rollback" {
1630 t.Fatalf("the claim release's rollback entry: %+v in %+v", ev, log)
1631 }
1632 if p := w.paged(t); len(p) != 0 {
1633 t.Fatalf("a clean fenced target paged: %+v", p)
1634 }
1635}
1636
1637// TestRecoveryPassFenceBeatsTheDeadlineRollback (Q8): a landing past its
1638// deadline whose target was terminated is rolled back by the fence (the
1639// grant does both halves), not by the deadline.
1640func TestRecoveryPassFenceBeatsTheDeadlineRollback(t *testing.T) {
1641 w, ref, _ := fencedTargetWorld(t, "code-mvpass-fence-deadline")
1642 past := time.Now().UTC().Add(-time.Minute)
1643 j17SetRecord(t, w.fx, func(r *provision.Record) { r.Move.LandingDeadline = &past })
1644 w.pass(t)
1645 rb := w.record(t).Move.Rollback
1646 if rb == nil || rb.Reason != provision.RollbackTargetFenced || rb.TargetPhase != provision.RbTargetLost || rb.LossRef != ref.Key {
1647 t.Fatalf("the deadline won: %+v", rb)
1648 }
1649}
1650
1651// TestRecoveryPassFlipsBeforeTheFence (Q8): with the claim still moving/sealed
1652// and the record landing, the pass writes the flip and does not call the
1653// fence (the grant refuses a moving claim). The fence follows on the next
1654// pass.
1655func TestRecoveryPassFlipsBeforeTheFence(t *testing.T) {
1656 d := decideMoveRecovery(moveRecoveryInput{Now: mvT0, Claim: mvClaim(hostclaim.KindMoving, hostclaim.PhaseSealed), Rec: mvRecord(provision.MoveLanding), TargetFence: &mvTargetFence})
1657 if d.Action != moveActClaim || d.Claim != hostops.RecoveryMoveFlip {
1658 t.Fatalf("decided %+v, want the flip", d)
1659 }
1660 w, ref, calls := fencedTargetWorld(t, "code-mvpass-fence-flip")
1661 w.setClaim(t, func(c *hostclaim.Claim) {
1662 c.Op.Kind, c.Op.Phase, c.Op.Exec = hostclaim.KindMoving, hostclaim.PhaseSealed, ""
1663 c.Op.Participants = []string{"ie-box", "uk-box"}
1664 })
1665 w.pass(t)
1666 if c := w.claim(t); c.Op.Kind != hostclaim.KindLanding {
1667 t.Fatalf("not flipped: %+v", c.Op)
1668 }
1669 if n := len(calls()); n != 0 {
1670 t.Fatalf("the flip's pass called the fence %d times", n)
1671 }
1672 if m := w.record(t).Move; m.State != provision.MoveLanding {
1673 t.Fatalf("the flip's pass rolled the record back: %+v", m)
1674 }
1675 w.pass(t)
1676 if rb := w.record(t).Move.Rollback; rb == nil || rb.LossRef != ref.Key {
1677 t.Fatalf("the fence did not follow the flip: %+v", rb)
1678 }
1679}
1680
1681// TestRecoveryPassLeavesADirtyFencedTargetToJ4AndPagesOnce (Q8): a target
1682// terminated after it released dirty holds writes the source does not have.
1683// The grant refuses it (no return lane can be built from a dead box), so the
1684// pass writes nothing for the fence, pages once that an operator must declare
1685// the target lost, and leaves it for J4.
1686func TestRecoveryPassLeavesADirtyFencedTargetToJ4AndPagesOnce(t *testing.T) {
1687 w, _, calls := fencedTargetWorld(t, "code-mvpass-fence-dirty")
1688 rbID := dirtyFencedRollback(t, w)
1689 w.pass(t) // the claim's rollback half, which the fence does not take for a dirty target
1690 if c := w.claim(t); c.Op.Kind != hostclaim.KindRestoring || c.Op.Intent["rollback"] != rbID {
1691 t.Fatalf("the rollback half: %+v", c.Op)
1692 }
1693 if n := len(calls()); n != 0 {
1694 t.Fatalf("the fence was called before the rollback half: %d", n)
1695 }
1696 claim, rec := claimBytes(t, w.fx, j4Host), w.record(t)
1697 w.pass(t)
1698 w.pass(t)
1699 w.advance(2 * time.Hour)
1700 w.pass(t)
1701 if n := len(calls()); n == 0 {
1702 t.Fatal("the fence was never tried: its verification is the page's evidence")
1703 }
1704 if after := w.record(t); after.RecordSeq != rec.RecordSeq || claimBytes(t, w.fx, j4Host) != claim {
1705 t.Fatalf("a dirty fenced target was written: %+v", after.Move.Rollback)
1706 }
1707 p := w.paged(t)
1708 if len(p) != 1 {
1709 t.Fatalf("pages %+v, want one", p)
1710 }
1711 subject, body := moveTargetFencedDirtyPage(j4Host, w.id, "uk-box", "uk", "ie")
1712 if p[0].Level != alerting.LevelIncident || p[0].Subject != subject || p[0].Body != body {
1713 t.Fatalf("the page: %+v", p[0])
1714 }
1715 if !strings.Contains(body, "--declare-lost uk-box") || !strings.Contains(body, j4Host) {
1716 t.Fatalf("the page does not say how to declare the target lost: %q", body)
1717 }
1718}
1719
1720// dirtyFencedRollback puts the fenced world at a rollback whose target
1721// released dirty, the claim still landing, and returns the rollback's id.
1722func dirtyFencedRollback(t *testing.T, w *movePassWorld) string {
1723 t.Helper()
1724 rbID, at := newRollbackID(t), time.Now().UTC().Add(-time.Minute)
1725 deadline := at.Add(time.Hour)
1726 j17SetRecord(t, w.fx, func(r *provision.Record) {
1727 r.Region = "ie"
1728 r.Move.State = provision.MoveRollingBack
1729 r.Move.Rollback = &provision.MoveRollback{ID: rbID, RequestedAt: &at, Reason: provision.RollbackLandingDeadline, TargetPhase: provision.RbTargetReleased, Dirty: true,
1730 SourcePhase: provision.RbSourceWaiting, ReleaseDeadline: &deadline}
1731 })
1732 return rbID
1733}
1734
1735// TestRecoveryPassPagesOnlyAVerifiedDirtyTarget (Task 5 fix round 1, item
1736// 1): the dirty page rests only on the grant's verified-dirty outcome. The
1737// move's registration withdrawn from the admission between the pass's
1738// observation and the fence call makes the grant answer ErrPending before it
1739// verifies anything: the pass sends no page, then or on a later pass.
1740func TestRecoveryPassPagesOnlyAVerifiedDirtyTarget(t *testing.T) {
1741 w, _, calls := fencedTargetWorld(t, "code-mvpass-fence-dirty-admission")
1742 dirtyFencedRollback(t, w)
1743 w.pass(t) // the claim's rollback half
1744 ctx := context.Background()
1745 withdraw := func(hostclaim.FenceRef) {
1746 body, etag, err := w.fx.rel.Get(ctx, "apps/bab/titogo/admission.json")
1747 if err != nil {
1748 t.Error(err)
1749 return
1750 }
1751 var a hostops.AppAdmission
1752 if err := json.Unmarshal(body, &a); err != nil {
1753 t.Error(err)
1754 return
1755 }
1756 if _, ok := a.Registrations[w.id]; !ok {
1757 return
1758 }
1759 delete(a.Registrations, w.id)
1760 if body, err = json.Marshal(a); err != nil {
1761 t.Error(err)
1762 return
1763 }
1764 if _, err := w.fx.rel.Put(ctx, "apps/bab/titogo/admission.json", body, etag); err != nil {
1765 t.Error(err)
1766 }
1767 }
1768 w.fx.srv.moveFenceCall = withdraw
1769 w.pass(t)
1770 w.pass(t)
1771 if len(calls()) != 0 {
1772 t.Fatal("the seam was replaced; the world's own counter should not run")
1773 }
1774 if p := w.paged(t); len(p) != 0 {
1775 t.Fatalf("paged without a verified fence: %+v", p)
1776 }
1777}
1778
1779// TestRecoveryPassLogsOnlyItsOwnFenceWrites (Task 5 fix round 1, item 2):
1780// the console logs a fence step from the grant's own evidence, never from a
1781// record read after it. A box's record write that lands just after the
1782// grant's write (the claim's release, then the record's rollback) is not
1783// logged as the console's, and the console's own entries are all there:
1784// claim_rollback for the claim's step, rolling_back for the record's.
1785func TestRecoveryPassLogsOnlyItsOwnFenceWrites(t *testing.T) {
1786 recordKey := provision.RecordKey("bab", "titogo", j4Host)
1787 claimKey, err := hostclaim.Key(j4Host)
1788 if err != nil {
1789 t.Fatal(err)
1790 }
1791 // landAfter writes the record once, as the source box would (its
1792 // rollback's source phase), right after the next accepted Put of key.
1793 landAfter := func(t *testing.T, w *movePassWorld, key string) *bool {
1794 t.Helper()
1795 st := w.fx.rel.(*deletableStore)
1796 landed := new(bool)
1797 st.afterPut = func(_ context.Context, k string) {
1798 if k != key {
1799 return
1800 }
1801 st.afterPut = nil
1802 *landed = true
1803 j17SetRecord(t, w.fx, func(r *provision.Record) {
1804 if r.Move != nil && r.Move.Rollback != nil {
1805 r.Move.Rollback.SourcePhase = provision.RbSourceRestoring
1806 }
1807 })
1808 }
1809 t.Cleanup(func() { st.afterPut = nil })
1810 return landed
1811 }
1812 notTheConsoles := func(t *testing.T, w *movePassWorld) {
1813 t.Helper()
1814 if ev, ok := moveLogOf(t, w)["rolling_back-source-"+provision.RbSourceRestoring]; ok {
1815 t.Fatalf("the box's write was logged as the console's: %+v", ev)
1816 }
1817 }
1818 t.Run("the claim's step", func(t *testing.T) {
1819 w, _, _ := fencedTargetWorld(t, "code-mvpass-fence-log-claim")
1820 w.pass(t) // the record's rollback
1821 landed := landAfter(t, w, claimKey)
1822 w.pass(t) // the claim's release, which rolls the landing claim back
1823 if !*landed {
1824 t.Fatal("the box's write did not land after the claim's step")
1825 }
1826 if ev := moveLogOf(t, w)["claim-rollback"]; ev.Actor != "console" || ev.Event != "claim_rollback" {
1827 t.Fatalf("the claim's rollback was not logged: %+v in %+v", ev, moveLogOf(t, w))
1828 }
1829 notTheConsoles(t, w)
1830 })
1831 t.Run("the record's step", func(t *testing.T) {
1832 w, _, _ := fencedTargetWorld(t, "code-mvpass-fence-log-record")
1833 landed := landAfter(t, w, recordKey)
1834 w.pass(t) // the record's rollback
1835 if !*landed {
1836 t.Fatal("the box's write did not land after the record's step")
1837 }
1838 log := moveLogOf(t, w)
1839 if ev := log["rolling_back"]; ev.Actor != "console" || ev.Fields["reason"] != provision.RollbackTargetFenced {
1840 t.Fatalf("the fenced rollback was not logged: %+v in %+v", ev, log)
1841 }
1842 if _, ok := log["claim-rollback"]; ok {
1843 t.Fatalf("a record step logged a claim rollback: %+v", log)
1844 }
1845 notTheConsoles(t, w)
1846 })
1847}
1848
1849// TestRecoveryPassStopsCallingTheFenceOnceConsumed (Q8): once the claim
1850// carries the consumed fence and the record its loss (LossRef and Evidence
1851// the fence's key), the pass never calls the fence again.
1852func TestRecoveryPassStopsCallingTheFenceOnceConsumed(t *testing.T) {
1853 w, ref, calls := fencedTargetWorld(t, "code-mvpass-fence-stop")
1854 for range 3 {
1855 w.pass(t)
1856 }
1857 if rb := w.record(t).Move.Rollback; rb == nil || rb.LossRef != ref.Key || rb.Evidence != ref.Key {
1858 t.Fatalf("not at the stop condition: %+v", rb)
1859 }
1860 n := len(calls())
1861 if n != 3 {
1862 t.Fatalf("%d fence calls for three steps", n)
1863 }
1864 for range 3 {
1865 w.pass(t)
1866 }
1867 if got := len(calls()); got != n {
1868 t.Fatalf("the fence was called %d more times after its loss was committed", got-n)
1869 }
1870}
1871
1872// TestRecoveryPassReadsTheConsumedFenceNotTheBucket (Q8): after the claim
1873// consumed the fence, the pass takes the reference from the claim, never the
1874// bucket: the fence object deleted, the record's loss commit still follows.
1875func TestRecoveryPassReadsTheConsumedFenceNotTheBucket(t *testing.T) {
1876 w, ref, calls := fencedTargetWorld(t, "code-mvpass-fence-consumed")
1877 w.pass(t)
1878 w.pass(t)
1879 if _, ok := hostclaim.ConsumedMoveTargetFence(w.claim(t)); !ok {
1880 t.Fatal("the fence was not consumed in two passes")
1881 }
1882 if err := w.fx.rel.(*deletableStore).Delete(context.Background(), ref.Key); err != nil {
1883 t.Fatal(err)
1884 }
1885 w.pass(t)
1886 if rb := w.record(t).Move.Rollback; rb == nil || rb.TargetPhase != provision.RbTargetReleased || rb.Evidence != ref.Key {
1887 t.Fatalf("the loss commit did not follow the deleted fence: %+v", rb)
1888 }
1889 if got := calls(); len(got) != 3 || got[2] != ref {
1890 t.Fatalf("the third call: %+v", got)
1891 }
1892}
1893