//go:build smoke package smoketest import ( "errors" "fmt" "io" "os" "os/exec" "os/user" "path/filepath" "regexp" "strings" "testing" ) // Package-level state set up in TestMain and consumed by every test. // Lowercase, file-scope; tests in this package don't share globals // with other packages because of the build tag. var ( bangerBin string bangerdBin string vsockBin string coverDir string scratchRoot string runtimeDir string repoDir string smokeOwner string ) const ( serviceCoverDir = "/var/lib/banger" smokeMarker = "/etc/banger/.smoke-owned" ownerService = "bangerd.service" rootService = "bangerd-root.service" ) // smokeConfigTOML is the smoke-tuned daemon config dropped at // /etc/banger/config.toml after install (mirrors scripts/smoke.sh:404-415). // Small VMs by default — scenarios that need full-size resources override // --vcpu / --memory / --disk-size explicitly. const smokeConfigTOML = `# Smoke-tuned defaults — every VM starts small unless the scenario # overrides --vcpu / --memory / --disk-size explicitly. [vm_defaults] vcpu = 2 memory_mib = 1024 disk_size = "2G" system_overlay_size = "2G" ` func TestMain(m *testing.M) { // `go test -list ...` (used by `make smoke-list`) just enumerates // the test names. Skip the install preamble and let m.Run() print // the listing — env vars + KVM aren't needed for discovery. if isListMode() { os.Exit(m.Run()) } if err := requireEnv(); err != nil { fmt.Fprintf(os.Stderr, "[smoke] %v\n", err) // Skip cleanly when run outside `make smoke`. Returning 0 // prevents `go test` from being mistaken for a real failure // when a contributor accidentally runs the smoke package // directly without the harness env. os.Exit(0) } // Export GOCOVERDIR so every banger / bangerd subprocess this // test binary spawns lands its covdata under BANGER_SMOKE_COVER_DIR. // The test binary itself is not instrumented; only the smoke // binaries are (they were built with `go build -cover`). if err := os.Setenv("GOCOVERDIR", coverDir); err != nil { fmt.Fprintf(os.Stderr, "[smoke] setenv GOCOVERDIR: %v\n", err) os.Exit(1) } if err := installPreamble(); err != nil { fmt.Fprintf(os.Stderr, "[smoke] install preamble failed: %v\n", err) os.Exit(1) } if err := setupRepoFixture(); err != nil { fmt.Fprintf(os.Stderr, "[smoke] fixture setup failed: %v\n", err) teardown() os.Exit(1) } code := m.Run() teardown() os.Exit(code) } // isListMode returns true when the test binary was invoked with the // `-test.list` flag, which `go test -list ...` translates into. In that // mode the harness only enumerates names and never spawns a test, so // requireEnv / installPreamble would needlessly block discovery on a // fresh checkout (no KVM, no sudo). func isListMode() bool { for _, a := range os.Args[1:] { if a == "-test.list" || strings.HasPrefix(a, "-test.list=") { return true } } return false } // requireEnv reads and validates the three BANGER_SMOKE_* env vars and // confirms the binaries they point at exist and are executable. Returns // a single descriptive error so a contributor running by hand sees // exactly which variable is missing. func requireEnv() error { binDir := os.Getenv("BANGER_SMOKE_BIN_DIR") if binDir == "" { return errors.New("BANGER_SMOKE_BIN_DIR not set; run via `make smoke`") } cov := os.Getenv("BANGER_SMOKE_COVER_DIR") if cov == "" { return errors.New("BANGER_SMOKE_COVER_DIR not set; run via `make smoke`") } xdg := os.Getenv("BANGER_SMOKE_XDG_DIR") if xdg == "" { return errors.New("BANGER_SMOKE_XDG_DIR not set; run via `make smoke`") } bangerBin = filepath.Join(binDir, "banger") bangerdBin = filepath.Join(binDir, "bangerd") vsockBin = filepath.Join(binDir, "banger-vsock-agent") coverDir = cov scratchRoot = xdg for _, bin := range []string{bangerBin, bangerdBin, vsockBin} { st, err := os.Stat(bin) if err != nil { return fmt.Errorf("smoke binary missing: %s: %w", bin, err) } if st.Mode()&0o111 == 0 { return fmt.Errorf("smoke binary not executable: %s", bin) } } if err := os.MkdirAll(coverDir, 0o755); err != nil { return fmt.Errorf("mkdir cover dir: %w", err) } // Reset the scratch root each run — leftover state from a prior // crashed run would otherwise leak into this one's fixtures. if err := os.RemoveAll(scratchRoot); err != nil { return fmt.Errorf("clean scratch root: %w", err) } if err := os.MkdirAll(scratchRoot, 0o755); err != nil { return fmt.Errorf("mkdir scratch root: %w", err) } rt, err := os.MkdirTemp(scratchRoot, "runtime-") if err != nil { return fmt.Errorf("mktemp runtime: %w", err) } runtimeDir = rt u, err := user.Current() if err != nil { return fmt.Errorf("user.Current: %w", err) } smokeOwner = u.Username return nil } // installPreamble mirrors scripts/smoke.sh's install_preamble. Refuses to // overwrite a non-smoke install, otherwise installs the instrumented // services, runs doctor, drops the smoke-tuned config, and restarts. func installPreamble() error { if installExists() { if markerExists() { fmt.Fprintln(os.Stderr, "[smoke] found stale smoke-owned install; purging it first") _ = exec.Command("sudo", "env", "GOCOVERDIR="+coverDir, bangerBin, "system", "uninstall", "--purge").Run() } else { return errors.New("banger is already installed on this host; supported-path smoke refuses to overwrite a non-smoke install") } } // Wipe the user-side known_hosts. Fresh VMs reuse guest IPs with // new host keys every run; a stale entry trips StrictHostKeyChecking. // scripts/smoke.sh:374-380 explains why this is host-side, not // daemon-side state. if home, err := os.UserHomeDir(); err == nil { _ = os.Remove(filepath.Join(home, ".local", "state", "banger", "ssh", "known_hosts")) } fmt.Fprintln(os.Stderr, "[smoke] installing smoke-owned services") install := exec.Command("sudo", "env", "GOCOVERDIR="+coverDir, "BANGER_SYSTEM_GOCOVERDIR="+serviceCoverDir, "BANGER_ROOT_HELPER_GOCOVERDIR="+serviceCoverDir, bangerBin, "system", "install", "--owner", smokeOwner, ) if out, err := install.CombinedOutput(); err != nil { return fmt.Errorf("system install: %w\n%s", err, out) } if out, err := exec.Command("sudo", "touch", smokeMarker).CombinedOutput(); err != nil { return fmt.Errorf("touch smoke marker: %w\n%s", err, out) } if err := assertServicesActive("after install"); err != nil { return err } fmt.Fprintln(os.Stderr, "[smoke] doctor: checking host readiness") if out, err := exec.Command(bangerBin, "doctor").CombinedOutput(); err != nil { return fmt.Errorf("doctor reported failures; fix the host before running smoke:\n%s", out) } fmt.Fprintln(os.Stderr, "[smoke] writing smoke-tuned daemon config") if err := writeSmokeConfig(); err != nil { return err } fmt.Fprintln(os.Stderr, "[smoke] system restart: services should come back cleanly") restart := exec.Command("sudo", "env", "GOCOVERDIR="+coverDir, bangerBin, "system", "restart") if out, err := restart.CombinedOutput(); err != nil { return fmt.Errorf("system restart: %w\n%s", err, out) } return assertServicesActive("after restart") } // installExists checks /etc/banger/install.toml under sudo (the dir is // not always world-readable). func installExists() bool { return exec.Command("sudo", "test", "-f", "/etc/banger/install.toml").Run() == nil } func markerExists() bool { return exec.Command("sudo", "test", "-f", smokeMarker).Run() == nil } var ( statusOwnerRE = regexp.MustCompile(`(?m)^active\s+active\b`) statusHelperRE = regexp.MustCompile(`(?m)^helper_active\s+active\b`) ) func assertServicesActive(label string) error { out, err := exec.Command(bangerBin, "system", "status").CombinedOutput() if err != nil { return fmt.Errorf("system status %s: %w\n%s", label, err, out) } if !statusOwnerRE.Match(out) { return fmt.Errorf("owner daemon not active %s:\n%s", label, out) } if !statusHelperRE.Match(out) { return fmt.Errorf("root helper not active %s:\n%s", label, out) } return nil } // writeSmokeConfig drops smokeConfigTOML at /etc/banger/config.toml via // `sudo tee`. tee is the path of least resistance for "write to a root- // owned file from a non-root process". func writeSmokeConfig() error { cmd := exec.Command("sudo", "tee", "/etc/banger/config.toml") cmd.Stdin = strings.NewReader(smokeConfigTOML) cmd.Stdout = io.Discard cmd.Stderr = os.Stderr if err := cmd.Run(); err != nil { return fmt.Errorf("write smoke config: %w", err) } return nil } // teardown is the equivalent of scripts/smoke.sh's `cleanup` trap. It // best-efforts every step — partial failures during teardown should // not mask the test outcome. func teardown() { shutdownReleaseServer() stopServicesForCoverage() collectServiceCoverage() _ = exec.Command("sudo", "env", "GOCOVERDIR="+coverDir, bangerBin, "system", "uninstall", "--purge").Run() _ = os.RemoveAll(scratchRoot) } func stopServicesForCoverage() { _ = exec.Command("sudo", "systemctl", "stop", ownerService, rootService).Run() } // collectServiceCoverage copies covmeta.* / covcounters.* out of // /var/lib/banger into BANGER_SMOKE_COVER_DIR, chowning to the test // user so subsequent `go tool covdata` invocations can read them. // Mirrors the inline `sudo bash -lc '...'` in scripts/smoke.sh:307-325. func collectServiceCoverage() { uid := fmt.Sprint(os.Getuid()) gid := fmt.Sprint(os.Getgid()) const script = ` shopt -s nullglob for file in "$1"/covmeta.* "$1"/covcounters.*; do base="${file##*/}" cp "$file" "$2/$base" chown "$3:$4" "$2/$base" chmod 0644 "$2/$base" done ` _ = exec.Command("sudo", "bash", "-c", script, "bash", serviceCoverDir, coverDir, uid, gid).Run() }