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:
@@ -255,10 +255,17 @@ func (s *Server) handleDisks(w http.ResponseWriter, r *http.Request, vmid int) {
|
||||
if d.UUID != "" {
|
||||
// Resolve to the real /dev node (e.g. /dev/sdd), not the by-uuid symlink path, to match
|
||||
// how Observe-sourced rows display the backing device.
|
||||
if dev, err := storage.ResolveStorageDevice("uuid:" + d.UUID); err == nil {
|
||||
if dev, err := s.resolveStorageDevice("uuid:" + d.UUID); err == nil {
|
||||
di.BackingDevice = dev
|
||||
}
|
||||
}
|
||||
// Fix B (v0.95.0): union-path drives skip Observe's enrich, so read SMART here through the
|
||||
// same seam the dir targets use. Only set when the read actually ran (Health != "").
|
||||
if di.BackingDevice != "" && s.smart != nil {
|
||||
if sm := s.smart.SMARTForBacking(r.Context(), di.BackingDevice); sm.Health != "" {
|
||||
di.Smart = &sm
|
||||
}
|
||||
}
|
||||
if total, used, okc := statfsCapacity(d.MountPath); okc {
|
||||
di.TotalBytes, di.UsedBytes = total, used
|
||||
di.UsedFraction = float64(used) / float64(total)
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
package localapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
@@ -62,3 +65,75 @@ func TestDisks_SmartSerialized(t *testing.T) {
|
||||
t.Errorf("nosmart disk: smart must be omitted when Health is empty, got %+v", byName["nosmart"].Smart)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Fix B (v0.95.0): the /disks union path reads SMART for registry/USB drives ----
|
||||
|
||||
type fakeKnownTargets struct{ drives []storage.KnownTarget }
|
||||
|
||||
func (f fakeKnownTargets) Known(context.Context) ([]storage.KnownTarget, error) { return f.drives, nil }
|
||||
|
||||
type fakeSmartReader struct {
|
||||
byDev map[string]hub.SmartSummary
|
||||
calls []string
|
||||
}
|
||||
|
||||
func (f *fakeSmartReader) SMARTForBacking(_ context.Context, dev string) hub.SmartSummary {
|
||||
f.calls = append(f.calls, dev)
|
||||
if s, ok := f.byDev[dev]; ok {
|
||||
return s
|
||||
}
|
||||
return hub.SmartSummary{}
|
||||
}
|
||||
|
||||
func sp(s string) *string { return &s }
|
||||
|
||||
// A union-path (registry/USB) drive now gets a real SMART read + model, via the Smart seam — it used
|
||||
// to ride the enrich-free union path and show "Nincs adat".
|
||||
// Red-proof: delete the Fix-B block in handleDisks (the s.smart.SMARTForBacking call) → the union
|
||||
// drive carries no smart and this fails.
|
||||
func TestDisks_UnionPathReadsSMART(t *testing.T) {
|
||||
d := &fakeDiskOps{probe: storage.DeviceProbe{Probed: true}}
|
||||
sm := &fakeSmartReader{byDev: map[string]hub.SmartSummary{
|
||||
"/dev/sdb1": {Health: hub.SmartPassed, ModelName: sp("TOSHIBA MQ04ABF100")},
|
||||
}}
|
||||
srv, err := NewServer(Options{
|
||||
ListenAddr: "127.0.0.1:0",
|
||||
Guests: &fakeGuests{},
|
||||
Backups: &fakeBackups{},
|
||||
Store: &fakeStore{},
|
||||
Storage: fakeStorage{}, // no Observe targets → the union drive is not deduped away
|
||||
DriveTargets: fakeKnownTargets{drives: []storage.KnownTarget{
|
||||
{Name: "data-usb", Type: hub.StorageTypeUSB, MountPath: "/mnt/hdd_1", DurableID: "uuid:47a3", UUID: "47a3"},
|
||||
}},
|
||||
Smart: sm,
|
||||
Tokens: staticTokens{"A": 8200},
|
||||
Disks: d,
|
||||
HostReader: sysOnSDA(),
|
||||
Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("new server: %v", err)
|
||||
}
|
||||
srv.baseCtx = context.Background()
|
||||
srv.resolveStorageDevice = func(string) (string, error) { return "/dev/sdb1", nil }
|
||||
|
||||
disks := decodeDisks(t, do(t, srv.Handler(), "GET", "/disks", "A", "").Body.Bytes())
|
||||
var usb *DiskInfo
|
||||
for i := range disks {
|
||||
if disks[i].Name == "data-usb" {
|
||||
usb = &disks[i]
|
||||
}
|
||||
}
|
||||
if usb == nil {
|
||||
t.Fatalf("union drive not in /disks: %+v", disks)
|
||||
}
|
||||
if usb.Smart == nil || usb.Smart.Health != hub.SmartPassed {
|
||||
t.Fatalf("union drive SMART not read: %+v", usb.Smart)
|
||||
}
|
||||
if usb.Smart.ModelName == nil || *usb.Smart.ModelName != "TOSHIBA MQ04ABF100" {
|
||||
t.Errorf("union drive model not carried: %v", usb.Smart)
|
||||
}
|
||||
if len(sm.calls) != 1 || sm.calls[0] != "/dev/sdb1" {
|
||||
t.Errorf("SMART should be read once on /dev/sdb1, got %v", sm.calls)
|
||||
}
|
||||
}
|
||||
|
||||
+79
-61
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user