diff --git a/README.md b/README.md index 86cd4f8..77a7ecb 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ One-command development sandboxes on Firecracker microVMs. -**Requirements:** Linux + KVM (`/dev/kvm`), `firecracker` on PATH (or `firecracker_bin` in config). +**Requirements:** Linux + KVM (`/dev/kvm`), `firecracker` on PATH (or `firecracker_bin` in config). banger v0.1.0 is tested against [Firecracker v1.14.1](https://github.com/firecracker-microvm/firecracker/releases/tag/v1.14.1) and supports any Firecracker ≥ v1.5.0. `banger doctor` warns when the installed version sits outside the tested range, and prints a distro-aware install hint when it's missing. ## Quick start diff --git a/internal/daemon/doctor.go b/internal/daemon/doctor.go index eb657ad..bb6dfcf 100644 --- a/internal/daemon/doctor.go +++ b/internal/daemon/doctor.go @@ -10,6 +10,7 @@ import ( "syscall" "banger/internal/config" + "banger/internal/firecracker" "banger/internal/imagecat" "banger/internal/installmeta" "banger/internal/model" @@ -91,11 +92,132 @@ func (d *Daemon) doctorReport(ctx context.Context, storeErr error, storeMissing d.addVMDefaultsCheck(&report) d.addSSHShortcutCheck(&report) d.addCapabilityDoctorChecks(ctx, &report) + d.addFirecrackerVersionCheck(ctx, &report) d.addSecurityPostureChecks(ctx, &report) return report } +// addFirecrackerVersionCheck verifies the configured firecracker +// binary exists, is recent enough for banger's expectations +// (firecracker.MinSupportedVersion), and surfaces a distro-aware +// install hint if it's missing. Three outcomes: +// +// - present + version in [Min, Tested]: PASS. +// - present + version above Tested: WARN. Newer firecracker +// usually works (the API is stable within a major), but it's +// outside banger's tested window. +// - present + version below Min: FAIL with the upgrade hint. +// - missing entirely: FAIL with a guess at the user's package +// manager plus the upstream Releases URL. +// +// We intentionally don't use the generic RequireExecutable preflight +// for this check — its static hint string can't carry the distro +// dispatch. +func (d *Daemon) addFirecrackerVersionCheck(ctx context.Context, report *system.Report) { + binPath := strings.TrimSpace(d.config.FirecrackerBin) + if binPath == "" { + binPath = "firecracker" + } + resolved, err := system.LookupExecutable(binPath) + if err != nil { + details := []string{fmt.Sprintf("not found: %s", binPath)} + details = append(details, firecrackerInstallHint(osReleaseSource)...) + report.AddFail("firecracker binary", details...) + return + } + parsed, err := firecracker.QueryVersion(ctx, d.runner, resolved) + if err != nil { + report.AddFail("firecracker binary", + fmt.Sprintf("`%s --version` failed: %v", resolved, err), + "reinstall firecracker; see https://github.com/firecracker-microvm/firecracker/releases") + return + } + reported := parsed.String() + min := firecracker.MustParseSemVer(firecracker.MinSupportedVersion) + tested := firecracker.MustParseSemVer(firecracker.KnownTestedVersion) + switch { + case parsed.Compare(min) < 0: + report.AddFail("firecracker binary", + fmt.Sprintf("%s at %s; banger requires ≥ v%s", reported, resolved, firecracker.MinSupportedVersion), + "upgrade firecracker — see https://github.com/firecracker-microvm/firecracker/releases") + case parsed.Compare(tested) > 0: + report.AddWarn("firecracker binary", + fmt.Sprintf("%s at %s (newer than banger's tested v%s; usually works)", reported, resolved, firecracker.KnownTestedVersion)) + default: + report.AddPass("firecracker binary", + fmt.Sprintf("%s at %s (within tested range; min v%s, tested v%s)", + reported, resolved, firecracker.MinSupportedVersion, firecracker.KnownTestedVersion)) + } +} + +// osReleaseSource is the file the install-hint reads to detect the +// host distro. Var rather than const so doctor tests can swap in a +// fixture. +var osReleaseSource = "/etc/os-release" + +// firecrackerInstallHint returns 1-2 detail lines describing how to +// install firecracker on the current host: a one-line guess based on +// /etc/os-release when the distro is recognised, plus the upstream +// Releases URL as a universal fallback. Anything we can't recognise +// gets only the URL — better silence than wrong instructions. +func firecrackerInstallHint(osReleasePath string) []string { + hints := []string{} + if cmd := guessFirecrackerInstallCommand(osReleasePath); cmd != "" { + hints = append(hints, "install: "+cmd) + } + hints = append(hints, "or download a static binary from https://github.com/firecracker-microvm/firecracker/releases") + return hints +} + +// guessFirecrackerInstallCommand reads osReleasePath and returns a +// short, copy-pasteable install command for the detected distro, or +// "" when no reliable mapping applies. We only suggest commands for +// distros where firecracker is actually packaged — guessing wrong +// here would send users on a wild goose chase. +func guessFirecrackerInstallCommand(osReleasePath string) string { + data, err := os.ReadFile(osReleasePath) + if err != nil { + return "" + } + id, idLike := parseOSReleaseIDs(string(data)) + candidates := append([]string{id}, strings.Fields(idLike)...) + for _, c := range candidates { + switch c { + case "debian": + // Packaged in Debian since trixie / bookworm-backports. + return "sudo apt install firecracker" + case "arch", "manjaro", "endeavouros": + // AUR; we don't assume a specific helper, but `paru` is the + // common one. Users who prefer yay/makepkg/etc. will + // substitute mentally. + return "paru -S firecracker # or your preferred AUR helper" + case "nixos": + return "nix-env -iA nixos.firecracker # or add to your configuration.nix" + } + } + return "" +} + +// parseOSReleaseIDs extracts the ID and ID_LIKE values from an +// /etc/os-release blob. Both are returned with surrounding quotes +// stripped; missing keys return empty strings. We don't validate +// the format beyond `KEY=value` — os-release is a simple format and +// any drift would manifest as a quiet "no distro hint" rather than +// a false positive. +func parseOSReleaseIDs(content string) (id, idLike string) { + for _, line := range strings.Split(content, "\n") { + line = strings.TrimSpace(line) + if rest, ok := strings.CutPrefix(line, "ID="); ok { + id = strings.Trim(rest, `"`) + } + if rest, ok := strings.CutPrefix(line, "ID_LIKE="); ok { + idLike = strings.Trim(rest, `"`) + } + } + return id, idLike +} + // addSecurityPostureChecks verifies the install matches what // docs/privileges.md describes: helper + owner-daemon units active, // sockets at the expected mode/owner, unit files carrying the @@ -358,7 +480,10 @@ func (d *Daemon) addVMDefaultsCheck(report *system.Report) { func (d *Daemon) runtimeChecks() *system.Preflight { checks := system.NewPreflight() - checks.RequireExecutable(d.config.FirecrackerBin, "firecracker binary", `install firecracker or set "firecracker_bin"`) + // Firecracker presence + version is a separate top-level check (see + // addFirecrackerVersionCheck) so the report can carry a distro-aware + // install hint when the binary is missing — RequireExecutable's + // static `hint` string can't do that. checks.RequireFile(d.config.SSHKeyPath, "ssh private key", `set "ssh_key_path" or let banger create its default key`) if helper, err := vsockAgentBinary(d.layout); err == nil { checks.RequireExecutable(helper, "vsock agent helper", `run 'make build' or reinstall banger`) diff --git a/internal/daemon/doctor_test.go b/internal/daemon/doctor_test.go index 78c4d49..58b379e 100644 --- a/internal/daemon/doctor_test.go +++ b/internal/daemon/doctor_test.go @@ -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) } } diff --git a/internal/firecracker/version.go b/internal/firecracker/version.go new file mode 100644 index 0000000..6da9a0f --- /dev/null +++ b/internal/firecracker/version.go @@ -0,0 +1,133 @@ +package firecracker + +import ( + "context" + "fmt" + "regexp" + "strconv" + "strings" +) + +// MinSupportedVersion is the lowest firecracker version banger has +// been validated against. Below this, banger refuses to launch — the +// jailer flags banger relies on (notably the `--exec-file` / +// `--chroot-base-dir` pair plus the structured chroot layout) might +// behave differently or be missing entirely. +// +// Bumping this is a deliberate decision; it should change in lockstep +// with whatever feature in the helper started requiring the newer +// firecracker. +const MinSupportedVersion = "1.5.0" + +// KnownTestedVersion is the firecracker version banger's smoke suite +// is currently exercised against. Newer versions usually work +// (firecracker keeps its API stable within a major) but they sit +// outside the tested window — `banger doctor` warns rather than fails +// when it finds a higher version. +const KnownTestedVersion = "1.14.1" + +// versionPattern matches the canonical `Firecracker v1.14.1` line +// emitted by `firecracker --version`. The pre-release suffix +// (e.g. `-beta`) is captured for fidelity in the reported string but +// ignored for ordering. +var versionPattern = regexp.MustCompile(`Firecracker v(\d+)\.(\d+)\.(\d+)(?:-([\w.]+))?`) + +// SemVer is a structural representation of a `MAJOR.MINOR.PATCH` +// triple plus an optional pre-release label. Comparisons use only +// the triple; pre-releases are kept for display. +type SemVer struct { + Major, Minor, Patch int + PreRelease string +} + +// String renders the SemVer back to its canonical form, with a +// leading "v" so it matches firecracker's own output. +func (s SemVer) String() string { + if s.PreRelease == "" { + return fmt.Sprintf("v%d.%d.%d", s.Major, s.Minor, s.Patch) + } + return fmt.Sprintf("v%d.%d.%d-%s", s.Major, s.Minor, s.Patch, s.PreRelease) +} + +// Compare returns -1, 0, or +1 based on the (Major, Minor, Patch) +// triple. Pre-release labels are ignored — banger doesn't +// distinguish `v1.10.0` from `v1.10.0-rc1` for compatibility purposes. +func (s SemVer) Compare(other SemVer) int { + if s.Major != other.Major { + return cmpInt(s.Major, other.Major) + } + if s.Minor != other.Minor { + return cmpInt(s.Minor, other.Minor) + } + return cmpInt(s.Patch, other.Patch) +} + +// ParseVersionOutput pulls the SemVer out of `firecracker --version` +// stdout. firecracker historically prints the version line followed +// by a freeform "exiting successfully" line; we match the first +// occurrence of the pattern anywhere in the output to be tolerant of +// future cosmetic changes. +func ParseVersionOutput(out string) (SemVer, error) { + m := versionPattern.FindStringSubmatch(out) + if m == nil { + return SemVer{}, fmt.Errorf("unrecognised firecracker version output: %q", strings.TrimSpace(out)) + } + major, _ := strconv.Atoi(m[1]) + minor, _ := strconv.Atoi(m[2]) + patch, _ := strconv.Atoi(m[3]) + return SemVer{Major: major, Minor: minor, Patch: patch, PreRelease: m[4]}, nil +} + +// MustParseSemVer parses a `MAJOR.MINOR.PATCH` (optionally `v`-prefixed) +// constant. Panics on malformed input — only used for the package-level +// constants above and in tests, so a malformed string is a developer +// error rather than a runtime concern. +func MustParseSemVer(s string) SemVer { + parts := strings.SplitN(strings.TrimPrefix(s, "v"), ".", 3) + if len(parts) != 3 { + panic("MustParseSemVer: " + s) + } + major, err := strconv.Atoi(parts[0]) + if err != nil { + panic("MustParseSemVer: " + s) + } + minor, err := strconv.Atoi(parts[1]) + if err != nil { + panic("MustParseSemVer: " + s) + } + patch, err := strconv.Atoi(parts[2]) + if err != nil { + panic("MustParseSemVer: " + s) + } + return SemVer{Major: major, Minor: minor, Patch: patch} +} + +// VersionRunner is the slim contract QueryVersion needs from a +// command-runner. system.Runner satisfies it; defining the interface +// inline keeps internal/firecracker free of cross-cutting imports. +type VersionRunner interface { + Run(ctx context.Context, name string, args ...string) ([]byte, error) +} + +// QueryVersion runs ` --version` and parses the result. Returns +// only the parsed SemVer — firecracker's stdout includes a trailing +// "exiting successfully" log line that we have no use for; callers +// render the result via SemVer.String() ("v1.14.1") for display. +func QueryVersion(ctx context.Context, runner VersionRunner, bin string) (SemVer, error) { + out, err := runner.Run(ctx, bin, "--version") + if err != nil { + return SemVer{}, err + } + return ParseVersionOutput(string(out)) +} + +func cmpInt(a, b int) int { + switch { + case a < b: + return -1 + case a > b: + return 1 + default: + return 0 + } +} diff --git a/internal/firecracker/version_test.go b/internal/firecracker/version_test.go new file mode 100644 index 0000000..e314631 --- /dev/null +++ b/internal/firecracker/version_test.go @@ -0,0 +1,96 @@ +package firecracker + +import ( + "strings" + "testing" +) + +func TestParseVersionOutput(t *testing.T) { + t.Parallel() + for _, tc := range []struct { + name string + input string + want SemVer + wantErr bool + }{ + {name: "canonical", input: "Firecracker v1.14.1\n", want: SemVer{Major: 1, Minor: 14, Patch: 1}}, + {name: "with_trailing_log", input: "Firecracker v1.14.1\n\n2026-04-28T17:38:12.392171332 [anonymous-instance:main] exit_code=0\n", want: SemVer{Major: 1, Minor: 14, Patch: 1}}, + {name: "prerelease", input: "Firecracker v1.10.0-rc1\n", want: SemVer{Major: 1, Minor: 10, Patch: 0, PreRelease: "rc1"}}, + {name: "two_digit_minor", input: "Firecracker v2.0.42\n", want: SemVer{Major: 2, Minor: 0, Patch: 42}}, + {name: "garbage", input: "not a firecracker", wantErr: true}, + {name: "empty", input: "", wantErr: true}, + {name: "missing_v", input: "Firecracker 1.14.1", wantErr: true}, + } { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + got, err := ParseVersionOutput(tc.input) + if tc.wantErr { + if err == nil { + t.Fatalf("ParseVersionOutput(%q) succeeded, want error", tc.input) + } + return + } + if err != nil { + t.Fatalf("ParseVersionOutput(%q) = %v", tc.input, err) + } + if got != tc.want { + t.Fatalf("ParseVersionOutput(%q) = %+v, want %+v", tc.input, got, tc.want) + } + }) + } +} + +func TestSemVerCompare(t *testing.T) { + t.Parallel() + for _, tc := range []struct { + name string + a, b string + want int + }{ + {name: "equal", a: "1.14.1", b: "1.14.1", want: 0}, + {name: "patch_lower", a: "1.14.0", b: "1.14.1", want: -1}, + {name: "patch_higher", a: "1.14.2", b: "1.14.1", want: 1}, + {name: "minor_dominates_patch", a: "1.10.999", b: "1.11.0", want: -1}, + {name: "major_dominates", a: "2.0.0", b: "1.99.99", want: 1}, + {name: "min_vs_tested_today", a: MinSupportedVersion, b: KnownTestedVersion, want: -1}, + } { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + a := MustParseSemVer(tc.a) + b := MustParseSemVer(tc.b) + if got := a.Compare(b); got != tc.want { + t.Fatalf("(%s).Compare(%s) = %d, want %d", tc.a, tc.b, got, tc.want) + } + }) + } +} + +func TestSemVerString(t *testing.T) { + t.Parallel() + if got := MustParseSemVer("1.14.1").String(); got != "v1.14.1" { + t.Fatalf("v1.14.1.String() = %q", got) + } + pre := SemVer{Major: 1, Minor: 10, Patch: 0, PreRelease: "rc1"} + if got := pre.String(); got != "v1.10.0-rc1" { + t.Fatalf("rc String() = %q", got) + } +} + +// MustParseSemVer panics on malformed input; pin a few inputs so a +// future refactor doesn't accidentally widen what counts as valid. +func TestMustParseSemVerRejectsMalformed(t *testing.T) { + t.Parallel() + for _, bad := range []string{"", "1", "1.2", "1.2.3.4", "v1.2.x", "vfoo"} { + bad := bad + t.Run(strings.ReplaceAll(bad, ".", "_"), func(t *testing.T) { + defer func() { + if r := recover(); r == nil { + t.Errorf("MustParseSemVer(%q) did not panic", bad) + } + }() + _ = MustParseSemVer(bad) + }) + } +}