703db166e7
gates / gates (push) Successful in 8s
After a rebuild the customer's own drives could not be re-attached: candidates returned initialize:[] attach:[] while both drives sat there, and the deploy refused with 'choose an attached drive from the list' — a list that was empty. Measured live three times. Mechanism: enrolment mounts a drive TWICE, at /mnt/felhom-drives/<name> and at the raw /mnt/<name> it creates on the host. The host survives a guest rebuild; the controller's registry does not. So classifyClaim saw a mount outside the managed prefix and concluded 'claimed by something else' — about our own mount. The fix is CORROBORATED, not a widened prefix: a non-managed mountpoint is forgiven only when the SAME device is also mounted under the managed path, a pairing only our enrolment produces. A disk another system uses — /srv/data, /media/x, even /mnt/someone-elses-disk — has no counterpart and is STILL refused, with its own test and a red-proof showing an over-wide fix offering it for formatting. Read from /proc/mounts deliberately: the lsblk invocation is pinned verbatim in configs/felhom-agent.sudoers, so using the plural MOUNTPOINTS would have coupled this to a sudoers rollout. /proc/mounts is world-readable — no sudo, no new allowlisted command, no config change. Fail-safe: an unreadable mount table corroborates NOTHING, so the device classifies exactly as before. 'Could not corroborate' must never read as 'ours'. 29 packages ok, vet clean, agent gates OK.
577 lines
24 KiB
Go
577 lines
24 KiB
Go
package storage
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"log/slog"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"gitea.dooplex.hu/admin/felhom-agent/internal/hub"
|
|
"gitea.dooplex.hu/admin/felhom-agent/internal/proxmox"
|
|
)
|
|
|
|
// HostOps is the narrow privileged host surface (slice 5 Phase B) — the ONE place the
|
|
// agent steps outside its Proxmox API token into OS-root. Production is *SudoHostOps
|
|
// (shells out via a sudoers allowlist, arg vectors, no shell); tests use a fake, so NO
|
|
// real root runs in the test suite.
|
|
//
|
|
// Every method validates its arguments (validate.go) before constructing a command. The
|
|
// surface is deliberately tiny: persistent mounts (systemd .mount units keyed by fs-UUID),
|
|
// detach (stop+disable the unit), SMART, and thin-pool metadata.
|
|
type HostOps interface {
|
|
// EnsureMount writes + enables a systemd .mount unit for spec (idempotent: re-applying
|
|
// an existing target is a no-op). Benign — additive, no signature.
|
|
EnsureMount(ctx context.Context, spec MountSpec) error
|
|
// Unmount stops + disables the .mount unit for a mountpoint (detach from service). This
|
|
// is DESTRUCTIVE (deliberately removing a target) — the caller MUST have routed it
|
|
// through the gate first; HostOps only performs an already-authorized op.
|
|
Unmount(ctx context.Context, where string) error
|
|
// SMART returns a parsed health summary for a raw block device, degrading to
|
|
// {Health: UNKNOWN} when the device exposes no SMART (e.g. a USB-SATA bridge).
|
|
SMART(ctx context.Context, device string) (hub.SmartSummary, error)
|
|
// ThinPoolMetadata returns the lvmthin pool's metadata-used fraction (0..1) via lvs.
|
|
// ok=false when it cannot be read (the field stays null in the report).
|
|
ThinPoolMetadata(ctx context.Context, vg, pool string) (fraction float64, ok bool)
|
|
// InspectDevice probes a block device for data-bearing evidence (filesystem signature,
|
|
// partition table, partitions, mounted) — the AGENT-INTERNAL evidence the 8C classifier
|
|
// uses, NEVER the caller's claim. Conservative: a failed/ambiguous probe → DataBearing()
|
|
// true (fail-safe). This is the read that decides whether a format is benign or destructive.
|
|
InspectDevice(ctx context.Context, device string) (DeviceProbe, error)
|
|
// Format runs mkfs.<fstype> on a (validated) device. DESTRUCTIVE to whatever is on the
|
|
// device — the caller MUST have classified it non-data-bearing AND/OR routed it through the
|
|
// gate first; HostOps only performs an already-authorized format.
|
|
Format(ctx context.Context, device, fstype string) error
|
|
}
|
|
|
|
// DeviceProbe is the result of inspecting a block device for data-bearing evidence (8C). The
|
|
// agent decides data-bearing-ness from THIS (its own device read), never from the caller's claim.
|
|
type DeviceProbe struct {
|
|
Device string `json:"device"`
|
|
Probed bool `json:"probed"` // false = the probe failed/was ambiguous → treat as data-bearing
|
|
HasFilesystem bool `json:"has_filesystem"` // a filesystem signature (blkid TYPE / USAGE)
|
|
HasPartitionTable bool `json:"has_partition_table"` // a partition table (blkid PTTYPE)
|
|
HasPartitions bool `json:"has_partitions"` // child partitions present (lsblk)
|
|
Mounted bool `json:"mounted"` // currently mounted somewhere
|
|
FSType string `json:"fstype,omitempty"`
|
|
}
|
|
|
|
// DataBearing is the conservative verdict: any signature / partition table / partition / mount —
|
|
// OR a probe that did not complete cleanly — makes the device data-bearing. Only a device that
|
|
// probed cleanly AND shows none of those is considered blank (benign to format).
|
|
func (p DeviceProbe) DataBearing() bool {
|
|
if !p.Probed {
|
|
return true // fail-safe: never call an unprobed device blank
|
|
}
|
|
return p.HasFilesystem || p.HasPartitionTable || p.HasPartitions || p.Mounted
|
|
}
|
|
|
|
// Reason returns a short human string for why the device is data-bearing (for the UI/audit).
|
|
func (p DeviceProbe) Reason() string {
|
|
switch {
|
|
case !p.Probed:
|
|
return "device could not be reliably inspected"
|
|
case p.Mounted:
|
|
return "device is mounted"
|
|
case p.HasFilesystem:
|
|
return "device has a " + p.FSType + " filesystem"
|
|
case p.HasPartitionTable:
|
|
return "device has a partition table"
|
|
case p.HasPartitions:
|
|
return "device has partitions"
|
|
default:
|
|
return "device is blank"
|
|
}
|
|
}
|
|
|
|
// MountSpec describes a persistent by-UUID mount.
|
|
type MountSpec struct {
|
|
Name string // storage name (unit Description only)
|
|
UUID string // filesystem UUID — validated; becomes What=/dev/disk/by-uuid/<UUID>
|
|
Where string // mountpoint — validated; the unit name is derived from it
|
|
FSType string // optional Type=
|
|
Options string // optional Options=
|
|
}
|
|
|
|
// Binaries holds the absolute paths of the allow-listed binaries (overridable from config
|
|
// so the sudoers entries and the agent agree on exact paths).
|
|
type Binaries struct {
|
|
Systemctl string
|
|
Install string
|
|
Smartctl string
|
|
Lvs string
|
|
Blkid string // device signature probe (8C data-bearing detection)
|
|
Lsblk string // partition/mount topology (8C)
|
|
MkfsExt4 string // 8C format executor (ext4) — now invoked by the guarded wrapper, not the agent directly
|
|
MkfsXfs string // 8C format executor (xfs) — now invoked by the guarded wrapper, not the agent directly
|
|
MkfsGuarded string // Impl-1 Part B: the guarded-mkfs wrapper the agent execs (device+fstype)
|
|
Pvs string // Impl-1 claim filter: LVM physical-volume enumeration (read-only)
|
|
Zpool string // Impl-1 claim filter: ZFS pool member enumeration (read-only)
|
|
}
|
|
|
|
func (b Binaries) withDefaults() Binaries {
|
|
if b.Systemctl == "" {
|
|
b.Systemctl = "/usr/bin/systemctl"
|
|
}
|
|
if b.Install == "" {
|
|
b.Install = "/usr/bin/install"
|
|
}
|
|
if b.Smartctl == "" {
|
|
b.Smartctl = "/usr/sbin/smartctl"
|
|
}
|
|
if b.Lvs == "" {
|
|
b.Lvs = "/usr/sbin/lvs"
|
|
}
|
|
if b.Blkid == "" {
|
|
b.Blkid = "/usr/sbin/blkid"
|
|
}
|
|
if b.Lsblk == "" {
|
|
b.Lsblk = "/usr/bin/lsblk"
|
|
}
|
|
if b.MkfsExt4 == "" {
|
|
b.MkfsExt4 = "/usr/sbin/mkfs.ext4"
|
|
}
|
|
if b.MkfsXfs == "" {
|
|
b.MkfsXfs = "/usr/sbin/mkfs.xfs"
|
|
}
|
|
if b.MkfsGuarded == "" {
|
|
b.MkfsGuarded = "/usr/local/sbin/felhom-mkfs-guarded"
|
|
}
|
|
if b.Pvs == "" {
|
|
b.Pvs = "/usr/sbin/pvs"
|
|
}
|
|
if b.Zpool == "" {
|
|
b.Zpool = "/usr/sbin/zpool"
|
|
}
|
|
return b
|
|
}
|
|
|
|
// SudoHostOps is the production HostOps: it stages a unit file the agent owns, then uses
|
|
// the sudoers allowlist (`install` it into the unit dir, `systemctl` to manage it,
|
|
// `smartctl`/`lvs` to read). The Runner execs with an arg vector (no shell) — see
|
|
// proxmox.ExecRunner in RunnerSudo mode.
|
|
type SudoHostOps struct {
|
|
runner proxmox.Runner
|
|
bins Binaries
|
|
unitDir string // where enabled units live (e.g. /etc/systemd/system)
|
|
stageDir string // agent-owned staging dir for unit files before install
|
|
host HostReader // root-free reads (mount table) for the Impl-1 Format claim guard
|
|
logger *slog.Logger
|
|
// unitFailed reports whether a systemd unit is in the failed state (incl. start-limit-hit). An
|
|
// UNPRIVILEGED read (`systemctl is-failed`) — seam-injected so the reassert's F10 reset-failed path
|
|
// is unit-testable without a real systemd. Default set in NewSudoHostOps.
|
|
unitFailed func(ctx context.Context, unit string) bool
|
|
// mountTable (R-220) yields the host mount table for the "is this mount Felhom's own?"
|
|
// corroboration. nil ⇒ the real /proc/mounts; tests inject.
|
|
mountTable mountTableSource
|
|
}
|
|
|
|
// SudoHostOpsConfig configures a SudoHostOps.
|
|
type SudoHostOpsConfig struct {
|
|
Runner proxmox.Runner
|
|
Bins Binaries
|
|
UnitDir string // default /etc/systemd/system
|
|
StageDir string // default <dataDir>/units; must be agent-writable
|
|
Host HostReader // default NewProcHostReader(); the Format claim guard's mount-table read
|
|
Logger *slog.Logger
|
|
}
|
|
|
|
// NewSudoHostOps builds the production privileged surface.
|
|
func NewSudoHostOps(cfg SudoHostOpsConfig) *SudoHostOps {
|
|
unitDir := cfg.UnitDir
|
|
if unitDir == "" {
|
|
unitDir = "/etc/systemd/system"
|
|
}
|
|
stageDir := cfg.StageDir
|
|
if stageDir == "" {
|
|
stageDir = "/var/lib/felhom-agent/units"
|
|
}
|
|
logger := cfg.Logger
|
|
if logger == nil {
|
|
logger = slog.Default()
|
|
}
|
|
host := cfg.Host
|
|
if host == nil {
|
|
host = NewProcHostReader()
|
|
}
|
|
return &SudoHostOps{
|
|
runner: cfg.Runner,
|
|
bins: cfg.Bins.withDefaults(),
|
|
unitDir: unitDir,
|
|
stageDir: stageDir,
|
|
host: host,
|
|
logger: logger,
|
|
unitFailed: systemctlIsFailed,
|
|
}
|
|
}
|
|
|
|
// systemctlIsFailed is the production unitFailed: `systemctl is-failed <unit>` prints "failed" for a
|
|
// failed/start-limit-hit unit and exits nonzero otherwise. UNPRIVILEGED (unit state is world-readable)
|
|
// — deliberately NOT routed through the sudo runner, so it needs no sudoers grant.
|
|
func systemctlIsFailed(ctx context.Context, unit string) bool {
|
|
out, _ := exec.CommandContext(ctx, "systemctl", "is-failed", "--", unit).Output()
|
|
return strings.TrimSpace(string(out)) == "failed"
|
|
}
|
|
|
|
// EnsureMount validates, renders, stages, installs and enables the .mount unit.
|
|
func (h *SudoHostOps) EnsureMount(ctx context.Context, spec MountSpec) error {
|
|
// VALIDATE FIRST — refuse before constructing any command.
|
|
if err := ValidateUUID(spec.UUID); err != nil {
|
|
return err
|
|
}
|
|
if err := ValidateMountPath(spec.Where); err != nil {
|
|
return err
|
|
}
|
|
if err := validateUnitOpt(spec.FSType); err != nil {
|
|
return fmt.Errorf("storage: fstype: %w", err)
|
|
}
|
|
if err := validateUnitOpt(spec.Options); err != nil {
|
|
return fmt.Errorf("storage: mount options: %w", err)
|
|
}
|
|
unitName, err := UnitNameForMount(spec.Where)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
content := renderMountUnit(spec) // uses the validated fields only
|
|
|
|
// Stage the unit file (agent-owned dir; no root needed for this write).
|
|
if err := os.MkdirAll(h.stageDir, 0o700); err != nil {
|
|
return fmt.Errorf("storage: staging dir: %w", err)
|
|
}
|
|
stagePath := filepath.Join(h.stageDir, unitName)
|
|
if err := os.WriteFile(stagePath, []byte(content), 0o644); err != nil {
|
|
return fmt.Errorf("storage: staging unit: %w", err)
|
|
}
|
|
|
|
dest := filepath.Join(h.unitDir, unitName)
|
|
// install as root (atomic copy with fixed mode/owner) — fixed arg vector.
|
|
if err := h.run(ctx, h.bins.Install, "-o", "root", "-g", "root", "-m", "0644", "--", stagePath, dest); err != nil {
|
|
return fmt.Errorf("storage: installing unit %s: %w", unitName, err)
|
|
}
|
|
if err := h.run(ctx, h.bins.Systemctl, "daemon-reload"); err != nil {
|
|
return fmt.Errorf("storage: daemon-reload: %w", err)
|
|
}
|
|
// enable --now both mounts now and persists across reboot. Idempotent.
|
|
if err := h.run(ctx, h.bins.Systemctl, "enable", "--now", "--", unitName); err != nil {
|
|
return fmt.Errorf("storage: enabling mount %s: %w", unitName, err)
|
|
}
|
|
h.logger.Info("storage: ensured mount", "name", spec.Name, "where", spec.Where, "unit", unitName)
|
|
return nil
|
|
}
|
|
|
|
// ReassertEnrolledMounts re-mounts every enrolled drive whose backing device is present (resolved FRESH
|
|
// by filesystem UUID, never a cached /dev node) but whose systemd mount unit is not currently mounted —
|
|
// the host-reboot remount fix. On a host reboot a unit left `disabled` by a prior detach never
|
|
// auto-mounts, and kernel re-enumeration can move the device (/dev/sdb→sdc); re-running EnsureMount
|
|
// (idempotent `enable --now`, What=/dev/disk/by-uuid/<UUID>) re-enables the unit AND mounts the CURRENT
|
|
// device by UUID, so a letter reshuffle is a no-op. Idempotent + cheap on the steady state: an
|
|
// already-mounted drive is skipped (no daemon-reload churn). A drive whose UUID no longer resolves
|
|
// (genuinely absent) is skipped, not failed — its unit re-asserts on a later tick once it enumerates.
|
|
func (h *SudoHostOps) ReassertEnrolledMounts(ctx context.Context) {
|
|
entries, err := os.ReadDir(h.unitDir)
|
|
if err != nil {
|
|
h.logger.Warn("storage: reassert enrolled mounts — cannot read unit dir", "dir", h.unitDir, "err", err)
|
|
return
|
|
}
|
|
mounted := h.mountedSet()
|
|
for _, e := range entries {
|
|
if !strings.HasSuffix(e.Name(), ".mount") {
|
|
continue
|
|
}
|
|
data, rerr := os.ReadFile(filepath.Join(h.unitDir, e.Name()))
|
|
if rerr != nil {
|
|
continue
|
|
}
|
|
spec, ok := parseFelhomMountUnit(string(data))
|
|
if !ok {
|
|
continue // not one of ours
|
|
}
|
|
// Re-assert unless the drive is BOTH mounted AND its unit enabled (the durable steady state).
|
|
// A mounted-but-DISABLED unit (the live felhom-usb bug: a prior detach left it disabled, so it
|
|
// would NOT auto-mount on the next host reboot) is re-asserted too — EnsureMount's enable --now
|
|
// re-creates the wants-symlink. Skipping a mounted-but-disabled unit would leave the reboot fragile.
|
|
if !shouldReassertMount(mounted[spec.Where], h.unitEnabled(e.Name())) {
|
|
continue // mounted + enabled → no churn
|
|
}
|
|
dev, derr := ResolveStorageDevice("uuid:" + spec.UUID)
|
|
if derr != nil {
|
|
h.logger.Info("storage: enrolled drive absent by UUID — not re-asserting (will retry when it enumerates)", "name", spec.Name, "where", spec.Where, "uuid", spec.UUID)
|
|
continue
|
|
}
|
|
if err := h.EnsureMount(ctx, spec); err != nil {
|
|
h.logger.Warn("storage: re-assert enrolled mount failed", "name", spec.Name, "where", spec.Where, "err", err)
|
|
continue
|
|
}
|
|
h.logger.Info("storage: re-asserted enrolled mount by UUID (enable --now)", "name", spec.Name, "where", spec.Where, "uuid", spec.UUID, "device", dev, "wasMounted", mounted[spec.Where])
|
|
}
|
|
}
|
|
|
|
// shouldReassertMount decides whether an enrolled mount needs re-asserting. Re-assert unless it is
|
|
// BOTH currently mounted AND its unit enabled — the durable steady state. The mounted-but-disabled
|
|
// case is the load-bearing one: the drive serves now but its unit has no wants-symlink, so a host
|
|
// reboot would not auto-mount it; re-asserting re-enables it. Pure → unit-tested.
|
|
func shouldReassertMount(mounted, enabled bool) bool {
|
|
return !(mounted && enabled)
|
|
}
|
|
|
|
// unitEnabled reports whether a WantedBy=multi-user.target unit is enabled, by checking for its
|
|
// wants-symlink. A privilege-free os.Lstat (the systemd dirs are world-readable) — no subprocess and
|
|
// no sudoers entry, consistent with the durable-id reads. A missing symlink (or any stat error) ==
|
|
// not enabled, so the re-assert errs toward re-enabling rather than leaving a reboot fragile.
|
|
func (h *SudoHostOps) unitEnabled(unitName string) bool {
|
|
_, err := os.Lstat(filepath.Join(h.unitDir, "multi-user.target.wants", unitName))
|
|
return err == nil
|
|
}
|
|
|
|
// mountedSet returns the set of currently-active mountpoints from /proc/mounts (mountpoint is field 2).
|
|
// Best-effort: a read failure yields an empty set (every enrolled drive is then considered for
|
|
// re-assert, which EnsureMount makes idempotent).
|
|
func (h *SudoHostOps) mountedSet() map[string]bool {
|
|
out := map[string]bool{}
|
|
data, err := os.ReadFile("/proc/mounts")
|
|
if err != nil {
|
|
return out
|
|
}
|
|
for _, line := range strings.Split(string(data), "\n") {
|
|
f := strings.Fields(line)
|
|
if len(f) >= 2 {
|
|
out[f[1]] = true
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// Unmount stops + disables the unit (detach). The caller is responsible for authorization.
|
|
func (h *SudoHostOps) Unmount(ctx context.Context, where string) error {
|
|
unitName, err := UnitNameForMount(where)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := h.run(ctx, h.bins.Systemctl, "stop", "--", unitName); err != nil {
|
|
return fmt.Errorf("storage: stopping mount %s: %w", unitName, err)
|
|
}
|
|
if err := h.run(ctx, h.bins.Systemctl, "disable", "--", unitName); err != nil {
|
|
return fmt.Errorf("storage: disabling mount %s: %w", unitName, err)
|
|
}
|
|
h.logger.Info("storage: unmounted (detached)", "where", where, "unit", unitName)
|
|
return nil
|
|
}
|
|
|
|
// SMART runs `smartctl -a -j <device>` and parses the JSON.
|
|
func (h *SudoHostOps) SMART(ctx context.Context, device string) (hub.SmartSummary, error) {
|
|
if err := ValidateSMARTDevice(device); err != nil {
|
|
return hub.SmartSummary{Health: hub.SmartUnknown}, err
|
|
}
|
|
out, stderr, err := h.runner.Run(ctx, h.bins.Smartctl, "-a", "-j", device)
|
|
if err != nil && len(out) == 0 {
|
|
// smartctl uses a nonzero exit bitmask even on success; only treat empty output
|
|
// as a hard failure. A device with no SMART → degrade to UNKNOWN, not an error.
|
|
return hub.SmartSummary{Health: hub.SmartUnknown}, fmt.Errorf("storage: smartctl %s: %w: %s", device, err, trim(stderr))
|
|
}
|
|
return parseSMART(out), nil
|
|
}
|
|
|
|
// ThinPoolMetadata runs `lvs` for the pool and returns its metadata-used fraction.
|
|
func (h *SudoHostOps) ThinPoolMetadata(ctx context.Context, vg, pool string) (float64, bool) {
|
|
if err := ValidateLVMName(vg); err != nil {
|
|
h.logger.Warn("storage: refusing lvs on invalid vg", "vg", vg, "err", err)
|
|
return 0, false
|
|
}
|
|
if err := ValidateLVMName(pool); err != nil {
|
|
h.logger.Warn("storage: refusing lvs on invalid pool", "pool", pool, "err", err)
|
|
return 0, false
|
|
}
|
|
// --reportformat json, metadata_percent for the specific LV. lv path "vg/pool".
|
|
out, stderr, err := h.runner.Run(ctx, h.bins.Lvs, "--reportformat", "json", "--units", "b",
|
|
"-o", "lv_name,data_percent,metadata_percent", "--", vg+"/"+pool)
|
|
if err != nil && len(out) == 0 {
|
|
h.logger.Warn("storage: lvs failed", "vg", vg, "pool", pool, "err", err, "stderr", trim(stderr))
|
|
return 0, false
|
|
}
|
|
return parseThinPoolMetadata(out)
|
|
}
|
|
|
|
// InspectDevice probes a device for data-bearing evidence (8C). It runs `blkid -p -o export`
|
|
// (the reliable signature probe) for filesystem/partition-table signatures and `lsblk -J` for
|
|
// child partitions + mount state. The verdict defaults to data-bearing on ANY read failure
|
|
// (Probed=false), so a compromised caller cannot get a data-bearing device declared blank.
|
|
func (h *SudoHostOps) InspectDevice(ctx context.Context, device string) (DeviceProbe, error) {
|
|
if err := ValidateBlockDevice(device); err != nil {
|
|
return DeviceProbe{Device: device}, err // Probed=false → DataBearing()=true
|
|
}
|
|
probe := DeviceProbe{Device: device}
|
|
|
|
// blkid -p -o export is the authoritative on-disk SIGNATURE probe. Its OUTPUT is the signal:
|
|
// any TYPE/PTTYPE/USAGE line is positive data-bearing evidence. Its exit code is NOT relied
|
|
// on (blkid exits 2 on a blank device) — output presence is what matters. A broken/empty
|
|
// blkid simply adds no positive evidence; lsblk (below) is the read-success authority.
|
|
bout, _, _ := h.runner.Run(ctx, h.bins.Blkid, "-p", "-o", "export", device)
|
|
for k, v := range parseBlkidExport(bout) {
|
|
switch k {
|
|
case "TYPE":
|
|
probe.HasFilesystem = true
|
|
probe.FSType = v
|
|
case "PTTYPE":
|
|
probe.HasPartitionTable = true
|
|
case "USAGE":
|
|
if v != "" {
|
|
probe.HasFilesystem = true // filesystem/raid/crypto member = data-bearing
|
|
}
|
|
}
|
|
}
|
|
|
|
// lsblk -J is the READ-SUCCESS authority + the partition/mount view. It exits 0 on any valid
|
|
// device (blank or not), so a clean parse means the agent reliably read the device. If lsblk
|
|
// fails, Probed stays false → DataBearing()=true (fail-safe — never call a device blank on a
|
|
// failed read).
|
|
lout, _, lerr := h.runner.Run(ctx, h.bins.Lsblk, "-J", "-o", "NAME,FSTYPE,PTTYPE,MOUNTPOINT", device)
|
|
if lerr == nil {
|
|
probe.Probed = true
|
|
hasChildren, mounted, fstype, pttype := parseLsblkDevice(lout)
|
|
if hasChildren {
|
|
probe.HasPartitions = true
|
|
}
|
|
if mounted {
|
|
probe.Mounted = true
|
|
}
|
|
if fstype != "" {
|
|
probe.HasFilesystem = true
|
|
if probe.FSType == "" {
|
|
probe.FSType = fstype
|
|
}
|
|
}
|
|
if pttype != "" {
|
|
probe.HasPartitionTable = true
|
|
}
|
|
}
|
|
return probe, nil
|
|
}
|
|
|
|
// Format runs mkfs.<fstype> on a validated device. The caller is responsible for authorization
|
|
// (8C: only after classifying the device non-data-bearing, or via a slice-10 operator signature).
|
|
func (h *SudoHostOps) Format(ctx context.Context, device, fstype string) error {
|
|
if err := ValidateBlockDevice(device); err != nil {
|
|
return err
|
|
}
|
|
if err := ValidateFSType(fstype); err != nil {
|
|
return err
|
|
}
|
|
// MANDATORY unclaimed-disk guard (Impl-1): mkfs is destructive and the sudoers permits `mkfs /dev/*`,
|
|
// so THIS check — not the caller's authorization and not `DataBearing` (the OS disk is data-bearing) —
|
|
// is the real guard. Evaluated on the device as passed (the caller re-resolves the durable-id first).
|
|
// Fail-safe: anything not provably unclaimed (incl. any read error) is refused BEFORE any mkfs.
|
|
if ok, reason := h.deviceUnclaimed(ctx, device); !ok {
|
|
h.logger.Warn("storage: REFUSING format — device is claimed", "device", device, "reason", reason)
|
|
return fmt.Errorf("storage: refusing to format %s: %s", device, reason)
|
|
}
|
|
// Part B: mkfs runs through the guarded wrapper (the sudoers allowlists ONLY the wrapper, not raw
|
|
// mkfs) — a second, below-the-agent gate that re-checks the catastrophic cases even against an agent
|
|
// bug. The wrapper takes <device> <fstype> and picks/execs the right mkfs.
|
|
if err := h.run(ctx, h.bins.MkfsGuarded, device, fstype); err != nil {
|
|
return fmt.Errorf("storage: guarded mkfs %s (%s): %w", device, fstype, err)
|
|
}
|
|
h.logger.Info("storage: formatted device", "device", device, "fstype", fstype)
|
|
return nil
|
|
}
|
|
|
|
// run execs an allow-listed command with a fixed arg vector and wraps a nonzero exit.
|
|
func (h *SudoHostOps) run(ctx context.Context, name string, args ...string) error {
|
|
_, stderr, err := h.runner.Run(ctx, name, args...)
|
|
if err != nil {
|
|
return fmt.Errorf("%s %s: %w: %s", name, strings.Join(args, " "), err, trim(stderr))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// validateUnitOpt rejects metacharacters / newlines in an optional unit value (FSType /
|
|
// Options) so a crafted value can't inject extra directives into the unit file. Empty is OK.
|
|
func validateUnitOpt(v string) error {
|
|
if v == "" {
|
|
return nil
|
|
}
|
|
if strings.ContainsAny(v, "\n\r\x00[]=") {
|
|
return fmt.Errorf("storage: value %q contains forbidden characters", v)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func trim(b []byte) string {
|
|
s := strings.TrimSpace(string(b))
|
|
if len(s) > 300 {
|
|
return s[:300] + "…"
|
|
}
|
|
return s
|
|
}
|
|
|
|
// parseBlkidExport parses `blkid -p -o export` output (KEY=value lines) into a map.
|
|
func parseBlkidExport(out []byte) map[string]string {
|
|
m := map[string]string{}
|
|
for _, line := range strings.Split(string(out), "\n") {
|
|
line = strings.TrimSpace(line)
|
|
if i := strings.IndexByte(line, '='); i > 0 {
|
|
m[line[:i]] = line[i+1:]
|
|
}
|
|
}
|
|
return m
|
|
}
|
|
|
|
// lsblkDevice mirrors the `lsblk -J` device shape (only the fields we read).
|
|
type lsblkDevice struct {
|
|
Name string `json:"name"`
|
|
FSType string `json:"fstype"`
|
|
PTType string `json:"pttype"`
|
|
MountPoint string `json:"mountpoint"`
|
|
Children []lsblkDevice `json:"children"`
|
|
}
|
|
|
|
// parseLsblkDevice parses `lsblk -J -o NAME,FSTYPE,PTTYPE,MOUNTPOINT <device>` for the top device:
|
|
// whether it has child partitions, is mounted (itself or any child), and its fstype/pttype.
|
|
func parseLsblkDevice(out []byte) (hasChildren, mounted bool, fstype, pttype string) {
|
|
var doc struct {
|
|
BlockDevices []lsblkDevice `json:"blockdevices"`
|
|
}
|
|
if json.Unmarshal(out, &doc) != nil || len(doc.BlockDevices) == 0 {
|
|
return false, false, "", ""
|
|
}
|
|
d := doc.BlockDevices[0]
|
|
fstype, pttype = d.FSType, d.PTType
|
|
hasChildren = len(d.Children) > 0
|
|
mounted = d.MountPoint != ""
|
|
for _, c := range d.Children {
|
|
if c.MountPoint != "" {
|
|
mounted = true
|
|
}
|
|
}
|
|
return hasChildren, mounted, fstype, pttype
|
|
}
|
|
|
|
// NoopHostOps is the safe fallback when the privileged surface is unavailable or declined
|
|
// (a missing sudoers entry must degrade with a clear warning, not crash — slice notes). It
|
|
// reports SMART as UNKNOWN, no thin-pool metadata, and errors on any write (so a benign
|
|
// re-mount logs a clear failure rather than silently "succeeding").
|
|
type NoopHostOps struct{ Logger *slog.Logger }
|
|
|
|
func (n NoopHostOps) EnsureMount(context.Context, MountSpec) error {
|
|
return fmt.Errorf("storage: privileged HostOps not configured; cannot mount")
|
|
}
|
|
func (n NoopHostOps) Unmount(context.Context, string) error {
|
|
return fmt.Errorf("storage: privileged HostOps not configured; cannot unmount")
|
|
}
|
|
func (n NoopHostOps) SMART(context.Context, string) (hub.SmartSummary, error) {
|
|
return hub.SmartSummary{Health: hub.SmartUnknown}, nil
|
|
}
|
|
func (n NoopHostOps) ThinPoolMetadata(context.Context, string, string) (float64, bool) {
|
|
return 0, false
|
|
}
|
|
func (n NoopHostOps) InspectDevice(_ context.Context, device string) (DeviceProbe, error) {
|
|
// Probed=false → DataBearing()=true: with no privileged surface we MUST NOT call any device
|
|
// blank (fail-safe — a format would then be refused as destructive).
|
|
return DeviceProbe{Device: device}, nil
|
|
}
|
|
func (n NoopHostOps) Format(context.Context, string, string) error {
|
|
return fmt.Errorf("storage: privileged HostOps not configured; cannot format")
|
|
}
|