feat(update): felis update 报告 JRE 与 PostgreSQL(含停更提示),bootstrap 支持 FELIS_UPGRADE_DEPS=1 升级 k3s/cloudflared
This commit is contained in:
19 files changed
+790
-78
No files matched your search
+87
-20
@@ -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=<minor> 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<commit>, 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)
|
||||
|
||||
@@ -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<commit> 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)
|
||||
}
|
||||
}
|
||||
+86
-16
@@ -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 <n>M or <n>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 <n>M or <n>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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
+28
-5
@@ -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=<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 <old-major> main
|
||||
sudo systemctl start postgresql
|
||||
sudo k3s kubectl -n felis scale deploy/felis-api deploy/felis-operator --replicas=1
|
||||
```
|
||||
|
||||
## 5. Disaster recovery
|
||||
|
||||
|
||||
@@ -1320,8 +1320,8 @@ Two properties of the control plane matter when you do:
|
||||
| Download | Check |
|
||||
|---|---|
|
||||
| `felis-linux-<arch>` (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) |
|
||||
|
||||
@@ -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/<major>/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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
+40
-14
@@ -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-<version>" ("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
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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<feature>-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},
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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, "<tag>+g<sha>", against the tag it is built past.
|
||||
// It must read EQUAL, never newer: deploy/bootstrap.sh's dev channel stamps the
|
||||
|
||||
Reference in new issue
Block a user