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:
flyemoji committed 2026-07-04 23:41:46 +09:00
1 parent 0a2accd245
commit 96b3cc901c
8 files changed
+683

No files matched your search

+30
View File
@@ -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
+111
View File
@@ -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
}
+134
View File
@@ -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())
}
}
+80
View File
@@ -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
}
+166
View File
@@ -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])
}
}
}
+57
View File
@@ -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)
}
}
+57
View File
@@ -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"},
}
}
+48
View File
@@ -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)")
}
}
}