doctor: pin firecracker version range, distro-aware install hint

Pre-release polish: be explicit about which firecracker versions
banger has been validated against, and give users a one-line install
suggestion when the binary is missing rather than the previous
generic "install firecracker or set firecracker_bin".

internal/firecracker/version.go (new):
  * MinSupportedVersion = "1.5.0" — the floor banger refuses to
    launch below. Bumping this is a deliberate decision, paired
    with whatever helper feature started requiring the newer
    firecracker.
  * KnownTestedVersion = "1.14.1" — what banger's smoke suite
    actually runs against today.
  * SemVer + Compare + ParseVersionOutput, table-tested. The parser
    tolerates the trailing "exiting successfully" log line that
    firecracker tacks onto --version; only the canonical
    "Firecracker vX.Y.Z" line matters.
  * QueryVersion shells `<bin> --version` through a CommandRunner-
    shaped interface; doesn't import internal/system to keep the
    firecracker package leaf-clean.

internal/daemon/doctor.go:
  * New addFirecrackerVersionCheck replaces the previous bare
    RequireExecutable preflight for firecracker. Three outcomes:
    PASS within [Min, Tested], WARN above Tested (newer firecracker
    usually works but is outside the tested window), FAIL below Min
    or when the binary is missing.
  * On missing binary, surfaces a distro-aware install command via
    parseOSReleaseIDs(/etc/os-release) → guessFirecrackerInstall
    Command. Pinned suggestions for debian (apt), arch/manjaro
    (paru), and nixos (nix-env). Other distros get only the upstream
    Releases URL — guessing wrong sends users on a wild goose chase.
  * runtimeChecks no longer includes the firecracker preflight; the
    new check subsumes it.

README.md:
  * Requirements line now spells out the tested-against version
    (v1.14.1) and the supported floor (≥ v1.5.0), and points at
    `banger doctor` for the version check + install hint.

Tests: ParseVersionOutput across canonical/prerelease/garbage inputs,
SemVer.Compare across major/minor/patch boundaries, MustParseSemVer
panics on malformed inputs. Doctor-side: PASS on tested version,
FAIL below Min, WARN above Tested, FAIL with upstream URL when
missing, install-hint dispatch table covering debian/ubuntu (via
ID_LIKE)/arch/manjaro/nixos/fedora-fallback/missing-os-release.
The renamed TestDoctorReport_MissingFirecrackerFails... now asserts
against the new check name. Live `banger doctor` reports
"v1.14.1 at /usr/bin/firecracker (within tested range; min v1.5.0,
tested v1.14.1)" against the smoke host.

Smoke bare_run still green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Thales Maciel 2026-04-28 17:47:42 -03:00
parent f7a6832ebf
commit 1c1ca7d6a4
No known key found for this signature in database
GPG key ID: 33112E6833C34679
5 changed files with 510 additions and 6 deletions

View file

@ -8,6 +8,7 @@ import (
"strings"
"testing"
"banger/internal/firecracker"
"banger/internal/model"
"banger/internal/paths"
"banger/internal/system"
@ -347,17 +348,166 @@ func TestDoctorReport_StoreSuccessSurfacesAsPass(t *testing.T) {
}
}
func TestDoctorReport_MissingFirecrackerFailsHostRuntime(t *testing.T) {
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, "host runtime")
check := findCheck(report, "firecracker binary")
if check == nil {
t.Fatal("host runtime check missing from report")
t.Fatal("firecracker binary check missing from report")
}
if check.Status != system.CheckStatusFail {
t.Fatalf("host runtime status = %q, want fail when firecracker binary missing", check.Status)
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)
}
}
// 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)
}
}