package host import ( "context" "os" "path/filepath" "runtime" "strings" "testing" ) func hostexec(t *testing.T, f *applyFixture) string { t.Helper() dir := filepath.Join(f.a.dataDir, hostexecDir) if err := os.MkdirAll(dir, 0o755); err != nil { t.Fatal(err) } return dir } func assertNotUpdated(t *testing.T, f *applyFixture, why string) { t.Helper() r := f.result() if r == nil || r.Outcome != systemOutcomeDeferred || !strings.Contains(r.Reason, why) { t.Fatalf("result = %+v, want deferred naming %q", r, why) } if r.terminal() { t.Fatal("the agent would never retry this promote") } if bin, _ := os.ReadFile(systemBinaryPath(f.a.optDir)); !strings.Contains(string(bin), "carlos v1") { t.Fatal("the binary changed under a refusal") } if j, _ := readSystemJournal(f.a.optDir); j != nil { t.Fatalf("a refusal opened a journal: %+v", j) } if _, err := os.Stat(updateBegunPath(f.a.optDir)); !os.IsNotExist(err) { t.Fatalf("the update-begun marker outlived the refusal: %v", err) } if f.called("systemctl stop") || f.called("systemctl restart") { t.Fatal("a refused update touched a unit") } } // Spec ยง5.10: any controller record, in any state, refuses the update. func TestApplyRefusesWhileThisBoxTakesPartInAClaimOperation(t *testing.T) { t.Parallel() for _, state := range []string{"pending", "active", "lost"} { t.Run(state, func(t *testing.T) { f, _ := applyHappy(t) f.a.regionHA = true os.WriteFile(filepath.Join(hostexec(t, f), "shop.bab.test.json"), []byte(`{"state":"`+state+`"}`), 0o600) if err := f.a.Run(context.Background(), false); err != nil { t.Fatal(err) } assertNotUpdated(t, f, "takes part in a running claim operation") }) } } // Review Focus 4: what root cannot read is a refusal, never a pass. func TestApplyRefusesWhenItCannotTellWhetherThisBoxTakesPart(t *testing.T) { t.Parallel() cases := map[string]func(t *testing.T, f *applyFixture){ "a temporary file": func(t *testing.T, f *applyFixture) { os.WriteFile(filepath.Join(hostexec(t, f), ".shop.bab.test.json.tmp"), nil, 0o600) }, "garbage": func(t *testing.T, f *applyFixture) { os.WriteFile(filepath.Join(hostexec(t, f), "shop.bab.test.json"), []byte("{half"), 0o600) }, "a symlink in place of the directory": func(t *testing.T, f *applyFixture) { elsewhere := t.TempDir() if err := os.Symlink(elsewhere, filepath.Join(f.a.dataDir, hostexecDir)); err != nil { t.Skip("no symlinks here:", err) } }, "a file in place of the directory": func(t *testing.T, f *applyFixture) { os.WriteFile(filepath.Join(f.a.dataDir, hostexecDir), nil, 0o600) }, } for name, setup := range cases { t.Run(name, func(t *testing.T) { f, _ := applyHappy(t) f.a.regionHA = true setup(t, f) if err := f.a.Run(context.Background(), false); err != nil { t.Fatal(err) } assertNotUpdated(t, f, "hostexec") }) } } func TestApplyRefusesWhenItCannotListTheControllerDirectory(t *testing.T) { if runtime.GOOS == "windows" || os.Geteuid() == 0 { t.Skip("permission bits do not stop this user") } f, _ := applyHappy(t) f.a.regionHA = true dir := hostexec(t, f) os.Chmod(dir, 0) t.Cleanup(func() { os.Chmod(dir, 0o755) }) if err := f.a.Run(context.Background(), false); err != nil { t.Fatal(err) } assertNotUpdated(t, f, "cannot be listed") } // The marker is written before the box changes and is gone once the // update is done; an empty controller directory is no participation. func TestApplyHoldsTheUpdateBegunMarkerAcrossTheInstall(t *testing.T) { f, req := applyHappy(t) f.a.regionHA = true hostexec(t, f) // present and empty seen := false orig := f.a.ops.systemctl f.a.ops.systemctl = func(args ...string) (string, error) { if args[0] == "restart" && args[len(args)-1] == systemEdgeUnit { _, err := os.Stat(updateBegunPath(f.a.optDir)) seen = err == nil } return orig(args...) } if err := f.a.Run(context.Background(), false); err != nil { t.Fatal(err) } if r := f.result(); r == nil || r.Outcome != systemOutcomeApplied || !r.matches("stable", "v2", req.PromotedAt) { t.Fatalf("result = %+v", r) } if !seen { t.Fatal("no update-begun marker while the edge restarted onto the new binary") } if _, err := os.Stat(updateBegunPath(f.a.optDir)); !os.IsNotExist(err) { t.Fatalf("marker left after the update finished: %v", err) } } func TestApplyWithoutRegionHAWritesNoMarkerAndIgnoresControllerRecords(t *testing.T) { f, _ := applyHappy(t) os.WriteFile(filepath.Join(hostexec(t, f), "shop.bab.test.json"), []byte(`{}`), 0o600) if err := f.a.Run(context.Background(), false); err != nil { t.Fatal(err) } if r := f.result(); r == nil || r.Outcome != systemOutcomeApplied { t.Fatalf("result = %+v: the flag is off, so nothing changes", r) } if _, err := os.Stat(systemGuardDir(f.a.optDir)); !os.IsNotExist(err) { t.Fatalf("the guard directory was created with the flag off: %v", err) } } // A crash between the marker and the journal leaves a marker with no // update behind it; the next run, --recover included, clears it. func TestAStaleUpdateBegunMarkerIsClearedByTheNextRun(t *testing.T) { f := newApplyFixture(t, "stable", "v1") os.MkdirAll(systemGuardDir(f.a.optDir), 0o755) os.WriteFile(updateBegunPath(f.a.optDir), []byte(`{"target":"v2"}`), 0o644) if err := f.a.Run(context.Background(), true); err != nil { t.Fatal(err) } if _, err := os.Stat(updateBegunPath(f.a.optDir)); !os.IsNotExist(err) { t.Fatalf("stale marker kept: %v", err) } } // A rollback that could not finish keeps its journal for the next run, and // the marker with it: the update is not over. func TestTheMarkerStaysWhileAJournalIsOpen(t *testing.T) { f := newApplyFixture(t, "stable", "v1") f.a.regionHA = true os.MkdirAll(systemGuardDir(f.a.optDir), 0o755) os.WriteFile(updateBegunPath(f.a.optDir), []byte(`{"target":"v2"}`), 0o644) // A journal recovery cannot act on: no carlos.prev to restore. if err := writeJSONDurable(f.a.file("journal.json"), systemJournal{Phase: systemPhaseInstalled, Target: "v2"}, 0o644); err != nil { t.Fatal(err) } _ = f.a.Run(context.Background(), true) if j, _ := readSystemJournal(f.a.optDir); j == nil { t.Fatal("recovery closed the journal; this test needs one it cannot finish") } if _, err := os.Stat(updateBegunPath(f.a.optDir)); err != nil { t.Fatalf("marker cleared while the journal is still open: %v", err) } } // Astra plan R1-3: participation defers, it does not refuse. Once the // operation ends and its record is cleared, the same promote applies. The // agent's side, re-requesting a deferred apply, is // TestSystemLegReusesAVerifiedCacheOnARetry. func TestApplyDeferredForParticipationAppliesOnceItEnds(t *testing.T) { f, req := applyHappy(t) f.a.regionHA = true rec := filepath.Join(hostexec(t, f), "shop.bab.test.json") os.WriteFile(rec, []byte(`{"state":"active"}`), 0o600) if err := f.a.Run(context.Background(), false); err != nil { t.Fatal(err) } if r := f.result(); r == nil || r.Outcome != systemOutcomeDeferred || r.terminal() { t.Fatalf("result = %+v, want deferred, which the agent retries", r) } os.Remove(rec) if err := f.a.Run(context.Background(), false); err != nil { t.Fatal(err) } if r := f.result(); r == nil || r.Outcome != systemOutcomeApplied || !r.matches("stable", "v2", req.PromotedAt) { t.Fatalf("result after the operation ended = %+v, want applied", r) } }