//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 -- 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") }