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:
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user