From 96b3cc901cc70a6163ce4bab03de7a734817c949 Mon Sep 17 00:00:00 2001 From: Minseong Choi Date: Sat, 4 Jul 2026 23:41:46 +0900 Subject: [PATCH] feat(updater): wire updates.Run to a caller with PaperMC v3 release discovery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- internal/updater/doc.go | 30 ++++++ internal/updater/papermc.go | 111 ++++++++++++++++++++ internal/updater/papermc_test.go | 134 ++++++++++++++++++++++++ internal/updater/runner.go | 80 ++++++++++++++ internal/updater/runner_test.go | 166 ++++++++++++++++++++++++++++++ internal/updater/source.go | 57 ++++++++++ internal/updater/topology.go | 57 ++++++++++ internal/updater/topology_test.go | 48 +++++++++ 8 files changed, 683 insertions(+) create mode 100644 internal/updater/doc.go create mode 100644 internal/updater/papermc.go create mode 100644 internal/updater/papermc_test.go create mode 100644 internal/updater/runner.go create mode 100644 internal/updater/runner_test.go create mode 100644 internal/updater/source.go create mode 100644 internal/updater/topology.go create mode 100644 internal/updater/topology_test.go diff --git a/internal/updater/doc.go b/internal/updater/doc.go new file mode 100644 index 0000000..f013643 --- /dev/null +++ b/internal/updater/doc.go @@ -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 diff --git a/internal/updater/papermc.go b/internal/updater/papermc.go new file mode 100644 index 0000000..c8c2236 --- /dev/null +++ b/internal/updater/papermc.go @@ -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":{"":["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 +} diff --git a/internal/updater/papermc_test.go b/internal/updater/papermc_test.go new file mode 100644 index 0000000..51d8e5e --- /dev/null +++ b/internal/updater/papermc_test.go @@ -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()) + } +} diff --git a/internal/updater/runner.go b/internal/updater/runner.go new file mode 100644 index 0000000..233661c --- /dev/null +++ b/internal/updater/runner.go @@ -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 +} diff --git a/internal/updater/runner_test.go b/internal/updater/runner_test.go new file mode 100644 index 0000000..36772fc --- /dev/null +++ b/internal/updater/runner_test.go @@ -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]) + } + } +} diff --git a/internal/updater/source.go b/internal/updater/source.go new file mode 100644 index 0000000..4dff827 --- /dev/null +++ b/internal/updater/source.go @@ -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) + } +} diff --git a/internal/updater/topology.go b/internal/updater/topology.go new file mode 100644 index 0000000..400e109 --- /dev/null +++ b/internal/updater/topology.go @@ -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"}, + } +} diff --git a/internal/updater/topology_test.go b/internal/updater/topology_test.go new file mode 100644 index 0000000..24b4c49 --- /dev/null +++ b/internal/updater/topology_test.go @@ -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)") + } + } +}