201 lines
7 KiB
Go
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]
|
|
}
|