carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1package host
2
3import (
4 "context"
5 "encoding/json"
6 "errors"
7 "fmt"
8 "github.com/carlosframework/platform/internal/rootstate"
9 "os"
10 "path/filepath"
11 "strings"
12 "testing"
13
14 "github.com/carlosframework/platform/internal/hostactors"
15 "github.com/carlosframework/platform/internal/hostfloor"
16 "github.com/carlosframework/platform/internal/release"
17 "github.com/carlosframework/platform/internal/rootparticipation"
18)
19
20// carlos-system-recover runs the root generation census to settle the
21// participation transaction an unfinished apply left open. The edge's guard
22// drop-in Requires= the recover unit, so a recover that fails keeps the
23// edge down. With no journal open the files are whole, so a census refusal
24// defers the settlement instead: recover exits 0 with the transaction still
25// open. With a journal open it fails as before.
26
27// harnessRefusal is the kernel census's refusal on the harness box, as
28// productionGenerationKernelCensus returns it and the launch verifier
29// passes it up unchanged.
30func harnessRefusal() error {
31 return &generationCensusRefusal{err: fmt.Errorf("/proc/1535/task/1535: service: %w", os.ErrPermission)}
32}
33
34// refuseCensus makes root's launch census refuse from its pass-th call on
35// (1 is every call), and stubs the pending controller recovery so the
36// refusal is the census's alone.
37func refuseCensusFrom(t *testing.T, f *applyFixture, pass int) *int {
38 t.Helper()
39 calls := 0
40 f.a.participation.VerifyLaunches = func(context.Context, uint64) error {
41 calls++
42 if calls < pass {
43 return nil
44 }
45 return harnessRefusal()
46 }
47 old := recoverPendingControllers
48 t.Cleanup(func() { recoverPendingControllers = old })
49 recoverPendingControllers = func(context.Context, string, string) error { return nil }
50 return &calls
51}
52
53func refuseCensus(t *testing.T, f *applyFixture) *int { return refuseCensusFrom(t, f, 1) }
54
55func logLines(f *applyFixture) *[]string {
56 var lines []string
57 logf := f.a.ops.logf
58 f.a.ops.logf = func(format string, args ...any) {
59 lines = append(lines, fmt.Sprintf(format, args...))
60 logf(format, args...)
61 }
62 return &lines
63}
64
65func TestRecoverDefersACensusRefusalWithNoJournalOpen(t *testing.T) {
66 for _, marker := range []bool{true, false} {
67 t.Run(map[bool]string{true: "marker", false: "no-marker"}[marker], func(t *testing.T) {
68 f, id := guardRecoveryFixture(t)
69 a := f.a
70 if err := a.floor.Begin(t.Context(), id, a.dataDir); err != nil {
71 t.Fatal(err)
72 }
73 begun := filepath.Join(a.floor.Dir, "update-begun.json")
74 if !marker {
75 if err := os.Remove(begun); err != nil {
76 t.Fatal(err)
77 }
78 }
79 calls := refuseCensus(t, f)
80 lines := logLines(f)
81 if err := a.Run(t.Context(), true); err != nil {
82 t.Fatalf("recover with no journal open = %v, want a deferral that exits 0", err)
83 }
84 // Once before root's pending-controller recovery and once after,
85 // and not again at the end of the run.
86 if *calls != 2 {
87 t.Fatalf("recover ran the census %d times, want 2", *calls)
88 }
89 // The transaction is still open: no completion, the reservation
90 // kept, root's update record kept, so acquisitions stay refused.
91 if _, err := os.Stat(filepath.Join(a.floor.Dir, "completed.json")); !os.IsNotExist(err) {
92 t.Fatalf("a refused census was settled: %v", err)
93 }
94 if _, err := os.Stat(filepath.Join(a.floor.Dir, "activation.json")); err != nil {
95 t.Fatalf("reservation discarded: %v", err)
96 }
97 if _, err := os.Stat(begun); marker && err != nil {
98 t.Fatalf("marker discarded: %v", err)
99 }
100 if got, err := a.participation.UpdateID(); err != nil || got != id {
101 t.Fatalf("update record = %q, %v; want %s still open", got, err, id)
102 }
103 if err := a.participation.Acquire(t.Context(), func() error { return nil }); err == nil {
104 t.Fatal("a claim acquisition started while the transaction was open")
105 }
106 if len(f.calls) != 0 {
107 t.Fatal("recovery executed a binary or touched a unit", f.calls)
108 }
109 if !strings.Contains(strings.Join(*lines, "\n"), "service: permission denied") {
110 t.Fatalf("the deferral did not log the census's refusal: %q", *lines)
111 }
112 })
113 }
114}
115
116func TestRecoverFailsACensusRefusalWithAJournalOpen(t *testing.T) {
117 f, id := guardRecoveryFixture(t)
118 a := f.a
119 if err := a.floor.Begin(t.Context(), id, a.dataDir); err != nil {
120 t.Fatal(err)
121 }
122 if err := a.journal(systemJournal{Phase: systemPhaseInstalled, Target: "new-build", PrevBuild: "old-build", StartedAt: f.now}); err != nil {
123 t.Fatal(err)
124 }
125 refuseCensus(t, f)
126 err := a.Run(t.Context(), true)
127 if err == nil || errors.Is(err, errSettlementDeferred) || !strings.Contains(err.Error(), "service: permission denied") {
128 t.Fatalf("recover with a journal open = %v, want the census's refusal", err)
129 }
130 if _, err := os.Stat(a.file("journal.json")); err != nil {
131 t.Fatalf("journal discarded: %v", err)
132 }
133}
134
135func TestRecoverDefersACensusRefusalWithoutAnArmedFloor(t *testing.T) {
136 // checkUnarmedParticipationRecovery: an apply began root's update
137 // record and stopped before writing a journal.
138 f := newApplyFixture(t, "stable", "old")
139 if f.a.guarded() {
140 t.Fatal("fixture has an armed floor")
141 }
142 if err := f.a.beginExecutableChange(t.Context(), "new", f.now); err != nil {
143 t.Fatal(err)
144 }
145 calls := refuseCensus(t, f)
146 if err := f.a.Run(t.Context(), true); err != nil {
147 t.Fatalf("recover with no journal open = %v, want a deferral that exits 0", err)
148 }
149 if *calls != 2 {
150 t.Fatalf("recover ran the census %d times, want 2 (not again to settle the marker)", *calls)
151 }
152 if _, err := f.a.participation.UpdateID(); err != nil {
153 t.Fatalf("recover settled a refused census: %v", err)
154 }
155 // With a journal open it still fails.
156 if err := f.a.journal(systemJournal{Phase: systemPhaseInstalling, Target: "new"}); err != nil {
157 t.Fatal(err)
158 }
159 if err := f.a.Run(t.Context(), true); err == nil {
160 t.Fatal("recover with a journal open passed a refused census")
161 }
162}
163
164func TestApplyStillFailsACensusRefusalWithNoJournalOpen(t *testing.T) {
165 // Only --recover defers: the apply's own start-up check returns the
166 // refusal as on main.
167 f, id := guardRecoveryFixture(t)
168 if err := f.a.floor.Begin(t.Context(), id, f.a.dataDir); err != nil {
169 t.Fatal(err)
170 }
171 refuseCensus(t, f)
172 if err := f.a.Run(t.Context(), false); err == nil || !strings.Contains(err.Error(), "service: permission denied") {
173 t.Fatalf("apply = %v, want the census's refusal", err)
174 }
175}
176
177func TestRuling6NoCensusRecoverIsUnchanged(t *testing.T) {
178 // A box without root participation (no census.json) never runs the
179 // generation census, and a refused settlement fails recover exactly as
180 // on main: the deferral is for census boxes only.
181 f, id := guardRecoveryFixture(t)
182 a := f.a
183 if err := a.floor.Begin(t.Context(), id, a.dataDir); err != nil {
184 t.Fatal(err)
185 }
186 if err := a.participation.State.Remove("census.json"); err != nil {
187 t.Fatal(err)
188 }
189 if required, err := a.participationDisk().ParticipationRequired(t.Context()); err != nil || required {
190 t.Fatalf("fixture still requires participation: %t %v", required, err)
191 }
192 a.participation.VerifyLaunches = func(context.Context, uint64) error {
193 t.Fatal("a no-census box ran the generation census")
194 return nil
195 }
196 if err := os.WriteFile(filepath.Join(a.dataDir, hostexecDir, "host.json"), []byte(`{"state":"pending"}`), 0600); err != nil {
197 t.Fatal(err)
198 }
199 err := a.Run(t.Context(), true)
200 if err == nil || errors.Is(err, errSettlementDeferred) {
201 t.Fatalf("no-census recover = %v, want main's refusal", err)
202 }
203 if _, err := os.Stat(filepath.Join(a.floor.Dir, "completed.json")); !os.IsNotExist(err) {
204 t.Fatalf("completion written: %v", err)
205 }
206 // No census refusal can reach a no-census box's settlement, and one
207 // that did would not defer either.
208 if err := a.settleWithoutJournal(t.Context(), "recover-system", harnessRefusal()); errors.Is(err, errSettlementDeferred) {
209 t.Fatalf("a no-census box deferred: %v", err)
210 }
211}
212
213// openTransaction checks the guarded transaction is still open after a
214// deferral: the reservation and root's update record kept, so claim
215// acquisitions stay refused. marker says whether root's marker must be
216// there too.
217func openTransaction(t *testing.T, f *applyFixture, id string, marker bool) {
218 t.Helper()
219 a := f.a
220 if _, err := os.Stat(filepath.Join(a.floor.Dir, "activation.json")); err != nil {
221 t.Fatalf("reservation discarded: %v", err)
222 }
223 if _, err := os.Stat(filepath.Join(a.floor.Dir, "update-begun.json")); marker && err != nil {
224 t.Fatalf("marker discarded: %v", err)
225 }
226 if got, err := a.participation.UpdateID(); err != nil || got != id {
227 t.Fatalf("update record = %q, %v; want %s still open", got, err, id)
228 }
229 if err := a.participation.Acquire(t.Context(), func() error { return nil }); err == nil {
230 t.Fatal("a claim acquisition started while the transaction was open")
231 }
232}
233
234func TestRecoverDefersACensusRefusalOnlyAtTheEndOfParticipation(t *testing.T) {
235 // The pending-controller census passes; the one inside EndParticipation
236 // (EndUpdateWith -> idle) refuses. A finished transaction.
237 f, id := guardRecoveryFixture(t)
238 a := f.a
239 if err := a.floor.Begin(t.Context(), id, a.dataDir); err != nil {
240 t.Fatal(err)
241 }
242 calls := refuseCensusFrom(t, f, 2)
243 if err := a.Run(t.Context(), true); err != nil {
244 t.Fatalf("recover = %v, want a deferral that exits 0", err)
245 }
246 if *calls != 2 {
247 t.Fatalf("census ran %d times, want 2 (controllers, then the end of participation)", *calls)
248 }
249 openTransaction(t, f, id, true)
250 // The next recover, with the census passing, settles it.
251 a.participation.VerifyLaunches = func(context.Context, uint64) error { return nil }
252 if err := a.Run(t.Context(), true); err != nil {
253 t.Fatal(err)
254 }
255 if raw, err := os.ReadFile(filepath.Join(a.floor.Dir, "completed.json")); err != nil || string(raw) != id {
256 t.Fatalf("completion = %q, %v", raw, err)
257 }
258 if _, err := a.participation.UpdateID(); !os.IsNotExist(err) {
259 t.Fatalf("update record after settlement: %v", err)
260 }
261}
262
263// unfinishedGuard makes the fixture's transaction one that never changed
264// the box: the signed reservation names another image as its target.
265func unfinishedGuard(t *testing.T, f *applyFixture, id string) {
266 t.Helper()
267 path := filepath.Join(f.a.floor.Dir, "activation.json")
268 raw, err := os.ReadFile(path)
269 if err != nil {
270 t.Fatal(err)
271 }
272 var tx guardTransaction
273 if err := json.Unmarshal(raw, &tx); err != nil {
274 t.Fatal(err)
275 }
276 var roster hostactors.Roster
277 if err := release.VerifyRootEnvelope(f.pub, tx.Roster, &roster); err != nil {
278 t.Fatal(err)
279 }
280 h := roster.Activations[id]
281 h.Hashes = map[string][]string{tx.Actor: {h.Hashes[tx.Actor][0], strings.Repeat("d", 64), h.Hashes[tx.Actor][2]}}
282 roster.Activations[id] = h
283 if err := roster.Validate("bucket"); err != nil {
284 t.Fatal(err)
285 }
286 if tx.Roster, err = release.SignRootEnvelope(release.RootSigner(f.priv), roster); err != nil {
287 t.Fatal(err)
288 }
289 raw, _ = json.Marshal(tx)
290 if err := hostfloor.Durable(path, raw, 0644); err != nil {
291 t.Fatal(err)
292 }
293 if finished, err := f.a.guardFinished(tx); err != nil || finished {
294 t.Fatalf("fixture transaction finished=%t err=%v, want unfinished", finished, err)
295 }
296}
297
298func TestRecoverDefersACensusRefusalOfAnUnfinishedTransaction(t *testing.T) {
299 f, id := guardRecoveryFixture(t)
300 a := f.a
301 if err := a.floor.Begin(t.Context(), id, a.dataDir); err != nil {
302 t.Fatal(err)
303 }
304 unfinishedGuard(t, f, id)
305 refuseCensus(t, f)
306 if err := a.Run(t.Context(), true); err != nil {
307 t.Fatalf("recover = %v, want a deferral that exits 0", err)
308 }
309 // The withdrawal removes nothing until its census passes.
310 openTransaction(t, f, id, true)
311 if _, err := os.Stat(filepath.Join(a.floor.Dir, "completed.json")); !os.IsNotExist(err) {
312 t.Fatalf("an unfinished transaction recorded a completion: %v", err)
313 }
314 a.participation.VerifyLaunches = func(context.Context, uint64) error { return nil }
315 if err := a.Run(t.Context(), true); err != nil {
316 t.Fatal(err)
317 }
318 for _, name := range []string{"update-begun.json", "activation.json", "completed.json"} {
319 if _, err := os.Stat(filepath.Join(a.floor.Dir, name)); !os.IsNotExist(err) {
320 t.Fatalf("%s after the withdrawal: %v", name, err)
321 }
322 }
323}
324
325func TestRecoverDefersACensusRefusalOfAnAlreadyCompletedTransaction(t *testing.T) {
326 // Completion recorded and the marker gone, root's update record still
327 // open: only EndParticipation is left, and its census refuses.
328 f, id := guardRecoveryFixture(t)
329 a := f.a
330 if err := a.floor.Begin(t.Context(), id, a.dataDir); err != nil {
331 t.Fatal(err)
332 }
333 if err := hostfloor.Durable(filepath.Join(a.floor.Dir, "completed.json"), []byte(id), 0644); err != nil {
334 t.Fatal(err)
335 }
336 if err := hostfloor.RemoveDurable(filepath.Join(a.floor.Dir, "update-begun.json")); err != nil {
337 t.Fatal(err)
338 }
339 calls := refuseCensus(t, f)
340 if err := a.Run(t.Context(), true); err != nil {
341 t.Fatalf("recover = %v, want a deferral that exits 0", err)
342 }
343 if *calls != 1 {
344 t.Fatalf("census ran %d times, want 1", *calls)
345 }
346 openTransaction(t, f, id, false)
347 if raw, err := os.ReadFile(filepath.Join(a.floor.Dir, "completed.json")); err != nil || string(raw) != id {
348 t.Fatalf("completion = %q, %v", raw, err)
349 }
350}
351
352func TestRecoverStillFailsSettlementErrorsThatAreNotCensusRefusals(t *testing.T) {
353 for _, tc := range []struct {
354 name string
355 setup func(*testing.T, *applyFixture, string)
356 }{
357 {"malformed-ledger-state", func(t *testing.T, f *applyFixture, _ string) {
358 // The verifier refuses the ledger itself, not through the census.
359 f.a.participation.VerifyLaunches = func(context.Context, uint64) error { return rootparticipation.ErrUnavailable }
360 old := recoverPendingControllers
361 t.Cleanup(func() { recoverPendingControllers = old })
362 recoverPendingControllers = func(context.Context, string, string) error { return nil }
363 }},
364 {"live-controller", func(t *testing.T, f *applyFixture, _ string) {
365 if err := os.WriteFile(filepath.Join(f.a.dataDir, hostexecDir, "host.json"), []byte(`{"state":"pending"}`), 0600); err != nil {
366 t.Fatal(err)
367 }
368 old := recoverPendingControllers
369 t.Cleanup(func() { recoverPendingControllers = old })
370 recoverPendingControllers = func(context.Context, string, string) error { return nil }
371 }},
372 {"image-verification", func(t *testing.T, f *applyFixture, _ string) {
373 if err := os.WriteFile(systemBinaryPath(f.a.optDir), []byte("an unsigned image"), 0755); err != nil {
374 t.Fatal(err)
375 }
376 }},
377 {"marker-for-another-transaction", func(t *testing.T, f *applyFixture, _ string) {
378 if err := hostfloor.Durable(filepath.Join(f.a.floor.Dir, "update-begun.json"), []byte(`{"id":"`+strings.Repeat("e", 32)+`"}`), 0644); err != nil {
379 t.Fatal(err)
380 }
381 }},
382 } {
383 t.Run(tc.name, func(t *testing.T) {
384 f, id := guardRecoveryFixture(t)
385 if err := f.a.floor.Begin(t.Context(), id, f.a.dataDir); err != nil {
386 t.Fatal(err)
387 }
388 tc.setup(t, f, id)
389 err := f.a.Run(t.Context(), true)
390 if err == nil || errors.Is(err, errSettlementDeferred) {
391 t.Fatalf("recover = %v, want the settlement's own failure", err)
392 }
393 if _, statErr := os.Stat(filepath.Join(f.a.floor.Dir, "activation.json")); statErr != nil {
394 t.Fatalf("reservation discarded: %v", statErr)
395 }
396 })
397 }
398}
399
400func TestOnlyCensusRefusal(t *testing.T) {
401 refusal := harnessRefusal()
402 for _, tc := range []struct {
403 err error
404 want bool
405 }{
406 {refusal, true},
407 {fmt.Errorf("end participation: %w", refusal), true},
408 {errors.Join(refusal, nil), true},
409 {errors.Join(refusal, harnessRefusal()), true},
410 {errors.Join(refusal, errors.New("remove update.json: read-only file system")), false},
411 {fmt.Errorf("x: %w", errors.Join(refusal, os.ErrPermission)), false},
412 {os.ErrPermission, false},
413 {rootparticipation.ErrUnavailable, false},
414 {nil, false},
415 } {
416 if got := onlyCensusRefusal(tc.err); got != tc.want {
417 t.Fatalf("onlyCensusRefusal(%v) = %t, want %t", tc.err, got, tc.want)
418 }
419 }
420}
421
422func TestRecoverWithoutAnArmedFloorKeepsALateRefusalNonFatal(t *testing.T) {
423 // The controllers' census passes; settling the update record at the end
424 // of the run (settleUpdateBegun -> EndUpdateWith) refuses. As on main
425 // that is logged, not fatal, and the record stays open.
426 f := newApplyFixture(t, "stable", "old")
427 if err := f.a.beginExecutableChange(t.Context(), "new", f.now); err != nil {
428 t.Fatal(err)
429 }
430 calls := refuseCensusFrom(t, f, 2)
431 if err := f.a.Run(t.Context(), true); err != nil {
432 t.Fatalf("recover = %v", err)
433 }
434 if *calls < 2 {
435 t.Fatalf("census ran %d times; the late settlement never ran", *calls)
436 }
437 if _, err := f.a.participation.UpdateID(); err != nil {
438 t.Fatalf("a refused settlement closed the update record: %v", err)
439 }
440}
441
442func TestRecoverHoldsAcquisitionsWhenNoUpdateRecordWasBegun(t *testing.T) {
443 // A prepared transaction: the reservation persisted, root's marker and
444 // update record never written. The census refuses the settlement's
445 // BeginUpdate, which withdraws its own record; recover must still
446 // leave acquisitions refused.
447 for _, finished := range []bool{true, false} {
448 t.Run(map[bool]string{true: "finished", false: "unfinished"}[finished], func(t *testing.T) {
449 f, id := guardRecoveryFixture(t)
450 a := f.a
451 if !finished {
452 unfinishedGuard(t, f, id)
453 }
454 if _, err := a.participation.UpdateID(); !os.IsNotExist(err) {
455 t.Fatalf("fixture has an update record: %v", err)
456 }
457 refuseCensus(t, f)
458 if err := a.Run(t.Context(), true); err != nil {
459 t.Fatalf("recover = %v, want a deferral that exits 0", err)
460 }
461 openTransaction(t, f, id, false)
462 a.participation.VerifyLaunches = func(context.Context, uint64) error { return nil }
463 if err := a.Run(t.Context(), true); err != nil {
464 t.Fatal(err)
465 }
466 if _, err := a.participation.UpdateID(); !os.IsNotExist(err) {
467 t.Fatalf("barrier left after settlement: %v", err)
468 }
469 if err := a.participation.Acquire(t.Context(), func() error { return nil }); err != nil {
470 t.Fatalf("acquisition after settlement = %v", err)
471 }
472 })
473 }
474}
475
476func TestRecoverReplaysASettledTransactionWithoutACensus(t *testing.T) {
477 // Completion recorded, marker and update record gone: settled. A
478 // replay closes nothing, runs no census and blocks nothing.
479 f, id := guardRecoveryFixture(t)
480 a := f.a
481 if err := a.floor.Begin(t.Context(), id, a.dataDir); err != nil {
482 t.Fatal(err)
483 }
484 if err := a.Run(t.Context(), true); err != nil {
485 t.Fatal(err)
486 }
487 if raw, err := os.ReadFile(filepath.Join(a.floor.Dir, "completed.json")); err != nil || string(raw) != id {
488 t.Fatalf("fixture not settled: %q %v", raw, err)
489 }
490 calls := refuseCensus(t, f)
491 if err := a.Run(t.Context(), true); err != nil {
492 t.Fatalf("replay = %v", err)
493 }
494 if *calls != 0 {
495 t.Fatalf("a settled replay ran the census %d times", *calls)
496 }
497 if _, err := a.participation.UpdateID(); !os.IsNotExist(err) {
498 t.Fatalf("a settled replay left an update record: %v", err)
499 }
500}
501
502// journalOpen writes an apply's journal for a recovery to finish.
503func journalOpen(t *testing.T, f *applyFixture, j systemJournal) {
504 t.Helper()
505 j.StartedAt = f.now
506 if err := f.a.journal(j); err != nil {
507 t.Fatal(err)
508 }
509}
510
511func TestRecoverDefersARefusalAfterTheJournalIsRemoved(t *testing.T) {
512 // Recovery's own census passes; the settlement after the journal is
513 // durably removed refuses. Unarmed forward (from gated) and backward
514 // (from installed); guarded through the journal close a guarded
515 // fixture can run, the legacy backing-up journal (the guarded forward
516 // and backward restores run a guarded image, which it cannot; the
517 // guarded backward close is TestGuardedRollbackCloseDefers...).
518 for _, tc := range []struct {
519 name string
520 guarded bool
521 forward bool
522 }{{"guarded-backing-up", true, true}, {"unarmed-forward", false, true}, {"unarmed-backward", false, false}} {
523 t.Run(tc.name, func(t *testing.T) {
524 var f *applyFixture
525 id := ""
526 if tc.guarded {
527 f, id = guardRecoveryFixture(t)
528 if err := f.a.floor.Begin(t.Context(), id, f.a.dataDir); err != nil {
529 t.Fatal(err)
530 }
531 } else {
532 f = newApplyFixture(t, "stable", "old-build")
533 if err := f.a.beginExecutableChange(t.Context(), "new-build", f.now); err != nil {
534 t.Fatal(err)
535 }
536 id, _ = f.a.participation.UpdateID()
537 }
538 a := f.a
539 // carlos.prev is the same signed build: the guard verifies it
540 // before a recovery with a journal open.
541 body, _ := os.ReadFile(systemBinaryPath(a.optDir))
542 if err := os.WriteFile(systemPrevPath(a.optDir), body, 0o755); err != nil {
543 t.Fatal(err)
544 }
545 prev := systemPrevPath(a.optDir)
546 f.answer["version:"+prev] = "carlos old-build\n"
547 f.answer["plan:"+prev] = f.answer["plan:"+systemBinaryPath(a.optDir)]
548 f.answer["units:"+prev] = f.answer["units:"+systemBinaryPath(a.optDir)]
549 if tc.guarded {
550 journalOpen(t, f, systemJournal{Phase: systemPhaseBackingUp, Target: "new-build", PrevBuild: "old-build"})
551 } else if tc.forward {
552 journalOpen(t, f, systemJournal{Phase: systemPhaseGated, Target: "old-build", PrevBuild: "old-build"})
553 } else {
554 journalOpen(t, f, systemJournal{Phase: systemPhaseInstalled, Target: "new-build", PrevBuild: "old-build"})
555 }
556 old := recoverPendingControllers
557 t.Cleanup(func() { recoverPendingControllers = old })
558 recoverPendingControllers = func(context.Context, string, string) error { return nil }
559 removed := false
560 a.participation.VerifyLaunches = func(context.Context, uint64) error {
561 if j, _ := readSystemJournal(a.optDir); j == nil {
562 removed = true
563 return harnessRefusal()
564 }
565 return nil
566 }
567 if err := a.Run(t.Context(), true); err != nil {
568 t.Fatalf("recover = %v, want a deferral that exits 0", err)
569 }
570 if !removed {
571 t.Fatal("the census never ran after the journal was removed")
572 }
573 if j, _ := readSystemJournal(a.optDir); j != nil {
574 t.Fatal("journal kept")
575 }
576 want := systemOutcomeRolledBack
577 if tc.forward {
578 want = systemOutcomeApplied
579 }
580 if r := f.result(); !tc.guarded && (r == nil || r.Outcome != want) {
581 t.Fatalf("result = %+v, want %s", r, want)
582 }
583 if got, err := a.participation.UpdateID(); err != nil || got != id {
584 t.Fatalf("update record = %q, %v; want %s held", got, err, id)
585 }
586 if err := a.participation.Acquire(t.Context(), func() error { return nil }); err == nil {
587 t.Fatal("a claim acquisition started while the settlement was deferred")
588 }
589 // The next recover, with the census passing, settles it.
590 a.participation.VerifyLaunches = func(context.Context, uint64) error { return nil }
591 if err := a.Run(t.Context(), true); err != nil {
592 t.Fatal(err)
593 }
594 if _, err := a.participation.UpdateID(); !os.IsNotExist(err) {
595 t.Fatalf("update record after settlement: %v", err)
596 }
597 })
598 }
599}
600
601func TestRecoverStillFailsANonCensusErrorAfterTheJournalIsRemoved(t *testing.T) {
602 f, id := guardRecoveryFixture(t)
603 a := f.a
604 if err := a.floor.Begin(t.Context(), id, a.dataDir); err != nil {
605 t.Fatal(err)
606 }
607 journalOpen(t, f, systemJournal{Phase: systemPhaseGated, Target: "old-build", PrevBuild: "old-build"})
608 a.participation.VerifyLaunches = func(context.Context, uint64) error {
609 if j, _ := readSystemJournal(a.optDir); j == nil {
610 return rootparticipation.ErrUnavailable
611 }
612 return nil
613 }
614 err := a.Run(t.Context(), true)
615 if err == nil || errors.Is(err, errSettlementDeferred) {
616 t.Fatalf("recover = %v, want the settlement's own failure", err)
617 }
618}
619
620func TestGuardedRollbackCloseDefersARefusalAfterTheJournalIsRemoved(t *testing.T) {
621 // The backward close of a guarded transaction (closeGuardRollback, as
622 // recovery's rollback and the apply's rollback call it): the rollback is
623 // recorded, the journal removed, and the settlement's census refuses.
624 // Restoring carlos.prev runs a guarded image, which this fixture
625 // cannot, so the close is driven directly; Run's consumption of the
626 // deferral is the forward case's.
627 f, id := guardRecoveryFixture(t)
628 a := f.a
629 if err := a.floor.Begin(t.Context(), id, a.dataDir); err != nil {
630 t.Fatal(err)
631 }
632 journalOpen(t, f, systemJournal{Phase: systemPhaseInstalled, Target: "new-build", PrevBuild: "old-build"})
633 refuseCensus(t, f)
634 err := a.closeGuardRollback(t.Context())
635 if !errors.Is(err, errSettlementDeferred) || !a.deferSettlement(true, err) {
636 t.Fatalf("rollback close = %v, want a deferral", err)
637 }
638 if a.deferSettlement(false, err) {
639 t.Fatal("the apply consumed a deferral")
640 }
641 if j, _ := readSystemJournal(a.optDir); j != nil {
642 t.Fatal("journal kept")
643 }
644 if begun, err := hostfloor.ReadBegun(a.floor.Dir); err != nil || !begun.RolledBack {
645 t.Fatalf("rollback not recorded: %+v %v", begun, err)
646 }
647 openTransaction(t, f, id, true)
648}
649
650func TestRecoverDoesNotDeferWithoutABarrier(t *testing.T) {
651 // A barrier that cannot be written (here an id the ledger refuses)
652 // leaves the census refusal fatal: recover never exits 0 with
653 // acquisitions open.
654 f, _ := guardRecoveryFixture(t)
655 err := f.a.settleWithoutJournal(t.Context(), "bad id", harnessRefusal())
656 if err == nil || errors.Is(err, errSettlementDeferred) || !strings.Contains(err.Error(), "acquisition barrier") {
657 t.Fatalf("settlement without a barrier = %v", err)
658 }
659 if err := f.a.participation.Acquire(t.Context(), func() error { return nil }); err != nil {
660 t.Fatalf("fixture: %v", err)
661 }
662}
663
664// failingBarrierStore is the participation store with one write refused:
665// the barrier's update.json, written after BeginUpdate's own write of it
666// was withdrawn by a census refusal.
667type failingBarrierStore struct {
668 rootstate.Store
669 writes int
670}
671
672func (f *failingBarrierStore) Write(name string, raw []byte) error {
673 if name == "update.json" {
674 f.writes++
675 if f.writes == 2 {
676 return errors.New("barrier write refused for the test")
677 }
678 }
679 return f.Store.Write(name, raw)
680}
681
682// legacyMarker writes the update-begun marker alone: root participation,
683// no armed floor, no update record, no journal (Astra round 3).
684func legacyMarker(t *testing.T, f *applyFixture) {
685 t.Helper()
686 a := f.a
687 if a.guarded() {
688 t.Fatal("fixture has an armed floor")
689 }
690 if err := os.MkdirAll(systemGuardDir(a.optDir), 0o755); err != nil {
691 t.Fatal(err)
692 }
693 if err := writeJSONDurable(updateBegunPath(a.optDir), updateBegun{Target: "new", At: f.now}, 0o644); err != nil {
694 t.Fatal(err)
695 }
696 if _, err := a.participation.UpdateID(); !os.IsNotExist(err) {
697 t.Fatalf("fixture has an update record: %v", err)
698 }
699 if j, err := readSystemJournal(a.optDir); err != nil || j != nil {
700 t.Fatalf("fixture has a journal: %v %v", j, err)
701 }
702}
703
704func TestRecoverHoldsAcquisitionsForALegacyMarker(t *testing.T) {
705 // checkUnarmedParticipationRecovery sees no update record and no
706 // journal and returns; the run's end settles the marker through
707 // clearUpdateBegun, whose BeginUpdate the census refuses (and which
708 // withdraws the record it wrote). Recover must exit 0 with the marker
709 // kept and acquisitions held refused, and settle both once the census
710 // passes.
711 f := newApplyFixture(t, "stable", "old")
712 a := f.a
713 legacyMarker(t, f)
714 refuseCensus(t, f)
715 if err := a.Run(t.Context(), true); err != nil {
716 t.Fatalf("recover = %v, want a deferral that exits 0", err)
717 }
718 if _, err := os.Lstat(updateBegunPath(a.optDir)); err != nil {
719 t.Fatalf("the marker went without a settlement: %v", err)
720 }
721 if id, err := a.participation.UpdateID(); err != nil || id != "recover-legacy" {
722 t.Fatalf("barrier = %q, %v; want root's update record under recover-legacy", id, err)
723 }
724 if err := a.participation.Acquire(t.Context(), func() error { return nil }); err == nil {
725 t.Fatal("an acquisition passed while recover had deferred the legacy marker's settlement")
726 }
727 a.participation.VerifyLaunches = func(context.Context, uint64) error { return nil }
728 if err := a.Run(t.Context(), true); err != nil {
729 t.Fatal(err)
730 }
731 if _, err := os.Lstat(updateBegunPath(a.optDir)); !os.IsNotExist(err) {
732 t.Fatalf("marker left after settlement: %v", err)
733 }
734 if _, err := a.participation.UpdateID(); !os.IsNotExist(err) {
735 t.Fatalf("barrier left after settlement: %v", err)
736 }
737 if err := a.participation.Acquire(t.Context(), func() error { return nil }); err != nil {
738 t.Fatalf("acquisition after settlement = %v", err)
739 }
740}
741
742func TestRecoverFailsALegacyMarkerWhoseBarrierCannotBeWritten(t *testing.T) {
743 // The same start, with the barrier's write refused: recover must fail,
744 // not exit 0 with acquisitions open; the marker stays for the next run.
745 f := newApplyFixture(t, "stable", "old")
746 a := f.a
747 legacyMarker(t, f)
748 refuseCensus(t, f)
749 a.participation.State = &failingBarrierStore{Store: a.participation.State}
750 err := a.Run(t.Context(), true)
751 if err == nil || !errors.Is(err, errAcquisitionBarrier) {
752 t.Fatalf("recover with no barrier = %v, want it fatal", err)
753 }
754 if _, err := os.Lstat(updateBegunPath(a.optDir)); err != nil {
755 t.Fatalf("the marker went without a settlement: %v", err)
756 }
757}
758
759// prerequisiteFailingStore is the participation store whose census stops
760// reading once BeginUpdate has withdrawn the update record it wrote: the
761// barrier's prerequisite, ParticipationRequired, then fails.
762type prerequisiteFailingStore struct {
763 rootstate.Store
764 withdrawn bool
765}
766
767func (p *prerequisiteFailingStore) Remove(name string) error {
768 if name == "update.json" {
769 p.withdrawn = true
770 }
771 return p.Store.Remove(name)
772}
773
774func (p *prerequisiteFailingStore) Read(name string) ([]byte, error) {
775 if name == "census.json" && p.withdrawn {
776 return nil, errors.New("census read refused for the test")
777 }
778 return p.Store.Read(name)
779}
780
781func TestRecoverFailsALegacyMarkerWhoseBarrierPrerequisiteFails(t *testing.T) {
782 // Astra round 4: after the census refused BeginUpdate and the record it
783 // wrote was withdrawn, the check whether a barrier is needed fails. That
784 // is a barrier failure: recover must not log it and exit 0.
785 f := newApplyFixture(t, "stable", "old")
786 a := f.a
787 legacyMarker(t, f)
788 refuseCensus(t, f)
789 a.participation.State = &prerequisiteFailingStore{Store: a.participation.State}
790 err := a.Run(t.Context(), true)
791 if err == nil || !errors.Is(err, errAcquisitionBarrier) {
792 t.Fatalf("recover with the barrier prerequisite failing = %v, want it fatal", err)
793 }
794 if _, err := os.Lstat(updateBegunPath(a.optDir)); err != nil {
795 t.Fatalf("the marker went without a settlement: %v", err)
796 }
797}
798