diff --git a/internal/updater/doc.go b/internal/updater/doc.go index f29cf71..a575ad4 100644 --- a/internal/updater/doc.go +++ b/internal/updater/doc.go @@ -10,30 +10,34 @@ // 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, the routing source, and the Runner's report-only composition. The -// PaperMC fixture is captured from the live endpoint, and the live call was -// exercised out-of-band on 2026-07-04 (curl fill.papermc.io/v3/projects/velocity): -// the response shape matches the fixture and 3.4.0 is confirmed the newest stable -// (3.5.0-SNAPSHOT correctly filtered). These prove the parse/plan/compose LOGIC -// and that the core is now wired to a caller. -// -// - NOT YET BUILT, but VERIFIABLE HERE (same technique as PaperMC — HTTP GET, JSON -// decode, tolerant Parse, prerelease filter, all httptest-testable): the GitHub -// Releases source. It is why 3 of the 4 components (felis-api, k3s, cloudflared) -// currently report "latest unknown" — RoutingSource returns errGitHubNotWired for -// them. This is the next VERIFIABLE slice, not integration remainder; until it -// exists the verifiable release-source work is only ~half done. +// - BUILT + UNIT-VERIFIED (Go tests, WSL oracle): the topology, BOTH release +// sources (PaperMC Fill v3 for Velocity; GitHub Releases for felis-api, k3s and +// cloudflared), the routing source, and the Runner's report-only composition. Each +// source's parser is a contract test whose fixture is captured from — and whose +// live call was exercised out-of-band against — the real endpoint: PaperMC on +// 2026-07-04 (3.4.0 is newest stable, 3.5.0-SNAPSHOT filtered), GitHub on +// 2026-07-05 (cloudflared 2026.6.1; k3s v1.36.2+k3s1, its "+k3s1"/"v" tolerated and +// its "-rcN"/prerelease builds rejected). These prove the parse/plan/compose LOGIC +// and that the core is wired to a caller for every tracked component. // // - CAVEATS on what the tests do NOT prove: they run against httptest, not the live -// host, so future upstream shape drift is not caught; and while Felis sends a -// descriptive User-Agent (PaperMC etiquette), upstream UA enforcement was not -// active on the project endpoint on 2026-07-04 (a bare UA got HTTP 200), so the UA -// is defensive, not load-bearing. +// hosts, so future upstream shape drift is not caught. On User-Agent the two APIs +// differ and the code reflects it: GitHub ENFORCES a UA (a bare request is 403'd, +// verified 2026-07-05) so Felis's UA is load-bearing there; PaperMC does NOT +// enforce (a bare request got HTTP 200 on 2026-07-04) so its UA is only etiquette. +// Also: felis-api's topology coord "felis/felis" is a PLACEHOLDER slug — the GitHub +// routing/parse logic is verified, but that one component stays dark at runtime (its +// Latest errors, degrading to "latest unknown") until a real repository is configured. // -// - REMAINING INTEGRATION (pure I/O, no verifiable-here logic): 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. +// - NOT YET BUILT, but VERIFIABLE HERE (the next slice): the VersionGatherer's +// extraction core — command output (`k3s --version`), image tag +// (`rancher/k3s:v1.36.2-k3s1`) or jar filename → Version. That is logic over an +// exec/read seam, testable with a fake runner à la internal/reaper's ExecRunner, +// and load-bearing: a mis-read current version makes every plan wrong (spurious +// applies or missed upgrades). Only the seam's actual I/O is un-verifiable here. +// +// - REMAINING INTEGRATION (genuinely I/O-bound — needs a cluster/mailbox to exercise): +// 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/github.go b/internal/updater/github.go new file mode 100644 index 0000000..ce2cb6e --- /dev/null +++ b/internal/updater/github.go @@ -0,0 +1,112 @@ +package updater + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "time" + + "felis.lolicon.best/internal/updates" +) + +// githubBaseURL is the GitHub REST API root. It is a field on the github source (not a +// hardcoded constant in the request) so discovery runs against an httptest server in +// tests without touching the network. +const githubBaseURL = "https://api.github.com" + +// github discovers the latest STABLE release of a GitHub repository from the REST API's +// /repos/{repo}/releases/latest endpoint, which GitHub defines as the most recent +// non-draft, non-prerelease release. Felis tracks felis-api, k3s and cloudflared this +// way. +// +// Unlike the PaperMC Fill API, GitHub ENFORCES a User-Agent: a request without one is +// answered "403 Request forbidden by administrative rules. Please make sure your request +// has a User-Agent header" (verified against api.github.com on 2026-07-05 — a UA-less +// request 403'd while a plain browser UA got 200, so the gate is on presence, not on the +// UA's content). Felis always sends its descriptive UA, so the requirement is met. +// +// It uses /releases/latest (one request per repo) rather than listing releases: it is +// GitHub's own "newest stable" definition and is gentle on the unauthenticated 60-req/h +// rate limit a CronJob shares. The one semantic gap — GitHub sorts "latest" by the +// release's created_at (not by version), so a repo that back-ports a patch to an OLD +// line LAST would report that patch — does not bite Felis's tracked repos: felis-api +// and cloudflared are +// single-line (date order == version order), and k3s is Notify-only (a missed +// notification self-corrects on the next release, and never drives an apply). +type github struct { + baseURL string // e.g. "https://api.github.com" + userAgent string // MUST be non-empty — GitHub 403s a UA-less request + hc *http.Client +} + +// newGitHub builds a source pointed at the live GitHub REST API with sane defaults. +func newGitHub() github { + return github{ + baseURL: githubBaseURL, + userAgent: defaultUserAgent, + hc: &http.Client{Timeout: 15 * time.Second}, + } +} + +// releaseResponse is the slice of GET /repos/{repo}/releases/latest that Felis reads. +// The live shape (2026-07-05) for both a CalVer project (cloudflared "2026.6.1") and a +// v-prefixed, build-tagged one (k3s "v1.36.2+k3s1") is: +// +// {"tag_name":"v1.36.2+k3s1","prerelease":false,"draft":false, ...} +// +// updates.Parse tolerates the leading "v" and strips "+build" metadata, so both tag +// styles reduce to a comparable Version while String() keeps the raw for the report. +type releaseResponse struct { + TagName string `json:"tag_name"` + Prerelease bool `json:"prerelease"` + Draft bool `json:"draft"` +} + +// latestStable returns the newest STABLE release of repo (e.g. "cloudflare/cloudflared"). +// +// It fails closed: a transport error, a non-200 status (GitHub answers 404 when a repo +// has no non-prerelease release, so "no stable release" surfaces as an error, never a +// zero version), an undecodable body, a response that is somehow marked draft/prerelease, +// or an unparseable / prerelease-parsing tag all return an error. /releases/latest +// already excludes drafts and prereleases; the explicit re-checks are defense in depth so +// an upstream change can never silently promote a prerelease into a scheduled apply. +func (g github) latestStable(ctx context.Context, repo string) (updates.Version, error) { + url := fmt.Sprintf("%s/repos/%s/releases/latest", g.baseURL, repo) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return updates.Version{}, fmt.Errorf("github: build request for %s: %w", repo, err) + } + ua := g.userAgent + if ua == "" { + ua = defaultUserAgent + } + req.Header.Set("User-Agent", ua) + req.Header.Set("Accept", "application/vnd.github+json") + + resp, err := g.hc.Do(req) + if err != nil { + return updates.Version{}, fmt.Errorf("github: get %s: %w", repo, err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return updates.Version{}, fmt.Errorf("github: %s releases/latest returned HTTP %d", repo, resp.StatusCode) + } + + var rr releaseResponse + if err := json.NewDecoder(resp.Body).Decode(&rr); err != nil { + return updates.Version{}, fmt.Errorf("github: decode %s: %w", repo, err) + } + if rr.Draft || rr.Prerelease { + return updates.Version{}, fmt.Errorf("github: %s releases/latest is unexpectedly draft/prerelease (tag %q)", repo, rr.TagName) + } + + v, err := updates.Parse(rr.TagName) + if err != nil { + return updates.Version{}, fmt.Errorf("github: parse tag %q for %s: %w", rr.TagName, repo, err) + } + if v.IsPrerelease() { + return updates.Version{}, fmt.Errorf("github: %s latest tag %q parses as a prerelease", repo, rr.TagName) + } + return v, nil +} diff --git a/internal/updater/github_test.go b/internal/updater/github_test.go new file mode 100644 index 0000000..8c8c05d --- /dev/null +++ b/internal/updater/github_test.go @@ -0,0 +1,142 @@ +package updater + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" +) + +// These fixtures are the meaningful slice of GET /repos/{repo}/releases/latest, captured +// from the live GitHub REST API on 2026-07-05. They exercise the two real tag styles +// Felis must handle: cloudflared ships a CalVer tag ("2026.6.1") and k3s a v-prefixed tag +// carrying build metadata ("v1.36.2+k3s1"). The k3s list at that time also showed the +// prerelease pattern this source must reject — rc builds are tagged "-rcN" AND flagged +// "prerelease": true — which the fail-closed tests below reproduce. +const ( + cloudflaredLatestFixture = `{"tag_name":"2026.6.1","prerelease":false,"draft":false,"name":"2026.6.1"}` + k3sLatestFixture = `{"tag_name":"v1.36.2+k3s1","prerelease":false,"draft":false,"name":"v1.36.2+k3s1"}` +) + +func newTestGitHub(srv *httptest.Server) github { + return github{ + baseURL: srv.URL, + userAgent: "felis-updater/0.1", + hc: srv.Client(), + } +} + +// ghFixtureServer serves a fixed body to any /repos/.../releases/latest path, and — like +// the real API — 403s a request that arrives without a User-Agent. +func ghFixtureServer(body string) *httptest.Server { + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("User-Agent") == "" { + http.Error(w, "Request forbidden by administrative rules", http.StatusForbidden) + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(body)) + })) +} + +// TestGitHubLatestStableParsesRealTags proves both live tag styles reduce to the right +// stable Version: cloudflared's CalVer and k3s's v-prefixed, build-tagged tag. It also +// pins that String() preserves the raw upstream tag (so "+k3s1" reaches the report), +// while the numeric core is what comparison uses. +func TestGitHubLatestStableParsesRealTags(t *testing.T) { + cases := []struct { + name string + repo string + body string + wantMajMinPat [3]int + wantRawInReport string + }{ + {"cloudflared CalVer", "cloudflare/cloudflared", cloudflaredLatestFixture, [3]int{2026, 6, 1}, "2026.6.1"}, + {"k3s v-prefix +build", "k3s-io/k3s", k3sLatestFixture, [3]int{1, 36, 2}, "v1.36.2+k3s1"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + srv := ghFixtureServer(tc.body) + defer srv.Close() + + v, err := newTestGitHub(srv).latestStable(context.Background(), tc.repo) + if err != nil { + t.Fatalf("latestStable: %v", err) + } + if got := [3]int{v.Major, v.Minor, v.Patch}; got != tc.wantMajMinPat { + t.Errorf("version core = %v, want %v", got, tc.wantMajMinPat) + } + if v.IsPrerelease() { + t.Errorf("stable release parsed as prerelease: %q", v.String()) + } + if v.String() != tc.wantRawInReport { + t.Errorf("String() = %q, want raw upstream tag %q preserved for the report", v.String(), tc.wantRawInReport) + } + }) + } +} + +// TestGitHubClearsTheUserAgentGate proves Felis's request carries the User-Agent GitHub +// requires: the fixture server refuses a UA-less request with 403 exactly as the live API +// does, so a successful discovery is evidence the client sent one. +func TestGitHubClearsTheUserAgentGate(t *testing.T) { + var gotUA string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotUA = r.Header.Get("User-Agent") + if gotUA == "" { + http.Error(w, "Request forbidden by administrative rules", http.StatusForbidden) + return + } + _, _ = w.Write([]byte(cloudflaredLatestFixture)) + })) + defer srv.Close() + + v, err := newTestGitHub(srv).latestStable(context.Background(), "cloudflare/cloudflared") + if err != nil { + t.Fatalf("latestStable through the UA gate: %v", err) + } + if v.String() != "2026.6.1" { + t.Errorf("latestStable = %q, want 2026.6.1", v.String()) + } + if gotUA == "" { + t.Fatal("no User-Agent sent — the real GitHub API would have refused this request") + } +} + +// TestGitHubFailsClosedOn404 proves a repo with no stable release (GitHub returns 404 +// from /releases/latest) is an error, not a bogus zero version. +func TestGitHubFailsClosedOn404(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + http.Error(w, `{"message":"Not Found"}`, http.StatusNotFound) + })) + defer srv.Close() + + if v, err := newTestGitHub(srv).latestStable(context.Background(), "acme/no-releases"); err == nil { + t.Fatalf("want error on 404, got version %q", v.String()) + } +} + +// TestGitHubFailsClosedOnPrereleaseFlag proves the defensive re-check holds: even if the +// endpoint ever returned a release flagged prerelease, Felis refuses it rather than +// promoting an rc into a scheduled apply. +func TestGitHubFailsClosedOnPrereleaseFlag(t *testing.T) { + const rc = `{"tag_name":"v1.37.0-rc1+k3s1","prerelease":true,"draft":false}` + srv := ghFixtureServer(rc) + defer srv.Close() + + if v, err := newTestGitHub(srv).latestStable(context.Background(), "k3s-io/k3s"); err == nil { + t.Fatalf("want error on a prerelease-flagged release, got %q", v.String()) + } +} + +// TestGitHubFailsClosedOnUnparseableTag proves a tag that is not a version (some repos +// tag "nightly" / "latest") is an error, not a silent zero. +func TestGitHubFailsClosedOnUnparseableTag(t *testing.T) { + const junk = `{"tag_name":"nightly","prerelease":false,"draft":false}` + srv := ghFixtureServer(junk) + defer srv.Close() + + if v, err := newTestGitHub(srv).latestStable(context.Background(), "acme/rolling"); err == nil { + t.Fatalf("want error on an unparseable tag, got %q", v.String()) + } +} diff --git a/internal/updater/runner_test.go b/internal/updater/runner_test.go index 36772fc..faa2c70 100644 --- a/internal/updater/runner_test.go +++ b/internal/updater/runner_test.go @@ -124,29 +124,52 @@ func TestRunnerSkipsUngatherableComponent(t *testing.T) { } } -// 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. +// TestRunnerWithRoutingSource wires the real RoutingSource — PaperMC and GitHub both +// pointed at live-shape httptest fixtures — through the runner end to end. Every +// component now discovers its real latest stable (velocity 3.4.0 via PaperMC; felis-api, +// k3s and cloudflared via GitHub Releases), the raw upstream tags survive into the report +// (k3s's "+k3s1"), and NO component falls back to a recorded source error. 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) { + now := time.Date(2026, 7, 5, 3, 30, 0, 0, time.UTC) + + paperSrv := 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() + defer paperSrv.Close() + + // GitHub fixtures keyed by repo (felis-api's coord is a placeholder slug). The + // handler also mirrors GitHub's real gate: a UA-less request is refused. + ghBodies := map[string]string{ + "/repos/felis/felis/releases/latest": `{"tag_name":"1.5.0","prerelease":false,"draft":false}`, + "/repos/k3s-io/k3s/releases/latest": k3sLatestFixture, + "/repos/cloudflare/cloudflared/releases/latest": cloudflaredLatestFixture, + } + ghSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("User-Agent") == "" { + http.Error(w, "Request forbidden by administrative rules", http.StatusForbidden) + return + } + body, ok := ghBodies[r.URL.Path] + if !ok { + http.Error(w, "unexpected "+r.URL.Path, http.StatusNotFound) + return + } + _, _ = w.Write([]byte(body)) + })) + defer ghSrv.Close() rs := NewRoutingSource(Topology()) - rs.paper = newTestPaperMC(srv) // point PaperMC discovery at the httptest server + rs.paper = newTestPaperMC(paperSrv) // point PaperMC discovery at its httptest server + rs.gh = newTestGitHub(ghSrv) // and GitHub discovery at its own 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"), + "k3s": mustV(t, "v1.35.6+k3s1"), + "cloudflared": mustV(t, "2026.5.0"), "velocity": mustV(t, "3.1.1"), }} rn := &Runner{Gatherer: gath, Source: rs} @@ -155,12 +178,19 @@ func TestRunnerWithRoutingSource(t *testing.T) { 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]) + // Every component's real discovered latest is in the report (each current is older). + for _, want := range []string{ + "velocity", "3.4.0", + "felis-api", "1.5.0", + "k3s", "v1.36.2+k3s1", + "cloudflared", "2026.6.1", + } { + if !strings.Contains(res.Report, want) { + t.Errorf("report missing discovered %q; got:\n%s", want, res.Report) } } + // Both routes are wired now, so nothing degrades to a source error. + if len(res.RunResult.SourceErrors) != 0 { + t.Errorf("expected no source errors with both routes wired, got %v", res.RunResult.SourceErrors) + } } diff --git a/internal/updater/source.go b/internal/updater/source.go index 4dff827..37aaa00 100644 --- a/internal/updater/source.go +++ b/internal/updater/source.go @@ -2,28 +2,19 @@ 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. +// configured upstream by the topology: the PaperMC Fill API for Velocity, and the +// GitHub Releases API for felis-api, k3s and cloudflared. type RoutingSource struct { routes map[string]Spec paper paperMC + gh github } // NewRoutingSource builds the router from a topology. Pinned specs are indexed too @@ -33,7 +24,7 @@ func NewRoutingSource(specs []Spec) *RoutingSource { for _, s := range specs { routes[s.Name] = s } - return &RoutingSource{routes: routes, paper: newPaperMC()} + return &RoutingSource{routes: routes, paper: newPaperMC(), gh: newGitHub()} } // Latest implements updates.ReleaseSource. An unknown component name is an error, not @@ -47,7 +38,7 @@ func (r *RoutingSource) Latest(ctx context.Context, comp updates.Component) (upd case sourcePaperMC: return r.paper.latestStable(ctx, spec.Coord) case sourceGitHub: - return updates.Version{}, errGitHubNotWired + return r.gh.latestStable(ctx, spec.Coord) 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)