feat(update): felis update 报告 JRE 与 PostgreSQL(含停更提示),bootstrap 支持 FELIS_UPGRADE_DEPS=1 升级 k3s/cloudflared

This commit is contained in:
Lemon-miaow committed 2026-09-25 01:35:52 +08:00
1 parent 6f7b8d7b30
commit b1678f78c8
19 files changed
+790 -78

No files matched your search

+87 -20
View File
@@ -34,6 +34,8 @@ const updateTimeout = 60 * time.Second
type updateTarget struct { type updateTarget struct {
// selector is the flag name without dashes. // selector is the flag name without dashes.
selector string 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 // component is the updates planner's name for this piece, or "" when the planner
// deliberately does not track it (Minecraft, which is pinned). // deliberately does not track it (Minecraft, which is pinned).
component string component string
@@ -41,9 +43,11 @@ type updateTarget struct {
note string note string
// command is the exact, already-tested way to apply it. // command is the exact, already-tested way to apply it.
command string 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 -- // 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 // `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, // 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. // 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" 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 // 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 // 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 // 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{ var updateTargets = []updateTarget{
{ {
selector: "panel", selector: "panel",
help: "select the panel + control plane (felis-api)",
component: "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", 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, command: installerRerun,
installer: true,
}, },
{ {
selector: "velocity", selector: "velocity",
help: "select the Velocity proxy",
component: "velocity", 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", 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, command: installerRerun,
installer: true,
}, },
{ {
selector: "plugins", selector: "plugins",
help: "select the Felis plugin jars (velocity/paper/limbo)",
component: "felis-api", 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)", 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, 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", selector: "mc",
help: "select Minecraft (pinned; reported only)",
component: "", // never tracked: see the pin note below 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", 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: "", command: "",
@@ -92,23 +138,24 @@ var updateTargets = []updateTarget{
// cmdUpdate reports what can be updated and what is already current. // cmdUpdate reports what can be updated and what is already current.
// //
// Bare `felis update` prints the status of every tracked component. Selector flags // 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 // 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 // instruction for a selected component that is already up to date, for the
// reinstall/repair case. // reinstall/repair case.
// //
// It never applies anything and never mutates the node, so unlike setup/breakGlass // 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 // it needs no root. The versions it reads come from this host: k3s, cloudflared and
// answer `--version`, Velocity's version is read out of the installed jar's // PostgreSQL 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 // manifest, the JRE's out of its release file, and felis-api's is this binary's own
// `felis version` prints, which is what the user asked to be the source of truth. // 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 { func cmdUpdate(args []string, stdout, stderr io.Writer) int {
fs := flag.NewFlagSet("update", flag.ContinueOnError) fs := flag.NewFlagSet("update", flag.ContinueOnError)
fs.SetOutput(stderr) fs.SetOutput(stderr)
panel := fs.Bool("panel", false, "select the panel + control plane (felis-api)") flags := map[string]*bool{}
velocity := fs.Bool("velocity", false, "select the Velocity proxy") for _, t := range updateTargets {
mc := fs.Bool("mc", false, "select Minecraft (pinned; reported only)") flags[t.selector] = fs.Bool(t.selector, false, t.help)
plugins := fs.Bool("plugins", false, "select the Felis plugin jars (velocity/paper/limbo)") }
all := fs.Bool("all", false, "select every component above") 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") 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") 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{} selected := map[string]bool{}
for sel, on := range map[string]bool{"panel": *panel, "velocity": *velocity, "mc": *mc, "plugins": *plugins} { for sel, on := range flags {
if on || *all { if *on || *all {
selected[sel] = true selected[sel] = true
} }
} }
@@ -130,9 +177,10 @@ func cmdUpdate(args []string, stdout, stderr io.Writer) int {
ctx, cancel := context.WithTimeout(context.Background(), updateTimeout) ctx, cancel := context.WithTimeout(context.Background(), updateTimeout)
defer cancel() defer cancel()
src := updater.NewRoutingSource(updater.Topology())
rn := &updater.Runner{ rn := &updater.Runner{
Gatherer: updater.NewHostGatherer(resolvedVersion(), *velocityJar), 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 // 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 // notification, and nothing here applies. The zero Window below means every
// Scheduled component degrades to a notify, so the report can never claim an // 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, renderUpdateReport(res, selected))
fmt.Fprint(stdout, renderNotes(src.Notes(), selected))
if len(selected) > 0 { if len(selected) > 0 {
fmt.Fprint(stdout, renderApplyGuidance(res, selected, *force)) fmt.Fprint(stdout, renderApplyGuidance(res, selected, *force))
} }
@@ -199,6 +248,23 @@ func renderUpdateReport(res updater.Result, selected map[string]bool) string {
return b.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 // 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. // 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) { 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 b strings.Builder
var offeredCommand bool var offeredInstaller bool
for _, t := range updateTargets { for _, t := range updateTargets {
if !selected[t.selector] { if !selected[t.selector] {
continue 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, " 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))) 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 // 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. // 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 // One trailer serves every selector now: setup is not an apply path at all on a
// completed install (shouldRunHostBootstrapBeforeConfig only enters the host // completed install (shouldRunHostBootstrapBeforeConfig only enters the host
// bootstrap while an install marker is missing), so the installer re-run is the one // 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 // 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 // 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 // moves it onto releases), and the private repo's one-liner needs the read token
// back in the environment before it can resolve anything. // back in the environment before it can resolve anything.
if offeredCommand { if offeredInstaller {
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") 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() 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 // 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 { func isReleaseTag(v updates.Version) bool {
s := v.String() s := v.String()
if !strings.HasPrefix(s, "v") || v.IsPrerelease() { if !strings.HasPrefix(s, "v") || v.IsPrerelease() || strings.Contains(s, "+") {
return false return false
} }
_, err := updates.Parse(s) _, err := updates.Parse(s)
+80
View File
@@ -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
View File
@@ -60,10 +60,13 @@
# FELIS_GO_SHA256 sha256 of that version's linux tarball for this host's architecture. # 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. # 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 # 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 # FELIS_CLOUDFLARED_VERSION / FELIS_CLOUDFLARED_SHA256 cloudflared release installed
# when none is present (default: 2026.9.1, digests pinned); the # when none is present (default: 2026.9.1, digests pinned); the
# sha256 is REQUIRED for any other version # 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_REPO_URL git URL to build from (raw script mode only)
# FELIS_VERSION_BOOTSTRAP release|dev — which version to install (default: release). # FELIS_VERSION_BOOTSTRAP release|dev — which version to install (default: release).
# release DOWNLOADS the prebuilt felis binary published for the newest # 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:-}" FELIS_GO_SHA256="${FELIS_GO_SHA256:-}"
# cloudflared runs as root on the edge, so it gets the same treatment: a pinned release and # 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 # 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; # its own FELIS_CLOUDFLARED_SHA256. An installed binary is replaced only under
# upgrading an installed one is `felis update`'s report plus a manual swap. # FELIS_UPGRADE_DEPS=1; `felis update --cloudflared` reports when that would change it.
CLOUDFLARED_PINNED_VERSION="2026.9.1" CLOUDFLARED_PINNED_VERSION="2026.9.1"
CLOUDFLARED_PINNED_SHA256_AMD64="03f1f25d1cc93b9ad6c60569d44060bc4f17ed97075760ed8cfca4b12dcd68cc" CLOUDFLARED_PINNED_SHA256_AMD64="03f1f25d1cc93b9ad6c60569d44060bc4f17ed97075760ed8cfca4b12dcd68cc"
CLOUDFLARED_PINNED_SHA256_ARM64="3d97437c71848bd8df68041e12436b484a661d95073ea1937f01a845ce88faa3" CLOUDFLARED_PINNED_SHA256_ARM64="3d97437c71848bd8df68041e12436b484a661d95073ea1937f01a845ce88faa3"
CLOUDFLARED_PINNED_SHA256_ARM="093ffa3638ab2b636de63c43a8c68f96a69cf71f9699dd8277a91b160b0f4fc0" CLOUDFLARED_PINNED_SHA256_ARM="093ffa3638ab2b636de63c43a8c68f96a69cf71f9699dd8277a91b160b0f4fc0"
FELIS_CLOUDFLARED_VERSION="${FELIS_CLOUDFLARED_VERSION:-$CLOUDFLARED_PINNED_VERSION}" FELIS_CLOUDFLARED_VERSION="${FELIS_CLOUDFLARED_VERSION:-$CLOUDFLARED_PINNED_VERSION}"
FELIS_CLOUDFLARED_SHA256="${FELIS_CLOUDFLARED_SHA256:-}" 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 # 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 # 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_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 # The in-cluster registry's image, by digest. It must equal platform.defaultRegistryImage
# (internal/platform/identities.go, TestBootstrapPinsTheRegistryImage): the renderer puts # (internal/platform/identities.go, TestBootstrapPinsTheRegistryImage): the renderer puts
# that ref in the Deployment, and this script caches and pins the same ref in containerd. # that ref in the Deployment, and this script caches and pins the same ref in containerd.
@@ -711,6 +716,16 @@ validate_settings() {
esac esac
[ "$(heap_megabytes "$FELIS_VELOCITY_XMX")" -ge 256 ] \ [ "$(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}')" || 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 # 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() { install_cloudflared() {
if command -v cloudflared >/dev/null 2>&1; then local current="" path
ok "cloudflared already installed" if path="$(command -v cloudflared 2>/dev/null)"; then
return 0 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 fi
local machine arch url tmp want have local machine arch url tmp want have
machine="$(uname -m)" machine="$(uname -m)"
@@ -932,9 +963,14 @@ install_cloudflared() {
rm -f "$tmp" rm -f "$tmp"
die "cloudflared-linux-${arch} ${FELIS_CLOUDFLARED_VERSION} hashes to ${have}, expected ${want}; refusing to install it" die "cloudflared-linux-${arch} ${FELIS_CLOUDFLARED_VERSION} hashes to ${have}, expected ${want}; refusing to install it"
fi fi
install -m 0755 "$tmp" /usr/local/bin/cloudflared install -m 0755 "$tmp" "$CLOUDFLARED_BIN"
rm -f "$tmp" rm -f "$tmp"
ok "cloudflared installed ($(cloudflared --version | head -n 1))" 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 configure_k3s_firewall
if [ -x "$K3S_BIN" ]; then 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 else
log "installing k3s ${FELIS_K3S_VERSION} into ${K3S_BIN_DIR} (no traefik/servicelb/metrics-server)" 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 run_k3s_installer
# 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 -
fi fi
[ -x "$K3S_BIN" ] || die "k3s installation completed but ${K3S_BIN} is missing" [ -x "$K3S_BIN" ] || die "k3s installation completed but ${K3S_BIN} is missing"
@@ -1080,6 +1119,37 @@ install_k3s() {
wait_for_node_ready 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 # 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 # registry-mirror restart below: both restart the agent, and a bootstrap that
# proceeds early fails later with a misleading "not found"/timeout instead. # proceeds early fails later with a misleading "not found"/timeout instead.
+65 -4
View File
@@ -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] 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" \ 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 \ 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; } die() { printf "DIE: %s\n" "$*"; exit 1; }
log() { printf "LOG: %s\n" "$*"; } log() { printf "LOG: %s\n" "$*"; }
ok() { printf "OK: %s\n" "$*"; } ok() { printf "OK: %s\n" "$*"; }
warn() { printf "WARN: %s\n" "$*"; }
remember_temp() { :; } remember_temp() { :; }
command() { return 1; } command() { [ -n "$CF_PATH" ] && [ "$1" = -v ] && [ "$2" = cloudflared ] && echo "$CF_PATH"; }
uname() { echo x86_64; } 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 curl() { printf "CURL: %s\n" "$*"; while [ "$#" -gt 1 ] && [ "$1" != "-o" ]; do shift; done
printf "stand-in cloudflared\n" > "$2"; } printf "stand-in cloudflared\n" > "$2"; }
install() { printf "INSTALL: %s\n" "$*"; } install() { printf "INSTALL: %s\n" "$*"; }
systemctl() { case "$1" in is-active) [ "$CF_ACTIVE" = 1 ] ;; *) printf "SYSTEMCTL: %s\n" "$*" ;; esac; }
'"$(awk '/^version_newer\(\) \{/,/^}/' "$BS")"'
'"$cfblock"' '"$cfblock"'
install_cloudflared' 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 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")" 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. # 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 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" 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 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 ------------- # --- 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 # 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. # network git call goes through git_auth, so the switch belongs there.
+28 -5
View File
@@ -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) | | 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 | | 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 -` | | 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 | replace `/usr/local/bin/cloudflared` with the release binary, then `systemctl restart cloudflared-felis` | | 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; a major version needs `pg_upgrade` first (the installer refuses to start a newer server on an older cluster) | | 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 | | Docker, git, nftables | distribution packages | the package manager |
`sudo felis update` reports Felis, Velocity, k3s and cloudflared against their newest ```sh
releases. 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 ## 5. Disaster recovery
+2 -2
View File
@@ -1320,8 +1320,8 @@ Two properties of the control plane matter when you do:
| Download | Check | | 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 | | `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 | | 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) | release `FELIS_CLOUDFLARED_VERSION` (default `2026.9.1`) against a pinned sha256; another version needs `FELIS_CLOUDFLARED_SHA256` | | 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` | | 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…`) | | 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) | | 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) |
+43
View File
@@ -6,6 +6,7 @@ import (
"context" "context"
"fmt" "fmt"
"io" "io"
"os"
"path/filepath" "path/filepath"
"strings" "strings"
@@ -38,6 +39,10 @@ import (
// exists. // exists.
const DefaultVelocityJarPath = "/opt/felis/velocity/velocity.jar" 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. // NewHostGatherer builds the VersionGatherer for `felis update` running on the node.
// felisVersion is the CLI's own resolved build stamp; velocityJar is the installed // felisVersion is the CLI's own resolved build stamp; velocityJar is the installed
// proxy jar (empty means DefaultVelocityJarPath). k3s and cloudflared keep the // proxy jar (empty means DefaultVelocityJarPath). k3s and cloudflared keep the
@@ -50,6 +55,7 @@ func NewHostGatherer(felisVersion, velocityJar string) VersionGatherer {
sys: sysGatherer{run: execRunner{}}, sys: sysGatherer{run: execRunner{}},
felisVersion: felisVersion, felisVersion: felisVersion,
velocityJar: velocityJar, velocityJar: velocityJar,
jreRelease: DefaultJREReleasePath,
} }
} }
@@ -60,6 +66,7 @@ type hostGatherer struct {
sys sysGatherer sys sysGatherer
felisVersion string felisVersion string
velocityJar string velocityJar string
jreRelease string
} }
// Current implements VersionGatherer. // Current implements VersionGatherer.
@@ -76,11 +83,47 @@ func (g hostGatherer) Current(ctx context.Context, spec Spec) (updates.Version,
return v, nil return v, nil
case "velocity": case "velocity":
return velocityJarVersion(g.velocityJar) 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: default:
return g.sys.Current(ctx, spec) 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. // velocityJarVersion reads the installed proxy's version out of the jar itself.
// //
// It reads META-INF/MANIFEST.MF's Implementation-Version, which is authoritative // It reads META-INF/MANIFEST.MF's Implementation-Version, which is authoritative
+53
View File
@@ -104,3 +104,56 @@ func TestHostGathererUsesOwnBuildStamp(t *testing.T) {
t.Fatalf("version = %s, want 1.2.3", got) 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")
}
}
+9
View File
@@ -164,7 +164,16 @@ func TestSysGathererCurrentDispatch(t *testing.T) {
"felis-api": {[3]int{1, 4, 0}, "1.4.0"}, "felis-api": {[3]int{1, 4, 0}, "1.4.0"},
"velocity": {[3]int{3, 4, 0}, "3.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() { 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] w, ok := want[spec.Name]
if !ok { if !ok {
t.Fatalf("Topology grew a component %q with no gather expectation — update this test", spec.Name) t.Fatalf("Topology grew a component %q with no gather expectation — update this test", spec.Name)
+40 -14
View File
@@ -6,6 +6,7 @@ import (
"fmt" "fmt"
"net/http" "net/http"
"os" "os"
"strings"
"time" "time"
"felis.lolicon.best/internal/updates" "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 // 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. // 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) { 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) url := fmt.Sprintf("%s/repos/%s/releases/latest", g.baseURL, repo)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != 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 ua := g.userAgent
if ua == "" { if ua == "" {
@@ -108,7 +141,7 @@ func (g github) latestStable(ctx context.Context, repo string) (updates.Version,
resp, err := g.hc.Do(req) resp, err := g.hc.Do(req)
if err != nil { 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() defer resp.Body.Close()
if resp.StatusCode != http.StatusOK { 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 // are the same status. Name both causes, and name the fix for the one an operator
// can act on. // can act on.
if resp.StatusCode == http.StatusNotFound && g.token == "" { 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", "github: %s releases/latest returned HTTP 404 — either it has no published stable release, or it is private and %s is unset",
repo, tokenEnv) 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 var rr releaseResponse
if err := json.NewDecoder(resp.Body).Decode(&rr); err != nil { 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 { 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) return rr.TagName, nil
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
} }
+23
View File
@@ -17,8 +17,31 @@ import (
const ( const (
cloudflaredLatestFixture = `{"tag_name":"2026.6.1","prerelease":false,"draft":false,"name":"2026.6.1"}` 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"}` 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 { func newTestGitHub(srv *httptest.Server) github {
return github{ return github{
baseURL: srv.URL, baseURL: srv.URL,
+105
View File
@@ -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
}
+74
View File
@@ -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()
}
}
+19 -5
View File
@@ -144,9 +144,10 @@ func TestRunnerWithRoutingSource(t *testing.T) {
// GitHub fixtures keyed by repo. The handler also mirrors GitHub's real gate: a // GitHub fixtures keyed by repo. The handler also mirrors GitHub's real gate: a
// UA-less request is refused. // UA-less request is refused.
ghBodies := map[string]string{ ghBodies := map[string]string{
"/repos/FelisMC/Felis/releases/latest": `{"tag_name":"1.5.0","prerelease":false,"draft":false}`, "/repos/FelisMC/Felis/releases/latest": `{"tag_name":"1.5.0","prerelease":false,"draft":false}`,
"/repos/k3s-io/k3s/releases/latest": k3sLatestFixture, "/repos/k3s-io/k3s/releases/latest": k3sLatestFixture,
"/repos/cloudflare/cloudflared/releases/latest": cloudflaredLatestFixture, "/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) { ghSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("User-Agent") == "" { if r.Header.Get("User-Agent") == "" {
@@ -165,12 +166,17 @@ func TestRunnerWithRoutingSource(t *testing.T) {
rs := NewRoutingSource(Topology()) rs := NewRoutingSource(Topology())
rs.paper = newTestPaperMC(paperSrv) // point PaperMC discovery at its httptest server rs.paper = newTestPaperMC(paperSrv) // point PaperMC discovery at its httptest server
rs.gh = newTestGitHub(ghSrv) // and GitHub discovery at its own 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{ gath := fakeGatherer{cur: map[string]updates.Version{
"felis-api": mustV(t, "1.4.0"), "felis-api": mustV(t, "1.4.0"),
"k3s": mustV(t, "v1.35.6+k3s1"), "k3s": mustV(t, "v1.35.6+k3s1"),
"cloudflared": mustV(t, "2026.5.0"), "cloudflared": mustV(t, "2026.5.0"),
"velocity": mustV(t, "3.1.1"), "velocity": mustV(t, "3.1.1"),
"jre": mustV(t, "25.0.4+8"),
"postgresql": mustV(t, "13.22"),
}} }}
rn := &Runner{Gatherer: gath, Source: rs} rn := &Runner{Gatherer: gath, Source: rs}
res, err := rn.Run(context.Background(), now, updates.Window{}) res, err := rn.Run(context.Background(), now, updates.Window{})
@@ -184,13 +190,21 @@ func TestRunnerWithRoutingSource(t *testing.T) {
"felis-api", "1.5.0", "felis-api", "1.5.0",
"k3s", "v1.36.2+k3s1", "k3s", "v1.36.2+k3s1",
"cloudflared", "2026.6.1", "cloudflared", "2026.6.1",
"jre", "25.0.4.1+1",
"postgresql", "13.23",
} { } {
if !strings.Contains(res.Report, want) { if !strings.Contains(res.Report, want) {
t.Errorf("report missing discovered %q; got:\n%s", want, res.Report) 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 { 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)
} }
} }
+25 -3
View File
@@ -9,12 +9,17 @@ import (
// RoutingSource is the production updates.ReleaseSource. updates.Run calls a single // 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 // 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 // configured upstream by the topology: the PaperMC Fill API for Velocity, the GitHub
// GitHub Releases API for felis-api, k3s and cloudflared. // Releases API for felis-api, k3s, cloudflared and the Temurin JRE, and the
// PostgreSQL project's release table for the database.
type RoutingSource struct { type RoutingSource struct {
routes map[string]Spec routes map[string]Spec
paper paperMC paper paperMC
gh github 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 // 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 { for _, s := range specs {
routes[s.Name] = s 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 // Latest implements updates.ReleaseSource. An unknown component name is an error, not
// a silent zero, so a topology/route mismatch is loud. // a silent zero, so a topology/route mismatch is loud.
func (r *RoutingSource) Latest(ctx context.Context, comp updates.Component) (updates.Version, error) { 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) return r.paper.latestStable(ctx, spec.Coord)
case sourceGitHub: case sourceGitHub:
return r.gh.latestStable(ctx, spec.Coord) 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: case sourceNone:
// A pinned component (Run never reaches this, but be explicit and loud). // 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) return updates.Version{}, fmt.Errorf("updater: %q is pinned and has no release source", comp.Name)
+11 -3
View File
@@ -6,9 +6,11 @@ import "felis.lolicon.best/internal/updates"
type sourceKind int type sourceKind int
const ( const (
sourceNone sourceKind = iota // pinned components are never queried sourceNone sourceKind = iota // pinned components are never queried
sourceGitHub // GitHub Releases (Coord = "owner/repo") sourceGitHub // GitHub Releases (Coord = "owner/repo")
sourcePaperMC // PaperMC Fill v3 (Coord = project id) 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 // 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 // - 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 // NOT manageable: even under a schedule it can only ever be Notify. Its releases
// come from PaperMC (Fill v3), not GitHub. // 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 // 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. // 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: "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: "cloudflared", Policy: updates.PolicyScheduled, Manageable: true, Source: sourceGitHub, Coord: "cloudflare/cloudflared"},
{Name: "velocity", Policy: updates.PolicyNotify, Manageable: false, Source: sourcePaperMC, Coord: "velocity"}, {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},
} }
} }
+2
View File
@@ -15,6 +15,8 @@ func TestTopologyEncodesPolicies(t *testing.T) {
"k3s": {Name: "k3s", Policy: updates.PolicyNotify, Manageable: false, Source: sourceGitHub, Coord: "k3s-io/k3s"}, "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"}, "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"}, "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() got := Topology()
if len(got) != len(want) { if len(got) != len(want) {
+15 -5
View File
@@ -35,6 +35,9 @@ type Version struct {
Major int Major int
Minor int Minor int
Patch 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 // Prerelease is the dot-separated identifier set after "-" (empty for a normal
// release). Its presence is what IsPrerelease reports and what makes this version // release). Its presence is what IsPrerelease reports and what makes this version
// sort below the same Major.Minor.Patch without a prerelease. // 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", // 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 // metadata after "+" for ordering while preserving it in the raw string, and keeps
// any "-prerelease" tail. It fails closed: an unparseable core (non-numeric // 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 // 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, ".") 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) 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 { for i, p := range parts {
n, err := strconv.Atoi(strings.TrimSpace(p)) n, err := strconv.Atoi(strings.TrimSpace(p))
if err != nil { if err != nil {
@@ -85,7 +89,7 @@ func Parse(s string) (Version, error) {
} }
nums[i] = n 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 return v, nil
} }
@@ -101,6 +105,9 @@ func (v Version) String() string {
return v.raw return v.raw
} }
base := fmt.Sprintf("%d.%d.%d", v.Major, v.Minor, v.Patch) 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 != "" { if v.Prerelease != "" {
return base + "-" + 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 // 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 // version WITH a prerelease sorts below one without, and two prereleases compare by
// their dot-separated identifiers (numeric identifiers numerically, others // their dot-separated identifiers (numeric identifiers numerically, others
// lexically; a numeric identifier always sorts below an alphanumeric one). Build // 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 { if c := cmpInt(v.Patch, o.Patch); c != 0 {
return c return c
} }
if c := cmpInt(v.Revision, o.Revision); c != 0 {
return c
}
return comparePrerelease(v.Prerelease, o.Prerelease) return comparePrerelease(v.Prerelease, o.Prerelease)
} }
+23 -1
View File
@@ -21,6 +21,7 @@ func TestParseTolerant(t *testing.T) {
{"v2", 2, 0, 0, ""}, // missing minor/patch fill 0 {"v2", 2, 0, 0, ""}, // missing minor/patch fill 0
{"2.0", 2, 0, 0, ""}, // missing patch fills 0 {"2.0", 2, 0, 0, ""}, // missing patch fills 0
{" v1.2.3 ", 1, 2, 3, ""}, // surrounding whitespace {" 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 { for _, c := range cases {
v, err := Parse(c.in) 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 // 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. // that would read as "older than everything" and trigger a spurious upgrade.
func TestParseFailsClosed(t *testing.T) { 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 { for _, in := range bad {
if v, err := Parse(in); err == nil { if v, err := Parse(in); err == nil {
t.Errorf("Parse(%q) = %+v, want error", in, v) 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) { func TestCompareAndAfter(t *testing.T) {
cases := []struct { cases := []struct {
a, b string 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-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-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 {"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. // 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 // It must read EQUAL, never newer: deploy/bootstrap.sh's dev channel stamps the