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:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user