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

+43
View File
@@ -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
+53
View File
@@ -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")
}
}
+9
View File
@@ -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
View File
@@ -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
}
+23
View File
@@ -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,
+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
// 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)
}
}
+25 -3
View File
@@ -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)
+11 -3
View File
@@ -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},
}
}
+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"},
"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) {
+15 -5
View File
@@ -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)
}
+23 -1
View File
@@ -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