F9: auto-re-assert enrolled guest data-drive binds on agent startup
The in-guest bind (pct set -mpN) is config state that a destroy+re-provision drops, and nothing restored it — so a re-provisioned guest came up with its enrolled HDD unattached (the live-drive F9 finding). New GuestBindStore persists, per guest, the durable-ids of enrolled drives (recorded at guest-attach); ReassertGuestBinds runs on agent startup (the host's bring-up/reconcile trigger) and re-adds any bind a guest is MISSING — but ONLY when the durable-id still resolves to a present, mounted drive (a swapped/absent drive is never auto-bound) and the guest lacks it (idempotent). The re-added bind activates on the guest's next reboot, like the enroll flow. Wired in main.go (store opened beside drive-intents.json; ReassertGuestBinds called before the local API serves). Tests: restores a missing bind with no manual call (the operator's real-trigger proof); skips absent/swapped durable-id; no-op when already bound; store survives reopen (restart).
This commit is contained in:
@@ -0,0 +1,126 @@
|
||||
package localapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"log/slog"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-agent/internal/hub"
|
||||
)
|
||||
|
||||
func tempBindStore(t *testing.T) *GuestBindStore {
|
||||
t.Helper()
|
||||
gb, err := OpenGuestBindStore(filepath.Join(t.TempDir(), "guest-binds.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return gb
|
||||
}
|
||||
|
||||
func reassertServer(t *testing.T, ga GuestAttacher, sv StorageView, mounts map[int]map[string]string, gb *GuestBindStore) *Server {
|
||||
t.Helper()
|
||||
srv, err := NewServer(Options{
|
||||
ListenAddr: "127.0.0.1:0",
|
||||
Guests: &fakeGuestsCfg{mounts: mounts},
|
||||
Backups: &fakeBackups{}, Store: &fakeStore{},
|
||||
Storage: sv, Tokens: staticTokens{"A": 8200},
|
||||
GuestAttach: ga, GuestBinds: gb,
|
||||
Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
srv.baseCtx = context.Background()
|
||||
return srv
|
||||
}
|
||||
|
||||
// usbPresent is a storage view with felhom-usb present at /mnt/felhom-usb (durable uuid:usb-1).
|
||||
func usbPresent() fakeStorage {
|
||||
return fakeStorage{targets: []hub.StorageTarget{
|
||||
{Name: "usb", Type: hub.StorageTypeUSB, BackingDevice: "/dev/sdb1", MountPath: "/mnt/felhom-usb", DurableID: "uuid:usb-1"},
|
||||
}}
|
||||
}
|
||||
|
||||
// TestReassertGuestBinds_RestoresMissingBind is the F9 core proof (the operator's "fire the real
|
||||
// trigger" requirement): an enrolled drive that is present on the host but MISSING from the guest config
|
||||
// (the post-re-provision gap) is auto-re-attached on the startup re-assert — with NO manual guest-attach
|
||||
// call. Fails on the pre-fix code (no re-assert existed).
|
||||
func TestReassertGuestBinds_RestoresMissingBind(t *testing.T) {
|
||||
gb := tempBindStore(t)
|
||||
if err := gb.Record(8200, "uuid:usb-1"); err != nil { // enrolled at a prior boot
|
||||
t.Fatal(err)
|
||||
}
|
||||
ga := &fakeGuestAttacher{}
|
||||
// guest 8200 has docker-data only — the felhom-usb bind was dropped by the re-provision.
|
||||
srv := reassertServer(t, ga, usbPresent(), map[int]map[string]string{
|
||||
8200: {"mp0": "local-lvm:8,mp=/var/lib/docker"},
|
||||
}, gb)
|
||||
|
||||
srv.ReassertGuestBinds(context.Background())
|
||||
|
||||
if ga.count() != 1 {
|
||||
t.Fatalf("AttachBind called %d times, want 1 (auto-re-assert on startup)", ga.count())
|
||||
}
|
||||
if ga.calls[0].vmid != 8200 || ga.calls[0].where != "/mnt/felhom-usb" {
|
||||
t.Fatalf("re-asserted bind = %+v, want vmid 8200 where /mnt/felhom-usb", ga.calls[0])
|
||||
}
|
||||
}
|
||||
|
||||
// TestReassertGuestBinds_SkipsAbsentDurable: an enrolled drive whose durable-id is NOT currently present
|
||||
// (unplugged / swapped for a different disk) must NOT be auto-bound — the "on durable-id match" safety.
|
||||
func TestReassertGuestBinds_SkipsAbsentDurable(t *testing.T) {
|
||||
gb := tempBindStore(t)
|
||||
_ = gb.Record(8200, "uuid:usb-1")
|
||||
ga := &fakeGuestAttacher{}
|
||||
srv := reassertServer(t, ga, fakeStorage{}, map[int]map[string]string{ // empty storage view → absent
|
||||
8200: {"mp0": "local-lvm:8,mp=/var/lib/docker"},
|
||||
}, gb)
|
||||
|
||||
srv.ReassertGuestBinds(context.Background())
|
||||
if ga.count() != 0 {
|
||||
t.Fatalf("AttachBind called %d times — must NOT auto-bind an absent/swapped drive", ga.count())
|
||||
}
|
||||
}
|
||||
|
||||
// TestReassertGuestBinds_SkipsAlreadyBound: when the guest already has the bind, the re-assert is a no-op.
|
||||
func TestReassertGuestBinds_SkipsAlreadyBound(t *testing.T) {
|
||||
gb := tempBindStore(t)
|
||||
_ = gb.Record(8200, "uuid:usb-1")
|
||||
ga := &fakeGuestAttacher{}
|
||||
srv := reassertServer(t, ga, usbPresent(), map[int]map[string]string{
|
||||
8200: {"mp0": "local-lvm:8,mp=/var/lib/docker", "mp3": "/mnt/felhom-usb/felhom-data,mp=/mnt/felhom-usb"},
|
||||
}, gb)
|
||||
|
||||
srv.ReassertGuestBinds(context.Background())
|
||||
if ga.count() != 0 {
|
||||
t.Fatalf("AttachBind called %d times — already bound, must be a no-op", ga.count())
|
||||
}
|
||||
}
|
||||
|
||||
// TestGuestBindStore_Persist round-trips the store across reopen (the record must survive an agent
|
||||
// restart, since that is exactly when the re-assert runs).
|
||||
func TestGuestBindStore_Persist(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "guest-binds.json")
|
||||
gb, err := OpenGuestBindStore(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = gb.Record(8200, "uuid:usb-1")
|
||||
_ = gb.Record(8200, "uuid:usb-1") // idempotent
|
||||
_ = gb.Record(9300, "byid:wwn-x")
|
||||
|
||||
re, err := OpenGuestBindStore(path) // simulate restart
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
g := re.Guests()
|
||||
if len(g[8200]) != 1 || g[8200][0] != "uuid:usb-1" {
|
||||
t.Fatalf("vmid 8200 = %v, want [uuid:usb-1]", g[8200])
|
||||
}
|
||||
if len(g[9300]) != 1 || g[9300][0] != "byid:wwn-x" {
|
||||
t.Fatalf("vmid 9300 = %v, want [byid:wwn-x]", g[9300])
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user