carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1package rollout
2
3import (
4 "context"
5 "encoding/json"
6 "errors"
7 "fmt"
8 "os"
9 "path/filepath"
10 "strings"
11 "sync"
12 "testing"
13 "time"
14
15 "github.com/carlosframework/platform/internal/activator"
16 "github.com/carlosframework/platform/internal/appconfig"
17 "github.com/carlosframework/platform/internal/status"
18)
19
20var markV2 = HoldRan{Account: "bab", App: "blog", Build: "v2"}
21
22// Final review wave 2, N2: decideWake's rule for an instance with a
23// hold-ran marker. The good build never boots in place of the installed
24// build, and (Astra R3 N7, ruling 205) the marker lifts no failure record
25// and the installed good build waits for a signed rollback.
26func TestDecideWakeWithAHoldRanMarker(t *testing.T) {
27 overlap := DefaultPolicy()
28 book := func(recs ...FailureRecord) *FailureBook {
29 b := &FailureBook{}
30 for _, r := range recs {
31 b.Record(r)
32 }
33 b.MarkHoldRan("a", markV2)
34 return b
35 }
36 sibV2 := FailureRecord{Account: "bab", App: "blog", Build: "v2", Host: "x", At: t0}
37 sibV3 := FailureRecord{Account: "bab", App: "blog", Build: "v3", Host: "x", At: t0}
38
39 // N7: the marked build, blocked for the instance by a sibling's record,
40 // with a good build: refused, never v1 and never v2.
41 p, err := decideWake(inputs(wakeRoute("v2", "v1"), book(sibV2), overlap))
42 if err != nil || p.Refuse != HeldDownRefusal("v2") || p.Boot.StagedPath != "" {
43 t.Fatalf("installed is the marked build, blocked: want the wake refused in the hold-down words, got %+v, %v", p, err)
44 }
45 // With no good build a record alone refuses nothing (spec §8.5): the
46 // marked build boots, hold, no fallback.
47 noGood := wakeRoute("v2", "")
48 noGood.GoodDigest = ""
49 if p, err := decideWake(inputs(noGood, book(sibV2), overlap)); err != nil || p.Refuse != "" || p.Boot.Version != "v2" ||
50 !p.FirstWake || p.Fallback != nil || p.Mode != activator.ModeHold {
51 t.Fatalf("installed is the marked build, blocked, no good build: want a hold first wake of v2, got %+v, %v", p, err)
52 }
53 // Unblocked, and its release allows overlap: still hold, no fallback.
54 if p, _ := decideWake(inputs(wakeRoute("v2", "v1"), book(), overlap)); p.Boot.Version != "v2" || p.Fallback != nil || p.Mode != activator.ModeHold {
55 t.Fatalf("installed is the marked build, unblocked: want hold with no fallback, got %+v", p)
56 }
57 // Held down on the instance: the hold-down refusal still comes first.
58 down := book(FailureRecord{Account: "bab", App: "blog", Build: "v2", Host: "a", HeldDown: []string{"a"}, At: t0})
59 if p, _ := decideWake(inputs(wakeRoute("v2", "v1"), down, overlap)); p.Refuse != HeldDownRefusal("v2") {
60 t.Fatalf("marked build held down: want the hold-down refusal, got %+v", p)
61 }
62 // Another installed build held for the instance: refused, never v1.
63 if p, _ := decideWake(inputs(wakeRoute("v3", "v1"), book(sibV3), overlap)); p.Refuse != HeldDownRefusal("v2") || p.Boot.StagedPath != "" {
64 t.Fatalf("another held build: want the wake refused in the hold-down words, got %+v", p)
65 }
66 // Another, unblocked build: a first wake with no fallback, in its own mode.
67 if p, _ := decideWake(inputs(wakeRoute("v3", "v1"), book(), overlap)); p.Refuse != "" || p.Boot.Version != "v3" || !p.FirstWake ||
68 p.Fallback != nil || p.Mode != activator.ModeOverlap {
69 t.Fatalf("another unblocked build: want an overlap first wake of v3 with no fallback, got %+v", p)
70 }
71 // Ruling 205: the good build installed is no proof of a rollback (root's
72 // sibling repoint installs it too). The planner refuses while the marker
73 // stands; the pointer pass lifts the marker on a signed rollback.
74 if p, _ := decideWake(inputs(wakeRoute("v1", "v1"), book(), overlap)); p.Refuse != HeldDownRefusal("v2") || p.Boot.StagedPath != "" {
75 t.Fatalf("the good build installed under a marker: want the wake refused, got %+v", p)
76 }
77 // Task 3 fix round 1: the marked build is also the good build, and it is
78 // installed. The marked-build rule applies, not the good-build arm: it
79 // boots, in hold mode with no fallback. A sibling's record of it does not
80 // hold an instance that served it (spec §8.4, Blocks with served); only a
81 // hold-down refuses it.
82 for name, b := range map[string]*FailureBook{"no record": book(), "a sibling's record": book(sibV2)} {
83 if p, err := decideWake(inputs(wakeRoute("v2", "v2"), b, overlap)); err != nil || p.Refuse != "" || p.Boot.Version != "v2" ||
84 !p.FirstWake || p.Fallback != nil || p.Mode != activator.ModeHold {
85 t.Fatalf("marked build = installed = good, %s: want a hold first wake of v2 with no fallback, got %+v, %v", name, p, err)
86 }
87 }
88 downGood := book(FailureRecord{Account: "bab", App: "blog", Build: "v2", Host: "a", HeldDown: []string{"a"}, At: t0})
89 if p, _ := decideWake(inputs(wakeRoute("v2", "v2"), downGood, overlap)); p.Refuse != HeldDownRefusal("v2") || p.Boot.StagedPath != "" {
90 t.Fatalf("marked build = installed = good, held down: want the hold-down refusal, got %+v", p)
91 }
92 // A staging refusal never boots the good build instead.
93 in := inputs(wakeRoute("v2", "v1"), book(), overlap)
94 in.Demand = func(string, string) (string, string, error) { return "", "", errors.New("digest mismatch") }
95 if p, err := decideWake(in); err == nil || p.Refuse == "" || p.Boot.StagedPath != "" {
96 t.Fatalf("a staging refusal under the marker: want the wake refused, got %+v, %v", p, err)
97 }
98 // Another app's marker under the same host name changes nothing.
99 other := &FailureBook{}
100 other.Record(sibV2)
101 other.MarkHoldRan("a", HoldRan{Account: "bab", App: "other", Build: "v2"})
102 if p, _ := decideWake(inputs(wakeRoute("v2", "v1"), other, overlap)); p.Boot.Version != "v1" || p.FirstWake {
103 t.Fatalf("another app's marker: want the usual recovery onto v1, got %+v", p)
104 }
105}
106
107// Final review wave 2, N2: a planned wake onto the good build made before
108// the marker existed (a recovery for the blocked v2) is refused at its exec
109// once the marker stands. The marker comes from a hold first wake whose
110// observation was cancelled after it ran. The refusal uses existing words.
111func TestAHoldRanMarkerRefusesTheGoodBuildAtTheExec(t *testing.T) {
112 ctx := context.Background()
113 h := newHarness(t, "h")
114 h.stage("v2")
115 h.install("v2", "h")
116 h.sleepHost("h")
117 h.pass()
118 h.book.Record(FailureRecord{Account: "bab", App: "blog", Build: "v2", Host: "sib", At: t0})
119 h.wakeHost("h")
120 recovery := h.m.Plan(ctx, h.rows.get("h"))
121 if recovery.Boot.Version != "v1" || recovery.FirstWake || recovery.Check == nil {
122 t.Fatalf("plan %+v: want the recovery onto the good v1 with its check (v2 is blocked, no marker yet)", recovery)
123 }
124 if err := recovery.Check("v1"); err != nil {
125 t.Fatalf("before the marker the good build passes the check: %v", err)
126 }
127 h.m.OnGen(activator.GenEvent{Kind: activator.GenCancelled, Route: h.rows.get("h"), Gen: activator.Gen{Build: "v2"},
128 Mode: activator.ModeHold, FirstWake: true, Executed: true})
129 err := recovery.Check("v1")
130 if err == nil {
131 t.Fatal("the recovery onto v1 passed its exec check after a hold build ran on h")
132 }
133 if want := HeldDownRefusal("v2"); err.Error() != want {
134 t.Fatalf("refusal %q, want the existing words %q", err, want)
135 }
136}
137
138// Final review wave 2, N2: a planned hold first wake whose observation was
139// cancelled after its build ran (the activator's GenCancelled, Executed)
140// leaves the marker, so a sibling's failure of the build afterwards does not
141// send the instance's next wake to the good build. The marker is a
142// restriction on the instance, not a failure: nothing is recorded or halted.
143// The instance passing on the build (a settled first wake records it good)
144// clears the marker, and with it the restriction.
145func TestAFirstWakeCancelledAfterItsHoldBuildRanAndItsClearingByAPass(t *testing.T) {
146 ctx := context.Background()
147 h := newHarness(t, "s")
148 h.swap = appconfig.SwapHold
149 h.stage("v2", "v3")
150 h.install("v2", "s")
151 h.sleepHost("s")
152 h.pass()
153 h.wakeHost("s")
154 p := h.m.Plan(ctx, h.rows.get("s"))
155 if !p.FirstWake || p.Boot.Version != "v2" || p.Mode != activator.ModeHold {
156 t.Fatalf("plan %+v: want a hold first wake of v2", p)
157 }
158 g := activator.Gen{N: 7, Build: "v2", StagedPath: p.Boot.StagedPath, Digest: p.Boot.Digest}
159 h.m.OnGen(activator.GenEvent{Kind: activator.GenHealthy, Route: h.rows.get("s"), Gen: g, Mode: p.Mode, FirstWake: true, Token: p.Token})
160 h.m.OnGen(activator.GenEvent{Kind: activator.GenCancelled, Route: h.rows.get("s"), Gen: g, Mode: p.Mode, FirstWake: true,
161 Token: p.Token, Executed: true})
162 if got, ok := h.book.HoldRanOn("s"); !ok || !got.same(markV2) || !got.At.Equal(t0) {
163 t.Fatalf("marker = %+v, %v: want v2's, stamped at its wake's plan", got, ok)
164 }
165 if _, ok := h.book.Lookup("bab", "blog", "v2"); ok {
166 t.Fatal("a cancelled observation recorded a failure")
167 }
168
169 // A sibling then fails v2: the record holds v2 for s too (Astra R3 N7),
170 // and the marker holds v1, so s stays down rather than boot either.
171 h.book.Record(FailureRecord{Account: "bab", App: "blog", Build: "v2", Host: "sib", PromotedAt: t0, At: t0})
172 h.sleepHost("s")
173 h.wakeHost("s")
174 if p := h.m.Plan(ctx, h.rows.get("s")); p.Refuse != HeldDownRefusal("v2") {
175 t.Fatalf("plan %+v: want the wake refused while the sibling's record of v2 stands", p)
176 }
177 // `carlos promote --retry` of v2 clears the record; v2 boots again, in
178 // hold mode with nothing behind it, never the good v1.
179 h.advance(time.Minute)
180 h.pointer("stable", "v2", h.clock())
181 h.pass()
182 if _, ok := h.book.Lookup("bab", "blog", "v2"); ok {
183 t.Fatal("the retry pointer left v2's record standing")
184 }
185 h.wakeHost("s")
186 p = h.m.Plan(ctx, h.rows.get("s"))
187 if p.Refuse != "" || p.Boot.Version != "v2" || !p.FirstWake || p.Fallback != nil || p.Mode != activator.ModeHold {
188 t.Fatalf("plan %+v: want v2 again, never the good v1", p)
189 }
190
191 // It runs and settles: v2 is s's good build now, and the marker goes.
192 h.act.serve("s", "v2")
193 cur, _ := h.act.Current("s")
194 h.m.OnGen(activator.GenEvent{Kind: activator.GenHealthy, Route: h.rows.get("s"), Gen: cur, Mode: p.Mode, FirstWake: true, Token: p.Token})
195 h.m.OnGen(activator.GenEvent{Kind: activator.GenSettled, Route: h.rows.get("s"), Gen: cur, Mode: p.Mode, FirstWake: true, Token: p.Token})
196 if r := h.rows.get("s"); r.GoodVersion != "v2" {
197 t.Fatalf("good build %q after the settle, want v2", r.GoodVersion)
198 }
199 if got, ok := h.book.HoldRanOn("s"); ok {
200 t.Fatalf("marker %+v survived the instance passing on a build", got)
201 }
202 // The usual rule again: a held v3 sends the wake to the good build.
203 h.install("v3", "s")
204 h.book.Record(FailureRecord{Account: "bab", App: "blog", Build: "v3", Host: "sib", At: t0})
205 h.sleepHost("s")
206 h.wakeHost("s")
207 if p := h.m.Plan(ctx, h.rows.get("s")); p.Boot.Version != "v2" || p.FirstWake {
208 t.Fatalf("plan %+v after the marker cleared: want the recovery onto the good v2", p)
209 }
210}
211
212// Final review wave 2, N2: `carlos instances retry` clears the host's
213// marker, with or without a failure record to clear beside it.
214func TestRetryHostClearsTheHoldRanMarker(t *testing.T) {
215 t.Run("with a record", func(t *testing.T) {
216 h := newHarness(t, "a")
217 h.stage("v2")
218 h.install("v2", "a")
219 h.sleepHost("a")
220 h.pass()
221 h.book.Record(FailureRecord{Account: "bab", App: "blog", Build: "v2", Host: "sib", At: t0})
222 h.book.MarkHoldRan("a", markV2)
223 if !h.retry("a", "v2") {
224 t.Fatal("RetryHost reported nothing cleared")
225 }
226 if _, ok := h.book.HoldRanOn("a"); ok {
227 t.Fatal("the marker survived the host's retry")
228 }
229 })
230 t.Run("marker only", func(t *testing.T) {
231 h := newHarness(t, "a", "b")
232 h.stage("v2")
233 h.install("v2", "a")
234 h.sleepHost("a")
235 h.pass()
236 h.book.MarkHoldRan("a", markV2)
237 h.book.MarkHoldRan("b", markV2)
238 if !h.retry("a", "v2") {
239 t.Fatal("RetryHost reported nothing cleared with a marker standing")
240 }
241 if _, ok := h.book.HoldRanOn("a"); ok {
242 t.Fatal("the marker survived the host's retry")
243 }
244 if _, ok := h.book.HoldRanOn("b"); !ok {
245 t.Fatal("a's retry cleared b's marker")
246 }
247 })
248}
249
250// Final review wave 2, N2, the scheduler's half: which attempt outcomes set
251// the marker. A hold adoption that ran and did not pass, and was not held
252// down, sets it; a failed one sets it in the same save as its record. One
253// that ran nothing, an overlap attempt, a restart, a hold-down and a pass do
254// not.
255func TestWhichAttemptOutcomesLeaveTheHoldRanMarker(t *testing.T) {
256 // Started is the dispatch's (scheduler.execute): the marker's At, on disk
257 // too, whichever save sets it (Task 3 fix round 1).
258 started := t0.Add(-time.Minute)
259 adopt := dispatch{ID: Identity{Host: "a", Cause: activator.CauseRelease, Build: "v2"}, SchedRev: 1, Class: classAdopt, Cohort: cohortKey("v2", classAdopt), Started: started}
260 restart := dispatch{ID: Identity{Host: "a", Cause: activator.CauseRestart, Build: "v1", RestartID: "r1"}, SchedRev: 1, Class: classRecover, Cohort: cohortKey("v1", classRecover)}
261 for _, tc := range []struct {
262 name string
263 d dispatch
264 res activator.SwapResult
265 mode activator.SwapMode
266 want bool
267 }{
268 {"hold, superseded after it ran", adopt, activator.SwapResult{Outcome: activator.SwapAbandoned, Executed: true}, activator.ModeHold, true},
269 {"hold, failed after it ran, not held down", adopt, activator.SwapResult{Outcome: activator.SwapFailed, Reason: "health check failed", Executed: true}, activator.ModeHold, true},
270 {"hold, abandoned before it ran", adopt, activator.SwapResult{Outcome: activator.SwapAbandoned}, activator.ModeHold, false},
271 {"hold, down: held down instead", adopt, activator.SwapResult{Outcome: activator.SwapDown, Reason: "health check failed", Executed: true}, activator.ModeHold, false},
272 {"overlap, superseded after it ran", adopt, activator.SwapResult{Outcome: activator.SwapAbandoned, Executed: true}, activator.ModeOverlap, false},
273 {"hold restart, superseded after it ran", restart, activator.SwapResult{Outcome: activator.SwapAbandoned, Executed: true}, activator.ModeHold, false},
274 } {
275 t.Run(tc.name, func(t *testing.T) {
276 h := newHarness(t, "a")
277 dir := t.TempDir()
278 b, err := OpenFailureBook(dir)
279 if err != nil {
280 t.Fatal(err)
281 }
282 h.book, h.m.d.Book = b, b
283 s := h.m.scheduler(appKey{"bab", "blog"})
284 s.mu.Lock()
285 slot := s.q.slot("a", t0)
286 slot.serving, slot.good = "v1", "v1"
287 d := tc.d
288 slot.running = &d
289 s.q.running++
290 s.mu.Unlock()
291 s.done(tc.d, tc.res, tc.mode)
292
293 again, err := OpenFailureBook(dir)
294 if err != nil {
295 t.Fatal(err)
296 }
297 got, ok := again.HoldRanOn("a")
298 if ok != tc.want || (ok && (!got.same(markV2) || !got.At.Equal(started))) {
299 t.Fatalf("marker on disk = %+v, %v; want set %v, stamped at the dispatch %v", got, ok, tc.want, started)
300 }
301 if tc.res.Outcome == activator.SwapFailed {
302 if _, ok := again.Lookup("bab", "blog", "v2"); !ok {
303 t.Fatal("the failure record is not on disk beside the marker")
304 }
305 }
306 })
307 }
308}
309
310// Final review wave 2, N2: status shows an instance with a marker as held,
311// in the existing words (HeldBy, AttemptDown), never new ones.
312//
313// Astra R3 N7 and ruling 205 add two down cases: the marked build itself
314// held by a sibling's record (d), and the good build installed with no
315// signed rollback yet (c). Their wakes are refused, so they are down.
316func TestStatusShowsAHoldRanInstanceAsHeld(t *testing.T) {
317 h := newHarness(t, "a", "b", "c")
318 h.stage("v2", "v3")
319 h.install("v2", "a")
320 h.install("v3", "b")
321 h.sleepHost("a")
322 h.sleepHost("b")
323 h.sleepHost("c")
324 h.pass()
325 h.book.MarkHoldRan("a", markV2)
326 h.book.MarkHoldRan("b", markV2)
327 h.book.MarkHoldRan("c", markV2)
328 h.book.Record(FailureRecord{Account: "bab", App: "blog", Build: "v3", Host: "sib", Reason: "exited early", At: t0})
329 r := h.m.Status()
330 if got := r.Hosts["a"]; got.HeldBy != "v2" || got.Attempt != "" {
331 t.Fatalf("a = %+v: want held by v2 (its wake boots v2, nothing else)", got)
332 }
333 if got := r.Hosts["b"]; got.HeldBy != "v3" || got.Attempt != status.AttemptDown || got.Reason != status.ReasonExit {
334 t.Fatalf("b = %+v: want down, held by v3 (its wakes are refused, not fallen back)", got)
335 }
336 if got := r.Hosts["c"]; got.HeldBy != "v2" || got.Attempt != status.AttemptDown {
337 t.Fatalf("c = %+v: want down, held by v2 (its good build v1 is installed, but no signed rollback lifted the marker)", got)
338 }
339
340 d := newHarness(t, "d")
341 d.stage("v2")
342 d.install("v2", "d")
343 d.sleepHost("d")
344 d.pass()
345 d.book.MarkHoldRan("d", markV2)
346 d.book.Record(FailureRecord{Account: "bab", App: "blog", Build: "v2", Host: "sib", Reason: "exited early", At: t0})
347 if got := d.m.Status().Hosts["d"]; got.HeldBy != "v2" || got.Attempt != status.AttemptDown || got.Reason != status.ReasonExit {
348 t.Fatalf("d = %+v: want down, held by v2 (the marked build is held by a sibling's record)", got)
349 }
350
351 // Task 3 fix round 1: the marked build is also the installed good build.
352 // Its wake boots it (the marked-build rule), so it is held, not down.
353 e := newHarness(t, "e")
354 e.stage("v2")
355 r2 := e.rows.get("e")
356 r2.Version, r2.GoodVersion, r2.GoodDigest = "v2", "v2", "sha-v2"
357 e.rows.put(r2)
358 e.sleepHost("e")
359 e.pass()
360 e.book.MarkHoldRan("e", markV2)
361 e.book.Record(FailureRecord{Account: "bab", App: "blog", Build: "v2", Host: "sib", Reason: "exited early", At: t0})
362 if got := e.m.Status().Hosts["e"]; got.HeldBy != "v2" || got.Attempt != "" {
363 t.Fatalf("e = %+v: want held by v2 and not down (its marked build is its installed good build, which its wake boots)", got)
364 }
365}
366
367// Task 3 fix round 1: when the marker names the installed good build, the
368// marked-build rule applies at the plan and at the exec, even with a
369// sibling's record of the build standing (the instance served it). The good
370// build is still refused at the exec of any other plan while the marker
371// stands.
372func TestAMarkerOnTheInstalledGoodBuildBootsIt(t *testing.T) {
373 ctx := context.Background()
374 h := newHarness(t, "h")
375 h.stage("v2", "v3")
376 r := h.rows.get("h")
377 r.Version, r.GoodVersion, r.GoodDigest = "v2", "v2", "sha-v2"
378 h.rows.put(r)
379 h.book.MarkHoldRan("h", markV2)
380 h.book.Record(FailureRecord{Account: "bab", App: "blog", Build: "v2", Host: "sib", At: t0})
381 h.sleepHost("h")
382 h.wakeHost("h")
383 p := h.m.Plan(ctx, h.rows.get("h"))
384 if p.Refuse != "" || p.Boot.Version != "v2" || p.Mode != activator.ModeHold || p.Fallback != nil || p.Check == nil {
385 t.Fatalf("plan %+v: want a hold wake of v2 with no fallback", p)
386 }
387 if err := p.Check("v2"); err != nil {
388 t.Fatalf("the plan's check refuses v2, the marked and installed good build: %v", err)
389 }
390 // Another build installed: the good v2 is not the installed build, and
391 // the good-build arm refuses it at that plan's exec.
392 h.install("v3", "h")
393 other := h.rows.get("h")
394 check := h.m.planCheck(other, activator.WakePlan{Boot: p.Boot, Mode: activator.ModeHold, FirstWake: true})
395 if err := check("v2"); err == nil || err.Error() != HeldDownRefusal("v2") {
396 t.Fatalf("check of the good v2 with v3 installed = %v, want the hold-down refusal", err)
397 }
398}
399
400// Final review wave 2, N2: the markers survive a reopen; a record written
401// with MarkHoldRan carries its marker in the same file and never stores the
402// instruction; a clear is saved too.
403func TestTheHoldRanMarkerSurvivesReopen(t *testing.T) {
404 dir := t.TempDir()
405 b, err := OpenFailureBook(dir)
406 if err != nil {
407 t.Fatal(err)
408 }
409 if err := b.MarkHoldRan("a.example.com", markV2); err != nil {
410 t.Fatal(err)
411 }
412 r := rec("v3", "c.example.com")
413 r.MarkHoldRan = true
414 if _, err := b.Record(r); err != nil {
415 t.Fatal(err)
416 }
417 raw, err := os.ReadFile(filepath.Join(dir, FailureFile))
418 if err != nil {
419 t.Fatal(err)
420 }
421 if strings.Contains(strings.ToLower(string(raw)), "markholdran") || strings.Contains(string(raw), "mark_hold_ran") {
422 t.Fatalf("the instruction was stored:\n%s", raw)
423 }
424 again, err := OpenFailureBook(dir)
425 if err != nil {
426 t.Fatal(err)
427 }
428 if got, ok := again.HoldRanOn("a.example.com"); !ok || !got.same(markV2) {
429 t.Fatalf("a's marker after reopen = %+v, %v", got, ok)
430 }
431 if got, ok := again.HoldRanOn("c.example.com"); !ok || got.Build != "v3" {
432 t.Fatalf("c's marker (set with its record) after reopen = %+v, %v", got, ok)
433 }
434 if rec, _ := again.Lookup("bab", "blog", "v3"); rec.MarkHoldRan {
435 t.Fatal("a record came back carrying the instruction")
436 }
437 if cleared, err := again.ClearHoldRan("a.example.com"); !cleared || err != nil {
438 t.Fatalf("ClearHoldRan = %v, %v", cleared, err)
439 }
440 third, err := OpenFailureBook(dir)
441 if err != nil {
442 t.Fatal(err)
443 }
444 if _, ok := third.HoldRanOn("a.example.com"); ok {
445 t.Fatal("the clear was not saved")
446 }
447 if _, ok := third.HoldRanOn("c.example.com"); !ok {
448 t.Fatal("clearing a's marker dropped c's")
449 }
450}
451
452// Final review wave 2, N2: the markers are additive to schema 1. A file an
453// older agent wrote (no hold_ran key) opens with no markers. A file with
454// markers still parses into the old shape (plain encoding/json skips the
455// unknown key) with every record intact. Since Task 5 it is written as
456// schema 2, and since the crash-epoch hard limit as schema 3, which an older
457// agent's reader refuses on purpose rather than read it and drop the
458// markers and intents (TestTheBookIsUnreadableToAnOlderReader).
459func TestTheHoldRanMarkerIsSchemaAdditive(t *testing.T) {
460 dir := t.TempDir()
461 old := `{"schema": 1, "records": [{"account": "bab", "app": "blog", "build": "v2", "host": "a", "cause": "release",
462 "mode": "hold", "reason": "health check failed", "promoted_at": "2023-11-14T22:13:20Z", "at": "2023-11-14T22:13:20Z"}]}`
463 if err := os.WriteFile(filepath.Join(dir, FailureFile), []byte(old), 0o644); err != nil {
464 t.Fatal(err)
465 }
466 b, err := OpenFailureBook(dir)
467 if err != nil {
468 t.Fatalf("an older agent's file: %v", err)
469 }
470 if _, ok := b.HoldRanOn("a"); ok {
471 t.Fatal("a file without markers came back with one")
472 }
473 if !b.Blocks("bab", "blog", "v2", "x", false) {
474 t.Fatal("the older file's record was lost")
475 }
476 if err := b.MarkHoldRan("a", markV2); err != nil {
477 t.Fatal(err)
478 }
479 raw, err := os.ReadFile(filepath.Join(dir, FailureFile))
480 if err != nil {
481 t.Fatal(err)
482 }
483 // The schema 1 shape an agent before the markers decodes into.
484 var older struct {
485 Schema int `json:"schema"`
486 Records []FailureRecord `json:"records"`
487 }
488 if err := json.Unmarshal(raw, &older); err != nil {
489 t.Fatalf("an older agent cannot parse the file with markers: %v", err)
490 }
491 if older.Schema != 3 || len(older.Records) != 1 || older.Records[0].Build != "v2" {
492 t.Fatalf("older agent's view = %+v: want schema 3 and the one record", older)
493 }
494 if !strings.Contains(string(raw), `"hold_ran"`) {
495 t.Fatalf("the marker is not in the file:\n%s", raw)
496 }
497}
498
499// Astra R3 N7: a marker never turns into permission to run a build a
500// sibling's failure record holds. The instance has a good build, so it stays
501// down, at the plan and at the exec of a plan made before the record.
502func TestAMarkedBuildStillHonoursASiblingsFailureRecord(t *testing.T) {
503 ctx := context.Background()
504 h := newHarness(t, "h", "s")
505 h.stage("v2")
506 h.install("v2", "h", "s")
507 h.book.MarkHoldRan("h", markV2)
508 h.sleepHost("h")
509 h.wakeHost("h")
510 before := h.m.Plan(ctx, h.rows.get("h"))
511 if before.Refuse != "" || before.Boot.Version != "v2" || before.Mode != activator.ModeHold || before.Fallback != nil {
512 t.Fatalf("plan %+v: want a hold first wake of v2 while no record holds it", before)
513 }
514 h.book.Record(FailureRecord{Account: "bab", App: "blog", Build: "v2", Host: "s", At: t0}) // a sibling's plain failure
515 if err := before.Check("v2"); err == nil {
516 t.Fatal("the earlier plan's check lets v2 boot after a sibling's record of it landed")
517 }
518 h.wakeHost("h")
519 if p := h.m.Plan(ctx, h.rows.get("h")); p.Refuse != HeldDownRefusal("v2") {
520 t.Fatalf("plan %+v: want the hold-down refusal for v2", p)
521 }
522}
523
524// Ruling 205 (Astra R3): the installed good build is no proof of an explicit
525// rollback, because root's automatic sibling repoint installs it too. Only a
526// signed pointer naming the good build, newer than the marker (`carlos
527// rollback` re-signs one), lifts the marker; the pointer pass does it. Until
528// then the plan is refused, and so is the good build at the exec of any
529// earlier plan.
530func TestASignedRollbackClearsTheMarkerAndTheGoodBuildBoots(t *testing.T) {
531 ctx := context.Background()
532 h := newHarness(t, "h")
533 h.stage("v2")
534 h.sleepHost("h")
535 h.wakeHost("h")
536 early := h.m.Plan(ctx, h.rows.get("h"))
537 if early.Refuse != "" || early.Boot.Version != "v1" || early.FirstWake || early.Check == nil {
538 t.Fatalf("plan %+v: want the ordinary wake of the installed good v1 before any marker", early)
539 }
540 // Hold v2 ran on h, and root repointed h to its good build: v1 is
541 // installed, under a marker stamped at t0. v1's pointer predates it.
542 h.book.MarkHoldRan("h", markV2)
543 h.pointer("stable", "v1", t0.Add(-time.Hour))
544 h.advance(time.Minute)
545 h.pass()
546 if _, ok := h.book.HoldRanOn("h"); !ok {
547 t.Fatal("a pointer older than the marker cleared it")
548 }
549 h.wakeHost("h")
550 if p := h.m.Plan(ctx, h.rows.get("h")); p.Refuse != HeldDownRefusal("v2") {
551 t.Fatalf("no rollback pointer: plan %+v, want refused", p)
552 }
553 if err := early.Check("v1"); err == nil || err.Error() != HeldDownRefusal("v2") {
554 t.Fatalf("the earlier plan's check on v1 = %v, want the hold-down refusal for v2", err)
555 }
556
557 // `carlos rollback` signs v1's pointer afresh.
558 h.pointer("stable", "v1", h.clock())
559 h.advance(pointerReadEvery)
560 h.pass()
561 if got, ok := h.book.HoldRanOn("h"); ok {
562 t.Fatalf("marker %+v survived a signed rollback newer than it", got)
563 }
564 h.wakeHost("h")
565 p := h.m.Plan(ctx, h.rows.get("h"))
566 if p.Refuse != "" || p.Boot.Version != "v1" || p.FirstWake {
567 t.Fatalf("after a signed rollback: plan %+v, want v1's ordinary wake", p)
568 }
569 if err := p.Check("v1"); err != nil {
570 t.Fatalf("after a signed rollback the check refuses v1: %v", err)
571 }
572}
573
574// Ruling 205, in the order it happens (Task 3 fix round 1): hold build v2 is
575// dispatched and running on h; `carlos rollback` signs v1 and root installs
576// v1 on h; the attempt is superseded after v2 ran, and marks h. The marker's
577// At is the attempt's dispatch, so the rollback signed while v2 ran is newer
578// than it, and the pass after the mark clears it at once (setting the marker
579// made the pointer read due; the pass that saw the install came before any
580// marker). The sleeping h's next wake boots v1.
581//
582// A pointer naming v1 signed before the attempt started clears nothing: it
583// says nothing about recovering from v2.
584func TestAnInstallOntoTheGoodBuildMakesTheRollbackReadDueAtOnce(t *testing.T) {
585 ctx := context.Background()
586 for _, tc := range []struct {
587 name string
588 signedAt time.Duration // the v1 pointer's PromotedAt, from the attempt's dispatch
589 cleared bool
590 }{
591 {"rollback signed while the hold build ran", 5 * time.Second, true},
592 {"pointer signed before the hold build started", -time.Minute, false},
593 } {
594 t.Run(tc.name, func(t *testing.T) {
595 h := newHarness(t, "h")
596 h.swap = appconfig.SwapHold
597 h.stage("v2", "v3")
598 h.act.mode = activator.ModeHold
599 h.act.executed = func(a activator.Attempt, _ activator.SwapOutcome) bool { return a.Build == "v2" }
600 // A record of another build keeps the app's pointers read; the
601 // first pass reads them, so no read is due for pointerReadEvery
602 // unless something makes it so.
603 h.book.Record(FailureRecord{Account: "bab", App: "blog", Build: "v3", Host: "sib", PromotedAt: t0, At: t0})
604 gate := h.act.hold("h")
605 h.install("v2", "h")
606 h.pass()
607 waitFor(t, "h's hold attempt onto v2", func() bool {
608 _, started, _ := h.act.snapshot()
609 return len(started) == 1 && started[0] == "h:v2"
610 })
611 start := h.clock()
612
613 h.advance(5 * time.Second)
614 h.pointer("stable", "v1", start.Add(tc.signedAt)) // `carlos rollback` to v1 (or an older pointer)
615 h.install("v1", "h") // root installs it
616 h.pass() // the newer desire supersedes the attempt
617 h.advance(5 * time.Second)
618 close(gate)
619 waitFor(t, "h's attempt to finish", func() bool { return !h.m.attemptRunning("h") })
620 got, ok := h.book.HoldRanOn("h")
621 if !ok || !got.same(markV2) || !got.At.Equal(start) {
622 t.Fatalf("marker = %+v, %v: want v2's, stamped at the attempt's dispatch %v", got, ok, start)
623 }
624
625 h.sleepHost("h")
626 h.pass()
627 if _, still := h.book.HoldRanOn("h"); still == tc.cleared {
628 t.Fatalf("after the pass the marker stands: %v, want %v", still, !tc.cleared)
629 }
630 h.wakeHost("h")
631 p := h.m.Plan(ctx, h.rows.get("h"))
632 if tc.cleared {
633 if p.Refuse != "" || p.Boot.Version != "v1" || p.FirstWake {
634 t.Fatalf("plan %+v after the rollback: want v1's ordinary wake", p)
635 }
636 if err := p.Check("v1"); err != nil {
637 t.Fatalf("after the rollback the check refuses v1: %v", err)
638 }
639 return
640 }
641 if p.Refuse != HeldDownRefusal("v2") {
642 t.Fatalf("plan %+v with only an older pointer: want refused", p)
643 }
644 })
645 }
646}
647
648// Ruling 205 (Task 3 fix round 1): a planned wake's marker is stamped with
649// its own plan's time, the start of its execution, kept per token: a newer
650// plan for the host does not change an older wake's start. A wake whose
651// start is not known (no token) falls back to the book's clock, the safe
652// direction.
653func TestAWakesHoldRanMarkerIsStampedWithItsPlanTime(t *testing.T) {
654 ctx := context.Background()
655 h := newHarness(t, "s")
656 h.swap = appconfig.SwapHold
657 h.stage("v2")
658 h.install("v2", "s")
659 h.sleepHost("s")
660 h.pass()
661 h.wakeHost("s")
662 first := h.m.Plan(ctx, h.rows.get("s"))
663 if !first.FirstWake || first.Boot.Version != "v2" || first.Mode != activator.ModeHold {
664 t.Fatalf("plan %+v: want a hold first wake of v2", first)
665 }
666 planned := h.clock()
667 h.advance(time.Minute)
668 h.wakeHost("s")
669 if second := h.m.Plan(ctx, h.rows.get("s")); second.Token == first.Token || second.Boot.Version != "v2" {
670 t.Fatalf("second plan %+v: want another plan of v2", second)
671 }
672 h.advance(time.Minute)
673 h.m.OnGen(activator.GenEvent{Kind: activator.GenCancelled, Route: h.rows.get("s"), Gen: activator.Gen{Build: "v2"},
674 Mode: activator.ModeHold, FirstWake: true, Token: first.Token, Executed: true})
675 if got, ok := h.book.HoldRanOn("s"); !ok || !got.same(markV2) || !got.At.Equal(planned) {
676 t.Fatalf("marker = %+v, %v: want v2's, stamped at its wake's plan %v", got, ok, planned)
677 }
678
679 h.book.ClearHoldRan("s")
680 h.m.OnGen(activator.GenEvent{Kind: activator.GenCancelled, Route: h.rows.get("s"), Gen: activator.Gen{Build: "v2"},
681 Mode: activator.ModeHold, FirstWake: true, Executed: true})
682 if got, ok := h.book.HoldRanOn("s"); !ok || !got.At.Equal(h.clock()) {
683 t.Fatalf("marker = %+v, %v: a wake of unknown start, want the book's clock %v", got, ok, h.clock())
684 }
685}
686
687// Ruling 205's book half: a marker keeps the At its caller gives (the
688// execution's start), and one given none is stamped by the book's clock; a
689// repeated report of the same execution keeps the first stamp, and writes
690// nothing (another execution renews it: bookseq_test.go). The rollback
691// clear lifts only (acct, app)'s marker, and only for a pointer newer than
692// it.
693func TestTheHoldRanMarkerStampAndItsRollbackClear(t *testing.T) {
694 now := t0
695 b := &FailureBook{now: func() time.Time { return now }}
696 one := markV2
697 one.Exec = "a1"
698 b.MarkHoldRan("a", one)
699 now = now.Add(time.Minute)
700 b.MarkHoldRan("a", one)
701 if got, _ := b.HoldRanOn("a"); !got.At.Equal(t0) {
702 t.Fatalf("marker At %v after a repeated report of its execution, want %v", got.At, t0)
703 }
704 if ok, _ := b.ClearHoldRanOnPointer("a", "bab", "blog", t0); ok {
705 t.Fatal("a pointer as old as the marker cleared it")
706 }
707 if ok, _ := b.ClearHoldRanOnPointer("a", "bab", "other", t0.Add(time.Second)); ok {
708 t.Fatal("another app's pointer cleared the marker")
709 }
710 if ok, _ := b.ClearHoldRanOnPointer("a", "bab", "blog", t0.Add(time.Second)); !ok {
711 t.Fatal("a newer pointer left the marker standing")
712 }
713 b.MarkHoldRan("a", HoldRan{Account: "bab", App: "blog", Build: "v3"})
714 if got, _ := b.HoldRanOn("a"); got.Build != "v3" || !got.At.Equal(now) {
715 t.Fatalf("marker %+v: want v3's, stamped at %v", got, now)
716 }
717 // A report of an execution that started before the standing marker's
718 // (Task 4 fix round 1) names its build but keeps the later stamp: At
719 // never moves back.
720 started := now.Add(-30 * time.Second)
721 b.MarkHoldRan("a", HoldRan{Account: "bab", App: "blog", Build: "v4", At: started})
722 if got, _ := b.HoldRanOn("a"); got.Build != "v4" || !got.At.Equal(now) {
723 t.Fatalf("marker %+v: want v4's, keeping the later stamp %v", got, now)
724 }
725 b.ClearHoldRan("a")
726 b.MarkHoldRan("a", HoldRan{Account: "bab", App: "blog", Build: "v4", At: started})
727 if got, _ := b.HoldRanOn("a"); got.Build != "v4" || !got.At.Equal(started) {
728 t.Fatalf("marker %+v: want v4's, stamped at its execution's start %v, not the book's clock", got, started)
729 }
730}
731
732// Ruling 205 at the scheduler (Task 3 fix round 1): an awake instance (a
733// socket instance, or a worker) serving v2 under v2's hold-ran marker, with
734// its good v1 installed (root's repoint, say), is not moved onto v1 until a
735// signed rollback lifts the marker. The gated attempt ends abandoned with
736// nothing run, is derived again after the abandon deferral, and is gated
737// again; once the pointer pass sees `carlos rollback`'s pointer and clears
738// the marker, the next derivation runs the move.
739func TestAMoveOntoTheInstalledGoodBuildWaitsForASignedRollback(t *testing.T) {
740 for _, worker := range []bool{false, true} {
741 name := "socket instance"
742 if worker {
743 name = "worker"
744 }
745 t.Run(name, func(t *testing.T) {
746 var h *harness
747 if worker {
748 h = workerHarness(t, "h")
749 } else {
750 h = newHarness(t, "h")
751 }
752 var (
753 mu sync.Mutex
754 gated int
755 )
756 h.m.d.Logf = func(format string, args ...any) {
757 if strings.Contains(fmt.Sprintf(format, args...), "waiting for a signed rollback") {
758 mu.Lock()
759 gated++
760 mu.Unlock()
761 }
762 }
763 gatedTimes := func() int {
764 mu.Lock()
765 defer mu.Unlock()
766 return gated
767 }
768 moves := func() int {
769 calls, _, _ := h.act.snapshot()
770 return len(calls) + len(h.act.replacements())
771 }
772 h.stage("v2")
773 h.act.serve("h", "v2")
774 h.book.MarkHoldRan("h", HoldRan{Account: "bab", App: "blog", Build: "v2", At: t0})
775 h.pass()
776 waitFor(t, "the move onto v1 to be gated", func() bool { return gatedTimes() == 1 && !h.m.attemptRunning("h") })
777 if n := moves(); n != 0 {
778 t.Fatalf("%d Swap/ReplaceWorker calls while v2's marker stands, want none", n)
779 }
780 // After the abandon deferral it is derived again, and gated again.
781 h.advance(abandonDeferral + time.Second)
782 h.pass()
783 waitFor(t, "the move onto v1 to be gated again", func() bool { return gatedTimes() == 2 && !h.m.attemptRunning("h") })
784 if n := moves(); n != 0 {
785 t.Fatalf("%d Swap/ReplaceWorker calls while v2's marker stands, want none", n)
786 }
787
788 // `carlos rollback` signs v1; the pointer pass clears the marker,
789 // and the next derivation moves h onto v1.
790 h.pointer("stable", "v1", h.clock())
791 h.advance(pointerReadEvery)
792 h.pass()
793 if got, ok := h.book.HoldRanOn("h"); ok {
794 t.Fatalf("marker %+v survived the signed rollback", got)
795 }
796 waitFor(t, "the move onto v1", func() bool { return moves() == 1 })
797 if calls, _, _ := h.act.snapshot(); len(calls) == 1 && (worker || calls[0].a.Build != "v1") {
798 t.Fatalf("swap %+v: want one onto v1 (and none for a worker)", calls[0].a)
799 }
800 if r := h.act.replacements(); len(r) == 1 && (!worker || r[0].a.Build != "v1") {
801 t.Fatalf("replacement %+v: want one onto v1 (and none for a socket instance)", r[0].a)
802 }
803 })
804 }
805}
806