Files
felhom-agent/internal/storage/netverify_test.go
T
admin added9d226 v0.81.0: NAS verify-before-commit — retry=0, journal classifier, detached verify job + auto-rollback
Agent half of the verify-before-commit task (SPIKE-nas-verify-2026-07-11, b57f6c1):
retry=0 in the production NFS options (Q4-vi); ClassifyNetVerifyFailure on the live
Q4 strings (nfs_export merges not-found/not-permitted); add = sync fast-fail (2s TCP
pre-probe, nothing installed) + detached in-memory verify job judging /proc/mounts
only, auto-rollback on failure; GET /netstorage/verify-status (phase none = the
controller's Scenario-F rollback signal); unprivileged journalctl (systemd-journal
group, NO new sudoers grants).

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

108 lines
5.4 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 (
"strings"
"testing"
)
// --- A1: the retry=0 knob (SPIKE-nas-verify Q4-vi) --------------------------------------------------
// TestMountOptions_NFSRetry0_SMBWithout: retry=0 is in the NFS option string (dead-NAS access 91 s →
// 3.8 s) and NOT in the SMB one (retry is a mount.nfs option; mount.cifs would reject the mount).
// Companion red-proof: revert the mountOptions NFS branch → the first assertion fails.
func TestMountOptions_NFSRetry0_SMBWithout(t *testing.T) {
nfs := NetworkMountSpec{Name: "m", Protocol: ProtocolNFS, Server: "s", Export: "/e", MappedUID: 1000, MappedGID: 1000}
if got := nfs.mountOptions(); !strings.Contains(got, ",retry=0") {
t.Errorf("NFS options missing retry=0 (Q4-vi): %q", got)
}
smb := NetworkMountSpec{Name: "m", Protocol: ProtocolSMB, Server: "s", Export: "e",
MappedUID: 1000, MappedGID: 1000, CredsRef: "/var/lib/felhom-agent/smb-creds/m.cred"}
if got := smb.mountOptions(); strings.Contains(got, "retry=") {
t.Errorf("SMB options must NOT carry retry= (mount.cifs rejects it): %q", got)
}
}
// --- A2: the journal classifier (SPIKE-nas-verify Q4 — VERBATIM live strings) -----------------------
// TestClassifyNetVerifyFailure runs the Q4 taxonomy on the strings captured live in the spike.
// Companion red-proof: an exit-code-based classifier (every failure is rc=32) collapses smb_auth and
// smb_share into one code — modeled by replacing the body with `return NetVerifyMountFailed, ""`;
// every non-generic row fails.
func TestClassifyNetVerifyFailure(t *testing.T) {
cases := []struct {
name string
journal string
reachable bool
want string
}{
// Q4 i — unreachable with retry=0 (the production unit's shape).
{"no route", "mount.nfs4: No route to host for 192.168.0.199:/srv/nope on /mnt/felhom-drives/x", false, NetVerifyUnreachable},
{"conn refused", "mount.nfs4: Connection refused", false, NetVerifyUnreachable},
{"conn timed out", "mount.nfs4: Connection timed out", false, NetVerifyUnreachable},
// Q4 ii ≡ iii — the MERGED category: NFSv4 cannot distinguish no-export from not-permitted.
{"nfs export missing", "mount.nfs4: mounting 192.168.0.180:/srv/nas-spike2/nope failed, reason given by server: No such file or directory", true, NetVerifyNFSExport},
{"nfs export denied (identical string)", "mount.nfs4: mounting 192.168.0.180:/srv/nas-spike2/q4iii failed, reason given by server: No such file or directory", true, NetVerifyNFSExport},
// Q4 iv / v — SMB splits cleanly on the errno line.
{"smb wrong password", "mount error(13): Permission denied\nRefer to the mount.cifs(8) manual page", true, NetVerifySMBAuth},
{"smb wrong share", "mount error(2): No such file or directory\nRefer to the mount.cifs(8) manual page", true, NetVerifySMBShare},
// Q4 i (default retry) — systemd kills mount.nfs at its 90 s cap, no mount.nfs diagnostic.
{"systemd timeout", "Mounting timed out. Terminating.\nMount process exited, code=killed, status=15/TERM", true, NetVerifyTimeout},
// Degradations: nothing matched.
{"empty journal, reachable", "", true, NetVerifyMountFailed},
{"empty journal, unreachable", "", false, NetVerifyUnreachable},
{"garbage, reachable", "some future mount.nfs5 wording", true, NetVerifyMountFailed},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
code, hint := ClassifyNetVerifyFailure(tc.journal, tc.reachable)
if code != tc.want {
t.Errorf("ClassifyNetVerifyFailure(%q, reachable=%v) = %q, want %q", tc.journal, tc.reachable, code, tc.want)
}
if hint == "" {
t.Errorf("hint must never be empty (code %q)", code)
}
})
}
}
// TestClassify_SMBShareNeverShadowsNFS: the SMB error(2) line contains "No such file or directory"
// too — the NFS rule must key on the full "reason given by server:" fragment so neither shadows the
// other regardless of table order.
func TestClassify_SMBShareNeverShadowsNFS(t *testing.T) {
code, _ := ClassifyNetVerifyFailure("mount error(2): No such file or directory", true)
if code != NetVerifySMBShare {
t.Errorf("SMB error(2) classified %q, want %q", code, NetVerifySMBShare)
}
code, _ = ClassifyNetVerifyFailure("failed, reason given by server: No such file or directory", true)
if code != NetVerifyNFSExport {
t.Errorf("NFS server-reason classified %q, want %q", code, NetVerifyNFSExport)
}
}
// --- networkMountedIn: the §8 truth source (autofs trigger ≠ mounted) --------------------------------
func TestNetworkMountedIn(t *testing.T) {
procMounts := `sysfs /sys sysfs rw 0 0
systemd-1 /mnt/felhom-drives/idle autofs rw,relatime,fd=86 0 0
systemd-1 /mnt/felhom-drives/live autofs rw,relatime,fd=86 0 0
192.168.0.180:/srv/media /mnt/felhom-drives/live nfs4 rw,noatime,vers=4.1,soft 0 0
//nas/share /mnt/felhom-drives/smb cifs rw,vers=3.0 0 0
/dev/sda1 /mnt/felhom-drives/disk ext4 rw 0 0
`
cases := []struct {
where string
want bool
}{
{"/mnt/felhom-drives/live", true}, // real nfs4 (the autofs line for the same path must not confuse it)
{"/mnt/felhom-drives/smb", true}, // cifs
{"/mnt/felhom-drives/idle", false}, // autofs trigger ONLY — idle automount is NOT mounted
{"/mnt/felhom-drives/disk", false}, // a local fs at the path is not a network mount
{"/mnt/felhom-drives/nope", false}, // absent
}
for _, tc := range cases {
if got := networkMountedIn(procMounts, tc.where); got != tc.want {
t.Errorf("networkMountedIn(%q) = %v, want %v", tc.where, got, tc.want)
}
}
}