3c174bc6f2
Operator ruling 2026-07-13: walk the root's backing device through /sys/block/<dev>/slaves recursively down to physical disks (dm AND md; topology, never VG names); those + any mounted-ESP holder are system; the all-system fail-safe returns to being the WALK-FAILURE error case only. SAFETY DIRECTION: a root-backing disk classified candidate is made impossible — per-branch conservatism (any unresolvable slave fails the WHOLE walk -> ok=false -> the unchanged all-system path). - physicalDisksOf/walkSlaves in role.go (symlink canon -> wholeDiskOf fast path -> recursive slaves walk; cycle/depth guard; non-/dev sources unwalkable) - HostReader.BlockSlaves(name) — the ONE new seam method; ProcHostReader reads /sys/block/<name>/slaves; all four test fakes mirror it - role_walk_test.go: signature table (root-backing disk ALWAYS system across legacy-LVM / md-raid / EFI+raw / EFI+LVM / nested dm-on-md — NEVER weaken) + dead-wizard-lives + dangling-slave fail-safe (real sysKnown=false path) + cycle + empty-slaves; red-proofs A/B/D run->fail->revert (recorded in REPORT) - §3 spike transcripts (drill legacy: dm-1->sda3->sda; felhom-pve: ESP+walk agree on sda -> byte-identical regression); caller audit: none relied on all-system as a feature - format/mkfs paths, data-bearing guards, wizard UI untouched
194 lines
8.9 KiB
Go
194 lines
8.9 KiB
Go
package localapi
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"net/http"
|
|
"sync"
|
|
"testing"
|
|
|
|
"gitea.dooplex.hu/admin/felhom-agent/internal/hub"
|
|
"gitea.dooplex.hu/admin/felhom-agent/internal/storage"
|
|
)
|
|
|
|
// f2HostReader is a configurable HostReader fixture for the roleForMountPath F2 fallback: it serves a
|
|
// mount table (device→mountpoint), resolves fs-UUIDs for the decommission bind-prune, and COUNTS
|
|
// Mounts() calls so a test can prove the fallback did/didn't run (SystemDisks calls Mounts() once per
|
|
// request; the fallback is a SECOND call — so Observe-error → exactly 1 call proves the fallback was
|
|
// skipped).
|
|
type f2HostReader struct {
|
|
mounts []storage.Mount
|
|
uuids map[string]string
|
|
mu sync.Mutex
|
|
calls int
|
|
}
|
|
|
|
func (f *f2HostReader) Mounts() ([]storage.Mount, error) {
|
|
f.mu.Lock()
|
|
f.calls++
|
|
f.mu.Unlock()
|
|
return f.mounts, nil
|
|
}
|
|
func (f *f2HostReader) ResolveUUID(dev string) (string, bool) { u, ok := f.uuids[dev]; return u, ok }
|
|
func (f *f2HostReader) DeviceExists(string) bool { return true }
|
|
func (f *f2HostReader) Rotational(string) (bool, bool) { return false, false }
|
|
func (f *f2HostReader) Removable(string) (bool, bool) { return false, false }
|
|
func (f *f2HostReader) BlockSlaves(string) ([]string, bool) { return nil, false }
|
|
func (f *f2HostReader) mountsCalls() int { f.mu.Lock(); defer f.mu.Unlock(); return f.calls }
|
|
|
|
// errStorage is a StorageView whose Observe always fails — for the Observe-error fail-safe (edge C3).
|
|
type errStorage struct{}
|
|
|
|
func (errStorage) Observe(context.Context) ([]hub.StorageTarget, error) {
|
|
return nil, errors.New("proxmox unreachable")
|
|
}
|
|
|
|
// f2EjectServer builds an eject/decommission-capable server with a custom storage view + host reader.
|
|
func f2EjectServer(t *testing.T, d *fakeDiskOps, sv StorageView, host storage.HostReader) *Server {
|
|
t.Helper()
|
|
srv := newDiskServerRaw(t, d, &fakeGate{}, sv, nil)
|
|
if host != nil {
|
|
srv.host = host
|
|
}
|
|
return srv
|
|
}
|
|
|
|
// A1 — the campaign shape: a bind-mounted enrolled user-data drive (/mnt/teszt_enroll on /dev/sdb) is
|
|
// NOT a PVE storage, so it misses the MountPath loop. Pre-fix → fail-safe system → 403 EVERY user-data
|
|
// drive. Post-fix → the mount-table fallback classifies /dev/sdb (not the system disk /dev/sda) as
|
|
// user-data → eject 200 + decommission fires its effects.
|
|
//
|
|
// COMPANION RED-PROOF: on the pre-fix roleForMountPath (the plain "no target → RoleSystem" body) this
|
|
// eject returns 403 → the 200 assertion FAILS.
|
|
func TestRoleForMountPath_F2_BindMountedUserData_Ejectable(t *testing.T) {
|
|
// storage view: a PBS + a system dir, but NOTHING at /mnt/teszt_enroll (the raw enrolled drive).
|
|
sv := fakeStorage{targets: []hub.StorageTarget{
|
|
{Name: "felhom-pbs", Type: hub.StorageTypePBS, MountPath: "/mnt/pbs"},
|
|
{Name: "local", Type: "local", MountPath: "/var/lib/vz"},
|
|
}}
|
|
host := &f2HostReader{
|
|
mounts: []storage.Mount{
|
|
{Device: "/dev/sda1", MountPoint: "/"}, // system disk
|
|
{Device: "/dev/sdb", MountPoint: "/mnt/teszt_enroll"}, // the raw enrolled user-data drive
|
|
},
|
|
uuids: map[string]string{"/dev/sdb": "f2236136-ced7"},
|
|
}
|
|
|
|
// eject → 200, and the raw is NOT unmounted (intermediary model).
|
|
d := &fakeDiskOps{}
|
|
h := f2EjectServer(t, d, sv, host).Handler()
|
|
if w := do(t, h, "POST", "/disks/eject", "A", `{"where":"/mnt/teszt_enroll"}`); w.Code != http.StatusOK {
|
|
t.Fatalf("eject bind-mounted user-data drive: got %d want 200 (%s)", w.Code, w.Body.String())
|
|
}
|
|
d.mu.Lock()
|
|
if len(d.unmountCalls) != 0 {
|
|
t.Fatalf("user-data eject must not unmount the raw drive: %v", d.unmountCalls)
|
|
}
|
|
d.mu.Unlock()
|
|
|
|
// decommission → 200 + all three effects (intent, bind prune, DetachDrive), no unmount/format.
|
|
intent := newFakeIntent()
|
|
intent.SetEnrolled("uuid:f2236136-ced7")
|
|
gb := tempBindStore(t)
|
|
_ = gb.Record(8200, "uuid:f2236136-ced7")
|
|
ga := &fakeGuestAttacher{}
|
|
d2 := &fakeDiskOps{}
|
|
srv := decommServer(t, d2, sv, fakeGuestList{}, intent, gb, ga, nil)
|
|
srv.host = host // inject the teszt_enroll mount table
|
|
if w := do(t, srv.Handler(), "POST", "/disks/decommission", "A", `{"where":"/mnt/teszt_enroll"}`); w.Code != http.StatusOK {
|
|
t.Fatalf("decommission bind-mounted user-data drive: got %d want 200 (%s)", w.Code, w.Body.String())
|
|
}
|
|
if got := intent.Get("uuid:f2236136-ced7"); got != storage.IntentDecommissioned {
|
|
t.Errorf("intent = %q, want decommissioned", got)
|
|
}
|
|
if ids := gb.Guests()[8200]; len(ids) != 0 {
|
|
t.Errorf("guest-bind not pruned: %v", ids)
|
|
}
|
|
if len(ga.detachDrives) != 1 || ga.detachDrives[0] != "/mnt/teszt_enroll" {
|
|
t.Errorf("DetachDrive calls = %v, want [/mnt/teszt_enroll]", ga.detachDrives)
|
|
}
|
|
d2.mu.Lock()
|
|
defer d2.mu.Unlock()
|
|
if len(d2.unmountCalls) != 0 || len(d2.formatCalls) != 0 {
|
|
t.Errorf("decommission must not unmount/format: unmount=%v format=%v", d2.unmountCalls, d2.formatCalls)
|
|
}
|
|
}
|
|
|
|
// B1 (containment) — a mount whose device shares a whole disk with a KNOWN protected target must honor
|
|
// THAT target's role, not raw-classify. Here /dev/sdb1 backs a lvmthin (→ system) target; a mount on
|
|
// /dev/sdb1 that misses the MountPath loop must resolve to system (403), NOT user-data.
|
|
//
|
|
// COMPANION RED-PROOF: the containment-skipping mutation (fallback = bare RoleForRawDevice(m.Device))
|
|
// returns user-data for /dev/sdb1 (not the system disk) → this eject returns 200 → the 403 FAILS.
|
|
func TestRoleForMountPath_F2_Containment(t *testing.T) {
|
|
sv := fakeStorage{targets: []hub.StorageTarget{
|
|
// lvmthin on an external disk /dev/sdb → RoleForStorage(lvmthin) = system (default tier).
|
|
{Name: "extra-lvm", Type: "lvmthin", BackingDevice: "/dev/sdb1", MountPath: "/never/matched"},
|
|
}}
|
|
host := &f2HostReader{mounts: []storage.Mount{
|
|
{Device: "/dev/sda1", MountPoint: "/"},
|
|
{Device: "/dev/sdb1", MountPoint: "/mnt/onprotecteddisk"},
|
|
}}
|
|
d := &fakeDiskOps{}
|
|
h := f2EjectServer(t, d, sv, host).Handler()
|
|
if w := do(t, h, "POST", "/disks/eject", "A", `{"where":"/mnt/onprotecteddisk"}`); w.Code != http.StatusForbidden {
|
|
t.Fatalf("containment: mount on a protected target's disk must be 403, got %d (%s)", w.Code, w.Body.String())
|
|
}
|
|
d.mu.Lock()
|
|
if len(d.unmountCalls) != 0 {
|
|
t.Fatalf("containment: no unmount on the refused mount: %v", d.unmountCalls)
|
|
}
|
|
d.mu.Unlock()
|
|
}
|
|
|
|
// B2 — a mount on the SYSTEM disk (/dev/sda) is refused: RoleForRawDevice sees it is system-backed.
|
|
func TestRoleForMountPath_F2_SystemDiskMount(t *testing.T) {
|
|
sv := fakeStorage{targets: []hub.StorageTarget{{Name: "felhom-pbs", Type: hub.StorageTypePBS, MountPath: "/mnt/pbs"}}}
|
|
host := &f2HostReader{mounts: []storage.Mount{
|
|
{Device: "/dev/sda1", MountPoint: "/"},
|
|
{Device: "/dev/sda3", MountPoint: "/mnt/onsystemdisk"},
|
|
}}
|
|
h := f2EjectServer(t, &fakeDiskOps{}, sv, host).Handler()
|
|
if w := do(t, h, "POST", "/disks/eject", "A", `{"where":"/mnt/onsystemdisk"}`); w.Code != http.StatusForbidden {
|
|
t.Fatalf("system-disk mount must be 403, got %d (%s)", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
// C1/C2 — fail-safe: a mount absent from the table, and a non-/dev (NAS) source, both refuse.
|
|
func TestRoleForMountPath_F2_FailSafe(t *testing.T) {
|
|
sv := fakeStorage{targets: []hub.StorageTarget{{Name: "felhom-pbs", Type: hub.StorageTypePBS, MountPath: "/mnt/pbs"}}}
|
|
host := &f2HostReader{mounts: []storage.Mount{
|
|
{Device: "/dev/sda1", MountPoint: "/"},
|
|
{Device: "nas.local:/export", MountPoint: "/mnt/nas"}, // NAS: own lifecycle, never drive-ejectable
|
|
}}
|
|
// C1: mount not in the table at all.
|
|
h := f2EjectServer(t, &fakeDiskOps{}, sv, host).Handler()
|
|
if w := do(t, h, "POST", "/disks/eject", "A", `{"where":"/mnt/absent"}`); w.Code != http.StatusForbidden {
|
|
t.Fatalf("absent mount must be 403, got %d", w.Code)
|
|
}
|
|
// C2: NAS-backed mount → system (non-/dev source).
|
|
if w := do(t, h, "POST", "/disks/eject", "A", `{"where":"/mnt/nas"}`); w.Code != http.StatusForbidden {
|
|
t.Fatalf("NAS mount must be 403 (has its own lifecycle), got %d (%s)", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
// C3 — the CRITICAL fail-safe: when Observe() fails the fallback must NOT run (a blind containment
|
|
// pass could label a backup drive user-data — permissive). Assert 403 AND that Mounts() was called
|
|
// exactly once (by SystemDisks; the fallback — a second call — was skipped).
|
|
//
|
|
// COMPANION RED-PROOF: moving the fallback ahead of the Observe-error return (running it regardless)
|
|
// classifies /dev/sdb as user-data → this eject returns 200 → the 403 FAILS.
|
|
func TestRoleForMountPath_F2_ObserveError_NoFallback(t *testing.T) {
|
|
host := &f2HostReader{mounts: []storage.Mount{
|
|
{Device: "/dev/sda1", MountPoint: "/"},
|
|
{Device: "/dev/sdb", MountPoint: "/mnt/teszt_enroll"}, // would classify user-data IF the fallback ran
|
|
}}
|
|
h := f2EjectServer(t, &fakeDiskOps{}, errStorage{}, host).Handler()
|
|
if w := do(t, h, "POST", "/disks/eject", "A", `{"where":"/mnt/teszt_enroll"}`); w.Code != http.StatusForbidden {
|
|
t.Fatalf("Observe error must fail safe to 403, got %d (%s)", w.Code, w.Body.String())
|
|
}
|
|
if c := host.mountsCalls(); c != 1 {
|
|
t.Fatalf("on Observe error the fallback must be skipped: Mounts() called %d times, want 1 (SystemDisks only)", c)
|
|
}
|
|
}
|