Unverified Commit 7db57b9f authored by Minseong Choi's avatar Minseong Choi 💬
Browse files

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.
parent e5747498
Loading
Loading
Loading
Loading
+16 −10
Changes for internal/updater/doc.go: 16 added lines, 10 removed lines.
Original line number Diff line number Diff line
@@ -29,15 +29,21 @@
//     routing/parse logic is verified, but that one component stays dark at runtime (its
//     Latest errors, degrading to "latest unknown") until a real repository is configured.
//
//   - NOT YET BUILT, but VERIFIABLE HERE (the next slice): the VersionGatherer's
//     extraction core — command output (`k3s --version`), image tag
//     (`rancher/k3s:v1.36.2-k3s1`) or jar filename → Version. That is logic over an
//     exec/read seam, testable with a fake runner à la internal/reaper's ExecRunner,
//     and load-bearing: a mis-read current version makes every plan wrong (spurious
//     applies or missed upgrades). Only the seam's actual I/O is un-verifiable here.
//   - ALSO BUILT + UNIT-VERIFIED: the VersionGatherer's extraction core and dispatch.
//     Three pure extractors turn raw system text into a Version — a `--version` banner
//     (k3s, cloudflared), a container image tag (felis-api), a proxy jar filename
//     (velocity) — and sysGatherer routes each component to the right one over an
//     injected seam, all exercised with a fake runner (gatherer_test.go). The
//     load-bearing case is proven: k3s's registry tag "v1.36.2-k3s1" is repaired to the
//     "+k3s1" build form the binary reports (a Docker tag cannot hold '+'), so an image
//     read and a CLI read agree instead of the image masquerading as a prerelease. The
//     CLI seam (execRunner) is wired for real; only its exec I/O is un-verified here.
//
//   - REMAINING INTEGRATION (genuinely I/O-bound — needs a cluster/mailbox to exercise):
//     the concrete Notifier (SMTP + in-game) and Applier (control-plane image bump,
//     cloudflared swap), the `felis update` CLI + CronJob entry point, and the runtime
//     append of the live Pinned Minecraft fleet.
//   - REMAINING INTEGRATION (genuinely I/O-bound — needs a node/cluster/mailbox): the
//     two current-version PRODUCING seams the gatherer still lacks — the k8s read of the
//     control-plane Deployment's image (felis-api) and the off-cluster Velocity jar
//     inspection, both left nil so those components surface a gather error rather than a
//     wrong version — plus the concrete Notifier (SMTP + in-game) and Applier
//     (control-plane image bump, cloudflared swap), the `felis update` CLI + CronJob
//     entry point, and the runtime append of the live Pinned Minecraft fleet.
package updater
+194 −0
Changes for internal/updater/gatherer.go: 194 added lines, 0 removed lines.
Original line number Diff line number Diff line
package updater

import (
	"context"
	"fmt"
	"regexp"
	"strings"

	"felis.lolicon.best/internal/updates"
)

// This file is the VERIFIABLE core of the current-version gatherer: the pure
// extractors that turn a raw system string (a `--version` line, a container image
// reference, a proxy jar filename) into an updates.Version, plus a sysGatherer that
// dispatches each component to the right extractor over an injected seam. The seams'
// actual I/O — shelling out, reading a running pod's image, listing an off-cluster
// jar — is integration and lives in gatherer_integration.go; here every path is
// exercised with a fake, exactly as the release sources are exercised with httptest.
//
// Why the extraction is load-bearing enough to unit-test: Current() feeds
// updates.Run's comparison. A mis-read current version is not a cosmetic bug — it
// silently makes every downstream decision wrong (a spurious apply, or a missed
// upgrade). The subtlety that proves the point is k3s: its Git tag "v1.36.2+k3s1"
// parses as a STABLE release (build metadata after '+' is ignored), but a container
// registry cannot store '+' in a tag, so the SAME build ships as the image tag
// "v1.36.2-k3s1" — which, taken literally, parses as a PRERELEASE ("-k3s1" tail) and
// would wrongly bar a stable image from comparison. versionFromImageRef normalizes
// that convention back; versionFromCLI never sees it because the k3s binary prints the
// '+' form.

// commandRunner is the exec seam: it runs a binary and returns its combined output.
// The production implementation (execRunner, gatherer_integration.go) shells out to
// the real k3s/cloudflared binary; tests inject a fake that returns canned output, so
// the extraction logic is proven without either tool installed.
type commandRunner interface {
	output(ctx context.Context, name string, args ...string) ([]byte, error)
}

