bc4eda926b
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017CDMFpFx84pfviCTVuGGhf
574 lines
24 KiB
Go
574 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
|
|
}
|
|
|
|
// 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")
|
|
}
|