27b68f043b
Fill the slice-3 storage_targets stub and add the fast-poll storage watchdog. Read-only this phase; the host-root surface (mounts/SMART/grow/destructive gate) is Phase B. Hub-owned desired manifest is slice 10, so reconcile against it is built-but-unfed. - internal/storage: StorageTarget wire contract, durable_id derivation per type, HostReader seam (procfs/sysfs, root-free), Observer (storage_targets from ListStorage/NodeStorage + host reads, lvmthin thin-pool fill), and the watchdog (third daemon goroutine; debounced out-of-band report on a known target's attach/disconnect transition). - proxmox.Storage: additive parse-only config fields (durable_id sources). - collector StorageObserver seam; Loop.SetTrigger out-of-band report; daemon runs the watchdog as a third goroutine; StorageConfig knobs. - cross-repo golden kept byte-identical with felhom.eu/hub; bidirectional key-set test. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
232 lines
7.0 KiB
Go
232 lines
7.0 KiB
Go
package storage
|
|
|
|
import (
|
|
"bufio"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
)
|
|
|
|
// HostReader is the non-privileged host-read seam the observer and watchdog need. All of
|
|
// it is read-only and root-free: the active mount table, fs-UUID resolution via the
|
|
// /dev/disk/by-uuid symlinks, block-device presence, and the rotational/removable sysfs
|
|
// flags. Production is *ProcHostReader; tests inject a fake.
|
|
//
|
|
// Anything that needs root (smartctl, lvs, blkid) is NOT here — it lands on Phase B's
|
|
// privileged HostOps surface. Keep this seam root-free.
|
|
type HostReader interface {
|
|
// Mounts parses the active mount table (/proc/mounts).
|
|
Mounts() ([]Mount, error)
|
|
// ResolveUUID returns the filesystem UUID of a block device, derived from the
|
|
// /dev/disk/by-uuid symlinks. ok=false when the device has no by-uuid entry.
|
|
ResolveUUID(device string) (uuid string, ok bool)
|
|
// DeviceExists reports whether a block-device node is present (the fast USB-drop
|
|
// signal for the watchdog).
|
|
DeviceExists(device string) bool
|
|
// Rotational reads the backing disk's rotational flag (true=HDD/slow, false=SSD/fast).
|
|
// ok=false when it cannot be determined (network fs, missing sysfs, device-mapper).
|
|
Rotational(device string) (rotational bool, ok bool)
|
|
// Removable reads the backing disk's removable flag (true => a USB/hot-plug device).
|
|
// ok=false when it cannot be determined.
|
|
Removable(device string) (removable bool, ok bool)
|
|
}
|
|
|
|
// Mount is one active-mount-table entry.
|
|
type Mount struct {
|
|
Device string // e.g. "/dev/sdb1", "server:/export", "//server/share"
|
|
MountPoint string
|
|
FSType string
|
|
}
|
|
|
|
// ProcHostReader is the production HostReader: it reads the host's /proc, /dev, and /sys.
|
|
// The paths are fields so tests CAN point it at fixtures, though most tests use a fully
|
|
// fake HostReader instead.
|
|
type ProcHostReader struct {
|
|
ProcMounts string // default "/proc/mounts"
|
|
ByUUIDDir string // default "/dev/disk/by-uuid"
|
|
SysClass string // default "/sys/class/block"
|
|
}
|
|
|
|
// NewProcHostReader builds a ProcHostReader with the standard host paths.
|
|
func NewProcHostReader() *ProcHostReader {
|
|
return &ProcHostReader{
|
|
ProcMounts: "/proc/mounts",
|
|
ByUUIDDir: "/dev/disk/by-uuid",
|
|
SysClass: "/sys/class/block",
|
|
}
|
|
}
|
|
|
|
// Mounts parses /proc/mounts. The format is space-separated, octal-escaped fields:
|
|
// device mountpoint fstype options dump pass. We unescape the first three.
|
|
func (r *ProcHostReader) Mounts() ([]Mount, error) {
|
|
f, err := os.Open(r.procMounts())
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer f.Close()
|
|
|
|
var out []Mount
|
|
sc := bufio.NewScanner(f)
|
|
for sc.Scan() {
|
|
fields := strings.Fields(sc.Text())
|
|
if len(fields) < 3 {
|
|
continue
|
|
}
|
|
out = append(out, Mount{
|
|
Device: unescapeMount(fields[0]),
|
|
MountPoint: unescapeMount(fields[1]),
|
|
FSType: fields[2],
|
|
})
|
|
}
|
|
return out, sc.Err()
|
|
}
|
|
|
|
// ResolveUUID reverse-maps a device path to its fs-UUID by reading the /dev/disk/by-uuid
|
|
// symlinks and matching the canonical target of each against the device.
|
|
func (r *ProcHostReader) ResolveUUID(device string) (string, bool) {
|
|
if device == "" {
|
|
return "", false
|
|
}
|
|
want := canonPath(device)
|
|
entries, err := os.ReadDir(r.byUUIDDir())
|
|
if err != nil {
|
|
return "", false
|
|
}
|
|
for _, e := range entries {
|
|
link := filepath.Join(r.byUUIDDir(), e.Name())
|
|
target, err := os.Readlink(link)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
if !filepath.IsAbs(target) {
|
|
target = filepath.Join(r.byUUIDDir(), target)
|
|
}
|
|
if canonPath(target) == want {
|
|
return e.Name(), true
|
|
}
|
|
}
|
|
return "", false
|
|
}
|
|
|
|
// DeviceExists stats the device node (after resolving symlinks like /dev/disk/by-uuid/X).
|
|
func (r *ProcHostReader) DeviceExists(device string) bool {
|
|
if device == "" {
|
|
return false
|
|
}
|
|
_, err := os.Stat(device)
|
|
return err == nil
|
|
}
|
|
|
|
// Rotational reads /sys/block/<parent-disk>/queue/rotational for the device's backing
|
|
// disk. "1" => rotational (HDD/slow), "0" => SSD/fast.
|
|
func (r *ProcHostReader) Rotational(device string) (bool, bool) {
|
|
disk, ok := r.parentDisk(device)
|
|
if !ok {
|
|
return false, false
|
|
}
|
|
b, err := os.ReadFile(filepath.Join(r.sysBlockDir(), disk, "queue", "rotational"))
|
|
if err != nil {
|
|
return false, false
|
|
}
|
|
switch strings.TrimSpace(string(b)) {
|
|
case "1":
|
|
return true, true
|
|
case "0":
|
|
return false, true
|
|
}
|
|
return false, false
|
|
}
|
|
|
|
// Removable reads /sys/block/<parent-disk>/removable. "1" => removable (USB/hot-plug).
|
|
func (r *ProcHostReader) Removable(device string) (bool, bool) {
|
|
disk, ok := r.parentDisk(device)
|
|
if !ok {
|
|
return false, false
|
|
}
|
|
b, err := os.ReadFile(filepath.Join(r.sysBlockDir(), disk, "removable"))
|
|
if err != nil {
|
|
return false, false
|
|
}
|
|
switch strings.TrimSpace(string(b)) {
|
|
case "1":
|
|
return true, true
|
|
case "0":
|
|
return false, true
|
|
}
|
|
return false, false
|
|
}
|
|
|
|
// parentDisk maps a device path (possibly a partition like /dev/sdb1 or /dev/nvme0n1p2)
|
|
// to its parent disk's sysfs name (sdb / nvme0n1). It uses /sys/class/block/<name>, whose
|
|
// real path ends in .../<disk>/<partition> for a partition and .../<disk> for a whole disk.
|
|
func (r *ProcHostReader) parentDisk(device string) (string, bool) {
|
|
name := filepath.Base(strings.TrimSpace(device))
|
|
if name == "" || name == "." || name == "/" {
|
|
return "", false
|
|
}
|
|
real, err := filepath.EvalSymlinks(filepath.Join(r.sysClass(), name))
|
|
if err != nil {
|
|
return "", false
|
|
}
|
|
// If <name> is a partition, /sys/class/block/<name>/partition exists and its parent
|
|
// directory is the disk. Otherwise <name> IS the disk.
|
|
if _, err := os.Stat(filepath.Join(real, "partition")); err == nil {
|
|
return filepath.Base(filepath.Dir(real)), true
|
|
}
|
|
return filepath.Base(real), true
|
|
}
|
|
|
|
// sysBlockDir derives /sys/block from the configured /sys/class/block.
|
|
func (r *ProcHostReader) sysBlockDir() string {
|
|
return filepath.Join(filepath.Dir(filepath.Dir(r.sysClass())), "block")
|
|
}
|
|
|
|
func (r *ProcHostReader) procMounts() string {
|
|
if r.ProcMounts != "" {
|
|
return r.ProcMounts
|
|
}
|
|
return "/proc/mounts"
|
|
}
|
|
func (r *ProcHostReader) byUUIDDir() string {
|
|
if r.ByUUIDDir != "" {
|
|
return r.ByUUIDDir
|
|
}
|
|
return "/dev/disk/by-uuid"
|
|
}
|
|
func (r *ProcHostReader) sysClass() string {
|
|
if r.SysClass != "" {
|
|
return r.SysClass
|
|
}
|
|
return "/sys/class/block"
|
|
}
|
|
|
|
// canonPath resolves symlinks for a best-effort canonical comparison, falling back to the
|
|
// cleaned path when the target can't be resolved (e.g. the device just disappeared).
|
|
func canonPath(p string) string {
|
|
if real, err := filepath.EvalSymlinks(p); err == nil {
|
|
return real
|
|
}
|
|
return filepath.Clean(p)
|
|
}
|
|
|
|
// unescapeMount decodes the octal \040-style escapes /proc/mounts uses for spaces, tabs,
|
|
// newlines and backslashes in the device/mountpoint fields.
|
|
func unescapeMount(s string) string {
|
|
if !strings.Contains(s, `\`) {
|
|
return s
|
|
}
|
|
var b strings.Builder
|
|
for i := 0; i < len(s); i++ {
|
|
if s[i] == '\\' && i+3 < len(s) && isOctal(s[i+1]) && isOctal(s[i+2]) && isOctal(s[i+3]) {
|
|
v := (int(s[i+1]-'0') << 6) | (int(s[i+2]-'0') << 3) | int(s[i+3]-'0')
|
|
b.WriteByte(byte(v))
|
|
i += 3
|
|
continue
|
|
}
|
|
b.WriteByte(s[i])
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
func isOctal(c byte) bool { return c >= '0' && c <= '7' }
|