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