agent v0.36.5: AttachDrive normalizes to exactly one bind (converges stacked binds)
countHostMounts + normalize: no-op only when exactly one bind is guest-visible; else strip all binds and lay one fresh. Converges a stacked double-bind to one (the old umount-one+mount-one never did). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -3,6 +3,13 @@
|
|||||||
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.5 — AttachDrive normalizes to exactly one bind (2026-06-15)
|
||||||
|
|
||||||
|
AttachDrive now COUNTS the binds at a stable path (countHostMounts) and normalizes to exactly one: it is
|
||||||
|
a no-op only when there is exactly ONE bind the guest sees; otherwise it strips ALL existing binds
|
||||||
|
(bounded loop) and lays down one fresh bind. This converges a stacked double-bind to one — the old
|
||||||
|
umount-one+mount-one force-rebind never did. Caught when a double-bind survived a guest reboot.
|
||||||
|
|
||||||
## v0.36.4 — serialize AttachDrive/DetachDrive (no double-bind race) (2026-06-15)
|
## v0.36.4 — serialize AttachDrive/DetachDrive (no double-bind race) (2026-06-15)
|
||||||
|
|
||||||
A mutex on GuestBinder serializes AttachDrive/DetachDrive so a controller-triggered reconnect and the
|
A mutex on GuestBinder serializes AttachDrive/DetachDrive so a controller-triggered reconnect and the
|
||||||
|
|||||||
@@ -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.36.4"
|
var version = "0.36.5"
|
||||||
|
|
||||||
// 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
|
||||||
|
|||||||
@@ -169,29 +169,49 @@ func (b *GuestBinder) AttachDrive(ctx context.Context, vmid int, where string) (
|
|||||||
if err := b.run(ctx, "mkdir", "-p", stable); err != nil {
|
if err := b.run(ctx, "mkdir", "-p", stable); err != nil {
|
||||||
return "", fmt.Errorf("guest-attach: stable dir %s: %w", stable, err)
|
return "", fmt.Errorf("guest-attach: stable dir %s: %w", stable, err)
|
||||||
}
|
}
|
||||||
hostHas := isHostMountpoint(stable)
|
// NORMALIZE to EXACTLY ONE bind. The target is usable only when there is exactly one bind AND the
|
||||||
guestSees := b.GuestSeesMount(ctx, vmid, stable)
|
// guest sees it; in that case this is a no-op. Otherwise (zero binds, a post-reboot bind the guest
|
||||||
switch {
|
// can't see, OR stacked duplicate binds from an earlier race/operator action) we strip ALL existing
|
||||||
case hostHas && guestSees:
|
// binds (bounded loop) and lay down exactly one fresh bind — which also re-fires propagation into the
|
||||||
return stable, nil // fully live — no-op
|
// current guest namespace. Counting (countHostMounts) rather than a boolean isHostMountpoint is what
|
||||||
case hostHas && !guestSees:
|
// makes this converge a double-bind to one (the old umount-one+mount-one never did).
|
||||||
// Guest rebooted (or bound the parent before this drive's bind existed): re-fire propagation.
|
n := countHostMounts(stable)
|
||||||
if err := b.run(ctx, "umount", stable); err != nil {
|
if n == 1 && b.GuestSeesMount(ctx, vmid, stable) {
|
||||||
b.logger.Warn("guest-attach: re-bind umount failed (continuing to re-mount)", "stable", stable, "err", err)
|
return stable, nil // exactly one bind + guest-visible → fully live, no-op
|
||||||
}
|
|
||||||
if err := b.run(ctx, "mount", "--bind", src, stable); err != nil {
|
|
||||||
return "", fmt.Errorf("guest-attach: re-bind %s -> %s: %w", src, stable, err)
|
|
||||||
}
|
|
||||||
b.logger.Info("guest-attach: re-bound drive to re-propagate into guest (post-reboot)", "vmid", vmid, "where", where, "stable", stable)
|
|
||||||
default: // !hostHas
|
|
||||||
if err := b.run(ctx, "mount", "--bind", src, stable); err != nil {
|
|
||||||
return "", fmt.Errorf("guest-attach: bind %s -> %s: %w", src, stable, err)
|
|
||||||
}
|
|
||||||
b.logger.Info("guest-attach: drive bound under shared parent (live, no reboot)", "vmid", vmid, "where", where, "stable", stable)
|
|
||||||
}
|
}
|
||||||
|
for i := 0; i < 16 && countHostMounts(stable) > 0; i++ {
|
||||||
|
if err := b.run(ctx, "umount", stable); err != nil {
|
||||||
|
b.logger.Warn("guest-attach: normalize umount failed (continuing)", "stable", stable, "err", err)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := b.run(ctx, "mount", "--bind", src, stable); err != nil {
|
||||||
|
return "", fmt.Errorf("guest-attach: bind %s -> %s: %w", src, stable, err)
|
||||||
|
}
|
||||||
|
b.logger.Info("guest-attach: drive bound under shared parent (normalized to one bind, live)", "vmid", vmid, "where", where, "stable", stable, "prior_binds", n)
|
||||||
return stable, nil
|
return stable, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// countHostMounts returns how many times `path` appears as a mount target in /proc/self/mountinfo (i.e.
|
||||||
|
// how many stacked binds are at it). 0 = not mounted; >1 = stacked duplicates. Used to normalize to one.
|
||||||
|
func countHostMounts(path string) int {
|
||||||
|
f, err := os.Open("/proc/self/mountinfo")
|
||||||
|
if err != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
n := 0
|
||||||
|
sc := bufio.NewScanner(f)
|
||||||
|
sc.Buffer(make([]byte, 0, 64*1024), 1024*1024)
|
||||||
|
for sc.Scan() {
|
||||||
|
fields := strings.Fields(sc.Text())
|
||||||
|
if len(fields) >= 5 && fields[4] == path {
|
||||||
|
n++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
// GuestSeesMount reports whether vmid's guest currently has `path` as a mount target in ITS mount
|
// GuestSeesMount reports whether vmid's guest currently has `path` as a mount target in ITS mount
|
||||||
// namespace (read from /proc/<guest-init-pid>/mountinfo). This is the GUEST-side truth the host-side
|
// namespace (read from /proc/<guest-init-pid>/mountinfo). This is the GUEST-side truth the host-side
|
||||||
// isHostMountpoint can't see — the signal that distinguishes "bound on the host" from "live in the
|
// isHostMountpoint can't see — the signal that distinguishes "bound on the host" from "live in the
|
||||||
|
|||||||
Reference in New Issue
Block a user