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:
+88
-73
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user