b58d7bcf39
BoundUnderParent, the one field the controller's drive-absent gate keys on,
reported only "is this path a mount target in the guest's mountinfo". The
drive's raw mount at /mnt/<name> is a systemd mount unit bound to its device and
dies with it, but the agent's own bind of <raw>/felhom-data under the shared
parent is an ordinary bind: nothing ties it to the device, so its mountinfo
entry OUTLIVES the device as a stale shell. Presence read that survivor as true,
planDriveGates 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: detached at 10:58:37Z, silent for 4.5 minutes while the agent itself
logged "enrolled drive absent by UUID" every 20s (felhom.eu
audits/E2D-fresh-vm-2026-07-29.md §5.2).
The fix: BoundUnderParent becomes a CONJUNCTION -- bound under the parent AND
the drive's raw host mount still mounted (devicePresent, new deviceCheck seam
defaulting to isHostMountpoint). Applied at BOTH /disks construction sites. The
union path matters more, not less: it hardcodes State:"attached", so the
raw-mount check is the only device truth that row carries, and it is exactly the
shape E-2d detached.
Why a conjunction and not a replacement: half 2 alone would regress boot
ordering, where the raw drive mounts early and the bind lands ~18s later; the
gate depends on that window reading ABSENT. The conjunction leaves that
byte-identical and closes only the case the gate could never see.
Unknown is never absent: devicePresent("") returns TRUE. A false absent stops a
working customer's apps -- the failure mode of this fix, not of the bug.
Controller UNCHANGED, no MinAgent bump. BoundUnderParent has exactly one
functional consumer (planDriveGates, intermediary.go:226); every other mention
in both repos is a comment or a test, and boot convergence deliberately moved
off it to pollLiveBinds/driveBindLive. The alternative -- a new DevicePresent
bool the controller ANDs in -- was rejected as dangerous: a bool absent from an
older agent's JSON decodes to false, so every drive on a pre-0.114.0 agent would
have read ABSENT and stopped its apps.
Tests +6 in internal/localapi (208 -> 214): groups A-D plus a wire-contract test
asserting the ENCODED bound_under_parent, since that is what crosses to the
controller. Four red-proofs run and reverted (drop the conjunction on each path;
invert unknown; drop the bind half); disks.go verified byte-identical after.
NOT LIVE-VALIDATED. No drive was pulled. Leg awaiting Session C: device loss ->
gate Stop -> SetDisconnected -> backup_target_absent on the wire.
105 lines
4.8 KiB
Go
105 lines
4.8 KiB
Go
package localapi
|
|
|
|
import (
|
|
"context"
|
|
"io"
|
|
"log/slog"
|
|
"testing"
|
|
|
|
"gitea.dooplex.hu/admin/felhom-agent/internal/hub"
|
|
"gitea.dooplex.hu/admin/felhom-agent/internal/storage"
|
|
)
|
|
|
|
// TestStarttimeFromStat parses field 22 (starttime) from a /proc/<pid>/stat line whose comm contains
|
|
// spaces AND an inner ')' — the case naive whitespace-splitting (or split-on-FIRST-')') mis-parses.
|
|
//
|
|
// COMPANION GUARD: a pre-fix impl that splits the whole line on whitespace reads the wrong field (the
|
|
// multi-token comm shifts every index); one that splits on the FIRST ')' splits inside the comm. Both
|
|
// return != "9988776655" here.
|
|
func TestStarttimeFromStat(t *testing.T) {
|
|
// pid=42, comm="(weird ) name)" (spaces + an inner ')'), state 'S', then fields; field 22 = 9988776655.
|
|
stat := "42 (weird ) name) S 1 42 42 0 -1 4194560 100 0 0 0 5 6 0 0 20 0 1 0 9988776655 12345 67 1 1 1 0 0 0 0\n"
|
|
if got := starttimeFromStat(stat); got != "9988776655" {
|
|
t.Fatalf("starttimeFromStat = %q, want 9988776655 (comm with spaces+')' must not break the parse)", got)
|
|
}
|
|
if got := starttimeFromStat("garbage no parens"); got != "" {
|
|
t.Fatalf("malformed stat should yield \"\", got %q", got)
|
|
}
|
|
}
|
|
|
|
func TestStablePathForRaw_DriveName(t *testing.T) {
|
|
cases := []struct {
|
|
where, name, stable string
|
|
}{
|
|
{"/mnt/felhom-usb", "felhom-usb", "/mnt/felhom-drives/felhom-usb"},
|
|
{"/mnt/felhom-flash", "felhom-flash", "/mnt/felhom-drives/felhom-flash"},
|
|
{"/mnt/felhom-drives/x", "", ""}, // nested (slash in remainder) → not a /mnt/<name> raw mount
|
|
{"/var/lib/vz", "", ""}, // not under /mnt
|
|
{"/mnt/", "", ""}, // empty name
|
|
}
|
|
for _, c := range cases {
|
|
if got := DriveNameFromRaw(c.where); got != c.name {
|
|
t.Errorf("DriveNameFromRaw(%q) = %q, want %q", c.where, got, c.name)
|
|
}
|
|
if got := StablePathForRaw(c.where); got != c.stable {
|
|
t.Errorf("StablePathForRaw(%q) = %q, want %q", c.where, got, c.stable)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestDisks_GuestPathAndBoundUnderParent asserts the intermediary reporting the controller relies on: a
|
|
// user-data drive gets a STABLE guest path (/mnt/felhom-drives/<name>) and BoundUnderParent reflects the
|
|
// host mount check; a SYSTEM mount gets neither (it never crosses into the guest).
|
|
//
|
|
// COMPANION GUARD: a pre-fix impl that left GuestPath empty (no repoint signal) or that reported
|
|
// BoundUnderParent ignoring the real mount state would fail the assertions below.
|
|
func TestDisks_GuestPathAndBoundUnderParent(t *testing.T) {
|
|
d := &fakeDiskOps{probe: storage.DeviceProbe{Probed: true, HasFilesystem: true, FSType: "ext4"}}
|
|
sv := fakeStorage{targets: []hub.StorageTarget{
|
|
{Name: "usb", Type: hub.StorageTypeUSB, BackingDevice: "/dev/sdb1", MountPath: "/mnt/felhom-usb"},
|
|
{Name: "flash", Type: hub.StorageTypeUSB, BackingDevice: "/dev/sdc1", MountPath: "/mnt/felhom-flash"},
|
|
{Name: "system", Type: "local", MountPath: "/var/lib/vz"}, // system role → no guest path
|
|
}}
|
|
fg := &fakeGuestsCfg{}
|
|
srv, err := NewServer(Options{
|
|
ListenAddr: "127.0.0.1:0", Guests: fg, Backups: &fakeBackups{}, Store: &fakeStore{},
|
|
Storage: sv, Tokens: staticTokens{"A": 8200},
|
|
Disks: d, DiskGate: &fakeGate{}, HostReader: sysOnSDA(),
|
|
Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
srv.baseCtx = context.Background()
|
|
// felhom-usb is bound under the parent; felhom-flash is not.
|
|
srv.boundCheck = func(p string) bool { return p == "/mnt/felhom-drives/felhom-usb" }
|
|
// R-113 (v0.114.0): BoundUnderParent is now `bound && device present`. This test's subject is the
|
|
// BIND half, so hold the device half constant at present — otherwise the fixture would be asserting
|
|
// a drive that is bound with no raw mount underneath it, which is the absent state, not this test's
|
|
// case. Device presence has its own tests (TestDisks_DevicePresence*).
|
|
srv.deviceCheck = func(string) bool { return true }
|
|
|
|
disks := decodeDisks(t, do(t, srv.Handler(), "GET", "/disks", "A", "").Body.Bytes())
|
|
byMount := map[string]DiskInfo{}
|
|
for _, di := range disks {
|
|
byMount[di.MountPath] = di
|
|
}
|
|
|
|
if gp := byMount["/mnt/felhom-usb"].GuestPath; gp != "/mnt/felhom-drives/felhom-usb" {
|
|
t.Errorf("usb GuestPath = %q, want /mnt/felhom-drives/felhom-usb", gp)
|
|
}
|
|
if !byMount["/mnt/felhom-usb"].BoundUnderParent {
|
|
t.Errorf("usb should report BoundUnderParent=true (bound under the parent)")
|
|
}
|
|
if byMount["/mnt/felhom-flash"].BoundUnderParent {
|
|
t.Errorf("flash should report BoundUnderParent=false (not bound)")
|
|
}
|
|
if gp := byMount["/mnt/felhom-flash"].GuestPath; gp != "/mnt/felhom-drives/felhom-flash" {
|
|
t.Errorf("flash GuestPath = %q, want /mnt/felhom-drives/felhom-flash", gp)
|
|
}
|
|
// A SYSTEM mount must never get a guest path (it does not cross into the guest).
|
|
if gp := byMount["/var/lib/vz"].GuestPath; gp != "" {
|
|
t.Errorf("system mount GuestPath = %q, want empty (never crosses into the guest)", gp)
|
|
}
|
|
}
|