feat(updater): add GitHub Releases source and route felis-api/k3s/cloudflared
Give RoutingSource its second upstream so every non-pinned component now
resolves a real latest-stable: Velocity via PaperMC (already wired), and
felis-api, k3s and cloudflared via the GitHub REST API.
github.go queries /repos/{repo}/releases/latest (one request, rate-limit
friendly) and fails closed: a transport error, a non-200 status (404 = no
stable release), an undecodable body, a draft/prerelease flag, or an
unparseable / prerelease-parsing tag all return an error, never a zero
version. It sends the User-Agent GitHub requires (a UA-less request is
403'd) and tolerates the two live tag styles -- cloudflared's CalVer
"2026.6.1" and k3s's v-prefixed, build-tagged "v1.36.2+k3s1" -- while
String() keeps the raw tag for the report.
source.go routes sourceGitHub to it and drops the errGitHubNotWired stub;
velocity still routes to PaperMC.
Tests: github_test.go covers both tag styles, the User-Agent gate, and
fail-closed on 404 / prerelease-flag / unparseable tag, with fixtures
captured from api.github.com on 2026-07-05. runner_test.go now drives
PaperMC and GitHub through dual httptest servers end to end with no source
degrading to an error.
doc.go re-tiers the verification boundary: both release sources are now
built and live-grounded; the VersionGatherer's version-extraction core is
the next verifiable slice (logic over an exec seam, not pure I/O); the
genuine I/O remainder is the Notifier, Applier and felis update CLI/CronJob.
felis-api's coord is still a placeholder slug, so that component is dark at
runtime until a real repository is configured.
This commit is contained in:
5 files changed
+333
-54
No files matched your search
+27
-23
@@ -10,30 +10,34 @@
|
|||||||
// Verification boundary — stated honestly so a green test suite is not mistaken for
|
// Verification boundary — stated honestly so a green test suite is not mistaken for
|
||||||
// "the updater works against real infra":
|
// "the updater works against real infra":
|
||||||
//
|
//
|
||||||
// - BUILT + UNIT-VERIFIED (Go tests, WSL oracle): the topology, the PaperMC Fill
|
// - BUILT + UNIT-VERIFIED (Go tests, WSL oracle): the topology, BOTH release
|
||||||
// v3 parser, the routing source, and the Runner's report-only composition. The
|
// sources (PaperMC Fill v3 for Velocity; GitHub Releases for felis-api, k3s and
|
||||||
// PaperMC fixture is captured from the live endpoint, and the live call was
|
// cloudflared), the routing source, and the Runner's report-only composition. Each
|
||||||
// exercised out-of-band on 2026-07-04 (curl fill.papermc.io/v3/projects/velocity):
|
// source's parser is a contract test whose fixture is captured from — and whose
|
||||||
// the response shape matches the fixture and 3.4.0 is confirmed the newest stable
|
// live call was exercised out-of-band against — the real endpoint: PaperMC on
|
||||||
// (3.5.0-SNAPSHOT correctly filtered). These prove the parse/plan/compose LOGIC
|
// 2026-07-04 (3.4.0 is newest stable, 3.5.0-SNAPSHOT filtered), GitHub on
|
||||||
// and that the core is now wired to a caller.
|
// 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
|
||||||
// - NOT YET BUILT, but VERIFIABLE HERE (same technique as PaperMC — HTTP GET, JSON
|
// and that the core is wired to a caller for every tracked component.
|
||||||
// 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.
|
|
||||||
//
|
//
|
||||||
// - CAVEATS on what the tests do NOT prove: they run against httptest, not the live
|
// - 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
|
// hosts, so future upstream shape drift is not caught. On User-Agent the two APIs
|
||||||
// descriptive User-Agent (PaperMC etiquette), upstream UA enforcement was not
|
// differ and the code reflects it: GitHub ENFORCES a UA (a bare request is 403'd,
|
||||||
// active on the project endpoint on 2026-07-04 (a bare UA got HTTP 200), so the UA
|
// verified 2026-07-05) so Felis's UA is load-bearing there; PaperMC does NOT
|
||||||
// is defensive, not load-bearing.
|
// 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
|
// - NOT YET BUILT, but VERIFIABLE HERE (the next slice): the VersionGatherer's
|
||||||
// VersionGatherer (`k3s --version`, image-tag / jar inspection), the concrete
|
// extraction core — command output (`k3s --version`), image tag
|
||||||
// Notifier (SMTP + in-game) and Applier (control-plane image bump, cloudflared
|
// (`rancher/k3s:v1.36.2-k3s1`) or jar filename → Version. That is logic over an
|
||||||
// swap), the `felis update` CLI + CronJob entry point, and the runtime append of
|
// exec/read seam, testable with a fake runner à la internal/reaper's ExecRunner,
|
||||||
// the live Pinned Minecraft fleet.
|
// 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
|
package updater
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -124,29 +124,52 @@ func TestRunnerSkipsUngatherableComponent(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestRunnerWithRoutingSource wires the real RoutingSource — PaperMC via the live-shape
|
// TestRunnerWithRoutingSource wires the real RoutingSource — PaperMC and GitHub both
|
||||||
// httptest fixture, GitHub honestly un-wired — through the runner end to end. velocity
|
// pointed at live-shape httptest fixtures — through the runner end to end. Every
|
||||||
// discovers its real latest stable (3.4.0 → notify); the GitHub-backed components
|
// component now discovers its real latest stable (velocity 3.4.0 via PaperMC; felis-api,
|
||||||
// degrade to "latest unknown" via the recorded errGitHubNotWired, never a fabricated
|
// k3s and cloudflared via GitHub Releases), the raw upstream tags survive into the report
|
||||||
// version.
|
// (k3s's "+k3s1"), and NO component falls back to a recorded source error.
|
||||||
func TestRunnerWithRoutingSource(t *testing.T) {
|
func TestRunnerWithRoutingSource(t *testing.T) {
|
||||||
now := time.Date(2026, 7, 4, 3, 30, 0, 0, time.UTC)
|
now := time.Date(2026, 7, 5, 3, 30, 0, 0, time.UTC)
|
||||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
|
paperSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
if !strings.HasPrefix(r.URL.Path, "/v3/projects/") {
|
if !strings.HasPrefix(r.URL.Path, "/v3/projects/") {
|
||||||
http.Error(w, "unexpected "+r.URL.Path, http.StatusNotFound)
|
http.Error(w, "unexpected "+r.URL.Path, http.StatusNotFound)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
_, _ = w.Write([]byte(velocityV3Fixture))
|
_, _ = 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 := 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{
|
gath := fakeGatherer{cur: map[string]updates.Version{
|
||||||
"felis-api": mustV(t, "1.4.0"),
|
"felis-api": mustV(t, "1.4.0"),
|
||||||
"k3s": mustV(t, "v1.30.2+k3s1"),
|
"k3s": mustV(t, "v1.35.6+k3s1"),
|
||||||
"cloudflared": mustV(t, "2024.2.1"),
|
"cloudflared": mustV(t, "2026.5.0"),
|
||||||
"velocity": mustV(t, "3.1.1"),
|
"velocity": mustV(t, "3.1.1"),
|
||||||
}}
|
}}
|
||||||
rn := &Runner{Gatherer: gath, Source: rs}
|
rn := &Runner{Gatherer: gath, Source: rs}
|
||||||
@@ -155,12 +178,19 @@ func TestRunnerWithRoutingSource(t *testing.T) {
|
|||||||
t.Fatalf("Run: %v", err)
|
t.Fatalf("Run: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !strings.Contains(res.Report, "velocity") || !strings.Contains(res.Report, "3.4.0") {
|
// Every component's real discovered latest is in the report (each current is older).
|
||||||
t.Errorf("report should show velocity's discovered latest 3.4.0; got:\n%s", res.Report)
|
for _, want := range []string{
|
||||||
}
|
"velocity", "3.4.0",
|
||||||
for _, name := range []string{"felis-api", "k3s", "cloudflared"} {
|
"felis-api", "1.5.0",
|
||||||
if !errors.Is(res.RunResult.SourceErrors[name], errGitHubNotWired) {
|
"k3s", "v1.36.2+k3s1",
|
||||||
t.Errorf("SourceErrors[%s] = %v, want errGitHubNotWired", name, res.RunResult.SourceErrors[name])
|
"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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -2,28 +2,19 @@ package updater
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"felis.lolicon.best/internal/updates"
|
"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
|
// 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
|
// 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
|
// configured upstream by the topology: the PaperMC Fill API for Velocity, and the
|
||||||
// (Velocity) and returns errGitHubNotWired for the GitHub-backed components
|
// GitHub Releases API for felis-api, k3s and cloudflared.
|
||||||
// (felis-api, k3s, cloudflared), which are enumerated remaining integration.
|
|
||||||
type RoutingSource struct {
|
type RoutingSource struct {
|
||||||
routes map[string]Spec
|
routes map[string]Spec
|
||||||
paper paperMC
|
paper paperMC
|
||||||
|
gh github
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewRoutingSource builds the router from a topology. Pinned specs are indexed too
|
// 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 {
|
for _, s := range specs {
|
||||||
routes[s.Name] = s
|
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
|
// 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:
|
case sourcePaperMC:
|
||||||
return r.paper.latestStable(ctx, spec.Coord)
|
return r.paper.latestStable(ctx, spec.Coord)
|
||||||
case sourceGitHub:
|
case sourceGitHub:
|
||||||
return updates.Version{}, errGitHubNotWired
|
return r.gh.latestStable(ctx, spec.Coord)
|
||||||
case sourceNone:
|
case sourceNone:
|
||||||
// A pinned component (Run never reaches this, but be explicit and loud).
|
// 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)
|
return updates.Version{}, fmt.Errorf("updater: %q is pinned and has no release source", comp.Name)
|
||||||
|
|||||||
Reference in new issue
Block a user