agent v0.36.0: guest boot-id on /disks (deterministic guest-reboot recreate)

GET /disks emits guest_boot_id = <host-btime>-<guest-init-starttime>: 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) <noreply@anthropic.com>
This commit is contained in:
2026-06-15 19:13:15 +02:00
parent 9b4ae3df28
commit 05be509e6e
6 changed files with 101 additions and 2 deletions
+9
View File
@@ -3,6 +3,15 @@
All notable changes to **felhom-agent** are recorded here. Update on every code All notable changes to **felhom-agent** are recorded here. Update on every code
change that gets pushed. 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: `<host-btime>-<guest-init-starttime>` — 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/<init-pid>/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) ## 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 Fix for the host-reboot ordering (the shared-parent oneshot never ran before pve-guests on the live
+1 -1
View File
@@ -44,7 +44,7 @@ import (
// version is the agent version. Overridable at build time with // version is the agent version. Overridable at build time with
// -ldflags "-X main.version=<v>"; defaults to the in-repo CHANGELOG version. // -ldflags "-X main.version=<v>"; 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 <vmid> <phase>`). On the // runGuestHook is the PVE pre-start hook body (`felhom-agent guest-hook <vmid> <phase>`). On the
// pre-start phase it creates placeholder dirs for any absent bind-mount source so the guest always boots // pre-start phase it creates placeholder dirs for any absent bind-mount source so the guest always boots
+10 -1
View File
@@ -78,6 +78,9 @@ type GuestAttacher interface {
// GuestSeesMount reports whether vmid's guest sees `path` as a mount in its own namespace (the // 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. // guest-usable signal — distinct from the host having the bind). Backs BoundUnderParent.
GuestSeesMount(ctx context.Context, vmid int, path string) bool 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 // AttachBind is the LEGACY per-drive `pct set -mpN` bind (pre-intermediary). Retained for the
// transition; new attaches use AttachDrive. // transition; new attaches use AttachDrive.
AttachBind(ctx context.Context, vmid int, mountKey, where string) error 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) 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 { type assignRequest struct {
+1
View File
@@ -422,6 +422,7 @@ func (f *fakeGuestAttacher) AttachDrive(_ context.Context, _ int, where string)
return StablePathForRaw(where), nil return StablePathForRaw(where), nil
} }
func (f *fakeGuestAttacher) GuestSeesMount(_ context.Context, _ int, _ string) bool { return true } 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 { func (f *fakeGuestAttacher) attachDriveCount() int {
f.mu.Lock() f.mu.Lock()
defer f.mu.Unlock() defer f.mu.Unlock()
+63
View File
@@ -222,6 +222,69 @@ func (b *GuestBinder) guestInitPID(ctx context.Context, vmid int) string {
return strings.TrimSpace(string(out)) 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: "<host-btime>-<guest-init-starttime>". 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/<pid>/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/<pid>/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/<pid>/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), // 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, // 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. // since host uid 0 is unmapped). No pct, no reboot. Idempotent: a non-mountpoint is a no-op.
+17
View File
@@ -10,6 +10,23 @@ import (
"gitea.dooplex.hu/admin/felhom-agent/internal/storage" "gitea.dooplex.hu/admin/felhom-agent/internal/storage"
) )
// TestStarttimeFromStat parses field 22 (starttime) from a /proc/<pid>/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) { func TestStablePathForRaw_DriveName(t *testing.T) {
cases := []struct { cases := []struct {
where, name, stable string where, name, stable string