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
335 lines
14 KiB
Go
335 lines
14 KiB
Go
package storage
|
|
|
|
import (
|
|
"context"
|
|
"os"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// --- Unit rendering: the exact, locked SPIKE option sets + the +100000 recipe -----------------------
|
|
|
|
func TestNetworkMount_NFSUnitRendering(t *testing.T) {
|
|
spec := NetworkMountSpec{
|
|
Name: "media", Protocol: ProtocolNFS, Server: "192.168.0.180", Export: "/srv/nas-sim/media",
|
|
MappedUID: 1000, MappedGID: 1000,
|
|
}
|
|
if err := ValidateNetworkMountSpec(spec); err != nil {
|
|
t.Fatalf("valid NFS spec rejected: %v", err)
|
|
}
|
|
mu := renderNetworkMountUnit(spec)
|
|
|
|
wantOpts := "Options=vers=4.1,soft,timeo=50,retrans=2,noatime,_netdev,retry=0"
|
|
for _, want := range []string{
|
|
netUnitMarker,
|
|
"What=192.168.0.180:/srv/nas-sim/media",
|
|
"Where=/mnt/felhom-drives/media",
|
|
"Type=nfs4",
|
|
wantOpts,
|
|
} {
|
|
if !strings.Contains(mu, want) {
|
|
t.Errorf("NFS .mount missing %q:\n%s", want, mu)
|
|
}
|
|
}
|
|
// soft is the failure-isolation knob; a default hard mount wedges — it must NEVER appear.
|
|
if strings.Contains(mu, "hard") {
|
|
t.Errorf("NFS mount must not be hard:\n%s", mu)
|
|
}
|
|
// NFS uid mapping is the EXPORT's job (anonuid=101000) — the client mount carries no uid/gid.
|
|
if strings.Contains(mu, "uid=") || strings.Contains(mu, "anonuid") {
|
|
t.Errorf("NFS client mount must not carry uid options (server-side squash):\n%s", mu)
|
|
}
|
|
|
|
au := renderNetworkAutomountUnit(spec)
|
|
for _, want := range []string{
|
|
netUnitMarker,
|
|
"Where=/mnt/felhom-drives/media",
|
|
"[Automount]",
|
|
"TimeoutIdleSec=60",
|
|
"WantedBy=multi-user.target",
|
|
} {
|
|
if !strings.Contains(au, want) {
|
|
t.Errorf("NFS .automount missing %q:\n%s", want, au)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestNetworkMount_SMBUnitRendering_Plus100000 is the headline +100000 companion: a container uid/gid of
|
|
// 1000 MUST render the SMB client mount with uid=101000/gid=101000 (host = container+100000). A naive
|
|
// +0 implementation (uid=1000) would FAIL here — and lands as nobody:nogroup in the guest (not writable).
|
|
func TestNetworkMount_SMBUnitRendering_Plus100000(t *testing.T) {
|
|
spec := NetworkMountSpec{
|
|
Name: "media", Protocol: ProtocolSMB, Server: "nas.local", Export: "media",
|
|
MappedUID: 1000, MappedGID: 1000, CredsRef: "/var/lib/felhom-agent/smb-creds/media.cred",
|
|
}
|
|
if err := ValidateNetworkMountSpec(spec); err != nil {
|
|
t.Fatalf("valid SMB spec rejected: %v", err)
|
|
}
|
|
mu := renderNetworkMountUnit(spec)
|
|
|
|
wantOpts := "Options=vers=3.0,credentials=/var/lib/felhom-agent/smb-creds/media.cred,uid=101000,gid=101000,forceuid,forcegid,file_mode=0664,dir_mode=0775,_netdev"
|
|
for _, want := range []string{
|
|
netUnitMarker,
|
|
"What=//nas.local/media",
|
|
"Where=/mnt/felhom-drives/media",
|
|
"Type=cifs",
|
|
wantOpts,
|
|
} {
|
|
if !strings.Contains(mu, want) {
|
|
t.Errorf("SMB .mount missing %q:\n%s", want, mu)
|
|
}
|
|
}
|
|
// THE companion red-proof: the +100000 offset must be applied; a +0 impl emits uid=1000.
|
|
if strings.Contains(mu, "uid=1000,") || strings.Contains(mu, "gid=1000,") {
|
|
t.Errorf("SMB mount used the raw container id, not +100000 (the documented non-writable trap):\n%s", mu)
|
|
}
|
|
// Modes must be PLAIN octal (0664/0775), never the setgid 2775 the drive userdata uses.
|
|
if strings.Contains(mu, "2775") {
|
|
t.Errorf("SMB dir_mode must be plain octal 0775, not setgid 2775:\n%s", mu)
|
|
}
|
|
}
|
|
|
|
func TestNetworkMount_HostOffset(t *testing.T) {
|
|
s := NetworkMountSpec{MappedUID: 1000, MappedGID: 1000}
|
|
if s.HostUID() != 101000 || s.HostGID() != 101000 {
|
|
t.Fatalf("HostUID/HostGID = %d/%d, want 101000/101000", s.HostUID(), s.HostGID())
|
|
}
|
|
}
|
|
|
|
// --- Validation -------------------------------------------------------------------------------------
|
|
|
|
func TestValidateNetworkMountSpec(t *testing.T) {
|
|
base := NetworkMountSpec{Name: "media", Protocol: ProtocolNFS, Server: "10.0.0.5", Export: "/srv/media", MappedUID: 1000, MappedGID: 1000}
|
|
good := func(mut func(*NetworkMountSpec)) NetworkMountSpec { s := base; mut(&s); return s }
|
|
|
|
bad := []struct {
|
|
name string
|
|
spec NetworkMountSpec
|
|
}{
|
|
{"empty name", good(func(s *NetworkMountSpec) { s.Name = "" })},
|
|
{"name traversal", good(func(s *NetworkMountSpec) { s.Name = ".." })},
|
|
{"name with slash", good(func(s *NetworkMountSpec) { s.Name = "a/b" })},
|
|
{"name with space", good(func(s *NetworkMountSpec) { s.Name = "a b" })},
|
|
{"bad protocol", good(func(s *NetworkMountSpec) { s.Protocol = "afp" })},
|
|
{"server metachar", good(func(s *NetworkMountSpec) { s.Server = "a;rm -rf" })},
|
|
{"empty server", good(func(s *NetworkMountSpec) { s.Server = "" })},
|
|
{"nfs relative export", good(func(s *NetworkMountSpec) { s.Export = "srv/media" })},
|
|
{"nfs export traversal", good(func(s *NetworkMountSpec) { s.Export = "/srv/../etc" })},
|
|
{"uid out of range", good(func(s *NetworkMountSpec) { s.MappedUID = 70000 })},
|
|
{"negative gid", good(func(s *NetworkMountSpec) { s.MappedGID = -1 })},
|
|
{"smb without creds", good(func(s *NetworkMountSpec) { s.Protocol = ProtocolSMB; s.Export = "media"; s.CredsRef = "" })},
|
|
{"smb bad share name", good(func(s *NetworkMountSpec) { s.Protocol = ProtocolSMB; s.Export = "a/b"; s.CredsRef = "/x/y.cred" })},
|
|
}
|
|
for _, c := range bad {
|
|
if err := ValidateNetworkMountSpec(c.spec); err == nil {
|
|
t.Errorf("%s: expected rejection, got nil", c.name)
|
|
}
|
|
}
|
|
|
|
// Good specs.
|
|
if err := ValidateNetworkMountSpec(base); err != nil {
|
|
t.Errorf("valid NFS spec rejected: %v", err)
|
|
}
|
|
smb := good(func(s *NetworkMountSpec) { s.Protocol = ProtocolSMB; s.Export = "media"; s.CredsRef = "/var/lib/felhom-agent/smb-creds/media.cred" })
|
|
if err := ValidateNetworkMountSpec(smb); err != nil {
|
|
t.Errorf("valid SMB spec rejected: %v", err)
|
|
}
|
|
}
|
|
|
|
// --- Role gate: bulk-userdata namespace only --------------------------------------------------------
|
|
|
|
func TestNetworkMountRole(t *testing.T) {
|
|
userdata := []string{"/mnt/felhom-drives/media", "/mnt/felhom-drives/photos", NetworkMountRoot}
|
|
for _, p := range userdata {
|
|
if NetworkMountRole(p) != RoleUserData {
|
|
t.Errorf("%s should be user-data", p)
|
|
}
|
|
}
|
|
system := []string{"/etc/passwd", "/srv/system/x", "/mnt/felhom-drivesX/y", "/var/lib/felhom-agent"}
|
|
for _, p := range system {
|
|
if NetworkMountRole(p) != RoleSystem {
|
|
t.Errorf("%s should be system (refused)", p)
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- Liveness parse + health ------------------------------------------------------------------------
|
|
|
|
func TestParseNetworkMountUnit_RoundTrip(t *testing.T) {
|
|
nfs := NetworkMountSpec{Name: "media", Protocol: ProtocolNFS, Server: "10.0.0.5", Export: "/srv/media", MappedUID: 1000, MappedGID: 1000}
|
|
proto, server, export, where, ok := parseNetworkMountUnit(renderNetworkMountUnit(nfs))
|
|
if !ok || proto != "nfs" || server != "10.0.0.5" || export != "/srv/media" || where != "/mnt/felhom-drives/media" {
|
|
t.Errorf("NFS parse = %q %q %q %q ok=%v", proto, server, export, where, ok)
|
|
}
|
|
smb := NetworkMountSpec{Name: "vids", Protocol: ProtocolSMB, Server: "nas", Export: "vids", MappedUID: 1000, MappedGID: 1000, CredsRef: "/x/y.cred"}
|
|
proto, server, export, where, ok = parseNetworkMountUnit(renderNetworkMountUnit(smb))
|
|
if !ok || proto != "smb" || server != "nas" || export != "vids" || where != "/mnt/felhom-drives/vids" {
|
|
t.Errorf("SMB parse = %q %q %q %q ok=%v", proto, server, export, where, ok)
|
|
}
|
|
// A non-network unit (a drive by-uuid .mount) must NOT parse as a network mount.
|
|
drive := renderMountUnit(MountSpec{Name: "usb", UUID: "0fc63daf-8483-4772-8e79-3d69d8477de4", Where: "/mnt/felhom-usb", FSType: "ext4"})
|
|
if _, _, _, _, ok := parseNetworkMountUnit(drive); ok {
|
|
t.Errorf("a drive by-uuid unit must not parse as a network mount")
|
|
}
|
|
}
|
|
|
|
func TestNetworkHealth(t *testing.T) {
|
|
cases := []struct {
|
|
reachable, mounted bool
|
|
want string
|
|
}{
|
|
{true, true, NetHealthOK},
|
|
{true, false, NetHealthIdle},
|
|
{false, true, NetHealthUnreachable},
|
|
{false, false, NetHealthUnreachable},
|
|
}
|
|
for _, c := range cases {
|
|
if got := networkHealth(c.reachable, c.mounted); got != c.want {
|
|
t.Errorf("networkHealth(%v,%v)=%q want %q", c.reachable, c.mounted, got, c.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestIsNetworkMounted(t *testing.T) {
|
|
for _, fs := range []string{"nfs", "nfs4", "cifs"} {
|
|
if !isNetworkMounted(fs) {
|
|
t.Errorf("%s should count as mounted", fs)
|
|
}
|
|
}
|
|
for _, fs := range []string{"autofs", "", "ext4", "tmpfs"} {
|
|
if isNetworkMounted(fs) {
|
|
t.Errorf("%s must NOT count as a real network mount (idle automount = autofs)", fs)
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- Scenario D: the drive machinery ignores a NAS mount (guard + companion red-proof) --------------
|
|
|
|
// TestNetMount_DriveMachineryGuard proves parseFelhomMountUnit (the host-reboot drive re-assert's
|
|
// classifier) REFUSES a network unit, so a NAS mount never enters the drive lifecycle. The companion
|
|
// red-proof: the SAME unit content WITHOUT the network marker but WITH a by-uuid What parses as a drive
|
|
// — i.e. it is the netUnitMarker guard (not luck) that keeps the NAS out of the drive machinery.
|
|
func TestNetMount_DriveMachineryGuard(t *testing.T) {
|
|
// A real network .mount unit: parseFelhomMountUnit must reject it.
|
|
netUnit := renderNetworkMountUnit(NetworkMountSpec{
|
|
Name: "media", Protocol: ProtocolNFS, Server: "10.0.0.5", Export: "/srv/media", MappedUID: 1000, MappedGID: 1000,
|
|
})
|
|
if _, ok := parseFelhomMountUnit(netUnit); ok {
|
|
t.Fatalf("a NAS network unit must NOT be classified as a drive by parseFelhomMountUnit:\n%s", netUnit)
|
|
}
|
|
|
|
// Contrived worst case: a unit carrying the network marker AND a by-uuid What (the shape that WOULD
|
|
// otherwise parse as a drive). The guard must still refuse it.
|
|
contrived := "# " + netUnitMarker + "\n[Unit]\nDescription=Felhom storage mount x\n[Mount]\n" +
|
|
"What=" + byUUIDDir + "/0fc63daf-8483-4772-8e79-3d69d8477de4\nWhere=/mnt/felhom-drives/x\nType=nfs4\n"
|
|
if _, ok := parseFelhomMountUnit(contrived); ok {
|
|
t.Fatalf("the netUnitMarker guard must refuse a by-uuid-shaped network unit")
|
|
}
|
|
|
|
// COMPANION RED-PROOF: identical content but with the drive marker instead of the network marker
|
|
// DOES parse as a drive — confirming the guard is the discriminator, not an accident of shape.
|
|
driveShaped := strings.Replace(contrived, "# "+netUnitMarker, "# "+felhomUnitMarker, 1)
|
|
if spec, ok := parseFelhomMountUnit(driveShaped); !ok || spec.UUID == "" {
|
|
t.Fatalf("control: a by-uuid unit with ONLY the drive marker should parse as a drive (ok=%v uuid=%q)", ok, spec.UUID)
|
|
}
|
|
}
|
|
|
|
// --- SudoHostOps command sequence (Linux only — the unit filename embeds an escaped '-' = backslash) -
|
|
|
|
func TestEnsureNetworkMount_Commands(t *testing.T) {
|
|
if runtime.GOOS == "windows" {
|
|
t.Skip("systemd-escaped unit filename contains a backslash; staging is exercised on the Linux build server")
|
|
}
|
|
ctx := context.Background()
|
|
stage, unitDir := t.TempDir(), t.TempDir()
|
|
rr := &recordingRunner{}
|
|
ops := NewSudoHostOps(SudoHostOpsConfig{
|
|
Runner: rr, Bins: Binaries{}.withDefaults(), UnitDir: unitDir, StageDir: stage, Logger: quietLogger(),
|
|
})
|
|
spec := NetworkMountSpec{Name: "media", Protocol: ProtocolNFS, Server: "10.0.0.5", Export: "/srv/media", MappedUID: 1000, MappedGID: 1000}
|
|
if err := ops.EnsureNetworkMount(ctx, spec); err != nil {
|
|
t.Fatalf("EnsureNetworkMount: %v", err)
|
|
}
|
|
// Expect: mkdir, install(.mount), install(.automount), daemon-reload, enable --now <automount>.
|
|
var sawMkdir, sawEnableAutomount, sawReload bool
|
|
installs := 0
|
|
for _, c := range rr.calls {
|
|
joined := strings.Join(c, " ")
|
|
switch {
|
|
case strings.Contains(joined, "mkdir") && strings.Contains(joined, "/mnt/felhom-drives/media"):
|
|
sawMkdir = true
|
|
case strings.Contains(joined, "install"):
|
|
installs++
|
|
case strings.Contains(joined, "daemon-reload"):
|
|
sawReload = true
|
|
case strings.Contains(joined, "enable") && strings.Contains(joined, "--now") && strings.Contains(joined, ".automount"):
|
|
sawEnableAutomount = true
|
|
}
|
|
}
|
|
if !sawMkdir || installs != 2 || !sawReload || !sawEnableAutomount {
|
|
t.Fatalf("unexpected command sequence (mkdir=%v installs=%d reload=%v enableAutomount=%v): %v",
|
|
sawMkdir, installs, sawReload, sawEnableAutomount, rr.calls)
|
|
}
|
|
// The .automount is enabled; the .mount is NOT (automount triggers it).
|
|
for _, c := range rr.calls {
|
|
joined := strings.Join(c, " ")
|
|
if strings.Contains(joined, "enable") && strings.Contains(joined, ".mount") && !strings.Contains(joined, ".automount") {
|
|
t.Errorf("the .mount unit must NOT be enabled (automount drives it): %v", c)
|
|
}
|
|
}
|
|
|
|
// Staged units carry the right bodies.
|
|
autoName := "mnt-felhom\\x2ddrives-media.automount"
|
|
body, err := os.ReadFile(filepath.Join(stage, autoName))
|
|
if err != nil {
|
|
t.Fatalf("staged automount not written: %v", err)
|
|
}
|
|
if !strings.Contains(string(body), "[Automount]") {
|
|
t.Errorf("staged automount missing [Automount]:\n%s", body)
|
|
}
|
|
}
|
|
|
|
func TestEnsureNetworkMount_RejectsBadSpec(t *testing.T) {
|
|
ops := NewSudoHostOps(SudoHostOpsConfig{Runner: &recordingRunner{}, Bins: Binaries{}.withDefaults(), UnitDir: t.TempDir(), StageDir: t.TempDir(), Logger: quietLogger()})
|
|
rr := ops.runner.(*recordingRunner)
|
|
if err := ops.EnsureNetworkMount(context.Background(), NetworkMountSpec{Name: "..", Protocol: ProtocolNFS, Server: "x", Export: "/y"}); err == nil {
|
|
t.Fatal("a bad spec must be refused")
|
|
}
|
|
if len(rr.calls) != 0 {
|
|
t.Fatalf("a refused spec must construct ZERO commands, got: %v", rr.calls)
|
|
}
|
|
}
|
|
|
|
func TestRemoveNetworkMount_Commands(t *testing.T) {
|
|
if runtime.GOOS == "windows" {
|
|
t.Skip("systemd-escaped unit filename contains a backslash; exercised on the Linux build server")
|
|
}
|
|
ctx := context.Background()
|
|
rr := &recordingRunner{}
|
|
ops := NewSudoHostOps(SudoHostOpsConfig{Runner: rr, Bins: Binaries{}.withDefaults(), UnitDir: t.TempDir(), StageDir: t.TempDir(), Logger: quietLogger()})
|
|
if err := ops.RemoveNetworkMount(ctx, "media"); err != nil {
|
|
t.Fatalf("RemoveNetworkMount: %v", err)
|
|
}
|
|
var stopAuto, disableAuto, rmCount, reload bool
|
|
rms := 0
|
|
for _, c := range rr.calls {
|
|
j := strings.Join(c, " ")
|
|
switch {
|
|
case strings.Contains(j, "stop") && strings.Contains(j, ".automount"):
|
|
stopAuto = true
|
|
case strings.Contains(j, "disable") && strings.Contains(j, ".automount"):
|
|
disableAuto = true
|
|
case strings.Contains(j, "rm"):
|
|
rms++
|
|
case strings.Contains(j, "daemon-reload"):
|
|
reload = true
|
|
}
|
|
}
|
|
rmCount = rms == 2
|
|
if !stopAuto || !disableAuto || !rmCount || !reload {
|
|
t.Fatalf("unexpected remove sequence (stopAuto=%v disableAuto=%v rm=%d reload=%v): %v", stopAuto, disableAuto, rms, reload, rr.calls)
|
|
}
|
|
}
|