v0.5.0: slice 5 Phase B — the host-root surface (mounts + SMART + grow + destructive gate)

The privileged write surface, isolated behind a narrow, arg-validated, adversarially-
tested seam (HostOps), the same discipline as the slice-4 gate. Completes slice 5.

- internal/storage: HostOps seam + SudoHostOps (systemd .mount units by fs-UUID, detach,
  SMART, lvs) via sudoers allowlist + fixed arg vectors, no shell; NoopHostOps fallback.
- validate.go: strict UUID/mount-path/device/LVM validators + in-process systemd-escape.
  Headline test: adversarial matrix (metacharacters/traversal/malformed) refused with
  zero exec.
- smart.go: smartctl SATA + NVMe parse, UNKNOWN-degrade; lvs thin-pool metadata fill.
- observer enrichment (Observe only): fills smart + thin-pool metadata.
- watchdog: benign re-mount response off the poll path (DevicePresent probe, rate-limited).
- reconcile: ActionResize (benign, grow-only) + proxmox.ResizeLXC; destructive storage ops
  (ClassStorageWipe/Decommission) through the slice-4 gate, target-scoped; built+tested,
  inert live.
- --selftest=storage [-watch] live harness; configs/felhom-agent.sudoers; privileged.* knobs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-09 10:53:38 +02:00
parent 27b68f043b
commit 9d6e49236c
25 changed files with 2074 additions and 182 deletions
+8 -1
View File
@@ -17,6 +17,11 @@ const (
ClassStart OpClass = "start"
ClassStop OpClass = "stop"
ClassSetConfig OpClass = "set_config" // benign sizing/description changes only
ClassResize OpClass = "resize" // GROW-only rootfs/volume resize (slice 5 Phase B)
// Benign storage attach — re-mount-by-UUID of a known target whose device returned
// (slice 5 Phase B). Additive (no data loss), so benign by construction.
ClassStorageMount OpClass = "storage_mount"
// Benign by construction — classified now, executors land in later slices.
ClassCreate OpClass = "create" // provision a NEW guest (restore-to-new, slice 7)
@@ -76,7 +81,7 @@ func (p Provenance) internalEvidence() bool {
// - an UNKNOWN class fails safe → Destructive (require a signature).
func Classify(class OpClass, prov Provenance) Disposition {
switch class {
case ClassStart, ClassStop, ClassSetConfig, ClassCreate, ClassRestart:
case ClassStart, ClassStop, ClassSetConfig, ClassResize, ClassStorageMount, ClassCreate, ClassRestart:
return Benign
case ClassGuestDestroy, ClassStorageWipe, ClassRestoreOverwrite, ClassDecommission:
if prov.internalEvidence() {
@@ -100,6 +105,8 @@ func classOfAction(k ActionKind) OpClass {
return ClassStop
case ActionSetConfig:
return ClassSetConfig
case ActionResize:
return ClassResize
default:
return OpClass(k)
}
+13 -1
View File
@@ -5,6 +5,7 @@ import (
"fmt"
"log/slog"
"strconv"
"strings"
"sync/atomic"
"time"
@@ -164,6 +165,15 @@ func (e *Engine) execute(ctx context.Context, act Action) error {
upid, err = e.api.Stop(ctx, act.VMID)
case ActionSetConfig:
upid, err = e.api.SetConfig(ctx, act.VMID, act.Params)
case ActionResize:
// Defensive grow-only guard at the executor: a resize size MUST be a "+<n>" grow.
// The planner only ever emits grows, but never let a shrink reach Proxmox here.
disk, size := act.Params["disk"], act.Params["size"]
if !strings.HasPrefix(size, "+") {
err = fmt.Errorf("reconcile: refusing non-grow resize size %q (data-losing shrink is a signed op)", size)
} else {
upid, err = e.api.ResizeLXC(ctx, act.VMID, disk, size)
}
default:
err = fmt.Errorf("reconcile: unknown action kind %q", act.Kind)
}
@@ -208,7 +218,9 @@ func (e *Engine) readActual(ctx context.Context) (ActualState, error) {
}
guests := make(map[int]ActualGuest, len(lxc))
for _, g := range lxc {
a := ActualGuest{VMID: g.VMID, Run: normRun(g.Status)}
// MaxDisk (bytes) comes from the list entry and is reliable independent of the
// per-guest config read — it is the actual side of the grow comparison.
a := ActualGuest{VMID: g.VMID, Run: normRun(g.Status), DiskBytes: g.MaxDisk}
cfg, err := e.api.GuestConfig(ctx, g.VMID)
if err != nil {
e.logger.Warn("reconcile: GuestConfig failed; spec unknown (run-state kept)",
+15 -2
View File
@@ -18,8 +18,8 @@ type fakeAPI struct {
lxc []proxmox.Guest
cfg map[int]proxmox.GuestConfig
startUPID, stopUPID, setUPID string
startErr, stopErr, setErr error
startUPID, stopUPID, setUPID, resizeUPID string
startErr, stopErr, setErr, resizeErr error
// waitFunc maps a UPID to a (status, err); default = OK. Mirrors the real client,
// which errors on a non-OK exitstatus.
waitFunc func(upid string) (proxmox.TaskStatus, error)
@@ -29,10 +29,16 @@ type fakeAPI struct {
starts []int
stops []int
sets []setCall
resizes []resizeCall
waits []string
listErr error
}
type resizeCall struct {
vmid int
disk, size string
}
func (f *fakeAPI) TaskStatusOnce(_ context.Context, upid string) (proxmox.TaskStatus, error) {
if f.statusFunc != nil {
return f.statusFunc(upid)
@@ -81,6 +87,13 @@ func (f *fakeAPI) SetConfig(_ context.Context, vmid int, params map[string]strin
return f.setUPID, f.setErr
}
func (f *fakeAPI) ResizeLXC(_ context.Context, vmid int, disk, size string) (string, error) {
f.mu.Lock()
f.resizes = append(f.resizes, resizeCall{vmid, disk, size})
f.mu.Unlock()
return f.resizeUPID, f.resizeErr
}
func (f *fakeAPI) WaitTask(_ context.Context, upid string, _ proxmox.WaitOptions) (proxmox.TaskStatus, error) {
f.mu.Lock()
f.waits = append(f.waits, upid)
+31 -2
View File
@@ -20,8 +20,18 @@ const (
// ActionSetConfig applies benign config changes (cores/memory/description) in one
// PUT (proxmox VM.Config.*). May return synchronously (empty UPID) — slice-4 proven.
ActionSetConfig ActionKind = "set_config"
// ActionResize GROWS the rootfs (proxmox `pct resize`, async). Grow-only — the planner
// emits it only when desired DiskBytes > actual; a shrink is data-losing and is refused
// (never silently applied as a grow). Slice 5 Phase B; unfed live until slice 10.
ActionResize ActionKind = "resize"
)
// growRoundMiB rounds a positive byte delta UP to whole MiB for the Proxmox `+<n>M` grow
// size (Proxmox resizes in whole units; rounding up never under-provisions the desired size).
func growRoundMiB(deltaBytes int64) int64 {
return (deltaBytes + bytesPerMiB - 1) / bytesPerMiB
}
// Action is one minimal mutation the engine will dispatch onto the per-guest queue.
// In Phase A every Action is benign by construction (only the benign kinds exist).
// Phase B's classifier/gate sits in front of the executor and may tag an action
@@ -99,8 +109,27 @@ func Plan(desired DesiredState, actual ActualState, norm FieldNormalizers) []Act
params["memory"] = strconv.FormatInt(want, 10)
reasons = append(reasons, fmt.Sprintf("memory %dMiB->%dMiB", a.MemoryMiB, want))
}
// DiskBytes is intentionally NOT reconciled here (rootfs grow is
// `pct resize`, grow-only and separate — a later slice).
}
// Rootfs GROW (slice 5 Phase B) — a separate async op from the config PUT, so
// its own Action. GROW-ONLY: emit a resize only when desired > actual. A shrink
// (desired < actual) is data-losing and is REFUSED here — we never silently
// clamp it to a grow; it is simply not planned (a deliberate shrink would have
// to come as a signed destructive op, slice 10). DiskBytes==0 means unmanaged.
if d.Spec != nil && d.Spec.DiskBytes > 0 && a.DiskBytes > 0 {
switch {
case d.Spec.DiskBytes > a.DiskBytes:
deltaMiB := growRoundMiB(d.Spec.DiskBytes - a.DiskBytes)
actions = append(actions, Action{
VMID: vmid,
Kind: ActionResize,
Params: map[string]string{"disk": "rootfs", "size": fmt.Sprintf("+%dM", deltaMiB)},
Reason: fmt.Sprintf("disk grow %dB->%dB (+%dMiB)", a.DiskBytes, d.Spec.DiskBytes, deltaMiB),
})
case d.Spec.DiskBytes < a.DiskBytes:
// Shrink refused by omission: emit NO action (a data-losing shrink is a
// signed destructive op, slice 10 — never a benign reconcile grow). The
// executor also guards (size must start with '+'). See the resize note above.
}
}
if d.Description != nil && !norm.Equal("description", *d.Description, a.Description) {
params["description"] = *d.Description
+3
View File
@@ -69,6 +69,7 @@ type ActualGuest struct {
SpecKnown bool
Cores int
MemoryMiB int64 // proxmox LXC `memory` is MiB
DiskBytes int64 // rootfs size in bytes (from the LXC list MaxDisk; for grow planning)
Description string // raw (may carry PVE's trailing newline; compared via normalizers)
}
@@ -111,6 +112,8 @@ type GuestAPI interface {
Start(ctx context.Context, vmid int) (string, error)
Stop(ctx context.Context, vmid int) (string, error)
SetConfig(ctx context.Context, vmid int, params map[string]string) (string, error)
// ResizeLXC grows a volume (grow-only; the planner never emits a shrink). Async → UPID.
ResizeLXC(ctx context.Context, vmid int, disk, size string) (string, error)
WaitTask(ctx context.Context, upid string, opts proxmox.WaitOptions) (proxmox.TaskStatus, error)
// TaskStatusOnce is a single non-blocking task-status read — used by crash
// recovery to learn the outcome of an op that was in flight when the agent died.
+49
View File
@@ -0,0 +1,49 @@
package reconcile
import "encoding/json"
// Storage operations (slice 5 Phase B) flow through the SAME reversibility gate as guest
// ops — no new gate, no new crypto. They are HOST/TARGET-scoped (no guest), so the op binds
// on the STORAGE TARGET IDENTITY rather than a vmid.
//
// Scoping decision (documented): the scoped resource id is carried in the op's
// target.guest_id (and the Intent.GuestID) as the storage target's NAME — the operator-
// facing handle and the hub manifest key. VMID is 0 (host-scoped; no queue routing by
// guest). So a signature for "wipe target A" (guest_id="A") cannot authorize "wipe target
// B" (guest_id="B") — the gate's op-to-action binding rejects it (binding_mismatch),
// exactly as it does for the wrong guest on a guest op.
//
// Benign storage ops (re-mount, slice 5) use IntentForStorageMount and pass the gate
// unsigned. Destructive storage ops (detach/wipe/decommission, inert until slice 10) use
// IntentForStorageDestructive and require a verified, role-scoped, target-bound operator
// signature — else pending_signature.
// IntentForStorageMount builds the benign re-mount intent for a known target (additive, no
// data loss → benign by classification). targetID is the storage target name.
func IntentForStorageMount(hostID, targetID string) Intent {
return Intent{
Class: ClassStorageMount,
HostID: hostID,
GuestID: targetID, // storage target identity (host-scoped op)
VMID: 0,
Provenance: Provenance{}, // never hub-sourced
Source: SourceDesiredDelta,
}
}
// IntentForStorageDestructive builds a destructive storage intent (detach/wipe via
// ClassStorageWipe, or ClassDecommission). It carries the target identity in GuestID and the
// canonical params for op-to-action binding. Provenance is the zero value — a destructive
// storage op is NOT made benign by hub-supplied evidence (only agent-internal provenance
// could, and storage detach/wipe carries none here).
func IntentForStorageDestructive(class OpClass, hostID, targetID string, params json.RawMessage, source SourceKind) Intent {
return Intent{
Class: class,
HostID: hostID,
GuestID: targetID,
VMID: 0,
ParamsJSON: params,
Provenance: Provenance{},
Source: source,
}
}
+170
View File
@@ -0,0 +1,170 @@
package reconcile
import (
"context"
"encoding/json"
"path/filepath"
"testing"
"gitea.dooplex.hu/admin/felhom-agent/internal/authz"
"gitea.dooplex.hu/admin/felhom-agent/internal/hub"
)
// --- The disk-grow executor (deferred from slice 4): grow applies, shrink refused. ---
func TestPlan_DiskGrowOnly(t *testing.T) {
norm := DefaultNormalizers()
t.Run("grow emits a resize action", func(t *testing.T) {
desired := DesiredState{Guests: map[int]DesiredGuest{
100: {VMID: 100, Spec: &hub.GuestSpec{DiskBytes: 20 << 30}}, // want 20 GiB
}}
actual := ActualState{Guests: map[int]ActualGuest{
100: {VMID: 100, SpecKnown: true, DiskBytes: 8 << 30}, // have 8 GiB
}}
var resize *Action
for _, a := range Plan(desired, actual, norm) {
if a.Kind == ActionResize {
a := a
resize = &a
}
}
if resize == nil {
t.Fatal("expected a resize action for a grow")
}
if resize.Params["disk"] != "rootfs" || resize.Params["size"] != "+12288M" {
t.Errorf("resize params = %v, want disk=rootfs size=+12288M", resize.Params)
}
})
t.Run("shrink is refused (no action)", func(t *testing.T) {
desired := DesiredState{Guests: map[int]DesiredGuest{100: {VMID: 100, Spec: &hub.GuestSpec{DiskBytes: 4 << 30}}}}
actual := ActualState{Guests: map[int]ActualGuest{100: {VMID: 100, SpecKnown: true, DiskBytes: 8 << 30}}}
for _, a := range Plan(desired, actual, norm) {
if a.Kind == ActionResize {
t.Fatalf("a data-losing shrink must NOT be planned as a resize: %+v", a)
}
}
})
t.Run("equal size is a no-op", func(t *testing.T) {
desired := DesiredState{Guests: map[int]DesiredGuest{100: {VMID: 100, Spec: &hub.GuestSpec{DiskBytes: 8 << 30}}}}
actual := ActualState{Guests: map[int]ActualGuest{100: {VMID: 100, SpecKnown: true, DiskBytes: 8 << 30}}}
for _, a := range Plan(desired, actual, norm) {
if a.Kind == ActionResize {
t.Fatalf("equal disk size must not resize: %+v", a)
}
}
})
}
func TestEngine_GrowExecutes_NonGrowRefusedAtExecutor(t *testing.T) {
a := &fakeAPI{resizeUPID: "UPID:resize:1"}
e, _, q := newEngine(t, a, EmptyProvider{})
defer q.Close()
// A grow applies (ResizeLXC called with the grow size).
if err := e.execute(context.Background(), Action{VMID: 100, Kind: ActionResize,
Params: map[string]string{"disk": "rootfs", "size": "+12288M"}}); err != nil {
t.Fatalf("grow execute: %v", err)
}
if len(a.resizes) != 1 || a.resizes[0].size != "+12288M" || a.resizes[0].disk != "rootfs" {
t.Fatalf("ResizeLXC not called correctly: %+v", a.resizes)
}
// A non-grow ("absolute"/shrink) size is refused at the executor and never hits the API.
a.resizes = nil
if err := e.execute(context.Background(), Action{VMID: 100, Kind: ActionResize,
Params: map[string]string{"disk": "rootfs", "size": "4G"}}); err == nil {
t.Fatal("a non-grow resize size must be refused at the executor")
}
if len(a.resizes) != 0 {
t.Fatalf("refused resize must not call the API: %+v", a.resizes)
}
}
// --- Destructive storage ops through the slice-4 gate (real verifier). ---
func wipeIntent(targetID string) Intent {
return IntentForStorageDestructive(ClassStorageWipe, testHost, targetID,
json.RawMessage(`{"wipe":true}`), SourceOneShotJob)
}
func TestGate_StorageWipeUnsignedPendingSignature(t *testing.T) {
op := newTestSigner(t)
v, _ := realVerifierAt(t, filepath.Join(t.TempDir(), "n.log"), testHost, op.allowed(t, "op1", authz.RoleOperational))
aud := &captureAudit{}
g := NewGate(v, testHost, aud, nil)
d := g.Authorize(wipeIntent("usb-backup"), nil)
if d.Allowed || d.Reason != ReasonPendingSignature {
t.Fatalf("unsigned storage wipe: got allowed=%v reason=%s, want pending_signature", d.Allowed, d.Reason)
}
if len(aud.recs) != 1 || aud.recs[0].Allowed {
t.Errorf("refused wipe must be audited: %+v", aud.recs)
}
}
func TestGate_StorageWipeWrongTargetBindingMismatch(t *testing.T) {
// A valid signature authorizing "wipe target A" must NOT authorize "wipe target B" —
// the op-to-action binding rejects it (the storage analog of the wrong-guest case).
op := newTestSigner(t)
v, _ := realVerifierAt(t, filepath.Join(t.TempDir(), "n.log"), testHost, op.allowed(t, "op1", authz.RoleOperational))
g := NewGate(v, testHost, nil, nil)
issued, expires := freshWindow()
signed := op.mint("storage_wipe", testHost, "usb-backup", "op1", nonce(), `{"wipe":true}`, issued, expires)
d := g.Authorize(wipeIntent("nfs-arch"), signed) // action targets a DIFFERENT store
if d.Allowed || d.Reason != ReasonBindingMismatch {
t.Fatalf("wrong-target wipe: got allowed=%v reason=%s, want binding_mismatch", d.Allowed, d.Reason)
}
}
func TestGate_StorageWipeValidAccepted(t *testing.T) {
op := newTestSigner(t)
v, _ := realVerifierAt(t, filepath.Join(t.TempDir(), "n.log"), testHost, op.allowed(t, "op1", authz.RoleOperational))
g := NewGate(v, testHost, nil, nil)
issued, expires := freshWindow()
signed := op.mint("storage_wipe", testHost, "usb-backup", "op1", nonce(), `{"wipe":true}`, issued, expires)
d := g.Authorize(wipeIntent("usb-backup"), signed)
if !d.Allowed || d.Reason != ReasonSigned {
t.Fatalf("valid storage wipe: got allowed=%v reason=%s err=%v, want accepted/signed", d.Allowed, d.Reason, d.Err)
}
}
func TestGate_StorageMountBenign(t *testing.T) {
// A re-mount is benign by classification → allowed unsigned (no verifier needed).
g := NewGate(nil, testHost, nil, nil)
d := g.Authorize(IntentForStorageMount(testHost, "usb-backup"), nil)
if !d.Allowed || d.Reason != ReasonBenign {
t.Fatalf("storage mount: got allowed=%v reason=%s, want benign", d.Allowed, d.Reason)
}
}
// TestRunSignedJob_StorageDestructiveExecutes proves the authorized destructive-storage path
// reaches its executor (inert live — wired in main.go later; here a fake exec records it).
func TestRunSignedJob_StorageDestructiveExecutes(t *testing.T) {
op := newTestSigner(t)
v, _ := realVerifierAt(t, filepath.Join(t.TempDir(), "n.log"), testHost, op.allowed(t, "op1", authz.RoleOperational))
a := &fakeAPI{}
q := NewQueue()
t.Cleanup(q.Close)
e := NewEngine(EngineOptions{API: a, Queue: q, Gate: NewGate(v, testHost, nil, nil), HostID: testHost})
issued, expires := freshWindow()
signed := op.mint("storage_wipe", testHost, "usb-backup", "op1", nonce(), `{"wipe":true}`, issued, expires)
var ran bool
exec := func(_ context.Context, intent Intent, _ *authz.VerifiedOp) (string, error) {
ran = true
if intent.GuestID != "usb-backup" {
t.Errorf("executor got wrong target %q", intent.GuestID)
}
return "", nil
}
res := e.RunSignedJob(context.Background(), wipeIntent("usb-backup"), signed, exec)
if !res.Executed || !ran || res.Err != nil {
t.Fatalf("authorized storage wipe should execute cleanly: %+v ran=%v", res, ran)
}
}