Unverified Commit 96b3cc90 authored by Minseong Choi's avatar Minseong Choi 💬
Browse files

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.
parent 0a2accd2
Loading
Loading
Loading
Loading
+30 −0
Changes for internal/updater/doc.go: 30 added lines, 0 removed lines.
Original line number Diff line number Diff line
// 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 −0
Changes for internal/updater/papermc.go: 111 added lines, 0 removed lines.
Original line number Diff line number Diff line
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 −0
Changes for internal/updater/papermc_test.go: 134 added lines, 0 removed lines.
Original line number Diff line number Diff line
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 −0
Changes for internal/updater/runner.go: 80 added lines, 0 removed lines.
Original line number Diff line number Diff line
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 −0
Changes for internal/updater/runner_test.go: 166 added lines, 0 removed lines.
Original line number Diff line number Diff line
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])
		}
	}
}
Loading