305 lines
9.5 KiB
Go
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()
|
|
}
|