Compare commits

...

3 Commits

Author SHA1 Message Date
admin 56ef1d6655 v0.145.0 code: the OS wrapper repairs dpkg's update journal by itself after a power cut (R-876); restore-test first check 30 min after start (R-874); neutral "sent late" text (R-875)
gates / gates (push) Successful in 20s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-05 09:23:23 +02:00
admin 78c890e4bb REPORT: the 2026-10-05 night-fixes session
gates / gates (push) Successful in 21s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-05 08:25:49 +02:00
admin d48f1bbb23 v0.144.1: CHANGELOG (released 6ccd521d…, bundle e89a9ddf…)
gates / gates (push) Successful in 21s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-05 07:49:29 +02:00
8 changed files with 224 additions and 31 deletions
+18
View File
@@ -1,3 +1,21 @@
## v0.144.1 — a killed pass really keeps its report: the wrapper survives a dead reader; the agent looks again every 5 minutes (R-868, measured live) (2026-10-05)
Released by `scripts/release-agent.sh`: binary sha256 `6ccd521d47e64999017e8eb5bc613d724543cfdc5ef9b13bcae9e3ea8c53b8f3`,
config bundle sha256 `e89a9ddfb767e177f8874d56f3dcd3bfd47409d157ff830d362653333bf815e8`. The wrapper changed again:
a box needs the signed `agent_update` AND the signed `agent_config_update`.
- **Found live on demo-hp 2026-10-05 05:45 UTC with v0.144.0** (the night's A5 shape: kill -9 of the pass and the
daemon while apt-get ran): apt finished all 13 packages, but the wrapper's next log line went to a stderr pipe no
process read any more → `BrokenPipeError` → the wrapper died before it saved its report copy (journal: `PLAN
upgrade=13`, then nothing; no copy; the hub got nothing). v0.144.0's mechanism was right and never reached.
`Runner.log` and the final `OSAPPLY-REPORT` line now survive a dead reader (the journal still gets every line).
Test `AgentDiesMidPass` drives the REAL `log()` into a pipe that breaks while apt-get runs.
- **Also found live:** the restarted daemon looked for kept copies ~7 s before the orphaned wrapper wrote one. The
daemon now looks at start and every 5 minutes (`Leg.SendUnsentLoop`); `TestR868_ACopyWrittenAfterTheStartIsSentByTheLoop`.
- Red-proofs: `felhom.eu/documentation/audits/night-fixes-2026-10-05/partD/r868-brokenpipe-red-proof.txt`.
- A second agent release in one session, against "one release per repo": recorded as `09` decision 108 (operator may
reverse) — the alternative was to ship a fix proven not to work.
## v0.144.0 — R8 measures the real download; an OS pass reports even when its agent was killed; the debug pass runs with the hub away (R-865, R-868, R-866) (2026-10-05)
Released by `scripts/release-agent.sh`: binary sha256 `f18093c3466749ec4cd47f83f97a401160a24bad1e704f1183051adad14db928`,
+16 -16
View File
@@ -1,19 +1,19 @@
# REPORT — agent v0.143.0: the config bundle (R-840) — 2026-10-04
# REPORT — agent v0.144.0 + v0.144.1 (2026-10-05): the night's fixes
**What:** a signed route for a box's root-owned files. `felhom-os-apply` gained mode `bundle` (signed
`agent_config_update`, verified by the wrapper itself) and `--install-bundle` (the installer's root entry);
`BUNDLE_FILES` is the one table of 22 paths; `scripts/build-config-bundle.py` builds it reproducibly;
`release-agent.sh` publishes it beside the binary; the agent reports `system.config_bundle`; `felhom-opsign` signs it.
Also `build-golden.sh` 3.2.0 (`GOLDEN_GUEST_PKGS`).
Brief: the 2026-10-05 night-fixes brief (operator), Parts C, D, E. Full session report:
`felhom.eu/REPORT-night-fixes-2026-10-05.md`. Architecture read: `11-os-updates.md` (§5.4.1 R8, §8.1–8.3).
**Released:** v0.143.0, binary `41c0d306…`, bundle `8d7273cf…` (reproducible), vouched in the hub with golden 0.293.0.
**Delivered:** demo-hp and demo-felhom (signed `agent_update`), then the one-file bootstrap, then the bundle by the
signed route (0 written of 22; probe 71/71). demo-hp also: a wrong sha refused, a one-line change and its undo, a replay
rejected. Tester 2: the signed `agent_update` queued (operator ruling 97); its bundle waits for the operator's
bootstrap (R-862).
| Row | Fix | Proof |
|---|---|---|
| R-865 | R8: `--print-uris` WITHOUT `-s` (with `-s` apt lists no URIs → 0 B) | fake answers like real apt (verbatim 9202 output); red-proof; live: the installed wrapper read 12 802 456 B for 13 pending upgrades on demo-hp |
| R-868 | the wrapper keeps its apply report beside the plan; the agent sends kept copies (start + every 5 min) and deletes them; pass lock (flock) | v0.144.0 measured NOT to work live (the wrapper died on a broken stderr pipe); v0.144.1: survives a dead reader + the 5-min look; live A5 shape on demo-hp → ONE `applied` report (13 packages) at the hub |
| R-866 | the daemon saves the hub's block; the selftest uses it with the hub away and says so | live on demo-felhom with the hub blackholed: `block=SAVED(…)`, pass ran; its kept reports reached the hub at the next start |
**Tests:** `configs/test_felhom_config_bundle.py` 43 (22 of 22 mutants red), Go `internal/osupdate/bundle_test.go`,
`internal/hub/bundle_record_test.go`; `go vet ./... && go test ./...` rc=0; gates green.
**Found:** R-861 — the sudoers already lets the agent user reach root (read, not exploited).
Evidence and the full report: `felhom.eu/documentation/audits/r840-config-bundle-2026-10-04/`,
`felhom.eu/REPORT-r840-config-bundle-2026-10-04.md`.
Released by `scripts/release-agent.sh`: v0.144.0 (`f18093c3…`, bundle `6acf42fe…`) and v0.144.1 (`6ccd521d…`, bundle
`e89a9ddf…`), both verified by download. Delivered by signed `agent_update` + `agent_config_update` to demo-hp,
demo-felhom and tester-1 (71/71 capability probe after each bundle). Vouched: agent 0.144.1, golden 0.294.0,
min_agent 0.131.0. A second release in one session is `09` decision 108.
**Found, not fixed: R-876 (P2)** — after a power cut mid-update (Part E, demo-hp) `dpkg --audit` is clean but dpkg's
update journal is not; `repair()` skips, every pass fails until `dpkg --configure -a` by hand. Next agent release.
Also R-875 (P4): the kept-report reason text is wrong for the hub-away case.
+30 -7
View File
@@ -63,6 +63,9 @@ SNAPSHOT_LIST = "/etc/apt/sources.list.d/felhom-os-snapshot.list"
APT_ENV = ["env", "DEBIAN_FRONTEND=noninteractive", "APT_LISTCHANGES_FRONTEND=none", "NEEDRESTART_MODE=l", "LC_ALL=C"]
DPKG_OPTS = ["-o", "Dpkg::Options::=--force-confold", "-o", "Dpkg::Options::=--force-confdef"]
MIN_FREE = 500 * 1024 * 1024
# R-876: dpkg's state in ONE call — `--audit`, a marker line, then the update journal's file names.
JOURNAL_MARK = "@@FELHOM-DPKG-JOURNAL@@"
DPKG_STATE_SCRIPT = "dpkg --audit; echo " + JOURNAL_MARK + "; ls -A /var/lib/dpkg/updates 2>/dev/null; true"
# The installer's ROOT-OWNED record (felhom-host-install.sh `state_set mode`); the agent cannot write it.
INSTALL_STATE = "/var/lib/felhom-install/state.json"
# Kernel, boot and firmware packages are the SLOW lane on the host whatever their origin (`11` C3, §5.2): a host
@@ -902,20 +905,34 @@ class Apply:
self.report["health_after"] = self.health()
return 0
def repair(self):
rc, before, _ = self.x(["dpkg", "--audit"])
def dpkg_state(self):
"""`dpkg --audit` AND dpkg's update journal, in ONE call (R-876, agent v0.145.0). A crash in the middle of an
install can leave `/var/lib/dpkg/updates/` non-empty while `--audit` reads clean — measured on demo-hp
2026-10-05 — and that journal is exactly what apt refuses on ("dpkg was interrupted"). One `sh -c` with a
constant script keeps R-845's speed: a clean pass still costs one call here, as before."""
rc, out, _ = self.x(["sh", "-c", DPKG_STATE_SCRIPT])
audit, _, journal = out.partition(JOURNAL_MARK + "\n")
return audit, [l for l in journal.split() if l]
def repair(self, force=False):
before, journal = self.dpkg_state()
configured = len([l for l in before.splitlines() if l.startswith(" ")])
fixed = 0
after = ""
if before.strip(): # nothing half-done → nothing to run (R-845: two calls saved on every clean pass)
after, journal_after = "", []
# nothing half-done and no update journal → nothing to run (R-845: two calls saved on every clean pass).
# R-876: the JOURNAL counts too, and `force` (apt said "dpkg was interrupted") always repairs.
if before.strip() or journal or force:
self.x(APT_ENV + ["dpkg", "--configure", "-a", "--force-confold"])
rc2, out, err = self.x(APT_ENV + ["apt-get", "-f", "install", "-y", "-q"] + DPKG_OPTS)
_, after, _ = self.x(["dpkg", "--audit"])
after, journal_after = self.dpkg_state()
fixed = len(re.findall(r"^Setting up ", out, re.M))
self.report["repair"] = {"half_configured_before": configured, "fixed": fixed, "clean_after": after.strip() == ""}
self.r.log(f"os-apply: REPAIR configured={configured} fixed={fixed}")
self.report["repair"] = {"half_configured_before": configured, "journal_before": len(journal), "fixed": fixed,
"clean_after": after.strip() == "" and not journal_after}
self.r.log(f"os-apply: REPAIR configured={configured} journal={len(journal)} fixed={fixed}" + (" forced" if force else ""))
if after.strip():
raise Refused("R13", "dpkg is still broken after the repair: " + after.strip().splitlines()[0])
if journal_after:
raise Refused("R13", f"dpkg's update journal is still not empty after the repair ({len(journal_after)} file(s))")
def pending_fast(self):
"""Ring 0 (select pending-fast): every pending upgrade of an INSTALLED package whose every origin is Debian /
@@ -1032,6 +1049,12 @@ class Apply:
raise Refused("R8", f"free space {free} B is below max(500 MB, 3 x download {need} B)")
t0 = time.time()
rc, out, err = self.x(APT_ENV + ["apt-get", "-y", "-q"] + DPKG_OPTS + args)
if rc != 0 and "dpkg was interrupted" in (out + err):
# R-876 (belt): apt says dpkg was interrupted although the repair found nothing — repair and
# retry ONCE. Never a loop.
self.r.log("os-apply: INTERRUPTED apt says dpkg was interrupted — repairing and retrying once")
self.repair(force=True)
rc, out, err = self.x(APT_ENV + ["apt-get", "-y", "-q"] + DPKG_OPTS + args)
secs = time.time() - t0
# dpkg says "Installing new version of config file X" when X was NOT changed locally (the package's new
# version is taken), and "Configuration file 'X'" + "Keeping old config file" when it was (--force-confold
+56
View File
@@ -154,6 +154,8 @@ class Fake:
if cmd == "dpkg" and a[1] == "--audit":
return 0, self.dpkg_audit, ""
if cmd == "dpkg" and a[1] == "--configure":
self.configured_calls = getattr(self, "configured_calls", 0) + 1
self.dpkg_journal = [] # `dpkg --configure -a` replays and empties the update journal
return 0, "", ""
if cmd == "fuser":
return (0, " 123", "") if self.lock_held else (1, "", "")
@@ -176,6 +178,9 @@ class Fake:
if "--print-uris" in a or "-s" in a:
return self.sim(a) # --print-uris prints and installs nothing, with or without -s (9202, 2026-10-05)
if "install" in a:
if getattr(self, "dpkg_journal", []):
# real apt (demo-hp 2026-10-05): a non-empty update journal refuses every install
return 100, "", "E: dpkg was interrupted, you must manually run 'sudo dpkg --configure -a' to correct the problem.\n"
if self.install_rc:
return self.install_rc, "", "E: boom"
for x in a:
@@ -220,6 +225,8 @@ class Fake:
return 0, "", ""
if cmd == "getent":
return 0, "1.2.3.4 deb.debian.org\n", ""
if cmd == "sh" and a[2] == osapply.DPKG_STATE_SCRIPT:
return 0, self.dpkg_audit + osapply.JOURNAL_MARK + "\n" + "".join(j + "\n" for j in getattr(self, "dpkg_journal", [])), ""
if cmd == "sh":
if "vmlinuz" in a[2]:
return 0, "/boot/vmlinuz-7.0.2-6-pve\n/boot/vmlinuz-7.0.14-20-pve\n", ""
@@ -1123,3 +1130,52 @@ class AgentDiesMidPass(unittest.TestCase):
self.assertEqual(len(f.saved_reports), 1, "the kept copy is the only way this report reaches the hub")
self.assertEqual(len(f.saved_reports[0][1]["upgraded"]), 2)
self.assertTrue(any(l.startswith("os-apply: DONE") for l in journal), "the journal must still get the DONE line")
class CrashLeftTheJournal(unittest.TestCase):
"""R-876 — THE MEASURED SHAPE (demo-hp 2026-10-05, a crash while dpkg unpacked): `dpkg --audit` CLEAN, but
/var/lib/dpkg/updates holds 3 files; apt refuses every install ("dpkg was interrupted"). v0.144.1 logged
`REPAIR configured=0 fixed=0`, then `FAILED rc=100`, every pass, until a person ran `dpkg --configure -a`.
COMPANION RED-PROOFS: drop `or journal` from repair()'s condition -> test 1 fails; drop the interrupted-retry
-> test 3 fails; make repair() always run -> test 2 fails (R-845's speed)."""
def test_the_next_pass_repairs_by_itself_and_finishes(self):
f = Fake()
f.dpkg_audit = ""
f.dpkg_journal = ["0000", "0001", "0002"]
rc, rep = run(f)
self.assertEqual(rc, 0, rep)
self.assertEqual(rep["repair"]["journal_before"], 3)
self.assertTrue(rep["repair"]["clean_after"])
self.assertEqual(len(rep["upgraded"]), 2)
cfg = next(i for i, c in enumerate(f.calls) if c[0] == "guest" and "--configure" in c[2])
inst = next(i for i, c in enumerate(f.calls) if c[0] == "guest" and "install" in c[2] and "-s" not in c[2]
and "-f" not in c[2] and "--print-uris" not in c[2])
self.assertLess(cfg, inst, "the repair must run before the install")
self.assertFalse(any("INTERRUPTED" in l for l in f.logs), "the journal check must catch it BEFORE apt refuses")
def test_a_clean_pass_still_costs_one_state_call(self):
f = Fake()
rc, rep = run(f)
self.assertEqual(rc, 0, rep)
state = [c for c in f.calls if c[0] == "guest" and c[2][:2] == ["sh", "-c"] and c[2][2] == osapply.DPKG_STATE_SCRIPT]
self.assertEqual(len(state), 1, "R-845: a clean pass reads dpkg's state ONCE and runs no repair")
self.assertEqual(getattr(f, "configured_calls", 0), 0)
def test_apt_interrupted_is_repaired_and_retried_once(self):
"""The belt: the journal probe saw nothing (a race, an odd layout), apt still says interrupted."""
f = Fake()
orig = f.emulate
state = {"first": True}
def emulate(argv):
a = [x for x in argv if not re.match(r"^[A-Z_]+=", x) and x != "env"]
if a[0] == "apt-get" and "install" in a and "-s" not in a and "-f" not in a and "--print-uris" not in a and state["first"]:
state["first"] = False
return 100, "", "E: dpkg was interrupted, you must manually run 'sudo dpkg --configure -a' to correct the problem.\n"
return orig(argv)
f.emulate = emulate
rc, rep = run(f)
self.assertEqual(rc, 0, rep)
self.assertTrue(any("INTERRUPTED" in l for l in f.logs), f.logs)
self.assertTrue(any(l.startswith("os-apply: REPAIR ") and l.endswith("forced") for l in f.logs), f.logs)
+63
View File
@@ -0,0 +1,63 @@
package backup
import (
"context"
"fmt"
"sync/atomic"
"testing"
"time"
"gitea.dooplex.hu/admin/felhom-agent/internal/reconcile"
)
// R-874 (v0.145.0). THE MEASURED SHAPE (Part F spike, Tester 2): power-on sessions of ~1.5 h and ~5 min against a
// 6 h evaluation ticker that restarts at every start — no restore-test ever evaluated. Now the first evaluation runs
// FirstEval after start.
// COMPANION RED-PROOF: drop the first-evaluation timer in Run (back to the bare ticker) → "no evaluation within".
func TestR874_FirstEvaluationAfterStart(t *testing.T) {
var picks int32
s := NewScheduler(SchedulerOptions{
Runner: &fakeRTRunner{res: reconcile.RestoreTestResult{Pass: true, Verified: "boot+running"}},
Pick: func(context.Context) (string, error) {
atomic.AddInt32(&picks, 1)
return fmt.Sprintf("local:backup/vzdump-lxc-9201-%d.tar.zst", atomic.LoadInt32(&picks)), nil
},
Store: NewStore(), Spec: (&specSpy{}).build,
Cadence: 6 * time.Hour, FirstEval: 30 * time.Millisecond, Logger: quiet(),
})
ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
go func() { _ = s.Run(ctx); close(done) }()
deadline := time.Now().Add(3 * time.Second)
for atomic.LoadInt32(&picks) == 0 && time.Now().Before(deadline) {
time.Sleep(10 * time.Millisecond)
}
cancel()
<-done
if atomic.LoadInt32(&picks) == 0 {
t.Fatal("no evaluation within 3 s of start (FirstEval 30 ms) — a box with short sessions never gets a restore-test")
}
}
// The earned restraint stays: an agent that restarts before FirstEval never evaluates (a crash loop does not
// hammer a failing tier).
func TestR874_CrashLoopNeverEvaluates(t *testing.T) {
var picks int32
for i := 0; i < 5; i++ { // five quick "restarts"
s := NewScheduler(SchedulerOptions{
Runner: &fakeRTRunner{res: reconcile.RestoreTestResult{Pass: true}},
Pick: func(context.Context) (string, error) { atomic.AddInt32(&picks, 1); return "x", nil },
Store: NewStore(), Spec: (&specSpy{}).build,
Cadence: 6 * time.Hour, FirstEval: 200 * time.Millisecond, Logger: quiet(),
})
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond)
_ = s.Run(ctx)
cancel()
}
if n := atomic.LoadInt32(&picks); n != 0 {
t.Fatalf("a restart before FirstEval evaluated %d time(s)", n)
}
if DefaultFirstEval != 30*time.Minute {
t.Fatalf("DefaultFirstEval = %s, the documented 30 min", DefaultFirstEval)
}
}
+34 -6
View File
@@ -60,12 +60,21 @@ type Scheduler struct {
// R-85 tier rotation. All optional: without them the scheduler behaves exactly as before
// (single tier via `pick`), which keeps every existing caller and test working untouched.
tiers []string // configured tier target ids, primary first
tierPick TierPicker // newest archive on a named tier
rtState *RestoreTestState // persisted last-successful-per-tier (drives oldest-first)
inFlight *InFlight // shared with the backup path — Scenario F
tiers []string // configured tier target ids, primary first
tierPick TierPicker // newest archive on a named tier
rtState *RestoreTestState // persisted last-successful-per-tier (drives oldest-first)
inFlight *InFlight // shared with the backup path — Scenario F
firstEval time.Duration // R-874: the first evaluation after start
}
// DefaultFirstEval (R-874): the first due-ness evaluation runs 30 minutes after the agent starts, then every
// cadence. MEASURED need (2026-10-05 Part F spike): a box whose power-on sessions are all shorter than the 6 h
// interval (Tester 2: ~1.5 h and ~5 min) NEVER evaluated, because the ticker restarts at each start. 30 minutes
// keeps the earned restraint below — a crash-looping agent restarts far more often than that and still never
// evaluates — while a box that stays on for half an hour gets its due test. Pinned by
// TestR874_FirstEvaluationAfterStart and TestR874_CrashLoopNeverEvaluates.
const DefaultFirstEval = 30 * time.Minute
// SchedulerOptions configures a Scheduler.
type SchedulerOptions struct {
Runner RestoreTestRunner
@@ -81,6 +90,8 @@ type SchedulerOptions struct {
// 0 → no settle requirement (any archive is a candidate).
Settle time.Duration
Logger *slog.Logger
// FirstEval (R-874, v0.145.0) is when the FIRST evaluation runs after start; 0 → DefaultFirstEval.
FirstEval time.Duration
// R-85 (all optional — omit for the pre-R-85 single-tier behaviour):
// Tiers are the configured tier target ids (primary first); TierPick resolves an archive on a
@@ -110,6 +121,12 @@ func NewScheduler(opts SchedulerOptions) *Scheduler {
tierPick: opts.TierPick,
rtState: opts.State,
inFlight: opts.InFlight,
firstEval: func() time.Duration {
if opts.FirstEval > 0 {
return opts.FirstEval
}
return DefaultFirstEval
}(),
}
}
@@ -120,8 +137,9 @@ func NewScheduler(opts SchedulerOptions) *Scheduler {
// trigger any more: its phase is the process's uptime, and agent deploys reset it, which is exactly
// the defect R-86 removes. What decides that a test happens is `EvaluateDue`.
//
// It still does NOT evaluate immediately on start — the first evaluation is one interval in. That
// is an EARNED restraint, kept deliberately: a restore is heavy, agent restarts are routine, and a
// It still does NOT evaluate immediately on start. v0.145.0 (R-874): the first evaluation is
// firstEval (30 min) in, then every interval — it was one full interval in, which a box with short
// power-on sessions never reached. The restraint itself is EARNED and kept: a restore is heavy, agent restarts are routine, and a
// crash-loop that evaluated at start would hammer a permanently-failing tier as fast as it could
// restart. Due-ness does not expire while we wait, so the only cost is up to one interval of
// latency on a tier that just became due. On-demand runs use `--selftest=restore-test`.
@@ -135,6 +153,16 @@ func (s *Scheduler) Run(ctx context.Context) error {
}
s.logger.Info("backup: restore-test scheduler starting (per-archive due-check)",
"eval_interval", s.cadence, "settle", s.settle)
first := time.NewTimer(s.firstEval)
defer first.Stop()
select {
case <-ctx.Done():
s.logger.Info("backup: restore-test scheduler shutting down", "reason", ctx.Err())
return nil
case <-first.C:
s.logger.Info("backup: restore-test first evaluation after start (R-874)", "after", s.firstEval)
s.tick(ctx)
}
t := time.NewTicker(s.cadence)
defer t.Stop()
for {
+2 -2
View File
@@ -101,7 +101,7 @@ func (l *Leg) sendUnsentLocked(ctx context.Context) int {
}
rep := l.reportFromKept(ctx, wr, f)
lg := l.log().With("run", rep.RunID, "layer", rep.Layer, "vmid", rep.VMID, "ring", rep.Ring, "trigger", rep.Trigger)
lg.Info("osupdate: sending a report the agent never sent (the agent stopped mid-pass, R-868)", "path", f)
lg.Info("osupdate: sending a kept report late (the agent was stopped mid-pass or the hub was away, R-868)", "path", f)
before := rep.unsent
_ = l.finish(ctx, lg, rep)
if _, err := os.Stat(before); os.IsNotExist(err) {
@@ -130,7 +130,7 @@ func (l *Leg) reportFromKept(ctx context.Context, wr WrapperReport, path string)
}
rep := Report{RunID: runID, Layer: wr.Layer, Trigger: trigger, Mode: wr.Mode, Ring: ring, ReleaseID: wr.ReleaseID,
VMID: wr.VMID, unsent: path}
prefix := "sent after the agent stopped mid-pass (R-868)"
prefix := "sent late — kept on the box until the hub could take it (R-868, R-875)"
switch {
case wr.refused():
rep.Outcome, rep.Refused, rep.HealthReason = "refused", wr.Refused, prefix
+5
View File
@@ -6,6 +6,7 @@ import (
"errors"
"os"
"path/filepath"
"strings"
"testing"
"time"
@@ -38,6 +39,10 @@ func TestR868_KilledPassIsReportedAtStart(t *testing.T) {
if r.Outcome != "applied" || !r.Healthy || r.Trigger != "debug" || r.RunID != kept.RunID || r.Ring != 0 || len(r.Upgraded) != 2 || r.VMID != 9201 {
t.Fatalf("report = %+v", r)
}
// R-875 (v0.145.0): neutral — the copy cannot tell a killed agent from an absent hub.
if !strings.HasPrefix(r.HealthReason, "sent late") || strings.Contains(r.HealthReason, "stopped mid-pass") {
t.Fatalf("health_reason = %q, want the neutral \"sent late …\"", r.HealthReason)
}
for _, p := range []string{rp, pp} {
if _, err := os.Stat(p); !os.IsNotExist(err) {
t.Fatalf("%s still on disk after the hub got it", filepath.Base(p))