diff --git a/cmd/felis/update.go b/cmd/felis/update.go index 808837e..36d96d9 100644 --- a/cmd/felis/update.go +++ b/cmd/felis/update.go @@ -34,6 +34,8 @@ const updateTimeout = 60 * time.Second type updateTarget struct { // selector is the flag name without dashes. selector string + // help is the flag's usage line. + help 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 @@ -41,9 +43,11 @@ type updateTarget struct { note string // command is the exact, already-tested way to apply it. command string + // installer marks a command that re-runs the installer, which the trailer explains. + installer bool } -// installerRerun is the tested apply path for every planner-backed selector: re-run the +// installerRerun is the tested apply path for every selector Felis installs: re-run the // installer. It is idempotent, and it is the only path that fetches a newer version -- // `felis setup` skips its host-bootstrap phase on a completed install (all four install // markers already exist), so there it opens the config console and moves no component, @@ -58,6 +62,10 @@ type updateTarget struct { // below points at the README's token'd form for that case. const installerRerun = "curl -fsSL https://raw.githubusercontent.com/FelisMC/Felis/{ref}/deploy/bootstrap.sh | sudo bash" +// installerRerunDeps is the same re-run with FELIS_UPGRADE_DEPS=1, which lets it move an +// installed k3s and cloudflared to the versions the release pins. +const installerRerunDeps = "curl -fsSL https://raw.githubusercontent.com/FelisMC/Felis/{ref}/deploy/bootstrap.sh | sudo FELIS_UPGRADE_DEPS=1 bash" + // 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 @@ -65,24 +73,62 @@ const installerRerun = "curl -fsSL https://raw.githubusercontent.com/FelisMC/Fel var updateTargets = []updateTarget{ { selector: "panel", + help: "select the panel + control plane (felis-api)", 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: installerRerun, + installer: true, }, { selector: "velocity", + help: "select the Velocity proxy", component: "velocity", note: "re-runs install_velocity: the build the release pins in deploy/game-stack.lock (FELIS_VELOCITY_VERSION= takes that minor's newest build instead), sha256-checked, atomic jar install, then restarts felis-velocity only if the jar or its config changed", command: installerRerun, + installer: true, }, { selector: "plugins", + help: "select the Felis plugin jars (velocity/paper/limbo)", 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 + re-mirror into the in-cluster registry (the installer re-run does both)", command: installerRerun, + installer: true, + }, + { + selector: "k3s", + help: "select k3s", + component: "k3s", + note: "FELIS_UPGRADE_DEPS=1 moves k3s to the version the Felis release pins, which can trail the newest upstream; it moves one minor version at a time and refuses a larger jump. Running game servers keep running while k3s restarts", + command: installerRerunDeps, + installer: true, + }, + { + selector: "cloudflared", + help: "select cloudflared", + component: "cloudflared", + note: "FELIS_UPGRADE_DEPS=1 swaps the binary for the sha256-pinned build the Felis release names and restarts cloudflared-felis; the panel's tunnel drops for a few seconds", + command: installerRerunDeps, + installer: true, + }, + { + selector: "jre", + help: "select the Temurin JRE Velocity runs on", + component: "jre", + note: "the installer installs the Temurin build the Felis release pins (sha256-checked) and restarts felis-velocity when it changed; a newer upstream build reaches the host with a release that pins it", + command: installerRerun, + installer: true, + }, + { + selector: "postgres", + help: "select PostgreSQL", + component: "postgresql", + note: "PostgreSQL comes from the distribution's packages, so a minor release is a package update followed by a restart (a few seconds without the API). A new major needs pg_upgrade first: docs/operations.md §4", + command: "sudo dnf upgrade 'postgresql*' || sudo apt-get install --only-upgrade 'postgresql*'; sudo systemctl restart postgresql", }, { selector: "mc", + help: "select Minecraft (pinned; reported only)", 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: "", @@ -92,23 +138,24 @@ var updateTargets = []updateTarget{ // 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 +// (--panel/--velocity/--plugins/--k3s/--cloudflared/--jre/--postgres/--mc/--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. +// it needs no root. The versions it reads come from this host: k3s, cloudflared and +// PostgreSQL answer `--version`, Velocity's version is read out of the installed jar's +// manifest, the JRE's out of its release file, 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)") + flags := map[string]*bool{} + for _, t := range updateTargets { + flags[t.selector] = fs.Bool(t.selector, false, t.help) + } 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") @@ -121,8 +168,8 @@ func cmdUpdate(args []string, stdout, stderr io.Writer) int { } selected := map[string]bool{} - for sel, on := range map[string]bool{"panel": *panel, "velocity": *velocity, "mc": *mc, "plugins": *plugins} { - if on || *all { + for sel, on := range flags { + if *on || *all { selected[sel] = true } } @@ -130,9 +177,10 @@ func cmdUpdate(args []string, stdout, stderr io.Writer) int { ctx, cancel := context.WithTimeout(context.Background(), updateTimeout) defer cancel() + src := updater.NewRoutingSource(updater.Topology()) rn := &updater.Runner{ Gatherer: updater.NewHostGatherer(resolvedVersion(), *velocityJar), - Source: updater.NewRoutingSource(updater.Topology()), + Source: src, // 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 @@ -145,6 +193,7 @@ func cmdUpdate(args []string, stdout, stderr io.Writer) int { } fmt.Fprint(stdout, renderUpdateReport(res, selected)) + fmt.Fprint(stdout, renderNotes(src.Notes(), selected)) if len(selected) > 0 { fmt.Fprint(stdout, renderApplyGuidance(res, selected, *force)) } @@ -199,6 +248,23 @@ func renderUpdateReport(res updater.Result, selected map[string]bool) string { return b.String() } +// renderNotes prints what the release lookups learned beyond the versions (today: a +// PostgreSQL major past its end of life), for the components the selectors show. +func renderNotes(notes map[string]string, selected map[string]bool) string { + names := make([]string, 0, len(notes)) + for name := range notes { + if len(selected) == 0 || selectedCovers(selected, name) { + names = append(names, name) + } + } + sort.Strings(names) + var b strings.Builder + for _, name := range names { + fmt.Fprintf(&b, "%-13s note: %s\n", name, notes[name]) + } + 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) { @@ -234,7 +300,7 @@ func renderApplyGuidance(res updater.Result, selected map[string]bool, force boo } var b strings.Builder - var offeredCommand bool + var offeredInstaller bool for _, t := range updateTargets { if !selected[t.selector] { continue @@ -262,7 +328,7 @@ func renderApplyGuidance(res updater.Result, selected map[string]bool, force boo 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", strings.ReplaceAll(t.command, "{ref}", installerRef(byComponent))) - offeredCommand = true + offeredInstaller = offeredInstaller || t.installer } // 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. @@ -270,13 +336,13 @@ func renderApplyGuidance(res updater.Result, selected map[string]bool, force boo // One trailer serves every selector now: setup is not an apply path at all on a // completed install (shouldRunHostBootstrapBeforeConfig only enters the host // bootstrap while an install marker is missing), so the installer re-run is the one - // worked path for all three components and there is no per-component exception left + // worked path for every component Felis installs and there is no per-component exception left // to scope. Two caveats stay because following the advice without them bites real // hosts: the channel is not persisted anywhere (a bare re-run on a main host quietly // moves it onto releases), and the private repo's one-liner needs the read token // back in the environment before it can resolve anything. - if offeredCommand { - b.WriteString("\nRe-running the installer applies everything above: it fetches the newest version on\nthe channel in effect and re-applies the bundle (release is the default). The channel\nis not persisted, so pass FELIS_VERSION_BOOTSTRAP=dev if this host tracks main. While\nthis repo is private, the one-liner above 404s without a token; the README's install\nsection has the token'd form that works. felis setup is not this path: on a completed\ninstall it opens the config console and installs nothing newer. Restart game servers\nafterwards.\n") + if offeredInstaller { + b.WriteString("\nRe-running the installer applies each installer command above: it fetches the newest version on\nthe channel in effect and re-applies the bundle (release is the default). The channel\nis not persisted, so pass FELIS_VERSION_BOOTSTRAP=dev if this host tracks main. While\nthis repo is private, the one-liner above 404s without a token; the README's install\nsection has the token'd form that works. felis setup is not this path: on a completed\ninstall it opens the config console and installs nothing newer. Restart game servers\nafterwards.\n") } return b.String() } @@ -299,10 +365,11 @@ func installerRef(byComponent map[string]updates.Action) string { } // isReleaseTag reports whether v was read from a stable vX.Y.Z tag, the only refs -// release.yml publishes a binary for. +// release.yml publishes a binary for. A source build stamps v0.0.0+g, which +// names no tag, so build metadata disqualifies a version too. func isReleaseTag(v updates.Version) bool { s := v.String() - if !strings.HasPrefix(s, "v") || v.IsPrerelease() { + if !strings.HasPrefix(s, "v") || v.IsPrerelease() || strings.Contains(s, "+") { return false } _, err := updates.Parse(s) diff --git a/cmd/felis/update_test.go b/cmd/felis/update_test.go index b077deb..cf7f9b4 100644 --- a/cmd/felis/update_test.go +++ b/cmd/felis/update_test.go @@ -199,3 +199,83 @@ func TestApplyGuidanceReadsTheInstallerAtTheReleaseTag(t *testing.T) { } } } + +// Every selector in the table is a flag: the FlagSet is built from the table. +func TestUpdateSelectorsAreFlags(t *testing.T) { + var out, errb strings.Builder + if code := cmdUpdate([]string{"-h"}, &out, &errb); code != 2 { + t.Fatalf("-h exit = %d, want 2", code) + } + for _, target := range updateTargets { + if !strings.Contains(errb.String(), "-"+target.selector+"\n") { + t.Errorf("usage has no -%s flag:\n%s", target.selector, errb.String()) + } + } +} + +// k3s and cloudflared move only when the re-run is told to; PostgreSQL is the package +// manager's, so its guidance carries no installer trailer. +func TestApplyGuidanceForHostDependencies(t *testing.T) { + notify := func(c string) updater.Result { + return planResult([]updates.Action{{Component: c, Kind: updates.ActionNotify, LatestKnown: true}}) + } + for _, sel := range []string{"k3s", "cloudflared"} { + out := renderApplyGuidance(notify(sel), map[string]bool{sel: true}, false) + if !strings.Contains(out, "sudo FELIS_UPGRADE_DEPS=1 bash") || !strings.Contains(out, "Re-running the installer") { + t.Errorf("--%s guidance must re-run the installer with FELIS_UPGRADE_DEPS=1:\n%s", sel, out) + } + } + jre := renderApplyGuidance(notify("jre"), map[string]bool{"jre": true}, false) + if !strings.Contains(jre, "| sudo bash") || strings.Contains(jre, "FELIS_UPGRADE_DEPS") { + t.Errorf("--jre guidance is the plain installer re-run:\n%s", jre) + } + pg := renderApplyGuidance(notify("postgresql"), map[string]bool{"postgres": true}, false) + if !strings.Contains(pg, "apt-get install --only-upgrade") || strings.Contains(pg, "Re-running the installer") { + t.Errorf("--postgres guidance is the package manager, without the installer trailer:\n%s", pg) + } +} + +func TestRenderNotesHonoursSelectors(t *testing.T) { + notes := map[string]string{"postgresql": "PostgreSQL 13 reached end of life on 2025-11-13"} + if out := renderNotes(notes, nil); !strings.Contains(out, "postgresql") || !strings.Contains(out, "note: PostgreSQL 13 reached end of life") { + t.Errorf("unfiltered notes = %q", out) + } + if out := renderNotes(notes, map[string]bool{"postgres": true}); !strings.Contains(out, "end of life") { + t.Errorf("--postgres must show its note, got %q", out) + } + if out := renderNotes(notes, map[string]bool{"velocity": true}); out != "" { + t.Errorf("--velocity must not show the postgresql note, got %q", out) + } +} + +// A source build's v0.0.0+g names no tag, so the installer one-liner has to +// fall back to main instead of a 404ing ref. +func TestInstallerRefNamesATag(t *testing.T) { + v := func(s string) updates.Version { + t.Helper() + x, err := updates.Parse(s) + if err != nil { + t.Fatal(err) + } + return x + } + cases := []struct { + name string + api updates.Action + want string + }{ + {"newest release", updates.Action{Current: v("v1.2.0"), Latest: v("v1.3.0"), LatestKnown: true}, "v1.3.0"}, + {"feed down, host on a release", updates.Action{Current: v("v1.2.0")}, "v1.2.0"}, + {"source build", updates.Action{Current: v("v0.0.0+gunknown")}, "main"}, + {"source build with commit", updates.Action{Current: v("v0.0.0+g1a2b3c4")}, "main"}, + {"prerelease", updates.Action{Current: v("v1.3.0-rc.1")}, "main"}, + } + for _, c := range cases { + if got := installerRef(map[string]updates.Action{"felis-api": c.api}); got != c.want { + t.Errorf("%s: installerRef = %q, want %q", c.name, got, c.want) + } + } + if got := installerRef(nil); got != "main" { + t.Errorf("no felis-api row: installerRef = %q, want main", got) + } +} diff --git a/deploy/bootstrap.sh b/deploy/bootstrap.sh index 8514d13..9bfe772 100644 --- a/deploy/bootstrap.sh +++ b/deploy/bootstrap.sh @@ -60,10 +60,13 @@ # FELIS_GO_SHA256 sha256 of that version's linux tarball for this host's architecture. # REQUIRED for a non-default FELIS_GO_VERSION; the default's is pinned. # FELIS_K3S_VERSION k3s release a fresh install gets (default: v1.36.4+k3s1). An -# installed k3s is left alone. +# installed k3s is left alone unless FELIS_UPGRADE_DEPS=1. # FELIS_CLOUDFLARED_VERSION / FELIS_CLOUDFLARED_SHA256 cloudflared release installed # when none is present (default: 2026.9.1, digests pinned); the # sha256 is REQUIRED for any other version +# FELIS_UPGRADE_DEPS 1 moves an installed k3s and cloudflared to the versions above +# (k3s one minor version at a time; neither is ever downgraded) and +# restarts cloudflared-felis onto the new binary (default: 0) # FELIS_REPO_URL git URL to build from (raw script mode only) # FELIS_VERSION_BOOTSTRAP release|dev — which version to install (default: release). # release DOWNLOADS the prebuilt felis binary published for the newest @@ -227,18 +230,20 @@ FELIS_GO_VERSION="${FELIS_GO_VERSION:-$GO_PINNED_VERSION}" FELIS_GO_SHA256="${FELIS_GO_SHA256:-}" # cloudflared runs as root on the edge, so it gets the same treatment: a pinned release and # the sha256 GitHub lists for each asset. A different FELIS_CLOUDFLARED_VERSION has to bring -# its own FELIS_CLOUDFLARED_SHA256. install_cloudflared only runs when the binary is absent; -# upgrading an installed one is `felis update`'s report plus a manual swap. +# its own FELIS_CLOUDFLARED_SHA256. An installed binary is replaced only under +# FELIS_UPGRADE_DEPS=1; `felis update --cloudflared` reports when that would change it. CLOUDFLARED_PINNED_VERSION="2026.9.1" CLOUDFLARED_PINNED_SHA256_AMD64="03f1f25d1cc93b9ad6c60569d44060bc4f17ed97075760ed8cfca4b12dcd68cc" CLOUDFLARED_PINNED_SHA256_ARM64="3d97437c71848bd8df68041e12436b484a661d95073ea1937f01a845ce88faa3" CLOUDFLARED_PINNED_SHA256_ARM="093ffa3638ab2b636de63c43a8c68f96a69cf71f9699dd8277a91b160b0f4fc0" FELIS_CLOUDFLARED_VERSION="${FELIS_CLOUDFLARED_VERSION:-$CLOUDFLARED_PINNED_VERSION}" FELIS_CLOUDFLARED_SHA256="${FELIS_CLOUDFLARED_SHA256:-}" +CLOUDFLARED_BIN=/usr/local/bin/cloudflared # The k3s release a fresh install gets, and the tag its install script is read from. The # script checks the k3s binary against that release's sha256sum file, so pinning the tag -# pins both. An installed k3s is never touched; see docs/troubleshooting.md for upgrades. +# pins both. An installed k3s moves only under FELIS_UPGRADE_DEPS=1. FELIS_K3S_VERSION="${FELIS_K3S_VERSION:-v1.36.4+k3s1}" +FELIS_UPGRADE_DEPS="${FELIS_UPGRADE_DEPS:-0}" # The in-cluster registry's image, by digest. It must equal platform.defaultRegistryImage # (internal/platform/identities.go, TestBootstrapPinsTheRegistryImage): the renderer puts # that ref in the Deployment, and this script caches and pins the same ref in containerd. @@ -711,6 +716,16 @@ validate_settings() { esac [ "$(heap_megabytes "$FELIS_VELOCITY_XMX")" -ge 256 ] \ || die "FELIS_VELOCITY_XMX must be a heap size of at least 256M, written M or G (got '${FELIS_VELOCITY_XMX}')" + case "$FELIS_UPGRADE_DEPS" in + 0|1) ;; + *) die "FELIS_UPGRADE_DEPS must be 0 or 1 (got '${FELIS_UPGRADE_DEPS}')" ;; + esac +} + +# version_newer reports whether version $1 sorts after $2 (a leading v is ignored). +version_newer() { + local a="${1#v}" b="${2#v}" + [ "$a" != "$b" ] && [ "$(printf '%s\n%s\n' "$a" "$b" | sort -V | tail -n 1)" = "$a" ] } # heap_megabytes prints a JVM heap size written M or G in megabytes, or 0 for any @@ -908,9 +923,25 @@ install_base() { } install_cloudflared() { - if command -v cloudflared >/dev/null 2>&1; then - ok "cloudflared already installed" - return 0 + local current="" path + if path="$(command -v cloudflared 2>/dev/null)"; then + current="$(cloudflared --version 2>/dev/null | awk '{ for (i = 1; i < NF; i++) if ($i == "version") { print $(i + 1); exit } }')" + if [ "$current" = "$FELIS_CLOUDFLARED_VERSION" ]; then + ok "cloudflared ${current} already installed" + return 0 + fi + if [ "$FELIS_UPGRADE_DEPS" != 1 ]; then + ok "cloudflared ${current:-(version unreadable)} already installed; this release pins ${FELIS_CLOUDFLARED_VERSION} (FELIS_UPGRADE_DEPS=1 moves it)" + return 0 + fi + if [ "$path" != "$CLOUDFLARED_BIN" ]; then + warn "cloudflared at ${path} was not installed by Felis; upgrade it the way it was installed" + return 0 + fi + if [ -n "$current" ] && version_newer "$current" "$FELIS_CLOUDFLARED_VERSION"; then + ok "cloudflared ${current} is newer than the pinned ${FELIS_CLOUDFLARED_VERSION}; left as it is" + return 0 + fi fi local machine arch url tmp want have machine="$(uname -m)" @@ -932,9 +963,14 @@ install_cloudflared() { rm -f "$tmp" die "cloudflared-linux-${arch} ${FELIS_CLOUDFLARED_VERSION} hashes to ${have}, expected ${want}; refusing to install it" fi - install -m 0755 "$tmp" /usr/local/bin/cloudflared + install -m 0755 "$tmp" "$CLOUDFLARED_BIN" rm -f "$tmp" ok "cloudflared installed ($(cloudflared --version | head -n 1))" + # The running tunnel keeps the old binary mapped until it restarts. + if [ -n "$current" ] && systemctl is-active --quiet cloudflared-felis 2>/dev/null; then + systemctl restart cloudflared-felis + ok "cloudflared-felis restarted onto ${FELIS_CLOUDFLARED_VERSION}" + fi } # --------------------------------------------------------------------------- @@ -1060,16 +1096,19 @@ install_k3s() { configure_k3s_firewall if [ -x "$K3S_BIN" ]; then - ok "k3s already installed at ${K3S_BIN}" + local current + current="$("$K3S_BIN" --version 2>/dev/null | awk 'NR == 1 { print $3 }')" + if [ "$current" = "$FELIS_K3S_VERSION" ]; then + ok "k3s ${current} already installed at ${K3S_BIN}" + elif [ "$FELIS_UPGRADE_DEPS" != 1 ]; then + ok "k3s ${current:-(version unreadable)} already installed at ${K3S_BIN}; this release pins ${FELIS_K3S_VERSION} (FELIS_UPGRADE_DEPS=1 moves it)" + elif k3s_upgrade_allowed "$current" "$FELIS_K3S_VERSION"; then + log "upgrading k3s ${current} to ${FELIS_K3S_VERSION}; running pods keep running while it restarts" + run_k3s_installer + fi else log "installing k3s ${FELIS_K3S_VERSION} into ${K3S_BIN_DIR} (no traefik/servicelb/metrics-server)" - # The script from the release's own tag rather than get.k3s.io, which serves whatever - # master holds today. '+' is literal in a URL path, so the tag needs no escaping. - curl -sfL --retry 5 --retry-delay 2 "https://raw.githubusercontent.com/k3s-io/k3s/${FELIS_K3S_VERSION}/install.sh" | \ - INSTALL_K3S_VERSION="$FELIS_K3S_VERSION" \ - INSTALL_K3S_BIN_DIR="$K3S_BIN_DIR" \ - INSTALL_K3S_EXEC="--disable traefik --disable servicelb --disable metrics-server --write-kubeconfig-mode 644" \ - sh - + run_k3s_installer fi [ -x "$K3S_BIN" ] || die "k3s installation completed but ${K3S_BIN} is missing" @@ -1080,6 +1119,37 @@ install_k3s() { wait_for_node_ready } +# The script from the release's own tag rather than get.k3s.io, which serves whatever +# master holds today. '+' is literal in a URL path, so the tag needs no escaping. On an +# installed k3s the same script replaces the binary in place and restarts the service. +run_k3s_installer() { + curl -sfL --retry 5 --retry-delay 2 "https://raw.githubusercontent.com/k3s-io/k3s/${FELIS_K3S_VERSION}/install.sh" | \ + INSTALL_K3S_VERSION="$FELIS_K3S_VERSION" \ + INSTALL_K3S_BIN_DIR="$K3S_BIN_DIR" \ + INSTALL_K3S_EXEC="--disable traefik --disable servicelb --disable metrics-server --write-kubeconfig-mode 644" \ + sh - +} + +# k3s_upgrade_allowed decides whether an installed k3s ($1) may move to $2. Kubernetes +# supports upgrading one minor version at a time, so a larger jump stops the install +# before anything changed; a newer installed k3s is left as it is. +k3s_upgrade_allowed() { + local current="$1" want="$2" cur_major cur_minor want_major want_minor rest + IFS=. read -r cur_major cur_minor rest <<<"${current#v}" + IFS=. read -r want_major want_minor rest <<<"${want#v}" + case "${cur_major}${cur_minor}${want_major}${want_minor}" in + ""|*[!0-9]*) die "cannot compare the installed k3s '${current}' with ${want}; upgrade it by hand (docs/operations.md §4)" ;; + esac + if version_newer "$current" "$want"; then + ok "k3s ${current} is newer than the pinned ${want}; left as it is" + return 1 + fi + if [ "$cur_major" != "$want_major" ] || [ "$((want_minor - cur_minor))" -gt 1 ]; then + die "k3s ${current} -> ${want} skips a minor version, and Kubernetes upgrades one minor at a time. Rerun with FELIS_K3S_VERSION set to the newest v${cur_major}.$((cur_minor + 1)).x+k3sN release first (https://github.com/k3s-io/k3s/releases)" + fi + return 0 +} + # Waits for the (single) node to report Ready. Shared by the k3s install and the # registry-mirror restart below: both restart the agent, and a bootstrap that # proceeds early fails later with a misleading "not found"/timeout instead. diff --git a/deploy/bootstrap_test.sh b/deploy/bootstrap_test.sh index f5383d3..b731790 100644 --- a/deploy/bootstrap_test.sh +++ b/deploy/bootstrap_test.sh @@ -783,17 +783,22 @@ cfsum="$(printf 'stand-in cloudflared\n' | sha256sum | cut -d' ' -f1)" run_cf() { # FELIS_CLOUDFLARED_VERSION pinned-amd64-digest [FELIS_CLOUDFLARED_SHA256] FELIS_CLOUDFLARED_VERSION="$1" CLOUDFLARED_PINNED_VERSION=2026.9.1 CLOUDFLARED_PINNED_SHA256_AMD64="$2" \ CLOUDFLARED_PINNED_SHA256_ARM64=unused CLOUDFLARED_PINNED_SHA256_ARM=unused \ - FELIS_CLOUDFLARED_SHA256="${3:-}" TMPDIR="$sdir" bash -c ' + FELIS_CLOUDFLARED_SHA256="${3:-}" TMPDIR="$sdir" CLOUDFLARED_BIN=/usr/local/bin/cloudflared \ + FELIS_UPGRADE_DEPS="${CF_UPGRADE:-0}" CF_PATH="${CF_PATH:-}" CF_HAVE="${CF_HAVE:-test}" \ + CF_ACTIVE="${CF_ACTIVE:-0}" bash -c ' die() { printf "DIE: %s\n" "$*"; exit 1; } log() { printf "LOG: %s\n" "$*"; } ok() { printf "OK: %s\n" "$*"; } + warn() { printf "WARN: %s\n" "$*"; } remember_temp() { :; } - command() { return 1; } + command() { [ -n "$CF_PATH" ] && [ "$1" = -v ] && [ "$2" = cloudflared ] && echo "$CF_PATH"; } uname() { echo x86_64; } - cloudflared() { echo "cloudflared version test"; } + cloudflared() { echo "cloudflared version ${CF_HAVE} (built 2026-01-01-0000 UTC)"; } curl() { printf "CURL: %s\n" "$*"; while [ "$#" -gt 1 ] && [ "$1" != "-o" ]; do shift; done printf "stand-in cloudflared\n" > "$2"; } install() { printf "INSTALL: %s\n" "$*"; } + systemctl() { case "$1" in is-active) [ "$CF_ACTIVE" = 1 ] ;; *) printf "SYSTEMCTL: %s\n" "$*" ;; esac; } + '"$(awk '/^version_newer\(\) \{/,/^}/' "$BS")"' '"$cfblock"' install_cloudflared' } @@ -806,12 +811,68 @@ case "$out" in *INSTALL:*) echo "FAIL: a refused cloudflared must not be install expect "another cloudflared version needs its own digest" "DIE: no pinned sha256 for cloudflared 2027.1.0" "$(run_cf 2027.1.0 "$cfsum")" expect "another cloudflared version installs with its digest" "INSTALL: -m 0755" "$(run_cf 2027.1.0 deadbeef "$cfsum")" +# An installed cloudflared moves only under FELIS_UPGRADE_DEPS=1, only when Felis put it +# there, never backwards, and the running tunnel is restarted onto the new binary. +out="$(CF_PATH=/usr/local/bin/cloudflared CF_HAVE=2026.9.1 run_cf 2026.9.1 "$cfsum")" +expect "a cloudflared at the pin is left alone" "OK: cloudflared 2026.9.1 already installed" "$out" +case "$out" in *CURL:*) echo "FAIL: a cloudflared at the pin must not be downloaded again"; fails=$((fails + 1)) ;; esac +out="$(CF_PATH=/usr/local/bin/cloudflared CF_HAVE=2025.8.0 run_cf 2026.9.1 "$cfsum")" +expect "an older cloudflared is reported without the flag" "this release pins 2026.9.1 (FELIS_UPGRADE_DEPS=1 moves it)" "$out" +case "$out" in *CURL:*) echo "FAIL: an installed cloudflared must not move without FELIS_UPGRADE_DEPS=1"; fails=$((fails + 1)) ;; esac +out="$(CF_UPGRADE=1 CF_ACTIVE=1 CF_PATH=/usr/local/bin/cloudflared CF_HAVE=2025.8.0 run_cf 2026.9.1 "$cfsum")" +expect "FELIS_UPGRADE_DEPS=1 installs the pinned cloudflared over an older one" "INSTALL: -m 0755" "$out" +expect "the running tunnel is restarted onto the new cloudflared" "SYSTEMCTL: restart cloudflared-felis" "$out" +out="$(CF_UPGRADE=1 CF_PATH=/usr/local/bin/cloudflared CF_HAVE=2025.8.0 run_cf 2026.9.1 "$cfsum")" +case "$out" in *SYSTEMCTL:*) echo "FAIL: a stopped cloudflared-felis must not be started by an upgrade"; fails=$((fails + 1)) ;; esac +out="$(CF_UPGRADE=1 CF_PATH=/usr/bin/cloudflared CF_HAVE=2025.8.0 run_cf 2026.9.1 "$cfsum")" +expect "a packaged cloudflared is left to its package manager" "WARN: cloudflared at /usr/bin/cloudflared was not installed by Felis" "$out" +case "$out" in *CURL:*) echo "FAIL: a packaged cloudflared must not be overwritten"; fails=$((fails + 1)) ;; esac +out="$(CF_UPGRADE=1 CF_PATH=/usr/local/bin/cloudflared CF_HAVE=2026.10.2 run_cf 2026.9.1 "$cfsum")" +expect "a newer cloudflared is never downgraded" "cloudflared 2026.10.2 is newer than the pinned 2026.9.1" "$out" +case "$out" in *CURL:*) echo "FAIL: a newer cloudflared must not be downgraded"; fails=$((fails + 1)) ;; esac + # k3s: the install script is read from the pinned tag, and told the same version. -kblock="$(awk '/^install_k3s\(\) \{/,/^}/' "$BS")" +kblock="$(awk '/^run_k3s_installer\(\) \{/,/^}/' "$BS")" expect "k3s's install script comes from the pinned tag" 'raw.githubusercontent.com/k3s-io/k3s/${FELIS_K3S_VERSION}/install.sh' "$kblock" expect "k3s's install script is told the pinned version" 'INSTALL_K3S_VERSION="$FELIS_K3S_VERSION"' "$kblock" case "$kblock" in *"https://get.k3s.io"*) echo "FAIL: get.k3s.io serves master's script; read it from the pinned tag"; fails=$((fails + 1)) ;; esac +# An installed k3s moves only under FELIS_UPGRADE_DEPS=1, one minor version at a time and +# never backwards; the refusal names the release to go through first. +kfake="$sdir/k3s" +run_k3s() { # installed-version pinned-version [FELIS_UPGRADE_DEPS] + printf '#!/bin/sh\necho "k3s version %s (0123abcd)"\necho "go version go1.26"\n' "$1" > "$kfake" + chmod +x "$kfake" + K3S_BIN="$kfake" FELIS_K3S_VERSION="$2" FELIS_UPGRADE_DEPS="${3:-0}" bash -c ' + die() { printf "DIE: %s\n" "$*"; exit 1; } + log() { printf "LOG: %s\n" "$*"; } + ok() { printf "OK: %s\n" "$*"; } + configure_k3s_firewall() { :; } + run_k3s_installer() { printf "INSTALLER: %s\n" "$FELIS_K3S_VERSION"; } + systemctl() { :; } + wait_for_node_ready() { :; } + '"$(awk '/^version_newer\(\) \{/,/^}/' "$BS")"' + '"$(awk '/^k3s_upgrade_allowed\(\) \{/,/^}/' "$BS")"' + '"$(awk '/^install_k3s\(\) \{/,/^}/' "$BS")"' + install_k3s' +} +out="$(run_k3s v1.36.4+k3s1 v1.36.4+k3s1 1)" +expect "a k3s at the pin is left alone" "OK: k3s v1.36.4+k3s1 already installed" "$out" +case "$out" in *INSTALLER:*) echo "FAIL: a k3s at the pin must not be reinstalled"; fails=$((fails + 1)) ;; esac +out="$(run_k3s v1.35.2+k3s1 v1.36.4+k3s1)" +expect "an older k3s is reported without the flag" "this release pins v1.36.4+k3s1 (FELIS_UPGRADE_DEPS=1 moves it)" "$out" +case "$out" in *INSTALLER:*) echo "FAIL: an installed k3s must not move without FELIS_UPGRADE_DEPS=1"; fails=$((fails + 1)) ;; esac +expect "FELIS_UPGRADE_DEPS=1 moves k3s up one minor" "INSTALLER: v1.36.4+k3s1" "$(run_k3s v1.35.2+k3s1 v1.36.4+k3s1 1)" +expect "FELIS_UPGRADE_DEPS=1 moves k3s to a newer patch" "INSTALLER: v1.36.4+k3s1" "$(run_k3s v1.36.1+k3s2 v1.36.4+k3s1 1)" +out="$(run_k3s v1.34.6+k3s1 v1.36.4+k3s1 1)" +expect "a k3s upgrade that skips a minor is refused" "DIE: k3s v1.34.6+k3s1 -> v1.36.4+k3s1 skips a minor version" "$out" +expect "the refusal names the minor to go through first" "newest v1.35.x+k3sN release first" "$out" +case "$out" in *INSTALLER:*) echo "FAIL: a skipping k3s upgrade must not run the installer"; fails=$((fails + 1)) ;; esac +out="$(run_k3s v1.37.0+k3s1 v1.36.4+k3s1 1)" +expect "a newer k3s is never downgraded" "OK: k3s v1.37.0+k3s1 is newer than the pinned v1.36.4+k3s1" "$out" +case "$out" in *INSTALLER:*) echo "FAIL: a newer k3s must not be downgraded"; fails=$((fails + 1)) ;; esac +expect "an unreadable k3s version stops the upgrade" "DIE: cannot compare the installed k3s 'dev'" "$(run_k3s dev v1.36.4+k3s1 1)" + # --- a private repo without a token fails with the hint instead of prompting ------------- # git asks for credentials on /dev/tty, where a piped install would sit waiting. Every # network git call goes through git_auth, so the switch belongs there. diff --git a/docs/operations.md b/docs/operations.md index f8530ee..6eb2dd0 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -222,13 +222,36 @@ the version they were installed with unless noted: |---|---|---| | Velocity, Limbo, Paper, LuckPerms | follow `deploy/game-stack.lock` | rerun after a release that moves the lock (§15b) | | Temurin JRE | moves to the pinned patch build | rerun | -| k3s | left alone | by hand, one minor version at a time: `curl -sfL https://get.k3s.io \| INSTALL_K3S_VERSION= sh -` | -| cloudflared | left alone | replace `/usr/local/bin/cloudflared` with the release binary, then `systemctl restart cloudflared-felis` | -| PostgreSQL | the distribution's package | the package manager; a major version needs `pg_upgrade` first (the installer refuses to start a newer server on an older cluster) | +| k3s | left alone | rerun with `FELIS_UPGRADE_DEPS=1`: moves to the pinned release through that tag's install script, one minor version at a time (a bigger jump stops before anything changes and names the release to go through), never backwards | +| cloudflared | left alone | rerun with `FELIS_UPGRADE_DEPS=1`: swaps `/usr/local/bin/cloudflared` for the pinned, sha256-checked release and restarts `cloudflared-felis`; a cloudflared the distribution installed stays with its package manager | +| PostgreSQL | the distribution's package | the package manager for a minor release; a major version needs `pg_upgrade` first (below) | | Docker, git, nftables | distribution packages | the package manager | -`sudo felis update` reports Felis, Velocity, k3s and cloudflared against their newest -releases. +```sh +curl -fsSL https://raw.githubusercontent.com/FelisMC/Felis/main/deploy/bootstrap.sh \ + | sudo FELIS_UPGRADE_DEPS=1 bash +``` + +`sudo felis update` reports Felis, Velocity, k3s, cloudflared, the JRE and PostgreSQL +against their newest releases; `--k3s`, `--cloudflared`, `--jre` and `--postgres` narrow +it to one. PostgreSQL is compared within its major, since a minor release is a package +update, and a major past its end of life gets a note naming the current one. + +### PostgreSQL major versions [CODE-ONLY] + +The installer takes the major the distribution ships (13 on EL9) and never moves it. To +go to a newer one, stop the writers, keep a dump, then use the distribution's upgrade +path: + +```sh +sudo k3s kubectl -n felis scale deploy/felis-api deploy/felis-operator --replicas=0 +sudo -u postgres pg_dumpall > /root/felis-pg-$(date +%F).sql +# EL9: sudo systemctl stop postgresql; sudo dnf module switch-to postgresql:16 +# sudo dnf install postgresql-upgrade; sudo postgresql-setup --upgrade +# Debian/Ubuntu: sudo pg_upgradecluster main +sudo systemctl start postgresql +sudo k3s kubectl -n felis scale deploy/felis-api deploy/felis-operator --replicas=1 +``` ## 5. Disaster recovery diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index d7414cc..6eedb3c 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -1320,8 +1320,8 @@ Two properties of the control plane matter when you do: | Download | Check | |---|---| | `felis-linux-` (release channel) | its sha256 must match the release's `SHA256SUMS`; a release without one, or a mismatch, is compiled from the same tag instead | -| k3s (fresh install only) | the install script is read at `FELIS_K3S_VERSION`'s tag (default `v1.36.4+k3s1`), and it checks the binary against that release's sha256 list | -| cloudflared (when absent) | release `FELIS_CLOUDFLARED_VERSION` (default `2026.9.1`) against a pinned sha256; another version needs `FELIS_CLOUDFLARED_SHA256` | +| k3s (fresh install, or `FELIS_UPGRADE_DEPS=1`) | the install script is read at `FELIS_K3S_VERSION`'s tag (default `v1.36.4+k3s1`), and it checks the binary against that release's sha256 list | +| cloudflared (when absent, or `FELIS_UPGRADE_DEPS=1`) | release `FELIS_CLOUDFLARED_VERSION` (default `2026.9.1`) against a pinned sha256; another version needs `FELIS_CLOUDFLARED_SHA256` | | Go toolchain (nano, source builds) | pinned sha256 per architecture; another version needs `FELIS_GO_SHA256` | | the registry image | pinned by digest (`registry:2.8.3@sha256:a3d8…`) | | Limbo, its spawn schematic, Paper, LuckPerms, Velocity | the builds and sha256s in `deploy/game-stack.lock`; each image build and the proxy install refuse a download that hashes differently (§15b) | diff --git a/internal/updater/gatherer_host.go b/internal/updater/gatherer_host.go index 493f03e..9ec2779 100644 --- a/internal/updater/gatherer_host.go +++ b/internal/updater/gatherer_host.go @@ -6,6 +6,7 @@ import ( "context" "fmt" "io" + "os" "path/filepath" "strings" @@ -38,6 +39,10 @@ import ( // exists. const DefaultVelocityJarPath = "/opt/felis/velocity/velocity.jar" +// DefaultJREReleasePath is the release file of the runtime deploy/bootstrap.sh +// installs for Velocity (install_jre unpacks Temurin into /opt/felis/jre). +const DefaultJREReleasePath = "/opt/felis/jre/release" + // 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 @@ -50,6 +55,7 @@ func NewHostGatherer(felisVersion, velocityJar string) VersionGatherer { sys: sysGatherer{run: execRunner{}}, felisVersion: felisVersion, velocityJar: velocityJar, + jreRelease: DefaultJREReleasePath, } } @@ -60,6 +66,7 @@ type hostGatherer struct { sys sysGatherer felisVersion string velocityJar string + jreRelease string } // Current implements VersionGatherer. @@ -76,11 +83,47 @@ func (g hostGatherer) Current(ctx context.Context, spec Spec) (updates.Version, return v, nil case "velocity": return velocityJarVersion(g.velocityJar) + case "jre": + return jreReleaseVersion(g.jreRelease) + case "postgresql": + // The server binary is on PATH on the dnf family; Debian and Ubuntu keep it + // under /usr/lib/postgresql//bin and put only the client on PATH, which + // the distribution ships at the same version. + if v, err := g.sys.cliVersion(ctx, "postgres"); err == nil { + return v, nil + } + return g.sys.cliVersion(ctx, "psql") default: return g.sys.Current(ctx, spec) } } +// jreReleaseVersion reads the runtime's version from its release file. Temurin writes +// SEMANTIC_VERSION="25.0.4.1+1"; JAVA_VERSION="25.0.4.1" is the fallback every JDK +// build writes. JAVA_RUNTIME_VERSION is avoided: its "-LTS" tail reads as a prerelease. +func jreReleaseVersion(path string) (updates.Version, error) { + raw, err := os.ReadFile(path) + if err != nil { + return updates.Version{}, fmt.Errorf("updater: read JRE release file: %w", err) + } + fields := map[string]string{} + for _, line := range strings.Split(string(raw), "\n") { + k, v, ok := strings.Cut(line, "=") + if ok { + fields[strings.TrimSpace(k)] = strings.Trim(strings.TrimSpace(v), `"`) + } + } + for _, key := range []string{"SEMANTIC_VERSION", "JAVA_VERSION"} { + if fields[key] == "" { + continue + } + if v, err := updates.Parse(fields[key]); err == nil { + return v, nil + } + } + return updates.Version{}, fmt.Errorf("updater: no SEMANTIC_VERSION or JAVA_VERSION in %s", path) +} + // velocityJarVersion reads the installed proxy's version out of the jar itself. // // It reads META-INF/MANIFEST.MF's Implementation-Version, which is authoritative diff --git a/internal/updater/gatherer_host_test.go b/internal/updater/gatherer_host_test.go index fa7e384..438fe81 100644 --- a/internal/updater/gatherer_host_test.go +++ b/internal/updater/gatherer_host_test.go @@ -104,3 +104,56 @@ func TestHostGathererUsesOwnBuildStamp(t *testing.T) { t.Fatalf("version = %s, want 1.2.3", got) } } + +// The JRE answers from its release file. Temurin's SEMANTIC_VERSION keeps the respin +// component; JAVA_RUNTIME_VERSION's "-LTS" tail would read as a prerelease. +func TestJREReleaseVersion(t *testing.T) { + dir := t.TempDir() + cases := map[string]string{ + "JAVA_RUNTIME_VERSION=\"25.0.4.1+1-LTS\"\nJAVA_VERSION=\"25.0.4.1\"\nSEMANTIC_VERSION=\"25.0.4.1+1\"\n": "25.0.4.1+1", + "JAVA_VERSION=\"21.0.8\"\n": "21.0.8", + } + for body, want := range cases { + path := filepath.Join(dir, "release") + if err := os.WriteFile(path, []byte(body), 0o644); err != nil { + t.Fatal(err) + } + v, err := jreReleaseVersion(path) + if err != nil { + t.Fatalf("jreReleaseVersion(%q): %v", body, err) + } + if v.String() != want || v.IsPrerelease() { + t.Errorf("jreReleaseVersion(%q) = %s, want stable %s", body, v, want) + } + } + if err := os.WriteFile(filepath.Join(dir, "release"), []byte("IMPLEMENTOR=\"x\"\n"), 0o644); err != nil { + t.Fatal(err) + } + if v, err := jreReleaseVersion(filepath.Join(dir, "release")); err == nil { + t.Errorf("a release file without a version = %s, want an error", v) + } + if _, err := jreReleaseVersion(filepath.Join(dir, "missing")); err == nil { + t.Error("a missing release file must be an error") + } +} + +// PostgreSQL answers from the server binary where it is on PATH, else from the client. +func TestHostGathererPostgres(t *testing.T) { + for name, out := range map[string]map[string][]byte{ + "server on PATH": {"postgres": []byte("postgres (PostgreSQL) 13.23\n"), "psql": []byte("psql (PostgreSQL) 12.1\n")}, + "client only": {"psql": []byte("psql (PostgreSQL) 13.23 (Ubuntu 13.23-1.pgdg24.04+1)\n")}, + } { + g := hostGatherer{sys: sysGatherer{run: fakeCmd{out: out}}} + v, err := g.Current(context.Background(), Spec{Name: "postgresql"}) + if err != nil { + t.Fatalf("%s: %v", name, err) + } + if v.String() != "13.23" { + t.Errorf("%s: version = %s, want 13.23", name, v) + } + } + g := hostGatherer{sys: sysGatherer{run: fakeCmd{out: map[string][]byte{}}}} + if _, err := g.Current(context.Background(), Spec{Name: "postgresql"}); err == nil { + t.Error("no postgres and no psql must be an error") + } +} diff --git a/internal/updater/gatherer_test.go b/internal/updater/gatherer_test.go index 7a067fc..701fbc1 100644 --- a/internal/updater/gatherer_test.go +++ b/internal/updater/gatherer_test.go @@ -164,7 +164,16 @@ func TestSysGathererCurrentDispatch(t *testing.T) { "felis-api": {[3]int{1, 4, 0}, "1.4.0"}, "velocity": {[3]int{3, 4, 0}, "3.4.0"}, } + // The JRE and PostgreSQL live on the host alone; hostGatherer answers them + // (gatherer_host_test.go), and here they must fail closed. + hostOnly := map[string]bool{"jre": true, "postgresql": true} for _, spec := range Topology() { + if hostOnly[spec.Name] { + if v, err := g.Current(context.Background(), spec); err == nil { + t.Errorf("Current(%s) = %s from the system gatherer, want an error", spec.Name, v) + } + continue + } w, ok := want[spec.Name] if !ok { t.Fatalf("Topology grew a component %q with no gather expectation — update this test", spec.Name) diff --git a/internal/updater/github.go b/internal/updater/github.go index 71543d6..5cd8f2c 100644 --- a/internal/updater/github.go +++ b/internal/updater/github.go @@ -6,6 +6,7 @@ import ( "fmt" "net/http" "os" + "strings" "time" "felis.lolicon.best/internal/updates" @@ -91,10 +92,42 @@ type releaseResponse struct { // already excludes drafts and prereleases; the explicit re-checks are defense in depth so // an upstream change can never silently promote a prerelease into a scheduled apply. func (g github) latestStable(ctx context.Context, repo string) (updates.Version, error) { + tag, err := g.latestTag(ctx, repo) + if err != nil { + return updates.Version{}, err + } + return parseStableTag(repo, tag) +} + +// latestTemurin returns the newest stable Temurin build of one JDK feature release. +// Adoptium publishes each feature line from its own repository and tags every build +// "jdk-" ("jdk-25.0.4.1+1"); the prefix is the only thing Parse cannot take. +func (g github) latestTemurin(ctx context.Context, feature int) (updates.Version, error) { + repo := fmt.Sprintf("adoptium/temurin%d-binaries", feature) + tag, err := g.latestTag(ctx, repo) + if err != nil { + return updates.Version{}, err + } + return parseStableTag(repo, strings.TrimPrefix(tag, "jdk-")) +} + +func parseStableTag(repo, tag string) (updates.Version, error) { + v, err := updates.Parse(tag) + if err != nil { + return updates.Version{}, fmt.Errorf("github: parse tag %q for %s: %w", tag, repo, err) + } + if v.IsPrerelease() { + return updates.Version{}, fmt.Errorf("github: %s latest tag %q parses as a prerelease", repo, tag) + } + return v, nil +} + +// latestTag fetches the tag of repo's /releases/latest. +func (g github) latestTag(ctx context.Context, repo string) (string, error) { url := fmt.Sprintf("%s/repos/%s/releases/latest", g.baseURL, repo) req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { - return updates.Version{}, fmt.Errorf("github: build request for %s: %w", repo, err) + return "", fmt.Errorf("github: build request for %s: %w", repo, err) } ua := g.userAgent if ua == "" { @@ -108,7 +141,7 @@ func (g github) latestStable(ctx context.Context, repo string) (updates.Version, resp, err := g.hc.Do(req) if err != nil { - return updates.Version{}, fmt.Errorf("github: get %s: %w", repo, err) + return "", fmt.Errorf("github: get %s: %w", repo, err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { @@ -117,27 +150,20 @@ func (g github) latestStable(ctx context.Context, repo string) (updates.Version, // are the same status. Name both causes, and name the fix for the one an operator // can act on. if resp.StatusCode == http.StatusNotFound && g.token == "" { - return updates.Version{}, fmt.Errorf( + return "", fmt.Errorf( "github: %s releases/latest returned HTTP 404 — either it has no published stable release, or it is private and %s is unset", repo, tokenEnv) } - return updates.Version{}, fmt.Errorf("github: %s releases/latest returned HTTP %d", repo, resp.StatusCode) + return "", fmt.Errorf("github: %s releases/latest returned HTTP %d", repo, resp.StatusCode) } var rr releaseResponse if err := json.NewDecoder(resp.Body).Decode(&rr); err != nil { - return updates.Version{}, fmt.Errorf("github: decode %s: %w", repo, err) + return "", fmt.Errorf("github: decode %s: %w", repo, err) } if rr.Draft || rr.Prerelease { - return updates.Version{}, fmt.Errorf("github: %s releases/latest is unexpectedly draft/prerelease (tag %q)", repo, rr.TagName) + return "", fmt.Errorf("github: %s releases/latest is unexpectedly draft/prerelease (tag %q)", repo, rr.TagName) } - v, err := updates.Parse(rr.TagName) - if err != nil { - return updates.Version{}, fmt.Errorf("github: parse tag %q for %s: %w", rr.TagName, repo, err) - } - if v.IsPrerelease() { - return updates.Version{}, fmt.Errorf("github: %s latest tag %q parses as a prerelease", repo, rr.TagName) - } - return v, nil + return rr.TagName, nil } diff --git a/internal/updater/github_test.go b/internal/updater/github_test.go index b3964c0..7066b77 100644 --- a/internal/updater/github_test.go +++ b/internal/updater/github_test.go @@ -17,8 +17,31 @@ import ( const ( cloudflaredLatestFixture = `{"tag_name":"2026.6.1","prerelease":false,"draft":false,"name":"2026.6.1"}` k3sLatestFixture = `{"tag_name":"v1.36.2+k3s1","prerelease":false,"draft":false,"name":"v1.36.2+k3s1"}` + // adoptium/temurin25-binaries on 2026-09-25: an emergency respin, four components. + temurinLatestFixture = `{"tag_name":"jdk-25.0.4.1+1","prerelease":false,"draft":false,"name":"jdk-25.0.4.1+1"}` ) +// TestGitHubLatestTemurin reads the feature line's repository and drops the "jdk-" +// prefix Adoptium tags every build with. +func TestGitHubLatestTemurin(t *testing.T) { + var path string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + path = r.URL.Path + _, _ = w.Write([]byte(temurinLatestFixture)) + })) + defer srv.Close() + v, err := newTestGitHub(srv).latestTemurin(context.Background(), 25) + if err != nil { + t.Fatalf("latestTemurin: %v", err) + } + if path != "/repos/adoptium/temurin25-binaries/releases/latest" { + t.Errorf("requested %s, want the temurin25-binaries repository", path) + } + if v.String() != "25.0.4.1+1" || v.Revision != 1 { + t.Errorf("version = %s (%+v), want 25.0.4.1+1 with revision 1", v, v) + } +} + func newTestGitHub(srv *httptest.Server) github { return github{ baseURL: srv.URL, diff --git a/internal/updater/postgres.go b/internal/updater/postgres.go new file mode 100644 index 0000000..31ae797 --- /dev/null +++ b/internal/updater/postgres.go @@ -0,0 +1,105 @@ +package updater + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strconv" + "time" + + "felis.lolicon.best/internal/updates" +) + +// postgresFeedURL is the PostgreSQL project's machine-readable release table, the one +// its own versioning page renders from. Checked 2026-09-25: an array with one entry per +// major, e.g. {"major":"13","latestMinor":"23","supported":false,"eolDate":"2025-11-13"}. +const postgresFeedURL = "https://www.postgresql.org/versions.json" + +// postgresFeed discovers the newest minor release of the host's PostgreSQL major. +// +// Felis installs PostgreSQL from the distribution, so the major is whatever the +// distribution ships and moving it is a pg_upgrade the operator plans. The report +// therefore compares within the major (a minor release is a package update), and a +// major that is past its end of life is surfaced separately as a note. +type postgresFeed struct { + url string + userAgent string + hc *http.Client +} + +func newPostgresFeed() postgresFeed { + return postgresFeed{url: postgresFeedURL, userAgent: defaultUserAgent, hc: &http.Client{Timeout: 15 * time.Second}} +} + +type postgresMajor struct { + Major string `json:"major"` + LatestMinor string `json:"latestMinor"` + Supported bool `json:"supported"` + Current bool `json:"current"` + EOLDate string `json:"eolDate"` +} + +// postgresRelease is one lookup's answer: the newest minor of the current major, and +// a note when that major is no longer supported. +type postgresRelease struct { + latest updates.Version + note string +} + +// majorKey is the feed's name for the major a version belongs to: "13" from 10 on, +// "9.6" before that, when the second number was still part of the major. +func majorKey(v updates.Version) string { + if v.Major < 10 { + return fmt.Sprintf("%d.%d", v.Major, v.Minor) + } + return strconv.Itoa(v.Major) +} + +func (p postgresFeed) latest(ctx context.Context, current updates.Version) (postgresRelease, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, p.url, nil) + if err != nil { + return postgresRelease{}, fmt.Errorf("postgresql: build request: %w", err) + } + req.Header.Set("User-Agent", p.userAgent) + resp, err := p.hc.Do(req) + if err != nil { + return postgresRelease{}, fmt.Errorf("postgresql: get %s: %w", p.url, err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return postgresRelease{}, fmt.Errorf("postgresql: %s returned HTTP %d", p.url, resp.StatusCode) + } + var majors []postgresMajor + if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&majors); err != nil { + return postgresRelease{}, fmt.Errorf("postgresql: decode %s: %w", p.url, err) + } + + key := majorKey(current) + var mine *postgresMajor + newest := "" + for i := range majors { + if majors[i].Major == key { + mine = &majors[i] + } + if majors[i].Current { + newest = majors[i].Major + } + } + if mine == nil { + return postgresRelease{}, fmt.Errorf("postgresql: the release feed has no major %s", key) + } + latest, err := updates.Parse(mine.Major + "." + mine.LatestMinor) + if err != nil { + return postgresRelease{}, fmt.Errorf("postgresql: major %s latest minor %q: %w", key, mine.LatestMinor, err) + } + rel := postgresRelease{latest: latest} + if !mine.Supported { + rel.note = fmt.Sprintf("PostgreSQL %s reached end of life on %s and gets no more fixes", key, mine.EOLDate) + if newest != "" { + rel.note += fmt.Sprintf("; the current major is %s (pg_upgrade, see docs/operations.md §4)", newest) + } + } + return rel, nil +} diff --git a/internal/updater/postgres_test.go b/internal/updater/postgres_test.go new file mode 100644 index 0000000..b6092f0 --- /dev/null +++ b/internal/updater/postgres_test.go @@ -0,0 +1,74 @@ +package updater + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +// postgresFeedFixture is a slice of https://www.postgresql.org/versions.json as served +// on 2026-09-25: a pre-10 major, an end-of-life major, and the current one. +const postgresFeedFixture = `[ +{"current":false,"eolDate":"2021-11-11","firstRelDate":"2016-09-29","latestMinor":"24","major":"9.6","relDate":"2021-11-11","supported":false}, +{"current":false,"eolDate":"2025-11-13","firstRelDate":"2020-09-24","latestMinor":"23","major":"13","relDate":"2025-11-13","supported":false}, +{"current":false,"eolDate":"2029-11-08","firstRelDate":"2024-09-26","latestMinor":"10","major":"17","relDate":"2026-08-13","supported":true}, +{"current":true,"eolDate":"2030-11-14","firstRelDate":"2025-09-25","latestMinor":"6","major":"18","relDate":"2026-08-13","supported":true} +]` + +func pgFixtureServer(body string) *httptest.Server { + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(body)) + })) +} + +func newTestPostgresFeed(srv *httptest.Server) postgresFeed { + return postgresFeed{url: srv.URL, userAgent: "felis-updater/0.1", hc: srv.Client()} +} + +func TestPostgresFeedComparesWithinTheMajor(t *testing.T) { + srv := pgFixtureServer(postgresFeedFixture) + defer srv.Close() + cases := []struct { + current, latest string + note []string + }{ + {"17.4", "17.10", nil}, + {"18.6", "18.6", nil}, + {"13.22", "13.23", []string{"PostgreSQL 13 reached end of life on 2025-11-13", "current major is 18"}}, + {"9.6.3", "9.6.24", []string{"PostgreSQL 9.6 reached end of life"}}, + } + for _, c := range cases { + rel, err := newTestPostgresFeed(srv).latest(context.Background(), mustV(t, c.current)) + if err != nil { + t.Fatalf("latest(%s): %v", c.current, err) + } + if rel.latest.Compare(mustV(t, c.latest)) != 0 { + t.Errorf("latest(%s) = %s, want %s", c.current, rel.latest, c.latest) + } + if len(c.note) == 0 && rel.note != "" { + t.Errorf("latest(%s) note = %q, want none for a supported major", c.current, rel.note) + } + for _, w := range c.note { + if !strings.Contains(rel.note, w) { + t.Errorf("latest(%s) note = %q, want it to mention %q", c.current, rel.note, w) + } + } + } +} + +func TestPostgresFeedFailsClosed(t *testing.T) { + for name, body := range map[string]string{ + "unknown major": postgresFeedFixture, + "garbled feed": `{"not":"a list"}`, + "garbled minor": `[{"major":"16","latestMinor":"x","supported":true}]`, + } { + srv := pgFixtureServer(body) + if v, err := newTestPostgresFeed(srv).latest(context.Background(), mustV(t, "16.2")); err == nil { + t.Errorf("%s: latest = %s, want an error", name, v.latest) + } + srv.Close() + } +} diff --git a/internal/updater/runner_test.go b/internal/updater/runner_test.go index 1af20a9..f3c223a 100644 --- a/internal/updater/runner_test.go +++ b/internal/updater/runner_test.go @@ -144,9 +144,10 @@ func TestRunnerWithRoutingSource(t *testing.T) { // GitHub fixtures keyed by repo. The handler also mirrors GitHub's real gate: a // UA-less request is refused. ghBodies := map[string]string{ - "/repos/FelisMC/Felis/releases/latest": `{"tag_name":"1.5.0","prerelease":false,"draft":false}`, - "/repos/k3s-io/k3s/releases/latest": k3sLatestFixture, - "/repos/cloudflare/cloudflared/releases/latest": cloudflaredLatestFixture, + "/repos/FelisMC/Felis/releases/latest": `{"tag_name":"1.5.0","prerelease":false,"draft":false}`, + "/repos/k3s-io/k3s/releases/latest": k3sLatestFixture, + "/repos/cloudflare/cloudflared/releases/latest": cloudflaredLatestFixture, + "/repos/adoptium/temurin25-binaries/releases/latest": temurinLatestFixture, } ghSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Header.Get("User-Agent") == "" { @@ -165,12 +166,17 @@ func TestRunnerWithRoutingSource(t *testing.T) { rs := NewRoutingSource(Topology()) rs.paper = newTestPaperMC(paperSrv) // point PaperMC discovery at its httptest server rs.gh = newTestGitHub(ghSrv) // and GitHub discovery at its own + pgSrv := pgFixtureServer(postgresFeedFixture) + defer pgSrv.Close() + rs.pg = newTestPostgresFeed(pgSrv) gath := fakeGatherer{cur: map[string]updates.Version{ "felis-api": mustV(t, "1.4.0"), "k3s": mustV(t, "v1.35.6+k3s1"), "cloudflared": mustV(t, "2026.5.0"), "velocity": mustV(t, "3.1.1"), + "jre": mustV(t, "25.0.4+8"), + "postgresql": mustV(t, "13.22"), }} rn := &Runner{Gatherer: gath, Source: rs} res, err := rn.Run(context.Background(), now, updates.Window{}) @@ -184,13 +190,21 @@ func TestRunnerWithRoutingSource(t *testing.T) { "felis-api", "1.5.0", "k3s", "v1.36.2+k3s1", "cloudflared", "2026.6.1", + "jre", "25.0.4.1+1", + "postgresql", "13.23", } { if !strings.Contains(res.Report, want) { t.Errorf("report missing discovered %q; got:\n%s", want, res.Report) } } - // Both routes are wired now, so nothing degrades to a source error. + // Every route is wired, so nothing degrades to a source error. if len(res.RunResult.SourceErrors) != 0 { - t.Errorf("expected no source errors with both routes wired, got %v", res.RunResult.SourceErrors) + t.Errorf("expected no source errors with every route wired, got %v", res.RunResult.SourceErrors) + } + if len(res.GatherErrors) != 0 { + t.Errorf("expected every component gathered, got %v", res.GatherErrors) + } + if note := rs.Notes()["postgresql"]; !strings.Contains(note, "end of life on 2025-11-13") || !strings.Contains(note, "current major is 18") { + t.Errorf("postgresql note = %q, want the EOL date and the current major", note) } } diff --git a/internal/updater/source.go b/internal/updater/source.go index 37aaa00..a38b447 100644 --- a/internal/updater/source.go +++ b/internal/updater/source.go @@ -9,12 +9,17 @@ import ( // RoutingSource is the production updates.ReleaseSource. updates.Run calls a single // source for every non-pinned component, so this one dispatches each component to its -// configured upstream by the topology: the PaperMC Fill API for Velocity, and the -// GitHub Releases API for felis-api, k3s and cloudflared. +// configured upstream by the topology: the PaperMC Fill API for Velocity, the GitHub +// Releases API for felis-api, k3s, cloudflared and the Temurin JRE, and the +// PostgreSQL project's release table for the database. type RoutingSource struct { routes map[string]Spec paper paperMC gh github + pg postgresFeed + // notes holds what a lookup learned beyond the version, keyed by component: today + // only an end-of-life PostgreSQL major. updates.Run calls Latest sequentially. + notes map[string]string } // NewRoutingSource builds the router from a topology. Pinned specs are indexed too @@ -24,9 +29,13 @@ func NewRoutingSource(specs []Spec) *RoutingSource { for _, s := range specs { routes[s.Name] = s } - return &RoutingSource{routes: routes, paper: newPaperMC(), gh: newGitHub()} + return &RoutingSource{routes: routes, paper: newPaperMC(), gh: newGitHub(), pg: newPostgresFeed(), notes: map[string]string{}} } +// Notes returns what the last lookups learned beyond each version, keyed by +// component, for the caller to print under the report. +func (r *RoutingSource) Notes() map[string]string { return r.notes } + // Latest implements updates.ReleaseSource. An unknown component name is an error, not // a silent zero, so a topology/route mismatch is loud. func (r *RoutingSource) Latest(ctx context.Context, comp updates.Component) (updates.Version, error) { @@ -39,6 +48,19 @@ func (r *RoutingSource) Latest(ctx context.Context, comp updates.Component) (upd return r.paper.latestStable(ctx, spec.Coord) case sourceGitHub: return r.gh.latestStable(ctx, spec.Coord) + case sourceTemurin: + // The feature release the host runs picks the repository: a newer feature is a + // release decision (the installer's pin), never a patch to report. + return r.gh.latestTemurin(ctx, comp.Current.Major) + case sourcePostgres: + rel, err := r.pg.latest(ctx, comp.Current) + if err != nil { + return updates.Version{}, err + } + if rel.note != "" { + r.notes[comp.Name] = rel.note + } + return rel.latest, nil case sourceNone: // A pinned component (Run never reaches this, but be explicit and loud). return updates.Version{}, fmt.Errorf("updater: %q is pinned and has no release source", comp.Name) diff --git a/internal/updater/topology.go b/internal/updater/topology.go index 431086b..ab2b4fb 100644 --- a/internal/updater/topology.go +++ b/internal/updater/topology.go @@ -6,9 +6,11 @@ import "felis.lolicon.best/internal/updates" type sourceKind int const ( - sourceNone sourceKind = iota // pinned components are never queried - sourceGitHub // GitHub Releases (Coord = "owner/repo") - sourcePaperMC // PaperMC Fill v3 (Coord = project id) + sourceNone sourceKind = iota // pinned components are never queried + sourceGitHub // GitHub Releases (Coord = "owner/repo") + sourcePaperMC // PaperMC Fill v3 (Coord = project id) + sourceTemurin // adoptium/temurin-binaries, feature from Current + sourcePostgres // postgresql.org/versions.json, within Current's major ) // Spec is one platform component's static update policy plus how to find its latest @@ -40,6 +42,10 @@ type Spec struct { // - velocity — the proxy, but off-cluster on an admin-operated macvlan host, so // NOT manageable: even under a schedule it can only ever be Notify. Its releases // come from PaperMC (Fill v3), not GitHub. +// - jre — the Temurin runtime Velocity runs on. The installer pins its patch +// build, so a newer build reaches a host through a Felis release: Notify only. +// - postgresql — the control-plane database, from the distribution's packages. +// Notify only; a minor release is a package update, a major one a pg_upgrade. // // Minecraft is deliberately ABSENT: every MC server is Pinned and is appended to the // plan at runtime from the live fleet (integration), never force-tracked here. @@ -56,5 +62,7 @@ func Topology() []Spec { {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: "velocity", Policy: updates.PolicyNotify, Manageable: false, Source: sourcePaperMC, Coord: "velocity"}, + {Name: "jre", Policy: updates.PolicyNotify, Manageable: false, Source: sourceTemurin}, + {Name: "postgresql", Policy: updates.PolicyNotify, Manageable: false, Source: sourcePostgres}, } } diff --git a/internal/updater/topology_test.go b/internal/updater/topology_test.go index 7c57b69..a211dd2 100644 --- a/internal/updater/topology_test.go +++ b/internal/updater/topology_test.go @@ -15,6 +15,8 @@ func TestTopologyEncodesPolicies(t *testing.T) { "k3s": {Name: "k3s", Policy: updates.PolicyNotify, Manageable: false, Source: sourceGitHub, Coord: "k3s-io/k3s"}, "cloudflared": {Name: "cloudflared", Policy: updates.PolicyScheduled, Manageable: true, Source: sourceGitHub, Coord: "cloudflare/cloudflared"}, "velocity": {Name: "velocity", Policy: updates.PolicyNotify, Manageable: false, Source: sourcePaperMC, Coord: "velocity"}, + "jre": {Name: "jre", Policy: updates.PolicyNotify, Manageable: false, Source: sourceTemurin}, + "postgresql": {Name: "postgresql", Policy: updates.PolicyNotify, Manageable: false, Source: sourcePostgres}, } got := Topology() if len(got) != len(want) { diff --git a/internal/updates/version.go b/internal/updates/version.go index 072686d..4ea6f58 100644 --- a/internal/updates/version.go +++ b/internal/updates/version.go @@ -35,6 +35,9 @@ type Version struct { Major int Minor int Patch int + // Revision is an optional fourth numeric component. Temurin numbers an emergency + // respin of a JDK update that way ("25.0.4.1+1"), and it orders after Patch. + Revision int // Prerelease is the dot-separated identifier set after "-" (empty for a normal // release). Its presence is what IsPrerelease reports and what makes this version // sort below the same Major.Minor.Patch without a prerelease. @@ -45,7 +48,8 @@ type Version struct { } // Parse reads a tolerant semantic version. It accepts an optional leading "v", -// fills missing minor/patch with 0 (so "v2" and "2.0" parse), strips build +// fills missing minor/patch with 0 (so "v2" and "2.0" parse), takes an optional +// fourth numeric component (the JDK's "25.0.4.1"), strips build // metadata after "+" for ordering while preserving it in the raw string, and keeps // any "-prerelease" tail. It fails closed: an unparseable core (non-numeric // major/minor/patch) returns an error rather than a zero Version, so a garbled feed @@ -71,10 +75,10 @@ func Parse(s string) (Version, error) { } parts := strings.Split(core, ".") - if len(parts) == 0 || len(parts) > 3 { + if len(parts) == 0 || len(parts) > 4 { return Version{}, fmt.Errorf("updates: %q is not a dotted version", raw) } - nums := make([]int, 3) + nums := make([]int, 4) for i, p := range parts { n, err := strconv.Atoi(strings.TrimSpace(p)) if err != nil { @@ -85,7 +89,7 @@ func Parse(s string) (Version, error) { } nums[i] = n } - v.Major, v.Minor, v.Patch = nums[0], nums[1], nums[2] + v.Major, v.Minor, v.Patch, v.Revision = nums[0], nums[1], nums[2], nums[3] return v, nil } @@ -101,6 +105,9 @@ func (v Version) String() string { return v.raw } base := fmt.Sprintf("%d.%d.%d", v.Major, v.Minor, v.Patch) + if v.Revision != 0 { + base += fmt.Sprintf(".%d", v.Revision) + } if v.Prerelease != "" { return base + "-" + v.Prerelease } @@ -108,7 +115,7 @@ func (v Version) String() string { } // Compare returns -1, 0, or +1 as v sorts before, equal to, or after o, by SemVer -// 2.0.0 precedence: numeric Major.Minor.Patch first, then — for an equal core — a +// 2.0.0 precedence: numeric Major.Minor.Patch(.Revision) first, then — for an equal core — a // version WITH a prerelease sorts below one without, and two prereleases compare by // their dot-separated identifiers (numeric identifiers numerically, others // lexically; a numeric identifier always sorts below an alphanumeric one). Build @@ -123,6 +130,9 @@ func (v Version) Compare(o Version) int { if c := cmpInt(v.Patch, o.Patch); c != 0 { return c } + if c := cmpInt(v.Revision, o.Revision); c != 0 { + return c + } return comparePrerelease(v.Prerelease, o.Prerelease) } diff --git a/internal/updates/version_test.go b/internal/updates/version_test.go index 651da62..4a71fc7 100644 --- a/internal/updates/version_test.go +++ b/internal/updates/version_test.go @@ -21,6 +21,7 @@ func TestParseTolerant(t *testing.T) { {"v2", 2, 0, 0, ""}, // missing minor/patch fill 0 {"2.0", 2, 0, 0, ""}, // missing patch fills 0 {" v1.2.3 ", 1, 2, 3, ""}, // surrounding whitespace + {"25.0.4.1+1", 25, 0, 4, ""}, // Temurin respin: fourth component, see TestParseRevision } for _, c := range cases { v, err := Parse(c.in) @@ -38,7 +39,7 @@ func TestParseTolerant(t *testing.T) { // TestParseFailsClosed proves a garbled version is an error, never a silent 0.0.0 // that would read as "older than everything" and trigger a spurious upgrade. func TestParseFailsClosed(t *testing.T) { - bad := []string{"", " ", "vx.y.z", "1.2.x", "1.2.3.4", "abc", "-1.2.3", "1.-2.3"} + bad := []string{"", " ", "vx.y.z", "1.2.x", "1.2.3.4.5", "1.2.3.x", "abc", "-1.2.3", "1.-2.3"} for _, in := range bad { if v, err := Parse(in); err == nil { t.Errorf("Parse(%q) = %+v, want error", in, v) @@ -46,6 +47,24 @@ func TestParseFailsClosed(t *testing.T) { } } +// TestParseRevision covers the fourth component Temurin uses for an emergency respin +// of a JDK update: it is read, kept in the report, and ordered after Patch. +func TestParseRevision(t *testing.T) { + v, err := Parse("25.0.4.1+1") + if err != nil { + t.Fatal(err) + } + if v.Revision != 1 || v.String() != "25.0.4.1+1" { + t.Fatalf("Parse(25.0.4.1+1) = %+v (%s), want revision 1 and the raw string kept", v, v) + } + if got := (Version{Major: 25, Patch: 4, Revision: 1}).String(); got != "25.0.4.1" { + t.Fatalf("String() without raw = %q, want 25.0.4.1", got) + } + if got := (Version{Major: 25, Patch: 4}).String(); got != "25.0.4" { + t.Fatalf("String() of a three-part version = %q, want 25.0.4", got) + } +} + func TestCompareAndAfter(t *testing.T) { cases := []struct { a, b string @@ -65,6 +84,9 @@ func TestCompareAndAfter(t *testing.T) { {"1.2.3-alpha", "1.2.3-beta", -1}, // alphanumeric lexical {"1.2.3-rc.1", "1.2.3-rc.1.1", -1}, // longer identifier set is higher {"1.2.3-1", "1.2.3-alpha", -1}, // numeric identifier sorts below alphanumeric + {"25.0.4.1+1", "25.0.4+8", 1}, // a respin is newer than the update it respins + {"25.0.4.1+1", "25.0.5+3", -1}, // and older than the next update + {"25.0.4", "25.0.4.0", 0}, // an absent revision is zero // A dev build's own stamp, "+g", against the tag it is built past. // It must read EQUAL, never newer: deploy/bootstrap.sh's dev channel stamps the