//go:build smoke package smoketest import ( "os/exec" "regexp" "strings" "testing" ) // testInvalidSpec is the Go port of scenario_invalid_spec. Asserts that // `vm run --rm --vcpu 0 ...` is rejected and that no VM row is leaked // in the process. Global-class because it asserts on host-wide vm-list // counts; running concurrently with pure-class VM creation would race. func testInvalidSpec(t *testing.T) { preCount := vmListAllCount(t) res := banger(t, "vm", "run", "--rm", "--vcpu", "0", "--", "echo", "unused") if res.rc == 0 { t.Fatalf("invalid spec: vm run unexpectedly succeeded with --vcpu 0\nstdout: %s\nstderr: %s", res.stdout, res.stderr) } postCount := vmListAllCount(t) if preCount != postCount { t.Fatalf("invalid spec leaked a VM row: pre=%d, post=%d", preCount, postCount) } } // vmListAllCount returns the line count of `banger vm list --all`. // Mirrors the bash `vm list --all | wc -l` idiom; the absolute count // doesn't matter, only that it doesn't change across the rejected // invocation. func vmListAllCount(t *testing.T) int { t.Helper() out := mustBanger(t, "vm", "list", "--all") return strings.Count(out, "\n") } // testVMPrune ports scenario_vm_prune. `vm prune -f` should remove // stopped VMs while preserving running ones. Global-class because it // asserts on host-wide vm-list contents. func testVMPrune(t *testing.T) { mustBanger(t, "vm", "create", "--name", "smoke-prune-running") t.Cleanup(func() { vmDelete("smoke-prune-running") }) mustBanger(t, "vm", "create", "--name", "smoke-prune-stopped") t.Cleanup(func() { vmDelete("smoke-prune-stopped") }) mustBanger(t, "vm", "stop", "smoke-prune-stopped") mustBanger(t, "vm", "prune", "-f") if banger(t, "vm", "show", "smoke-prune-running").rc != 0 { t.Fatalf("vm prune: running VM was deleted (regression!)") } if banger(t, "vm", "show", "smoke-prune-stopped").rc == 0 { t.Fatalf("vm prune: stopped VM survived prune") } } // guestIPRE captures `"guest_ip": "172.16.0.X"` from `vm show` JSON. // Used by testNAT to map VMs to their POSTROUTING rule subjects. var guestIPRE = regexp.MustCompile(`"guest_ip":\s*"([^"]+)"`) // vmGuestIP returns the guest_ip field from `vm show`. Fatals if // missing — every running VM has one. func vmGuestIP(t *testing.T, name string) string { t.Helper() show := mustBanger(t, "vm", "show", name) m := guestIPRE.FindStringSubmatch(show) if len(m) != 2 { t.Fatalf("could not read guest_ip from vm show %q:\n%s", name, show) } return m[1] } // testNAT ports scenario_nat. Verifies that `--nat` installs a per-VM // MASQUERADE rule, that the rule survives stop/start, and that delete // cleans it up. The control VM (no --nat) must NOT have a rule. func testNAT(t *testing.T) { requireSudoIptables(t) mustBanger(t, "vm", "create", "--name", "smoke-nat", "--nat") t.Cleanup(func() { vmDelete("smoke-nat") }) mustBanger(t, "vm", "create", "--name", "smoke-nocnat") t.Cleanup(func() { vmDelete("smoke-nocnat") }) natIP := vmGuestIP(t, "smoke-nat") ctlIP := vmGuestIP(t, "smoke-nocnat") postrouting := iptablesPostrouting(t) natRule := "-s " + natIP + "/32" if !strings.Contains(postrouting, natRule) || !strings.Contains(postrouting, "MASQUERADE") { t.Fatalf("NAT: --nat VM has no POSTROUTING MASQUERADE rule for %s; got:\n%s", natIP, postrouting) } if strings.Contains(postrouting, "-s "+ctlIP+"/32") { t.Fatalf("NAT: control VM unexpectedly has a MASQUERADE rule for %s", ctlIP) } mustBanger(t, "vm", "stop", "smoke-nat") mustBanger(t, "vm", "start", "smoke-nat") postrouting = iptablesPostrouting(t) count := strings.Count(postrouting, natRule) if count != 1 { t.Fatalf("NAT: MASQUERADE rule count for %s = %d after restart, want 1", natIP, count) } mustBanger(t, "vm", "delete", "smoke-nat") mustBanger(t, "vm", "delete", "smoke-nocnat") postrouting = iptablesPostrouting(t) if strings.Contains(postrouting, natRule) { t.Fatalf("NAT: delete left a MASQUERADE rule behind for %s", natIP) } } func iptablesPostrouting(t *testing.T) string { t.Helper() out, err := exec.Command("sudo", "-n", "iptables", "-t", "nat", "-S", "POSTROUTING").Output() if err != nil { t.Fatalf("read iptables POSTROUTING: %v", err) } return string(out) } // testInvalidName ports scenario_invalid_name. A handful of malformed // names must all be rejected and none of them may leak a VM row. func testInvalidName(t *testing.T) { preCount := vmListAllCount(t) for _, bad := range []string{"MyBox", "my box", "box.vm", "-box"} { res := banger(t, "vm", "create", "--name", bad, "--no-start") if res.rc == 0 { t.Fatalf("invalid name: vm create accepted %q", bad) } } if postCount := vmListAllCount(t); postCount != preCount { t.Fatalf("invalid name leaked VM row(s): pre=%d, post=%d", preCount, postCount) } } // updateBaseArgs are the manifest/pubkey flags every update scenario // needs to redirect the updater away from the production R2 bucket // and at our smoke release server. Built lazily because manifestURL / // pubkeyFile are populated by prepareSmokeReleases. func updateBaseArgs() []string { return []string{"--manifest-url", manifestURL, "--pubkey-file", pubkeyFile} } // testUpdateCheck ports scenario_update_check. `update --check` must // succeed against the smoke release server and announce the available // version on stdout. func testUpdateCheck(t *testing.T) { if err := prepareSmokeReleases(); err != nil { t.Fatalf("prepare smoke releases: %v", err) } args := append([]string{"update", "--check"}, updateBaseArgs()...) res := banger(t, args...) if res.rc != 0 { t.Fatalf("update --check failed: rc=%d\nstdout: %s\nstderr: %s", res.rc, res.stdout, res.stderr) } wantContains(t, res.stdout+res.stderr, "update available: ", "update --check stdout") } // testUpdateToUnknown ports scenario_update_to_unknown. Asking for a // version not in the manifest must fail before any host mutation — // the installed binary's version stays put. func testUpdateToUnknown(t *testing.T) { if err := prepareSmokeReleases(); err != nil { t.Fatalf("prepare smoke releases: %v", err) } preVer := installedVersion(t) args := append([]string{"update", "--to", "v9.9.9"}, updateBaseArgs()...) res := banger(t, args...) if res.rc == 0 { t.Fatalf("update --to v9.9.9: exit 0 (out: %s%s)", res.stdout, res.stderr) } combined := strings.ToLower(res.stdout + res.stderr) if !strings.Contains(combined, "not found") { t.Fatalf("update --to v9.9.9: error doesn't say 'not found'; got: %s%s", res.stdout, res.stderr) } if postVer := installedVersion(t); preVer != postVer { t.Fatalf("update --to v9.9.9 mutated the install: %s -> %s", preVer, postVer) } } // testUpdateNoRoot ports scenario_update_no_root. Non-sudo invocation // of `update --to` must refuse with a root-required error and leave // the install untouched. func testUpdateNoRoot(t *testing.T) { if err := prepareSmokeReleases(); err != nil { t.Fatalf("prepare smoke releases: %v", err) } preVer := installedVersion(t) args := append([]string{"update", "--to", smokeReleaseGood}, updateBaseArgs()...) res := banger(t, args...) if res.rc == 0 { t.Fatalf("update without sudo: exit 0 (out: %s%s)", res.stdout, res.stderr) } combined := strings.ToLower(res.stdout + res.stderr) if !strings.Contains(combined, "root") { t.Fatalf("update without sudo: error doesn't mention root; got: %s%s", res.stdout, res.stderr) } if postVer := installedVersion(t); preVer != postVer { t.Fatalf("update without sudo mutated the install: %s -> %s", preVer, postVer) } } // testUpdateDryRun ports scenario_update_dry_run. `--dry-run` fetches // + verifies the new release but must not swap the binary. func testUpdateDryRun(t *testing.T) { requirePasswordlessSudo(t) if err := prepareSmokeReleases(); err != nil { t.Fatalf("prepare smoke releases: %v", err) } preVer := installedVersion(t) args := append([]string{"update", "--to", smokeReleaseGood, "--dry-run"}, updateBaseArgs()...) res := sudoBanger(t, args...) if res.rc != 0 { t.Fatalf("update --dry-run failed: %s%s", res.stdout, res.stderr) } wantContains(t, res.stdout+res.stderr, "dry-run:", "update --dry-run stdout") if postVer := installedVersion(t); preVer != postVer { t.Fatalf("update --dry-run swapped the binary: %s -> %s", preVer, postVer) } } // vmBootID reads /proc/sys/kernel/random/boot_id from the guest. The // kernel regenerates it on every boot, so an unchanged value across a // daemon restart proves the firecracker process survived. Used by both // update scenarios that assert "the VM stays alive". func vmBootID(t *testing.T, name string) string { t.Helper() out, _ := exec.Command(bangerBin, "vm", "ssh", name, "--", "cat", "/proc/sys/kernel/random/boot_id").Output() return strings.TrimSpace(string(out)) } var installTomlVersionRE = regexp.MustCompile(`(?m)^version\s*=\s*"([^"]+)"`) // installedTomlVersion reads /etc/banger/install.toml's version field // (under sudo since the dir is not always world-readable). func installedTomlVersion(t *testing.T) string { t.Helper() out, err := exec.Command("sudo", "cat", "/etc/banger/install.toml").Output() if err != nil { t.Fatalf("read /etc/banger/install.toml: %v", err) } m := installTomlVersionRE.FindStringSubmatch(string(out)) if len(m) != 2 { t.Fatalf("install.toml: no version field in:\n%s", out) } return m[1] } // testUpdateKeepsVMAlive ports scenario_update_keeps_vm_alive. The // long-running update scenario: a real swap to v0.smoke.0, must not // reboot the running VM, must update the install metadata, and the VM // must still answer SSH afterwards. func testUpdateKeepsVMAlive(t *testing.T) { requirePasswordlessSudo(t) if err := prepareSmokeReleases(); err != nil { t.Fatalf("prepare smoke releases: %v", err) } const name = "smoke-update" vmCreate(t, name) waitForSSH(t, name) preBoot := vmBootID(t, name) if preBoot == "" { t.Fatalf("pre-update boot_id capture failed") } preVer := installedVersion(t) args := append([]string{"update", "--to", smokeReleaseGood}, updateBaseArgs()...) if res := sudoBanger(t, args...); res.rc != 0 { t.Fatalf("update --to %s failed: %s%s", smokeReleaseGood, res.stdout, res.stderr) } postVer := installedVersion(t) if postVer != smokeReleaseGood { t.Fatalf("post-update /usr/local/bin/banger version = %s, want %s", postVer, smokeReleaseGood) } if preVer == postVer { t.Fatalf("update did not change the binary version (pre==post=%s)", postVer) } if metaVer := installedTomlVersion(t); metaVer != smokeReleaseGood { t.Fatalf("install.toml version = %q, want %s", metaVer, smokeReleaseGood) } waitForSSH(t, name) postBoot := vmBootID(t, name) if postBoot == "" { t.Fatalf("post-update boot_id read failed") } if preBoot != postBoot { t.Fatalf("VM rebooted during update: boot_id %s -> %s", preBoot, postBoot) } } // testUpdateRollbackKeepsVMAlive ports scenario_update_rollback_keeps_vm_alive. // Rollback drill: install the broken-bangerd release, which passes the // pre-swap migration sanity but fails as a service. runUpdate's // rollbackAndWrap must restore the previous binaries, and the VM must // survive the whole drill. func testUpdateRollbackKeepsVMAlive(t *testing.T) { requirePasswordlessSudo(t) if err := prepareSmokeReleases(); err != nil { t.Fatalf("prepare smoke releases: %v", err) } preVer := installedVersion(t) const name = "smoke-rollback" vmCreate(t, name) waitForSSH(t, name) preBoot := vmBootID(t, name) if preBoot == "" { t.Fatalf("pre-drill boot_id capture failed") } args := append([]string{"update", "--to", smokeReleaseBroken}, updateBaseArgs()...) res := sudoBanger(t, args...) if res.rc == 0 { t.Fatalf("rollback drill: update returned exit 0 despite broken bangerd\nstdout: %s\nstderr: %s", res.stdout, res.stderr) } if postVer := installedVersion(t); postVer != preVer { t.Fatalf("rollback drill: post-rollback version = %s, want %s", postVer, preVer) } waitForSSH(t, name) postBoot := vmBootID(t, name) if postBoot == "" { t.Fatalf("post-rollback boot_id read failed") } if preBoot != postBoot { t.Fatalf("VM rebooted during rollback drill: boot_id %s -> %s", preBoot, postBoot) } } // testDaemonAdmin ports scenario_daemon_admin. MUST be the last global // scenario in the run order: `banger daemon stop` tears the installed // services down, so anything after it that talks to the daemon would // fail. The teardown path re-stops idempotently. func testDaemonAdmin(t *testing.T) { socket := strings.TrimSpace(mustBanger(t, "daemon", "socket")) if socket != "/run/banger/bangerd.sock" { t.Fatalf("daemon socket: got %q, want /run/banger/bangerd.sock", socket) } migOut, err := exec.Command(bangerdBin, "--system", "--check-migrations").CombinedOutput() if err != nil { t.Fatalf("bangerd --check-migrations: %v\n%s", err, migOut) } if !strings.HasPrefix(strings.TrimSpace(string(migOut)), "compatible:") { t.Fatalf("bangerd --check-migrations: stdout missing 'compatible:' prefix; got: %s", migOut) } requirePasswordlessSudo(t) if res := sudoBanger(t, "daemon", "stop"); res.rc != 0 { t.Fatalf("banger daemon stop: %s%s", res.stdout, res.stderr) } status, _ := exec.Command(bangerBin, "system", "status").Output() if !regexp.MustCompile(`(?m)^active\s+inactive`).Match(status) { t.Fatalf("owner daemon still active after daemon stop:\n%s", status) } if !regexp.MustCompile(`(?m)^helper_active\s+inactive`).Match(status) { t.Fatalf("root helper still active after daemon stop:\n%s", status) } }