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:
2026-07-01 17:33:49 +02:00
parent 066e3bf153
commit 91f6a26490
10 changed files with 488 additions and 5 deletions
+28
View File
@@ -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) ## 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 Impl-1 (SPIKE-drive-enrollment-2026-07-01). Hardens the destructive `Format`/mkfs path BEFORE the
+6
View File
@@ -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: that scoped token (`VM.Allocate`+`Pool.Allocate` at `/pool/felhom`). Layout + validation:
`felhom.eu/documentation/audits/SPIKE-pool-scoped-acl-2026-07-01.md`. `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** **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, (`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`. ZFS/mdraid member, foreign mount, read-only; fail-safe on any read error), independent of `DataBearing`.
+20 -4
View File
@@ -45,7 +45,7 @@ import (
// version is the agent version. Overridable at build time with // version is the agent version. Overridable at build time with
// -ldflags "-X main.version=<v>"; defaults to the in-repo CHANGELOG version. // -ldflags "-X main.version=<v>"; 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 <vmid> <phase>`). On the // runGuestHook is the PVE pre-start hook body (`felhom-agent guest-hook <vmid> <phase>`). On the
// pre-start phase it creates placeholder dirs for any absent bind-mount source so the guest always boots // 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) logger.Warn("storage: format-job store unavailable — format restart-recovery disabled (F20-BUG3)", "err", fjErr)
formatJobStore = nil 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{ watchdog := storage.NewWatchdog(storage.WatchdogOptions{
Targets: storage.NewCachingKnownTargets(observer, cfg.Storage.KnownRefresh()), Targets: storage.NewCachingKnownTargets(driveKnown, cfg.Storage.KnownRefresh()),
Liveness: storage.NewHostLiveness(hostReader, 0), Liveness: storage.NewHostLiveness(hostReader, 0),
Remounter: remounter, Remounter: remounter,
Intent: intentReader, // P3: only re-mount enrolled drives 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) jobsRunner := signedjobs.NewRunner(client, gate, signedjobs.ExecutorChain{wipeExec, decommExec}, cfg.Hub.HostID, logger)
loop.SetEnvelopeObserver(hub.MultiObserver(desiredSyncer, jobsRunner)) 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 { if localTokens != nil {
defer localTokens.Close() 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 // 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 // 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. // 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() { if !cfg.LocalAPI.Enabled() {
return nil return nil
} }
@@ -751,6 +766,7 @@ func buildLocalAPIServer(cfg config.Config, px *proxmox.Client, store *backup.St
Backups: runner, Backups: runner,
Store: store, Store: store,
Storage: observer, Storage: observer,
DriveTargets: driveTargets, // Impl-2a: registry+units drives for the /disks view (union w/ Observe storages)
Tokens: tokens, Tokens: tokens,
BackupCadence: cfg.Backup.BackupCadence(), BackupCadence: cfg.Backup.BackupCadence(),
// Disk management (slice 8C): the privileged host surface + the data-bearing wipe gate. // Disk management (slice 8C): the privileged host surface + the data-bearing wipe gate.
+55
View File
@@ -27,6 +27,9 @@ type DiskOps interface {
Unmount(ctx context.Context, where string) error Unmount(ctx context.Context, where string) error
Format(ctx context.Context, device, fstype string) error Format(ctx context.Context, device, fstype string) error
InspectDevice(ctx context.Context, device string) (storage.DeviceProbe, 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 // 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) 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. // 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. // The controller persists it and deterministically recreates drive-backed apps when it changes.
bootID := "" 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}) 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 { type assignRequest struct {
VMID int `json:"vmid"` VMID int `json:"vmid"`
UUID string `json:"uuid"` 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)
}
}
+6
View File
@@ -26,6 +26,12 @@ type fakeDiskOps struct {
formatCalls []string formatCalls []string
mountCalls []storage.MountSpec mountCalls []storage.MountSpec
unmountCalls []string 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) { func (f *fakeDiskOps) InspectDevice(_ context.Context, device string) (storage.DeviceProbe, error) {
+7 -1
View File
@@ -70,6 +70,9 @@ type Options struct {
Backups BackupService Backups BackupService
Store BackupStore Store BackupStore
Storage StorageView 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 Tokens TokenAuthority
// BackupCadence is the per-guest backup interval driving GET /backup/due (slice 8B). A guest // 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 // 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 guests GuestAPI
backups BackupService backups BackupService
store BackupStore store BackupStore
storage StorageView storage StorageView
driveTargets storage.KnownTargets // Impl-2a: registry+units drives for /disks (optional)
tokens TokenAuthority tokens TokenAuthority
cadence time.Duration cadence time.Duration
logger *slog.Logger logger *slog.Logger
@@ -226,6 +230,7 @@ func NewServer(o Options) (*Server, error) {
backups: o.Backups, backups: o.Backups,
store: o.Store, store: o.Store,
storage: o.Storage, storage: o.Storage,
driveTargets: o.DriveTargets,
tokens: o.Tokens, tokens: o.Tokens,
cadence: cadence, cadence: cadence,
logger: o.Logger, logger: o.Logger,
@@ -270,6 +275,7 @@ func (s *Server) Handler() http.Handler {
mux.HandleFunc("GET /host/metrics", s.withGuest(s.handleHostMetrics)) mux.HandleFunc("GET /host/metrics", s.withGuest(s.handleHostMetrics))
// Disk management (slice 8C) — self-scoped; format routes through the data-bearing classifier+gate. // 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", 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/assign", s.withGuest(s.handleDiskAssign))
mux.HandleFunc("POST /disks/eject", s.withGuest(s.handleDiskEject)) mux.HandleFunc("POST /disks/eject", s.withGuest(s.handleDiskEject))
mux.HandleFunc("POST /disks/decommission", s.withGuest(s.handleDiskDecommission)) mux.HandleFunc("POST /disks/decommission", s.withGuest(s.handleDiskDecommission))
+111
View File
@@ -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))
}
+117
View File
@@ -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)
}
}
}
+98
View File
@@ -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"))
}
}