bc4eda926b
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017CDMFpFx84pfviCTVuGGhf
171 lines
7.6 KiB
Go
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))
|
|
}
|