| 1 | package rollout |
| 2 | |
| 3 | import ( |
| 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 | |
| 25 | func 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 | |
| 36 | func 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. |
| 45 | func (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. |
| 58 | func 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. |
| 77 | func 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 | |
| 106 | func 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 | |
| 130 | func 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). |
| 155 | func 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 | |
| 176 | func 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. |
| 193 | func 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. |
| 262 | func 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. |
| 308 | func 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. |
| 340 | func 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. |
| 374 | func 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. |
| 412 | func 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. |
| 433 | func 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). |
| 455 | func 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 | |