Files
Felis/internal/updater/gatherer.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

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] + "…"
}