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

311 lines
10 KiB
Go

//go:build smoke
package smoketest
import (
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
"sync"
"testing"
)
// testBareRun is the Go port of scenario_bare_run from
// scripts/smoke.sh. Bare ephemeral VM run: create + start + ssh +
// echo + --rm.
func testBareRun(t *testing.T) {
t.Parallel()
out := mustBanger(t, "vm", "run", "--rm", "--", "echo", "smoke-bare-ok")
wantContains(t, out, "smoke-bare-ok", "bare vm run stdout")
}
// testExitCode is the Go port of scenario_exit_code. Asserts that
// `vm run -- sh -c 'exit 42'` propagates rc=42 verbatim.
func testExitCode(t *testing.T) {
t.Parallel()
res := banger(t, "vm", "run", "--rm", "--", "sh", "-c", "exit 42")
wantExit(t, res, 42, "exit-code propagation")
}
// testConcurrentRun fires two `vm run --rm` invocations simultaneously
// and asserts both succeed and emit their respective markers. Bash uses
// `& ; wait`; Go uses two goroutines that capture the result and a
// WaitGroup. Note: t.Fatalf cannot be called from a goroutine, so the
// children write to result slots and assertions run on the main goroutine.
func testConcurrentRun(t *testing.T) {
t.Parallel()
var wg sync.WaitGroup
var resA, resB result
run := func(dst *result, marker string) {
defer wg.Done()
cmd := exec.Command(bangerBin, "vm", "run", "--rm", "--", "echo", marker)
var out, errBuf strings.Builder
cmd.Stdout = &out
cmd.Stderr = &errBuf
err := cmd.Run()
dst.stdout = out.String()
dst.stderr = errBuf.String()
if err != nil {
if exitErr, ok := err.(*exec.ExitError); ok {
dst.rc = exitErr.ExitCode()
} else {
dst.rc = -1
dst.stderr += "\nexec error: " + err.Error()
}
}
}
wg.Add(2)
go run(&resA, "smoke-concurrent-a")
go run(&resB, "smoke-concurrent-b")
wg.Wait()
wantExit(t, resA, 0, "concurrent A exit")
wantExit(t, resB, 0, "concurrent B exit")
wantContains(t, resA.stdout, "smoke-concurrent-a", "concurrent A stdout")
wantContains(t, resB.stdout, "smoke-concurrent-b", "concurrent B stdout")
}
// testDetachRun ports scenario_detach_run. Verifies -d combined with
// --rm or with a guest command is rejected before VM creation, then
// that -d --name leaves the VM running and ssh-able.
func testDetachRun(t *testing.T) {
t.Parallel()
res := banger(t, "vm", "run", "-d", "--rm")
if res.rc == 0 {
t.Fatalf("detach: -d --rm should be rejected before VM creation")
}
res = banger(t, "vm", "run", "-d", "--", "echo", "hi")
if res.rc == 0 {
t.Fatalf("detach: -d -- <cmd> should be rejected before VM creation")
}
const name = "smoke-detach"
mustBanger(t, "vm", "run", "-d", "--name", name)
t.Cleanup(func() { vmDelete(name) })
show := mustBanger(t, "vm", "show", name)
wantContains(t, show, `"state": "running"`, "detach: post-detach state")
out := mustBanger(t, "vm", "ssh", name, "--", "echo", "detach-marker")
wantContains(t, out, "detach-marker", "detach: ssh stdout")
}
// testBootstrapPrecondition ports scenario_bootstrap_precondition.
// A workspace with .mise.toml requires NAT (or --no-bootstrap) to run.
// The fake repo lives in a TempDir so it doesn't pollute the shared
// repodir fixture used by repodir-class scenarios.
func testBootstrapPrecondition(t *testing.T) {
t.Parallel()
miseRepo := t.TempDir()
gitInit := func(args ...string) {
t.Helper()
cmd := exec.Command(args[0], args[1:]...)
cmd.Dir = miseRepo
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("setup mise repo: %s: %v\n%s", args, err, out)
}
}
gitInit("git", "init", "-q")
gitInit("git", "-c", "user.email=smoke@banger", "-c", "user.name=smoke",
"commit", "--allow-empty", "-q", "-m", "init")
if err := os.WriteFile(filepath.Join(miseRepo, ".mise.toml"), []byte("[tools]\n"), 0o644); err != nil {
t.Fatalf("write .mise.toml: %v", err)
}
gitInit("git", "add", ".mise.toml")
gitInit("git", "-c", "user.email=smoke@banger", "-c", "user.name=smoke",
"commit", "-q", "-m", "add mise")
res := banger(t, "vm", "run", "--rm", miseRepo, "--", "echo", "nope")
if res.rc == 0 {
t.Fatalf("bootstrap: workspace with .mise.toml should refuse without --nat / --no-bootstrap")
}
out := mustBanger(t, "vm", "run", "--rm", "--no-bootstrap", miseRepo, "--", "echo", "no-bootstrap-ok")
wantContains(t, out, "no-bootstrap-ok", "bootstrap: --no-bootstrap stdout")
}
// testVMLifecycle ports scenario_vm_lifecycle. Drives an explicit
// create / show / ssh / stop / start / ssh / delete and asserts the
// state transitions are visible in `vm show`.
func testVMLifecycle(t *testing.T) {
t.Parallel()
const name = "smoke-lifecycle"
vmCreate(t, name)
show := mustBanger(t, "vm", "show", name)
wantContains(t, show, `"state": "running"`, "post-create state")
waitForSSH(t, name)
out := mustBanger(t, "vm", "ssh", name, "--", "echo", "hello-1")
wantContains(t, out, "hello-1", "vm ssh #1")
mustBanger(t, "vm", "stop", name)
show = mustBanger(t, "vm", "show", name)
wantContains(t, show, `"state": "stopped"`, "post-stop state")
mustBanger(t, "vm", "start", name)
show = mustBanger(t, "vm", "show", name)
wantContains(t, show, `"state": "running"`, "post-start state")
waitForSSH(t, name)
out = mustBanger(t, "vm", "ssh", name, "--", "echo", "hello-2")
wantContains(t, out, "hello-2", "vm ssh #2 (post-restart)")
mustBanger(t, "vm", "delete", name)
res := banger(t, "vm", "show", name)
if res.rc == 0 {
t.Fatalf("vm show still finds %q after delete\nstdout: %s", name, res.stdout)
}
}
// testVMSet ports scenario_vm_set. Creates with --vcpu 2, asserts
// guest sees 2 CPUs, reconfigures to 4 while stopped, asserts guest
// sees 4 after restart.
func testVMSet(t *testing.T) {
t.Parallel()
const name = "smoke-set"
vmCreate(t, name, "--vcpu", "2")
waitForSSH(t, name)
out := mustBanger(t, "vm", "ssh", name, "--", "nproc")
if got := strings.TrimSpace(out); got != "2" {
t.Fatalf("vm set: initial nproc got %q, want 2", got)
}
mustBanger(t, "vm", "stop", name)
mustBanger(t, "vm", "set", name, "--vcpu", "4")
mustBanger(t, "vm", "start", name)
waitForSSH(t, name)
out = mustBanger(t, "vm", "ssh", name, "--", "nproc")
if got := strings.TrimSpace(out); got != "4" {
t.Fatalf("vm set: post-reconfig nproc got %q, want 4 (spec change didn't land)", got)
}
}
// testVMRestart ports scenario_vm_restart. Reads /proc boot_id before
// and after `vm restart`; the kernel regenerates it on every boot, so
// distinct values prove the verb actually rebooted the guest.
func testVMRestart(t *testing.T) {
t.Parallel()
const name = "smoke-restart"
vmCreate(t, name)
waitForSSH(t, name)
bootBefore := strings.TrimSpace(mustBanger(t, "vm", "ssh", name, "--", "cat", "/proc/sys/kernel/random/boot_id"))
if bootBefore == "" {
t.Fatalf("vm restart: could not read initial boot_id")
}
mustBanger(t, "vm", "restart", name)
waitForSSH(t, name)
bootAfter := strings.TrimSpace(mustBanger(t, "vm", "ssh", name, "--", "cat", "/proc/sys/kernel/random/boot_id"))
if bootAfter == "" {
t.Fatalf("vm restart: could not read post-restart boot_id")
}
if bootBefore == bootAfter {
t.Fatalf("vm restart: boot_id unchanged (%s); verb didn't actually reboot the guest", bootBefore)
}
}
// dmDevRE captures the dm-snapshot device name from `vm show` JSON.
// Used by testVMKill to check that `vm kill --signal KILL` cleans up
// the dm device alongside the firecracker process.
var dmDevRE = regexp.MustCompile(`"dm_dev":\s*"(fc-rootfs-[^"]+)"`)
// testVMKill ports scenario_vm_kill. `vm kill --signal KILL` must stop
// the VM and clean up its dm-snapshot device. The dm-name capture
// degrades gracefully — older builds without the field still pass the
// state-check half.
func testVMKill(t *testing.T) {
t.Parallel()
const name = "smoke-kill"
vmCreate(t, name)
show := mustBanger(t, "vm", "show", name)
var dmName string
if m := dmDevRE.FindStringSubmatch(show); len(m) == 2 {
dmName = m[1]
}
mustBanger(t, "vm", "kill", "--signal", "KILL", name)
show = mustBanger(t, "vm", "show", name)
wantContains(t, show, `"state": "stopped"`, "post-kill state")
if dmName != "" {
out, _ := exec.Command("sudo", "-n", "dmsetup", "ls").CombinedOutput()
for _, line := range strings.Split(string(out), "\n") {
fields := strings.Fields(line)
if len(fields) > 0 && fields[0] == dmName {
t.Fatalf("vm kill: dm device %q still mapped (cleanup didn't run)", dmName)
}
}
}
}
// testVMPorts ports scenario_vm_ports. Asserts `vm ports` reports the
// guest's sshd listener under the VM's DNS name.
func testVMPorts(t *testing.T) {
t.Parallel()
const name = "smoke-ports"
vmCreate(t, name)
waitForSSH(t, name)
out := mustBanger(t, "vm", "ports", name)
wantContains(t, out, "smoke-ports.vm:22", "vm ports stdout (host:port)")
wantContains(t, out, "sshd", "vm ports stdout (process name)")
}
// testSSHConfig ports scenario_ssh_config. Drives ssh-config
// install/uninstall against a fake $HOME so the contributor's real
// ~/.ssh/config is never touched. Verifies idempotent install,
// preservation of pre-existing user content, and clean uninstall.
func testSSHConfig(t *testing.T) {
t.Parallel()
fakeHome := t.TempDir()
if err := os.MkdirAll(filepath.Join(fakeHome, ".ssh"), 0o700); err != nil {
t.Fatalf("mkdir .ssh: %v", err)
}
cfg := filepath.Join(fakeHome, ".ssh", "config")
if err := os.WriteFile(cfg, []byte("Host myserver\n HostName example.invalid\n"), 0o600); err != nil {
t.Fatalf("write fake config: %v", err)
}
mustBangerHome(t, fakeHome, "ssh-config", "--install")
cfgBytes, err := os.ReadFile(cfg)
if err != nil {
t.Fatalf("read fake config after install: %v", err)
}
body := string(cfgBytes)
if !strings.Contains(body, "\nInclude ") && !strings.HasPrefix(body, "Include ") {
t.Fatalf("ssh-config: install didn't add Include line:\n%s", body)
}
wantContains(t, body, "Host myserver", "ssh-config: install must preserve user content")
mustBangerHome(t, fakeHome, "ssh-config", "--install")
cfgBytes, _ = os.ReadFile(cfg)
body = string(cfgBytes)
includeCount := 0
for _, line := range strings.Split(body, "\n") {
if strings.HasPrefix(line, "Include ") && strings.Contains(line, "banger") {
includeCount++
}
}
if includeCount != 1 {
t.Fatalf("ssh-config: install not idempotent (Include appeared %d times)", includeCount)
}
mustBangerHome(t, fakeHome, "ssh-config", "--uninstall")
cfgBytes, _ = os.ReadFile(cfg)
body = string(cfgBytes)
for _, line := range strings.Split(body, "\n") {
if strings.HasPrefix(line, "Include ") && strings.Contains(line, "banger") {
t.Fatalf("ssh-config: uninstall left the Include line behind:\n%s", body)
}
}
wantContains(t, body, "Host myserver", "ssh-config: uninstall must keep user content")
}