feat(cli): apply coordinated updates during maintenance

This commit is contained in:
Lemon-miaow committed 2026-10-04 17:36:21 +08:00
1 parent cb3ed94026
commit 4d8e9af240
21 files changed
+990 -457

No files matched your search

+5 -7
View File
@@ -19,16 +19,14 @@ import (
// SINGLE GLOBAL WINDOW (deliberate). internal/updates models the window PER
// Component (Component.Window), but this endpoint stores ONE platform-wide window.
// The task scopes "the SysAdmin-set update Window" as a single maintenance slot,
// and the core's own comment defers recurrence to the caller — so the (not-yet-
// built, INTEGRATION-ONLY) `felis update` runner reads this one window and fans it
// out to every Scheduled+manageable component when it assembles its []Component.
// and the core's own comment defers recurrence to the caller. The host
// `felis update --apply` reads this window before backup and before installation.
// A future need for per-component windows would layer keys on top; this is the
// platform default.
//
// Felis applies no update on its own, so the window is advisory. Its consumer
// is `felis update` on the host (cmd/felis/update.go readUpdateWindow), which
// reads this row, prints where now sits against it, and warns before an apply
// outside it. The panel's Updates page says the same.
// Felis applies no update on its own. `felis update` reports this window, and
// explicit --apply refuses outside it unless --now starts manual maintenance.
// An unreadable window is always a refusal, including for --now and --force.
// updateWindowKey is the platform_settings key holding the maintenance window as
// JSON {"start","end"} (RFC3339, or null when unset). It reuses the generic
+5 -2
View File
@@ -47,8 +47,11 @@
// ON-HOST caller has both: NewHostGatherer (gatherer_host.go) answers felis-api from
// the running binary's build stamp and Velocity from the installed jar's manifest,
// and that is what the built `felis update` CLI runs on. Still absent: the concrete
// Notifier (SMTP + in-game) and Applier (control-plane image bump, cloudflared swap)
// — the CLI passes nil for both on purpose, so it reports and never applies — the
// Notifier (SMTP + in-game) and unattended Applier — the check/record runner
// passes nil for both. Explicit host application uses the target installer in
// cmd/felis/update_apply.go after window checks and a database/state backup.
// InstallerSource pins that script and the source checkout to a full commit;
// its HTTP boundaries and the host execution order are tested with fakes. Still absent: the
// in-cluster CronJob entry point, and the runtime append of the live Pinned
// Minecraft fleet. The scheduled check runs on the host instead:
// felis-update-check.timer runs `felis update --record`, which stores the report
+17 -10
View File
@@ -125,22 +125,29 @@ func parseStableTag(repo, tag string) (updates.Version, error) {
// latestTag fetches the tag of repo's /releases/latest.
func (g github) latestTag(ctx context.Context, repo string) (string, error) {
url := fmt.Sprintf("%s/repos/%s/releases/latest", g.baseURL, repo)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
return g.releaseTag(ctx, repo, "releases/latest")
}
func (g github) get(ctx context.Context, path, accept string) (*http.Response, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, g.baseURL+path, nil)
if err != nil {
return "", fmt.Errorf("github: build request for %s: %w", repo, err)
return nil, err
}
ua := g.userAgent
if ua == "" {
ua = defaultUserAgent
}
req.Header.Set("User-Agent", ua)
req.Header.Set("Accept", "application/vnd.github+json")
req.Header.Set("Accept", accept)
if g.token != "" {
req.Header.Set("Authorization", "Bearer "+g.token)
}
resp, err := g.hc.Do(req)
return g.hc.Do(req)
}
func (g github) releaseTag(ctx context.Context, repo, endpoint string) (string, error) {
resp, err := g.get(ctx, "/repos/"+repo+"/"+endpoint, "application/vnd.github+json")
if err != nil {
return "", fmt.Errorf("github: get %s: %w", repo, err)
}
@@ -150,15 +157,15 @@ func (g github) latestTag(ctx context.Context, repo string) (string, error) {
// 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. The official repository is public, so there only the first applies.
if resp.StatusCode == http.StatusNotFound && strings.EqualFold(repo, officialRepo) {
if endpoint == "releases/latest" && resp.StatusCode == http.StatusNotFound && strings.EqualFold(repo, officialRepo) {
return "", fmt.Errorf("github: %s releases/latest returned HTTP 404 — it has no published stable release", repo)
}
if resp.StatusCode == http.StatusNotFound && g.token == "" {
return "", 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)
"github: %s %s returned HTTP 404 — either no published stable release exists, or the repository is private and %s is unset",
repo, endpoint, tokenEnv)
}
return "", fmt.Errorf("github: %s releases/latest returned HTTP %d", repo, resp.StatusCode)
return "", fmt.Errorf("github: %s %s returned HTTP %d", repo, endpoint, resp.StatusCode)
}
var rr releaseResponse
@@ -166,7 +173,7 @@ func (g github) latestTag(ctx context.Context, repo string) (string, error) {
return "", fmt.Errorf("github: decode %s: %w", repo, err)
}
if rr.Draft || rr.Prerelease {
return "", fmt.Errorf("github: %s releases/latest is unexpectedly draft/prerelease (tag %q)", repo, rr.TagName)
return "", fmt.Errorf("github: %s %s is unexpectedly draft/prerelease (tag %q)", repo, endpoint, rr.TagName)
}
return rr.TagName, nil
+117
View File
@@ -0,0 +1,117 @@
package updater
import (
"context"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"regexp"
"strings"
)
var releaseTagPattern = regexp.MustCompile(`^v[0-9]+\.[0-9]+\.[0-9]+$`)
var githubRepoPattern = regexp.MustCompile(`^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$`)
// InstallTarget pins both the installer and the source tree to one revision.
// Release is set only when installing checksum-verified published assets.
type InstallTarget struct {
Release string
Revision string
Script string
}
// InstallerSource uses the same GitHub client and token as release discovery.
type InstallerSource struct {
github github
repo string
}
func NewInstallerSource(repoURL string) (InstallerSource, error) {
repo := officialRepo
if repoURL != "" {
if strings.HasPrefix(repoURL, "[email protected]:") {
repo = strings.TrimPrefix(repoURL, "[email protected]:")
} else {
u, err := url.Parse(repoURL)
if err != nil || u.Hostname() != "github.com" || (u.Scheme != "https" && u.Scheme != "ssh") || u.RawQuery != "" || u.Fragment != "" {
return InstallerSource{}, fmt.Errorf("FELIS_REPO_URL must name a GitHub repository over HTTPS or SSH")
}
repo = strings.TrimPrefix(u.Path, "/")
}
repo = strings.TrimSuffix(strings.TrimSuffix(repo, "/"), ".git")
if !githubRepoPattern.MatchString(repo) || strings.Contains(repo, "..") {
return InstallerSource{}, fmt.Errorf("FELIS_REPO_URL must name a GitHub owner/repository")
}
}
return InstallerSource{github: newGitHub(), repo: repo}, nil
}
func (s InstallerSource) RepoURL() string { return "https://github.com/" + s.repo + ".git" }
// Prepare downloads the complete script before any host changes. A moving ref
// (including main) is resolved once; the installer receives that same full SHA.
func (s InstallerSource) Prepare(ctx context.Context, release, ref string) (InstallTarget, error) {
if ref == "" {
endpoint := "releases/latest"
if release != "" {
endpoint = "releases/tags/" + url.PathEscape(release)
}
tag, err := s.github.releaseTag(ctx, s.repo, endpoint)
if err != nil {
return InstallTarget{}, err
}
if !releaseTagPattern.MatchString(tag) || (release != "" && tag != release) {
return InstallTarget{}, fmt.Errorf("unexpected Felis release tag %q", tag)
}
release, ref = tag, tag
}
resp, err := s.github.get(ctx, "/repos/"+s.repo+"/commits/"+url.PathEscape(ref), "application/vnd.github+json")
if err != nil {
return InstallTarget{}, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return InstallTarget{}, fmt.Errorf("github: resolve ref %q: HTTP %d", ref, resp.StatusCode)
}
var commit struct {
SHA string `json:"sha"`
}
if err := json.NewDecoder(resp.Body).Decode(&commit); err != nil {
return InstallTarget{}, fmt.Errorf("github: decode commit: %w", err)
}
if _, err := hex.DecodeString(commit.SHA); err != nil || len(commit.SHA) != 40 {
return InstallTarget{}, fmt.Errorf("github: ref %q did not resolve to a full commit SHA", ref)
}
commit.SHA = strings.ToLower(commit.SHA)
if _, err := hex.DecodeString(ref); err == nil && len(ref) == 40 && !strings.EqualFold(ref, commit.SHA) {
return InstallTarget{}, fmt.Errorf("github: resolved commit %s differs from requested %s", commit.SHA, ref)
}
script, err := s.script(ctx, commit.SHA)
if err != nil {
return InstallTarget{}, err
}
return InstallTarget{Release: release, Revision: commit.SHA, Script: script}, nil
}
func (s InstallerSource) script(ctx context.Context, revision string) (string, error) {
resp, err := s.github.get(ctx, "/repos/"+s.repo+"/contents/deploy/bootstrap.sh?ref="+revision, "application/vnd.github.raw+json")
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("github: download installer at %s: HTTP %d", revision, resp.StatusCode)
}
const limit = 2 << 20
raw, err := io.ReadAll(io.LimitReader(resp.Body, limit+1))
if err != nil {
return "", fmt.Errorf("github: read installer: %w", err)
}
if len(raw) > limit || !strings.HasPrefix(string(raw), "#!/") {
return "", fmt.Errorf("github: installer is oversized or is not a shell script")
}
return string(raw), nil
}
+126
View File
@@ -0,0 +1,126 @@
package updater
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"reflect"
"strings"
"testing"
)
const installerTestSHA = "0123456789abcdef0123456789abcdef01234567"
func TestInstallerResolvesMovingRefsOnceAndPinsTheScript(t *testing.T) {
for _, tc := range []struct{ name, release, ref, releasePath, commitRef string }{
{"latest release", "", "", "releases/latest", "v0.2.0"},
{"named release", "v0.2.0", "", "releases/tags/v0.2.0", "v0.2.0"},
{"main", "", "main", "", "main"},
{"branch with slash", "", "feature/example", "", "feature/example"},
{"exact commit", "", installerTestSHA, "", installerTestSHA},
} {
t.Run(tc.name, func(t *testing.T) {
var calls []string
const script = "#!/bin/bash\n# FELIS_RELEASE\necho example\n"
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" || r.Header.Get("Authorization") != "Bearer private-token" || r.Header.Get("User-Agent") == "" {
t.Errorf("unexpected request headers/method: %v", r)
}
calls = append(calls, r.URL.Path)
switch r.URL.Path {
case "/repos/example/Felis/" + tc.releasePath:
fmt.Fprint(w, `{"tag_name":"v0.2.0"}`)
case "/repos/example/Felis/commits/" + tc.commitRef:
fmt.Fprintf(w, `{"sha":%q}`, installerTestSHA)
case "/repos/example/Felis/contents/deploy/bootstrap.sh":
if r.URL.Query().Get("ref") != installerTestSHA || r.Header.Get("Accept") != "application/vnd.github.raw+json" {
t.Errorf("script not pinned/raw: %v", r)
}
fmt.Fprint(w, script)
default:
http.NotFound(w, r)
}
}))
defer srv.Close()
gh := newTestGitHub(srv)
gh.token = "private-token"
target, err := (InstallerSource{github: gh, repo: "example/Felis"}).Prepare(context.Background(), tc.release, tc.ref)
if err != nil {
t.Fatal(err)
}
wantRelease := ""
wantCalls := []string{}
if tc.releasePath != "" {
wantRelease = "v0.2.0"
wantCalls = append(wantCalls, "/repos/example/Felis/"+tc.releasePath)
}
wantCalls = append(wantCalls, "/repos/example/Felis/commits/"+tc.commitRef, "/repos/example/Felis/contents/deploy/bootstrap.sh")
if target.Release != wantRelease || target.Revision != installerTestSHA || target.Script != script || !reflect.DeepEqual(calls, wantCalls) {
t.Fatalf("target %+v, calls %v; want %v", target, calls, wantCalls)
}
})
}
}
func TestInstallerFailsClosedBeforeExecutingUnusableTargets(t *testing.T) {
for _, tc := range []struct {
name, releaseBody, commitBody, script string
status int
}{
{"prerelease", `{"tag_name":"v0.2.0","prerelease":true}`, "", "", 200},
{"invalid tag", `{"tag_name":"latest"}`, "", "", 200},
{"short commit", `{"tag_name":"v0.2.0"}`, `{"sha":"abcdef0"}`, "", 200},
{"invalid commit", `{"tag_name":"v0.2.0"}`, `{"sha":"zz23456789abcdef0123456789abcdef01234567"}`, "", 200},
{"non-script response", `{"tag_name":"v0.2.0"}`, fmt.Sprintf(`{"sha":%q}`, installerTestSHA), "<html>unavailable</html>", 200},
{"oversized script", `{"tag_name":"v0.2.0"}`, fmt.Sprintf(`{"sha":%q}`, installerTestSHA), "#!/bin/bash\n" + strings.Repeat("#", 2<<20), 200},
{"unavailable", "", "", "", 503},
} {
t.Run(tc.name, func(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(tc.status)
switch {
case strings.Contains(r.URL.Path, "/releases/"):
fmt.Fprint(w, tc.releaseBody)
case strings.Contains(r.URL.Path, "/commits/"):
fmt.Fprint(w, tc.commitBody)
default:
fmt.Fprint(w, tc.script)
}
}))
defer srv.Close()
got, err := (InstallerSource{github: newTestGitHub(srv), repo: officialRepo}).Prepare(context.Background(), "", "")
if err == nil || got != (InstallTarget{}) {
t.Fatalf("unsafe target %+v, err %v", got, err)
}
})
}
}
func TestInstallerAcceptsGitHubReposWithoutEmbeddingCredentials(t *testing.T) {
for _, repoURL := range []string{"", "https://github.com/FelisMC/Felis.git", "[email protected]:FelisMC/Felis.git", "ssh://[email protected]/FelisMC/Felis.git"} {
s, err := NewInstallerSource(repoURL)
if err != nil || s.RepoURL() != "https://github.com/FelisMC/Felis.git" {
t.Errorf("%s: %v, %+v", repoURL, err, s)
}
}
for _, repoURL := range []string{"https://example.com/FelisMC/Felis", "http://github.com/FelisMC/Felis", "https://github.com/FelisMC/Felis?token=secret", "[email protected]:../../bad"} {
if _, err := NewInstallerSource(repoURL); err == nil {
t.Errorf("accepted %s", repoURL)
}
}
}
func TestInstallerRefusesACommitDifferentFromTheRequestedSHA(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.Contains(r.URL.Path, "/contents/") {
t.Error("mismatched commit must not reach installer download")
}
fmt.Fprintf(w, `{"sha":%q}`, strings.Repeat("a", 40))
}))
defer srv.Close()
target, err := (InstallerSource{github: newTestGitHub(srv), repo: officialRepo}).Prepare(context.Background(), "", installerTestSHA)
if err == nil || target != (InstallTarget{}) {
t.Fatalf("accepted a different commit: %+v / %v", target, err)
}
}