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.
This commit is contained in:
5 files changed
+462
-12
No files matched your search
+16
-10
@@ -29,15 +29,21 @@
|
|||||||
// routing/parse logic is verified, but that one component stays dark at runtime (its
|
// 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.
|
// Latest errors, degrading to "latest unknown") until a real repository is configured.
|
||||||
//
|
//
|
||||||
// - NOT YET BUILT, but VERIFIABLE HERE (the next slice): the VersionGatherer's
|
// - ALSO BUILT + UNIT-VERIFIED: the VersionGatherer's extraction core and dispatch.
|
||||||
// extraction core — command output (`k3s --version`), image tag
|
// Three pure extractors turn raw system text into a Version — a `--version` banner
|
||||||
// (`rancher/k3s:v1.36.2-k3s1`) or jar filename → Version. That is logic over an
|
// (k3s, cloudflared), a container image tag (felis-api), a proxy jar filename
|
||||||
// exec/read seam, testable with a fake runner à la internal/reaper's ExecRunner,
|
// (velocity) — and sysGatherer routes each component to the right one over an
|
||||||
// and load-bearing: a mis-read current version makes every plan wrong (spurious
|
// injected seam, all exercised with a fake runner (gatherer_test.go). The
|
||||||
// applies or missed upgrades). Only the seam's actual I/O is un-verifiable here.
|
// 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):
|
// - REMAINING INTEGRATION (genuinely I/O-bound — needs a node/cluster/mailbox): the
|
||||||
// the concrete Notifier (SMTP + in-game) and Applier (control-plane image bump,
|
// two current-version PRODUCING seams the gatherer still lacks — the k8s read of the
|
||||||
// cloudflared swap), the `felis update` CLI + CronJob entry point, and the runtime
|
// control-plane Deployment's image (felis-api) and the off-cluster Velocity jar
|
||||||
// append of the live Pinned Minecraft fleet.
|
// 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
|
package updater
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
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] + "…"
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
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{}}
|
||||||
|
}
|
||||||
@@ -0,0 +1,214 @@
|
|||||||
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -47,8 +47,9 @@ type Spec struct {
|
|||||||
// pin — there is no policy path by which Topology can propose changing it.
|
// pin — there is no policy path by which Topology can propose changing it.
|
||||||
func Topology() []Spec {
|
func Topology() []Spec {
|
||||||
return []Spec{
|
return []Spec{
|
||||||
// Coord "felis/felis" is a placeholder for the operator's own release repo; the
|
// 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.
|
// 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: "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: "k3s", Policy: updates.PolicyNotify, Manageable: false, Source: sourceGitHub, Coord: "k3s-io/k3s"},
|
||||||
{Name: "cloudflared", Policy: updates.PolicyScheduled, Manageable: true, Source: sourceGitHub, Coord: "cloudflare/cloudflared"},
|
{Name: "cloudflared", Policy: updates.PolicyScheduled, Manageable: true, Source: sourceGitHub, Coord: "cloudflare/cloudflared"},
|
||||||
|
|||||||
Reference in new issue
Block a user