From 91f6a26490bd672c71673696ed8ce6832bb02679 Mon Sep 17 00:00:00 2001 From: kisfenyo Date: Wed, 1 Jul 2026 17:33:49 +0200 Subject: [PATCH] v0.55.0: raw-device discovery + registry-sourced drive tracking (Impl-2a) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- CHANGELOG.md | 28 +++++ README.md | 6 ++ cmd/felhom-agent/main.go | 24 ++++- internal/localapi/disks.go | 55 ++++++++++ internal/localapi/disks_candidates_test.go | 40 +++++++ internal/localapi/disks_test.go | 6 ++ internal/localapi/server.go | 8 +- internal/storage/candidates.go | 111 +++++++++++++++++++ internal/storage/registry_known.go | 117 +++++++++++++++++++++ internal/storage/registry_known_test.go | 98 +++++++++++++++++ 10 files changed, 488 insertions(+), 5 deletions(-) create mode 100644 internal/localapi/disks_candidates_test.go create mode 100644 internal/storage/candidates.go create mode 100644 internal/storage/registry_known.go create mode 100644 internal/storage/registry_known_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index d9d6735..fa535be 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,31 @@ +## v0.55.0 — raw-device discovery + registry-sourced drive tracking (Impl-2a) (2026-07-01) + +Agent backend for drive enrollment (SPIKE-drive-enrollment §SQ1/SQ4/SQ5). Makes raw (non-PVE-storage) +drives (a) discoverable for enrollment and (b) health-tracked WITHOUT being a PVE storage — so a drive +enrolled the new way isn't enrolled-but-untracked (the 3b-fix false-detach class). No mkfs here (Impl-1 +owns it); the controller wizard rewiring is Impl-2b. + +- **`GET /disks/candidates`** (`internal/storage/candidates.go` + `internal/localapi/disks.go`): + enumerates host whole-disks from `/sys/block`, runs the Impl-1 unclaimed filter, and returns the free + ones with probe info (size/model/FS/data-bearing/durable-id preview), split into `initialize` (all + unclaimed) and `attach` (the subset carrying a mountable ext4/xfs FS). Fail-safe carries through (a + device not provably unclaimed is omitted). +- **`RegistryKnownTargets`** (`internal/storage/registry_known.go`): the watchdog's known-DRIVE set now + comes from the intent registry + Felhom `.mount` units, NOT `Observe()` (PVE storages). A unit is + tracked iff its intent ≠ `new` (enrolled/ejected/decommissioned; the watchdog's existing IntentReader + gate still decides re-mount). `main.go` swaps the watchdog `KnownTargets` source. **`Observe()` is + KEPT** for real PVE storages (local/local-lvm/pbs) — reports + the `/disks` view. +- **`handleDisks` union:** additive + deduped-by-mount-path — appends registry drives Observe doesn't + surface (a registry-only drive now appears in the agent-view) without dropping any Observe row (can't + regress the current view). +- **Existing-drive migration** (`ReconcileExistingDrives`, idempotent, at agent start): records each + currently-mounted Felhom-unit drive as `enrolled` so the registry-sourced `Known()` tracks it without + its legacy PVE dir-storage. Does NOT create/remove PVE storages. +- **Tests:** RegistryKnownTargets (enrolled tracked / new excluded / ejected tracked) + the **red-proof** + (Observe-based `Known()` misses a drive with no PVE storage; the registry provider tracks it); + migration idempotency; candidates init/attach split. `go build`/`vet`/`test ./...` clean. Watchdog/ + HostLiveness/Remounter unchanged (only the source injected). + ## v0.54.0 — format-safety foundation: unclaimed-disk guard + guarded-mkfs wrapper (2026-07-01) Impl-1 (SPIKE-drive-enrollment-2026-07-01). Hardens the destructive `Format`/mkfs path BEFORE the diff --git a/README.md b/README.md index bfc6d2f..62bf1d9 100644 --- a/README.md +++ b/README.md @@ -250,6 +250,12 @@ of `/` (blast-radius containment on a shared host) — the restore is how a fres that scoped token (`VM.Allocate`+`Pool.Allocate` at `/pool/felhom`). Layout + validation: `felhom.eu/documentation/audits/SPIKE-pool-scoped-acl-2026-07-01.md`. +**Drive discovery + tracking (v0.55.0, Impl-2a):** `GET /disks/candidates` lists host disks the Impl-1 +filter proves are free to enroll (split initialize/attach). The watchdog's known-drive set is sourced +from the intent registry + Felhom `.mount` units (`RegistryKnownTargets`), NOT PVE storages — so a drive +enrolled with no PVE dir-storage is still health-tracked (`Observe()` stays for real PVE storages). The +controller wizard consuming this is Impl-2b. + **Format safety (v0.54.0, Impl-1):** `Format` (mkfs) is gated by a mandatory **unclaimed-disk guard** (`internal/storage/claim.go`) — it refuses any device not provably free for Felhom (OS disk, LVM PV, ZFS/mdraid member, foreign mount, read-only; fail-safe on any read error), independent of `DataBearing`. diff --git a/cmd/felhom-agent/main.go b/cmd/felhom-agent/main.go index 8e08f90..0f6b845 100644 --- a/cmd/felhom-agent/main.go +++ b/cmd/felhom-agent/main.go @@ -45,7 +45,7 @@ import ( // version is the agent version. Overridable at build time with // -ldflags "-X main.version="; defaults to the in-repo CHANGELOG version. -var version = "0.54.0" +var version = "0.55.0" // runGuestHook is the PVE pre-start hook body (`felhom-agent guest-hook `). On the // pre-start phase it creates placeholder dirs for any absent bind-mount source so the guest always boots @@ -429,8 +429,23 @@ func runDaemon(cfg config.Config, logger *slog.Logger) int { logger.Warn("storage: format-job store unavailable — format restart-recovery disabled (F20-BUG3)", "err", fjErr) formatJobStore = nil } + // Impl-2a: source the watchdog's known-DRIVE set from the intent registry + Felhom .mount units + // (NOT PVE storages via Observe) — a registry-only drive (no PVE dir-storage) is now health-tracked. + // Real PVE storages (local/local-lvm/pbs) stay Observe-sourced for /disks + reports (observer below). + driveUnitDir := cfg.Privileged.UnitDir + if driveUnitDir == "" { + driveUnitDir = "/etc/systemd/system" + } + driveKnown := storage.NewRegistryKnownTargets(driveUnitDir, intentReader, logger) + // One-time migration: record pre-Impl-2a drives (mounted, with a Felhom unit) as enrolled so the + // registry-sourced Known() tracks them without their legacy PVE dir-storage. Idempotent. + if mounts, merr := hostReader.Mounts(); merr == nil { + storage.ReconcileExistingDrives(driveUnitDir, mounts, intentStore, logger) + } else { + logger.Warn("storage: existing-drive migration skipped — mount read failed", "err", merr) + } watchdog := storage.NewWatchdog(storage.WatchdogOptions{ - Targets: storage.NewCachingKnownTargets(observer, cfg.Storage.KnownRefresh()), + Targets: storage.NewCachingKnownTargets(driveKnown, cfg.Storage.KnownRefresh()), Liveness: storage.NewHostLiveness(hostReader, 0), Remounter: remounter, Intent: intentReader, // P3: only re-mount enrolled drives @@ -513,7 +528,7 @@ func runDaemon(cfg config.Config, logger *slog.Logger) int { jobsRunner := signedjobs.NewRunner(client, gate, signedjobs.ExecutorChain{wipeExec, decommExec}, cfg.Hub.HostID, logger) loop.SetEnvelopeObserver(hub.MultiObserver(desiredSyncer, jobsRunner)) - localSrv := buildLocalAPIServer(cfg, px, backupStore, observer, hostOps, gate, collector, intentRec, guestBindStore, formatJobStore, logger, &localTokens) + localSrv := buildLocalAPIServer(cfg, px, backupStore, observer, driveKnown, hostOps, gate, collector, intentRec, guestBindStore, formatJobStore, logger, &localTokens) if localTokens != nil { defer localTokens.Close() } @@ -706,7 +721,7 @@ func buildRestoreTestScheduler(cfg config.Config, px *proxmox.Client, engine *re // leaf (stable fingerprint). Any failure DISABLES the server (returns nil) WITHOUT crashing the // daemon — the host still reports/reconciles; only the controller channel is unavailable until // fixed. The opened token store is returned via outTokens so the caller can Close it. -func buildLocalAPIServer(cfg config.Config, px *proxmox.Client, store *backup.Store, observer *storage.Observer, hostOps *storage.SudoHostOps, gate *reconcile.Gate, collector *hub.Collector, intent localapi.IntentRecorder, guestBinds *localapi.GuestBindStore, formatJobs *localapi.FormatJobStore, logger *slog.Logger, outTokens **localapi.TokenStore) *localapi.Server { +func buildLocalAPIServer(cfg config.Config, px *proxmox.Client, store *backup.Store, observer *storage.Observer, driveTargets storage.KnownTargets, hostOps *storage.SudoHostOps, gate *reconcile.Gate, collector *hub.Collector, intent localapi.IntentRecorder, guestBinds *localapi.GuestBindStore, formatJobs *localapi.FormatJobStore, logger *slog.Logger, outTokens **localapi.TokenStore) *localapi.Server { if !cfg.LocalAPI.Enabled() { return nil } @@ -751,6 +766,7 @@ func buildLocalAPIServer(cfg config.Config, px *proxmox.Client, store *backup.St Backups: runner, Store: store, Storage: observer, + DriveTargets: driveTargets, // Impl-2a: registry+units drives for the /disks view (union w/ Observe storages) Tokens: tokens, BackupCadence: cfg.Backup.BackupCadence(), // Disk management (slice 8C): the privileged host surface + the data-bearing wipe gate. diff --git a/internal/localapi/disks.go b/internal/localapi/disks.go index a024465..10ad8d9 100644 --- a/internal/localapi/disks.go +++ b/internal/localapi/disks.go @@ -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"` diff --git a/internal/localapi/disks_candidates_test.go b/internal/localapi/disks_candidates_test.go new file mode 100644 index 0000000..17bf238 --- /dev/null +++ b/internal/localapi/disks_candidates_test.go @@ -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) + } +} diff --git a/internal/localapi/disks_test.go b/internal/localapi/disks_test.go index 1fb2d37..db6642b 100644 --- a/internal/localapi/disks_test.go +++ b/internal/localapi/disks_test.go @@ -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) { diff --git a/internal/localapi/server.go b/internal/localapi/server.go index 5003c35..cc16378 100644 --- a/internal/localapi/server.go +++ b/internal/localapi/server.go @@ -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)) diff --git a/internal/storage/candidates.go b/internal/storage/candidates.go new file mode 100644 index 0000000..63f44ab --- /dev/null +++ b/internal/storage/candidates.go @@ -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:" 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//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//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)) +} diff --git a/internal/storage/registry_known.go b/internal/storage/registry_known.go new file mode 100644 index 0000000..fac145a --- /dev/null +++ b/internal/storage/registry_known.go @@ -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) + } + } +} diff --git a/internal/storage/registry_known_test.go b/internal/storage/registry_known_test.go new file mode 100644 index 0000000..24941f2 --- /dev/null +++ b/internal/storage/registry_known_test.go @@ -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")) + } +}