Files
Felis/internal/updater/gatherer_test.go
T
flyemoji 7db57b9fff feat(updater): add VersionGatherer extraction core and CLI gather seam
Give the Runner a way to read each component's CURRENT version so it can be
compared against the release sources already wired. Three pure extractors turn
raw system text into an updates.Version, each fail-closed:

  - versionFromCLI      — a `<tool> --version` banner   (k3s, cloudflared)
  - versionFromImageRef — a container image tag         (felis-api)
  - versionFromJarName  — a proxy jar filename          (velocity)

sysGatherer routes each Topology component to the right extractor over an
injected seam; every path is exercised with a fake runner, mirroring how the
release sources are proven against httptest.

The load-bearing case is k3s: its Git tag "v1.36.2+k3s1" parses stable, but a
registry cannot store '+', so the same build ships as image tag "v1.36.2-k3s1",
which parses as a prerelease unless repaired. versionFromImageRef normalizes
"-k3sN"/"-rke2rN" back to "+", so an image read and a CLI read agree instead of
the image masquerading as a prerelease and being barred from comparison.

Honest runtime state after this slice — a green suite is not "the updater runs
against real infra": only the CLI seam (execRunner) is wired, so of the four
tracked components just cloudflared is live end to end (gatherable AND
Scheduled/appliable). k3s is CLI-gatherable but Notify-only. felis-api and
velocity are NOT yet runtime-gatherable: their producing seams — a k8s read of
the control-plane Deployment image, and an off-cluster jar inspection — are left
nil, so both surface an explicit "gather seam not wired" error rather than a
wrong version. felis-api self-update is therefore not functional yet.

Remaining integration (tracked in doc.go): the two producing seams, the concrete
Notifier (SMTP + in-game), the Applier (image bump, cloudflared swap), the
`felis update` CLI + CronJob entry point, and the runtime append of the Pinned
Minecraft fleet.
2026-07-05 04:05:56 +09:00

215 lines
7.3 KiB
Go

