311 lines
10 KiB
Go
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")
|
|
}
|