f1b9b41214
gates / gates (push) Successful in 47s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
224 lines
10 KiB
Go
224 lines
10 KiB
Go
package localapi
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"io"
|
|
"log/slog"
|
|
"runtime"
|
|
"strings"
|
|
"testing"
|
|
|
|
"gitea.dooplex.hu/admin/felhom-agent/internal/hub"
|
|
"gitea.dooplex.hu/admin/felhom-agent/internal/storage"
|
|
)
|
|
|
|
// R-113 — BoundUnderParent must mean THE DEVICE IS THERE, not "a mount entry with this name exists".
|
|
//
|
|
// THE BUG THESE PIN. The drive's raw mount at /mnt/<name> is a systemd mount unit bound to its device
|
|
// and dies with it. The agent's own bind of <raw>/felhom-data under the shared parent is an ordinary
|
|
// bind — nothing ties it to the device — so it OUTLIVES the device as a stale shell. Before v0.114.0
|
|
// BoundUnderParent was half 1 only, so a pulled drive kept reporting present, the controller's
|
|
// drive-absent gate never produced a Stop action, and NOTHING fired on any channel: not
|
|
// backup_target_absent, not the generic storage_disconnected. Measured live in E-2d with the device
|
|
// detached — /mnt/mentes2 NOT mounted while /mnt/felhom-drives/mentes2 still read /dev/sdb[/felhom-data]
|
|
// (felhom.eu audits/E2D-fresh-vm-2026-07-29.md §5.2).
|
|
//
|
|
// RED-PROOF. Drop `&& s.devicePresent(...)` from either construction site in disks.go and
|
|
// TestDisks_DevicePresence_ObservePath_DeviceLossReadsAbsent / _UnionPath_... fail with
|
|
// "reports present — the bind outlived the device (R-113)".
|
|
//
|
|
// These drive the REAL production path: NewServer → GET /disks through srv.Handler() → the JSON the
|
|
// controller actually parses. The two lowest-level mount reads are injected (a unit test cannot create
|
|
// real mounts), but nothing above them is faked, and the wire test below asserts the encoded field.
|
|
|
|
// presenceServer builds a /disks server over one Observe target and/or one registry drive, with the
|
|
// bind and device checks independently controllable — the two conditions whose CONJUNCTION is the fix.
|
|
func presenceServer(t *testing.T, obs []hub.StorageTarget, known []storage.KnownTarget, bound, device bool) *Server {
|
|
t.Helper()
|
|
opts := Options{
|
|
ListenAddr: "127.0.0.1:0",
|
|
Guests: &fakeGuestsCfg{},
|
|
Backups: &fakeBackups{},
|
|
Store: &fakeStore{},
|
|
Storage: fakeStorage{targets: obs},
|
|
Tokens: staticTokens{"A": 8200},
|
|
Disks: &fakeDiskOps{probe: storage.DeviceProbe{Probed: true, HasFilesystem: true, FSType: "ext4"}},
|
|
DiskGate: &fakeGate{},
|
|
HostReader: sysOnSDA(),
|
|
Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
|
|
}
|
|
if known != nil {
|
|
opts.DriveTargets = fakeKnownTargets{drives: known}
|
|
}
|
|
srv, err := NewServer(opts)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
srv.baseCtx = context.Background()
|
|
srv.boundCheck = func(string) bool { return bound }
|
|
srv.deviceCheck = func(string) bool { return device }
|
|
srv.resolveStorageDevice = func(string) (string, error) { return "/dev/sdb1", nil }
|
|
return srv
|
|
}
|
|
|
|
func diskByMount(t *testing.T, srv *Server, mount string) DiskInfo {
|
|
t.Helper()
|
|
for _, di := range decodeDisks(t, do(t, srv.Handler(), "GET", "/disks", "A", "").Body.Bytes()) {
|
|
if di.MountPath == mount {
|
|
return di
|
|
}
|
|
}
|
|
t.Fatalf("no disk reported for mount %q", mount)
|
|
return DiskInfo{}
|
|
}
|
|
|
|
var obsUSB = []hub.StorageTarget{
|
|
{Name: "usb", Type: hub.StorageTypeUSB, BackingDevice: "/dev/sdb1", MountPath: "/mnt/felhom-usb", State: hub.StorageStateAttached},
|
|
}
|
|
|
|
var knownUSB = []storage.KnownTarget{
|
|
{Name: "mentes2", Type: hub.StorageTypeUSB, MountPath: "/mnt/mentes2", DurableID: "uuid:9303", UUID: "9303"},
|
|
}
|
|
|
|
// ── Group A — device loss is seen ───────────────────────────────────────────────────────────────
|
|
|
|
func TestDisks_DevicePresence_ObservePath_DeviceLossReadsAbsent(t *testing.T) {
|
|
// The exact E-2d shape: the bind survives (bound=true), the device is gone (device=false).
|
|
di := diskByMount(t, presenceServer(t, obsUSB, nil, true, false), "/mnt/felhom-usb")
|
|
if di.BoundUnderParent {
|
|
t.Error("BoundUnderParent reports present — the bind outlived the device (R-113). " +
|
|
"The controller's gate would emit no Stop action, so no alarm can fire.")
|
|
}
|
|
}
|
|
|
|
func TestDisks_DevicePresence_UnionPath_DeviceLossReadsAbsent(t *testing.T) {
|
|
// The union path matters MORE: a registry drive with no PVE dir-storage hardcodes State:"attached",
|
|
// so the raw-mount check is the only device truth the row carries. This is what E-2d detached.
|
|
di := diskByMount(t, presenceServer(t, nil, knownUSB, true, false), "/mnt/mentes2")
|
|
if di.BoundUnderParent {
|
|
t.Error("union-path drive reports present — the bind outlived the device (R-113)")
|
|
}
|
|
if di.State != hub.StorageStateAttached {
|
|
t.Logf("note: union-path State is %q", di.State) // hardcoded; see the OBSERVATION in the report
|
|
}
|
|
}
|
|
|
|
// ── Group B — the healthy drive, and the return ─────────────────────────────────────────────────
|
|
|
|
func TestDisks_DevicePresence_HealthyReadsPresent(t *testing.T) {
|
|
for _, c := range []struct {
|
|
name string
|
|
obs []hub.StorageTarget
|
|
known []storage.KnownTarget
|
|
mount string
|
|
}{
|
|
{"observe", obsUSB, nil, "/mnt/felhom-usb"},
|
|
{"union", nil, knownUSB, "/mnt/mentes2"},
|
|
} {
|
|
t.Run(c.name, func(t *testing.T) {
|
|
di := diskByMount(t, presenceServer(t, c.obs, c.known, true, true), c.mount)
|
|
if !di.BoundUnderParent {
|
|
t.Error("a bound drive whose device is present must read PRESENT — " +
|
|
"a false absent stops a working customer's apps (Scenario C's failure mode)")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// ── Group C — the over-correction guard: boot ordering must not regress ─────────────────────────
|
|
|
|
func TestDisks_DevicePresence_BootWindowStillReadsAbsent(t *testing.T) {
|
|
// Boot ordering: the raw drive mounts EARLY (device=true), the agent binds under the parent ~18s
|
|
// LATER (bound=false). Presence must stay FALSE in that window — unchanged from before R-113 — so
|
|
// apps stay stopped until the bind is live and the gate's Return branch recreates them.
|
|
di := diskByMount(t, presenceServer(t, obsUSB, nil, false, true), "/mnt/felhom-usb")
|
|
if di.BoundUnderParent {
|
|
t.Error("boot window reports present before the bind landed — this regresses the reboot " +
|
|
"convergence the controller's gate comment at intermediary.go:220-224 depends on")
|
|
}
|
|
}
|
|
|
|
// ── Group D — unknown must never mean absent ────────────────────────────────────────────────────
|
|
|
|
func TestDisks_DevicePresence_UnknownIsNotAbsent(t *testing.T) {
|
|
// devicePresent has nothing to ask about when there is no raw mount path. It must answer TRUE.
|
|
// Absence of a signal is not evidence of absence of a device — and the cost of getting this
|
|
// backwards is stopping a healthy customer's apps.
|
|
srv := presenceServer(t, nil, nil, true, false)
|
|
srv.deviceCheck = nil // exercise the real devicePresent, not the injected fake
|
|
if !srv.devicePresent("") {
|
|
t.Error("devicePresent(\"\") = false — an unanswerable question was reported as ABSENT")
|
|
}
|
|
}
|
|
|
|
// ── The wire contract — what the controller actually parses ─────────────────────────────────────
|
|
|
|
// TestDisks_DevicePresence_WireFieldIsFalseOnDeviceLoss travels construction → HTTP handler → JSON
|
|
// encoding and asserts the ENCODED field, because that is what crosses to the controller. A struct-level
|
|
// assertion would not catch the field being dropped from the wire (e.g. an omitempty regression), and
|
|
// `bound_under_parent` is the single field the controller's drive-absent gate keys on.
|
|
func TestDisks_DevicePresence_WireFieldIsFalseOnDeviceLoss(t *testing.T) {
|
|
body := do(t, presenceServer(t, nil, knownUSB, true, false).Handler(), "GET", "/disks", "A", "").Body.Bytes()
|
|
if !strings.Contains(string(body), `"bound_under_parent"`) {
|
|
t.Fatalf("the wire has no bound_under_parent field at all — the controller's gate reads nothing: %s", body)
|
|
}
|
|
var wire struct {
|
|
Data struct {
|
|
Disks []map[string]any `json:"disks"`
|
|
} `json:"data"`
|
|
}
|
|
if err := json.Unmarshal(body, &wire); err != nil {
|
|
t.Fatalf("decode /disks: %v", err)
|
|
}
|
|
var seen bool
|
|
for _, d := range wire.Data.Disks {
|
|
if d["mount_path"] != "/mnt/mentes2" {
|
|
continue
|
|
}
|
|
seen = true
|
|
if v, ok := d["bound_under_parent"].(bool); !ok || v {
|
|
t.Errorf("wire bound_under_parent = %v (want false) — the device is gone", d["bound_under_parent"])
|
|
}
|
|
}
|
|
if !seen {
|
|
t.Fatalf("the drive never reached the wire: %s", body)
|
|
}
|
|
}
|
|
|
|
// ── R-118 — an absent drive must not advertise the ROOT filesystem's capacity ───────────────────
|
|
|
|
// TestDisks_UnionPath_AbsentDeviceReportsNoRootCapacity drives the REAL statfsCapacity (no capacity
|
|
// seam): the registry drive's mount path is a real, bare temp directory — exactly what /mnt/<name>
|
|
// becomes once its device is gone (a plain directory on the host's filesystem). With the device absent
|
|
// the row must carry NO capacity; before R-118 the union path statfs'd that bare directory and reported
|
|
// the host filesystem's size and usage as the drive's (46 GiB at 9.2 % for a 4 GB drive, measured).
|
|
// The present half proves the test is not hollow: the same directory DOES yield capacity when the
|
|
// device is there, so a zero on the absent half is the guard's doing, not a statfs failure.
|
|
//
|
|
// RED-PROOF: drop the `if s.devicePresent(d.MountPath)` guard around statfsCapacity in disks.go → the
|
|
// absent subtest fails with "advertises ... bytes".
|
|
func TestDisks_UnionPath_AbsentDeviceReportsNoRootCapacity(t *testing.T) {
|
|
if runtime.GOOS != "linux" {
|
|
t.Skip("statfsCapacity is linux-only; production target is linux")
|
|
}
|
|
bare := t.TempDir()
|
|
known := []storage.KnownTarget{
|
|
{Name: "cel", Type: hub.StorageTypeUSB, MountPath: bare, DurableID: "uuid:4242", UUID: "4242"},
|
|
}
|
|
t.Run("absent", func(t *testing.T) {
|
|
di := diskByMount(t, presenceServer(t, nil, known, true, false), bare)
|
|
if di.TotalBytes != 0 || di.UsedBytes != 0 || di.UsedFraction != 0 {
|
|
t.Errorf("absent drive advertises total=%d used=%d frac=%.3f — that is the filesystem UNDER "+
|
|
"the bare mountpoint, not the drive (R-118)", di.TotalBytes, di.UsedBytes, di.UsedFraction)
|
|
}
|
|
})
|
|
t.Run("present", func(t *testing.T) {
|
|
di := diskByMount(t, presenceServer(t, nil, known, true, true), bare)
|
|
if di.TotalBytes <= 0 {
|
|
t.Errorf("present drive reports no capacity (total=%d) — the guard over-corrected and the "+
|
|
"size bar is gone for every healthy registry drive", di.TotalBytes)
|
|
}
|
|
})
|
|
}
|