package daemon import ( "bytes" "io" "log/slog" "path/filepath" "testing" "banger/internal/model" "banger/internal/paths" "banger/internal/store" "banger/internal/system" ) // testDaemonOpts collects everything newTestDaemon knows how to // override. Nothing is exported: the zero value is "sensible defaults", // tests pick overrides by option function. type testDaemonOpts struct { runner system.CommandRunner config *model.DaemonConfig store *store.Store logger *slog.Logger layout *paths.Layout vmCaps []vmCapability vmCapsSet bool vsockHostDevice string } // testDaemonOption applies a single override to testDaemonOpts. Pass // any combination to newTestDaemon; later options win on conflict. type testDaemonOption func(*testDaemonOpts) // withRunner sets the system.CommandRunner used by HostNetwork, // ImageService, WorkspaceService, and VMService. Most tests want // permissiveRunner or scriptedRunner; the default is a permissive // runner that returns empty output with no error. func withRunner(r system.CommandRunner) testDaemonOption { return func(o *testDaemonOpts) { o.runner = r } } // withConfig replaces the DaemonConfig. Useful for exercising config- // dependent code paths (bridge name, firecracker binary path, // default image name, etc.) without going through config.Load. func withConfig(cfg model.DaemonConfig) testDaemonOption { return func(o *testDaemonOpts) { o.config = &cfg } } // withStore reuses an externally-opened store instead of opening a // fresh tempdir DB. Useful when the test needs to pre-seed rows // before the daemon is wired. func withStore(st *store.Store) testDaemonOption { return func(o *testDaemonOpts) { o.store = st } } // withLogger routes daemon logs somewhere specific. Default is // io.Discard so a passing test run stays quiet; failing tests that // want structured log content can pass their own buffer-backed slog. func withLogger(l *slog.Logger) testDaemonOption { return func(o *testDaemonOpts) { o.logger = l } } // withLayout overrides the paths.Layout. Defaults build all dirs // under t.TempDir() so tests don't interfere with each other and // don't write into the user's real ~/.local/state/banger. func withLayout(layout paths.Layout) testDaemonOption { return func(o *testDaemonOpts) { o.layout = &layout } } // withVMCaps installs a specific capability list on the daemon. // Default is an empty slice, which means wireServices skips the // built-in workDisk/dns/nat capabilities — most harness tests don't // want those firing real side-effects. Pass capability fakes to // exercise dispatch paths. func withVMCaps(caps ...vmCapability) testDaemonOption { return func(o *testDaemonOpts) { o.vmCaps = caps o.vmCapsSet = true } } // withVsockHostDevice overrides the /dev/vhost-vsock path VMService // checks during preflight. Useful for tests that need RequireFile to // succeed against a tempfile without root access to the real device. func withVsockHostDevice(path string) testDaemonOption { return func(o *testDaemonOpts) { o.vsockHostDevice = path } } // newTestDaemon builds a wired *Daemon backed by tempdir state, // ready for tests that drive service methods or dispatch logic. // All infrastructure comes from either t.TempDir() or the // provided overrides; nothing touches the invoking user's real // state. // // What the harness gives you by default: // // - paths.Layout rooted at t.TempDir() (distinct StateDir, // ConfigDir, CacheDir, VMsDir, ImagesDir, KernelsDir, SSHDir, // KnownHostsPath) // - fresh store.Store opened against a tempdir state.db with all // migrations run, auto-closed on t.Cleanup // - permissiveRunner returning empty output + no error for every // Run/RunSudo call (override with scriptedRunner or any other // system.CommandRunner when you need assertion-style scripting) // - io.Discard logger (quiet tests) // - empty vmCaps (so default capability side-effects don't fire) // - defaultVsockHostDevice on VMService (tests that need this to // resolve via RequireFile should pass withVsockHostDevice to a // tempfile) // // Returns the wired *Daemon. Every service pointer is non-nil; // d.store is non-nil; d.vmCaps is exactly what the test asked for. func newTestDaemon(t *testing.T, opts ...testDaemonOption) *Daemon { t.Helper() applied := testDaemonOpts{} for _, opt := range opts { opt(&applied) } layout := applied.layout if layout == nil { dir := t.TempDir() layout = &paths.Layout{ StateDir: filepath.Join(dir, "state"), ConfigDir: filepath.Join(dir, "config"), CacheDir: filepath.Join(dir, "cache"), VMsDir: filepath.Join(dir, "state", "vms"), ImagesDir: filepath.Join(dir, "state", "images"), KernelsDir: filepath.Join(dir, "state", "kernels"), SSHDir: filepath.Join(dir, "state", "ssh"), KnownHostsPath: filepath.Join(dir, "state", "ssh", "known_hosts"), DBPath: filepath.Join(dir, "state", "state.db"), SocketPath: filepath.Join(dir, "state", "banger.sock"), RuntimeDir: filepath.Join(dir, "runtime"), } } st := applied.store if st == nil { st = openDaemonStore(t) } runner := applied.runner if runner == nil { runner = &permissiveRunner{} } logger := applied.logger if logger == nil { logger = slog.New(slog.NewTextHandler(io.Discard, nil)) } cfg := model.DaemonConfig{ StatsPollInterval: model.DefaultStatsPollInterval, BridgeName: model.DefaultBridgeName, BridgeIP: model.DefaultBridgeIP, CIDR: model.DefaultCIDR, DefaultDNS: model.DefaultDNS, } if applied.config != nil { cfg = *applied.config } d := &Daemon{ layout: *layout, config: cfg, store: st, runner: runner, logger: logger, vmCaps: applied.vmCaps, } wireServices(d) // wireServices fills in the default workDisk/dns/nat capability // list when vmCaps is empty at call time — that's the production // path. Harness callers who didn't opt in to capabilities via // withVMCaps explicitly want them OFF so their test doesn't // accidentally fire real NAT rules or a DNS publish. Reset to // nil here; withVMCaps sets vmCapsSet to skip this reset. if !applied.vmCapsSet { d.vmCaps = nil } if applied.vsockHostDevice != "" { d.vm.vsockHostDevice = applied.vsockHostDevice } return d } // TestNewTestDaemonDefaults pins the contract new callers rely on: // a zero-option call returns a fully-wired daemon with every service // pointer populated, a writable tempdir-backed store, and an empty // capability list (so nothing fires real side-effects). If any of // those invariants drift, every test that switches to newTestDaemon // will silently start exercising different behaviour. func TestNewTestDaemonDefaults(t *testing.T) { d := newTestDaemon(t) if d.net == nil || d.img == nil || d.ws == nil || d.vm == nil { t.Fatalf("wireServices left a service nil: net=%v img=%v ws=%v vm=%v", d.net != nil, d.img != nil, d.ws != nil, d.vm != nil) } if d.store == nil { t.Fatal("store is nil; harness must provide a working store") } if len(d.vmCaps) != 0 { t.Fatalf("vmCaps = %d, want 0 (harness default must not fire real capabilities)", len(d.vmCaps)) } if d.vm.vsockHostDevice != defaultVsockHostDevice { t.Fatalf("vsockHostDevice = %q, want default %q", d.vm.vsockHostDevice, defaultVsockHostDevice) } } // TestNewTestDaemonOptionsOverride verifies the option functions // actually land on the resulting Daemon. Guard against a silent // rename breaking option plumbing. func TestNewTestDaemonOptionsOverride(t *testing.T) { var buf bytes.Buffer customLogger := slog.New(slog.NewTextHandler(&buf, nil)) customRunner := &countingRunner{} customVsock := filepath.Join(t.TempDir(), "vhost-vsock") customCap := testCapability{name: "marker"} d := newTestDaemon(t, withLogger(customLogger), withRunner(customRunner), withVsockHostDevice(customVsock), withVMCaps(customCap), ) if d.logger != customLogger { t.Error("withLogger: logger not overridden") } if d.runner != customRunner { t.Error("withRunner: runner not overridden") } if d.vm.vsockHostDevice != customVsock { t.Errorf("withVsockHostDevice: got %q, want %q", d.vm.vsockHostDevice, customVsock) } if len(d.vmCaps) != 1 || d.vmCaps[0].Name() != "marker" { t.Errorf("withVMCaps: vmCaps = %v, want one 'marker' cap", d.vmCaps) } }