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/), 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)) }