README gets a top-level Updating section; docs/privileges.md gains a step-by-step trust-model writeup of `banger update`. The new scripts/publish-banger-release.sh drives the manual release cut: build, tar, sha256sum, cosign sign-blob, verify against the embedded public key, jq-merge into manifest.json, rclone upload to the R2 bucket. Refuses outright if the embedded key is still the placeholder so we can't accidentally publish an unverifiable release. Also folds in gofmt drift accumulated across the updater package and a few sibling files. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
590 lines
22 KiB
Go
590 lines
22 KiB
Go
package daemon
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"banger/internal/buildinfo"
|
|
"banger/internal/firecracker"
|
|
"banger/internal/installmeta"
|
|
"banger/internal/model"
|
|
"banger/internal/paths"
|
|
"banger/internal/system"
|
|
)
|
|
|
|
// permissiveRunner satisfies system.CommandRunner by returning a
|
|
// configurable response for every call. Doctor tests don't care about
|
|
// the exact ip/iptables commands run — they care that the aggregated
|
|
// report surfaces each feature check correctly, so a one-size runner
|
|
// keeps the test prelude short.
|
|
type permissiveRunner struct {
|
|
out []byte
|
|
err error
|
|
}
|
|
|
|
func (r *permissiveRunner) Run(_ context.Context, _ string, _ ...string) ([]byte, error) {
|
|
return r.out, r.err
|
|
}
|
|
|
|
func (r *permissiveRunner) RunSudo(_ context.Context, _ ...string) ([]byte, error) {
|
|
return r.out, r.err
|
|
}
|
|
|
|
// buildDoctorDaemon stands up a Daemon the way doctorReport expects:
|
|
// fake PATH with every tool the preflights look for, fake firecracker
|
|
// + vsock companion binaries, fake vsock host device file, and a
|
|
// permissive runner that claims a default-route via eth0 so NAT's
|
|
// defaultUplink call succeeds. Returns the wired *Daemon.
|
|
func buildDoctorDaemon(t *testing.T) *Daemon {
|
|
t.Helper()
|
|
binDir := t.TempDir()
|
|
for _, name := range []string{
|
|
"sudo", "ip", "dmsetup", "losetup", "blockdev", "truncate", "pgrep",
|
|
"chown", "chmod", "kill", "e2cp", "e2rm", "debugfs",
|
|
"iptables", "sysctl", "mkfs.ext4", "mount", "umount", "cp",
|
|
} {
|
|
writeFakeExecutable(t, filepath.Join(binDir, name))
|
|
}
|
|
t.Setenv("PATH", binDir)
|
|
|
|
firecrackerBin := filepath.Join(t.TempDir(), "firecracker")
|
|
if err := os.WriteFile(firecrackerBin, []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil {
|
|
t.Fatalf("write firecracker: %v", err)
|
|
}
|
|
vsockHelper := filepath.Join(t.TempDir(), "banger-vsock-agent")
|
|
if err := os.WriteFile(vsockHelper, []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil {
|
|
t.Fatalf("write vsock helper: %v", err)
|
|
}
|
|
t.Setenv("BANGER_VSOCK_AGENT_BIN", vsockHelper)
|
|
|
|
sshKey := filepath.Join(t.TempDir(), "id_ed25519")
|
|
if err := os.WriteFile(sshKey, []byte("unused"), 0o600); err != nil {
|
|
t.Fatalf("write ssh key: %v", err)
|
|
}
|
|
|
|
vsockHostDevice := filepath.Join(t.TempDir(), "vhost-vsock")
|
|
if err := os.WriteFile(vsockHostDevice, []byte{}, 0o644); err != nil {
|
|
t.Fatalf("write vsock host device: %v", err)
|
|
}
|
|
|
|
runner := &permissiveRunner{out: []byte("default via 10.0.0.1 dev eth0 proto static\n")}
|
|
|
|
d := &Daemon{
|
|
layout: paths.Layout{
|
|
ConfigDir: t.TempDir(),
|
|
StateDir: t.TempDir(),
|
|
DBPath: filepath.Join(t.TempDir(), "state.db"),
|
|
},
|
|
config: model.DaemonConfig{
|
|
FirecrackerBin: firecrackerBin,
|
|
SSHKeyPath: sshKey,
|
|
BridgeName: model.DefaultBridgeName,
|
|
BridgeIP: model.DefaultBridgeIP,
|
|
StatsPollInterval: model.DefaultStatsPollInterval,
|
|
},
|
|
runner: runner,
|
|
}
|
|
wireServices(d)
|
|
d.vm.vsockHostDevice = vsockHostDevice
|
|
// HostNetwork defaults its own runner to the one on the struct, but
|
|
// wireServices only copies the Daemon's runner if d.net is nil
|
|
// before that call — in this test we constructed d.net implicitly,
|
|
// so belt-and-braces the permissive runner onto HostNetwork too.
|
|
d.net.runner = runner
|
|
return d
|
|
}
|
|
|
|
// findCheck returns the first CheckResult with the given name, or nil
|
|
// if no such check was emitted. The test helper rather than a method
|
|
// on Report so the field scope stays tight.
|
|
func findCheck(report system.Report, name string) *system.CheckResult {
|
|
for i := range report.Checks {
|
|
if report.Checks[i].Name == name {
|
|
return &report.Checks[i]
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// TestDoctorReport_NonSystemModeEmitsSecurityWarn pins the non-
|
|
// system-mode branch: when install.toml is absent the security
|
|
// posture check must surface a warn that points at the dev-mode
|
|
// caveat in docs/privileges.md. A pass row in this mode would
|
|
// imply guarantees the install isn't actually providing. Drives
|
|
// the seam variant so the test is independent of whether the host
|
|
// happens to have /etc/banger/install.toml.
|
|
func TestDoctorReport_NonSystemModeEmitsSecurityWarn(t *testing.T) {
|
|
d := buildDoctorDaemon(t)
|
|
report := system.Report{}
|
|
missingInstall := filepath.Join(t.TempDir(), "install.toml")
|
|
d.addSecurityPostureChecksAt(context.Background(), &report, missingInstall, t.TempDir())
|
|
|
|
check := findCheck(report, "security posture")
|
|
if check == nil {
|
|
t.Fatal("security posture check missing from report")
|
|
}
|
|
if check.Status != system.CheckStatusWarn {
|
|
t.Fatalf("security posture status = %q, want warn", check.Status)
|
|
}
|
|
joined := strings.Join(check.Details, " ")
|
|
if !strings.Contains(joined, "outside the system install") {
|
|
t.Fatalf("warn details = %q, want mention of non-system mode", joined)
|
|
}
|
|
if !strings.Contains(joined, "docs/privileges.md") {
|
|
t.Fatalf("warn details = %q, want pointer to docs/privileges.md", joined)
|
|
}
|
|
}
|
|
|
|
func TestAddSocketPermsCheckRejectsWrongMode(t *testing.T) {
|
|
socketPath := filepath.Join(t.TempDir(), "fake.sock")
|
|
if err := os.WriteFile(socketPath, []byte{}, 0o644); err != nil {
|
|
t.Fatalf("write fake socket: %v", err)
|
|
}
|
|
report := system.Report{}
|
|
addSocketPermsCheck(&report, "test socket", socketPath, os.Getuid(), 0o600)
|
|
check := findCheck(report, "test socket")
|
|
if check == nil {
|
|
t.Fatal("expected test socket check")
|
|
}
|
|
if check.Status != system.CheckStatusFail {
|
|
t.Fatalf("status = %q, want fail when mode is 0644 vs 0600 expected", check.Status)
|
|
}
|
|
joined := strings.Join(check.Details, " ")
|
|
if !strings.Contains(joined, "mode is") {
|
|
t.Fatalf("details = %q, want mode-mismatch message", joined)
|
|
}
|
|
}
|
|
|
|
func TestAddSocketPermsCheckPassesWhenModeAndOwnerMatch(t *testing.T) {
|
|
socketPath := filepath.Join(t.TempDir(), "fake.sock")
|
|
if err := os.WriteFile(socketPath, []byte{}, 0o600); err != nil {
|
|
t.Fatalf("write fake socket: %v", err)
|
|
}
|
|
report := system.Report{}
|
|
addSocketPermsCheck(&report, "test socket", socketPath, os.Getuid(), 0o600)
|
|
check := findCheck(report, "test socket")
|
|
if check == nil {
|
|
t.Fatal("expected test socket check")
|
|
}
|
|
if check.Status != system.CheckStatusPass {
|
|
t.Fatalf("status = %q, want pass when mode + uid match; details = %v", check.Status, check.Details)
|
|
}
|
|
}
|
|
|
|
func TestAddUnitHardeningCheckFlagsMissingDirective(t *testing.T) {
|
|
unitPath := filepath.Join(t.TempDir(), "bangerd.service")
|
|
if err := os.WriteFile(unitPath, []byte("[Service]\nUser=alice\nProtectSystem=strict\n"), 0o644); err != nil {
|
|
t.Fatalf("write unit: %v", err)
|
|
}
|
|
report := system.Report{}
|
|
addUnitHardeningCheck(&report, "unit hardening", unitPath, []string{"User=alice", "NoNewPrivileges=yes", "ProtectSystem=strict"})
|
|
check := findCheck(report, "unit hardening")
|
|
if check == nil {
|
|
t.Fatal("expected unit hardening check")
|
|
}
|
|
if check.Status != system.CheckStatusFail {
|
|
t.Fatalf("status = %q, want fail when NoNewPrivileges is missing", check.Status)
|
|
}
|
|
joined := strings.Join(check.Details, " ")
|
|
if !strings.Contains(joined, "NoNewPrivileges=yes") {
|
|
t.Fatalf("details = %q, want mention of the missing directive", joined)
|
|
}
|
|
}
|
|
|
|
func TestAddUnitHardeningCheckPassesWhenAllPresent(t *testing.T) {
|
|
unitPath := filepath.Join(t.TempDir(), "bangerd-root.service")
|
|
body := "[Service]\nNoNewPrivileges=yes\nProtectSystem=strict\nProtectHome=yes\nCapabilityBoundingSet=CAP_CHOWN\n"
|
|
if err := os.WriteFile(unitPath, []byte(body), 0o644); err != nil {
|
|
t.Fatalf("write unit: %v", err)
|
|
}
|
|
report := system.Report{}
|
|
addUnitHardeningCheck(&report, "unit hardening", unitPath, []string{"NoNewPrivileges=yes", "ProtectSystem=strict", "CapabilityBoundingSet="})
|
|
check := findCheck(report, "unit hardening")
|
|
if check == nil {
|
|
t.Fatal("expected unit hardening check")
|
|
}
|
|
if check.Status != system.CheckStatusPass {
|
|
t.Fatalf("status = %q, want pass when every directive is present; details = %v", check.Status, check.Details)
|
|
}
|
|
}
|
|
|
|
func TestAddExecutableOwnershipCheckRejectsSymlink(t *testing.T) {
|
|
dir := t.TempDir()
|
|
real := filepath.Join(dir, "fc")
|
|
if err := os.WriteFile(real, []byte{}, 0o755); err != nil {
|
|
t.Fatalf("write fc: %v", err)
|
|
}
|
|
link := filepath.Join(dir, "fc-symlink")
|
|
if err := os.Symlink(real, link); err != nil {
|
|
t.Fatalf("symlink: %v", err)
|
|
}
|
|
report := system.Report{}
|
|
addExecutableOwnershipCheck(&report, "fc binary", link)
|
|
check := findCheck(report, "fc binary")
|
|
if check == nil {
|
|
t.Fatal("expected fc binary check")
|
|
}
|
|
if check.Status != system.CheckStatusFail {
|
|
t.Fatalf("status = %q, want fail for symlinked binary", check.Status)
|
|
}
|
|
joined := strings.Join(check.Details, " ")
|
|
if !strings.Contains(joined, "symlink") {
|
|
t.Fatalf("details = %q, want symlink rejection message", joined)
|
|
}
|
|
}
|
|
|
|
func TestAddExecutableOwnershipCheckRejectsGroupWritable(t *testing.T) {
|
|
if os.Getuid() == 0 {
|
|
t.Skip("test runs as root; can't construct a non-root-owned check target meaningfully")
|
|
}
|
|
path := filepath.Join(t.TempDir(), "fc")
|
|
if err := os.WriteFile(path, []byte{}, 0o775); err != nil {
|
|
t.Fatalf("write fc: %v", err)
|
|
}
|
|
report := system.Report{}
|
|
addExecutableOwnershipCheck(&report, "fc binary", path)
|
|
check := findCheck(report, "fc binary")
|
|
if check == nil {
|
|
t.Fatal("expected fc binary check")
|
|
}
|
|
if check.Status != system.CheckStatusFail {
|
|
t.Fatalf("status = %q, want fail when binary is group/world writable", check.Status)
|
|
}
|
|
}
|
|
|
|
// TestDoctorReport_SystemModeRunsAllSecurityChecks pins the system-mode
|
|
// branch end-to-end: with a fake install.toml + fake systemd dir it
|
|
// must contribute every security row (services, sockets, unit
|
|
// hardening, fc ownership). Statuses themselves vary because we can't
|
|
// easily fake root-owned files in a test, but every check name must
|
|
// appear so a future refactor can't silently drop one.
|
|
func TestDoctorReport_SystemModeRunsAllSecurityChecks(t *testing.T) {
|
|
d := buildDoctorDaemon(t)
|
|
|
|
installDir := t.TempDir()
|
|
installPath := filepath.Join(installDir, "install.toml")
|
|
if err := os.WriteFile(installPath, []byte("owner_user = \"alice\"\nowner_uid = 1000\nowner_gid = 1000\nowner_home = \"/home/alice\"\ninstalled_at = 2026-04-28T00:00:00Z\n"), 0o644); err != nil {
|
|
t.Fatalf("write install.toml: %v", err)
|
|
}
|
|
systemdDir := t.TempDir()
|
|
for _, svc := range []string{"bangerd.service", "bangerd-root.service"} {
|
|
if err := os.WriteFile(filepath.Join(systemdDir, svc), []byte(""), 0o644); err != nil {
|
|
t.Fatalf("write fake unit %s: %v", svc, err)
|
|
}
|
|
}
|
|
|
|
report := system.Report{}
|
|
d.addSecurityPostureChecksAt(context.Background(), &report, installPath, systemdDir)
|
|
|
|
for _, name := range []string{
|
|
"helper service",
|
|
"owner daemon service",
|
|
"helper socket",
|
|
"daemon socket",
|
|
"helper unit hardening",
|
|
"daemon unit hardening",
|
|
"firecracker binary ownership",
|
|
} {
|
|
if findCheck(report, name) == nil {
|
|
t.Errorf("system-mode security check %q missing from report", name)
|
|
}
|
|
}
|
|
if findCheck(report, "security posture") != nil {
|
|
t.Error("system mode should NOT emit the non-system-mode warn")
|
|
}
|
|
}
|
|
|
|
func TestDoctorReport_StoreErrorSurfacesAsFail(t *testing.T) {
|
|
d := buildDoctorDaemon(t)
|
|
report := d.doctorReport(context.Background(), errors.New("simulated open failure"), false)
|
|
|
|
check := findCheck(report, "state store")
|
|
if check == nil {
|
|
t.Fatal("state store check missing from report")
|
|
}
|
|
if check.Status != system.CheckStatusFail {
|
|
t.Fatalf("state store status = %q, want fail (store error should surface)", check.Status)
|
|
}
|
|
joined := strings.Join(check.Details, " ")
|
|
if !strings.Contains(joined, "simulated open failure") {
|
|
t.Fatalf("state store details = %q, want the storeErr message", joined)
|
|
}
|
|
}
|
|
|
|
func TestDoctorReport_StoreMissingSurfacesAsPassForFreshInstall(t *testing.T) {
|
|
d := buildDoctorDaemon(t)
|
|
// Fresh install: the DB file simply doesn't exist yet. doctor must
|
|
// not treat that as a failure — nothing's broken, the first daemon
|
|
// start will create the file. The status message should say so,
|
|
// so a user running `banger doctor` before ever booting a VM
|
|
// doesn't see a scary red check.
|
|
report := d.doctorReport(context.Background(), nil, true)
|
|
|
|
check := findCheck(report, "state store")
|
|
if check == nil {
|
|
t.Fatal("state store check missing from report")
|
|
}
|
|
if check.Status != system.CheckStatusPass {
|
|
t.Fatalf("state store status = %q, want pass for a missing DB on fresh install", check.Status)
|
|
}
|
|
joined := strings.Join(check.Details, " ")
|
|
if !strings.Contains(joined, "will be created") {
|
|
t.Fatalf("state store details = %q, want mention of 'will be created' so users know this is expected", joined)
|
|
}
|
|
}
|
|
|
|
func TestDoctorReport_StoreSuccessSurfacesAsPass(t *testing.T) {
|
|
d := buildDoctorDaemon(t)
|
|
report := d.doctorReport(context.Background(), nil, false)
|
|
|
|
check := findCheck(report, "state store")
|
|
if check == nil {
|
|
t.Fatal("state store check missing from report")
|
|
}
|
|
if check.Status != system.CheckStatusPass {
|
|
t.Fatalf("state store status = %q, want pass", check.Status)
|
|
}
|
|
}
|
|
|
|
func TestDoctorReport_MissingFirecrackerFailsFirecrackerBinaryCheck(t *testing.T) {
|
|
d := buildDoctorDaemon(t)
|
|
// Point at a nonexistent path. Note: the doctor's PATH lookup
|
|
// looks for the basename, so use an absolute non-existent path
|
|
// (that's the configured-path branch — bare-name lookups would
|
|
// fall through to the test-fixture binDir which DOES contain a
|
|
// fake `firecracker`).
|
|
d.config.FirecrackerBin = filepath.Join(t.TempDir(), "does-not-exist")
|
|
|
|
report := d.doctorReport(context.Background(), nil, false)
|
|
check := findCheck(report, "firecracker binary")
|
|
if check == nil {
|
|
t.Fatal("firecracker binary check missing from report")
|
|
}
|
|
if check.Status != system.CheckStatusFail {
|
|
t.Fatalf("firecracker binary status = %q, want fail when binary missing", check.Status)
|
|
}
|
|
joined := strings.Join(check.Details, " ")
|
|
if !strings.Contains(joined, "firecracker-microvm/firecracker/releases") {
|
|
t.Fatalf("missing-binary report should include the upstream URL; got %q", joined)
|
|
}
|
|
}
|
|
|
|
// TestVersionsDriftToleratesDevAndUnknown pins the suppression
|
|
// branches: a "dev"/"unknown" build on either side is the local-
|
|
// development case, not a drift problem; we don't want every
|
|
// developer-machine doctor run to emit a noisy warn.
|
|
func TestVersionsDriftToleratesDevAndUnknown(t *testing.T) {
|
|
t.Parallel()
|
|
cliReleased := buildinfo.Info{Version: "0.1.0", Commit: "abcd1234efgh", BuiltAt: "2026-04-28"}
|
|
metaReleased := installmeta.Metadata{Version: "0.1.0", Commit: "abcd1234efgh"}
|
|
|
|
// Match → no drift.
|
|
if versionsDrift(cliReleased, metaReleased) {
|
|
t.Fatal("identical CLI and install metadata reported as drifted")
|
|
}
|
|
// Real version mismatch → drift.
|
|
bumped := metaReleased
|
|
bumped.Version = "0.2.0"
|
|
if !versionsDrift(cliReleased, bumped) {
|
|
t.Fatal("differing version not flagged as drift")
|
|
}
|
|
// Same version, different commit → drift (rebuilt without retag).
|
|
differCommit := metaReleased
|
|
differCommit.Commit = "deadbeefdead"
|
|
if !versionsDrift(cliReleased, differCommit) {
|
|
t.Fatal("differing commit at same version not flagged as drift")
|
|
}
|
|
// "dev" CLI vs released install → suppressed.
|
|
devCLI := buildinfo.Info{Version: "dev", Commit: "f00fb00b", BuiltAt: "now"}
|
|
if versionsDrift(devCLI, metaReleased) {
|
|
t.Fatal("dev CLI vs released install incorrectly flagged as drift")
|
|
}
|
|
// Empty install version → suppressed (predates the field).
|
|
emptyMeta := installmeta.Metadata{}
|
|
if versionsDrift(cliReleased, emptyMeta) {
|
|
t.Fatal("empty install metadata incorrectly flagged as drift")
|
|
}
|
|
}
|
|
|
|
// TestFirecrackerInstallHintDispatchesByDistro pins the per-distro
|
|
// install command guess. Pinned IDs are the ones banger is willing to
|
|
// suggest a concrete command for; everything else gets only the
|
|
// upstream URL.
|
|
func TestFirecrackerInstallHintDispatchesByDistro(t *testing.T) {
|
|
t.Parallel()
|
|
for _, tc := range []struct {
|
|
name string
|
|
release string
|
|
wantSub string
|
|
wantNone bool
|
|
}{
|
|
{name: "debian", release: "ID=debian\nVERSION_CODENAME=bookworm\n", wantSub: "apt install firecracker"},
|
|
{name: "ubuntu_id_like_debian", release: "ID=ubuntu\nID_LIKE=debian\n", wantSub: "apt install firecracker"},
|
|
{name: "arch", release: "ID=arch\n", wantSub: "paru -S firecracker"},
|
|
{name: "manjaro_via_id_like", release: "ID=manjaro\nID_LIKE=arch\n", wantSub: "paru -S firecracker"},
|
|
{name: "nixos", release: "ID=nixos\n", wantSub: "nixos.firecracker"},
|
|
{name: "fedora_falls_back_to_url", release: "ID=fedora\n", wantNone: true},
|
|
{name: "missing_file", release: "", wantNone: true},
|
|
} {
|
|
tc := tc
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
osPath := filepath.Join(t.TempDir(), "os-release")
|
|
if tc.release != "" {
|
|
if err := os.WriteFile(osPath, []byte(tc.release), 0o644); err != nil {
|
|
t.Fatalf("write os-release: %v", err)
|
|
}
|
|
}
|
|
hints := firecrackerInstallHint(osPath)
|
|
joined := strings.Join(hints, " ")
|
|
if !strings.Contains(joined, "firecracker-microvm/firecracker/releases") {
|
|
t.Fatalf("hints missing upstream URL; got %q", joined)
|
|
}
|
|
if tc.wantNone {
|
|
// Distro-specific hint must NOT be present — only the URL.
|
|
if len(hints) != 1 {
|
|
t.Fatalf("unrecognised distro got distro-specific hint(s); want only the URL line, got %v", hints)
|
|
}
|
|
return
|
|
}
|
|
if !strings.Contains(joined, tc.wantSub) {
|
|
t.Fatalf("hints %q do not contain expected substring %q", joined, tc.wantSub)
|
|
}
|
|
if len(hints) < 2 {
|
|
t.Fatalf("expected distro hint + URL; got only %v", hints)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// firecrackerVersionRunner is a CommandRunner that actually executes
|
|
// firecracker --version (via system.Runner) but stubs everything else
|
|
// with the permissive default. The doctor uses d.runner for the
|
|
// firecracker version query AND for several other checks; this tiny
|
|
// dispatcher lets us run a real script for one command without
|
|
// rewiring the rest.
|
|
type firecrackerVersionRunner struct {
|
|
real system.Runner
|
|
canned []byte
|
|
bin string
|
|
}
|
|
|
|
func (r *firecrackerVersionRunner) Run(ctx context.Context, name string, args ...string) ([]byte, error) {
|
|
if name == r.bin {
|
|
return r.real.Run(ctx, name, args...)
|
|
}
|
|
return r.canned, nil
|
|
}
|
|
|
|
func (r *firecrackerVersionRunner) RunSudo(_ context.Context, _ ...string) ([]byte, error) {
|
|
return r.canned, nil
|
|
}
|
|
|
|
// stubFirecrackerVersion replaces the test daemon's firecracker
|
|
// stub with a script that prints the requested version line, then
|
|
// swaps d.runner for one that actually executes the script when the
|
|
// firecracker path is queried. Returns the resulting daemon ready
|
|
// for doctorReport.
|
|
func stubFirecrackerVersion(t *testing.T, d *Daemon, version string) {
|
|
t.Helper()
|
|
if err := os.WriteFile(d.config.FirecrackerBin, []byte("#!/bin/sh\necho 'Firecracker v"+version+"'\n"), 0o755); err != nil {
|
|
t.Fatalf("write firecracker stub: %v", err)
|
|
}
|
|
d.runner = &firecrackerVersionRunner{
|
|
real: system.NewRunner(),
|
|
canned: []byte("default via 10.0.0.1 dev eth0 proto static\n"),
|
|
bin: d.config.FirecrackerBin,
|
|
}
|
|
}
|
|
|
|
// TestFirecrackerVersionCheckPasses pins the happy path: when the
|
|
// configured firecracker reports a tested-range version, doctor
|
|
// emits a PASS row.
|
|
func TestFirecrackerVersionCheckPasses(t *testing.T) {
|
|
d := buildDoctorDaemon(t)
|
|
stubFirecrackerVersion(t, d, firecracker.KnownTestedVersion)
|
|
report := d.doctorReport(context.Background(), nil, false)
|
|
check := findCheck(report, "firecracker binary")
|
|
if check == nil {
|
|
t.Fatal("firecracker binary check missing from report")
|
|
}
|
|
if check.Status != system.CheckStatusPass {
|
|
t.Fatalf("status = %q, want pass; details=%v", check.Status, check.Details)
|
|
}
|
|
}
|
|
|
|
// TestFirecrackerVersionCheckFailsBelowMin pins the too-old path:
|
|
// a binary reporting a version below MinSupportedVersion must FAIL
|
|
// with the upgrade hint.
|
|
func TestFirecrackerVersionCheckFailsBelowMin(t *testing.T) {
|
|
d := buildDoctorDaemon(t)
|
|
stubFirecrackerVersion(t, d, "0.25.0")
|
|
report := d.doctorReport(context.Background(), nil, false)
|
|
check := findCheck(report, "firecracker binary")
|
|
if check == nil {
|
|
t.Fatal("firecracker binary check missing from report")
|
|
}
|
|
if check.Status != system.CheckStatusFail {
|
|
t.Fatalf("status = %q, want fail for below-min version", check.Status)
|
|
}
|
|
}
|
|
|
|
// TestFirecrackerVersionCheckWarnsAboveTested pins the over-tested
|
|
// path: a binary reporting a version newer than KnownTestedVersion
|
|
// must WARN — newer firecracker usually works, but it's outside the
|
|
// tested window.
|
|
func TestFirecrackerVersionCheckWarnsAboveTested(t *testing.T) {
|
|
d := buildDoctorDaemon(t)
|
|
stubFirecrackerVersion(t, d, "99.0.0")
|
|
report := d.doctorReport(context.Background(), nil, false)
|
|
check := findCheck(report, "firecracker binary")
|
|
if check == nil {
|
|
t.Fatal("firecracker binary check missing from report")
|
|
}
|
|
if check.Status != system.CheckStatusWarn {
|
|
t.Fatalf("status = %q, want warn for above-tested version", check.Status)
|
|
}
|
|
}
|
|
|
|
func TestDoctorReport_IncludesEveryDefaultCapability(t *testing.T) {
|
|
d := buildDoctorDaemon(t)
|
|
report := d.doctorReport(context.Background(), nil, false)
|
|
|
|
// Every registered capability that implements doctorCapability must
|
|
// contribute a check. Current defaults: work-disk, dns, nat. If a
|
|
// capability is added later it should either extend this list or
|
|
// register its own check name — either way, the assertion makes
|
|
// the contract visible.
|
|
for _, name := range []string{
|
|
"feature /root work disk",
|
|
"feature vm dns",
|
|
"feature nat",
|
|
} {
|
|
if findCheck(report, name) == nil {
|
|
t.Errorf("capability check %q missing from report", name)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestDoctorReport_EmitsVMDefaultsProvenance(t *testing.T) {
|
|
d := buildDoctorDaemon(t)
|
|
report := d.doctorReport(context.Background(), nil, false)
|
|
|
|
check := findCheck(report, "vm defaults")
|
|
if check == nil {
|
|
t.Fatal("vm defaults check missing from report")
|
|
}
|
|
if check.Status != system.CheckStatusPass {
|
|
t.Fatalf("vm defaults status = %q, want pass (this is an always-pass informational check)", check.Status)
|
|
}
|
|
joined := strings.Join(check.Details, "\n")
|
|
for _, needle := range []string{"vcpu:", "memory:", "disk:"} {
|
|
if !strings.Contains(joined, needle) {
|
|
t.Errorf("vm defaults details missing %q; got:\n%s", needle, joined)
|
|
}
|
|
}
|
|
}
|