felis-api's coord was the placeholder "felis/felis", which resolves against nothing on real GitHub. It is now MliroLirrorsIngenuity/Felis — the same slug deploy/bootstrap.sh clones from — so update reporting for the control plane itself is live rather than parked. That repo is private today, so the github source gained an optional token, read from FELIS_GITHUB_TOKEN: the variable bootstrap already needs, so an operator sets one value once. It comes from the environment and is never compiled in. A constant would be committed to the very repository it protects, ship inside every felis binary where strings(1) recovers it, reach every node the image is imported onto, and need a rebuild and a redeploy to rotate. Empty stays the correct posture for the other tracked components — k3s and cloudflared are public — and an empty token sends no Authorization header at all rather than an empty one. GitHub answers 404, not 401 or 403, for a private repo the caller cannot see, so "no token" and "no stable release published yet" arrive as the same status. On an unauthenticated 404 the error now names both causes and the variable that fixes the actionable one. With a token already set that hint would be wrong, so it is suppressed. Tests pin both halves: the Bearer header is sent only when the token is set, and the diagnostic names the variable only when it is not. doc.go's CAVEATS bullet still described the coord as a placeholder and the component as "dark at runtime". Both were true only until this change; it now records the real condition, which is that the component resolves like the others but needs a credential while the repo is private.
144 lines
6.4 KiB
Go
144 lines
6.4 KiB
Go
package updater
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"os"
|
|
"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
|
|
// token is an optional credential. Empty means unauthenticated, which is the right
|
|
// posture for the public repos Felis tracks (k3s, cloudflared) and is what the
|
|
// 60-req/h note above is about. It is load-bearing only for felis-api's own repo
|
|
// while that repo is private, where its absence does not look like an auth failure:
|
|
// GitHub answers 404 — not 401 or 403 — for a private repo the caller cannot see, so
|
|
// "no token" is indistinguishable from "no release published yet" by status alone.
|
|
// latestStable says both in the error rather than making an operator guess.
|
|
//
|
|
// It is read from the environment and never compiled in. A constant would be
|
|
// committed to the very repository it protects, ship inside every felis binary where
|
|
// strings(1) recovers it, reach every node the image is imported onto, and need a
|
|
// rebuild and a redeploy to rotate.
|
|
token string
|
|
hc *http.Client
|
|
}
|
|
|
|
// tokenEnv names the environment variable holding the GitHub credential. deploy/bootstrap.sh
|
|
// reads the same variable to clone a private repo, so an operator sets one value once.
|
|
const tokenEnv = "FELIS_GITHUB_TOKEN"
|
|
|
|
// newGitHub builds a source pointed at the live GitHub REST API with sane defaults.
|
|
func newGitHub() github {
|
|
return github{
|
|
baseURL: githubBaseURL,
|
|
userAgent: defaultUserAgent,
|
|
token: os.Getenv(tokenEnv),
|
|
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")
|
|
if g.token != "" {
|
|
req.Header.Set("Authorization", "Bearer "+g.token)
|
|
}
|
|
|
|
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 {
|
|
// 404 is the ambiguous one: GitHub hides a private repo behind it rather than
|
|
// answering 401/403, so an unauthenticated miss and a repo with no stable release
|
|
// are the same status. Name both causes, and name the fix for the one an operator
|
|
// can act on.
|
|
if resp.StatusCode == http.StatusNotFound && g.token == "" {
|
|
return updates.Version{}, fmt.Errorf(
|
|
"github: %s releases/latest returned HTTP 404 — either it has no published stable release, or it is private and %s is unset",
|
|
repo, tokenEnv)
|
|
}
|
|
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
|
|
}
|