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:
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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: "<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),
|
||||
// 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.
|
||||
|
||||
@@ -10,6 +10,23 @@ import (
|
||||
"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) {
|
||||
cases := []struct {
|
||||
where, name, stable string
|
||||
|
||||
Reference in New Issue
Block a user