package host import ( "context" "encoding/json" "errors" "fmt" "github.com/carlosframework/platform/internal/rootstate" "os" "path/filepath" "strings" "testing" "github.com/carlosframework/platform/internal/hostactors" "github.com/carlosframework/platform/internal/hostfloor" "github.com/carlosframework/platform/internal/release" "github.com/carlosframework/platform/internal/rootparticipation" ) // carlos-system-recover runs the root generation census to settle the // participation transaction an unfinished apply left open. The edge's guard // drop-in Requires= the recover unit, so a recover that fails keeps the // edge down. With no journal open the files are whole, so a census refusal // defers the settlement instead: recover exits 0 with the transaction still // open. With a journal open it fails as before. // harnessRefusal is the kernel census's refusal on the harness box, as // productionGenerationKernelCensus returns it and the launch verifier // passes it up unchanged. func harnessRefusal() error { return &generationCensusRefusal{err: fmt.Errorf("/proc/1535/task/1535: service: %w", os.ErrPermission)} } // refuseCensus makes root's launch census refuse from its pass-th call on // (1 is every call), and stubs the pending controller recovery so the // refusal is the census's alone. func refuseCensusFrom(t *testing.T, f *applyFixture, pass int) *int { t.Helper() calls := 0 f.a.participation.VerifyLaunches = func(context.Context, uint64) error { calls++ if calls < pass { return nil } return harnessRefusal() } old := recoverPendingControllers t.Cleanup(func() { recoverPendingControllers = old }) recoverPendingControllers = func(context.Context, string, string) error { return nil } return &calls } func refuseCensus(t *testing.T, f *applyFixture) *int { return refuseCensusFrom(t, f, 1) } func logLines(f *applyFixture) *[]string { var lines []string logf := f.a.ops.logf f.a.ops.logf = func(format string, args ...any) { lines = append(lines, fmt.Sprintf(format, args...)) logf(format, args...) } return &lines } func TestRecoverDefersACensusRefusalWithNoJournalOpen(t *testing.T) { for _, marker := range []bool{true, false} { t.Run(map[bool]string{true: "marker", false: "no-marker"}[marker], func(t *testing.T) { f, id := guardRecoveryFixture(t) a := f.a if err := a.floor.Begin(t.Context(), id, a.dataDir); err != nil { t.Fatal(err) } begun := filepath.Join(a.floor.Dir, "update-begun.json") if !marker { if err := os.Remove(begun); err != nil { t.Fatal(err) } } calls := refuseCensus(t, f) lines := logLines(f) if err := a.Run(t.Context(), true); err != nil { t.Fatalf("recover with no journal open = %v, want a deferral that exits 0", err) } // Once before root's pending-controller recovery and once after, // and not again at the end of the run. if *calls != 2 { t.Fatalf("recover ran the census %d times, want 2", *calls) } // The transaction is still open: no completion, the reservation // kept, root's update record kept, so acquisitions stay refused. if _, err := os.Stat(filepath.Join(a.floor.Dir, "completed.json")); !os.IsNotExist(err) { t.Fatalf("a refused census was settled: %v", err) } if _, err := os.Stat(filepath.Join(a.floor.Dir, "activation.json")); err != nil { t.Fatalf("reservation discarded: %v", err) } if _, err := os.Stat(begun); marker && err != nil { t.Fatalf("marker discarded: %v", err) } if got, err := a.participation.UpdateID(); err != nil || got != id { t.Fatalf("update record = %q, %v; want %s still open", got, err, id) } if err := a.participation.Acquire(t.Context(), func() error { return nil }); err == nil { t.Fatal("a claim acquisition started while the transaction was open") } if len(f.calls) != 0 { t.Fatal("recovery executed a binary or touched a unit", f.calls) } if !strings.Contains(strings.Join(*lines, "\n"), "service: permission denied") { t.Fatalf("the deferral did not log the census's refusal: %q", *lines) } }) } } func TestRecoverFailsACensusRefusalWithAJournalOpen(t *testing.T) { f, id := guardRecoveryFixture(t) a := f.a if err := a.floor.Begin(t.Context(), id, a.dataDir); err != nil { t.Fatal(err) } if err := a.journal(systemJournal{Phase: systemPhaseInstalled, Target: "new-build", PrevBuild: "old-build", StartedAt: f.now}); err != nil { t.Fatal(err) } refuseCensus(t, f) err := a.Run(t.Context(), true) if err == nil || errors.Is(err, errSettlementDeferred) || !strings.Contains(err.Error(), "service: permission denied") { t.Fatalf("recover with a journal open = %v, want the census's refusal", err) } if _, err := os.Stat(a.file("journal.json")); err != nil { t.Fatalf("journal discarded: %v", err) } } func TestRecoverDefersACensusRefusalWithoutAnArmedFloor(t *testing.T) { // checkUnarmedParticipationRecovery: an apply began root's update // record and stopped before writing a journal. f := newApplyFixture(t, "stable", "old") if f.a.guarded() { t.Fatal("fixture has an armed floor") } if err := f.a.beginExecutableChange(t.Context(), "new", f.now); err != nil { t.Fatal(err) } calls := refuseCensus(t, f) if err := f.a.Run(t.Context(), true); err != nil { t.Fatalf("recover with no journal open = %v, want a deferral that exits 0", err) } if *calls != 2 { t.Fatalf("recover ran the census %d times, want 2 (not again to settle the marker)", *calls) } if _, err := f.a.participation.UpdateID(); err != nil { t.Fatalf("recover settled a refused census: %v", err) } // With a journal open it still fails. if err := f.a.journal(systemJournal{Phase: systemPhaseInstalling, Target: "new"}); err != nil { t.Fatal(err) } if err := f.a.Run(t.Context(), true); err == nil { t.Fatal("recover with a journal open passed a refused census") } } func TestApplyStillFailsACensusRefusalWithNoJournalOpen(t *testing.T) { // Only --recover defers: the apply's own start-up check returns the // refusal as on main. f, id := guardRecoveryFixture(t) if err := f.a.floor.Begin(t.Context(), id, f.a.dataDir); err != nil { t.Fatal(err) } refuseCensus(t, f) if err := f.a.Run(t.Context(), false); err == nil || !strings.Contains(err.Error(), "service: permission denied") { t.Fatalf("apply = %v, want the census's refusal", err) } } func TestRuling6NoCensusRecoverIsUnchanged(t *testing.T) { // A box without root participation (no census.json) never runs the // generation census, and a refused settlement fails recover exactly as // on main: the deferral is for census boxes only. f, id := guardRecoveryFixture(t) a := f.a if err := a.floor.Begin(t.Context(), id, a.dataDir); err != nil { t.Fatal(err) } if err := a.participation.State.Remove("census.json"); err != nil { t.Fatal(err) } if required, err := a.participationDisk().ParticipationRequired(t.Context()); err != nil || required { t.Fatalf("fixture still requires participation: %t %v", required, err) } a.participation.VerifyLaunches = func(context.Context, uint64) error { t.Fatal("a no-census box ran the generation census") return nil } if err := os.WriteFile(filepath.Join(a.dataDir, hostexecDir, "host.json"), []byte(`{"state":"pending"}`), 0600); err != nil { t.Fatal(err) } err := a.Run(t.Context(), true) if err == nil || errors.Is(err, errSettlementDeferred) { t.Fatalf("no-census recover = %v, want main's refusal", err) } if _, err := os.Stat(filepath.Join(a.floor.Dir, "completed.json")); !os.IsNotExist(err) { t.Fatalf("completion written: %v", err) } // No census refusal can reach a no-census box's settlement, and one // that did would not defer either. if err := a.settleWithoutJournal(t.Context(), "recover-system", harnessRefusal()); errors.Is(err, errSettlementDeferred) { t.Fatalf("a no-census box deferred: %v", err) } } // openTransaction checks the guarded transaction is still open after a // deferral: the reservation and root's update record kept, so claim // acquisitions stay refused. marker says whether root's marker must be // there too. func openTransaction(t *testing.T, f *applyFixture, id string, marker bool) { t.Helper() a := f.a if _, err := os.Stat(filepath.Join(a.floor.Dir, "activation.json")); err != nil { t.Fatalf("reservation discarded: %v", err) } if _, err := os.Stat(filepath.Join(a.floor.Dir, "update-begun.json")); marker && err != nil { t.Fatalf("marker discarded: %v", err) } if got, err := a.participation.UpdateID(); err != nil || got != id { t.Fatalf("update record = %q, %v; want %s still open", got, err, id) } if err := a.participation.Acquire(t.Context(), func() error { return nil }); err == nil { t.Fatal("a claim acquisition started while the transaction was open") } } func TestRecoverDefersACensusRefusalOnlyAtTheEndOfParticipation(t *testing.T) { // The pending-controller census passes; the one inside EndParticipation // (EndUpdateWith -> idle) refuses. A finished transaction. f, id := guardRecoveryFixture(t) a := f.a if err := a.floor.Begin(t.Context(), id, a.dataDir); err != nil { t.Fatal(err) } calls := refuseCensusFrom(t, f, 2) if err := a.Run(t.Context(), true); err != nil { t.Fatalf("recover = %v, want a deferral that exits 0", err) } if *calls != 2 { t.Fatalf("census ran %d times, want 2 (controllers, then the end of participation)", *calls) } openTransaction(t, f, id, true) // The next recover, with the census passing, settles it. a.participation.VerifyLaunches = func(context.Context, uint64) error { return nil } if err := a.Run(t.Context(), true); err != nil { t.Fatal(err) } if raw, err := os.ReadFile(filepath.Join(a.floor.Dir, "completed.json")); err != nil || string(raw) != id { t.Fatalf("completion = %q, %v", raw, err) } if _, err := a.participation.UpdateID(); !os.IsNotExist(err) { t.Fatalf("update record after settlement: %v", err) } } // unfinishedGuard makes the fixture's transaction one that never changed // the box: the signed reservation names another image as its target. func unfinishedGuard(t *testing.T, f *applyFixture, id string) { t.Helper() path := filepath.Join(f.a.floor.Dir, "activation.json") raw, err := os.ReadFile(path) if err != nil { t.Fatal(err) } var tx guardTransaction if err := json.Unmarshal(raw, &tx); err != nil { t.Fatal(err) } var roster hostactors.Roster if err := release.VerifyRootEnvelope(f.pub, tx.Roster, &roster); err != nil { t.Fatal(err) } h := roster.Activations[id] h.Hashes = map[string][]string{tx.Actor: {h.Hashes[tx.Actor][0], strings.Repeat("d", 64), h.Hashes[tx.Actor][2]}} roster.Activations[id] = h if err := roster.Validate("bucket"); err != nil { t.Fatal(err) } if tx.Roster, err = release.SignRootEnvelope(release.RootSigner(f.priv), roster); err != nil { t.Fatal(err) } raw, _ = json.Marshal(tx) if err := hostfloor.Durable(path, raw, 0644); err != nil { t.Fatal(err) } if finished, err := f.a.guardFinished(tx); err != nil || finished { t.Fatalf("fixture transaction finished=%t err=%v, want unfinished", finished, err) } } func TestRecoverDefersACensusRefusalOfAnUnfinishedTransaction(t *testing.T) { f, id := guardRecoveryFixture(t) a := f.a if err := a.floor.Begin(t.Context(), id, a.dataDir); err != nil { t.Fatal(err) } unfinishedGuard(t, f, id) refuseCensus(t, f) if err := a.Run(t.Context(), true); err != nil { t.Fatalf("recover = %v, want a deferral that exits 0", err) } // The withdrawal removes nothing until its census passes. openTransaction(t, f, id, true) if _, err := os.Stat(filepath.Join(a.floor.Dir, "completed.json")); !os.IsNotExist(err) { t.Fatalf("an unfinished transaction recorded a completion: %v", err) } a.participation.VerifyLaunches = func(context.Context, uint64) error { return nil } if err := a.Run(t.Context(), true); err != nil { t.Fatal(err) } for _, name := range []string{"update-begun.json", "activation.json", "completed.json"} { if _, err := os.Stat(filepath.Join(a.floor.Dir, name)); !os.IsNotExist(err) { t.Fatalf("%s after the withdrawal: %v", name, err) } } } func TestRecoverDefersACensusRefusalOfAnAlreadyCompletedTransaction(t *testing.T) { // Completion recorded and the marker gone, root's update record still // open: only EndParticipation is left, and its census refuses. f, id := guardRecoveryFixture(t) a := f.a if err := a.floor.Begin(t.Context(), id, a.dataDir); err != nil { t.Fatal(err) } if err := hostfloor.Durable(filepath.Join(a.floor.Dir, "completed.json"), []byte(id), 0644); err != nil { t.Fatal(err) } if err := hostfloor.RemoveDurable(filepath.Join(a.floor.Dir, "update-begun.json")); err != nil { t.Fatal(err) } calls := refuseCensus(t, f) if err := a.Run(t.Context(), true); err != nil { t.Fatalf("recover = %v, want a deferral that exits 0", err) } if *calls != 1 { t.Fatalf("census ran %d times, want 1", *calls) } openTransaction(t, f, id, false) if raw, err := os.ReadFile(filepath.Join(a.floor.Dir, "completed.json")); err != nil || string(raw) != id { t.Fatalf("completion = %q, %v", raw, err) } } func TestRecoverStillFailsSettlementErrorsThatAreNotCensusRefusals(t *testing.T) { for _, tc := range []struct { name string setup func(*testing.T, *applyFixture, string) }{ {"malformed-ledger-state", func(t *testing.T, f *applyFixture, _ string) { // The verifier refuses the ledger itself, not through the census. f.a.participation.VerifyLaunches = func(context.Context, uint64) error { return rootparticipation.ErrUnavailable } old := recoverPendingControllers t.Cleanup(func() { recoverPendingControllers = old }) recoverPendingControllers = func(context.Context, string, string) error { return nil } }}, {"live-controller", func(t *testing.T, f *applyFixture, _ string) { if err := os.WriteFile(filepath.Join(f.a.dataDir, hostexecDir, "host.json"), []byte(`{"state":"pending"}`), 0600); err != nil { t.Fatal(err) } old := recoverPendingControllers t.Cleanup(func() { recoverPendingControllers = old }) recoverPendingControllers = func(context.Context, string, string) error { return nil } }}, {"image-verification", func(t *testing.T, f *applyFixture, _ string) { if err := os.WriteFile(systemBinaryPath(f.a.optDir), []byte("an unsigned image"), 0755); err != nil { t.Fatal(err) } }}, {"marker-for-another-transaction", func(t *testing.T, f *applyFixture, _ string) { if err := hostfloor.Durable(filepath.Join(f.a.floor.Dir, "update-begun.json"), []byte(`{"id":"`+strings.Repeat("e", 32)+`"}`), 0644); err != nil { t.Fatal(err) } }}, } { t.Run(tc.name, func(t *testing.T) { f, id := guardRecoveryFixture(t) if err := f.a.floor.Begin(t.Context(), id, f.a.dataDir); err != nil { t.Fatal(err) } tc.setup(t, f, id) err := f.a.Run(t.Context(), true) if err == nil || errors.Is(err, errSettlementDeferred) { t.Fatalf("recover = %v, want the settlement's own failure", err) } if _, statErr := os.Stat(filepath.Join(f.a.floor.Dir, "activation.json")); statErr != nil { t.Fatalf("reservation discarded: %v", statErr) } }) } } func TestOnlyCensusRefusal(t *testing.T) { refusal := harnessRefusal() for _, tc := range []struct { err error want bool }{ {refusal, true}, {fmt.Errorf("end participation: %w", refusal), true}, {errors.Join(refusal, nil), true}, {errors.Join(refusal, harnessRefusal()), true}, {errors.Join(refusal, errors.New("remove update.json: read-only file system")), false}, {fmt.Errorf("x: %w", errors.Join(refusal, os.ErrPermission)), false}, {os.ErrPermission, false}, {rootparticipation.ErrUnavailable, false}, {nil, false}, } { if got := onlyCensusRefusal(tc.err); got != tc.want { t.Fatalf("onlyCensusRefusal(%v) = %t, want %t", tc.err, got, tc.want) } } } func TestRecoverWithoutAnArmedFloorKeepsALateRefusalNonFatal(t *testing.T) { // The controllers' census passes; settling the update record at the end // of the run (settleUpdateBegun -> EndUpdateWith) refuses. As on main // that is logged, not fatal, and the record stays open. f := newApplyFixture(t, "stable", "old") if err := f.a.beginExecutableChange(t.Context(), "new", f.now); err != nil { t.Fatal(err) } calls := refuseCensusFrom(t, f, 2) if err := f.a.Run(t.Context(), true); err != nil { t.Fatalf("recover = %v", err) } if *calls < 2 { t.Fatalf("census ran %d times; the late settlement never ran", *calls) } if _, err := f.a.participation.UpdateID(); err != nil { t.Fatalf("a refused settlement closed the update record: %v", err) } } func TestRecoverHoldsAcquisitionsWhenNoUpdateRecordWasBegun(t *testing.T) { // A prepared transaction: the reservation persisted, root's marker and // update record never written. The census refuses the settlement's // BeginUpdate, which withdraws its own record; recover must still // leave acquisitions refused. for _, finished := range []bool{true, false} { t.Run(map[bool]string{true: "finished", false: "unfinished"}[finished], func(t *testing.T) { f, id := guardRecoveryFixture(t) a := f.a if !finished { unfinishedGuard(t, f, id) } if _, err := a.participation.UpdateID(); !os.IsNotExist(err) { t.Fatalf("fixture has an update record: %v", err) } refuseCensus(t, f) if err := a.Run(t.Context(), true); err != nil { t.Fatalf("recover = %v, want a deferral that exits 0", err) } openTransaction(t, f, id, false) a.participation.VerifyLaunches = func(context.Context, uint64) error { return nil } if err := a.Run(t.Context(), true); err != nil { t.Fatal(err) } if _, err := a.participation.UpdateID(); !os.IsNotExist(err) { t.Fatalf("barrier left after settlement: %v", err) } if err := a.participation.Acquire(t.Context(), func() error { return nil }); err != nil { t.Fatalf("acquisition after settlement = %v", err) } }) } } func TestRecoverReplaysASettledTransactionWithoutACensus(t *testing.T) { // Completion recorded, marker and update record gone: settled. A // replay closes nothing, runs no census and blocks nothing. f, id := guardRecoveryFixture(t) a := f.a if err := a.floor.Begin(t.Context(), id, a.dataDir); err != nil { t.Fatal(err) } if err := a.Run(t.Context(), true); err != nil { t.Fatal(err) } if raw, err := os.ReadFile(filepath.Join(a.floor.Dir, "completed.json")); err != nil || string(raw) != id { t.Fatalf("fixture not settled: %q %v", raw, err) } calls := refuseCensus(t, f) if err := a.Run(t.Context(), true); err != nil { t.Fatalf("replay = %v", err) } if *calls != 0 { t.Fatalf("a settled replay ran the census %d times", *calls) } if _, err := a.participation.UpdateID(); !os.IsNotExist(err) { t.Fatalf("a settled replay left an update record: %v", err) } } // journalOpen writes an apply's journal for a recovery to finish. func journalOpen(t *testing.T, f *applyFixture, j systemJournal) { t.Helper() j.StartedAt = f.now if err := f.a.journal(j); err != nil { t.Fatal(err) } } func TestRecoverDefersARefusalAfterTheJournalIsRemoved(t *testing.T) { // Recovery's own census passes; the settlement after the journal is // durably removed refuses. Unarmed forward (from gated) and backward // (from installed); guarded through the journal close a guarded // fixture can run, the legacy backing-up journal (the guarded forward // and backward restores run a guarded image, which it cannot; the // guarded backward close is TestGuardedRollbackCloseDefers...). for _, tc := range []struct { name string guarded bool forward bool }{{"guarded-backing-up", true, true}, {"unarmed-forward", false, true}, {"unarmed-backward", false, false}} { t.Run(tc.name, func(t *testing.T) { var f *applyFixture id := "" if tc.guarded { f, id = guardRecoveryFixture(t) if err := f.a.floor.Begin(t.Context(), id, f.a.dataDir); err != nil { t.Fatal(err) } } else { f = newApplyFixture(t, "stable", "old-build") if err := f.a.beginExecutableChange(t.Context(), "new-build", f.now); err != nil { t.Fatal(err) } id, _ = f.a.participation.UpdateID() } a := f.a // carlos.prev is the same signed build: the guard verifies it // before a recovery with a journal open. body, _ := os.ReadFile(systemBinaryPath(a.optDir)) if err := os.WriteFile(systemPrevPath(a.optDir), body, 0o755); err != nil { t.Fatal(err) } prev := systemPrevPath(a.optDir) f.answer["version:"+prev] = "carlos old-build\n" f.answer["plan:"+prev] = f.answer["plan:"+systemBinaryPath(a.optDir)] f.answer["units:"+prev] = f.answer["units:"+systemBinaryPath(a.optDir)] if tc.guarded { journalOpen(t, f, systemJournal{Phase: systemPhaseBackingUp, Target: "new-build", PrevBuild: "old-build"}) } else if tc.forward { journalOpen(t, f, systemJournal{Phase: systemPhaseGated, Target: "old-build", PrevBuild: "old-build"}) } else { journalOpen(t, f, systemJournal{Phase: systemPhaseInstalled, Target: "new-build", PrevBuild: "old-build"}) } old := recoverPendingControllers t.Cleanup(func() { recoverPendingControllers = old }) recoverPendingControllers = func(context.Context, string, string) error { return nil } removed := false a.participation.VerifyLaunches = func(context.Context, uint64) error { if j, _ := readSystemJournal(a.optDir); j == nil { removed = true return harnessRefusal() } return nil } if err := a.Run(t.Context(), true); err != nil { t.Fatalf("recover = %v, want a deferral that exits 0", err) } if !removed { t.Fatal("the census never ran after the journal was removed") } if j, _ := readSystemJournal(a.optDir); j != nil { t.Fatal("journal kept") } want := systemOutcomeRolledBack if tc.forward { want = systemOutcomeApplied } if r := f.result(); !tc.guarded && (r == nil || r.Outcome != want) { t.Fatalf("result = %+v, want %s", r, want) } if got, err := a.participation.UpdateID(); err != nil || got != id { t.Fatalf("update record = %q, %v; want %s held", got, err, id) } if err := a.participation.Acquire(t.Context(), func() error { return nil }); err == nil { t.Fatal("a claim acquisition started while the settlement was deferred") } // The next recover, with the census passing, settles it. a.participation.VerifyLaunches = func(context.Context, uint64) error { return nil } if err := a.Run(t.Context(), true); err != nil { t.Fatal(err) } if _, err := a.participation.UpdateID(); !os.IsNotExist(err) { t.Fatalf("update record after settlement: %v", err) } }) } } func TestRecoverStillFailsANonCensusErrorAfterTheJournalIsRemoved(t *testing.T) { f, id := guardRecoveryFixture(t) a := f.a if err := a.floor.Begin(t.Context(), id, a.dataDir); err != nil { t.Fatal(err) } journalOpen(t, f, systemJournal{Phase: systemPhaseGated, Target: "old-build", PrevBuild: "old-build"}) a.participation.VerifyLaunches = func(context.Context, uint64) error { if j, _ := readSystemJournal(a.optDir); j == nil { return rootparticipation.ErrUnavailable } return nil } err := a.Run(t.Context(), true) if err == nil || errors.Is(err, errSettlementDeferred) { t.Fatalf("recover = %v, want the settlement's own failure", err) } } func TestGuardedRollbackCloseDefersARefusalAfterTheJournalIsRemoved(t *testing.T) { // The backward close of a guarded transaction (closeGuardRollback, as // recovery's rollback and the apply's rollback call it): the rollback is // recorded, the journal removed, and the settlement's census refuses. // Restoring carlos.prev runs a guarded image, which this fixture // cannot, so the close is driven directly; Run's consumption of the // deferral is the forward case's. f, id := guardRecoveryFixture(t) a := f.a if err := a.floor.Begin(t.Context(), id, a.dataDir); err != nil { t.Fatal(err) } journalOpen(t, f, systemJournal{Phase: systemPhaseInstalled, Target: "new-build", PrevBuild: "old-build"}) refuseCensus(t, f) err := a.closeGuardRollback(t.Context()) if !errors.Is(err, errSettlementDeferred) || !a.deferSettlement(true, err) { t.Fatalf("rollback close = %v, want a deferral", err) } if a.deferSettlement(false, err) { t.Fatal("the apply consumed a deferral") } if j, _ := readSystemJournal(a.optDir); j != nil { t.Fatal("journal kept") } if begun, err := hostfloor.ReadBegun(a.floor.Dir); err != nil || !begun.RolledBack { t.Fatalf("rollback not recorded: %+v %v", begun, err) } openTransaction(t, f, id, true) } func TestRecoverDoesNotDeferWithoutABarrier(t *testing.T) { // A barrier that cannot be written (here an id the ledger refuses) // leaves the census refusal fatal: recover never exits 0 with // acquisitions open. f, _ := guardRecoveryFixture(t) err := f.a.settleWithoutJournal(t.Context(), "bad id", harnessRefusal()) if err == nil || errors.Is(err, errSettlementDeferred) || !strings.Contains(err.Error(), "acquisition barrier") { t.Fatalf("settlement without a barrier = %v", err) } if err := f.a.participation.Acquire(t.Context(), func() error { return nil }); err != nil { t.Fatalf("fixture: %v", err) } } // failingBarrierStore is the participation store with one write refused: // the barrier's update.json, written after BeginUpdate's own write of it // was withdrawn by a census refusal. type failingBarrierStore struct { rootstate.Store writes int } func (f *failingBarrierStore) Write(name string, raw []byte) error { if name == "update.json" { f.writes++ if f.writes == 2 { return errors.New("barrier write refused for the test") } } return f.Store.Write(name, raw) } // legacyMarker writes the update-begun marker alone: root participation, // no armed floor, no update record, no journal (Astra round 3). func legacyMarker(t *testing.T, f *applyFixture) { t.Helper() a := f.a if a.guarded() { t.Fatal("fixture has an armed floor") } if err := os.MkdirAll(systemGuardDir(a.optDir), 0o755); err != nil { t.Fatal(err) } if err := writeJSONDurable(updateBegunPath(a.optDir), updateBegun{Target: "new", At: f.now}, 0o644); err != nil { t.Fatal(err) } if _, err := a.participation.UpdateID(); !os.IsNotExist(err) { t.Fatalf("fixture has an update record: %v", err) } if j, err := readSystemJournal(a.optDir); err != nil || j != nil { t.Fatalf("fixture has a journal: %v %v", j, err) } } func TestRecoverHoldsAcquisitionsForALegacyMarker(t *testing.T) { // checkUnarmedParticipationRecovery sees no update record and no // journal and returns; the run's end settles the marker through // clearUpdateBegun, whose BeginUpdate the census refuses (and which // withdraws the record it wrote). Recover must exit 0 with the marker // kept and acquisitions held refused, and settle both once the census // passes. f := newApplyFixture(t, "stable", "old") a := f.a legacyMarker(t, f) refuseCensus(t, f) if err := a.Run(t.Context(), true); err != nil { t.Fatalf("recover = %v, want a deferral that exits 0", err) } if _, err := os.Lstat(updateBegunPath(a.optDir)); err != nil { t.Fatalf("the marker went without a settlement: %v", err) } if id, err := a.participation.UpdateID(); err != nil || id != "recover-legacy" { t.Fatalf("barrier = %q, %v; want root's update record under recover-legacy", id, err) } if err := a.participation.Acquire(t.Context(), func() error { return nil }); err == nil { t.Fatal("an acquisition passed while recover had deferred the legacy marker's settlement") } a.participation.VerifyLaunches = func(context.Context, uint64) error { return nil } if err := a.Run(t.Context(), true); err != nil { t.Fatal(err) } if _, err := os.Lstat(updateBegunPath(a.optDir)); !os.IsNotExist(err) { t.Fatalf("marker left after settlement: %v", err) } if _, err := a.participation.UpdateID(); !os.IsNotExist(err) { t.Fatalf("barrier left after settlement: %v", err) } if err := a.participation.Acquire(t.Context(), func() error { return nil }); err != nil { t.Fatalf("acquisition after settlement = %v", err) } } func TestRecoverFailsALegacyMarkerWhoseBarrierCannotBeWritten(t *testing.T) { // The same start, with the barrier's write refused: recover must fail, // not exit 0 with acquisitions open; the marker stays for the next run. f := newApplyFixture(t, "stable", "old") a := f.a legacyMarker(t, f) refuseCensus(t, f) a.participation.State = &failingBarrierStore{Store: a.participation.State} err := a.Run(t.Context(), true) if err == nil || !errors.Is(err, errAcquisitionBarrier) { t.Fatalf("recover with no barrier = %v, want it fatal", err) } if _, err := os.Lstat(updateBegunPath(a.optDir)); err != nil { t.Fatalf("the marker went without a settlement: %v", err) } } // prerequisiteFailingStore is the participation store whose census stops // reading once BeginUpdate has withdrawn the update record it wrote: the // barrier's prerequisite, ParticipationRequired, then fails. type prerequisiteFailingStore struct { rootstate.Store withdrawn bool } func (p *prerequisiteFailingStore) Remove(name string) error { if name == "update.json" { p.withdrawn = true } return p.Store.Remove(name) } func (p *prerequisiteFailingStore) Read(name string) ([]byte, error) { if name == "census.json" && p.withdrawn { return nil, errors.New("census read refused for the test") } return p.Store.Read(name) } func TestRecoverFailsALegacyMarkerWhoseBarrierPrerequisiteFails(t *testing.T) { // Astra round 4: after the census refused BeginUpdate and the record it // wrote was withdrawn, the check whether a barrier is needed fails. That // is a barrier failure: recover must not log it and exit 0. f := newApplyFixture(t, "stable", "old") a := f.a legacyMarker(t, f) refuseCensus(t, f) a.participation.State = &prerequisiteFailingStore{Store: a.participation.State} err := a.Run(t.Context(), true) if err == nil || !errors.Is(err, errAcquisitionBarrier) { t.Fatalf("recover with the barrier prerequisite failing = %v, want it fatal", err) } if _, err := os.Lstat(updateBegunPath(a.optDir)); err != nil { t.Fatalf("the marker went without a settlement: %v", err) } }