v0.5.0: slice 5 Phase B — the host-root surface (mounts + SMART + grow + destructive gate)

The privileged write surface, isolated behind a narrow, arg-validated, adversarially-
tested seam (HostOps), the same discipline as the slice-4 gate. Completes slice 5.

- internal/storage: HostOps seam + SudoHostOps (systemd .mount units by fs-UUID, detach,
  SMART, lvs) via sudoers allowlist + fixed arg vectors, no shell; NoopHostOps fallback.
- validate.go: strict UUID/mount-path/device/LVM validators + in-process systemd-escape.
  Headline test: adversarial matrix (metacharacters/traversal/malformed) refused with
  zero exec.
- smart.go: smartctl SATA + NVMe parse, UNKNOWN-degrade; lvs thin-pool metadata fill.
- observer enrichment (Observe only): fills smart + thin-pool metadata.
- watchdog: benign re-mount response off the poll path (DevicePresent probe, rate-limited).
- reconcile: ActionResize (benign, grow-only) + proxmox.ResizeLXC; destructive storage ops
  (ClassStorageWipe/Decommission) through the slice-4 gate, target-scoped; built+tested,
  inert live.
- --selftest=storage [-watch] live harness; configs/felhom-agent.sudoers; privileged.* knobs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-09 10:53:38 +02:00
parent 27b68f043b
commit 9d6e49236c
25 changed files with 2074 additions and 182 deletions
+70 -5
View File
@@ -4,6 +4,7 @@ import (
"context"
"fmt"
"log/slog"
"regexp"
"strings"
"gitea.dooplex.hu/admin/felhom-agent/internal/hub"
@@ -26,29 +27,37 @@ type StorageAPI interface {
}
// Observer builds the observed storage view from Proxmox + non-privileged host reads.
// In Phase B it also (optionally) enriches the reported view with the privileged reads —
// SMART + thin-pool metadata — via HostOps; a nil ops keeps the Phase-A behaviour
// (SMART UNKNOWN, metadata null).
type Observer struct {
api StorageAPI
host HostReader
ops HostOps
logger *slog.Logger
}
// NewObserver builds an Observer. host defaults to a ProcHostReader; logger to the
// default. A nil api makes Observe/Known return an error (misconfiguration), never panic.
func NewObserver(api StorageAPI, host HostReader, logger *slog.Logger) *Observer {
// default. ops is the privileged surface for SMART/lvs — nil disables those (Phase-A
// behaviour). A nil api makes Observe/Known return an error (misconfiguration), never panic.
func NewObserver(api StorageAPI, host HostReader, ops HostOps, logger *slog.Logger) *Observer {
if host == nil {
host = NewProcHostReader()
}
if logger == nil {
logger = slog.Default()
}
return &Observer{api: api, host: host, logger: logger}
return &Observer{api: api, host: host, ops: ops, logger: logger}
}
// observed is the rich internal view of one target, from which both the reported
// hub.StorageTarget and the watchdog's KnownTarget are projected.
// hub.StorageTarget and the watchdog's KnownTarget are projected. src/cat are kept for
// Observe-time privileged enrichment (NOT used by the watchdog's Known path).
type observed struct {
target hub.StorageTarget
known KnownTarget
src proxmox.Storage
cat storageCategory
}
// Observe builds the reported []hub.StorageTarget. A non-nil error means the Proxmox read
@@ -61,11 +70,43 @@ func (o *Observer) Observe(ctx context.Context) ([]hub.StorageTarget, error) {
}
out := make([]hub.StorageTarget, 0, len(snap))
for _, s := range snap {
out = append(out, s.target)
out = append(out, o.enrich(ctx, s))
}
return out, nil
}
// enrich adds the PRIVILEGED reads (SMART, thin-pool metadata) on top of the base target.
// Only Observe calls this (the watchdog's Known path skips it — these are the slow,
// root-shelling reads). A nil ops or a per-target failure degrades gracefully: SMART stays
// UNKNOWN, metadata stays null.
func (o *Observer) enrich(ctx context.Context, ob observed) hub.StorageTarget {
t := ob.target
if o.ops == nil {
return t
}
// SMART: only for dir-backed targets with a resolvable whole-disk device.
if ob.cat == catDir && t.BackingDevice != "" {
if dev, ok := smartDeviceFor(t.BackingDevice); ok {
if sm, err := o.ops.SMART(ctx, dev); err != nil {
o.logger.Warn("storage: SMART read failed; health UNKNOWN", "device", dev, "err", err)
} else {
t.Smart = sm
}
}
}
// Thin-pool metadata fill (the value Phase A left null): lvs on the vg/pool.
if t.Type == hub.StorageTypeLVMThin && t.ThinPool != nil && ob.src.VGName != "" && ob.src.ThinPool != "" {
if frac, ok := o.ops.ThinPoolMetadata(ctx, ob.src.VGName, ob.src.ThinPool); ok {
t.ThinPool.MetadataUsedFraction = &frac
if frac >= thinPoolWarnFraction {
o.logger.Warn("storage: lvmthin pool METADATA fill is high (exhaustion corrupts the pool like data exhaustion)",
"storage", t.Name, "metadata_used_fraction", frac)
}
}
}
return t
}
// Known projects the snapshot to the watchdog's lightweight KnownTarget set. Same Proxmox
// + host reads as Observe — callers that poll it fast should wrap it in a cache (the
// watchdog uses CachingKnownTargets).
@@ -194,10 +235,13 @@ func (o *Observer) build(s proxmox.Storage, mounts []Mount) observed {
return observed{
target: tgt,
src: s,
cat: category,
known: KnownTarget{
Name: s.Storage,
Type: typ,
DurableID: durableID,
UUID: uuid,
Network: category == catNetwork,
MountBacked: typ == hub.StorageTypeUSB || typ == hub.StorageTypeLocalDir,
BackingDevice: backingDevice,
@@ -207,6 +251,27 @@ func (o *Observer) build(s proxmox.Storage, mounts []Mount) observed {
}
}
// smartDeviceFor maps a backing device (possibly a partition) to its whole-disk path for
// smartctl (which targets the disk, not the partition). Returns ok=false when the result
// isn't a recognized raw disk (e.g. device-mapper / LVM), so SMART is simply skipped.
func smartDeviceFor(device string) (string, bool) {
dev := device
if m := reNVMePart.FindStringSubmatch(device); m != nil {
dev = m[1] // /dev/nvme0n1p2 -> /dev/nvme0n1
} else if m := reSDPart.FindStringSubmatch(device); m != nil {
dev = m[1] // /dev/sdb1 -> /dev/sdb
}
if ValidateSMARTDevice(dev) != nil {
return "", false
}
return dev, true
}
var (
reNVMePart = regexp.MustCompile(`^(/dev/nvme[0-9]+n[0-9]+)p[0-9]+$`)
reSDPart = regexp.MustCompile(`^(/dev/(?:sd|hd|vd)[a-z]+)[0-9]+$`)
)
// reachable decides whether the target is currently usable.
// - usb / local-dir: a Felhom extra/removable dir storage is realized as its OWN
// mountpoint, so reachable = it is currently an exact mount AND its device node exists.