feat(cli): report component updates and make the router a data table

Add `felis update`, which reports which platform components have newer
versions available, and route `felis version`, which shipped implemented but
unreachable.

That bug is why the subcommand router is now a map rather than a switch.
cmdVersion existed with nothing dispatching to it and no usage line, so
`felis version` fell through to "unknown command" and no test noticed — a
switch offers no way to enumerate what it routes, so the usage text and the
router could not be compared. As data, they can: a test now walks the
Commands: block and the table in both directions, failing an entry added to
one without the other. bootstrap-assets stays deliberately undocumented and
is listed as such, which makes its absence a decision rather than an
oversight.

The host gatherer answers the two seams NewSysGatherer leaves nil, for the
one caller that can satisfy them without a cluster client. felis-api is
answered from the running binary's own build stamp rather than the
Deployment's image tag: deploy/bootstrap.sh builds the image from the same
checkout it installs /usr/local/bin/felis from and stamps both with one git
describe, so it is the same artifact, and it is the identity `felis version`
reports. Reading the Deployment answers a slightly different question — what
is rolled out — and stays the right seam for the in-cluster path.

Velocity is read from the jar's own META-INF/MANIFEST.MF
Implementation-Version, which is what the proxy reports about itself at
runtime, because bootstrap installs the jar under a fixed name with no
version in it. The filename extractor remains only as a fallback for a
hand-placed velocity-3.5.1.jar.

An unstamped local build reports "dev" and is refused with an actionable
message rather than being treated as 0.0.0, which would make every release
upstream look like an upgrade. The panel and the plugin jars have no version
of their own on purpose: they are embedded in or built alongside the felis
binary, so the felis version is theirs.
This commit is contained in:
flyemoji committed 2026-07-20 14:32:58 +09:00
1 parent f36d5b87f6
commit 05cb8f6320
6 files changed
+725 -27

No files matched your search

