From a6bc3f1197993845ed1b33b428753975884e33a5 Mon Sep 17 00:00:00 2001 From: kisfenyo Date: Sun, 4 Oct 2026 13:39:14 +0200 Subject: [PATCH] =?UTF-8?q?os-apply:=20the=20host=20restart=20scan=20no=20?= =?UTF-8?q?longer=20hides=20lxc-start=20(skip=20':/lxc/'=20not=20'lxc'),?= =?UTF-8?q?=20and=20the=20host=20scans=20on=20every=20pass=20so=20a=20rebo?= =?UTF-8?q?ot=20clears=20'reboot=20needed'=20(reboot=5Fscanned=20reaches?= =?UTF-8?q?=20the=20hub)=20=E2=80=94=20both=20found=20live=20on=20demo-fel?= =?UTF-8?q?hom?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS --- configs/felhom-os-apply | 13 ++++++++++- configs/test_felhom_os_apply.py | 38 +++++++++++++++++++++++++++++++++ internal/osupdate/leg.go | 3 +++ internal/osupdate/leg_test.go | 14 ++++++++++++ 4 files changed, 67 insertions(+), 1 deletion(-) diff --git a/configs/felhom-os-apply b/configs/felhom-os-apply index a1a5d32..64ba129 100755 --- a/configs/felhom-os-apply +++ b/configs/felhom-os-apply @@ -58,6 +58,11 @@ INSTALL_STATE = "/var/lib/felhom-install/state.json" # reboot is needed for them to take effect, and a bad one can stop the box from booting. HOST_SLOW_RE = re.compile(r"^(linux-(image|headers|kbuild|modules|base)|proxmox-kernel|proxmox-default-kernel|pve-kernel|" r"pve-firmware|firmware-|grub|shim|systemd-boot|intel-microcode|amd64-microcode|efibootmgr)") +# restart_needed() leaves out processes whose cgroup line matches (grep basic regex). Host: the LXC guests' own +# processes (`0::/lxc//...`) -- NOT lxc-start itself, whose cgroup is `0::/lxc.monitor/` (measured +# 2026-10-04 on demo-felhom: the old pattern "lxc" hid lxc-start with 20 deleted maps, so "reboot needed" stayed false +# after a libc6 update). Pinned by test_restart_skip_patterns_against_real_cgroups. +RESTART_SKIP_CGROUP = {"guest": "docker", "host": ":/lxc/"} HOST_SERVICES = ["pveproxy", "pvedaemon", "pvestatd", "pve-cluster", "felhom-agent"] @@ -310,7 +315,7 @@ class Apply: def restart_needed(self): """Processes still mapping deleted files, OUTSIDE containers (C11). Guest: outside docker; host: outside the LXC guests (the host's /proc shows guest processes too).""" - skip = "docker" if self.layer == "guest" else "lxc" + skip = RESTART_SKIP_CGROUP[self.layer] script = ('for p in /proc/[0-9]*; do grep -q "(deleted)" $p/maps 2>/dev/null || continue; ' 'grep -q "%s" $p/cgroup 2>/dev/null && continue; echo "${p#/proc/} $(cat $p/comm 2>/dev/null)"; done' % skip) rc, out, _ = self.x(["sh", "-c", script], timeout=120) @@ -392,6 +397,12 @@ class Apply: if rc: return rc self.report.update(self.inventory(installed_after)) + if self.layer == "host" and "reboot_needed" not in self.report: + # The host is scanned on EVERY pass (local, no pct exec): a reboot must CLEAR "reboot needed", or the hub's + # 14-day alarm fires on a host that was rebooted long ago. The guest still scans only after an install + # (R-845; one pct exec, and no alarm reads it). Pinned by test_host_scans_every_pass_guest_only_after_install. + self.report["restart_needed"], self.report["reboot_needed"] = self.restart_needed() + self.report["reboot_scanned"] = "reboot_needed" in self.report self.report["health_after"] = self.health() return 0 diff --git a/configs/test_felhom_os_apply.py b/configs/test_felhom_os_apply.py index 500514e..e409162 100644 --- a/configs/test_felhom_os_apply.py +++ b/configs/test_felhom_os_apply.py @@ -551,6 +551,27 @@ class HostLayer(unittest.TestCase): rc, rep = run(f) self.assertTrue(rep["reboot_needed"], rep) + def test_host_scans_every_pass_guest_only_after_install(self): + # A host pass that installs nothing still scans, so a reboot clears the flag (the hub alarm reads it). + f = Fake() + f.plan["layer"] = "host" + f.plan["mode"] = "inventory" + f.restart_out = "2101 lxc-start\n" + rc, rep = run(f) + self.assertEqual(rc, 0, rep) + self.assertTrue(rep["reboot_scanned"], rep) + self.assertTrue(rep["reboot_needed"], rep) + f = Fake() + f.plan["layer"] = "host" + f.plan["mode"] = "inventory" + f.restart_out = "" # after the reboot: nothing maps a deleted file + rc, rep = run(f) + self.assertTrue(rep["reboot_scanned"] and rep["reboot_needed"] is False, rep) + f = Fake() + f.plan["mode"] = "inventory" + rc, rep = run(f) + self.assertFalse(rep["reboot_scanned"], "the guest scans only after an install (R-845)") + def test_no_reboot_for_ordinary_daemons(self): f = Fake() f.plan["layer"] = "host" @@ -586,5 +607,22 @@ class Failure(unittest.TestCase): self.assertTrue(any(l.startswith("os-apply: FAILED rc=100 step=install") for l in f.logs)) + +class RestartSkipPattern(unittest.TestCase): + """The cgroup filter runs as `grep -q PATTERN /proc//cgroup`; check it with grep itself against the cgroup + lines measured on demo-felhom 2026-10-04.""" + + def grep(self, pattern, line): + return subprocess.run(["grep", "-q", pattern], input=line + "\n", text=True).returncode == 0 + + def test_restart_skip_patterns_against_real_cgroups(self): + host = osapply.RESTART_SKIP_CGROUP["host"] + self.assertTrue(self.grep(host, "0::/lxc/9201/ns/system.slice/docker.service"), "a guest process must be skipped") + self.assertFalse(self.grep(host, "0::/lxc.monitor/9201"), "lxc-start must NOT be skipped (it runs the guest)") + self.assertFalse(self.grep(host, "0::/system.slice/pve-cluster.service"), "a host daemon must NOT be skipped") + guest = osapply.RESTART_SKIP_CGROUP["guest"] + self.assertTrue(self.grep(guest, "0::/system.slice/docker-0123abcd.scope")) + self.assertFalse(self.grep(guest, "0::/system.slice/cron.service")) + if __name__ == "__main__": unittest.main() diff --git a/internal/osupdate/leg.go b/internal/osupdate/leg.go index 58b0197..f8f2d7b 100644 --- a/internal/osupdate/leg.go +++ b/internal/osupdate/leg.go @@ -84,6 +84,7 @@ type WrapperReport struct { RestartNeeded []string `json:"restart_needed"` DockerRestartNeeded bool `json:"docker_restart_needed"` RebootNeeded bool `json:"reboot_needed"` + RebootScanned bool `json:"reboot_scanned"` HealthBefore *Health `json:"health_before"` HealthAfter *Health `json:"health_after"` Health *Health `json:"health"` @@ -112,6 +113,7 @@ type Report struct { RestartNeeded []string `json:"restart_needed,omitempty"` DockerRestartNeeded bool `json:"docker_restart_needed,omitempty"` RebootNeeded bool `json:"reboot_needed,omitempty"` + RebootScanned bool `json:"reboot_scanned,omitempty"` // the pass looked (host: every pass) — a false RebootNeeded then means "not needed" Refused json.RawMessage `json:"refused,omitempty"` PassSeconds float64 `json:"pass_seconds,omitempty"` } @@ -461,6 +463,7 @@ func (l *Leg) runLayer(ctx context.Context, runID, layer string, vmid int, trigg } rep.Installed, rep.Pending = wr.Installed, wr.Pending rep.RestartNeeded, rep.DockerRestartNeeded, rep.RebootNeeded = wr.RestartNeeded, wr.DockerRestartNeeded, wr.RebootNeeded + rep.RebootScanned = wr.RebootScanned rep.NotCovered = notCovered(wr.Pending, blk.Ring, planned) return l.finish(ctx, lg, rep) } diff --git a/internal/osupdate/leg_test.go b/internal/osupdate/leg_test.go index 6b0876e..f4dfb7b 100644 --- a/internal/osupdate/leg_test.go +++ b/internal/osupdate/leg_test.go @@ -353,3 +353,17 @@ func TestWrapperSuite(t *testing.T) { t.Fatalf("wrapper suite did not report OK:\n%s", out) } } + +// The host's restart scan result reaches the hub with reboot_scanned, so the hub can tell "looked: not needed" (a +// reboot cleared it) from "did not look". Red-proof: drop the RebootScanned copy in runLayer and this fails. +func TestHostReport_CarriesRebootScanned(t *testing.T) { + w := &fakeWrapper{t: t, applyRep: map[string]WrapperReport{ + LayerGuest: {}, + LayerHost: {RebootScanned: true, RebootNeeded: true, RestartNeeded: []string{"lxc-start"}}, + }} + l, h := newLeg(t, w, &hub.WireOSUpdate{Ring: 0, Enabled: true}) + l.Run(context.Background(), 9201, "night") + if len(h.reports) != 2 || !h.reports[1].RebootScanned || !h.reports[1].RebootNeeded || h.reports[0].RebootScanned { + t.Fatalf("hub got %+v", h.reports) + } +}