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

201 lines
7 KiB
Go

//go:build smoke
package smoketest
import (
"bytes"
"os"
"os/exec"
"strings"
"testing"
"time"
)
// result captures the output and exit status of a banger invocation.
// stdout / stderr are kept separate so assertions can target one or the
// other (matches the bash suite's `out=$(cmd)` vs `2>&1` patterns).
type result struct {
stdout string
stderr string
rc int
}
// runCmd executes the given exec.Cmd, capturing stdout and stderr into
// the returned result. Non-zero exits are returned as a non-zero rc, not
// as an error — scenarios decide for themselves whether non-zero is a
// failure or the assertion under test.
func runCmd(t *testing.T, cmd *exec.Cmd) result {
t.Helper()
var outBuf, errBuf bytes.Buffer
cmd.Stdout = &outBuf
cmd.Stderr = &errBuf
err := cmd.Run()
res := result{stdout: outBuf.String(), stderr: errBuf.String()}
if err != nil {
if exitErr, ok := err.(*exec.ExitError); ok {
res.rc = exitErr.ExitCode()
} else {
t.Fatalf("exec %s: %v\nstderr: %s", strings.Join(cmd.Args, " "), err, res.stderr)
}
}
return res
}
// banger runs the instrumented `banger` binary with the given arguments
// and returns the captured result. GOCOVERDIR is inherited from the
// process environment (TestMain exports it), so child covdata lands
// under BANGER_SMOKE_COVER_DIR automatically.
func banger(t *testing.T, args ...string) result {
t.Helper()
return runCmd(t, exec.Command(bangerBin, args...))
}
// mustBanger runs `banger` and Fatals if it exits non-zero. Returns the
// captured stdout for downstream `wantContains`. Most happy-path
// scenarios use this; scenarios that assert on non-zero exits use
// banger() directly.
func mustBanger(t *testing.T, args ...string) string {
t.Helper()
res := banger(t, args...)
if res.rc != 0 {
t.Fatalf("banger %s: exit %d\nstdout: %s\nstderr: %s",
strings.Join(args, " "), res.rc, res.stdout, res.stderr)
}
return res.stdout
}
// sudoBanger runs `banger` under `sudo env GOCOVERDIR=...`. Sudo strips
// the env by default; explicit re-export keeps coverage flowing for
// scenarios that exercise the privileged path (system install / restart
// / update / daemon stop).
func sudoBanger(t *testing.T, args ...string) result {
t.Helper()
full := append([]string{"env", "GOCOVERDIR=" + coverDir, bangerBin}, args...)
return runCmd(t, exec.Command("sudo", full...))
}
// wantContains asserts that haystack contains needle. label is a short
// human-readable identifier for the failure message.
func wantContains(t *testing.T, haystack, needle, label string) {
t.Helper()
if !strings.Contains(haystack, needle) {
t.Fatalf("%s missing %q\ngot: %s", label, needle, haystack)
}
}
// wantNotContains is the negative-assertion counterpart. Used by
// scenarios that verify a warning has been suppressed (e.g. the post-
// auto-prepare clean-state check in vm_exec) or that an export patch
// did NOT capture a guest-side commit.
func wantNotContains(t *testing.T, haystack, needle, label string) {
t.Helper()
if strings.Contains(haystack, needle) {
t.Fatalf("%s unexpectedly contains %q\ngot: %s", label, needle, haystack)
}
}
// wantExit asserts the captured result exited with want. Used for
// scenarios that test exit-code propagation or refusal paths.
func wantExit(t *testing.T, got result, want int, label string) {
t.Helper()
if got.rc != want {
t.Fatalf("%s: exit %d, want %d\nstdout: %s\nstderr: %s",
label, got.rc, want, got.stdout, got.stderr)
}
}
// vmDelete removes a VM, ignoring failure. Used in t.Cleanup hooks
// where the VM may already be gone (deleted by the scenario itself).
func vmDelete(name string) {
cmd := exec.Command(bangerBin, "vm", "delete", name)
_ = cmd.Run()
}
// vmCreate creates a VM with the given name and registers a cleanup
// hook to delete it. extraArgs is forwarded after `vm create --name X`
// so callers can pass --vcpu N / --nat / --no-start / etc. Fatals if
// creation fails — every scenario that uses vmCreate needs the VM up.
func vmCreate(t *testing.T, name string, extraArgs ...string) {
t.Helper()
args := append([]string{"vm", "create", "--name", name}, extraArgs...)
mustBanger(t, args...)
t.Cleanup(func() { vmDelete(name) })
}
// bangerHome runs `banger` with HOME overridden to the given directory.
// Used by ssh-config scenarios that mutate ~/.ssh/config under a fake
// home so the test doesn't touch the contributor's real config.
func bangerHome(t *testing.T, home string, args ...string) result {
t.Helper()
cmd := exec.Command(bangerBin, args...)
cmd.Env = append(os.Environ(), "HOME="+home)
return runCmd(t, cmd)
}
// mustBangerHome is bangerHome + Fatal-on-non-zero. Returns stdout.
func mustBangerHome(t *testing.T, home string, args ...string) string {
t.Helper()
res := bangerHome(t, home, args...)
if res.rc != 0 {
t.Fatalf("banger %s (HOME=%s): exit %d\nstdout: %s\nstderr: %s",
strings.Join(args, " "), home, res.rc, res.stdout, res.stderr)
}
return res.stdout
}
// waitForSSH polls `banger vm ssh <name> -- true` until SSH answers,
// up to 120 seconds. The original bash suite used 60s and occasionally
// flaked under load (post-update VM, large parallel pool); 120s gives
// enough headroom for the post-update / post-rollback paths where the
// daemon has just restarted, without making genuine breakage slow to
// surface.
func waitForSSH(t *testing.T, name string) {
t.Helper()
const timeout = 120 * time.Second
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
cmd := exec.Command(bangerBin, "vm", "ssh", name, "--", "true")
if err := cmd.Run(); err == nil {
return
}
time.Sleep(1 * time.Second)
}
t.Fatalf("vm %q ssh did not come up within %s", name, timeout)
}
// requirePasswordlessSudo skips the test if `sudo -n true` cannot run.
// Mirrors the bash `if ! sudo -n true 2>/dev/null; then return 0; fi`
// pattern used by scenarios that exercise privileged paths.
func requirePasswordlessSudo(t *testing.T) {
t.Helper()
if err := exec.Command("sudo", "-n", "true").Run(); err != nil {
t.Skip("passwordless sudo unavailable")
}
}
// requireSudoIptables skips the test if iptables can't be queried under
// `sudo -n`. Used by the NAT scenario whose assertions read POSTROUTING.
func requireSudoIptables(t *testing.T) {
t.Helper()
if err := exec.Command("sudo", "-n", "iptables", "-t", "nat", "-S", "POSTROUTING").Run(); err != nil {
t.Skip("passwordless sudo iptables unavailable")
}
}
// installedVersion reads `/usr/local/bin/banger --version` and returns
// the version token. This is the *installed* binary that `banger update`
// swaps out — the smoke CLI under $BANGER_SMOKE_BIN_DIR is separate
// (and unaffected by update). Mirrors the bash `installed_version`
// helper at scripts/smoke.sh:1156-1162.
func installedVersion(t *testing.T) string {
t.Helper()
out, err := exec.Command("/usr/local/bin/banger", "--version").Output()
if err != nil {
t.Fatalf("read installed version: %v", err)
}
parts := strings.Fields(string(out))
if len(parts) < 2 {
t.Fatalf("unparseable installed --version output: %q", string(out))
}
return parts[1]
}