+33 -27
View File
@@ -21,11 +21,39 @@ Commands:
manifests Render the control-plane RBAC + NetworkPolicy install bundle as YAML
apply Create a MinecraftServer CRD (direct K8s write; use -f server.json)
setup Run host bootstrap + first-run setup console (TUI; requires root/sudo)
version Print the build stamp of this binary
update Report which platform components have updates available
breakGlass Open the local break-glass emergency console (TUI; requires root/sudo)
Run "felis <command> -h" for command-specific flags.
`
// commands is the dispatch table. It is a map rather than a switch so the router's
// contents are DATA a test can compare against the usage text above: `version`
// shipped once as an implemented-but-unreachable command (cmdVersion existed with
// nothing routing to it), and a switch offers no way to notice that. Adding an entry
// here without documenting it in usage — or vice versa — now fails a test instead of
// shipping.
//
// The help aliases are deliberately NOT entries: they print usage rather than run a
// subcommand, and listing them would make the table disagree with the command list.
var commands = map[string]func(args []string, stdout, stderr io.Writer) int{
"migrate": cmdMigrate,
"operator": cmdOperator,
"api": cmdAPI,
"nano": cmdNano,
"reaper": cmdReaper,
"restore": cmdRestore,
"backup": cmdBackup,
"manifests": cmdManifests,
"apply": cmdApply,
"setup": cmdSetup,
"breakGlass": cmdBreakGlass,
"bootstrap-assets": cmdBootstrapAssets,
"version": cmdVersion,
"update": cmdUpdate,
}
// run dispatches a subcommand. It is separate from main so the router is
// testable without spawning a process.
func run(args []string, stdout, stderr io.Writer) int {
@@ -35,35 +63,13 @@ func run(args []string, stdout, stderr io.Writer) int {
}
cmd, rest := args[0], args[1:]
switch cmd {
case "migrate":
return cmdMigrate(rest, stdout, stderr)
case "operator":
return cmdOperator(rest, stdout, stderr)
case "api":
return cmdAPI(rest, stdout, stderr)
case "nano":
return cmdNano(rest, stdout, stderr)
case "reaper":
return cmdReaper(rest, stdout, stderr)
case "restore":
return cmdRestore(rest, stdout, stderr)
case "backup":
return cmdBackup(rest, stdout, stderr)
case "manifests":
return cmdManifests(rest, stdout, stderr)
case "apply":
return cmdApply(rest, stdout, stderr)
case "setup":
return cmdSetup(rest, stdout, stderr)
case "breakGlass":
return cmdBreakGlass(rest, stdout, stderr)
case "bootstrap-assets":
return cmdBootstrapAssets(rest, stdout, stderr)
case "-h", "--help", "help":
fmt.Fprint(stdout, usage)
return 0
default:
fmt.Fprintf(stderr, "felis: unknown command %q\n\n%s", cmd, usage)
return 2
}
if fn, ok := commands[cmd]; ok {
return fn(rest, stdout, stderr)
}
fmt.Fprintf(stderr, "felis: unknown command %q\n\n%s", cmd, usage)
return 2
}
+51
View File
@@ -37,6 +37,57 @@ func TestRunUnknownCommand(t *testing.T) {
}
}
// undocumentedCommands are routable on purpose but kept out of the usage text: they
// are called by deploy/bootstrap.sh, not by a human at a prompt. Listing them here is
// what makes their absence from usage a deliberate decision rather than an oversight.
var undocumentedCommands = map[string]bool{"bootstrap-assets": true}
// The usage text and the dispatch table must describe the same set of commands.
//
// This exists because the failure it catches already happened: `version` shipped
// implemented but unreachable — cmdVersion existed with nothing routing to it and no
// usage line — so `felis version` fell through to "unknown command", and no test
// noticed. Comparing the two lists is only possible because the router is a map; a
// switch cannot be enumerated.
//
// It compares names WITHOUT invoking anything. Running each command to see whether it
// is routed would start servers, dial clusters, and (for `update`) hit the network —
// a slow, flaky test of the wrong thing.
func TestUsageAndDispatchTableAgree(t *testing.T) {
documented := map[string]bool{}
var inCommands bool
for line := range strings.SplitSeq(usage, "\n") {
// Only the block under "Commands:" lists commands. The "Usage:" block above it
// has the same two-space indent but its entry is the "felis <command> [flags]"
// synopsis, which is not a subcommand.
if strings.HasPrefix(line, "Commands:") {
inCommands = true
continue
}
if !inCommands {
continue
}
// Command lines are the " <name> <description>" entries; the two-space indent
// distinguishes them from wrapped continuation lines.
if !strings.HasPrefix(line, " ") || strings.HasPrefix(line, " ") {
continue
}
name, _, ok := strings.Cut(strings.TrimSpace(line), " ")
if !ok || name == "" {
continue
}
documented[name] = true
if _, routed := commands[name]; !routed {
t.Errorf("usage advertises %q but the dispatch table has no entry for it", name)
}
}
for name := range commands {
if !documented[name] && !undocumentedCommands[name] {
t.Errorf("%q is routable but undocumented; add it to usage, or to undocumentedCommands if it is an internal entrypoint", name)
}
}
}
func TestRunApplyRequiresFileFlag(t *testing.T) {
// Without -f the command must fail with usage (2), not try to contact a
// cluster. It can't return 0 because no CRD was created, and it can't return 1
+257
View File
@@ -0,0 +1,257 @@
package main
import (
"context"
"flag"
"fmt"
"io"
"sort"
"strings"
"time"
"felis.lolicon.best/internal/updater"
"felis.lolicon.best/internal/updates"
)
// updateTimeout bounds the whole discovery pass. Each release source already caps
// its own HTTP client, but a hung DNS or a stalled TLS handshake would otherwise
// leave the operator staring at a silent terminal.
const updateTimeout = 60 * time.Second
// updateTarget maps a user-facing selector (`--panel`) onto the planner's component
// name and the command that actually performs the update.
//
// The apply side is deliberately NOT implemented in this command. Every component
// here is installed by deploy/bootstrap.sh, which is idempotent, already handles the
// parts that are easy to get wrong (Velocity's pinned MINOR, the atomic jar install,
// the k3s image re-import that a byte-identical StatefulSet template will not
// trigger on its own), and is the path that gets exercised on every install. A
// second installer living in this file would duplicate that policy, could drift from
// it silently, and would be reachable only on a live node where a mistake takes the
// proxy or the control plane down. So `felis update` reports, and hands the operator
// the tested command — it does not re-implement it.
type updateTarget struct {
// selector is the flag name without dashes.
selector string
// component is the updates planner's name for this piece, or "" when the planner
// deliberately does not track it (Minecraft, which is pinned).
component string
// note explains what this selector covers, printed above the command.
note string
// command is the exact, already-tested way to apply it.
command string
}
// updateTargets is the selector table. panel and plugins both resolve to felis-api
// because they are not separately versioned: the panel is compiled into the felis
// binary with //go:embed, and the plugin jars are built from this same repo in the
// same bootstrap run, so all three move together and carry one version.
var updateTargets = []updateTarget{
{
selector: "panel",
component: "felis-api",
note: "the panel is embedded in the felis binary (//go:embed), so updating it means rebuilding the felis image and rolling felis-api",
command: "sudo felis setup",
},
{
selector: "velocity",
component: "velocity",
note: "re-runs install_velocity: newest BUILD of the pinned minor (FELIS_VELOCITY_VERSION), atomic jar install, then restarts felis-velocity",
command: "sudo felis setup",
},
{
selector: "plugins",
component: "felis-api",
note: "felis-velocity.jar is a host-file swap, but felis-paper.jar and felis-limbo.jar are baked into the lobby/limbo images and need a rebuild + k3s image re-import",
command: "sudo felis setup",
},
{
selector: "mc",
component: "", // never tracked: see the pin note below
note: "Minecraft is pinned by policy (\"能不动的就别动\") and Felis never proposes a version change for it. A server's version is a property of that server's image — change it on the server, not through a platform update",
command: "",
},
}
// cmdUpdate reports what can be updated and what is already current.
//
// Bare `felis update` prints the status of every tracked component. Selector flags
// (--panel/--velocity/--mc/--plugins/--all) narrow that report to the components
// they name AND print how to apply each one. --force additionally prints the apply
// instruction for a selected component that is already up to date, for the
// reinstall/repair case.
//
// It never applies anything and never mutates the node, so unlike setup/breakGlass
// it needs no root. The versions it reads come from this host: k3s and cloudflared
// answer `--version`, Velocity's version is read out of the installed jar's
// manifest, and felis-api's is this binary's own build stamp — the same value
// `felis version` prints, which is what the user asked to be the source of truth.
func cmdUpdate(args []string, stdout, stderr io.Writer) int {
fs := flag.NewFlagSet("update", flag.ContinueOnError)
fs.SetOutput(stderr)
panel := fs.Bool("panel", false, "select the panel + control plane (felis-api)")
velocity := fs.Bool("velocity", false, "select the Velocity proxy")
mc := fs.Bool("mc", false, "select Minecraft (pinned; reported only)")
plugins := fs.Bool("plugins", false, "select the Felis plugin jars (velocity/paper/limbo)")
all := fs.Bool("all", false, "select every component above")
force := fs.Bool("force", false, "print the apply command for a selected component even when it is already up to date")
velocityJar := fs.String("velocity-jar", updater.DefaultVelocityJarPath, "path to the installed Velocity jar to read the current version from")
if err := fs.Parse(args); err != nil {
return 2
}
if fs.NArg() > 0 {
fmt.Fprintf(stderr, "felis update: unexpected argument %q (this command takes flags only)\n", fs.Arg(0))
return 2
}
selected := map[string]bool{}
for sel, on := range map[string]bool{"panel": *panel, "velocity": *velocity, "mc": *mc, "plugins": *plugins} {
if on || *all {
selected[sel] = true
}
}
ctx, cancel := context.WithTimeout(context.Background(), updateTimeout)
defer cancel()
rn := &updater.Runner{
Gatherer: updater.NewHostGatherer(resolvedVersion(), *velocityJar),
Source: updater.NewRoutingSource(updater.Topology()),
// Notifier and Applier stay nil on purpose: a human typing this command IS the
// notification, and nothing here applies. The zero Window below means every
// Scheduled component degrades to a notify, so the report can never claim an
// apply is under way.
}
res, err := rn.Run(ctx, time.Now(), updates.Window{})
if err != nil {
fmt.Fprintf(stderr, "felis update: %v\n", err)
return 1
}
fmt.Fprint(stdout, renderUpdateReport(res, selected))
if len(selected) > 0 {
fmt.Fprint(stdout, renderApplyGuidance(res, selected, *force))
}
return 0
}
// renderUpdateReport renders the component status table. With no selectors it shows
// every tracked component; with selectors it shows only the components those
// selectors name, so `felis update --velocity` is a focused answer rather than the
// whole platform. Components whose current version could not be read are listed
// separately rather than silently dropped — a component missing from the table with
// no explanation reads as "fine", which is the one thing it is not.
func renderUpdateReport(res updater.Result, selected map[string]bool) string {
plan := res.RunResult.Plan
if len(selected) > 0 {
want := map[string]bool{}
for _, t := range updateTargets {
if selected[t.selector] && t.component != "" {
want[t.component] = true
}
}
filtered := make([]updates.Action, 0, len(plan))
for _, a := range plan {
if want[a.Component] {
filtered = append(filtered, a)
}
}
plan = filtered
}
var b strings.Builder
if len(plan) > 0 {
b.WriteString(updates.Report(plan))
}
// Two different failures both end up as a component the report cannot speak to,
// and both are printed rather than swallowed. A component that simply vanishes
// from the table reads as "fine", and a bare "latest unknown" line reads as "there
// is nothing newer" — when the truth may be that the release feed was unreachable.
// Naming the cause is the difference between a report and a guess.
writeErrs(&b, "current version unreadable", res.GatherErrors, selected)
writeErrs(&b, "latest version undiscoverable", res.RunResult.SourceErrors, selected)
// With selectors active an empty table is not a surprise — `--mc` names a component
// the planner deliberately does not track — and the guidance below already explains
// it, so stay quiet rather than printing a bare "nothing matched" that reads as an
// error. Without selectors an empty table means nothing is tracked at all, which
// does need saying.
if b.Len() == 0 && len(selected) == 0 {
return "No components tracked.\n"
}
return b.String()
}
// writeErrs appends one explanatory line per failed component, in a stable order so
// the output does not shuffle between runs, honouring the active selector filter.
func writeErrs(b *strings.Builder, label string, errs map[string]error, selected map[string]bool) {
names := make([]string, 0, len(errs))
for name := range errs {
names = append(names, name)
}
sort.Strings(names)
for _, name := range names {
if len(selected) > 0 && !selectedCovers(selected, name) {
continue
}
fmt.Fprintf(b, "%-13s %s: %v\n", name, label, errs[name])
}
}
// selectedCovers reports whether any chosen selector maps to this component.
func selectedCovers(selected map[string]bool, component string) bool {
for _, t := range updateTargets {
if selected[t.selector] && t.component == component {
return true
}
}
return false
}
// renderApplyGuidance prints, for each selected target, how to actually apply the
// update. A target that is already current is skipped unless --force was passed.
func renderApplyGuidance(res updater.Result, selected map[string]bool, force bool) string {
byComponent := map[string]updates.Action{}
for _, a := range res.RunResult.Plan {
byComponent[a.Component] = a
}
var b strings.Builder
var offeredCommand bool
for _, t := range updateTargets {
if !selected[t.selector] {
continue
}
// Minecraft has no planner entry by design; state the pin and move on. There is
// no command to offer, so this must not arm the trailer below.
if t.component == "" {
fmt.Fprintf(&b, "\n--%s: %s.\n", t.selector, t.note)
continue
}
a, planned := byComponent[t.component]
// "Up to date" requires actually KNOWING the latest version. ActionNone covers
// both "nothing newer exists" and "the release feed could not be read", and
// collapsing those would report an unreachable upstream as currency — telling an
// operator they are current when nobody checked is the one answer an update tool
// must never give. LatestKnown is what separates them.
if planned && a.Kind == updates.ActionNone && a.LatestKnown && !force {
fmt.Fprintf(&b, "\n--%s: %s is already up to date; nothing to apply (use --force to reinstall anyway).\n", t.selector, t.component)
continue
}
fmt.Fprintf(&b, "\n--%s: %s\n", t.selector, t.note)
if planned && !a.LatestKnown {
// Unknown latest still offers the command: the operator asked about this
// component, and reinstalling the current release is a valid repair action.
fmt.Fprintf(&b, " note: cannot tell whether %s is current — its latest version could not be discovered (see above); this reinstalls it either way\n", t.component)
}
fmt.Fprintf(&b, " run: %s\n", t.command)
offeredCommand = true
}
// Only explain the command when one was actually offered; a --mc-only run has
// nothing to run and the trailer would be a non-sequitur.
if offeredCommand {
b.WriteString("\nfelis setup is idempotent and re-runs the installer that owns these components;\nit does not reinstall what is already current. Restart game servers afterwards.\n")
}
return b.String()
}
+134
View File
@@ -0,0 +1,134 @@
package main
import (
"errors"
"strings"
"testing"
"felis.lolicon.best/internal/updater"
"felis.lolicon.best/internal/updates"
)
// planResult builds a Result carrying the given plan, as updater.Runner would.
func planResult(plan []updates.Action) updater.Result {
return updater.Result{
RunResult: updates.RunResult{Plan: plan, SourceErrors: map[string]error{}},
GatherErrors: map[string]error{},
}
}
func TestUpdateReportFiltersToSelection(t *testing.T) {
res := planResult([]updates.Action{
{Component: "felis-api", Kind: updates.ActionNotify},
{Component: "velocity", Kind: updates.ActionNone, LatestKnown: true},
{Component: "k3s", Kind: updates.ActionNotify},
})
all := renderUpdateReport(res, nil)
for _, want := range []string{"felis-api", "velocity", "k3s"} {
if !strings.Contains(all, want) {
t.Fatalf("bare report missing %q:\n%s", want, all)
}
}
// --velocity must answer about velocity only; leaking k3s into a focused query is
// the whole reason the selector exists.
only := renderUpdateReport(res, map[string]bool{"velocity": true})
if !strings.Contains(only, "velocity") {
t.Fatalf("selected report missing velocity:\n%s", only)
}
if strings.Contains(only, "k3s") || strings.Contains(only, "felis-api") {
t.Fatalf("selected report leaked unselected components:\n%s", only)
}
}
// A component that cannot be read must never vanish silently, and "latest unknown"
// must never stand in for "the release feed was unreachable" — both causes are named.
func TestUpdateReportNamesBothFailureCauses(t *testing.T) {
res := planResult(nil)
res.GatherErrors["velocity"] = errors.New("jar missing")
res.RunResult.SourceErrors["felis-api"] = errors.New("HTTP 404")
out := renderUpdateReport(res, nil)
if !strings.Contains(out, "current version unreadable") || !strings.Contains(out, "jar missing") {
t.Fatalf("gather failure not explained:\n%s", out)
}
if !strings.Contains(out, "latest version undiscoverable") || !strings.Contains(out, "HTTP 404") {
t.Fatalf("source failure not explained:\n%s", out)
}
}
func TestApplyGuidanceUpToDateNeedsForce(t *testing.T) {
res := planResult([]updates.Action{{Component: "velocity", Kind: updates.ActionNone, LatestKnown: true}})
sel := map[string]bool{"velocity": true}
quiet := renderApplyGuidance(res, sel, false)
if !strings.Contains(quiet, "already up to date") {
t.Fatalf("want an up-to-date notice:\n%s", quiet)
}
if strings.Contains(quiet, "run:") {
t.Fatalf("must not offer a command for an up-to-date component without --force:\n%s", quiet)
}
forced := renderApplyGuidance(res, sel, true)
if !strings.Contains(forced, "run:") {
t.Fatalf("--force must offer the reinstall command:\n%s", forced)
}
}
// An undiscoverable latest version must never be reported as "up to date". Both
// states arrive as ActionNone and only LatestKnown separates them, so this is a live
// confusion, not a hypothetical one — it shipped that way until a smoke test showed
// `--panel` calling felis-api current right after the release feed returned 404.
func TestApplyGuidanceUnknownLatestIsNotUpToDate(t *testing.T) {
res := planResult([]updates.Action{{Component: "velocity", Kind: updates.ActionNone, LatestKnown: false}})
out := renderApplyGuidance(res, map[string]bool{"velocity": true}, false)
if strings.Contains(out, "already up to date") {
t.Fatalf("must not claim currency when the latest version is unknown:\n%s", out)
}
if !strings.Contains(out, "cannot tell") {
t.Fatalf("want the uncertainty stated plainly:\n%s", out)
}
// One header per selector: the uncertainty is a note under it, not a second block.
if n := strings.Count(out, "--velocity:"); n != 1 {
t.Fatalf("want exactly 1 selector header, got %d:\n%s", n, out)
}
// The operator asked about this component, so the repair command still belongs.
if !strings.Contains(out, "run:") {
t.Fatalf("want the reinstall command offered despite the unknown latest:\n%s", out)
}
}
// Minecraft is pinned and has no planner entry, so --mc explains the pin and offers
// NO command — and therefore must not print the trailer that explains the command.
func TestApplyGuidanceMinecraftOffersNoCommand(t *testing.T) {
out := renderApplyGuidance(planResult(nil), map[string]bool{"mc": true}, true)
if !strings.Contains(out, "pinned by policy") {
t.Fatalf("want the pin explained:\n%s", out)
}
if strings.Contains(out, "run:") || strings.Contains(out, "felis setup is idempotent") {
t.Fatalf("--mc must offer no command and no command trailer:\n%s", out)
}
}
// Every selector in the table must be a real flag on the FlagSet, and every
// planner-backed selector must name a component the topology actually tracks —
// otherwise a selector silently matches nothing at runtime.
func TestUpdateTargetsMatchTopology(t *testing.T) {
tracked := map[string]bool{}
for _, s := range updater.Topology() {
tracked[s.Name] = true
}
for _, target := range updateTargets {
if target.component == "" {
continue // deliberately untracked (Minecraft is pinned)
}
if !tracked[target.component] {
t.Fatalf("selector --%s maps to %q, which Topology() does not track", target.selector, target.component)
}
if target.command == "" {
t.Fatalf("selector --%s is planner-backed but offers no apply command", target.selector)
}
}
}
+144
View File
@@ -0,0 +1,144 @@
package updater
import (
"archive/zip"
"bufio"
"context"
"fmt"
"io"
"path/filepath"
"strings"
"felis.lolicon.best/internal/updates"
)
// This file wires the two gather seams NewSysGatherer deliberately leaves nil, for
// the one caller that can satisfy them without a cluster client: the on-host `felis
// update` CLI. Both reads are answered from the node the command runs on, which is
// exactly where bootstrap.sh installed the things being read.
//
// felis-api is answered from the RUNNING BINARY's own build stamp rather than from
// the control-plane Deployment's image tag. That is not a shortcut around the k8s
// read — it is the identity the user named ("当前版本号通过 felis version 查询"), and
// it is the same artifact: deploy/bootstrap.sh builds the felis image from the same
// checkout it installs /usr/local/bin/felis from, stamping both with one `git
// describe`. Reading the Deployment would answer a slightly different question (what
// is rolled out) and is still the right seam for the in-cluster CronJob path; it is
// left to NewSysGatherer, which keeps returning "not wired" there.
//
// The panel has no version of its own on purpose: it is compiled into the felis
// binary with //go:embed, so "the panel version" IS the felis version. The same is
// true of the plugin jars, which are built from this repo in the same bootstrap run.
// That is why the CLI's --panel and --plugins selectors both resolve here.
// DefaultVelocityJarPath is where deploy/bootstrap.sh installs the proxy. The jar is
// installed under a FIXED name with no version in it (install_velocity does
// `atomic_install_file "$tmp" "${VELOCITY_DIR}/velocity.jar"`), which is why the
// filename extractor alone cannot answer for Velocity and the manifest read below
// exists.
const DefaultVelocityJarPath = "/opt/felis/velocity/velocity.jar"
// NewHostGatherer builds the VersionGatherer for `felis update` running on the node.
// felisVersion is the CLI's own resolved build stamp; velocityJar is the installed
// proxy jar (empty means DefaultVelocityJarPath). k3s and cloudflared keep the
// existing exec seam — they are real binaries on this host and already answer.
func NewHostGatherer(felisVersion, velocityJar string) VersionGatherer {
if velocityJar == "" {
velocityJar = DefaultVelocityJarPath
}
return hostGatherer{
sys: sysGatherer{run: execRunner{}},
felisVersion: felisVersion,
velocityJar: velocityJar,
}
}
// hostGatherer answers the two seams it can satisfy from the local filesystem and
// delegates every other component to the standard system gatherer, so the CLI and
// the (future) in-cluster runner share one dispatch table and cannot drift.
type hostGatherer struct {
sys sysGatherer
felisVersion string
velocityJar string
}
// Current implements VersionGatherer.
func (g hostGatherer) Current(ctx context.Context, spec Spec) (updates.Version, error) {
switch spec.Name {
case "felis-api":
// An unstamped local `go build` reports "dev", which is not a version. Fail
// closed with an actionable message rather than inventing a 0.0.0 that would
// make every release upstream look like an upgrade.
v, err := updates.Parse(g.felisVersion)
if err != nil {
return updates.Version{}, fmt.Errorf("updater: felis build stamp %q is not a version (an unstamped build cannot be compared): %w", g.felisVersion, err)
}
return v, nil
case "velocity":
return velocityJarVersion(g.velocityJar)
default:
return g.sys.Current(ctx, spec)
}
}
// velocityJarVersion reads the installed proxy's version out of the jar itself.
//
// It reads META-INF/MANIFEST.MF's Implementation-Version, which is authoritative
// rather than incidental: Velocity reports its own version at runtime from that
// attribute (getImplementationVersion), so the value here is the same string the
// proxy prints about itself. The filename is only a fallback, and on a Felis node it
// is expected to fail — bootstrap installs the jar as a fixed "velocity.jar" — so it
// exists for the hand-placed "velocity-3.5.1.jar" case, not the normal one.
func velocityJarVersion(path string) (updates.Version, error) {
zr, err := zip.OpenReader(path)
if err != nil {
return updates.Version{}, fmt.Errorf("updater: open velocity jar %s: %w", path, err)
}
defer zr.Close()
raw, err := manifestAttr(&zr.Reader, "Implementation-Version")
if err == nil {
v, perr := updates.Parse(raw)
if perr == nil {
return v, nil
}
err = perr
}
// Fall back to the filename before surfacing the manifest failure, so a jar whose
// manifest is missing or unparseable still answers when the name carries a version.
if v, ferr := versionFromJarName(filepath.Base(path)); ferr == nil {
return v, nil
}
return updates.Version{}, fmt.Errorf("updater: no version in %s manifest or filename: %w", path, err)
}
// manifestAttr returns one attribute value from a jar's META-INF/MANIFEST.MF.
//
// ponytail: this does not implement the JAR spec's 72-byte line folding (a wrapped
// value continues on the next line after a single leading space). Version values are
// far short of the wrap point, so folding cannot bite here; if this ever reads a long
// attribute, join continuation lines before splitting on ':'.
func manifestAttr(zr *zip.Reader, key string) (string, error) {
f, err := zr.Open("META-INF/MANIFEST.MF")
if err != nil {
return "", fmt.Errorf("updater: jar has no manifest: %w", err)
}
defer f.Close()
// Bound the read: a manifest is a few KiB, and this parses a file that arrived
// over the network in an earlier bootstrap run.
sc := bufio.NewScanner(io.LimitReader(f, 256<<10))
for sc.Scan() {
name, value, ok := strings.Cut(sc.Text(), ":")
if !ok || !strings.EqualFold(strings.TrimSpace(name), key) {
continue
}
if v := strings.TrimSpace(value); v != "" {
return v, nil
}
}
if err := sc.Err(); err != nil {
return "", fmt.Errorf("updater: read manifest: %w", err)
}
return "", fmt.Errorf("updater: manifest has no %s", key)
}
+106
View File
@@ -0,0 +1,106 @@
package updater
import (
"archive/zip"
"context"
"os"
"path/filepath"
"testing"
"felis.lolicon.best/internal/updates"
)
// writeJar builds a minimal jar (a zip) at path. A nil manifest writes no
// META-INF/MANIFEST.MF at all, which is the "unreadable manifest" case.
func writeJar(t *testing.T, path string, manifest []byte) {
t.Helper()
f, err := os.Create(path)
if err != nil {
t.Fatalf("create jar: %v", err)
}
defer f.Close()
zw := zip.NewWriter(f)
if manifest != nil {
w, err := zw.Create("META-INF/MANIFEST.MF")
if err != nil {
t.Fatalf("create manifest entry: %v", err)
}
if _, err := w.Write(manifest); err != nil {
t.Fatalf("write manifest: %v", err)
}
}
if err := zw.Close(); err != nil {
t.Fatalf("close jar: %v", err)
}
}
// The manifest read is the whole reason this seam can answer at all: bootstrap
// installs the proxy as a fixed "velocity.jar", so the filename carries no version
// and versionFromJarName alone would always fail on a real Felis node.
func TestVelocityJarVersionReadsManifest(t *testing.T) {
dir := t.TempDir()
jar := filepath.Join(dir, "velocity.jar")
writeJar(t, jar, []byte("Manifest-Version: 1.0\r\nImplementation-Version: 3.5.1\r\nMain-Class: com.velocitypowered.proxy.Velocity\r\n\r\n"))
got, err := velocityJarVersion(jar)
if err != nil {
t.Fatalf("velocityJarVersion: %v", err)
}
if got.String() != "3.5.1" {
t.Fatalf("version = %q, want 3.5.1", got.String())
}
}
// A jar with no usable manifest still answers when the operator hand-placed a
// versioned filename — the fallback path.
func TestVelocityJarVersionFallsBackToFilename(t *testing.T) {
dir := t.TempDir()
jar := filepath.Join(dir, "velocity-3.4.0.jar")
writeJar(t, jar, nil)
got, err := velocityJarVersion(jar)
if err != nil {
t.Fatalf("velocityJarVersion: %v", err)
}
if got.String() != "3.4.0" {
t.Fatalf("version = %q, want 3.4.0", got.String())
}
}
// Fails closed: neither source carries a version, so this must error rather than
// report a zero version that would make every upstream release look like an upgrade.
func TestVelocityJarVersionFailsClosed(t *testing.T) {
dir := t.TempDir()
jar := filepath.Join(dir, "velocity.jar")
writeJar(t, jar, []byte("Manifest-Version: 1.0\r\n\r\n"))
if _, err := velocityJarVersion(jar); err == nil {
t.Fatal("want an error when neither the manifest nor the filename carries a version")
}
if _, err := velocityJarVersion(filepath.Join(dir, "absent.jar")); err == nil {
t.Fatal("want an error for a missing jar")
}
}
// An unstamped `go build` reports "dev". That must be a loud gather failure, not a
// silent 0.0.0 — a zero current would make every release upstream a false "upgrade".
func TestHostGathererRejectsUnstampedBuild(t *testing.T) {
g := NewHostGatherer("dev", filepath.Join(t.TempDir(), "velocity.jar"))
if _, err := g.Current(context.Background(), Spec{Name: "felis-api"}); err == nil {
t.Fatal("want an error for an unstamped build stamp")
}
}
// The felis binary's own stamp answers for felis-api: bootstrap builds the image and
// the host binary from one checkout with one `git describe`, so they are the same
// artifact. A release stamp must round-trip into a comparable version.
func TestHostGathererUsesOwnBuildStamp(t *testing.T) {
g := NewHostGatherer("v1.2.3", filepath.Join(t.TempDir(), "velocity.jar"))
got, err := g.Current(context.Background(), Spec{Name: "felis-api"})
if err != nil {
t.Fatalf("Current: %v", err)
}
if want := (updates.Version{Major: 1, Minor: 2, Patch: 3}); got.Compare(want) != 0 {
t.Fatalf("version = %s, want 1.2.3", got)
}
}