banger/internal/smoketest/smoke_main_test.go
2026-05-01 19:34:44 -03:00

305 lines
9.5 KiB
Go

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