Files

171 lines
7.6 KiB
Go

package storage
import (
"context"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
)
// F12 (CAMPAIGN-3, CRITICAL): NEITHER rendered network-storage unit may carry a network-online.target
// ordering — that is exactly what closed the boot ordering cycle. The .mount keeps `_netdev` (the
// correct, sufficient network ordering for the real mount). Companion red-proof: re-adding either
// `After=`/`Wants=network-online.target` line to a template makes these assertions fail.
func TestRenderNetworkUnits_NoNetworkOnlineOrdering(t *testing.T) {
for _, spec := range []NetworkMountSpec{
{Name: "media", Protocol: ProtocolNFS, Server: "10.0.0.5", Export: "/srv/media", MappedUID: 1000, MappedGID: 1000},
{Name: "docs", Protocol: ProtocolSMB, Server: "10.0.0.6", Export: "share", CredsRef: "/var/lib/felhom-agent/smb-creds/docs.cred", MappedUID: 1000, MappedGID: 1000},
} {
mountUnit := renderNetworkMountUnit(spec)
autoUnit := renderNetworkAutomountUnit(spec)
if strings.Contains(mountUnit, "network-online.target") {
t.Errorf("[%s] .mount unit still orders network-online.target (F12 cycle):\n%s", spec.Name, mountUnit)
}
if strings.Contains(autoUnit, "network-online.target") {
t.Errorf("[%s] .automount unit still orders network-online.target (F12 cycle):\n%s", spec.Name, autoUnit)
}
if !strings.Contains(mountUnit, "_netdev") {
t.Errorf("[%s] .mount unit lost _netdev — the ONLY correct network ordering for the real mount:\n%s", spec.Name, mountUnit)
}
}
}
// specFromNetworkUnits must reconstruct a spec that re-renders EXACTLY the installed unit pair — the
// idempotency contract of the drift reconcile. A round-trip that isn't byte-exact would make
// MigrateNetworkUnits rewrite on every pass (never converging).
func TestSpecFromNetworkUnits_RoundTrips(t *testing.T) {
for _, spec := range []NetworkMountSpec{
{Name: "media", Protocol: ProtocolNFS, Server: "10.0.0.5", Export: "/srv/media", MappedUID: 1000, MappedGID: 1000, IdleTimeoutSec: 60},
{Name: "docs", Protocol: ProtocolSMB, Server: "10.0.0.6", Export: "share", CredsRef: "/var/lib/felhom-agent/smb-creds/docs.cred", MappedUID: 1000, MappedGID: 1000, IdleTimeoutSec: 120},
} {
mountUnit := renderNetworkMountUnit(spec)
autoUnit := renderNetworkAutomountUnit(spec)
got, ok := specFromNetworkUnits(mountUnit, autoUnit)
if !ok {
t.Fatalf("[%s] specFromNetworkUnits failed to parse its own render", spec.Name)
}
if renderNetworkMountUnit(got) != mountUnit {
t.Errorf("[%s] .mount round-trip mismatch:\nWANT:\n%s\nGOT:\n%s", spec.Name, mountUnit, renderNetworkMountUnit(got))
}
if renderNetworkAutomountUnit(got) != autoUnit {
t.Errorf("[%s] .automount round-trip mismatch:\nWANT:\n%s\nGOT:\n%s", spec.Name, autoUnit, renderNetworkAutomountUnit(got))
}
}
}
// legacyNetworkUnitPair renders the pre-0.85 units WITH the F12 network-online ordering, the exact
// drift the migration must repair.
func legacyNetworkUnitPair(spec NetworkMountSpec) (mount, auto string) {
mount = renderNetworkMountUnit(spec)
mount = strings.Replace(mount, "\n[Mount]\n", "\nAfter=network-online.target\nWants=network-online.target\n[Mount]\n", 1)
auto = renderNetworkAutomountUnit(spec)
auto = strings.Replace(auto, "\n[Automount]\n", "\nAfter=network-online.target\nWants=network-online.target\n[Automount]\n", 1)
return mount, auto
}
// MigrateNetworkUnits rewrites a drifted (legacy, network-online-carrying) unit pair exactly ONCE,
// batches a single daemon-reload, and is idempotent (a second pass rewrites nothing). A clean unit is
// left untouched.
func TestMigrateNetworkUnits_RewritesDriftedOnceIdempotent(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()
stageDir := t.TempDir()
spec := NetworkMountSpec{Name: "media", Protocol: ProtocolNFS, Server: "10.0.0.5", Export: "/srv/media", MappedUID: 1000, MappedGID: 1000, IdleTimeoutSec: 60}
mountName, err := UnitNameForMount(spec.Where())
if err != nil {
t.Fatalf("unit name: %v", err)
}
autoName := strings.TrimSuffix(mountName, ".mount") + ".automount"
legacyMount, legacyAuto := legacyNetworkUnitPair(spec)
writeFile(t, filepath.Join(unitDir, mountName), legacyMount)
writeFile(t, filepath.Join(unitDir, autoName), legacyAuto)
rr := &recordingRunner{}
// installUnit stages then `install`s (recorded, not executed); rewrite the unit dir copy ourselves
// so the on-disk content reflects the migration for the idempotency re-read.
ops := NewSudoHostOps(SudoHostOpsConfig{Runner: rr, Bins: Binaries{}.withDefaults(), UnitDir: unitDir, StageDir: stageDir, Host: &fakeHostReader{}, Logger: quietLogger()})
migrated := ops.MigrateNetworkUnits(context.Background())
if migrated != 1 {
t.Fatalf("first pass must migrate exactly 1 unit, got %d", migrated)
}
// The recorded calls must include exactly one daemon-reload (batched), plus the two installs.
reloads, installs := 0, 0
for _, c := range rr.calls {
joined := strings.Join(c, " ")
if strings.Contains(joined, "daemon-reload") {
reloads++
}
if strings.Contains(joined, "install") {
installs++
}
}
if reloads != 1 {
t.Errorf("migration must batch exactly ONE daemon-reload, got %d (calls: %v)", reloads, rr.calls)
}
if installs != 2 {
t.Errorf("migration must rewrite both units (2 installs), got %d", installs)
}
// The staged content the installer would have placed must be the CLEAN template. Simulate the
// install landing (installUnit staged to stageDir/<unit>), then re-read for idempotency.
applyStaged(t, stageDir, unitDir, mountName)
applyStaged(t, stageDir, unitDir, autoName)
if got := readFile(t, filepath.Join(unitDir, mountName)); strings.Contains(got, "network-online.target") {
t.Errorf("migrated .mount still carries network-online.target:\n%s", got)
}
rr.calls = nil
if migrated := ops.MigrateNetworkUnits(context.Background()); migrated != 0 {
t.Fatalf("second pass over already-current units must migrate 0, got %d", migrated)
}
if len(rr.calls) != 0 {
t.Errorf("idempotent second pass must construct ZERO commands, got: %v", rr.calls)
}
}
// A non-marker unit file in the unit dir is never touched by the migration.
func TestMigrateNetworkUnits_IgnoresForeignUnits(t *testing.T) {
unitDir := t.TempDir()
writeFile(t, filepath.Join(unitDir, "some-service.mount"), "[Unit]\nDescription=not ours\n[Mount]\nWhere=/x\n")
rr := &recordingRunner{}
ops := NewSudoHostOps(SudoHostOpsConfig{Runner: rr, Bins: Binaries{}.withDefaults(), UnitDir: unitDir, StageDir: t.TempDir(), Host: &fakeHostReader{}, Logger: quietLogger()})
if migrated := ops.MigrateNetworkUnits(context.Background()); migrated != 0 {
t.Fatalf("a foreign unit must not be migrated, got %d", migrated)
}
if len(rr.calls) != 0 {
t.Errorf("a foreign unit must construct zero commands, got: %v", rr.calls)
}
}
func writeFile(t *testing.T, path, content string) {
t.Helper()
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatalf("write %s: %v", path, err)
}
}
func readFile(t *testing.T, path string) string {
t.Helper()
b, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read %s: %v", path, err)
}
return string(b)
}
// applyStaged mirrors what the (recorded, not executed) `install` would do: copy the agent-staged unit
// into the unit dir, so the idempotency re-read sees the migrated content.
func applyStaged(t *testing.T, stageDir, unitDir, unitName string) {
t.Helper()
src := filepath.Join(stageDir, unitName)
b, err := os.ReadFile(src)
if err != nil {
return // installUnit stages before the recorded install; if absent, nothing to mirror
}
writeFile(t, filepath.Join(unitDir, unitName), string(b))
}