feat(updater): wire updates.Run to a caller with PaperMC v3 release discovery
internal/updates is a pure, fakes-tested decision core with no production caller, so nothing could produce its "版本号状态" report. Add internal/updater as that caller: - topology: the fixed platform components and their user-set policies (felis-api and cloudflared Scheduled+manageable; k3s Notify, high-blast-radius single node; velocity Notify, off-cluster and unmanageable). Minecraft is pinned by ABSENCE, never force-tracked here, appended from the live fleet at runtime. - PaperMC Fill v3 release source: the v2 API (api.papermc.io) was retired 2026-07-01 and returns HTTP 410, so this targets fill.papermc.io/v3, sends the required non-generic User-Agent, and returns the newest STABLE version, filtering the -SNAPSHOT/rc prereleases the plan would otherwise suppress. Its test fixture is captured from the live v3 response shape (2026-07-04). - RoutingSource: the single ReleaseSource updates.Run requires, dispatching velocity to PaperMC and returning errGitHubNotWired for the GitHub-backed components so they degrade to "latest unknown" honestly, never a fabricated one. - Runner: gather current versions (seam) -> assemble Components -> updates.Run -> Report; report-only when notifier and applier are nil. Verification boundary: the parse/plan/compose logic is unit-tested (httptest + fakes, fixture grounded in the live v3 shape). Live network/TLS/User-Agent enforcement, the GitHub Releases source, the concrete version gatherer, the notifier and applier, and the felis update CLI/CronJob remain integration work, enumerated in doc.go.
This commit is contained in:
8 files changed
+683
No files matched your search
@@ -0,0 +1,30 @@
|
|||||||
|
// Package updater is the integration/caller side of the component self-update
|
||||||
|
// subsystem — it gives the pure decision core in internal/updates its first real
|
||||||
|
// caller. internal/updates declares the seams (ReleaseSource / Notifier / Applier)
|
||||||
|
// and orchestrates them (updates.Run) but performs no I/O; this package supplies the
|
||||||
|
// concrete wiring: the platform topology (which components Felis tracks and under
|
||||||
|
// what policy), the upstream release sources, and the Runner that gathers current
|
||||||
|
// versions, runs the plan against a maintenance window, and renders the "版本号状态"
|
||||||
|
// report.
|
||||||
|
//
|
||||||
|
// Verification boundary — stated honestly so a green test suite is not mistaken for
|
||||||
|
// "the updater works against real infra":
|
||||||
|
//
|
||||||
|
// - BUILT + UNIT-VERIFIED (Go tests, WSL oracle): the topology, the PaperMC Fill
|
||||||
|
// v3 parser (its fixture is captured from the REAL live response shape on
|
||||||
|
// 2026-07-04 — a grounded contract test, not a self-referential one), the
|
||||||
|
// routing source, and the Runner's report-only composition. These prove the
|
||||||
|
// parse/plan/compose LOGIC and that the core is now wired to a caller.
|
||||||
|
//
|
||||||
|
// - WRITTEN, NOT LIVE-VERIFIED: the tests assert the required non-generic
|
||||||
|
// User-Agent is transmitted, but real fill.papermc.io network/TLS/UA-enforcement
|
||||||
|
// is not exercised here; the fixture proves today's shape, not its future
|
||||||
|
// stability.
|
||||||
|
//
|
||||||
|
// - REMAINING INTEGRATION (not built here): the GitHub Releases source (felis-api,
|
||||||
|
// k3s, cloudflared — RoutingSource returns errGitHubNotWired for them today), the
|
||||||
|
// concrete VersionGatherer (`k3s --version`, image-tag / jar inspection), the
|
||||||
|
// concrete Notifier (SMTP + in-game) and Applier (control-plane image bump,
|
||||||
|
// cloudflared swap), the `felis update` CLI + CronJob entry point, and the
|
||||||
|
// runtime append of the live Pinned Minecraft fleet.
|
||||||
|
package updater
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
package updater
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"felis.lolicon.best/internal/updates"
|
||||||
|
)
|
||||||
|
|
||||||
|
// defaultUserAgent identifies Felis to the PaperMC Fill v3 API, which REQUIRES a
|
||||||
|
// non-generic User-Agent that names the software and carries a contact URL — a
|
||||||
|
// generic default (curl, wget, Go-http-client) is refused. It uses the public Felis
|
||||||
|
// module path as the contact and contains no operator-specific serving domain; a
|
||||||
|
// deployment can override it (paperMC.userAgent) with a SysAdmin contact from config.
|
||||||
|
const defaultUserAgent = "felis-updater/0.1 (+https://felis.lolicon.best)"
|
||||||
|
|
||||||
|
// paperMC discovers the latest STABLE version of a PaperMC project (Velocity, for
|
||||||
|
// Felis) from the Fill v3 API. The old v2 API (api.papermc.io) stopped serving
|
||||||
|
// builds on 2025-12-31 and was disabled 2026-07-01 — it now returns HTTP 410 — so
|
||||||
|
// Felis targets v3 at fill.papermc.io. baseURL, userAgent and hc are fields so the
|
||||||
|
// discovery logic is exercised against an httptest server without touching the
|
||||||
|
// network (see papermc_test.go, whose fixture is captured from the real v3 shape).
|
||||||
|
type paperMC struct {
|
||||||
|
baseURL string // e.g. "https://fill.papermc.io"
|
||||||
|
userAgent string // non-generic UA with a contact (Fill v3 requirement)
|
||||||
|
hc *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
// newPaperMC builds a source pointed at the live Fill v3 endpoint with sane defaults.
|
||||||
|
func newPaperMC() paperMC {
|
||||||
|
return paperMC{
|
||||||
|
baseURL: "https://fill.papermc.io",
|
||||||
|
userAgent: defaultUserAgent,
|
||||||
|
hc: &http.Client{Timeout: 15 * time.Second},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// projectResponse is the slice of GET /v3/projects/{project} that Felis reads. The
|
||||||
|
// live v3 shape (2026-07-04) is:
|
||||||
|
//
|
||||||
|
// {"project":{"id":"velocity","name":"Velocity"},
|
||||||
|
// "versions":{"<group>":["3.4.0","3.4.0-SNAPSHOT", ...], ...}}
|
||||||
|
//
|
||||||
|
// versions is an object keyed by version-group, each value a list of published
|
||||||
|
// version strings mixing stable ("3.4.0") and prerelease ("3.4.0-SNAPSHOT"). Felis
|
||||||
|
// ignores both the grouping and the array order: it flattens every version, keeps
|
||||||
|
// only stable ones, and takes the max — so a regrouped or reordered feed yields the
|
||||||
|
// same answer.
|
||||||
|
type projectResponse struct {
|
||||||
|
Versions map[string][]string `json:"versions"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// latestStable returns the newest STABLE (non-prerelease) version published for
|
||||||
|
// project. It matters that this filters prereleases: for Velocity the newest overall
|
||||||
|
// version is routinely a "-SNAPSHOT" (e.g. 3.5.0-SNAPSHOT while the newest release is
|
||||||
|
// 3.4.0), and PlanUpdates only ever acts on a stable upgrade — a source that returned
|
||||||
|
// the SNAPSHOT would make the plan silently do nothing.
|
||||||
|
//
|
||||||
|
// It fails closed: a transport error, a non-200 status, an undecodable body, or a
|
||||||
|
// feed with no parseable stable version all return an error, so a garbled or
|
||||||
|
// SNAPSHOT-only feed never yields a bogus "latest" that could drive a spurious
|
||||||
|
// notify/apply. An individual unparseable tag is skipped, not fatal — one weird entry
|
||||||
|
// does not blind discovery to the rest.
|
||||||
|
func (p paperMC) latestStable(ctx context.Context, project string) (updates.Version, error) {
|
||||||
|
url := fmt.Sprintf("%s/v3/projects/%s", p.baseURL, project)
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||||
|
if err != nil {
|
||||||
|
return updates.Version{}, fmt.Errorf("papermc: build request for %s: %w", project, err)
|
||||||
|
}
|
||||||
|
ua := p.userAgent
|
||||||
|
if ua == "" {
|
||||||
|
ua = defaultUserAgent
|
||||||
|
}
|
||||||
|
req.Header.Set("User-Agent", ua)
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
|
||||||
|
resp, err := p.hc.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return updates.Version{}, fmt.Errorf("papermc: get %s: %w", project, err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return updates.Version{}, fmt.Errorf("papermc: %s returned HTTP %d", project, resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
var pr projectResponse
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&pr); err != nil {
|
||||||
|
return updates.Version{}, fmt.Errorf("papermc: decode %s: %w", project, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var best updates.Version
|
||||||
|
found := false
|
||||||
|
for _, group := range pr.Versions {
|
||||||
|
for _, s := range group {
|
||||||
|
v, err := updates.Parse(s)
|
||||||
|
if err != nil || v.IsPrerelease() {
|
||||||
|
continue // skip unparseable tags and prereleases (SNAPSHOT / rc)
|
||||||
|
}
|
||||||
|
if !found || v.After(best) {
|
||||||
|
best, found = v, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
return updates.Version{}, fmt.Errorf("papermc: no stable release found for %s", project)
|
||||||
|
}
|
||||||
|
return best, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
package updater
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// velocityV3Fixture is the PaperMC Fill v3 GET /v3/projects/velocity body — its shape
|
||||||
|
// and version strings captured verbatim from the live API on 2026-07-04. Grounding
|
||||||
|
// the fixture in the real response is what makes this a contract test rather than a
|
||||||
|
// self-referential one: the newest overall version is a -SNAPSHOT (3.5.0-SNAPSHOT)
|
||||||
|
// while the newest stable release is 3.4.0, so the stable filter is exercised against
|
||||||
|
// real-world data, not an invented shape. (The v2 API this replaces now returns 410.)
|
||||||
|
const velocityV3Fixture = `{
|
||||||
|
"project": {"id": "velocity", "name": "Velocity"},
|
||||||
|
"versions": {
|
||||||
|
"3.5": ["3.5.0-SNAPSHOT"],
|
||||||
|
"3.4": ["3.4.0", "3.4.0-SNAPSHOT"],
|
||||||
|
"3.3": ["3.3.0-SNAPSHOT"],
|
||||||
|
"3.2": ["3.2.0-SNAPSHOT"],
|
||||||
|
"3.1": ["3.1.2-SNAPSHOT", "3.1.1", "3.1.1-SNAPSHOT", "3.1.0"],
|
||||||
|
"1.1": ["1.1.9"],
|
||||||
|
"1.0": ["1.0.10"]
|
||||||
|
}
|
||||||
|
}`
|
||||||
|
|
||||||
|
func newTestPaperMC(srv *httptest.Server) paperMC {
|
||||||
|
return paperMC{
|
||||||
|
baseURL: srv.URL,
|
||||||
|
userAgent: "felis-updater/0.1 (+https://felis.lolicon.best)",
|
||||||
|
hc: srv.Client(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPaperMCLatestStableFiltersSnapshots is the core assertion: against the real v3
|
||||||
|
// shape, latestStable returns the newest STABLE release (3.4.0), never the newer
|
||||||
|
// 3.5.0-SNAPSHOT prerelease.
|
||||||
|
func TestPaperMCLatestStableFiltersSnapshots(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path != "/v3/projects/velocity" {
|
||||||
|
http.Error(w, "unexpected path "+r.URL.Path, http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(velocityV3Fixture))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
v, err := newTestPaperMC(srv).latestStable(context.Background(), "velocity")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("latestStable: %v", err)
|
||||||
|
}
|
||||||
|
if v.String() != "3.4.0" {
|
||||||
|
t.Errorf("latestStable = %q, want 3.4.0 (newest stable; SNAPSHOTs filtered)", v.String())
|
||||||
|
}
|
||||||
|
if v.IsPrerelease() {
|
||||||
|
t.Errorf("latestStable returned a prerelease %q", v.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPaperMCSendsNonGenericUserAgent proves Felis transmits the contact-carrying,
|
||||||
|
// non-generic User-Agent the Fill v3 API requires (a generic UA is refused upstream).
|
||||||
|
func TestPaperMCSendsNonGenericUserAgent(t *testing.T) {
|
||||||
|
var gotUA string
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
gotUA = r.Header.Get("User-Agent")
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(velocityV3Fixture))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
if _, err := newTestPaperMC(srv).latestStable(context.Background(), "velocity"); err != nil {
|
||||||
|
t.Fatalf("latestStable: %v", err)
|
||||||
|
}
|
||||||
|
if gotUA == "" {
|
||||||
|
t.Fatal("no User-Agent sent")
|
||||||
|
}
|
||||||
|
for _, bad := range []string{"Go-http-client", "curl", "wget"} {
|
||||||
|
if strings.Contains(gotUA, bad) {
|
||||||
|
t.Errorf("User-Agent %q looks generic (contains %q)", gotUA, bad)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !strings.Contains(gotUA, "felis") || !strings.Contains(gotUA, "http") {
|
||||||
|
t.Errorf("User-Agent %q should name the software and carry a contact URL", gotUA)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPaperMCFailsClosedOnHTTPError proves a non-200 (like the real 410 Gone the dead
|
||||||
|
// v2 endpoint now returns) yields an error, never a bogus zero version.
|
||||||
|
func TestPaperMCFailsClosedOnHTTPError(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
http.Error(w, "Gone", http.StatusGone)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
if v, err := newTestPaperMC(srv).latestStable(context.Background(), "velocity"); err == nil {
|
||||||
|
t.Fatalf("want error on HTTP 410, got version %q", v.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPaperMCFailsClosedWhenOnlySnapshots proves a feed with no stable release is an
|
||||||
|
// error, not a silent latest — so a SNAPSHOT-only line never drives an update.
|
||||||
|
func TestPaperMCFailsClosedWhenOnlySnapshots(t *testing.T) {
|
||||||
|
const onlySnapshots = `{"versions":{"9.9":["9.9.0-SNAPSHOT","9.8.0-SNAPSHOT"]}}`
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
_, _ = w.Write([]byte(onlySnapshots))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
if _, err := newTestPaperMC(srv).latestStable(context.Background(), "velocity"); err == nil {
|
||||||
|
t.Fatal("want error when only prereleases are published, got nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPaperMCSkipsUnparseableTags proves one garbled entry does not blind discovery:
|
||||||
|
// the max stable among the parseable versions is still returned.
|
||||||
|
func TestPaperMCSkipsUnparseableTags(t *testing.T) {
|
||||||
|
const withJunk = `{"versions":{"g":["not-a-version","3.4.0","","3.4.0-SNAPSHOT"]}}`
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
_, _ = w.Write([]byte(withJunk))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
v, err := newTestPaperMC(srv).latestStable(context.Background(), "velocity")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("latestStable: %v", err)
|
||||||
|
}
|
||||||
|
if v.String() != "3.4.0" {
|
||||||
|
t.Errorf("latestStable = %q, want 3.4.0 (junk/empty/snapshot skipped)", v.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
package updater
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"felis.lolicon.best/internal/updates"
|
||||||
|
)
|
||||||
|
|
||||||
|
// VersionGatherer reads the CURRENT version of a component from the running system.
|
||||||
|
// It is an integration seam: the real implementation shells out (`k3s --version`),
|
||||||
|
// inspects a running image tag, or reads a jar manifest — none of which this package
|
||||||
|
// does. Tests inject a fake. A gather failure for one component drops it from the
|
||||||
|
// plan (Felis will not compare a version it cannot read) rather than sinking the
|
||||||
|
// whole cycle.
|
||||||
|
type VersionGatherer interface {
|
||||||
|
Current(ctx context.Context, spec Spec) (updates.Version, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Runner is the caller that gives updates.Run its first production entry point. It
|
||||||
|
// assembles the platform topology into updates.Components — filling Current from the
|
||||||
|
// gatherer and the shared maintenance Window into Scheduled components — runs the
|
||||||
|
// pure plan, and renders the report. Notifier and Applier are optional: nil/nil is
|
||||||
|
// the report-only mode (per seams.go) Felis runs before the executors are wired.
|
||||||
|
type Runner struct {
|
||||||
|
Gatherer VersionGatherer
|
||||||
|
Source updates.ReleaseSource
|
||||||
|
Notifier updates.Notifier // optional; nil = do not notify
|
||||||
|
Applier updates.Applier // optional; nil = report-only (apply actions record errNoApplier)
|
||||||
|
Topology func() []Spec // injectable for tests; nil defaults to the package Topology
|
||||||
|
}
|
||||||
|
|
||||||
|
// Result is one Run outcome: the decision core's RunResult, the rendered "版本号状态"
|
||||||
|
// report, and any per-component current-version gather failures.
|
||||||
|
type Result struct {
|
||||||
|
RunResult updates.RunResult
|
||||||
|
Report string
|
||||||
|
GatherErrors map[string]error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run performs one update cycle for `now` against the maintenance `window`. The
|
||||||
|
// window is read by the caller (from platform_settings "update_window" via the API)
|
||||||
|
// and passed in, so the runner needs no DB seam; it is applied to every Scheduled
|
||||||
|
// component. A component whose current version cannot be gathered is skipped and
|
||||||
|
// recorded in GatherErrors rather than planned against a zero version. The error
|
||||||
|
// return mirrors updates.Run's (collected, non-fatal today) so a future hard-stop has
|
||||||
|
// a channel.
|
||||||
|
func (rn *Runner) Run(ctx context.Context, now time.Time, window updates.Window) (Result, error) {
|
||||||
|
topo := rn.Topology
|
||||||
|
if topo == nil {
|
||||||
|
topo = Topology
|
||||||
|
}
|
||||||
|
|
||||||
|
res := Result{GatherErrors: map[string]error{}}
|
||||||
|
var comps []updates.Component
|
||||||
|
for _, spec := range topo() {
|
||||||
|
cur, err := rn.Gatherer.Current(ctx, spec)
|
||||||
|
if err != nil {
|
||||||
|
res.GatherErrors[spec.Name] = err
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
c := updates.Component{
|
||||||
|
Name: spec.Name,
|
||||||
|
Current: cur,
|
||||||
|
Policy: spec.Policy,
|
||||||
|
Manageable: spec.Manageable,
|
||||||
|
}
|
||||||
|
// The window is only ever consulted for a Scheduled component (PlanUpdates
|
||||||
|
// ignores it otherwise), but setting it only where it applies keeps intent clear.
|
||||||
|
if spec.Policy == updates.PolicyScheduled {
|
||||||
|
c.Window = window
|
||||||
|
}
|
||||||
|
comps = append(comps, c)
|
||||||
|
}
|
||||||
|
|
||||||
|
rr, err := updates.Run(ctx, rn.Source, rn.Notifier, rn.Applier, comps, now)
|
||||||
|
res.RunResult = rr
|
||||||
|
res.Report = updates.Report(rr.Plan)
|
||||||
|
return res, err
|
||||||
|
}
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
package updater
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"felis.lolicon.best/internal/updates"
|
||||||
|
)
|
||||||
|
|
||||||
|
func mustV(t *testing.T, s string) updates.Version {
|
||||||
|
t.Helper()
|
||||||
|
v, err := updates.Parse(s)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse %q: %v", s, err)
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
// fakeGatherer returns canned current versions, or an error for names in failFor.
|
||||||
|
type fakeGatherer struct {
|
||||||
|
cur map[string]updates.Version
|
||||||
|
failFor map[string]bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f fakeGatherer) Current(_ context.Context, s Spec) (updates.Version, error) {
|
||||||
|
if f.failFor[s.Name] {
|
||||||
|
return updates.Version{}, errors.New("gather boom")
|
||||||
|
}
|
||||||
|
v, ok := f.cur[s.Name]
|
||||||
|
if !ok {
|
||||||
|
return updates.Version{}, errors.New("no current for " + s.Name)
|
||||||
|
}
|
||||||
|
return v, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// fakeSource returns canned latest versions, isolating the runner's wiring from any
|
||||||
|
// real release source.
|
||||||
|
type fakeSource struct{ latest map[string]updates.Version }
|
||||||
|
|
||||||
|
func (f fakeSource) Latest(_ context.Context, c updates.Component) (updates.Version, error) {
|
||||||
|
v, ok := f.latest[c.Name]
|
||||||
|
if !ok {
|
||||||
|
return updates.Version{}, errors.New("not found")
|
||||||
|
}
|
||||||
|
return v, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRunnerReportOnlyComposesPlan drives the whole slice with fakes and nil
|
||||||
|
// notifier/applier (report-only): gather → assemble → updates.Run → Report. With the
|
||||||
|
// window open, the two Scheduled+manageable components (felis-api, cloudflared) plan
|
||||||
|
// to Apply, but with no applier wired the runner applies nothing and records the
|
||||||
|
// no-applier error — the honest report-only state, not a silent success.
|
||||||
|
func TestRunnerReportOnlyComposesPlan(t *testing.T) {
|
||||||
|
now := time.Date(2026, 7, 4, 3, 30, 0, 0, time.UTC)
|
||||||
|
window := updates.Window{
|
||||||
|
Start: time.Date(2026, 7, 4, 3, 0, 0, 0, time.UTC),
|
||||||
|
End: time.Date(2026, 7, 4, 4, 0, 0, 0, time.UTC),
|
||||||
|
}
|
||||||
|
gath := fakeGatherer{cur: map[string]updates.Version{
|
||||||
|
"felis-api": mustV(t, "1.4.0"),
|
||||||
|
"k3s": mustV(t, "v1.30.2+k3s1"),
|
||||||
|
"cloudflared": mustV(t, "2024.2.1"),
|
||||||
|
"velocity": mustV(t, "3.1.1"),
|
||||||
|
}}
|
||||||
|
src := fakeSource{latest: map[string]updates.Version{
|
||||||
|
"felis-api": mustV(t, "1.5.0"), // newer stable → apply (scheduled+manageable, window open)
|
||||||
|
"k3s": mustV(t, "v1.30.3+k3s1"), // newer → notify (high blast radius, unmanageable)
|
||||||
|
"cloudflared": mustV(t, "2024.3.0"), // newer stable → apply
|
||||||
|
"velocity": mustV(t, "3.4.0"), // newer stable → notify (off-cluster, unmanageable)
|
||||||
|
}}
|
||||||
|
|
||||||
|
rn := &Runner{Gatherer: gath, Source: src} // nil Notifier + nil Applier = report-only
|
||||||
|
res, err := rn.Run(context.Background(), now, window)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Run: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, want := range []string{
|
||||||
|
"felis-api", "1.5.0", "apply (scheduled window)",
|
||||||
|
"cloudflared", "2024.3.0",
|
||||||
|
"k3s", "update available (notify)",
|
||||||
|
"velocity", "3.4.0",
|
||||||
|
} {
|
||||||
|
if !strings.Contains(res.Report, want) {
|
||||||
|
t.Errorf("report missing %q; got:\n%s", want, res.Report)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(res.RunResult.Applied) != 0 {
|
||||||
|
t.Errorf("report-only run applied %+v, want nothing", res.RunResult.Applied)
|
||||||
|
}
|
||||||
|
for _, name := range []string{"felis-api", "cloudflared"} {
|
||||||
|
if res.RunResult.ApplyErrors[name] == nil {
|
||||||
|
t.Errorf("report-only run should record a no-applier error for %s (apply wanted, none wired)", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRunnerSkipsUngatherableComponent proves a component whose current version cannot
|
||||||
|
// be read is dropped from the plan and recorded, not planned against a zero version.
|
||||||
|
func TestRunnerSkipsUngatherableComponent(t *testing.T) {
|
||||||
|
now := time.Date(2026, 7, 4, 3, 30, 0, 0, time.UTC)
|
||||||
|
gath := fakeGatherer{
|
||||||
|
cur: map[string]updates.Version{"velocity": mustV(t, "3.1.1")},
|
||||||
|
failFor: map[string]bool{"felis-api": true, "k3s": true, "cloudflared": true},
|
||||||
|
}
|
||||||
|
src := fakeSource{latest: map[string]updates.Version{"velocity": mustV(t, "3.4.0")}}
|
||||||
|
|
||||||
|
rn := &Runner{Gatherer: gath, Source: src}
|
||||||
|
res, err := rn.Run(context.Background(), now, updates.Window{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Run: %v", err)
|
||||||
|
}
|
||||||
|
if res.GatherErrors["felis-api"] == nil {
|
||||||
|
t.Error("felis-api gather failure should be recorded")
|
||||||
|
}
|
||||||
|
if len(res.RunResult.Plan) != 1 || res.RunResult.Plan[0].Component != "velocity" {
|
||||||
|
t.Errorf("plan = %+v, want just velocity (the only gatherable component)", res.RunResult.Plan)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRunnerWithRoutingSource wires the real RoutingSource — PaperMC via the live-shape
|
||||||
|
// httptest fixture, GitHub honestly un-wired — through the runner end to end. velocity
|
||||||
|
// discovers its real latest stable (3.4.0 → notify); the GitHub-backed components
|
||||||
|
// degrade to "latest unknown" via the recorded errGitHubNotWired, never a fabricated
|
||||||
|
// version.
|
||||||
|
func TestRunnerWithRoutingSource(t *testing.T) {
|
||||||
|
now := time.Date(2026, 7, 4, 3, 30, 0, 0, time.UTC)
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !strings.HasPrefix(r.URL.Path, "/v3/projects/") {
|
||||||
|
http.Error(w, "unexpected "+r.URL.Path, http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, _ = w.Write([]byte(velocityV3Fixture))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
rs := NewRoutingSource(Topology())
|
||||||
|
rs.paper = newTestPaperMC(srv) // point PaperMC discovery at the httptest server
|
||||||
|
|
||||||
|
gath := fakeGatherer{cur: map[string]updates.Version{
|
||||||
|
"felis-api": mustV(t, "1.4.0"),
|
||||||
|
"k3s": mustV(t, "v1.30.2+k3s1"),
|
||||||
|
"cloudflared": mustV(t, "2024.2.1"),
|
||||||
|
"velocity": mustV(t, "3.1.1"),
|
||||||
|
}}
|
||||||
|
rn := &Runner{Gatherer: gath, Source: rs}
|
||||||
|
res, err := rn.Run(context.Background(), now, updates.Window{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Run: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strings.Contains(res.Report, "velocity") || !strings.Contains(res.Report, "3.4.0") {
|
||||||
|
t.Errorf("report should show velocity's discovered latest 3.4.0; got:\n%s", res.Report)
|
||||||
|
}
|
||||||
|
for _, name := range []string{"felis-api", "k3s", "cloudflared"} {
|
||||||
|
if !errors.Is(res.RunResult.SourceErrors[name], errGitHubNotWired) {
|
||||||
|
t.Errorf("SourceErrors[%s] = %v, want errGitHubNotWired", name, res.RunResult.SourceErrors[name])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
package updater
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"felis.lolicon.best/internal/updates"
|
||||||
|
)
|
||||||
|
|
||||||
|
// errGitHubNotWired is returned by RoutingSource for GitHub-backed components until
|
||||||
|
// the GitHub Releases source is implemented. updates.Run tolerates a per-component
|
||||||
|
// source error (records it in SourceErrors and plans that component to ActionNone),
|
||||||
|
// so an un-wired GitHub source degrades those components to "latest unknown" in the
|
||||||
|
// report — honest, never a fabricated version. It is a distinct sentinel so the gap
|
||||||
|
// is greppable and testable, not silently swallowed.
|
||||||
|
var errGitHubNotWired = errors.New("updater: github release source not yet wired")
|
||||||
|
|
||||||
|
// 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. Today it fully implements the PaperMC route
|
||||||
|
// (Velocity) and returns errGitHubNotWired for the GitHub-backed components
|
||||||
|
// (felis-api, k3s, cloudflared), which are enumerated remaining integration.
|
||||||
|
type RoutingSource struct {
|
||||||
|
routes map[string]Spec
|
||||||
|
paper paperMC
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewRoutingSource builds the router from a topology. Pinned specs are indexed too
|
||||||
|
// but never reached (Run skips pinned components before calling Latest).
|
||||||
|
func NewRoutingSource(specs []Spec) *RoutingSource {
|
||||||
|
routes := make(map[string]Spec, len(specs))
|
||||||
|
for _, s := range specs {
|
||||||
|
routes[s.Name] = s
|
||||||
|
}
|
||||||
|
return &RoutingSource{routes: routes, paper: newPaperMC()}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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) {
|
||||||
|
spec, ok := r.routes[comp.Name]
|
||||||
|
if !ok {
|
||||||
|
return updates.Version{}, fmt.Errorf("updater: no source route for %q", comp.Name)
|
||||||
|
}
|
||||||
|
switch spec.Source {
|
||||||
|
case sourcePaperMC:
|
||||||
|
return r.paper.latestStable(ctx, spec.Coord)
|
||||||
|
case sourceGitHub:
|
||||||
|
return updates.Version{}, errGitHubNotWired
|
||||||
|
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)
|
||||||
|
default:
|
||||||
|
return updates.Version{}, fmt.Errorf("updater: component %q has an unknown source kind", comp.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
package updater
|
||||||
|
|
||||||
|
import "felis.lolicon.best/internal/updates"
|
||||||
|
|
||||||
|
// sourceKind is how a component's latest upstream version is discovered.
|
||||||
|
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)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Spec is one platform component's static update policy plus how to find its latest
|
||||||
|
// upstream version. Current is deliberately NOT here — it is gathered at runtime
|
||||||
|
// (integration: an image tag, `k3s --version`, a jar manifest) and combined with the
|
||||||
|
// Spec to form an updates.Component. The topology is the pure, testable expression of
|
||||||
|
// the user's stated decisions: what Felis keeps current, and how aggressively.
|
||||||
|
type Spec struct {
|
||||||
|
Name string
|
||||||
|
Policy updates.Policy
|
||||||
|
Manageable bool
|
||||||
|
Source sourceKind
|
||||||
|
// Coord is the source-specific coordinate: "owner/repo" for GitHub, the project
|
||||||
|
// id for PaperMC, empty for pinned components.
|
||||||
|
Coord string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Topology returns the fixed platform components Felis tracks, each with the update
|
||||||
|
// policy the user set. The two user red lines shape every entry: "不要强制自动更新"
|
||||||
|
// (nothing is force-upgraded — the strongest policy is Scheduled, gated on a
|
||||||
|
// SysAdmin window) and "能不动的就别动" (Minecraft is always pinned).
|
||||||
|
//
|
||||||
|
// - felis-api — the control plane Felis ships. Felis MANAGES it (image bump +
|
||||||
|
// rollout), so Scheduled: applied only inside a SysAdmin-set window, else notify.
|
||||||
|
// - k3s — the single node the whole platform runs on. Upgrading it is
|
||||||
|
// high-blast-radius, so Notify only and NOT manageable: Felis reads its latest to
|
||||||
|
// tell the SysAdmin but never applies it; a human drives the node upgrade.
|
||||||
|
// - cloudflared — the edge tunnel binary + service Felis manages, so Scheduled.
|
||||||
|
// - 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.
|
||||||
|
//
|
||||||
|
// 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.
|
||||||
|
// Keeping Minecraft out of the static topology is the code-level expression of the
|
||||||
|
// pin — there is no policy path by which Topology can propose changing it.
|
||||||
|
func Topology() []Spec {
|
||||||
|
return []Spec{
|
||||||
|
// Coord "felis/felis" is a placeholder for the operator's own release repo; the
|
||||||
|
// GitHub source (which would consume it) is not yet wired, so it is inert today.
|
||||||
|
{Name: "felis-api", Policy: updates.PolicyScheduled, Manageable: true, Source: sourceGitHub, Coord: "felis/felis"},
|
||||||
|
{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"},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
package updater
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"felis.lolicon.best/internal/updates"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestTopologyEncodesPolicies pins the user's stated decisions: which components Felis
|
||||||
|
// tracks and how aggressively. A regression here would silently change what Felis is
|
||||||
|
// allowed to auto-apply.
|
||||||
|
func TestTopologyEncodesPolicies(t *testing.T) {
|
||||||
|
want := map[string]Spec{
|
||||||
|
"felis-api": {Name: "felis-api", Policy: updates.PolicyScheduled, Manageable: true, Source: sourceGitHub, Coord: "felis/felis"},
|
||||||
|
"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"},
|
||||||
|
}
|
||||||
|
got := Topology()
|
||||||
|
if len(got) != len(want) {
|
||||||
|
t.Fatalf("Topology has %d specs, want %d", len(got), len(want))
|
||||||
|
}
|
||||||
|
for _, s := range got {
|
||||||
|
w, ok := want[s.Name]
|
||||||
|
if !ok {
|
||||||
|
t.Errorf("unexpected component %q in topology", s.Name)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if s != w {
|
||||||
|
t.Errorf("component %q = %+v, want %+v", s.Name, s, w)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestTopologyPinsMinecraft proves the pin is expressed as ABSENCE: no component in
|
||||||
|
// the static topology is Pinned, because Minecraft is never force-tracked here — it
|
||||||
|
// is appended from the live fleet at runtime. And nothing off-cluster is proposed for
|
||||||
|
// self-apply: velocity must be non-manageable (Notify at most, never Apply).
|
||||||
|
func TestTopologyPinsMinecraft(t *testing.T) {
|
||||||
|
for _, s := range Topology() {
|
||||||
|
if s.Policy == updates.PolicyPinned {
|
||||||
|
t.Errorf("component %q is Pinned in the static topology; Minecraft pins belong to the runtime fleet, not here", s.Name)
|
||||||
|
}
|
||||||
|
if s.Name == "velocity" && s.Manageable {
|
||||||
|
t.Error("velocity is off-cluster and must be non-manageable (Notify only)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user