Files
felhom-agent/internal/storage/netreassert_test.go
T
admin 474b858c0b v0.84.0: ReassertNetworkMounts — NAS automount survives guest reboots (RCA fix 1)
Storage §8 decision table (stop + enable --now on idle triggers; active mounts untouched),
daemon leg at startup with per-running-guest visibility verify, guest-hook post-start leg
(root, direct systemctl, non-fatal). Red-proofs: always-rearm table FAIL; unwired hook FAIL.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PSK5g6qYLknKj8u3QAFEr6
2026-07-11 20:46:59 +02:00

161 lines
6.9 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package storage
import (
"context"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
)
// The §8 decision table, encoded exactly (RCA AUDIT-nas-cwa-rca-2026-07-11 fix 1).
func TestNetReassertAction_Table(t *testing.T) {
cases := []struct {
fstype string
want string
}{
{"nfs4", NetReassertSkipActive}, // real mount — inherited by fresh namespaces
{"nfs", NetReassertSkipActive}, // real mount
{"cifs", NetReassertSkipActive}, // real mount
{"autofs", NetReassertRearmed}, // idle trigger — NOT inherited, re-arm to propagate
{"", NetReassertSkipNone}, // nothing at the path — removed/orphan, owned elsewhere
{"ext4", NetReassertSkipNone}, // a local fs at the path is not a network state we own
{"tmpfs", NetReassertSkipNone}, //
}
for _, c := range cases {
if got := netReassertAction(c.fstype); got != c.want {
t.Errorf("netReassertAction(%q) = %q, want %q", c.fstype, got, c.want)
}
}
}
// installNetUnitFile writes a rendered network .mount unit straight into unitDir (bypassing the
// runner-mediated install — the reassert only READS unit files).
func installNetUnitFile(t *testing.T, unitDir string, spec NetworkMountSpec) (where, automountUnit string) {
t.Helper()
where = spec.Where()
mountUnit, err := UnitNameForMount(where)
if err != nil {
t.Fatalf("unit name: %v", err)
}
if err := os.WriteFile(filepath.Join(unitDir, mountUnit), []byte(renderNetworkMountUnit(spec)), 0o644); err != nil {
t.Fatalf("write unit: %v", err)
}
return where, strings.TrimSuffix(mountUnit, ".mount") + ".automount"
}
func netReassertOps(t *testing.T, unitDir string, mounts []Mount) (*SudoHostOps, *recordingRunner) {
t.Helper()
rr := &recordingRunner{}
ops := NewSudoHostOps(SudoHostOpsConfig{
Runner: rr, Bins: Binaries{}.withDefaults(), UnitDir: unitDir, StageDir: t.TempDir(),
Host: &fakeHostReader{mounts: mounts}, Logger: quietLogger(),
})
return ops, rr
}
// An idle trigger (autofs at the mountpoint) must be re-armed with EXACTLY the granted verbs:
// `systemctl stop -- <unit>.automount` then `systemctl enable --now -- <unit>.automount`.
func TestReassertNetworkAutomounts_RearmsIdleTrigger(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("systemd-escaped unit filename contains a backslash; exercised on the Linux build server")
}
unitDir := t.TempDir()
spec := NetworkMountSpec{Name: "media", Protocol: ProtocolNFS, Server: "10.0.0.5", Export: "/srv/media", MappedUID: 1000, MappedGID: 1000}
where, autoUnit := installNetUnitFile(t, unitDir, spec)
ops, rr := netReassertOps(t, unitDir, []Mount{{MountPoint: where, FSType: "autofs"}})
results := ops.ReassertNetworkAutomounts(context.Background())
if len(results) != 1 || results[0].Action != NetReassertRearmed || results[0].Err != nil {
t.Fatalf("want one rearmed result, got %+v", results)
}
if results[0].Where != where || results[0].Name != "media" {
t.Fatalf("result identity wrong: %+v", results[0])
}
if len(rr.calls) != 2 {
t.Fatalf("want exactly stop + enable --now, got %d calls: %v", len(rr.calls), rr.calls)
}
stop, enable := strings.Join(rr.calls[0], " "), strings.Join(rr.calls[1], " ")
if !strings.Contains(stop, "systemctl stop -- "+autoUnit) {
t.Errorf("first call must be `systemctl stop -- %s`, got: %s", autoUnit, stop)
}
if !strings.Contains(enable, "systemctl enable --now -- "+autoUnit) {
t.Errorf("second call must be `systemctl enable --now -- %s`, got: %s", autoUnit, enable)
}
}
// An ACTIVE real mount must not be touched — stopping the automount of a live mount would churn it.
// (Red-proof companion: a naive always-rearm implementation fails this with 2 recorded calls.)
func TestReassertNetworkAutomounts_ActiveMountUntouched(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("systemd-escaped unit filename contains a backslash; exercised on the Linux build server")
}
unitDir := t.TempDir()
spec := NetworkMountSpec{Name: "media", Protocol: ProtocolNFS, Server: "10.0.0.5", Export: "/srv/media", MappedUID: 1000, MappedGID: 1000}
where, _ := installNetUnitFile(t, unitDir, spec)
ops, rr := netReassertOps(t, unitDir, []Mount{{MountPoint: where, FSType: "nfs4"}})
results := ops.ReassertNetworkAutomounts(context.Background())
if len(results) != 1 || results[0].Action != NetReassertSkipActive {
t.Fatalf("want one skip-active result, got %+v", results)
}
if len(rr.calls) != 0 {
t.Fatalf("an actively-mounted share must trigger ZERO systemctl calls, got: %v", rr.calls)
}
}
// Neither a mount nor an armed trigger → skip (removed/orphan state, owned by add/remove flows).
func TestReassertNetworkAutomounts_NoTriggerSkips(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("systemd-escaped unit filename contains a backslash; exercised on the Linux build server")
}
unitDir := t.TempDir()
spec := NetworkMountSpec{Name: "media", Protocol: ProtocolNFS, Server: "10.0.0.5", Export: "/srv/media", MappedUID: 1000, MappedGID: 1000}
installNetUnitFile(t, unitDir, spec)
ops, rr := netReassertOps(t, unitDir, nil) // nothing at the mountpoint
results := ops.ReassertNetworkAutomounts(context.Background())
if len(results) != 1 || results[0].Action != NetReassertSkipNone {
t.Fatalf("want one skip-none result, got %+v", results)
}
if len(rr.calls) != 0 {
t.Fatalf("a unit with no trigger must not be acted on, got: %v", rr.calls)
}
}
// Idempotency: two consecutive passes over an idle trigger both succeed with the same action and no
// error (re-arming a fresh trigger is harmless — same end state).
func TestReassertNetworkAutomounts_Idempotent(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("systemd-escaped unit filename contains a backslash; exercised on the Linux build server")
}
unitDir := t.TempDir()
spec := NetworkMountSpec{Name: "media", Protocol: ProtocolNFS, Server: "10.0.0.5", Export: "/srv/media", MappedUID: 1000, MappedGID: 1000}
where, _ := installNetUnitFile(t, unitDir, spec)
ops, rr := netReassertOps(t, unitDir, []Mount{{MountPoint: where, FSType: "autofs"}})
first := ops.ReassertNetworkAutomounts(context.Background())
second := ops.ReassertNetworkAutomounts(context.Background())
if first[0].Action != NetReassertRearmed || second[0].Action != NetReassertRearmed {
t.Fatalf("both passes must re-arm: first=%+v second=%+v", first, second)
}
if first[0].Err != nil || second[0].Err != nil {
t.Fatalf("idempotent passes must not error: first=%v second=%v", first[0].Err, second[0].Err)
}
if len(rr.calls) != 4 {
t.Fatalf("two passes = 2×(stop+enable), got %d: %v", len(rr.calls), rr.calls)
}
}
// Zero configured network units → empty pass, zero commands.
func TestReassertNetworkAutomounts_NoUnitsNoOp(t *testing.T) {
ops, rr := netReassertOps(t, t.TempDir(), nil)
if results := ops.ReassertNetworkAutomounts(context.Background()); len(results) != 0 {
t.Fatalf("no units must yield no results, got %+v", results)
}
if len(rr.calls) != 0 {
t.Fatalf("no units must construct zero commands, got: %v", rr.calls)
}
}