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]) } }