9d6e49236c
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>
171 lines
6.6 KiB
Go
171 lines
6.6 KiB
Go
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)
|
|
}
|
|
}
|