agent v0.34.0: intermediary mount model — shared parent + host-side attach/detach + reconcile

Replaces the per-drive 'pct set -mpN' bind with ONE permanent parent bind
/mnt/felhom-drives plus host-side felhom-data swaps underneath it (propagates
into the running guest live, no pct, no reboot; C1-immune; confined; fail-closed
when absent). EnsureSharedParent installs a boot unit ordered Before=pve-guests.
ReassertGuestBinds is now a pure host-side reconcile. /disks reports GuestPath +
BoundUnderParent for the controller repoint+gate. Non-hollow tests + companions.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-15 16:29:32 +02:00
parent 44cdf82631
commit 3a9be73875
11 changed files with 528 additions and 131 deletions
+88 -73
View File
@@ -67,9 +67,18 @@ type GuestLister interface {
// mount (slice 10 P2, Model A) and reboots the guest to activate persisted-but-inactive binds (the
// host-side live inject is blocked on unprivileged guests). Satisfied by *GuestBinder.
type GuestAttacher interface {
// AttachDrive (intermediary model) binds the drive's felhom-data under the shared parent so it
// appears live in the guest at the returned stable path — no pct, no reboot. `where` = raw /mnt/<name>.
AttachDrive(ctx context.Context, where string) (guestPath string, err error)
// DetachDrive unmounts the drive's felhom-data from the shared parent (live, fail-closed).
DetachDrive(ctx context.Context, where string) error
// EnsureSharedParent makes the host stable parent shared + installs the boot-persistence unit.
EnsureSharedParent(ctx context.Context) error
// 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
// DetachBind removes a mountpoint slot from the guest config (the decommission C1 fix — a removed
// bind can't brick the next boot with a missing source). Runs on the running guest (no start lock).
// DetachBind removes a LEGACY mountpoint slot from the guest config (the decommission C1 fix — a
// removed bind can't brick the next boot with a missing source). Runs on the running guest.
DetachBind(ctx context.Context, vmid int, mountKey string) error
RebootGuest(ctx context.Context, vmid int) error
}
@@ -120,8 +129,18 @@ type DiskInfo struct {
// GuestAttached reports whether THIS guest (the token's vmid) actually has the drive's felhom-data
// namespace bound into its config — i.e. the drive is usable IN the guest, not merely present on the
// host. F9: host presence (State=attached) != guest-usable; this is the missing signal that made the
// HDD look available when it wasn't bound. Only meaningful for user-data drives.
// HDD look available when it wasn't bound. Only meaningful for user-data drives. LEGACY (per-drive
// `pct set -mpN` model) — the intermediary model uses BoundUnderParent.
GuestAttached bool `json:"guest_attached"`
// GuestPath is the drive's STABLE in-guest path in the intermediary-mount model
// (/mnt/felhom-drives/<name>). This is what the controller repoints HDD_PATH to and registers as the
// storage path. Set for /mnt/<name> drives; "" otherwise. Distinct from MountPath (the RAW host PVE
// mount the agent ops on).
GuestPath string `json:"guest_path,omitempty"`
// BoundUnderParent reports whether the drive's felhom-data is currently bound under the shared parent
// at GuestPath (a host mount-table check) — i.e. live + usable in the guest in the intermediary model.
// The controller's drive-absent gate + auto-restart key on this (and State).
BoundUnderParent bool `json:"bound_under_parent"`
}
// handleDisks lists the host's drives + data-bearing flags (read-only/benign).
@@ -153,6 +172,14 @@ func (s *Server) handleDisks(w http.ResponseWriter, r *http.Request, vmid int) {
UsedFraction: t.UsedFraction,
GuestAttached: t.MountPath != "" && boundPaths[t.MountPath],
}
// Intermediary model: the stable in-guest path + whether felhom-data is bound under the parent.
// Only user-data /mnt/<name> drives have a guest path (system/backup mounts never cross in).
if di.Role == string(storage.RoleUserData) {
if gp := StablePathForRaw(t.MountPath); gp != "" {
di.GuestPath = gp
di.BoundUnderParent = s.boundUnderParent(gp)
}
}
// Inspect the backing device for the UI's data-bearing hint (the authoritative check
// is re-run at format time on the actual device).
if t.BackingDevice != "" {
@@ -245,6 +272,13 @@ func (s *Server) handleDiskEject(w http.ResponseWriter, r *http.Request, vmid in
// self-heal watchdog leaves it alone — an OFFICIAL eject is the only thing that sets this (P3); an
// out-of-band unmount records nothing and is healed.
s.recordIntent(r.Context(), req.Where, "ejected")
// Intermediary model: unmount the felhom-data bind from under the shared parent FIRST (live, fail-
// closed in the guest), so nothing references the raw mount before we unmount it.
if s.guestAttach != nil {
if err := s.guestAttach.DetachDrive(r.Context(), req.Where); err != nil {
s.logger.Warn("local-api: eject guest-detach (intermediary) failed", "vmid", vmid, "where", req.Where, "err", err)
}
}
if err := s.disks.Unmount(r.Context(), req.Where); err != nil {
s.logger.Error("local-api: disk eject", "vmid", vmid, "where", req.Where, "err", err)
writeErr(w, http.StatusBadRequest, "eject failed: "+err.Error())
@@ -301,9 +335,14 @@ func (s *Server) handleDiskDecommission(w http.ResponseWriter, r *http.Request,
// `mpN` in the config → pre-start mount failure → all apps down. Runs on the running guest (config
// edit, no start lock → no deadlock). Best-effort: a missing slot is already clean.
if s.guestAttach != nil {
// Intermediary model: unmount the felhom-data bind from under the shared parent (live, no reboot).
if err := s.guestAttach.DetachDrive(r.Context(), req.Where); err != nil {
s.logger.Warn("local-api: guest-detach (intermediary) failed", "vmid", vmid, "where", req.Where, "err", err)
}
// Legacy per-drive model: delete any lingering `mpN` slot (no-op post-migration; C1 fix otherwise).
if slot := s.guestSlotForPath(r.Context(), vmid, req.Where); slot != "" {
if err := s.guestAttach.DetachBind(r.Context(), vmid, slot); err != nil {
s.logger.Warn("local-api: guest-detach failed — mp left in config (reboot may brick until reconciled)",
s.logger.Warn("local-api: guest-detach (legacy mp) failed — mp left in config",
"vmid", vmid, "slot", slot, "where", req.Where, "err", err)
}
}
@@ -344,41 +383,24 @@ func (s *Server) handleDiskGuestAttach(w http.ResponseWriter, r *http.Request, v
writeErr(w, http.StatusBadRequest, "where must be an absolute /mnt/<name> path (no traversal)")
return
}
// Read the guest config for idempotency + free-slot selection.
cfg, err := s.guests.GuestConfig(r.Context(), vmid)
// Intermediary model: ensure the host shared parent, then bind the drive's felhom-data UNDER it. It
// propagates into the running guest LIVE — no pct, no slot, no reboot. AttachDrive is idempotent (a
// no-op when the stable path is already a mountpoint), so this is the re-enroll path too.
if err := s.guestAttach.EnsureSharedParent(r.Context()); err != nil {
s.logger.Warn("local-api: guest-attach — shared parent ensure failed (continuing)", "vmid", vmid, "err", err)
}
stable, err := s.guestAttach.AttachDrive(r.Context(), where)
if err != nil {
s.logger.Error("local-api: guest-attach guest config", "vmid", vmid, "err", err)
writeErr(w, http.StatusBadGateway, "could not read guest config")
return
}
mounts := cfg.MountPoints()
// Idempotency: already bound at `where`? (a bind's mp= equals the guest path). Still (re)record the
// ENROLLED intent — a drive bound at a prior boot must be known to self-heal even if never
// re-attached this process lifetime.
for key, spec := range mounts {
if _, mp, _ := parseMount(spec); mp == where {
s.recordIntent(r.Context(), where, "enrolled")
s.recordGuestBind(r.Context(), vmid, where)
s.logger.Info("local-api: guest-attach idempotent (already bound)", "vmid", vmid, "where", where, "slot", key)
writeOK(w, map[string]any{"vmid": vmid, "attached": where, "slot": key, "already": true})
return
}
}
slot, ok := freeMountSlot(mounts)
if !ok {
writeErr(w, http.StatusConflict, "no free mountpoint slot on the guest")
return
}
if err := s.guestAttach.AttachBind(r.Context(), vmid, slot, where); err != nil {
s.logger.Error("local-api: guest-attach", "vmid", vmid, "where", where, "slot", slot, "err", err)
s.logger.Error("local-api: guest-attach (intermediary)", "vmid", vmid, "where", where, "err", err)
writeErr(w, http.StatusBadGateway, "guest-attach failed: "+err.Error())
return
}
// Record the drive as ENROLLED so the self-heal watchdog will reconcile it (P3), and persist the
// per-guest bind so the startup re-assert can restore it after a re-provision (F9).
// Record the drive as ENROLLED so the self-heal reconcile keeps it bound (P3), and persist the
// per-guest bind so the startup reconcile restores it after a re-provision (F9 — now host-side only).
s.recordIntent(r.Context(), where, "enrolled")
s.recordGuestBind(r.Context(), vmid, where)
writeOK(w, map[string]any{"vmid": vmid, "attached": where, "slot": slot})
s.logger.Info("local-api: guest-attach bound under shared parent", "vmid", vmid, "where", where, "guest_path", stable)
writeOK(w, map[string]any{"vmid": vmid, "attached": where, "guest_path": stable})
}
type guestRebootRequest struct {
@@ -663,6 +685,15 @@ func (s *Server) handleDiskFormat(w http.ResponseWriter, r *http.Request, vmid i
"device is system/backup-protected — format requires an operator signature ("+dec.Reason+")")
}
// boundUnderParent reports whether a drive's felhom-data is currently bound at its stable guest path
// (intermediary model). Injectable via s.boundCheck for tests; defaults to the host mount-table read.
func (s *Server) boundUnderParent(stablePath string) bool {
if s.boundCheck != nil {
return s.boundCheck(stablePath)
}
return isHostMountpoint(stablePath)
}
// guestBoundPaths returns the set of guest mountpoint paths (the `mp=` of each entry in the guest's
// config) — i.e. the host drives actually BOUND into the guest. F9: this is the guest-attached signal
// (`GuestAttached`) that distinguishes a guest-usable drive from one merely present on the host. A bind
@@ -725,16 +756,22 @@ func (s *Server) recordGuestBind(ctx context.Context, vmid int, where string) {
s.logger.Info("local-api: guest-bind recorded for startup re-assert", "vmid", vmid, "where", where, "durable_id", id)
}
// ReassertGuestBinds re-adds, on agent startup (the host's bring-up/reconcile trigger), any enrolled
// user-data drive bind a guest is MISSING from its config (F9 — a re-provision drops the mp, and nothing
// previously restored it). For each recorded (vmid, durable-id): only when the durable-id STILL resolves
// to a present, mounted drive AND the guest lacks the bind, it re-runs AttachBind. "On durable-id match"
// — a swapped or absent drive is never auto-bound. The re-added bind is config state; it activates on the
// guest's next reboot (logged), exactly like the enroll flow. Safe to call repeatedly (idempotent).
// ReassertGuestBinds is the intermediary-model startup reconcile: for every ENROLLED + PRESENT enrolled
// drive, it ensures the drive's felhom-data is bound under the shared parent (host-side, via AttachDrive)
// so it's live in the guest. This is PURE host-side mount work — NO pct, NO guest-config read, NO reboot
// — and it fixes F9 (a re-provision drops nothing host-side) AND drive-returned reconnection for free.
// It first ensures the host shared parent exists (so a fresh boot has it before any bind). "On durable-id
// match" + intent-gated: a swapped, ejected, or decommissioned drive is never auto-bound. AttachDrive is
// idempotent (a no-op when already bound), so this is safe to call repeatedly. Runs at agent startup and
// on a drive-returned event.
func (s *Server) ReassertGuestBinds(ctx context.Context) {
if s.guestBinds == nil || s.guestAttach == nil || s.guests == nil {
if s.guestBinds == nil || s.guestAttach == nil {
return
}
// Make sure the host stable parent is shared + boot-persistent before we bind anything under it.
if err := s.guestAttach.EnsureSharedParent(ctx); err != nil {
s.logger.Warn("reconcile: shared parent ensure failed — binds may not propagate live", "err", err)
}
// durable-id -> current host mount path (present drives only), from the agent's own storage view.
mountByDurable := map[string]string{}
if targets, err := s.storage.Observe(ctx); err == nil {
@@ -744,52 +781,30 @@ func (s *Server) ReassertGuestBinds(ctx context.Context) {
}
}
} else {
s.logger.Warn("F9 re-assert: storage view unavailable — skipping", "err", err)
s.logger.Warn("reconcile: storage view unavailable — skipping", "err", err)
return
}
for vmid, ids := range s.guestBinds.Guests() {
cfg, err := s.guests.GuestConfig(ctx, vmid)
if err != nil {
s.logger.Warn("F9 re-assert: skip guest (config read failed)", "vmid", vmid, "err", err)
continue
}
mounts := cfg.MountPoints()
boundPaths := map[string]bool{}
for _, spec := range mounts {
if _, mp, _ := parseMount(spec); mp != "" {
boundPaths[mp] = true
}
}
for _, id := range ids {
// Intent-aware (B2, the load-bearing correctness fix): NEVER re-bind a drive that is not
// currently `enrolled` — an ejected or decommissioned drive must not auto-rebind into the
// guest on agent restart, even if it is still host-mounted. Covers both the self-serve and
// the operator-signed decommission paths (both land on IntentDecommissioned). A nil intent
// store falls back to legacy bind-all (ungated), matching the watchdog's nil-intent rule.
// Intent-aware (B2, load-bearing): NEVER bind a drive that is not currently `enrolled` — an
// ejected or decommissioned drive must not auto-rebind, even if still host-mounted. A nil
// intent store falls back to bind-all (ungated), matching the watchdog's nil-intent rule.
if s.intent != nil && s.intent.Get(id) != storage.IntentEnrolled {
s.logger.Warn("F9 re-assert: skipping non-enrolled drive (intent-gated)", "vmid", vmid, "durable_id", id, "intent", string(s.intent.Get(id)))
s.logger.Warn("reconcile: skipping non-enrolled drive (intent-gated)", "vmid", vmid, "durable_id", id, "intent", string(s.intent.Get(id)))
continue
}
where, present := mountByDurable[id]
if !present {
s.logger.Warn("F9 re-assert: enrolled drive not present (durable-id absent) — skipping", "vmid", vmid, "durable_id", id)
s.logger.Warn("reconcile: enrolled drive not present (durable-id absent) — skipping", "vmid", vmid, "durable_id", id)
continue
}
if boundPaths[where] {
continue // already bound — nothing to re-assert
}
slot, ok := freeMountSlot(mounts)
if !ok {
s.logger.Warn("F9 re-assert: no free mountpoint slot on guest", "vmid", vmid, "where", where)
stable, err := s.guestAttach.AttachDrive(ctx, where)
if err != nil {
s.logger.Error("reconcile: AttachDrive failed", "vmid", vmid, "where", where, "err", err)
continue
}
if err := s.guestAttach.AttachBind(ctx, vmid, slot, where); err != nil {
s.logger.Error("F9 re-assert: AttachBind failed", "vmid", vmid, "where", where, "slot", slot, "err", err)
continue
}
mounts[slot] = where // reserve the slot so a second enrolled drive takes the next one
s.logger.Warn("F9 re-assert: re-attached enrolled drive into guest config (reboot to activate)",
"vmid", vmid, "where", where, "slot", slot, "durable_id", id)
s.logger.Info("reconcile: enrolled drive bound under shared parent (live, no reboot)",
"vmid", vmid, "where", where, "guest_path", stable, "durable_id", id)
}
}
}