From 05be509e6ed748e7eba6d94a2e2145fc40cd87eb Mon Sep 17 00:00:00 2001 From: kisfenyo Date: Mon, 15 Jun 2026 19:13:15 +0200 Subject: [PATCH] agent v0.36.0: guest boot-id on /disks (deterministic guest-reboot recreate) GET /disks emits guest_boot_id = -: changes on every guest/host boot, stable across controller-only restarts. The controller persists it + deterministically recreates drive-backed apps on change (replaces the timed state-sample). Non-hollow parser test + companion. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 9 ++++ cmd/felhom-agent/main.go | 2 +- internal/localapi/disks.go | 11 ++++- internal/localapi/disks_test.go | 1 + internal/localapi/intermediary.go | 63 ++++++++++++++++++++++++++ internal/localapi/intermediary_test.go | 17 +++++++ 6 files changed, 101 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b5fae69..9a3dc6b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,15 @@ All notable changes to **felhom-agent** are recorded here. Update on every code change that gets pushed. +## v0.36.0 — guest boot-id on /disks (deterministic guest-reboot recreate) (2026-06-15) + +The agent now emits `guest_boot_id` on GET /disks: `-` — changes on +every guest boot (host reboot OR guest reboot) but is STABLE across a controller-only restart. The +controller persists the last-seen value and DETERMINISTICALLY recreates drive-backed apps when it +changes (replacing the fragile timed state-sample that could miss an app stopped at the sample instant). +`GuestBootID` reads `/proc/stat` btime + field 22 of `/proc//stat` (parsed after the last +`)` so a comm with spaces/parens does not break it). + ## v0.35.1 — shared-parent unit: run before pve-guests on host boot (2026-06-15) Fix for the host-reboot ordering (the shared-parent oneshot never ran before pve-guests on the live diff --git a/cmd/felhom-agent/main.go b/cmd/felhom-agent/main.go index 4e4b3e0..9961edd 100644 --- a/cmd/felhom-agent/main.go +++ b/cmd/felhom-agent/main.go @@ -44,7 +44,7 @@ import ( // version is the agent version. Overridable at build time with // -ldflags "-X main.version="; defaults to the in-repo CHANGELOG version. -var version = "0.35.1" +var version = "0.36.0" // runGuestHook is the PVE pre-start hook body (`felhom-agent guest-hook `). On the // pre-start phase it creates placeholder dirs for any absent bind-mount source so the guest always boots diff --git a/internal/localapi/disks.go b/internal/localapi/disks.go index 4fe419f..e72f72f 100644 --- a/internal/localapi/disks.go +++ b/internal/localapi/disks.go @@ -78,6 +78,9 @@ type GuestAttacher interface { // GuestSeesMount reports whether vmid's guest sees `path` as a mount in its own namespace (the // guest-usable signal — distinct from the host having the bind). Backs BoundUnderParent. GuestSeesMount(ctx context.Context, vmid int, path string) bool + // GuestBootID returns a token that changes on every guest boot (host or guest) but is stable across a + // controller-only restart — the deterministic guest-reboot signal the controller recreates apps on. + GuestBootID(ctx context.Context, vmid int) string // AttachBind is the LEGACY per-drive `pct set -mpN` bind (pre-intermediary). Retained for the // transition; new attaches use AttachDrive. AttachBind(ctx context.Context, vmid int, mountKey, where string) error @@ -202,7 +205,13 @@ func (s *Server) handleDisks(w http.ResponseWriter, r *http.Request, vmid int) { } out = append(out, di) } - writeOK(w, map[string]any{"vmid": vmid, "disks": out}) + // Guest boot-id (intermediary model): changes on every guest boot, stable across controller restarts. + // The controller persists it and deterministically recreates drive-backed apps when it changes. + bootID := "" + if s.guestAttach != nil { + bootID = s.guestAttach.GuestBootID(r.Context(), vmid) + } + writeOK(w, map[string]any{"vmid": vmid, "disks": out, "guest_boot_id": bootID}) } type assignRequest struct { diff --git a/internal/localapi/disks_test.go b/internal/localapi/disks_test.go index 021ab69..983ddd5 100644 --- a/internal/localapi/disks_test.go +++ b/internal/localapi/disks_test.go @@ -422,6 +422,7 @@ func (f *fakeGuestAttacher) AttachDrive(_ context.Context, _ int, where string) return StablePathForRaw(where), nil } func (f *fakeGuestAttacher) GuestSeesMount(_ context.Context, _ int, _ string) bool { return true } +func (f *fakeGuestAttacher) GuestBootID(_ context.Context, _ int) string { return "boot-1" } func (f *fakeGuestAttacher) attachDriveCount() int { f.mu.Lock() defer f.mu.Unlock() diff --git a/internal/localapi/intermediary.go b/internal/localapi/intermediary.go index e69c022..293c331 100644 --- a/internal/localapi/intermediary.go +++ b/internal/localapi/intermediary.go @@ -222,6 +222,69 @@ func (b *GuestBinder) guestInitPID(ctx context.Context, vmid int) string { return strings.TrimSpace(string(out)) } +// GuestBootID returns a token that CHANGES on every guest boot (host reboot or guest reboot) but is +// STABLE across a controller-only restart: "-". The controller persists +// the last-seen value and, when it changes, DETERMINISTICALLY recreates drive-backed apps (they may have +// auto-started on the empty stable bind before the agent re-propagated the drive). host-btime (epoch of +// the host boot, /proc/stat) changes on a host reboot; the guest init's starttime (field 22 of +// /proc//stat — ticks since host boot, unique per process launch) changes on a guest reboot. "" +// on any read error (the controller then keeps its last-seen → no spurious recreate). +func (b *GuestBinder) GuestBootID(ctx context.Context, vmid int) string { + pid := b.guestInitPID(ctx, vmid) + if pid == "" { + return "" + } + start := procStarttime(pid) + if start == "" { + return "" + } + bt := hostBtime() + if bt == "" { + bt = "0" + } + return bt + "-" + start +} + +// procStarttime returns field 22 (starttime) of /proc//stat. The comm field (2) can contain spaces +// and parentheses, so we split AFTER the last ')': field 22 is index 19 of the post-comm fields +// (field 3 = state is index 0). "" on any error. +func procStarttime(pid string) string { + data, err := os.ReadFile("/proc/" + pid + "/stat") + if err != nil { + return "" + } + return starttimeFromStat(string(data)) +} + +// starttimeFromStat is the pure parser: field 22 (starttime) of a /proc//stat body. The comm field +// (2) can contain spaces and parentheses, so split AFTER the LAST ')': field 22 is index 19 of the +// post-comm fields (field 3 = state is index 0). "" on a malformed line. +func starttimeFromStat(s string) string { + rp := strings.LastIndexByte(s, ')') + if rp < 0 || rp+2 > len(s) { + return "" + } + fields := strings.Fields(s[rp+1:]) // fields[0] == state (field 3) + if len(fields) < 20 { + return "" + } + return fields[19] // field 22 (starttime) +} + +// hostBtime returns the host boot time (epoch seconds) from /proc/stat's "btime" line. "" on error. +func hostBtime() string { + data, err := os.ReadFile("/proc/stat") + if err != nil { + return "" + } + for _, line := range strings.Split(string(data), "\n") { + if strings.HasPrefix(line, "btime ") { + return strings.TrimSpace(strings.TrimPrefix(line, "btime ")) + } + } + return "" +} + // DetachDrive unmounts a drive's felhom-data from the stable parent (propagates OUT of the guest live), // leaving the bare HOST-ROOT-owned stable dir → fail-closed (the guest can't write to it even as root, // since host uid 0 is unmapped). No pct, no reboot. Idempotent: a non-mountpoint is a no-op. diff --git a/internal/localapi/intermediary_test.go b/internal/localapi/intermediary_test.go index 38f97fd..d66cee7 100644 --- a/internal/localapi/intermediary_test.go +++ b/internal/localapi/intermediary_test.go @@ -10,6 +10,23 @@ import ( "gitea.dooplex.hu/admin/felhom-agent/internal/storage" ) +// TestStarttimeFromStat parses field 22 (starttime) from a /proc//stat line whose comm contains +// spaces AND an inner ')' — the case naive whitespace-splitting (or split-on-FIRST-')') mis-parses. +// +// COMPANION GUARD: a pre-fix impl that splits the whole line on whitespace reads the wrong field (the +// multi-token comm shifts every index); one that splits on the FIRST ')' splits inside the comm. Both +// return != "9988776655" here. +func TestStarttimeFromStat(t *testing.T) { + // pid=42, comm="(weird ) name)" (spaces + an inner ')'), state 'S', then fields; field 22 = 9988776655. + stat := "42 (weird ) name) S 1 42 42 0 -1 4194560 100 0 0 0 5 6 0 0 20 0 1 0 9988776655 12345 67 1 1 1 0 0 0 0\n" + if got := starttimeFromStat(stat); got != "9988776655" { + t.Fatalf("starttimeFromStat = %q, want 9988776655 (comm with spaces+')' must not break the parse)", got) + } + if got := starttimeFromStat("garbage no parens"); got != "" { + t.Fatalf("malformed stat should yield \"\", got %q", got) + } +} + func TestStablePathForRaw_DriveName(t *testing.T) { cases := []struct { where, name, stable string