//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 -- 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] }