v0.95.0: SMART coverage — union-path drives + LVM/dm root + device model

Implements SPIKE-smart-coverage-2026-07-25 fixes B+A (additive; MinAgent unchanged).
Fix B: storage.SmartReader.SMARTForBacking wired into the /disks union path (localapi
Smart seam) so registry/USB drives get a real SMART read (watchdog Known stays
enrich-free). Fix A: smartDeviceFor resolves dm/LVM to the whole disk via
/sys/block/<dm>/slaves (recursive; skips >1-disk); the builtin local dir on the LVM
root gets a SMART-only device from its containing filesystem (never touches
backing/durable_id). SmartSummary.ModelName captured from smartctl. Fix C (-d sat)
stays rejected. Tests + red-proofs (dm multi-disk skip, enrich smartHint, union
routing); Known-path-never-SMARTs asserted.
This commit is contained in:
2026-07-25 08:21:45 +02:00
parent 643899c191
commit ed97232598
13 changed files with 579 additions and 74 deletions
+79 -61
View File
@@ -54,6 +54,13 @@ type StorageView interface {
Observe(ctx context.Context) ([]hub.StorageTarget, error)
}
// SmartReader (v0.95.0, Fix B) reads per-disk SMART for the /disks union path so registry/USB drives
// that ride the union (not Observe's enrich) still get a health verdict. A zero-value summary
// (Health "") means "could not read". Satisfied by *storage.SmartReader.
type SmartReader interface {
SMARTForBacking(ctx context.Context, backingDevice string) hub.SmartSummary
}
// TokenAuthority resolves a presented bearer token to its guest VMID. Satisfied by *TokenStore.
type TokenAuthority interface {
Lookup(token string) (int, bool)
@@ -77,7 +84,11 @@ type Options struct {
// 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
// Smart (v0.95.0, Fix B) reads per-disk SMART for the /disks UNION path — registry/USB drives ride
// the union (not Observe's enrich), so without this they carry no health verdict. OPTIONAL; nil →
// union rows have no SMART (pre-v0.95.0 behavior). Satisfied by *storage.SmartReader.
Smart SmartReader
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
// safe default (24h). The hub-served policy is slice 10; this is the agent-local cadence.
@@ -175,33 +186,34 @@ type backupJob struct {
// Server is the per-guest local API (doc 03 §6). It serves the agent's pinned self-signed leaf
// and authorizes every request against the token's guest only.
type Server struct {
addr string
cert tls.Certificate
guests GuestAPI
backups BackupService
store BackupStore
addr string
cert tls.Certificate
guests GuestAPI
backups BackupService
store BackupStore
storage StorageView
driveTargets storage.KnownTargets // Impl-2a: registry+units drives for /disks (optional)
tokens TokenAuthority
cadence time.Duration
logger *slog.Logger
now func() time.Time
smart SmartReader // v0.95.0 Fix B: SMART for the /disks union path (optional)
tokens TokenAuthority
cadence time.Duration
logger *slog.Logger
now func() time.Time
disks DiskOps // slice 8C (optional)
diskGate StorageGate // slice 8C (optional)
guestList GuestLister // slice 8C (optional)
guestAttach GuestAttacher // slice 10 P2 (optional)
mem MemoryOps // v0.90.0 R-24 guest RAM resize (optional)
memMu sync.Mutex // single-flight around a resize apply (one customer per host)
netStorage NetworkStorageOps // Part A1: NAS network mounts (optional)
netMountRoot string // the user-data namespace root for the network-mount role gate
smbCredsDir string // where SMB creds files are written (out-of-band, 0600)
escrowStagePath string // fork-4: 0600 staging file for the pushed restic repo password
intent IntentRecorder // slice 10 P3 (optional)
guestBinds *GuestBindStore // F9 startup bind re-assert record (optional)
formatJobs *FormatJobStore // F20-BUG3 detached-format job record (optional)
staleLock StaleLockController // F2-b startup stale-lock recovery (optional)
host storage.HostReader // role classification source (optional; defaults to ProcHostReader)
disks DiskOps // slice 8C (optional)
diskGate StorageGate // slice 8C (optional)
guestList GuestLister // slice 8C (optional)
guestAttach GuestAttacher // slice 10 P2 (optional)
mem MemoryOps // v0.90.0 R-24 guest RAM resize (optional)
memMu sync.Mutex // single-flight around a resize apply (one customer per host)
netStorage NetworkStorageOps // Part A1: NAS network mounts (optional)
netMountRoot string // the user-data namespace root for the network-mount role gate
smbCredsDir string // where SMB creds files are written (out-of-band, 0600)
escrowStagePath string // fork-4: 0600 staging file for the pushed restic repo password
intent IntentRecorder // slice 10 P3 (optional)
guestBinds *GuestBindStore // F9 startup bind re-assert record (optional)
formatJobs *FormatJobStore // F20-BUG3 detached-format job record (optional)
staleLock StaleLockController // F2-b startup stale-lock recovery (optional)
host storage.HostReader // role classification source (optional; defaults to ProcHostReader)
hostMetrics HostMetricsProvider // slice 9 (optional)
hostID string // slice 10B: for the data-bearing-format pending-op hint
@@ -212,6 +224,10 @@ type Server struct {
// inline customer-confirmed wipe (durable id → current device, re-derive+match,
// re-inspect). Defaults to s.reresolveDurableForWipe (real storage funcs); tests
// override it to avoid touching real /dev.
// resolveStorageDevice maps a durable id (uuid:<fs-uuid>) to its /dev node for the /disks union
// path. Defaults to storage.ResolveStorageDevice (hits /dev/disk/by-*); tests override it.
resolveStorageDevice func(durableID string) (string, error)
reresolveWipe func(ctx context.Context, durableID string) (string, error)
// reresolveBlank is the BLANK-format sibling (audit D3): same anti-retarget
@@ -255,13 +271,13 @@ type Server struct {
// holder. R lives ONLY in escrowR (never in the job struct — snapshots must be structurally
// incapable of carrying it) and is zeroed on claim, supersede, or TTL expiry. See
// escrow_ceremony.go for the custody rules.
escrowCeremony *EscrowCeremonyConfig
escrowMu sync.Mutex
escrowJob *escrowCeremonyJob
escrowR []byte
escrowRClaimed bool
escrowRExpiry time.Time
escrowDone <-chan struct{} // closes when the detached job finishes (tests wait on it)
escrowCeremony *EscrowCeremonyConfig
escrowMu sync.Mutex
escrowJob *escrowCeremonyJob
escrowR []byte
escrowRClaimed bool
escrowRExpiry time.Time
escrowDone <-chan struct{} // closes when the detached job finishes (tests wait on it)
// ceremonyRun executes the fixed-argv sudo self-invocation (tests inject canned JSON).
ceremonyRun ceremonyRunner
// escrowSudoCheck is the preflight's list-mode grant probe (`sudo -n -l -- <argv>`).
@@ -290,37 +306,38 @@ func NewServer(o Options) (*Server, error) {
cadence = defaultBackupCadence
}
s := &Server{
addr: o.ListenAddr,
cert: o.Cert,
guests: o.Guests,
backups: o.Backups,
store: o.Store,
storage: o.Storage,
driveTargets: o.DriveTargets,
tokens: o.Tokens,
cadence: cadence,
logger: o.Logger,
now: func() time.Time { return time.Now().UTC() },
disks: o.Disks,
diskGate: o.DiskGate,
guestList: o.Guests2,
guestAttach: o.GuestAttach,
mem: o.Memory,
netStorage: o.NetStorage,
netMountRoot: storage.NetworkMountRoot,
addr: o.ListenAddr,
cert: o.Cert,
guests: o.Guests,
backups: o.Backups,
store: o.Store,
storage: o.Storage,
driveTargets: o.DriveTargets,
smart: o.Smart,
tokens: o.Tokens,
cadence: cadence,
logger: o.Logger,
now: func() time.Time { return time.Now().UTC() },
disks: o.Disks,
diskGate: o.DiskGate,
guestList: o.Guests2,
guestAttach: o.GuestAttach,
mem: o.Memory,
netStorage: o.NetStorage,
netMountRoot: storage.NetworkMountRoot,
smbCredsDir: o.SmbCredsDir,
escrowStagePath: o.EscrowStagePath,
intent: o.Intent,
guestBinds: o.GuestBinds,
formatJobs: o.FormatJobs,
staleLock: o.StaleLock,
host: o.HostReader,
hostMetrics: o.HostMetrics,
hostID: o.HostID,
agentVersion: o.AgentVersion,
logRing: o.LogRing,
jobs: map[int]*backupJob{},
swapInFlight: map[int]bool{},
intent: o.Intent,
guestBinds: o.GuestBinds,
formatJobs: o.FormatJobs,
staleLock: o.StaleLock,
host: o.HostReader,
hostMetrics: o.HostMetrics,
hostID: o.HostID,
agentVersion: o.AgentVersion,
logRing: o.LogRing,
jobs: map[int]*backupJob{},
swapInFlight: map[int]bool{},
}
if s.escrowStagePath == "" {
s.escrowStagePath = escrow.StagedResticPasswordPath()
@@ -328,6 +345,7 @@ func NewServer(o Options) (*Server, error) {
s.reresolveWipe = s.reresolveDurableForWipe
s.reresolveBlank = s.reresolveDurableForBlankFormat
s.deviceDurableID = storage.DeviceDurableID
s.resolveStorageDevice = storage.ResolveStorageDevice
s.netTrigger = triggerNetMount
s.netMounted = storage.NetworkMountedAt
s.netJournal = readUnitJournal