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:
parent
f7a6832ebf
commit
1c1ca7d6a4
5 changed files with 510 additions and 6 deletions
133
internal/firecracker/version.go
Normal file
133
internal/firecracker/version.go
Normal 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
|
||||
}
|
||||
}
|
||||
96
internal/firecracker/version_test.go
Normal file
96
internal/firecracker/version_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue