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
+57 -18
View File
@@ -3,6 +3,7 @@ package localapi
import (
"context"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
@@ -394,6 +395,10 @@ type fakeGuestAttacher struct {
vmid int
slot string
}
attachDrives []string // where passed to AttachDrive (intermediary model)
detachDrives []string // where passed to DetachDrive
ensureParentN int // EnsureSharedParent call count
attachDriveFail bool // when set, AttachDrive returns an error
}
func (f *fakeGuestAttacher) AttachBind(_ context.Context, vmid int, mountKey, where string) error {
@@ -407,6 +412,35 @@ func (f *fakeGuestAttacher) AttachBind(_ context.Context, vmid int, mountKey, wh
}
func (f *fakeGuestAttacher) count() int { f.mu.Lock(); defer f.mu.Unlock(); return len(f.calls) }
func (f *fakeGuestAttacher) AttachDrive(_ context.Context, where string) (string, error) {
f.mu.Lock()
defer f.mu.Unlock()
if f.attachDriveFail {
return "", fmt.Errorf("fake attach-drive failure")
}
f.attachDrives = append(f.attachDrives, where)
return StablePathForRaw(where), nil
}
func (f *fakeGuestAttacher) attachDriveCount() int {
f.mu.Lock()
defer f.mu.Unlock()
return len(f.attachDrives)
}
func (f *fakeGuestAttacher) DetachDrive(_ context.Context, where string) error {
f.mu.Lock()
defer f.mu.Unlock()
f.detachDrives = append(f.detachDrives, where)
return nil
}
func (f *fakeGuestAttacher) EnsureSharedParent(_ context.Context) error {
f.mu.Lock()
defer f.mu.Unlock()
f.ensureParentN++
return nil
}
func (f *fakeGuestAttacher) DetachBind(_ context.Context, vmid int, mountKey string) error {
f.mu.Lock()
defer f.mu.Unlock()
@@ -444,8 +478,12 @@ func newAttachServer(t *testing.T, ga GuestAttacher, mounts map[int]map[string]s
return srv.Handler()
}
// A first attach picks the lowest free slot (mp0; mp9 bootstrap is taken) and calls the binder.
func TestGuestAttach_PicksFreeSlotAndBinds(t *testing.T) {
// Intermediary model: a guest-attach binds the drive's felhom-data under the shared parent (host-side)
// and returns its STABLE guest path — no pct slot. EnsureSharedParent runs first.
//
// COMPANION GUARD: the legacy `pct set -mpN` AttachBind must NOT be used in the intermediary model — a
// pre-fix impl that still called AttachBind would trip ga.count()!=0 here.
func TestGuestAttach_BindsUnderParent(t *testing.T) {
ga := &fakeGuestAttacher{}
h := newAttachServer(t, ga, map[int]map[string]string{
8200: {"mp9": "/var/lib/.../bootstrap,mp=/etc/felhom-bootstrap,ro=1"},
@@ -454,26 +492,27 @@ func TestGuestAttach_PicksFreeSlotAndBinds(t *testing.T) {
if w.Code != http.StatusOK {
t.Fatalf("attach: got %d want 200 (%s)", w.Code, w.Body.String())
}
if ga.count() != 1 || ga.calls[0].slot != "mp0" || ga.calls[0].where != "/mnt/felhom-usb" || ga.calls[0].vmid != 8200 {
t.Fatalf("AttachBind not called with mp0/where/vmid: %+v", ga.calls)
if ga.attachDriveCount() != 1 || ga.attachDrives[0] != "/mnt/felhom-usb" {
t.Fatalf("AttachDrive not called with /mnt/felhom-usb: %+v", ga.attachDrives)
}
if ga.ensureParentN < 1 {
t.Fatalf("EnsureSharedParent must run before binding the drive")
}
if !strings.Contains(w.Body.String(), `"guest_path":"/mnt/felhom-drives/felhom-usb"`) {
t.Fatalf("response missing the stable guest_path: %s", w.Body.String())
}
if ga.count() != 0 {
t.Fatalf("legacy pct AttachBind called (%d) — intermediary model must use host-side AttachDrive", ga.count())
}
}
// An already-bound drive is idempotent: returns the existing slot, binder NOT called again.
func TestGuestAttach_Idempotent(t *testing.T) {
ga := &fakeGuestAttacher{}
h := newAttachServer(t, ga, map[int]map[string]string{
8200: {"mp0": "/mnt/felhom-usb/felhom-data,mp=/mnt/felhom-usb"},
})
// An AttachDrive failure surfaces as 502 (no silent success).
func TestGuestAttach_AttachDriveFailure(t *testing.T) {
ga := &fakeGuestAttacher{attachDriveFail: true}
h := newAttachServer(t, ga, map[int]map[string]string{8200: {}})
w := do(t, h, "POST", "/disks/guest-attach", "A", `{"where":"/mnt/felhom-usb"}`)
if w.Code != http.StatusOK {
t.Fatalf("idempotent attach: got %d want 200 (%s)", w.Code, w.Body.String())
}
if !strings.Contains(w.Body.String(), `"already":true`) {
t.Fatalf("expected already:true: %s", w.Body.String())
}
if ga.count() != 0 {
t.Fatalf("AttachBind must NOT be called for an already-bound drive: %+v", ga.calls)
if w.Code != http.StatusBadGateway {
t.Fatalf("attach failure: got %d want 502 (%s)", w.Code, w.Body.String())
}
}