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 "os"
8 "path/filepath"
9 "strings"
10 "testing"
11 "time"
12
13 "github.com/carlosframework/platform/internal/activator"
14 "github.com/carlosframework/platform/internal/status"
15)
16
17// Task 5 (Astra F5; R1 #5, R3 §5 item 2): failure state survives a failed
18// book write followed by an agent restart. Every execution that may leave a
19// failure writes an intent first; a restart turns an intent left on disk
20// into the failure it may have been.
21//
22// A save fails by making the data dir read-only (WriteFileAtomic writes its
23// temp file beside the target). Running as root defeats it: the tests skip.
24
25func readOnly(t *testing.T, dir string) {
26 t.Helper()
27 if os.Geteuid() == 0 {
28 t.Skip("running as root: a read-only directory does not fail a save")
29 }
30 if err := os.Chmod(dir, 0o500); err != nil {
31 t.Fatal(err)
32 }
33 t.Cleanup(func() { os.Chmod(dir, 0o700) })
34}
35
36func writable(t *testing.T, dir string) {
37 t.Helper()
38 if err := os.Chmod(dir, 0o700); err != nil {
39 t.Fatal(err)
40 }
41}
42
43// onDisk gives the harness a book saved under a data dir of its own, before
44// anything has run, and returns the dir.
45func (h *harness) onDisk() string {
46 h.t.Helper()
47 dir := h.t.TempDir()
48 b, err := OpenFailureBook(dir)
49 if err != nil {
50 h.t.Fatal(err)
51 }
52 b.now = h.clock
53 h.book, h.m.d.Book = b, b
54 return dir
55}
56
57// fileOf reads the book file in dir as it stands on disk.
58func fileOf(t *testing.T, dir string) failureFile {
59 t.Helper()
60 raw, err := os.ReadFile(filepath.Join(dir, FailureFile))
61 if errors.Is(err, os.ErrNotExist) {
62 return failureFile{}
63 }
64 if err != nil {
65 t.Fatal(err)
66 }
67 var f failureFile
68 if err := json.Unmarshal(raw, &f); err != nil {
69 t.Fatal(err)
70 }
71 return f
72}
73
74// Review Focus 1: B's failure is held only in memory because the save
75// failed; the agent restarts. The intent written before B ran is still on
76// disk and comes back as B's failure.
77func TestAFailedSaveThenRestartKeepsTheFailure(t *testing.T) {
78 dd := t.TempDir()
79 b, _ := OpenFailureBook(dd)
80 start := t0.Add(-time.Minute)
81 if err := b.Begin("h", Intent{Account: "bab", App: "blog", Build: "v2", Mode: "hold", ID: "a7", Start: start}); err != nil {
82 t.Fatal(err)
83 }
84 readOnly(t, dd)
85 if _, err := b.Record(FailureRecord{Account: "bab", App: "blog", Build: "v2", Host: "h", Mode: "hold", HeldDown: []string{"h"}}); err == nil {
86 t.Skip("the save did not fail")
87 }
88 _ = b.End("h", "a7") // fails too: the intent stays on disk
89 writable(t, dd)
90 b2, err := OpenFailureBook(dd)
91 if err != nil {
92 t.Fatal(err)
93 }
94 if b2.Reconciled() != 1 || !b2.HeldDown("bab", "blog", "v2", "h") {
95 t.Fatalf("after restart: reconciled %d, held down %v", b2.Reconciled(), b2.HeldDown("bab", "blog", "v2", "h"))
96 }
97 m, ok := b2.HoldRanOn("h")
98 if !ok || m.Build != "v2" {
99 t.Fatal("a hold intent must leave its marker")
100 }
101 if m.Exec != "a7" || !m.At.Equal(start) {
102 t.Fatalf("marker %+v: want the intent's execution (a7) and its start %v", m, start)
103 }
104}
105
106func TestABeginThatCannotSaveRefusesAndLeavesNothing(t *testing.T) {
107 dd := t.TempDir()
108 b, _ := OpenFailureBook(dd)
109 readOnly(t, dd)
110 if err := b.Begin("h", Intent{Account: "bab", App: "blog", Build: "v2", Mode: "overlap", ID: "a1"}); err == nil {
111 t.Skip("the save did not fail")
112 }
113 if _, ok := b.intents["h"]; ok {
114 t.Fatal("a refused begin kept its intent in memory")
115 }
116 writable(t, dd)
117 b2, _ := OpenFailureBook(dd)
118 if b2.Reconciled() != 0 {
119 t.Fatal("a refused begin left an intent")
120 }
121 // The memory a later save writes has no intent either.
122 if err := b.Flush(); err != nil {
123 t.Fatal(err)
124 }
125 if b3, _ := OpenFailureBook(dd); b3.Reconciled() != 0 {
126 t.Fatal("a refused begin's intent reached the file at the next save")
127 }
128}
129
130func TestASchema1BookReadsAndIsWrittenAsSchema3(t *testing.T) {
131 dd := t.TempDir()
132 raw := `{"schema":1,"records":[{"account":"bab","app":"blog","build":"v2","host":"h","cause":"release","mode":"overlap","reason":"x","promoted_at":"2026-10-01T00:00:00Z","at":"2026-10-01T00:00:00Z"}]}`
133 if err := os.WriteFile(filepath.Join(dd, FailureFile), []byte(raw), 0o644); err != nil {
134 t.Fatal(err)
135 }
136 b, err := OpenFailureBook(dd)
137 if err != nil || !b.Blocks("bab", "blog", "v2", "x", false) {
138 t.Fatalf("schema 1: %v", err)
139 }
140 if err := b.Flush(); err != nil {
141 t.Fatal(err)
142 }
143 out, _ := os.ReadFile(filepath.Join(dd, FailureFile))
144 if !strings.Contains(string(out), `"schema": 3`) {
145 t.Fatalf("written as %s", out)
146 }
147 if _, err := OpenFailureBook(dd); err != nil {
148 t.Fatalf("schema 3 written, then refused: %v", err)
149 }
150}
151
152// Review Focus 2: what an older agent sees. Its reader refused anything
153// but schema 1 (OpenFailureBook before Task 5), or but schema 1 and 2
154// (before the crash-epoch hard limit, readiness round 2 Task 1, fix round 1).
155func TestTheBookIsUnreadableToAnOlderReader(t *testing.T) {
156 var f struct {
157 Schema int `json:"schema"`
158 }
159 dd := t.TempDir()
160 b, _ := OpenFailureBook(dd)
161 if err := b.Flush(); err != nil {
162 t.Fatal(err)
163 }
164 raw, _ := os.ReadFile(filepath.Join(dd, FailureFile))
165 if err := json.Unmarshal(raw, &f); err != nil {
166 t.Fatal(err)
167 }
168 if f.Schema == 1 {
169 t.Fatal("an old reader would accept the file and drop markers and intents")
170 }
171 if f.Schema == 2 {
172 t.Fatal("a reader before the crash-epoch rule would accept the file, keep its identity and publish unsaved epochs")
173 }
174}
175
176func TestABookOfAnUnknownSchemaIsRefused(t *testing.T) {
177 for _, schema := range []string{"0", "4"} {
178 dd := t.TempDir()
179 if err := os.WriteFile(filepath.Join(dd, FailureFile), []byte(`{"schema":`+schema+`,"records":[]}`), 0o644); err != nil {
180 t.Fatal(err)
181 }
182 if _, err := OpenFailureBook(dd); err == nil {
183 t.Fatalf("schema %s was read", schema)
184 }
185 }
186}
187
188// Rule 3: each intent left in the file becomes a failure record for its
189// build on that host: cause release, the intent's mode, held down with a
190// marker in hold mode, stamped with the open time, and a reason the member
191// surfaces word as a failed health check. The intents go, and the book is
192// owed a save (Flush), which leaves none on disk.
193func TestAnIntentLeftOnDiskBecomesItsFailure(t *testing.T) {
194 dd := t.TempDir()
195 b, _ := OpenFailureBook(dd)
196 start := t0.Add(-time.Hour)
197 for host, in := range map[string]Intent{
198 "a": {Account: "bab", App: "blog", Build: "v2", Mode: "overlap", ID: "a3", Start: start},
199 "b": {Account: "bab", App: "blog", Build: "v2", Mode: "hold", ID: "w9", Start: start},
200 "c": {Account: "bab", App: "blog", Build: "v3", Mode: "hold", ID: "a4"},
201 } {
202 if err := b.Begin(host, in); err != nil {
203 t.Fatal(err)
204 }
205 }
206 before := time.Now()
207 b2, err := OpenFailureBook(dd)
208 if err != nil {
209 t.Fatal(err)
210 }
211 after := time.Now()
212 if b2.Reconciled() != 3 {
213 t.Fatalf("reconciled %d, want 3", b2.Reconciled())
214 }
215 r, ok := b2.Lookup("bab", "blog", "v2")
216 if !ok || r.Cause != "release" || len(r.HeldDown) != 1 || r.HeldDown[0] != "b" {
217 t.Fatalf("v2's record %+v: want a release failure holding down b (the hold intent) alone", r)
218 }
219 if r.At.Before(before) || r.At.After(after) || !r.PromotedAt.Equal(r.At) {
220 t.Fatalf("v2's record stamped at %v, promoted at %v: want the open time for both", r.At, r.PromotedAt)
221 }
222 if reasonWord(r.Reason) != status.ReasonHealth {
223 t.Fatalf("reason %q words as %q", r.Reason, reasonWord(r.Reason))
224 }
225 if !b2.Blocks("bab", "blog", "v2", "a", false) || b2.HeldDown("bab", "blog", "v2", "a") {
226 t.Fatal("the overlap intent's host must be blocked, not held down")
227 }
228 if _, ok := b2.HoldRanOn("a"); ok {
229 t.Fatal("an overlap intent left a hold-ran marker")
230 }
231 if m, ok := b2.HoldRanOn("b"); !ok || m.Build != "v2" || m.Exec != "w9" || !m.At.Equal(start) {
232 t.Fatalf("b's marker %+v, %v: want v2's, from w9, at its start", m, ok)
233 }
234 rc, ok := b2.Lookup("bab", "blog", "v3")
235 if !ok || rc.Mode != "hold" || !b2.HeldDown("bab", "blog", "v3", "c") {
236 t.Fatalf("v3's record %+v: want c held down", rc)
237 }
238 if m, ok := b2.HoldRanOn("c"); !ok || m.Exec != "a4" || m.At.Before(before) || m.At.After(after) {
239 t.Fatalf("c's marker %+v: an intent with no start is stamped with the open time", m)
240 }
241 if len(b2.intents) != 0 {
242 t.Fatalf("intents %v stayed in memory", b2.intents)
243 }
244 // Nothing is written at open: the caller flushes.
245 if f := fileOf(t, dd); len(f.Intents) != 3 {
246 t.Fatalf("the open rewrote the file: %d intents on disk", len(f.Intents))
247 }
248 if err := b2.Flush(); err != nil {
249 t.Fatal(err)
250 }
251 if f := fileOf(t, dd); len(f.Intents) != 0 || len(f.Records) != 2 || f.Schema != failureSchema {
252 t.Fatalf("after the flush: %+v", f)
253 }
254 if b3, _ := OpenFailureBook(dd); b3.Reconciled() != 0 || !b3.HeldDown("bab", "blog", "v2", "b") {
255 t.Fatal("the flushed book did not keep the reconciled records")
256 }
257}
258
259// Rule 1 and 2: a newer wake's intent replaces an older wake's for the host;
260// an attempt's intent is replaced by nothing, and an attempt never replaces
261// a wake's; End removes only its own intent.
262func TestBeginAndEndKeepOneIntentPerHost(t *testing.T) {
263 dd := t.TempDir()
264 b, _ := OpenFailureBook(dd)
265 in := func(id string) Intent {
266 return Intent{Account: "bab", App: "blog", Build: "v2", Mode: "overlap", ID: id}
267 }
268 if err := b.Begin("h", in("w1")); err != nil {
269 t.Fatal(err)
270 }
271 if err := b.Begin("h", in("w2")); err != nil {
272 t.Fatalf("a newer wake's intent was refused: %v", err)
273 }
274 if err := b.Begin("h", in("a5")); !errors.Is(err, ErrIntentStands) {
275 t.Fatalf("an attempt replaced a wake's intent: %v", err)
276 }
277 if f := fileOf(t, dd); f.Intents["h"].ID != "w2" {
278 t.Fatalf("on disk %+v, want w2's intent", f.Intents)
279 }
280 if err := b.End("h", "w1"); err != nil {
281 t.Fatal(err)
282 }
283 if f := fileOf(t, dd); f.Intents["h"].ID != "w2" {
284 t.Fatal("an older wake's End removed the newer wake's intent")
285 }
286 if err := b.End("h", "w2"); err != nil {
287 t.Fatal(err)
288 }
289 if f := fileOf(t, dd); len(f.Intents) != 0 {
290 t.Fatalf("End left %+v on disk", f.Intents)
291 }
292 if err := b.Begin("h", in("a6")); err != nil {
293 t.Fatal(err)
294 }
295 for _, id := range []string{"w3", "a7"} {
296 if err := b.Begin("h", in(id)); !errors.Is(err, ErrIntentStands) {
297 t.Fatalf("%s replaced an attempt's intent: %v", id, err)
298 }
299 }
300 if err := b.Begin("other", in("w4")); err != nil {
301 t.Fatalf("another host's intent was refused: %v", err)
302 }
303}
304
305// Manager (a): an adoption whose intent cannot be written never reaches
306// Swap. It is abandoned unexecuted, waits out the abandon deferral, and runs
307// once the book can be written.
308func TestAnAdoptionWhoseIntentCannotBeWrittenNeverRuns(t *testing.T) {
309 h := newHarness(t, "a")
310 dir := h.onDisk()
311 h.stage("v2")
312 readOnly(t, dir)
313 h.install("v2", "a")
314 h.pass()
315 waitFor(t, "the attempt to be abandoned and deferred", func() bool {
316 s := h.m.schedulerOf("a")
317 if s == nil {
318 return false
319 }
320 s.mu.Lock()
321 defer s.mu.Unlock()
322 hs := s.q.hosts["a"]
323 return hs != nil && hs.running == nil && hs.notBefore.After(h.clock())
324 })
325 if calls, started, _ := h.act.snapshot(); len(calls) != 0 || len(started) != 0 {
326 t.Fatalf("Swap ran (%+v, %v) with no intent written", calls, started)
327 }
328 if len(h.book.List()) != 0 {
329 t.Fatal("a refused attempt wrote a failure")
330 }
331 writable(t, dir)
332 h.advance(abandonDeferral + time.Second)
333 h.pass()
334 waitFor(t, "the attempt after the wait", func() bool { return h.servingAll("v2", "a") })
335 waitFor(t, "the intent to end", func() bool { return len(fileOf(t, dir).Intents) == 0 })
336}
337
338// An adoption's intent is on disk while its Swap runs, with its execution's
339// identity, mode and start, and is gone once its outcome is written.
340func TestAnAdoptionsIntentStandsWhileItRuns(t *testing.T) {
341 for _, fail := range []bool{false, true} {
342 t.Run(map[bool]string{false: "done", true: "failed"}[fail], func(t *testing.T) {
343 h := newHarness(t, "a")
344 dir := h.onDisk()
345 h.stage("v2")
346 if fail {
347 h.act.outcome = func(activator.Attempt) activator.SwapOutcome { return activator.SwapFailed }
348 }
349 gate := h.act.hold("a")
350 h.install("v2", "a")
351 h.pass()
352 waitFor(t, "the swap to start", func() bool { _, started, _ := h.act.snapshot(); return len(started) == 1 })
353 in, ok := fileOf(t, dir).Intents["a"]
354 if !ok || !strings.HasPrefix(in.ID, "a") || in.Build != "v2" || in.Mode != "overlap" || !in.Start.Equal(t0) {
355 t.Fatalf("intent on disk during the swap: %+v, %v", in, ok)
356 }
357 close(gate)
358 waitFor(t, "the intent to end", func() bool { return len(fileOf(t, dir).Intents) == 0 })
359 recorded := false
360 for _, r := range fileOf(t, dir).Records {
361 recorded = recorded || r.Build == "v2"
362 }
363 if recorded != fail {
364 t.Fatalf("failed %v, but v2 recorded %v", fail, recorded)
365 }
366 })
367 }
368}
369
370// Plan review #2: Swap runs an overlap attempt on a foreign-layout row as a
371// hold. The intent carries the mode the activator will run (EffectiveMode),
372// so a crash mid-swap reopens the book with the instance held down and
373// marked, as a hold that went down would have left it.
374func TestAForeignLayoutHoldCrashReopensHeldDown(t *testing.T) {
375 h := newHarness(t, "a")
376 dir := h.onDisk()
377 h.stage("v2")
378 h.act.mode = activator.ModeHold // the foreign row's override
379 gate := h.act.hold("a")
380 // The parked attempt's done() writes the failure book and ends its intent
381 // in dir once the gate opens, on a goroutine the test does not own. Cleanups
382 // run last in, first out and onDisk's TempDir is already registered, so
383 // this one opens the gate and joins the manager before dir is removed.
384 t.Cleanup(func() {
385 close(gate)
386 ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
387 defer cancel()
388 h.m.Close(ctx)
389 })
390 h.install("v2", "a")
391 h.pass()
392 waitFor(t, "the swap to start", func() bool { _, started, _ := h.act.snapshot(); return len(started) == 1 })
393 if in := fileOf(t, dir).Intents["a"]; in.Mode != "hold" {
394 t.Fatalf("intent %+v: want the effective mode, hold", in)
395 }
396 // The agent dies here; the next one opens the book.
397 b, err := OpenFailureBook(dir)
398 if err != nil {
399 t.Fatal(err)
400 }
401 if b.Reconciled() != 1 || !b.HeldDown("bab", "blog", "v2", "a") {
402 t.Fatalf("reconciled %d: want a held down on v2", b.Reconciled())
403 }
404 m, ok := b.HoldRanOn("a")
405 if !ok || m.Build != "v2" || !strings.HasPrefix(m.Exec, "a") || !m.At.Equal(t0) {
406 t.Fatalf("marker %+v, %v: want v2's, from the attempt, at its dispatch", m, ok)
407 }
408}
409
410// Manager (b): a first wake whose intent cannot be written is refused with
411// the approved text, and nothing of it is registered.
412func TestAFirstWakeWhoseIntentCannotBeWrittenIsRefused(t *testing.T) {
413 h := newHarness(t, "a")
414 dir := h.onDisk()
415 h.stage("v2")
416 h.registerThenInstall("v2", "a")
417 h.wakeHost("a")
418 readOnly(t, dir)
419 p := h.m.Plan(context.Background(), h.rows.get("a"))
420 if p.Refuse != BookUnreadableRefusal || p.Boot.StagedPath != "" {
421 t.Fatalf("plan %+v: want the wake refused with %q", p, BookUnreadableRefusal)
422 }
423 h.m.mu.Lock()
424 _, planned := h.m.planned["a"]
425 h.m.mu.Unlock()
426 if planned {
427 t.Fatal("a refused wake stayed registered as planned")
428 }
429}
430
431// A planned wake does not take over an attempt's intent: its plan is
432// refused as the attempt's own planning refusal is, and nothing is lost.
433func TestAFirstWakeNeverReplacesAnAttemptsIntent(t *testing.T) {
434 h := newHarness(t, "a")
435 h.onDisk()
436 h.stage("v2")
437 h.registerThenInstall("v2", "a")
438 h.wakeHost("a")
439 if err := h.book.Begin("a", Intent{Account: "bab", App: "blog", Build: "v3", Mode: "hold", ID: "a1"}); err != nil {
440 t.Fatal(err)
441 }
442 p := h.m.Plan(context.Background(), h.rows.get("a"))
443 if p.Refuse != "this instance is still moving to another build" {
444 t.Fatalf("plan %+v: want the moving refusal", p)
445 }
446 if in := h.book.intents["a"]; in.ID != "a1" {
447 t.Fatalf("intent %+v: the attempt's was replaced", in)
448 }
449}
450
451// Manager (c), with plan review #1: a planned first wake's intent is written
452// at its plan, with the wake's identity and start, and ends with the first
453// event that settles its build's fate, never with the activator letting the
454// host go (an entry deleted before its failure is delivered).
455func TestAPlannedWakesIntentEndsWithItsOutcome(t *testing.T) {
456 ctx := context.Background()
457 setup := func(t *testing.T) (*harness, string, activator.WakePlan) {
458 h := newHarness(t, "a")
459 dir := h.onDisk()
460 h.stage("v2")
461 h.registerThenInstall("v2", "a")
462 h.wakeHost("a")
463 p := h.m.Plan(ctx, h.rows.get("a"))
464 if !p.FirstWake || p.Boot.Version != "v2" {
465 t.Fatalf("plan %+v: want a first wake of v2", p)
466 }
467 in, ok := fileOf(t, dir).Intents["a"]
468 if !ok || in.ID != h.m.wakeExecID(p.Token) || in.Build != "v2" || in.Mode != string(p.Mode) || !in.Start.Equal(t0) {
469 t.Fatalf("intent on disk %+v, %v: want the plan's", in, ok)
470 }
471 return h, dir, p
472 }
473 ev := func(p activator.WakePlan, h *harness, kind activator.GenEventKind, first bool) activator.GenEvent {
474 return activator.GenEvent{Kind: kind, Route: h.rows.get("a"), Gen: activator.Gen{N: 3, Build: "v2", Digest: "sha-v2"},
475 Mode: p.Mode, FirstWake: first, Token: p.Token, ConfigRev: p.ConfigRev}
476 }
477 for _, tc := range []struct {
478 name string
479 kinds []activator.GenEventKind
480 first bool
481 ends bool
482 }{
483 {"healthy first wake, under observation", []activator.GenEventKind{activator.GenHealthy}, true, false},
484 {"settled", []activator.GenEventKind{activator.GenHealthy, activator.GenSettled}, true, true},
485 {"failed", []activator.GenEventKind{activator.GenFailed}, true, true},
486 {"cancelled with nothing run", []activator.GenEventKind{activator.GenCancelled}, true, true},
487 {"the good build booted untried", []activator.GenEventKind{activator.GenHealthy}, false, true},
488 } {
489 t.Run(tc.name, func(t *testing.T) {
490 h, dir, p := setup(t)
491 for _, k := range tc.kinds {
492 e := ev(p, h, k, tc.first)
493 if k == activator.GenFailed {
494 e.Failed = p.Boot
495 }
496 h.m.OnGen(e)
497 }
498 if _, stands := fileOf(t, dir).Intents["a"]; stands == tc.ends {
499 t.Fatalf("after %v: intent stands %v", tc.kinds, stands)
500 }
501 })
502 }
503 t.Run("an older wake's event leaves a newer wake's intent", func(t *testing.T) {
504 h, dir, old := setup(t)
505 cur := h.m.Plan(ctx, h.rows.get("a"))
506 h.m.OnGen(ev(old, h, activator.GenCancelled, true))
507 if in := fileOf(t, dir).Intents["a"]; in.ID != h.m.wakeExecID(cur.Token) {
508 t.Fatalf("intent %+v, want the newer wake's", in)
509 }
510 })
511 t.Run("not by the activator letting the host go", func(t *testing.T) {
512 h, dir, _ := setup(t)
513 h.sleepHost("a") // Owns false: the failed wake's entry is gone
514 h.pass()
515 if _, stands := fileOf(t, dir).Intents["a"]; !stands {
516 t.Fatal("dropUnownedWakes ended the intent of a wake whose outcome is not delivered")
517 }
518 })
519}
520