// versionFromCLI extracts a version from a `<tool> --version` banner. It scans the
// whitespace-separated tokens and returns the FIRST that parses as a version, which
// matches the universal "<name> version <V> (<build>)" convention while tolerating the
// differing preambles and trailing build/date fields:
//
//	k3s:         "k3s version v1.36.2+k3s1 (a1b2c3)\ngo version go1.24.0"  -> v1.36.2+k3s1
//	cloudflared: "cloudflared version 2026.6.1 (built 2026-06-20-1057 UTC)" -> 2026.6.1
//
// It fails closed: output with no parseable token is an error, never a zero version.
func versionFromCLI(raw string) (updates.Version, error) {
	for _, tok := range strings.Fields(raw) {
		if v, err := updates.Parse(tok); err == nil {
			return v, nil
		}
	}
	return updates.Version{}, fmt.Errorf("updater: no version token in CLI output %q", truncate(raw, 80))
}

// dockerBuildSuffix matches the k3s / rke2 build metadata that a container tag encodes
// with '-' because a Docker tag may not contain the SemVer '+'. Restoring the '+'
// makes the image tag parse to the same STABLE version the CLI reports.
var dockerBuildSuffix = regexp.MustCompile(`-(k3s\d+|rke2r\d+)$`)

// versionFromImageRef extracts a version from a container image reference's tag:
//
//	"rancher/k3s:v1.36.2-k3s1"                    -> v1.36.2  (stable; "-k3s1" restored to "+k3s1")
//	"ghcr.io/acme/felis-api:1.4.0"                -> 1.4.0
//	"localhost:5000/felis-api:1.4.0@sha256:deadbeef" -> 1.4.0  (registry port kept, digest stripped)
//
// It strips any "@sha256:" digest, takes the tag after the last ':' of the final path
// segment (so a "host:port/repo" registry port is not mistaken for the tag), restores
// the Docker-encoded k3s/rke2 build suffix, and parses. A reference with no tag or a
// non-version tag ("latest") fails closed.
func versionFromImageRef(ref string) (updates.Version, error) {
	ref = strings.TrimSpace(ref)
	if ref == "" {
		return updates.Version{}, fmt.Errorf("updater: empty image reference")
	}
	if i := strings.IndexByte(ref, '@'); i >= 0 { // strip a "...@sha256:..." digest
		ref = ref[:i]
	}
	// The tag, if any, is after the last ':' within the final path segment; a ':' in an
	// earlier segment is a registry host port, not a tag separator.
	lastSeg := ref[strings.LastIndexByte(ref, '/')+1:]
	colon := strings.LastIndexByte(lastSeg, ':')
	if colon < 0 {
		return updates.Version{}, fmt.Errorf("updater: image reference %q has no tag", ref)
	}
	tag := lastSeg[colon+1:]
	if tag == "" {
		return updates.Version{}, fmt.Errorf("updater: image reference %q has an empty tag", ref)
	}
	tag = dockerBuildSuffix.ReplaceAllString(tag, "+$1")
	v, err := updates.Parse(tag)
	if err != nil {
		return updates.Version{}, fmt.Errorf("updater: image tag: %w", err)
	}
	return v, nil
}

// jarVersion matches the first dotted-numeric run in a filename (three parts preferred
// over two so "3.4.0" wins whole).
var jarVersion = regexp.MustCompile(`\d+\.\d+\.\d+|\d+\.\d+`)

// versionFromJarName extracts a version from a proxy jar filename:
//
//	"velocity-3.4.0-SNAPSHOT-461.jar" -> 3.4.0
//	"velocity-3.4.0.jar"              -> 3.4.0
//
// It is best-effort by nature (an admin may rename the jar): it returns the numeric
// core of the first version-looking token and fails closed if the name carries none.
// A "-SNAPSHOT" qualifier is intentionally dropped — Velocity is Notify-only and never
// auto-applied, so a slightly optimistic current only affects an advisory message.
func versionFromJarName(name string) (updates.Version, error) {
	m := jarVersion.FindString(name)
	if m == "" {
		return updates.Version{}, fmt.Errorf("updater: no version in jar name %q", name)
	}
	v, err := updates.Parse(m)
	if err != nil {
		return updates.Version{}, fmt.Errorf("updater: jar name %q: %w", name, err)
	}
	return v, nil
}

// sysGatherer is the production VersionGatherer. It reads each component's CURRENT
// version by the method that component exposes — a CLI banner for the node binaries
// (k3s, cloudflared), the running pod's image tag for the control plane (felis-api),
// the installed jar's name for the off-cluster proxy (velocity) — and turns it into a
// Version with the pure extractors above. The three seams are injected: `run` (exec)
// is wired in production; `imageForSpec` and `jarForSpec` are the two reads that still
// need real infra (a k8s client, off-cluster host access) and are nil until built, so
// those components surface a clear gather error rather than a wrong version.
//
// Dispatch is keyed by the component identities in Topology(); an unrecognized name is
// a loud error, not a silent skip.
type sysGatherer struct {
	run          commandRunner
	imageForSpec func(ctx context.Context, spec Spec) (string, error)
	jarForSpec   func(ctx context.Context, spec Spec) (string, error)
}

