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

@ -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 `<bin> --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
}
}

View file

@ -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)
})
}
}