v0.55.0: raw-device discovery + registry-sourced drive tracking (Impl-2a)
GET /disks/candidates enumerates host disks the Impl-1 unclaimed filter proves free (init/attach split). RegistryKnownTargets sources the watchdog's known-drive set from the intent registry + Felhom .mount units (not Observe/PVE storages) — decouples drive health from PVE storage (closes the registry-only false-detach class); Observe kept for real PVE storages + a deduped /disks union. Idempotent existing-drive migration at start. Tests + red-proof (Observe misses a registry-only drive; registry provider tracks it). go build/vet/test clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -27,6 +27,9 @@ type DiskOps interface {
|
||||
Unmount(ctx context.Context, where string) error
|
||||
Format(ctx context.Context, device, fstype string) error
|
||||
InspectDevice(ctx context.Context, device string) (storage.DeviceProbe, error)
|
||||
// ListCandidateDisks enumerates host disks the Impl-1 unclaimed filter proves are free to enroll
|
||||
// (Impl-2a discovery). Fail-safe: a device not provably unclaimed is omitted.
|
||||
ListCandidateDisks(ctx context.Context) ([]storage.CandidateDisk, error)
|
||||
}
|
||||
|
||||
// StorageGate authorizes a DESTRUCTIVE storage op (a data-bearing wipe/format) through the
|
||||
@@ -205,6 +208,33 @@ func (s *Server) handleDisks(w http.ResponseWriter, r *http.Request, vmid int) {
|
||||
}
|
||||
out = append(out, di)
|
||||
}
|
||||
// Impl-2a: union in registry+units drives that Observe() does NOT surface (a drive with no PVE
|
||||
// dir-storage). Additive + deduped by mount path — an existing drive already shown via Observe is
|
||||
// NOT duplicated, and no Observe row is dropped (so this can never regress the current view).
|
||||
if s.driveTargets != nil {
|
||||
seen := make(map[string]bool, len(out))
|
||||
for _, d := range out {
|
||||
if d.MountPath != "" {
|
||||
seen[d.MountPath] = true
|
||||
}
|
||||
}
|
||||
if drives, derr := s.driveTargets.Known(r.Context()); derr == nil {
|
||||
for _, d := range drives {
|
||||
if d.MountPath == "" || seen[d.MountPath] {
|
||||
continue
|
||||
}
|
||||
out = append(out, DiskInfo{
|
||||
Name: d.Name, Type: d.Type, State: "attached",
|
||||
MountPath: d.MountPath, DurableID: d.DurableID,
|
||||
Role: string(storage.RoleUserData),
|
||||
GuestAttached: boundPaths[d.MountPath],
|
||||
})
|
||||
}
|
||||
} else {
|
||||
s.logger.Warn("disks: registry drive union skipped", "err", derr)
|
||||
}
|
||||
}
|
||||
|
||||
// Guest boot-id (intermediary model): changes on every guest boot, stable across controller restarts.
|
||||
// The controller persists it and deterministically recreates drive-backed apps when it changes.
|
||||
bootID := ""
|
||||
@@ -214,6 +244,31 @@ func (s *Server) handleDisks(w http.ResponseWriter, r *http.Request, vmid int) {
|
||||
writeOK(w, map[string]any{"vmid": vmid, "disks": out, "guest_boot_id": bootID})
|
||||
}
|
||||
|
||||
// handleDiskCandidates (Impl-2a) lists the host disks FREE for Felhom to enroll — the raw-device
|
||||
// discovery the enrollment wizard (Impl-2b) consumes. Read-only + benign (the Impl-1 filter never
|
||||
// offers a claimed/OS disk). Response splits into `initialize` (all unclaimed) and `attach` (the subset
|
||||
// already carrying a mountable FS). GET /disks/candidates.
|
||||
func (s *Server) handleDiskCandidates(w http.ResponseWriter, r *http.Request, vmid int) {
|
||||
if s.disks == nil {
|
||||
writeErr(w, http.StatusServiceUnavailable, "disk management not configured on this host")
|
||||
return
|
||||
}
|
||||
cands, err := s.disks.ListCandidateDisks(r.Context())
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusBadGateway, "could not scan host disks")
|
||||
return
|
||||
}
|
||||
initialize := make([]storage.CandidateDisk, 0, len(cands))
|
||||
attach := make([]storage.CandidateDisk, 0)
|
||||
for _, c := range cands {
|
||||
initialize = append(initialize, c) // every unclaimed disk can be initialized (format → enroll)
|
||||
if c.Mountable {
|
||||
attach = append(attach, c) // already has a mountable FS → attach without formatting
|
||||
}
|
||||
}
|
||||
writeOK(w, map[string]any{"vmid": vmid, "initialize": initialize, "attach": attach})
|
||||
}
|
||||
|
||||
type assignRequest struct {
|
||||
VMID int `json:"vmid"`
|
||||
UUID string `json:"uuid"`
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
package localapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-agent/internal/storage"
|
||||
)
|
||||
|
||||
// handleDiskCandidates splits discovery into initialize (all unclaimed) + attach (mountable-FS subset).
|
||||
func TestDiskCandidates_Split(t *testing.T) {
|
||||
d := &fakeDiskOps{candidates: []storage.CandidateDisk{
|
||||
{Device: "/dev/sdd", SizeBytes: 64 << 30, DataBearing: false}, // blank → initialize only
|
||||
{Device: "/dev/sde", FSType: "ext4", DataBearing: true, Mountable: true, MountSource: "/dev/sde1"}, // FS → init + attach
|
||||
{Device: "/dev/sdf", FSType: "ntfs", DataBearing: true, Mountable: false}, // ntfs → initialize only
|
||||
}}
|
||||
h := newDiskServer(t, d, &fakeGate{}, nil, nil)
|
||||
|
||||
w := do(t, h, "GET", "/disks/candidates", "A", "")
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("candidates: got %d want 200 (%s)", w.Code, w.Body.String())
|
||||
}
|
||||
var env struct {
|
||||
Data struct {
|
||||
Initialize []storage.CandidateDisk `json:"initialize"`
|
||||
Attach []storage.CandidateDisk `json:"attach"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &env); err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
resp := env.Data
|
||||
if len(resp.Initialize) != 3 {
|
||||
t.Fatalf("initialize must list all 3 unclaimed disks, got %d", len(resp.Initialize))
|
||||
}
|
||||
if len(resp.Attach) != 1 || resp.Attach[0].Device != "/dev/sde" {
|
||||
t.Fatalf("attach must list only the mountable-FS disk, got %+v", resp.Attach)
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,12 @@ type fakeDiskOps struct {
|
||||
formatCalls []string
|
||||
mountCalls []storage.MountSpec
|
||||
unmountCalls []string
|
||||
candidates []storage.CandidateDisk // returned by ListCandidateDisks
|
||||
candErr error
|
||||
}
|
||||
|
||||
func (f *fakeDiskOps) ListCandidateDisks(_ context.Context) ([]storage.CandidateDisk, error) {
|
||||
return f.candidates, f.candErr
|
||||
}
|
||||
|
||||
func (f *fakeDiskOps) InspectDevice(_ context.Context, device string) (storage.DeviceProbe, error) {
|
||||
|
||||
@@ -70,6 +70,9 @@ type Options struct {
|
||||
Backups BackupService
|
||||
Store BackupStore
|
||||
Storage StorageView
|
||||
// DriveTargets (Impl-2a, optional) yields registry+units-sourced drives for the /disks view, so a
|
||||
// drive with NO PVE storage still appears. Unioned with Storage.Observe (deduped by mount path).
|
||||
DriveTargets storage.KnownTargets
|
||||
Tokens TokenAuthority
|
||||
// BackupCadence is the per-guest backup interval driving GET /backup/due (slice 8B). A guest
|
||||
// is "due" when no successful backup is recorded OR the newest one is older than this. 0 → a
|
||||
@@ -154,7 +157,8 @@ type Server struct {
|
||||
guests GuestAPI
|
||||
backups BackupService
|
||||
store BackupStore
|
||||
storage StorageView
|
||||
storage StorageView
|
||||
driveTargets storage.KnownTargets // Impl-2a: registry+units drives for /disks (optional)
|
||||
tokens TokenAuthority
|
||||
cadence time.Duration
|
||||
logger *slog.Logger
|
||||
@@ -226,6 +230,7 @@ func NewServer(o Options) (*Server, error) {
|
||||
backups: o.Backups,
|
||||
store: o.Store,
|
||||
storage: o.Storage,
|
||||
driveTargets: o.DriveTargets,
|
||||
tokens: o.Tokens,
|
||||
cadence: cadence,
|
||||
logger: o.Logger,
|
||||
@@ -270,6 +275,7 @@ func (s *Server) Handler() http.Handler {
|
||||
mux.HandleFunc("GET /host/metrics", s.withGuest(s.handleHostMetrics))
|
||||
// Disk management (slice 8C) — self-scoped; format routes through the data-bearing classifier+gate.
|
||||
mux.HandleFunc("GET /disks", s.withGuest(s.handleDisks))
|
||||
mux.HandleFunc("GET /disks/candidates", s.withGuest(s.handleDiskCandidates))
|
||||
mux.HandleFunc("POST /disks/assign", s.withGuest(s.handleDiskAssign))
|
||||
mux.HandleFunc("POST /disks/eject", s.withGuest(s.handleDiskEject))
|
||||
mux.HandleFunc("POST /disks/decommission", s.withGuest(s.handleDiskDecommission))
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// CandidateDisk is one host block device that the Impl-1 unclaimed filter proved is FREE for Felhom to
|
||||
// enroll (Impl-2a discovery). The controller wizard (Impl-2b) renders these; enrollment then formats
|
||||
// (guarded, Impl-1) and/or mounts + binds. Blank disks are "initialize"-only; disks already carrying a
|
||||
// mountable FS are ALSO "attach" candidates.
|
||||
type CandidateDisk struct {
|
||||
Device string `json:"device"` // whole disk, e.g. /dev/sdd
|
||||
SizeBytes int64 `json:"size_bytes"` // 0 when unreadable
|
||||
Model string `json:"model,omitempty"`
|
||||
FSType string `json:"fstype,omitempty"` // first filesystem found ("" = blank)
|
||||
DataBearing bool `json:"data_bearing"` // has any FS / partition (→ wipe-confirm downstream)
|
||||
Mountable bool `json:"mountable"` // carries an ext4/xfs FS the agent can mount as-is
|
||||
MountSource string `json:"mount_source,omitempty"` // the node to mount for attach (e.g. /dev/sdd1)
|
||||
DurableID string `json:"durable_id,omitempty"` // "uuid:<fs-uuid>" if it has an FS; "" for a blank disk
|
||||
}
|
||||
|
||||
// mountableFSTypes are the filesystems the agent can mount as-is (EnsureMount) → an "attach" candidate.
|
||||
// Others (ntfs, exfat, …) are data-bearing but not attach-mountable → "initialize" only (with wipe).
|
||||
var mountableFSTypes = map[string]bool{"ext4": true, "xfs": true}
|
||||
|
||||
// ListCandidateDisks enumerates the host's whole disks (from /sys/block — non-privileged) and returns
|
||||
// the subset the Impl-1 unclaimed filter proves is free for Felhom. Fail-safe carries through: a device
|
||||
// the filter cannot prove unclaimed (incl. any read error) is simply omitted (never offered).
|
||||
func (h *SudoHostOps) ListCandidateDisks(ctx context.Context) ([]CandidateDisk, error) {
|
||||
entries, err := os.ReadDir("/sys/block")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out []CandidateDisk
|
||||
for _, e := range entries {
|
||||
name := e.Name()
|
||||
dev := "/dev/" + name
|
||||
// Only real whole disks (sd*/nvme*n*/vd*/hd*). wholeDiskOf rejects loop/ram/dm-/zram/md/sr etc.
|
||||
if wd, ok := wholeDiskOf(dev); !ok || wd != dev {
|
||||
continue
|
||||
}
|
||||
unclaimed, _ := classifyClaim(h.gatherClaimFacts(ctx, dev))
|
||||
if !unclaimed {
|
||||
continue
|
||||
}
|
||||
out = append(out, h.buildCandidate(dev, name))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// buildCandidate assembles the probe info for an already-unclaimed disk. It re-reads the lsblk tree for
|
||||
// per-node FS (via the same allowlisted command the claim gather uses) and /sys for size/model.
|
||||
func (h *SudoHostOps) buildCandidate(dev, name string) CandidateDisk {
|
||||
c := CandidateDisk{Device: dev}
|
||||
c.SizeBytes = readSysBlockSize(name)
|
||||
c.Model = readSysBlockModel(name)
|
||||
|
||||
// Per-node FS from lsblk (reuses the FELHOM_FORMAT-allowlisted command).
|
||||
lout, _, lerr := h.runner.Run(context.Background(), h.bins.Lsblk, "-J", "-o", "NAME,FSTYPE,PTTYPE,MOUNTPOINT", dev)
|
||||
if lerr == nil {
|
||||
if nodes, perr := parseLsblkNodes(lout); perr == nil {
|
||||
for _, n := range nodes {
|
||||
if n.fstype == "" {
|
||||
continue
|
||||
}
|
||||
c.DataBearing = true
|
||||
if c.FSType == "" {
|
||||
c.FSType = n.fstype // first filesystem found (partition or whole-disk)
|
||||
}
|
||||
if mountableFSTypes[n.fstype] && c.MountSource == "" {
|
||||
c.Mountable = true
|
||||
c.MountSource = "/dev/" + n.name
|
||||
if uuid, ok := h.host.ResolveUUID("/dev/" + n.name); ok {
|
||||
c.DurableID = "uuid:" + uuid
|
||||
}
|
||||
}
|
||||
}
|
||||
// A partition table with no FS is still data-bearing (must be wiped before init).
|
||||
if len(nodes) > 1 {
|
||||
c.DataBearing = true
|
||||
}
|
||||
}
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// readSysBlockSize returns the device size in bytes from /sys/block/<name>/size (512-byte sectors).
|
||||
func readSysBlockSize(name string) int64 {
|
||||
b, err := os.ReadFile(filepath.Join("/sys/block", name, "size"))
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
sectors, err := strconv.ParseInt(strings.TrimSpace(string(b)), 10, 64)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return sectors * 512
|
||||
}
|
||||
|
||||
// readSysBlockModel returns the device model from /sys/block/<name>/device/model ("" if unreadable).
|
||||
func readSysBlockModel(name string) string {
|
||||
b, err := os.ReadFile(filepath.Join("/sys/block", name, "device", "model"))
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(string(b))
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-agent/internal/hub"
|
||||
)
|
||||
|
||||
// RegistryKnownTargets sources the watchdog's known-drive set from the drive INTENT registry + the
|
||||
// Felhom `.mount` units — NOT from PVE storages (Observe). Impl-2a decoupling: a drive tracked this way
|
||||
// needs NO PVE dir-storage (which is what hid registry-only drives from health/detect, the 3b-fix class).
|
||||
// Real PVE storages (local/local-lvm/pbs) are NOT drives and are handled elsewhere via Observe.
|
||||
//
|
||||
// A unit is included iff its drive's intent is not `new` (enrolled/ejected/decommissioned are all
|
||||
// "known" = health-tracked; the watchdog's IntentReader gate then decides whether to RE-MOUNT — only
|
||||
// `enrolled` is remounted). A `new`-intent unit (should not normally exist) is excluded so it never
|
||||
// auto-mounts.
|
||||
type RegistryKnownTargets struct {
|
||||
unitDir string
|
||||
intent IntentReader
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
// NewRegistryKnownTargets builds the provider. unitDir is the systemd unit dir the Felhom `.mount`
|
||||
// units live in (e.g. /etc/systemd/system); intent is the durable-id → intent store (may be nil →
|
||||
// every Felhom unit is treated as known, matching the legacy ungated behaviour).
|
||||
func NewRegistryKnownTargets(unitDir string, intent IntentReader, logger *slog.Logger) *RegistryKnownTargets {
|
||||
if logger == nil {
|
||||
logger = slog.Default()
|
||||
}
|
||||
return &RegistryKnownTargets{unitDir: unitDir, intent: intent, logger: logger}
|
||||
}
|
||||
|
||||
// Known enumerates the Felhom drive units under unitDir and returns one KnownTarget per enrolled/
|
||||
// tracked drive. It mirrors ReassertEnrolledMounts's enumeration (read dir → parseFelhomMountUnit).
|
||||
func (r *RegistryKnownTargets) Known(ctx context.Context) ([]KnownTarget, error) {
|
||||
entries, err := os.ReadDir(r.unitDir)
|
||||
if err != nil {
|
||||
return nil, err // let the caching layer keep the last good set; the watchdog logs + skips a cycle
|
||||
}
|
||||
var out []KnownTarget
|
||||
for _, e := range entries {
|
||||
if !strings.HasSuffix(e.Name(), ".mount") {
|
||||
continue
|
||||
}
|
||||
data, rerr := os.ReadFile(filepath.Join(r.unitDir, e.Name()))
|
||||
if rerr != nil {
|
||||
continue
|
||||
}
|
||||
spec, ok := parseFelhomMountUnit(string(data))
|
||||
if !ok {
|
||||
continue // not one of our by-uuid drive units (netmount units are excluded by the parser)
|
||||
}
|
||||
durableID := "uuid:" + spec.UUID // the same scheme enroll records in the intent store
|
||||
if r.intent != nil && r.intent.Get(durableID) == IntentNew {
|
||||
// A unit with no recorded intent — not enrolled/ejected/decommissioned. Do NOT track it
|
||||
// (and the watchdog must not auto-mount it): a genuine drive must be enrolled first.
|
||||
continue
|
||||
}
|
||||
out = append(out, KnownTarget{
|
||||
Name: spec.Name,
|
||||
Type: hub.StorageTypeUSB, // a Felhom drive; MountBacked is what the watchdog keys on
|
||||
DurableID: durableID,
|
||||
UUID: spec.UUID,
|
||||
MountBacked: true,
|
||||
MountPath: spec.Where,
|
||||
// BackingDevice left "" — HostLiveness resolves the device by UUID (by-uuid symlink).
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ReconcileExistingDrives migrates drives enrolled before Impl-2a into the intent-registry model so the
|
||||
// registry-sourced Known() tracks them without depending on their (legacy) PVE dir-storage: for each
|
||||
// Felhom `.mount` unit whose drive is currently mounted, ensure the intent registry records it
|
||||
// `enrolled`. Idempotent (an already-enrolled drive is a no-op) and non-destructive — it creates no PVE
|
||||
// storage and removes nothing. Best-effort per drive. Call once at agent start.
|
||||
func ReconcileExistingDrives(unitDir string, mounts []Mount, intent *IntentStore, logger *slog.Logger) {
|
||||
if intent == nil || logger == nil {
|
||||
return
|
||||
}
|
||||
mounted := make(map[string]bool, len(mounts))
|
||||
for _, m := range mounts {
|
||||
mounted[m.MountPoint] = true
|
||||
}
|
||||
entries, err := os.ReadDir(unitDir)
|
||||
if err != nil {
|
||||
logger.Warn("migrate: cannot read unit dir — existing-drive reconcile skipped", "dir", unitDir, "err", err)
|
||||
return
|
||||
}
|
||||
for _, e := range entries {
|
||||
if !strings.HasSuffix(e.Name(), ".mount") {
|
||||
continue
|
||||
}
|
||||
data, rerr := os.ReadFile(filepath.Join(unitDir, e.Name()))
|
||||
if rerr != nil {
|
||||
continue
|
||||
}
|
||||
spec, ok := parseFelhomMountUnit(string(data))
|
||||
if !ok || !mounted[spec.Where] {
|
||||
continue // only migrate currently-active Felhom drives
|
||||
}
|
||||
durableID := "uuid:" + spec.UUID
|
||||
if intent.Get(durableID) != IntentNew {
|
||||
continue // already tracked (enrolled/ejected/decommissioned) — idempotent no-op
|
||||
}
|
||||
if serr := intent.SetEnrolled(durableID); serr != nil {
|
||||
logger.Warn("migrate: could not record enrolled intent", "drive", spec.Name, "err", serr)
|
||||
} else {
|
||||
logger.Info("migrate: recorded existing drive as enrolled", "drive", spec.Name, "durable_id", durableID)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// fakeIntent is a minimal IntentReader for the registry tests.
|
||||
type fakeIntent struct{ m map[string]DriveIntent }
|
||||
|
||||
func (f *fakeIntent) Get(durableID string) DriveIntent { return f.m[durableID] }
|
||||
|
||||
func writeUnit(t *testing.T, dir, name, content string) {
|
||||
t.Helper()
|
||||
if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0o644); err != nil {
|
||||
t.Fatalf("write unit %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistryKnownTargets(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
writeUnit(t, dir, "mnt-a.mount", renderMountUnit(MountSpec{Name: "felhom-a", UUID: "UUID-A", Where: "/mnt/felhom-a", FSType: "ext4"}))
|
||||
writeUnit(t, dir, "mnt-b.mount", renderMountUnit(MountSpec{Name: "felhom-b", UUID: "UUID-B", Where: "/mnt/felhom-b", FSType: "ext4"}))
|
||||
writeUnit(t, dir, "other.mount", "[Mount]\nWhat=/dev/x\nWhere=/mnt/x\n") // not one of ours → ignored
|
||||
|
||||
// felhom-a enrolled, felhom-b has NO intent (new).
|
||||
intent := &fakeIntent{m: map[string]DriveIntent{"uuid:UUID-A": IntentEnrolled}}
|
||||
r := NewRegistryKnownTargets(dir, intent, quietLogger())
|
||||
|
||||
got, err := r.Known(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("Known: %v", err)
|
||||
}
|
||||
if len(got) != 1 || got[0].Name != "felhom-a" {
|
||||
t.Fatalf("want only felhom-a (enrolled); new felhom-b excluded, non-felhom ignored — got %+v", got)
|
||||
}
|
||||
kt := got[0]
|
||||
if kt.DurableID != "uuid:UUID-A" || kt.UUID != "UUID-A" || !kt.MountBacked || kt.MountPath != "/mnt/felhom-a" {
|
||||
t.Fatalf("KnownTarget fields wrong: %+v", kt)
|
||||
}
|
||||
|
||||
// An EJECTED drive is still tracked (known) — the watchdog's IntentReader gate prevents remount.
|
||||
intent.m["uuid:UUID-B"] = IntentEjected
|
||||
got2, _ := r.Known(context.Background())
|
||||
if len(got2) != 2 {
|
||||
t.Fatalf("ejected drive must still be a Known target: %+v", got2)
|
||||
}
|
||||
}
|
||||
|
||||
// The decoupling red-proof: with NO PVE storage for a drive, the OLD Observe-based Known() misses it,
|
||||
// while the registry+units provider tracks it. This is exactly the 3b-fix class the decoupling closes.
|
||||
func TestRegistryVsObserve_RedProof(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
writeUnit(t, dir, "mnt-a.mount", renderMountUnit(MountSpec{Name: "felhom-a", UUID: "UUID-A", Where: "/mnt/felhom-a", FSType: "ext4"}))
|
||||
intent := &fakeIntent{m: map[string]DriveIntent{"uuid:UUID-A": IntentEnrolled}}
|
||||
|
||||
// OLD path: Observer with an API that has NO storages → Known() is empty (drive invisible).
|
||||
obs := NewObserver(&fakeStorageAPI{node: "n"}, &fakeHostReader{}, nil, quietLogger())
|
||||
oldKnown, _ := obs.Known(context.Background())
|
||||
if len(oldKnown) != 0 {
|
||||
t.Fatalf("precondition: Observe-based Known should be empty with no PVE storage, got %+v", oldKnown)
|
||||
}
|
||||
|
||||
// NEW path: the registry provider tracks the drive from unit + intent, no PVE storage needed.
|
||||
newKnown, _ := NewRegistryKnownTargets(dir, intent, quietLogger()).Known(context.Background())
|
||||
if len(newKnown) != 1 || newKnown[0].Name != "felhom-a" {
|
||||
t.Fatalf("registry provider must track the registry-only drive: %+v", newKnown)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReconcileExistingDrives_Idempotent(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
writeUnit(t, dir, "mnt-a.mount", renderMountUnit(MountSpec{Name: "felhom-a", UUID: "UUID-A", Where: "/mnt/felhom-a", FSType: "ext4"}))
|
||||
store, err := OpenIntentStore(filepath.Join(t.TempDir(), "intents.json"))
|
||||
if err != nil {
|
||||
t.Fatalf("intent store: %v", err)
|
||||
}
|
||||
mounts := []Mount{{Device: "/dev/disk/by-uuid/UUID-A", MountPoint: "/mnt/felhom-a"}}
|
||||
|
||||
// First reconcile: an un-recorded mounted drive → becomes enrolled.
|
||||
ReconcileExistingDrives(dir, mounts, store, quietLogger())
|
||||
if store.Get("uuid:UUID-A") != IntentEnrolled {
|
||||
t.Fatalf("reconcile must record the mounted drive enrolled, got %q", store.Get("uuid:UUID-A"))
|
||||
}
|
||||
// Idempotent: a re-run doesn't change/downgrade it.
|
||||
ReconcileExistingDrives(dir, mounts, store, quietLogger())
|
||||
if store.Get("uuid:UUID-A") != IntentEnrolled {
|
||||
t.Fatalf("re-run must be a no-op, got %q", store.Get("uuid:UUID-A"))
|
||||
}
|
||||
// An UNMOUNTED drive is NOT auto-enrolled by the migration.
|
||||
writeUnit(t, dir, "mnt-c.mount", renderMountUnit(MountSpec{Name: "felhom-c", UUID: "UUID-C", Where: "/mnt/felhom-c", FSType: "ext4"}))
|
||||
ReconcileExistingDrives(dir, mounts, store, quietLogger())
|
||||
if store.Get("uuid:UUID-C") != IntentNew {
|
||||
t.Fatalf("unmounted drive must NOT be migrated, got %q", store.Get("uuid:UUID-C"))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user