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.
195 lines
8.3 KiB
Go
195 lines
8.3 KiB
Go
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] + "…"
|
|
}
|