added9d226
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
108 lines
5.4 KiB
Go
108 lines
5.4 KiB
Go
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)
|
||
}
|
||
}
|
||
}
|