// Current implements VersionGatherer.
func (g sysGatherer) Current(ctx context.Context, spec Spec) (updates.Version, error) {
	switch spec.Name {
	case "k3s":
		return g.cliVersion(ctx, "k3s")
	case "cloudflared":
		return g.cliVersion(ctx, "cloudflared")
	case "felis-api":
		if g.imageForSpec == nil {
			return updates.Version{}, fmt.Errorf("updater: image gather seam for %q not wired", spec.Name)
		}
		ref, err := g.imageForSpec(ctx, spec)
		if err != nil {
			return updates.Version{}, fmt.Errorf("updater: read image for %q: %w", spec.Name, err)
		}
		return versionFromImageRef(ref)
	case "velocity":
		if g.jarForSpec == nil {
			return updates.Version{}, fmt.Errorf("updater: jar gather seam for %q not wired", spec.Name)
		}
		name, err := g.jarForSpec(ctx, spec)
		if err != nil {
			return updates.Version{}, fmt.Errorf("updater: read jar for %q: %w", spec.Name, err)
		}
		return versionFromJarName(name)
	default:
		return updates.Version{}, fmt.Errorf("updater: no gather method for component %q", spec.Name)
	}
}

// cliVersion runs `<bin> --version` through the exec seam and extracts the version.
func (g sysGatherer) cliVersion(ctx context.Context, bin string) (updates.Version, error) {
	if g.run == nil {
		return updates.Version{}, fmt.Errorf("updater: command runner not wired for %q", bin)
	}
	out, err := g.run.output(ctx, bin, "--version")
	if err != nil {
		return updates.Version{}, fmt.Errorf("updater: run %s --version: %w", bin, err)
	}
	v, err := versionFromCLI(string(out))
	if err != nil {
		return updates.Version{}, fmt.Errorf("updater: %s: %w", bin, err)
	}
	return v, nil
}

// truncate bounds an error's echo of untrusted output.
func truncate(s string, n int) string {
	s = strings.TrimSpace(s)
	if len(s) <= n {
		return s
	}
	return s[:n] + "…"
}
+35 −0
Changes for internal/updater/gatherer_integration.go: 35 added lines, 0 removed lines.
Original line number Diff line number Diff line
package updater

import (
	"context"
	"os/exec"
)

// This file is INTEGRATION-ONLY. execRunner shells out to the real k3s / cloudflared
// binaries; it cannot run on a box without them and is never exercised by the unit
// tests — the extraction logic it feeds (gatherer.go) is proven with a fake
// commandRunner. This mirrors internal/cfsetup, which keeps its ExecRunner apart from
// its unit-verified core.

// execRunner is the production commandRunner: it runs the tool and returns combined
// output (banners sometimes print to stderr), bounded by the caller's context.
type execRunner struct{}

func (execRunner) output(ctx context.Context, name string, args ...string) ([]byte, error) {
	return exec.CommandContext(ctx, name, args...).CombinedOutput()
}

// NewSysGatherer builds the production VersionGatherer. The CLI seam (k3s / cloudflared
// `--version`) is wired and works on a real node. The felis-api pod-image read and the
// off-cluster Velocity jar inspection are the remaining integration seams; they are
// deliberately left nil, so those two components surface an explicit "gather seam not
// wired" error (which the Runner records and skips) rather than being planned against a
// wrong or zero version. Wiring them — a k8s client read of the control-plane
// Deployment's image, and however the operator exposes the proxy host — is the next
// integration step, tracked in doc.go. When the jar seam lands, revisit
// versionFromJarName's "-SNAPSHOT" handling: it reports a snapshot build as its stable
// core, which is harmless while this seam is nil and Velocity is Notify-only, but would
// suppress a legitimate "a stable is now out" notice once a real current flows.
func NewSysGatherer() VersionGatherer {
	return sysGatherer{run: execRunner{}}
}
+214 −0
Changes for internal/updater/gatherer_test.go: 214 added lines, 0 removed lines.
Original line number Diff line number Diff line
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")
	}
}
+3 −2
Changes for internal/updater/topology.go: 3 added lines, 2 removed lines.
Original line number Diff line number Diff line
@@ -47,8 +47,9 @@ type Spec struct {
// pin — there is no policy path by which Topology can propose changing it.
func Topology() []Spec {
	return []Spec{
		// Coord "felis/felis" is a placeholder for the operator's own release repo; the
		// GitHub source (which would consume it) is not yet wired, so it is inert today.
		// Coord "felis/felis" is a placeholder for the operator's own release repo: the
		// GitHub source now consumes it, so the routing/parse path is live, but it will
		// not resolve against real GitHub until the operator's actual repo slug is set.
		{Name: "felis-api", Policy: updates.PolicyScheduled, Manageable: true, Source: sourceGitHub, Coord: "felis/felis"},
		{Name: "k3s", Policy: updates.PolicyNotify, Manageable: false, Source: sourceGitHub, Coord: "k3s-io/k3s"},
		{Name: "cloudflared", Policy: updates.PolicyScheduled, Manageable: true, Source: sourceGitHub, Coord: "cloudflare/cloudflared"},