package updater
import (
"context"
"errors"
"fmt"
"testing"
)
// fakeCmd is a fake commandRunner: it returns canned `--version` output keyed by the
// binary name, or a fixed error, so the CLI extraction path is proven without k3s or
// cloudflared installed.
type fakeCmd struct {
out map[string][]byte
err error
}
func (f fakeCmd) output(_ context.Context, name string, _ ...string) ([]byte, error) {
if f.err != nil {
return nil, f.err
}
b, ok := f.out[name]
if !ok {
return nil, fmt.Errorf("no canned output for %q", name)
}
return b, nil
}
// Real `--version` banners (captured from the tools' known output shape). The k3s
// banner is the important one: the binary prints the '+k3s1' build form, which must
// parse STABLE — contrast versionFromImageRef, which has to repair the '-k3s1' the
// registry forces.
const (
k3sVersionBanner = "k3s version v1.36.2+k3s1 (a1b2c3d4)\ngo version go1.24.0\n"
cloudflaredVersionBanner = "cloudflared version 2026.6.1 (built 2026-06-20-1057 UTC)\n"
)
func TestVersionFromCLI(t *testing.T) {
cases := []struct {
name string
raw string
wantCore [3]int
wantStr string
}{
{"k3s keeps +build stable", k3sVersionBanner, [3]int{1, 36, 2}, "v1.36.2+k3s1"},
{"cloudflared calver", cloudflaredVersionBanner, [3]int{2026, 6, 1}, "2026.6.1"},
{"version after a label", "Version: 1.2.3", [3]int{1, 2, 3}, "1.2.3"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
v, err := versionFromCLI(tc.raw)
if err != nil {
t.Fatalf("versionFromCLI(%q): %v", tc.raw, err)
}
if got := [3]int{v.Major, v.Minor, v.Patch}; got != tc.wantCore {
t.Errorf("core = %v, want %v", got, tc.wantCore)
}
if v.IsPrerelease() {
t.Errorf("%q parsed as prerelease", tc.raw)
}
if v.String() != tc.wantStr {
t.Errorf("String() = %q, want %q", v.String(), tc.wantStr)
}
})
}
for _, bad := range []string{"", " ", "no version in here at all", "k3s\ngo version go1.24"} {
if v, err := versionFromCLI(bad); err == nil {
t.Errorf("versionFromCLI(%q) = %q, want error", bad, v.String())
}
}
}
func TestVersionFromImageRef(t *testing.T) {
cases := []struct {
name string
ref string
wantCore [3]int
}{
{"k3s docker tag restored to stable", "rancher/k3s:v1.36.2-k3s1", [3]int{1, 36, 2}},
{"rke2 docker tag restored to stable", "rancher/rke2-runtime:v1.31.4-rke2r1", [3]int{1, 31, 4}},
{"clean semver with registry", "ghcr.io/acme/felis-api:1.4.0", [3]int{1, 4, 0}},
{"registry port kept, digest stripped", "localhost:5000/felis-api:1.4.0@sha256:deadbeef", [3]int{1, 4, 0}},
{"cloudflared calver image", "cloudflare/cloudflared:2026.6.1", [3]int{2026, 6, 1}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
v, err := versionFromImageRef(tc.ref)
if err != nil {
t.Fatalf("versionFromImageRef(%q): %v", tc.ref, err)
}
if got := [3]int{v.Major, v.Minor, v.Patch}; got != tc.wantCore {
t.Errorf("core = %v, want %v", got, tc.wantCore)
}
if v.IsPrerelease() {
t.Errorf("%q parsed as prerelease — the '-k3s1'/'-rke2r1' build suffix was not restored to '+'", tc.ref)
}
})
}
// No tag, empty tag, non-version tag, and empty ref all fail closed.
for _, bad := range []string{"", "ubuntu", "ghcr.io/acme/felis-api", "repo:latest", "repo:", "repo:latest@sha256:abc"} {
if v, err := versionFromImageRef(bad); err == nil {
t.Errorf("versionFromImageRef(%q) = %q, want error", bad, v.String())
}
}
}
func TestVersionFromJarName(t *testing.T) {
cases := []struct {
name string
jar string
wantCore [3]int
}{
{"snapshot build, qualifier dropped", "velocity-3.4.0-SNAPSHOT-461.jar", [3]int{3, 4, 0}},
{"plain release jar", "velocity-3.4.0.jar", [3]int{3, 4, 0}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
v, err := versionFromJarName(tc.jar)
if err != nil {
t.Fatalf("versionFromJarName(%q): %v", tc.jar, err)
}
if got := [3]int{v.Major, v.Minor, v.Patch}; got != tc.wantCore {
t.Errorf("core = %v, want %v", got, tc.wantCore)
}
if v.IsPrerelease() {
t.Errorf("%q parsed as prerelease — the numeric core should be taken bare", tc.jar)
}
})
}
for _, bad := range []string{"", "velocity.jar", "proxy-latest.jar"} {
if v, err := versionFromJarName(bad); err == nil {
t.Errorf("versionFromJarName(%q) = %q, want error", bad, v.String())
}
}
}
// fullFakeGatherer wires every seam with a fake so Current() can be driven for all four
// real Topology components without a node, a cluster, or the proxy host.
func fullFakeGatherer() sysGatherer {
return sysGatherer{
run: fakeCmd{out: map[string][]byte{
"k3s": []byte(k3sVersionBanner),
"cloudflared": []byte(cloudflaredVersionBanner),
}},
imageForSpec: func(_ context.Context, _ Spec) (string, error) { return "ghcr.io/acme/felis-api:1.4.0", nil },
jarForSpec: func(_ context.Context, _ Spec) (string, error) { return "velocity-3.4.0-SNAPSHOT-461.jar", nil },
}
}
// TestSysGathererCurrentDispatch drives Current() for every component the real Topology
// tracks, proving each name routes to the right seam+extractor and the discovered
// current version (including the raw tag a report needs) comes back correctly.
func TestSysGathererCurrentDispatch(t *testing.T) {
g := fullFakeGatherer()
want := map[string]struct {
core [3]int
str string
}{
"k3s": {[3]int{1, 36, 2}, "v1.36.2+k3s1"},
"cloudflared": {[3]int{2026, 6, 1}, "2026.6.1"},
"felis-api": {[3]int{1, 4, 0}, "1.4.0"},
"velocity": {[3]int{3, 4, 0}, "3.4.0"},
}
for _, spec := range Topology() {
w, ok := want[spec.Name]
if !ok {
t.Fatalf("Topology grew a component %q with no gather expectation — update this test", spec.Name)
}
v, err := g.Current(context.Background(), spec)
if err != nil {
t.Errorf("Current(%s): %v", spec.Name, err)
continue
}
if got := [3]int{v.Major, v.Minor, v.Patch}; got != w.core {
t.Errorf("Current(%s) core = %v, want %v", spec.Name, got, w.core)
}
if v.String() != w.str {
t.Errorf("Current(%s) String() = %q, want %q", spec.Name, v.String(), w.str)
}
}
}
// TestSysGathererFailsClosed covers every way a gather can fail: a runner error, a seam
// that is not wired (the production default for felis-api/velocity), and an unknown
// component. None returns a usable zero version.
func TestSysGathererFailsClosed(t *testing.T) {
k3s := Spec{Name: "k3s"}
felis := Spec{Name: "felis-api"}
velo := Spec{Name: "velocity"}
// Runner error propagates.
boom := sysGatherer{run: fakeCmd{err: errors.New("exec: k3s not found")}}
if _, err := boom.Current(context.Background(), k3s); err == nil {
t.Error("a runner error should fail the gather")
}
// The production constructor leaves the image/jar seams nil: those components must
// report an explicit not-wired error, not a wrong version.
prod := NewSysGatherer()
if _, err := prod.Current(context.Background(), felis); err == nil {
t.Error("felis-api gather should error while the image seam is unwired")
}
if _, err := prod.Current(context.Background(), velo); err == nil {
t.Error("velocity gather should error while the jar seam is unwired")
}
// An unknown component is a loud error, never a silent skip.
if _, err := prod.Current(context.Background(), Spec{Name: "postgres"}); err == nil {
t.Error("an unrecognized component should error")
}
}