feat(update): felis update 报告 JRE 与 PostgreSQL(含停更提示),bootstrap 支持 FELIS_UPGRADE_DEPS=1 升级 k3s/cloudflared
This commit is contained in:
19 files changed
+790
-78
No files matched your search
@@ -6,6 +6,7 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
@@ -38,6 +39,10 @@ import (
|
||||
// exists.
|
||||
const DefaultVelocityJarPath = "/opt/felis/velocity/velocity.jar"
|
||||
|
||||
// DefaultJREReleasePath is the release file of the runtime deploy/bootstrap.sh
|
||||
// installs for Velocity (install_jre unpacks Temurin into /opt/felis/jre).
|
||||
const DefaultJREReleasePath = "/opt/felis/jre/release"
|
||||
|
||||
// NewHostGatherer builds the VersionGatherer for `felis update` running on the node.
|
||||
// felisVersion is the CLI's own resolved build stamp; velocityJar is the installed
|
||||
// proxy jar (empty means DefaultVelocityJarPath). k3s and cloudflared keep the
|
||||
@@ -50,6 +55,7 @@ func NewHostGatherer(felisVersion, velocityJar string) VersionGatherer {
|
||||
sys: sysGatherer{run: execRunner{}},
|
||||
felisVersion: felisVersion,
|
||||
velocityJar: velocityJar,
|
||||
jreRelease: DefaultJREReleasePath,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,6 +66,7 @@ type hostGatherer struct {
|
||||
sys sysGatherer
|
||||
felisVersion string
|
||||
velocityJar string
|
||||
jreRelease string
|
||||
}
|
||||
|
||||
// Current implements VersionGatherer.
|
||||
@@ -76,11 +83,47 @@ func (g hostGatherer) Current(ctx context.Context, spec Spec) (updates.Version,
|
||||
return v, nil
|
||||
case "velocity":
|
||||
return velocityJarVersion(g.velocityJar)
|
||||
case "jre":
|
||||
return jreReleaseVersion(g.jreRelease)
|
||||
case "postgresql":
|
||||
// The server binary is on PATH on the dnf family; Debian and Ubuntu keep it
|
||||
// under /usr/lib/postgresql/<major>/bin and put only the client on PATH, which
|
||||
// the distribution ships at the same version.
|
||||
if v, err := g.sys.cliVersion(ctx, "postgres"); err == nil {
|
||||
return v, nil
|
||||
}
|
||||
return g.sys.cliVersion(ctx, "psql")
|
||||
default:
|
||||
return g.sys.Current(ctx, spec)
|
||||
}
|
||||
}
|
||||
|
||||
// jreReleaseVersion reads the runtime's version from its release file. Temurin writes
|
||||
// SEMANTIC_VERSION="25.0.4.1+1"; JAVA_VERSION="25.0.4.1" is the fallback every JDK
|
||||
// build writes. JAVA_RUNTIME_VERSION is avoided: its "-LTS" tail reads as a prerelease.
|
||||
func jreReleaseVersion(path string) (updates.Version, error) {
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return updates.Version{}, fmt.Errorf("updater: read JRE release file: %w", err)
|
||||
}
|
||||
fields := map[string]string{}
|
||||
for _, line := range strings.Split(string(raw), "\n") {
|
||||
k, v, ok := strings.Cut(line, "=")
|
||||
if ok {
|
||||
fields[strings.TrimSpace(k)] = strings.Trim(strings.TrimSpace(v), `"`)
|
||||
}
|
||||
}
|
||||
for _, key := range []string{"SEMANTIC_VERSION", "JAVA_VERSION"} {
|
||||
if fields[key] == "" {
|
||||
continue
|
||||
}
|
||||
if v, err := updates.Parse(fields[key]); err == nil {
|
||||
return v, nil
|
||||
}
|
||||
}
|
||||
return updates.Version{}, fmt.Errorf("updater: no SEMANTIC_VERSION or JAVA_VERSION in %s", path)
|
||||
}
|
||||
|
||||
// velocityJarVersion reads the installed proxy's version out of the jar itself.
|
||||
//
|
||||
// It reads META-INF/MANIFEST.MF's Implementation-Version, which is authoritative
|
||||
|
||||
@@ -104,3 +104,56 @@ func TestHostGathererUsesOwnBuildStamp(t *testing.T) {
|
||||
t.Fatalf("version = %s, want 1.2.3", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The JRE answers from its release file. Temurin's SEMANTIC_VERSION keeps the respin
|
||||
// component; JAVA_RUNTIME_VERSION's "-LTS" tail would read as a prerelease.
|
||||
func TestJREReleaseVersion(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cases := map[string]string{
|
||||
"JAVA_RUNTIME_VERSION=\"25.0.4.1+1-LTS\"\nJAVA_VERSION=\"25.0.4.1\"\nSEMANTIC_VERSION=\"25.0.4.1+1\"\n": "25.0.4.1+1",
|
||||
"JAVA_VERSION=\"21.0.8\"\n": "21.0.8",
|
||||
}
|
||||
for body, want := range cases {
|
||||
path := filepath.Join(dir, "release")
|
||||
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
v, err := jreReleaseVersion(path)
|
||||
if err != nil {
|
||||
t.Fatalf("jreReleaseVersion(%q): %v", body, err)
|
||||
}
|
||||
if v.String() != want || v.IsPrerelease() {
|
||||
t.Errorf("jreReleaseVersion(%q) = %s, want stable %s", body, v, want)
|
||||
}
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "release"), []byte("IMPLEMENTOR=\"x\"\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if v, err := jreReleaseVersion(filepath.Join(dir, "release")); err == nil {
|
||||
t.Errorf("a release file without a version = %s, want an error", v)
|
||||
}
|
||||
if _, err := jreReleaseVersion(filepath.Join(dir, "missing")); err == nil {
|
||||
t.Error("a missing release file must be an error")
|
||||
}
|
||||
}
|
||||
|
||||
// PostgreSQL answers from the server binary where it is on PATH, else from the client.
|
||||
func TestHostGathererPostgres(t *testing.T) {
|
||||
for name, out := range map[string]map[string][]byte{
|
||||
"server on PATH": {"postgres": []byte("postgres (PostgreSQL) 13.23\n"), "psql": []byte("psql (PostgreSQL) 12.1\n")},
|
||||
"client only": {"psql": []byte("psql (PostgreSQL) 13.23 (Ubuntu 13.23-1.pgdg24.04+1)\n")},
|
||||
} {
|
||||
g := hostGatherer{sys: sysGatherer{run: fakeCmd{out: out}}}
|
||||
v, err := g.Current(context.Background(), Spec{Name: "postgresql"})
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", name, err)
|
||||
}
|
||||
if v.String() != "13.23" {
|
||||
t.Errorf("%s: version = %s, want 13.23", name, v)
|
||||
}
|
||||
}
|
||||
g := hostGatherer{sys: sysGatherer{run: fakeCmd{out: map[string][]byte{}}}}
|
||||
if _, err := g.Current(context.Background(), Spec{Name: "postgresql"}); err == nil {
|
||||
t.Error("no postgres and no psql must be an error")
|
||||
}
|
||||
}
|
||||
@@ -164,7 +164,16 @@ func TestSysGathererCurrentDispatch(t *testing.T) {
|
||||
"felis-api": {[3]int{1, 4, 0}, "1.4.0"},
|
||||
"velocity": {[3]int{3, 4, 0}, "3.4.0"},
|
||||
}
|
||||
// The JRE and PostgreSQL live on the host alone; hostGatherer answers them
|
||||
// (gatherer_host_test.go), and here they must fail closed.
|
||||
hostOnly := map[string]bool{"jre": true, "postgresql": true}
|
||||
for _, spec := range Topology() {
|
||||
if hostOnly[spec.Name] {
|
||||
if v, err := g.Current(context.Background(), spec); err == nil {
|
||||
t.Errorf("Current(%s) = %s from the system gatherer, want an error", spec.Name, v)
|
||||
}
|
||||
continue
|
||||
}
|
||||
w, ok := want[spec.Name]
|
||||
if !ok {
|
||||
t.Fatalf("Topology grew a component %q with no gather expectation — update this test", spec.Name)
|
||||
|
||||
+40
-14
@@ -6,6 +6,7 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"felis.lolicon.best/internal/updates"
|
||||
@@ -91,10 +92,42 @@ type releaseResponse struct {
|
||||
// already excludes drafts and prereleases; the explicit re-checks are defense in depth so
|
||||
// an upstream change can never silently promote a prerelease into a scheduled apply.
|
||||
func (g github) latestStable(ctx context.Context, repo string) (updates.Version, error) {
|
||||
tag, err := g.latestTag(ctx, repo)
|
||||
if err != nil {
|
||||
return updates.Version{}, err
|
||||
}
|
||||
return parseStableTag(repo, tag)
|
||||
}
|
||||
|
||||
// latestTemurin returns the newest stable Temurin build of one JDK feature release.
|
||||
// Adoptium publishes each feature line from its own repository and tags every build
|
||||
// "jdk-<version>" ("jdk-25.0.4.1+1"); the prefix is the only thing Parse cannot take.
|
||||
func (g github) latestTemurin(ctx context.Context, feature int) (updates.Version, error) {
|
||||
repo := fmt.Sprintf("adoptium/temurin%d-binaries", feature)
|
||||
tag, err := g.latestTag(ctx, repo)
|
||||
if err != nil {
|
||||
return updates.Version{}, err
|
||||
}
|
||||
return parseStableTag(repo, strings.TrimPrefix(tag, "jdk-"))
|
||||
}
|
||||
|
||||
func parseStableTag(repo, tag string) (updates.Version, error) {
|
||||
v, err := updates.Parse(tag)
|
||||
if err != nil {
|
||||
return updates.Version{}, fmt.Errorf("github: parse tag %q for %s: %w", tag, repo, err)
|
||||
}
|
||||
if v.IsPrerelease() {
|
||||
return updates.Version{}, fmt.Errorf("github: %s latest tag %q parses as a prerelease", repo, tag)
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
// latestTag fetches the tag of repo's /releases/latest.
|
||||
func (g github) latestTag(ctx context.Context, repo string) (string, error) {
|
||||
url := fmt.Sprintf("%s/repos/%s/releases/latest", g.baseURL, repo)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return updates.Version{}, fmt.Errorf("github: build request for %s: %w", repo, err)
|
||||
return "", fmt.Errorf("github: build request for %s: %w", repo, err)
|
||||
}
|
||||
ua := g.userAgent
|
||||
if ua == "" {
|
||||
@@ -108,7 +141,7 @@ func (g github) latestStable(ctx context.Context, repo string) (updates.Version,
|
||||
|
||||
resp, err := g.hc.Do(req)
|
||||
if err != nil {
|
||||
return updates.Version{}, fmt.Errorf("github: get %s: %w", repo, err)
|
||||
return "", fmt.Errorf("github: get %s: %w", repo, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
@@ -117,27 +150,20 @@ func (g github) latestStable(ctx context.Context, repo string) (updates.Version,
|
||||
// are the same status. Name both causes, and name the fix for the one an operator
|
||||
// can act on.
|
||||
if resp.StatusCode == http.StatusNotFound && g.token == "" {
|
||||
return updates.Version{}, fmt.Errorf(
|
||||
return "", fmt.Errorf(
|
||||
"github: %s releases/latest returned HTTP 404 — either it has no published stable release, or it is private and %s is unset",
|
||||
repo, tokenEnv)
|
||||
}
|
||||
return updates.Version{}, fmt.Errorf("github: %s releases/latest returned HTTP %d", repo, resp.StatusCode)
|
||||
return "", fmt.Errorf("github: %s releases/latest returned HTTP %d", repo, resp.StatusCode)
|
||||
}
|
||||
|
||||
var rr releaseResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&rr); err != nil {
|
||||
return updates.Version{}, fmt.Errorf("github: decode %s: %w", repo, err)
|
||||
return "", fmt.Errorf("github: decode %s: %w", repo, err)
|
||||
}
|
||||
if rr.Draft || rr.Prerelease {
|
||||
return updates.Version{}, fmt.Errorf("github: %s releases/latest is unexpectedly draft/prerelease (tag %q)", repo, rr.TagName)
|
||||
return "", fmt.Errorf("github: %s releases/latest is unexpectedly draft/prerelease (tag %q)", repo, rr.TagName)
|
||||
}
|
||||
|
||||
v, err := updates.Parse(rr.TagName)
|
||||
if err != nil {
|
||||
return updates.Version{}, fmt.Errorf("github: parse tag %q for %s: %w", rr.TagName, repo, err)
|
||||
}
|
||||
if v.IsPrerelease() {
|
||||
return updates.Version{}, fmt.Errorf("github: %s latest tag %q parses as a prerelease", repo, rr.TagName)
|
||||
}
|
||||
return v, nil
|
||||
return rr.TagName, nil
|
||||
}
|
||||
@@ -17,8 +17,31 @@ import (
|
||||
const (
|
||||
cloudflaredLatestFixture = `{"tag_name":"2026.6.1","prerelease":false,"draft":false,"name":"2026.6.1"}`
|
||||
k3sLatestFixture = `{"tag_name":"v1.36.2+k3s1","prerelease":false,"draft":false,"name":"v1.36.2+k3s1"}`
|
||||
// adoptium/temurin25-binaries on 2026-09-25: an emergency respin, four components.
|
||||
temurinLatestFixture = `{"tag_name":"jdk-25.0.4.1+1","prerelease":false,"draft":false,"name":"jdk-25.0.4.1+1"}`
|
||||
)
|
||||
|
||||
// TestGitHubLatestTemurin reads the feature line's repository and drops the "jdk-"
|
||||
// prefix Adoptium tags every build with.
|
||||
func TestGitHubLatestTemurin(t *testing.T) {
|
||||
var path string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
path = r.URL.Path
|
||||
_, _ = w.Write([]byte(temurinLatestFixture))
|
||||
}))
|
||||
defer srv.Close()
|
||||
v, err := newTestGitHub(srv).latestTemurin(context.Background(), 25)
|
||||
if err != nil {
|
||||
t.Fatalf("latestTemurin: %v", err)
|
||||
}
|
||||
if path != "/repos/adoptium/temurin25-binaries/releases/latest" {
|
||||
t.Errorf("requested %s, want the temurin25-binaries repository", path)
|
||||
}
|
||||
if v.String() != "25.0.4.1+1" || v.Revision != 1 {
|
||||
t.Errorf("version = %s (%+v), want 25.0.4.1+1 with revision 1", v, v)
|
||||
}
|
||||
}
|
||||
|
||||
func newTestGitHub(srv *httptest.Server) github {
|
||||
return github{
|
||||
baseURL: srv.URL,
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
package updater
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"felis.lolicon.best/internal/updates"
|
||||
)
|
||||
|
||||
// postgresFeedURL is the PostgreSQL project's machine-readable release table, the one
|
||||
// its own versioning page renders from. Checked 2026-09-25: an array with one entry per
|
||||
// major, e.g. {"major":"13","latestMinor":"23","supported":false,"eolDate":"2025-11-13"}.
|
||||
const postgresFeedURL = "https://www.postgresql.org/versions.json"
|
||||
|
||||
// postgresFeed discovers the newest minor release of the host's PostgreSQL major.
|
||||
//
|
||||
// Felis installs PostgreSQL from the distribution, so the major is whatever the
|
||||
// distribution ships and moving it is a pg_upgrade the operator plans. The report
|
||||
// therefore compares within the major (a minor release is a package update), and a
|
||||
// major that is past its end of life is surfaced separately as a note.
|
||||
type postgresFeed struct {
|
||||
url string
|
||||
userAgent string
|
||||
hc *http.Client
|
||||
}
|
||||
|
||||
func newPostgresFeed() postgresFeed {
|
||||
return postgresFeed{url: postgresFeedURL, userAgent: defaultUserAgent, hc: &http.Client{Timeout: 15 * time.Second}}
|
||||
}
|
||||
|
||||
type postgresMajor struct {
|
||||
Major string `json:"major"`
|
||||
LatestMinor string `json:"latestMinor"`
|
||||
Supported bool `json:"supported"`
|
||||
Current bool `json:"current"`
|
||||
EOLDate string `json:"eolDate"`
|
||||
}
|
||||
|
||||
// postgresRelease is one lookup's answer: the newest minor of the current major, and
|
||||
// a note when that major is no longer supported.
|
||||
type postgresRelease struct {
|
||||
latest updates.Version
|
||||
note string
|
||||
}
|
||||
|
||||
// majorKey is the feed's name for the major a version belongs to: "13" from 10 on,
|
||||
// "9.6" before that, when the second number was still part of the major.
|
||||
func majorKey(v updates.Version) string {
|
||||
if v.Major < 10 {
|
||||
return fmt.Sprintf("%d.%d", v.Major, v.Minor)
|
||||
}
|
||||
return strconv.Itoa(v.Major)
|
||||
}
|
||||
|
||||
func (p postgresFeed) latest(ctx context.Context, current updates.Version) (postgresRelease, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, p.url, nil)
|
||||
if err != nil {
|
||||
return postgresRelease{}, fmt.Errorf("postgresql: build request: %w", err)
|
||||
}
|
||||
req.Header.Set("User-Agent", p.userAgent)
|
||||
resp, err := p.hc.Do(req)
|
||||
if err != nil {
|
||||
return postgresRelease{}, fmt.Errorf("postgresql: get %s: %w", p.url, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return postgresRelease{}, fmt.Errorf("postgresql: %s returned HTTP %d", p.url, resp.StatusCode)
|
||||
}
|
||||
var majors []postgresMajor
|
||||
if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&majors); err != nil {
|
||||
return postgresRelease{}, fmt.Errorf("postgresql: decode %s: %w", p.url, err)
|
||||
}
|
||||
|
||||
key := majorKey(current)
|
||||
var mine *postgresMajor
|
||||
newest := ""
|
||||
for i := range majors {
|
||||
if majors[i].Major == key {
|
||||
mine = &majors[i]
|
||||
}
|
||||
if majors[i].Current {
|
||||
newest = majors[i].Major
|
||||
}
|
||||
}
|
||||
if mine == nil {
|
||||
return postgresRelease{}, fmt.Errorf("postgresql: the release feed has no major %s", key)
|
||||
}
|
||||
latest, err := updates.Parse(mine.Major + "." + mine.LatestMinor)
|
||||
if err != nil {
|
||||
return postgresRelease{}, fmt.Errorf("postgresql: major %s latest minor %q: %w", key, mine.LatestMinor, err)
|
||||
}
|
||||
rel := postgresRelease{latest: latest}
|
||||
if !mine.Supported {
|
||||
rel.note = fmt.Sprintf("PostgreSQL %s reached end of life on %s and gets no more fixes", key, mine.EOLDate)
|
||||
if newest != "" {
|
||||
rel.note += fmt.Sprintf("; the current major is %s (pg_upgrade, see docs/operations.md §4)", newest)
|
||||
}
|
||||
}
|
||||
return rel, nil
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package updater
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// postgresFeedFixture is a slice of https://www.postgresql.org/versions.json as served
|
||||
// on 2026-09-25: a pre-10 major, an end-of-life major, and the current one.
|
||||
const postgresFeedFixture = `[
|
||||
{"current":false,"eolDate":"2021-11-11","firstRelDate":"2016-09-29","latestMinor":"24","major":"9.6","relDate":"2021-11-11","supported":false},
|
||||
{"current":false,"eolDate":"2025-11-13","firstRelDate":"2020-09-24","latestMinor":"23","major":"13","relDate":"2025-11-13","supported":false},
|
||||
{"current":false,"eolDate":"2029-11-08","firstRelDate":"2024-09-26","latestMinor":"10","major":"17","relDate":"2026-08-13","supported":true},
|
||||
{"current":true,"eolDate":"2030-11-14","firstRelDate":"2025-09-25","latestMinor":"6","major":"18","relDate":"2026-08-13","supported":true}
|
||||
]`
|
||||
|
||||
func pgFixtureServer(body string) *httptest.Server {
|
||||
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(body))
|
||||
}))
|
||||
}
|
||||
|
||||
func newTestPostgresFeed(srv *httptest.Server) postgresFeed {
|
||||
return postgresFeed{url: srv.URL, userAgent: "felis-updater/0.1", hc: srv.Client()}
|
||||
}
|
||||
|
||||
func TestPostgresFeedComparesWithinTheMajor(t *testing.T) {
|
||||
srv := pgFixtureServer(postgresFeedFixture)
|
||||
defer srv.Close()
|
||||
cases := []struct {
|
||||
current, latest string
|
||||
note []string
|
||||
}{
|
||||
{"17.4", "17.10", nil},
|
||||
{"18.6", "18.6", nil},
|
||||
{"13.22", "13.23", []string{"PostgreSQL 13 reached end of life on 2025-11-13", "current major is 18"}},
|
||||
{"9.6.3", "9.6.24", []string{"PostgreSQL 9.6 reached end of life"}},
|
||||
}
|
||||
for _, c := range cases {
|
||||
rel, err := newTestPostgresFeed(srv).latest(context.Background(), mustV(t, c.current))
|
||||
if err != nil {
|
||||
t.Fatalf("latest(%s): %v", c.current, err)
|
||||
}
|
||||
if rel.latest.Compare(mustV(t, c.latest)) != 0 {
|
||||
t.Errorf("latest(%s) = %s, want %s", c.current, rel.latest, c.latest)
|
||||
}
|
||||
if len(c.note) == 0 && rel.note != "" {
|
||||
t.Errorf("latest(%s) note = %q, want none for a supported major", c.current, rel.note)
|
||||
}
|
||||
for _, w := range c.note {
|
||||
if !strings.Contains(rel.note, w) {
|
||||
t.Errorf("latest(%s) note = %q, want it to mention %q", c.current, rel.note, w)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostgresFeedFailsClosed(t *testing.T) {
|
||||
for name, body := range map[string]string{
|
||||
"unknown major": postgresFeedFixture,
|
||||
"garbled feed": `{"not":"a list"}`,
|
||||
"garbled minor": `[{"major":"16","latestMinor":"x","supported":true}]`,
|
||||
} {
|
||||
srv := pgFixtureServer(body)
|
||||
if v, err := newTestPostgresFeed(srv).latest(context.Background(), mustV(t, "16.2")); err == nil {
|
||||
t.Errorf("%s: latest = %s, want an error", name, v.latest)
|
||||
}
|
||||
srv.Close()
|
||||
}
|
||||
}
|
||||
@@ -144,9 +144,10 @@ func TestRunnerWithRoutingSource(t *testing.T) {
|
||||
// GitHub fixtures keyed by repo. The handler also mirrors GitHub's real gate: a
|
||||
// UA-less request is refused.
|
||||
ghBodies := map[string]string{
|
||||
"/repos/FelisMC/Felis/releases/latest": `{"tag_name":"1.5.0","prerelease":false,"draft":false}`,
|
||||
"/repos/k3s-io/k3s/releases/latest": k3sLatestFixture,
|
||||
"/repos/cloudflare/cloudflared/releases/latest": cloudflaredLatestFixture,
|
||||
"/repos/FelisMC/Felis/releases/latest": `{"tag_name":"1.5.0","prerelease":false,"draft":false}`,
|
||||
"/repos/k3s-io/k3s/releases/latest": k3sLatestFixture,
|
||||
"/repos/cloudflare/cloudflared/releases/latest": cloudflaredLatestFixture,
|
||||
"/repos/adoptium/temurin25-binaries/releases/latest": temurinLatestFixture,
|
||||
}
|
||||
ghSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("User-Agent") == "" {
|
||||
@@ -165,12 +166,17 @@ func TestRunnerWithRoutingSource(t *testing.T) {
|
||||
rs := NewRoutingSource(Topology())
|
||||
rs.paper = newTestPaperMC(paperSrv) // point PaperMC discovery at its httptest server
|
||||
rs.gh = newTestGitHub(ghSrv) // and GitHub discovery at its own
|
||||
pgSrv := pgFixtureServer(postgresFeedFixture)
|
||||
defer pgSrv.Close()
|
||||
rs.pg = newTestPostgresFeed(pgSrv)
|
||||
|
||||
gath := fakeGatherer{cur: map[string]updates.Version{
|
||||
"felis-api": mustV(t, "1.4.0"),
|
||||
"k3s": mustV(t, "v1.35.6+k3s1"),
|
||||
"cloudflared": mustV(t, "2026.5.0"),
|
||||
"velocity": mustV(t, "3.1.1"),
|
||||
"jre": mustV(t, "25.0.4+8"),
|
||||
"postgresql": mustV(t, "13.22"),
|
||||
}}
|
||||
rn := &Runner{Gatherer: gath, Source: rs}
|
||||
res, err := rn.Run(context.Background(), now, updates.Window{})
|
||||
@@ -184,13 +190,21 @@ func TestRunnerWithRoutingSource(t *testing.T) {
|
||||
"felis-api", "1.5.0",
|
||||
"k3s", "v1.36.2+k3s1",
|
||||
"cloudflared", "2026.6.1",
|
||||
"jre", "25.0.4.1+1",
|
||||
"postgresql", "13.23",
|
||||
} {
|
||||
if !strings.Contains(res.Report, want) {
|
||||
t.Errorf("report missing discovered %q; got:\n%s", want, res.Report)
|
||||
}
|
||||
}
|
||||
// Both routes are wired now, so nothing degrades to a source error.
|
||||
// Every route is wired, so nothing degrades to a source error.
|
||||
if len(res.RunResult.SourceErrors) != 0 {
|
||||
t.Errorf("expected no source errors with both routes wired, got %v", res.RunResult.SourceErrors)
|
||||
t.Errorf("expected no source errors with every route wired, got %v", res.RunResult.SourceErrors)
|
||||
}
|
||||
if len(res.GatherErrors) != 0 {
|
||||
t.Errorf("expected every component gathered, got %v", res.GatherErrors)
|
||||
}
|
||||
if note := rs.Notes()["postgresql"]; !strings.Contains(note, "end of life on 2025-11-13") || !strings.Contains(note, "current major is 18") {
|
||||
t.Errorf("postgresql note = %q, want the EOL date and the current major", note)
|
||||
}
|
||||
}
|
||||
@@ -9,12 +9,17 @@ import (
|
||||
|
||||
// RoutingSource is the production updates.ReleaseSource. updates.Run calls a single
|
||||
// source for every non-pinned component, so this one dispatches each component to its
|
||||
// configured upstream by the topology: the PaperMC Fill API for Velocity, and the
|
||||
// GitHub Releases API for felis-api, k3s and cloudflared.
|
||||
// configured upstream by the topology: the PaperMC Fill API for Velocity, the GitHub
|
||||
// Releases API for felis-api, k3s, cloudflared and the Temurin JRE, and the
|
||||
// PostgreSQL project's release table for the database.
|
||||
type RoutingSource struct {
|
||||
routes map[string]Spec
|
||||
paper paperMC
|
||||
gh github
|
||||
pg postgresFeed
|
||||
// notes holds what a lookup learned beyond the version, keyed by component: today
|
||||
// only an end-of-life PostgreSQL major. updates.Run calls Latest sequentially.
|
||||
notes map[string]string
|
||||
}
|
||||
|
||||
// NewRoutingSource builds the router from a topology. Pinned specs are indexed too
|
||||
@@ -24,9 +29,13 @@ func NewRoutingSource(specs []Spec) *RoutingSource {
|
||||
for _, s := range specs {
|
||||
routes[s.Name] = s
|
||||
}
|
||||
return &RoutingSource{routes: routes, paper: newPaperMC(), gh: newGitHub()}
|
||||
return &RoutingSource{routes: routes, paper: newPaperMC(), gh: newGitHub(), pg: newPostgresFeed(), notes: map[string]string{}}
|
||||
}
|
||||
|
||||
// Notes returns what the last lookups learned beyond each version, keyed by
|
||||
// component, for the caller to print under the report.
|
||||
func (r *RoutingSource) Notes() map[string]string { return r.notes }
|
||||
|
||||
// Latest implements updates.ReleaseSource. An unknown component name is an error, not
|
||||
// a silent zero, so a topology/route mismatch is loud.
|
||||
func (r *RoutingSource) Latest(ctx context.Context, comp updates.Component) (updates.Version, error) {
|
||||
@@ -39,6 +48,19 @@ func (r *RoutingSource) Latest(ctx context.Context, comp updates.Component) (upd
|
||||
return r.paper.latestStable(ctx, spec.Coord)
|
||||
case sourceGitHub:
|
||||
return r.gh.latestStable(ctx, spec.Coord)
|
||||
case sourceTemurin:
|
||||
// The feature release the host runs picks the repository: a newer feature is a
|
||||
// release decision (the installer's pin), never a patch to report.
|
||||
return r.gh.latestTemurin(ctx, comp.Current.Major)
|
||||
case sourcePostgres:
|
||||
rel, err := r.pg.latest(ctx, comp.Current)
|
||||
if err != nil {
|
||||
return updates.Version{}, err
|
||||
}
|
||||
if rel.note != "" {
|
||||
r.notes[comp.Name] = rel.note
|
||||
}
|
||||
return rel.latest, nil
|
||||
case sourceNone:
|
||||
// A pinned component (Run never reaches this, but be explicit and loud).
|
||||
return updates.Version{}, fmt.Errorf("updater: %q is pinned and has no release source", comp.Name)
|
||||
|
||||
@@ -6,9 +6,11 @@ import "felis.lolicon.best/internal/updates"
|
||||
type sourceKind int
|
||||
|
||||
const (
|
||||
sourceNone sourceKind = iota // pinned components are never queried
|
||||
sourceGitHub // GitHub Releases (Coord = "owner/repo")
|
||||
sourcePaperMC // PaperMC Fill v3 (Coord = project id)
|
||||
sourceNone sourceKind = iota // pinned components are never queried
|
||||
sourceGitHub // GitHub Releases (Coord = "owner/repo")
|
||||
sourcePaperMC // PaperMC Fill v3 (Coord = project id)
|
||||
sourceTemurin // adoptium/temurin<feature>-binaries, feature from Current
|
||||
sourcePostgres // postgresql.org/versions.json, within Current's major
|
||||
)
|
||||
|
||||
// Spec is one platform component's static update policy plus how to find its latest
|
||||
@@ -40,6 +42,10 @@ type Spec struct {
|
||||
// - velocity — the proxy, but off-cluster on an admin-operated macvlan host, so
|
||||
// NOT manageable: even under a schedule it can only ever be Notify. Its releases
|
||||
// come from PaperMC (Fill v3), not GitHub.
|
||||
// - jre — the Temurin runtime Velocity runs on. The installer pins its patch
|
||||
// build, so a newer build reaches a host through a Felis release: Notify only.
|
||||
// - postgresql — the control-plane database, from the distribution's packages.
|
||||
// Notify only; a minor release is a package update, a major one a pg_upgrade.
|
||||
//
|
||||
// Minecraft is deliberately ABSENT: every MC server is Pinned and is appended to the
|
||||
// plan at runtime from the live fleet (integration), never force-tracked here.
|
||||
@@ -56,5 +62,7 @@ func Topology() []Spec {
|
||||
{Name: "k3s", Policy: updates.PolicyNotify, Manageable: false, Source: sourceGitHub, Coord: "k3s-io/k3s"},
|
||||
{Name: "cloudflared", Policy: updates.PolicyScheduled, Manageable: true, Source: sourceGitHub, Coord: "cloudflare/cloudflared"},
|
||||
{Name: "velocity", Policy: updates.PolicyNotify, Manageable: false, Source: sourcePaperMC, Coord: "velocity"},
|
||||
{Name: "jre", Policy: updates.PolicyNotify, Manageable: false, Source: sourceTemurin},
|
||||
{Name: "postgresql", Policy: updates.PolicyNotify, Manageable: false, Source: sourcePostgres},
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,8 @@ func TestTopologyEncodesPolicies(t *testing.T) {
|
||||
"k3s": {Name: "k3s", Policy: updates.PolicyNotify, Manageable: false, Source: sourceGitHub, Coord: "k3s-io/k3s"},
|
||||
"cloudflared": {Name: "cloudflared", Policy: updates.PolicyScheduled, Manageable: true, Source: sourceGitHub, Coord: "cloudflare/cloudflared"},
|
||||
"velocity": {Name: "velocity", Policy: updates.PolicyNotify, Manageable: false, Source: sourcePaperMC, Coord: "velocity"},
|
||||
"jre": {Name: "jre", Policy: updates.PolicyNotify, Manageable: false, Source: sourceTemurin},
|
||||
"postgresql": {Name: "postgresql", Policy: updates.PolicyNotify, Manageable: false, Source: sourcePostgres},
|
||||
}
|
||||
got := Topology()
|
||||
if len(got) != len(want) {
|
||||
|
||||
Reference in new issue
Block a user