banger/internal/firecracker/version_test.go
Thales Maciel 1c1ca7d6a4
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>
2026-04-28 17:47:42 -03:00

96 lines
2.9 KiB
Go

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