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
+986 -453

No files matched your search

+1 -1
View File
@@ -44,7 +44,7 @@ Commands:
support-bundle Collect status, doctor, logs and cluster state into one redacted tar.gz to share when asking for help (requires root/sudo) support-bundle Collect status, doctor, logs and cluster state into one redacted tar.gz to share when asking for help (requires root/sudo)
watchdog Check the platform once and mail the owners what has gone wrong (run by felis-watchdog.timer) watchdog Check the platform once and mail the owners what has gone wrong (run by felis-watchdog.timer)
version Print the build stamp of this binary version Print the build stamp of this binary
update Report which platform components have updates available update Check platform versions; --apply installs a reviewed target
breakGlass Open the local break-glass emergency console (TUI; requires root/sudo) breakGlass Open the local break-glass emergency console (TUI; requires root/sudo)
Run "felis <command> -h" for command-specific flags. Run "felis <command> -h" for command-specific flags.
+119 -202
View File
@@ -7,8 +7,12 @@ import (
"flag" "flag"
"fmt" "fmt"
"io" "io"
"os"
"os/exec"
"os/signal"
"sort" "sort"
"strings" "strings"
"syscall"
"time" "time"
"github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5"
@@ -23,52 +27,15 @@ import (
// leave the operator staring at a silent terminal. // leave the operator staring at a silent terminal.
const updateTimeout = 60 * time.Second const updateTimeout = 60 * time.Second
// updateTarget maps a user-facing selector (`--panel`) onto the planner's component // updateTarget maps report selectors onto the components tracked by the planner.
// name and the command that actually performs the update. // Application always reuses bootstrap.sh for the compatible platform bundle.
//
// The apply side is deliberately NOT implemented in this command. Every component
// here is installed by deploy/bootstrap.sh, which is idempotent, already handles the
// parts that are easy to get wrong (Velocity's pinned MINOR, the atomic jar install,
// the image re-import + registry push that a byte-identical StatefulSet template
// will not trigger on its own), and is the path that gets exercised on every
// install. A
// second installer living in this file would duplicate that policy, could drift from
// it silently, and would be reachable only on a live node where a mistake takes the
// proxy or the control plane down. So `felis update` reports, and hands the operator
// the tested command — it does not re-implement it.
type updateTarget struct { type updateTarget struct {
// selector is the flag name without dashes.
selector string selector string
// help is the flag's usage line.
help string help string
// component is the updates planner's name for this piece, or "" when the planner
// deliberately does not track it (Minecraft, which is pinned).
component string component string
// note explains what this selector covers, printed above the command. note string // explanation for the untracked Minecraft selector
note string
// command is the exact, already-tested way to apply it.
command string
// installer marks a command that re-runs the installer, which the trailer explains.
installer bool
} }
// installerRerun is the tested apply path for every selector Felis installs: re-run the
// installer. It is idempotent, and it is the only path that fetches a newer version --
// `felis setup` skips its host-bootstrap phase on a completed install (all four install
// markers already exist), so there it opens the config console and moves no component,
// and even on the bootstrap path it re-images felis-api from the binary setup is already
// running (FELIS_BOOTSTRAP_BINARY), which looks like an update and changes nothing.
//
// The URL is the one-liner both READMEs hand out, read at a tag rather than main: the
// script's release channel installs the newest release's binary, and main can carry
// installer changes that binary was never tested with. installerRef picks the tag and
// renderApplyGuidance substitutes it for {ref}.
const installerRerun = "curl -fsSL https://raw.githubusercontent.com/FelisMC/Felis/{ref}/deploy/bootstrap.sh | sudo bash"
// installerRerunDeps is the same re-run with FELIS_UPGRADE_DEPS=1, which lets it move an
// installed k3s and cloudflared to the versions the release pins.
const installerRerunDeps = "curl -fsSL https://raw.githubusercontent.com/FelisMC/Felis/{ref}/deploy/bootstrap.sh | sudo FELIS_UPGRADE_DEPS=1 bash"
// updateTargets is the selector table. panel and plugins both resolve to felis-api // updateTargets is the selector table. panel and plugins both resolve to felis-api
// because they are not separately versioned: the panel is compiled into the felis // because they are not separately versioned: the panel is compiled into the felis
// binary with //go:embed, and the plugin jars are built from this same repo in the // binary with //go:embed, and the plugin jars are built from this same repo in the
@@ -78,82 +45,52 @@ var updateTargets = []updateTarget{
selector: "panel", selector: "panel",
help: "select the panel + control plane (felis-api)", help: "select the panel + control plane (felis-api)",
component: "felis-api", component: "felis-api",
note: "the panel is embedded in the felis binary (//go:embed), so updating it means rebuilding the felis image and rolling felis-api", },
command: installerRerun, {
installer: true, selector: "self",
help: "select the Felis binary and its core services",
component: "felis-api",
}, },
{ {
selector: "velocity", selector: "velocity",
help: "select the Velocity proxy", help: "select the Velocity proxy",
component: "velocity", component: "velocity",
note: "re-runs install_velocity: the build the release pins in deploy/game-stack.lock (FELIS_VELOCITY_VERSION=<minor> takes that minor's newest build instead), sha256-checked, atomic jar install, then restarts felis-velocity only if the jar or its config changed",
command: installerRerun,
installer: true,
}, },
{ {
selector: "plugins", selector: "plugins",
help: "select the Felis plugin jars (velocity/paper/limbo)", help: "select the Felis plugin jars (velocity/paper/limbo)",
component: "felis-api", component: "felis-api",
note: "felis-velocity.jar is a host-file swap, but felis-paper.jar and felis-limbo.jar are baked into the lobby/limbo images and need a rebuild + re-mirror into the in-cluster registry (the installer re-run does both)",
command: installerRerun,
installer: true,
}, },
{ {
selector: "k3s", selector: "k3s",
help: "select k3s", help: "select k3s",
component: "k3s", component: "k3s",
note: "FELIS_UPGRADE_DEPS=1 moves k3s to the version the Felis release pins, which can trail the newest upstream; it moves one minor version at a time and refuses a larger jump. Running game servers keep running while k3s restarts",
command: installerRerunDeps,
installer: true,
}, },
{ {
selector: "cloudflared", selector: "cloudflared",
help: "select cloudflared", help: "select cloudflared",
component: "cloudflared", component: "cloudflared",
note: "FELIS_UPGRADE_DEPS=1 swaps the binary for the sha256-pinned build the Felis release names and restarts cloudflared-felis; the panel's tunnel drops for a few seconds",
command: installerRerunDeps,
installer: true,
}, },
{ {
selector: "jre", selector: "jre",
help: "select the Temurin JRE Velocity runs on", help: "select the Temurin JRE Velocity runs on",
component: "jre", component: "jre",
note: "the installer installs the Temurin build the Felis release pins (sha256-checked) and restarts felis-velocity when it changed; a newer upstream build reaches the host with a release that pins it",
command: installerRerun,
installer: true,
}, },
{ {
selector: "postgres", selector: "postgres",
help: "select PostgreSQL", help: "select PostgreSQL",
component: "postgresql", component: "postgresql",
note: "PostgreSQL runs as the felis-postgres Deployment from the image the Felis release pins by digest; a newer minor reaches the host with a release that moves the pin, and the installer re-run restarts the database on it (a few seconds without the API). A new major is a dump and restore: docs/operations.md §4",
command: installerRerun,
installer: true,
}, },
{ {
selector: "mc", selector: "mc",
help: "select Minecraft (pinned; reported only)", help: "select Minecraft (pinned; reported only)",
component: "", // never tracked: see the pin note below component: "", // never tracked: see the pin note below
note: "Minecraft is pinned by policy (\"能不动的就别动\") and Felis never proposes a version change for it. A server's version is a property of that server's image — change it on the server, not through a platform update", note: "Minecraft is pinned by policy (\"能不动的就别动\") and Felis never proposes a version change for it. A server's version is a property of that server's image — change it on the server, not through a platform update",
command: "",
}, },
} }
// cmdUpdate reports what can be updated and what is already current. // cmdUpdate checks by default; only --apply changes the host. --record remains
// // read-only so the daily timer cannot start an unattended upgrade.
// Bare `felis update` prints the status of every tracked component. Selector flags
// (--panel/--velocity/--plugins/--k3s/--cloudflared/--jre/--postgres/--mc/--all) narrow that report to the components
// they name AND print how to apply each one. --force additionally prints the apply
// instruction for a selected component that is already up to date, for the
// reinstall/repair case.
//
// It never applies anything and never mutates the node, so unlike setup/breakGlass
// it needs no root, apart from PostgreSQL's version, which the felis-postgres container
// answers through the cluster's admin kubeconfig. The versions it reads come from this
// host: k3s, cloudflared and PostgreSQL answer `--version`, Velocity's version is read out of the installed jar's
// manifest, the JRE's out of its release file, and felis-api's is this binary's own
// build stamp — the same value `felis version` prints, which is what the user asked
// to be the source of truth.
func cmdUpdate(args []string, stdout, stderr io.Writer) int { func cmdUpdate(args []string, stdout, stderr io.Writer) int {
fs := flag.NewFlagSet("update", flag.ContinueOnError) fs := flag.NewFlagSet("update", flag.ContinueOnError)
fs.SetOutput(stderr) fs.SetOutput(stderr)
@@ -161,11 +98,19 @@ func cmdUpdate(args []string, stdout, stderr io.Writer) int {
for _, t := range updateTargets { for _, t := range updateTargets {
flags[t.selector] = fs.Bool(t.selector, false, t.help) flags[t.selector] = fs.Bool(t.selector, false, t.help)
} }
all := fs.Bool("all", false, "select every component above") var opts updateOptions
force := fs.Bool("force", false, "print the apply command for a selected component even when it is already up to date") fs.BoolVar(&opts.all, "all", false, "include the release-pinned k3s and cloudflared updates")
velocityJar := fs.String("velocity-jar", updater.DefaultVelocityJarPath, "path to the installed Velocity jar to read the current version from") fs.BoolVar(&opts.force, "force", false, "reinstall even at the same version; allow an explicitly requested Felis downgrade (does not bypass maintenance or dependency guards)")
cfgPath := fs.String("config", "/etc/felis/felis.toml", "path to felis.toml, read for the maintenance window the panel stores") fs.BoolVar(&opts.apply, "apply", false, "apply the inspected target after checking maintenance and taking a database/state backup")
record := fs.Bool("record", false, "also store this check for the panel's Updates page (felis-update-check.timer runs it daily)") fs.BoolVar(&opts.now, "now", false, "explicitly start manual maintenance now instead of using the configured window (requires --apply)")
fs.StringVar(&opts.release, "version", "", "install a published Felis release, e.g. v0.2.0")
fs.StringVar(&opts.expectedCommit, "expect-commit", "", "refuse application if the target differs from the full SHA printed by the check")
fs.StringVar(&opts.ref, "ref", "", "build an exact commit, tag or branch from source")
dev := fs.Bool("dev", false, "build the newest main commit (the check prints its full SHA)")
check := fs.Bool("check", false, "check and print the apply command without changing the host (default)")
velocityJar := fs.String("velocity-jar", updater.DefaultVelocityJarPath, "path to the installed Velocity jar")
fs.StringVar(&opts.cfgPath, "config", "/etc/felis/felis.toml", "host config for the maintenance window and pre-update backup")
record := fs.Bool("record", false, "store this read-only check for the panel (used by the daily timer)")
if err := fs.Parse(args); err != nil { if err := fs.Parse(args); err != nil {
return 2 return 2
} }
@@ -173,52 +118,108 @@ func cmdUpdate(args []string, stdout, stderr io.Writer) int {
fmt.Fprintf(stderr, "felis update: unexpected argument %q (this command takes flags only)\n", fs.Arg(0)) fmt.Fprintf(stderr, "felis update: unexpected argument %q (this command takes flags only)\n", fs.Arg(0))
return 2 return 2
} }
if err := opts.validate(*dev, *check, *record); err != nil {
selected := map[string]bool{} fmt.Fprintf(stderr, "felis update: %v\n", err)
return 2
}
if *dev {
opts.ref = "main"
}
opts.selected = map[string]bool{}
for sel, on := range flags { for sel, on := range flags {
if *on || *all { if *on || opts.all {
selected[sel] = true opts.selected[sel] = true
} }
} }
if len(opts.selected) == 1 && opts.selected["mc"] {
ctx, cancel := context.WithTimeout(context.Background(), updateTimeout) if opts.apply {
defer cancel() fmt.Fprintln(stderr, "felis update: Minecraft is managed through each server's image, not a platform update")
return 2
src := updater.NewRoutingSource(updater.Topology())
rn := &updater.Runner{
Gatherer: updater.NewHostGatherer(resolvedVersion(), *velocityJar),
Source: src,
// Notifier and Applier stay nil on purpose: a human typing this command IS the
// notification, and nothing here applies. The zero Window below means every
// Scheduled component degrades to a notify, so the report can never claim an
// apply is under way.
} }
res, err := rn.Run(ctx, time.Now(), updates.Window{}) for _, t := range updateTargets {
if t.selector == "mc" {
fmt.Fprintln(stdout, t.note)
}
}
return 0
}
if opts.apply && os.Geteuid() != 0 {
fmt.Fprintln(stderr, "felis update: --apply must run as root (use sudo)")
return 1
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
if !opts.apply {
if code := checkUpdates(ctx, opts, *velocityJar, *record, stdout, stderr); code != 0 {
return code
}
if *record {
return 0
}
}
source, err := updater.NewInstallerSource(os.Getenv("FELIS_REPO_URL"))
if err != nil { if err != nil {
fmt.Fprintf(stderr, "felis update: %v\n", err) fmt.Fprintf(stderr, "felis update: %v\n", err)
return 1 return 1
} }
fmt.Fprintln(stdout, "Resolving the Felis target and downloading its matching installer...")
lookup, cancel := context.WithTimeout(ctx, updateTimeout)
target, err := source.Prepare(lookup, opts.release, opts.ref)
cancel()
if err != nil {
fmt.Fprintf(stderr, "felis update: cannot prepare an update: %v\n", err)
return 1
}
if opts.expectedCommit != "" && target.Revision != opts.expectedCommit {
fmt.Fprintf(stderr, "felis update: target moved since the check: expected %s, got %s; check again before applying\n", opts.expectedCommit, target.Revision)
return 1
}
syntax := exec.CommandContext(ctx, "bash", "-n")
syntax.Stdin, syntax.Stderr = strings.NewReader(target.Script), stderr
if err := syntax.Run(); err != nil {
fmt.Fprintf(stderr, "felis update: invalid installer: %v\n", err)
return 1
}
if os.Getenv("FELIS_REPO_URL") != "" {
opts.repoURL = source.RepoURL()
}
opts.preserveToken = os.Getenv("FELIS_GITHUB_TOKEN") != ""
fmt.Fprint(stdout, renderInstallPlan(target, opts))
if !opts.apply {
return 0
}
if err := applyHostUpdate(ctx, target, opts, source.RepoURL(), stdout, stderr); err != nil {
fmt.Fprintf(stderr, "felis update: %v\n", err)
return 1
}
fmt.Fprintln(stdout, "Felis binary and core components updated and verified.")
return 0
}
func checkUpdates(ctx context.Context, opts updateOptions, velocityJar string, record bool, stdout, stderr io.Writer) int {
lookup, cancel := context.WithTimeout(ctx, updateTimeout)
defer cancel()
src := updater.NewRoutingSource(updater.Topology())
rn := &updater.Runner{Gatherer: updater.NewHostGatherer(resolvedVersion(), velocityJar), Source: src}
fmt.Fprintln(stdout, "Checking installed components and upstream versions (read-only)...")
res, err := rn.Run(lookup, time.Now(), updates.Window{})
if err != nil {
fmt.Fprintf(stderr, "felis update: %v\n", err)
return 1
}
now := time.Now() now := time.Now()
win, winErr := readUpdateWindow(ctx, *cfgPath) win, winErr := readUpdateWindow(ctx, opts.cfgPath)
fmt.Fprint(stdout, renderWindowLine(win, winErr, now)) fmt.Fprint(stdout, renderWindowLine(win, winErr, now))
fmt.Fprint(stdout, renderUpdateReport(res, selected)) fmt.Fprint(stdout, renderUpdateReport(res, opts.selected))
fmt.Fprint(stdout, renderNotes(src.Notes(), selected)) fmt.Fprint(stdout, renderNotes(src.Notes(), opts.selected))
if len(selected) > 0 { if record {
if winErr == nil && !win.Start.IsZero() && !win.Contains(now) { rctx, rcancel := context.WithTimeout(ctx, updateWindowTimeout)
fmt.Fprint(stdout, "Warning: this is outside the maintenance window; the commands below take effect as soon as you run them.\n")
}
fmt.Fprint(stdout, renderApplyGuidance(res, selected, *force))
}
if *record {
// A fresh context: the discovery pass may have spent most of updateTimeout.
rctx, rcancel := context.WithTimeout(context.Background(), updateWindowTimeout)
defer rcancel() defer rcancel()
if err := recordUpdateStatus(rctx, *cfgPath, buildStatusReport(res, src.Notes(), resolvedVersion(), now)); err != nil { if err := recordUpdateStatus(rctx, opts.cfgPath, buildStatusReport(res, src.Notes(), resolvedVersion(), now)); err != nil {
fmt.Fprintf(stderr, "felis update: record the check for the panel: %v\n", err) fmt.Fprintf(stderr, "felis update: record the check for the panel: %v\n", err)
return 1 return 1
} }
fmt.Fprint(stdout, "Recorded this check for the panel's Updates page.\n") fmt.Fprintln(stdout, "Recorded this check for the panel's Updates page.")
} }
return 0 return 0
} }
@@ -388,98 +389,14 @@ func selectedCovers(selected map[string]bool, component string) bool {
return false return false
} }
// renderApplyGuidance prints, for each selected target, how to actually apply the
// update. A target that is already current is skipped unless --force was passed.
func renderApplyGuidance(res updater.Result, selected map[string]bool, force bool) string {
byComponent := map[string]updates.Action{}
for _, a := range res.RunResult.Plan {
byComponent[a.Component] = a
}
var b strings.Builder
var offeredInstaller bool
for _, t := range updateTargets {
if !selected[t.selector] {
continue
}
// Minecraft has no planner entry by design; state the pin and move on. There is
// no command to offer, so this must not arm the trailer below.
if t.component == "" {
fmt.Fprintf(&b, "\n--%s: %s.\n", t.selector, t.note)
continue
}
a, planned := byComponent[t.component]
// "Up to date" requires actually KNOWING the latest version. ActionNone covers
// both "nothing newer exists" and "the release feed could not be read", and
// collapsing those would report an unreachable upstream as currency — telling an
// operator they are current when nobody checked is the one answer an update tool
// must never give. LatestKnown is what separates them.
if planned && a.Kind == updates.ActionNone && a.LatestKnown && !force {
fmt.Fprintf(&b, "\n--%s: %s is already up to date; nothing to apply (use --force to reinstall anyway).\n", t.selector, t.component)
continue
}
fmt.Fprintf(&b, "\n--%s: %s\n", t.selector, t.note)
if planned && !a.LatestKnown {
// Unknown latest still offers the command: the operator asked about this
// component, and reinstalling the current release is a valid repair action.
fmt.Fprintf(&b, " note: cannot tell whether %s is current — its latest version could not be discovered (see above); this reinstalls it either way\n", t.component)
}
fmt.Fprintf(&b, " run: %s\n", strings.ReplaceAll(t.command, "{ref}", installerRef(byComponent)))
offeredInstaller = offeredInstaller || t.installer
}
// Only explain the command when one was actually offered; a --mc-only run has
// nothing to run and the trailer would be a non-sequitur.
//
// One trailer serves every selector now: setup is not an apply path at all on a
// completed install (shouldRunHostBootstrapBeforeConfig only enters the host
// bootstrap while an install marker is missing), so the installer re-run is the one
// worked path for every component Felis installs and there is no per-component exception left
// to scope. One caveat stays because following the advice without it bites real
// hosts: the channel is not persisted anywhere (a bare re-run on a main host quietly
// moves it onto releases).
if offeredInstaller {
b.WriteString("\nRe-running the installer applies each installer command above: it fetches the newest version on\nthe channel in effect and re-applies the bundle (release is the default). The channel\nis not persisted, so pass FELIS_VERSION_BOOTSTRAP=dev if this host tracks main.\nfelis setup is not this path: on a completed install it opens the config console and\ninstalls nothing newer. Restart game servers afterwards.\n")
}
return b.String()
}
// installerRef is the git ref the installer re-run reads bootstrap.sh from: the newest
// stable felis release when the feed answered, which is the release that script then
// installs; else the release this host runs; main only when neither is a release tag.
func installerRef(byComponent map[string]updates.Action) string {
a, ok := byComponent["felis-api"]
if !ok {
return "main"
}
if a.LatestKnown && isReleaseTag(a.Latest) {
return a.Latest.String()
}
if isReleaseTag(a.Current) {
return a.Current.String()
}
return "main"
}
// isReleaseTag reports whether v was read from a stable vX.Y.Z tag, the only refs
// release.yml publishes a binary for. A source build stamps v0.0.0+g<commit>, which
// names no tag, so build metadata disqualifies a version too.
func isReleaseTag(v updates.Version) bool {
s := v.String()
if !strings.HasPrefix(s, "v") || v.IsPrerelease() || strings.Contains(s, "+") {
return false
}
_, err := updates.Parse(s)
return err == nil
}
// updateWindowTimeout bounds the maintenance-window read, so an unreachable // updateWindowTimeout bounds the maintenance-window read, so an unreachable
// database costs the report a line and never the report itself. // database costs the report a line and never the report itself.
const updateWindowTimeout = 3 * time.Second const updateWindowTimeout = 3 * time.Second
// readUpdateWindow reads the maintenance window the panel stores // readUpdateWindow reads the maintenance window the panel stores
// (platform_settings "update_window"). Felis applies nothing on its own: this // (platform_settings "update_window"). Felis applies nothing on its own: this
// command is the window's consumer, showing it and warning before an apply // command consumes it for both reporting and admission to an explicit apply.
// outside it. A missing row is an unset window. // A missing row is an unset window.
func readUpdateWindow(ctx context.Context, cfgPath string) (updates.Window, error) { func readUpdateWindow(ctx context.Context, cfgPath string) (updates.Window, error) {
cfg, err := config.Load(cfgPath) cfg, err := config.Load(cfgPath)
if err != nil { if err != nil {
@@ -515,12 +432,12 @@ func renderWindowLine(w updates.Window, err error, now time.Time) string {
case err != nil: case err != nil:
return fmt.Sprintf("Maintenance window: unknown (%v).\n", err) return fmt.Sprintf("Maintenance window: unknown (%v).\n", err)
case w.Start.IsZero() || w.End.IsZero(): case w.Start.IsZero() || w.End.IsZero():
return "Maintenance window: not set; apply whenever suits you.\n" return "Maintenance window: not set; configure it in the panel, or explicitly use --apply --now for manual maintenance.\n"
case w.Contains(now): case w.Contains(now):
return fmt.Sprintf("Maintenance window: open now, until %s.\n", w.End.Local().Format(layout)) return fmt.Sprintf("Maintenance window: open now, until %s.\n", w.End.Local().Format(layout))
case now.Before(w.Start): case now.Before(w.Start):
return fmt.Sprintf("Maintenance window: opens %s, until %s. Felis applies nothing on its own; run the apply commands inside it.\n", w.Start.Local().Format(layout), w.End.Local().Format(layout)) return fmt.Sprintf("Maintenance window: opens %s, until %s. Apply is blocked until this window opens (unless --now explicitly starts manual maintenance).\n", w.Start.Local().Format(layout), w.End.Local().Format(layout))
default: default:
return fmt.Sprintf("Maintenance window: ended %s; set a new one in the panel before applying.\n", w.End.Local().Format(layout)) return fmt.Sprintf("Maintenance window: ended %s; apply is blocked; set a new window in the panel or explicitly use --now.\n", w.End.Local().Format(layout))
} }
} }
+245
View File
@@ -0,0 +1,245 @@
package main
import (
"context"
"encoding/hex"
"errors"
"fmt"
"io"
"os"
"os/exec"
"strings"
"syscall"
"time"
"felis.lolicon.best/internal/updater"
"felis.lolicon.best/internal/updates"
)
type updateOptions struct {
release, ref, cfgPath, repoURL, expectedCommit string
preserveToken bool
apply, all, force, now bool
selected map[string]bool
}
func (o *updateOptions) validate(dev, check, record bool) error {
if (o.release != "" && o.ref != "") || (dev && (o.release != "" || o.ref != "")) {
return fmt.Errorf("choose only one of --version, --ref and --dev")
}
if o.apply && (check || record) {
return fmt.Errorf("--apply cannot be combined with --check or --record")
}
if o.expectedCommit != "" {
if _, err := hex.DecodeString(o.expectedCommit); err != nil || len(o.expectedCommit) != 40 || !o.apply {
return fmt.Errorf("--expect-commit requires --apply and a full commit SHA")
}
}
o.expectedCommit = strings.ToLower(o.expectedCommit)
if o.now && !o.apply {
return fmt.Errorf("--now requires --apply")
}
if record && (dev || o.release != "" || o.ref != "") {
return fmt.Errorf("--record checks installed components; use a separate target check")
}
if o.release != "" {
o.release = "v" + strings.TrimPrefix(o.release, "v")
v, err := updates.Parse(o.release)
if err != nil || v.IsPrerelease() || strings.Contains(o.release, "+") || strings.Count(o.release, ".") != 2 {
return fmt.Errorf("--version must be a published stable release such as v0.2.0; use --ref for other tags")
}
}
return nil
}
func (o updateOptions) dependencies() bool {
return o.all || o.selected["k3s"] || o.selected["cloudflared"]
}
func renderInstallPlan(target updater.InstallTarget, o updateOptions) string {
var b strings.Builder
label := target.Release
if label == "" {
label = "source build"
}
fmt.Fprintf(&b, "\nFelis target: %s\nCommit: %s\n", label, target.Revision)
b.WriteString("Scope: host CLI, API, operator, embedded panel, platform manifests/RBAC, Velocity + Felis plugins, login/lobby images, release-pinned JRE and PostgreSQL.\n")
if o.dependencies() {
b.WriteString("Host dependencies: also reconcile k3s and cloudflared to this target's pins.\n")
}
b.WriteString("Upstream availability above is advisory; application uses this target's compatible pins, not each upstream's newest release. User server images remain pinned.\n")
b.WriteString("Before applying: check maintenance, back up the database and host/server configuration, then check maintenance again.\n")
b.WriteString("API, proxy and system spaces may restart.\n")
if o.apply {
return b.String()
}
b.WriteString("No update is applied by this check.\n")
cmd := "sudo"
if o.preserveToken {
cmd += " --preserve-env=FELIS_GITHUB_TOKEN"
}
if o.repoURL != "" {
cmd += " env FELIS_REPO_URL=" + shellQuote(o.repoURL)
}
cmd += " felis update --apply"
assets := target.Release != "" && canUseReleaseAssets(target.Script, o.force)
if target.Release != "" && !assets {
b.WriteString("This release installer cannot pin the requested published-asset install; the apply command builds its exact source commit instead.\n")
}
if assets {
cmd += " --version " + target.Release + " --expect-commit " + target.Revision
} else {
cmd += " --ref " + target.Revision
}
if o.dependencies() {
cmd += " --all"
}
if o.force {
cmd += " --force"
}
if o.cfgPath != "/etc/felis/felis.toml" {
cmd += " --config " + shellQuote(o.cfgPath)
}
fmt.Fprintf(&b, "\nAfter reviewing, run inside the maintenance window:\n %s\n", cmd)
b.WriteString("Without an active window, apply is refused. For an explicit manual maintenance run, add --now. --force never bypasses these checks.\n")
return b.String()
}
// updateApplySteps isolates the three mutating/verification boundaries so the
// ordering and refusal paths can be tested without a live node.
type updateApplySteps struct {
window func(context.Context) (updates.Window, error)
backup, install, verify func(context.Context) error
}
func runUpdateApply(ctx context.Context, o updateOptions, steps updateApplySteps) error {
check := func() error {
win, err := steps.window(ctx)
if err != nil {
return fmt.Errorf("cannot verify maintenance window: %w", err)
}
if !o.now && (win.Start.IsZero() || win.End.IsZero() || !win.Contains(time.Now())) {
return fmt.Errorf("no active maintenance window; set one in the panel or explicitly use --apply --now for manual maintenance")
}
return ctx.Err()
}
if err := check(); err != nil {
return err
}
if err := steps.backup(ctx); err != nil {
return fmt.Errorf("pre-update backup failed; nothing applied: %w", err)
}
if err := check(); err != nil {
return err
}
if err := steps.install(ctx); err != nil {
return fmt.Errorf("installer failed; retain the pre-update backup and inspect its output before retrying: %w", err)
}
if err := steps.verify(ctx); err != nil {
return fmt.Errorf("installed binary verification failed: %w", err)
}
return nil
}
func canUseReleaseAssets(script string, force bool) bool {
return strings.Contains(script, `FELIS_RELEASE="${FELIS_RELEASE:-}"`) &&
(!force || strings.Contains(script, "${FELIS_FORCE_UPDATE:-0}"))
}
func applyHostUpdate(ctx context.Context, target updater.InstallTarget, o updateOptions, repoURL string, stdout, stderr io.Writer) error {
if target.Release != "" {
current, err := updates.Parse(resolvedVersion())
want, _ := updates.Parse(target.Release)
if err == nil && want.Compare(current) < 0 && !o.force {
return fmt.Errorf("%s is older than %s; an intentional Felis downgrade requires --force", target.Release, current)
}
if !canUseReleaseAssets(target.Script, o.force) {
return fmt.Errorf("this release installer cannot pin the requested published-asset install; use --ref %s to rebuild its exact source", target.Revision)
}
}
exe, err := os.Executable()
if err != nil {
return err
}
return runUpdateApply(ctx, o, updateApplySteps{
window: func(ctx context.Context) (updates.Window, error) { return readUpdateWindow(ctx, o.cfgPath) },
backup: func(ctx context.Context) error {
fmt.Fprintln(stdout, "[felis] taking the pre-update database and deployment-state backup")
cmd := exec.CommandContext(ctx, exe, "db", "backup", "--config", o.cfgPath, "--label", "pre-migrate")
cmd.Stdout, cmd.Stderr = stdout, stderr
return cmd.Run()
},
install: func(ctx context.Context) error {
fmt.Fprintln(stdout, "[felis] applying the inspected Felis target")
return runUpdateInstaller(ctx, target.Script, updateInstallerEnv(os.Environ(), target, o, repoURL), stdout, stderr)
},
verify: func(ctx context.Context) error {
out, err := exec.CommandContext(ctx, hostBinDir+"/felis", "version").Output()
if err != nil {
return err
}
line, _, _ := strings.Cut(string(out), "\n")
if !installedUpdateMatches(strings.TrimPrefix(line, "felis "), target) {
return fmt.Errorf("wanted %s / %s, got %q", target.Release, target.Revision, line)
}
fmt.Fprintln(stdout, line)
return nil
},
})
}
func installedUpdateMatches(version string, target updater.InstallTarget) bool {
if target.Release != "" {
return version == target.Release
}
_, sha, ok := strings.Cut(version, "+g")
return ok && len(sha) >= 7 && strings.HasPrefix(target.Revision, sha)
}
func updateInstallerEnv(env []string, target updater.InstallTarget, o updateOptions, repoURL string) []string {
// A setup hand-off or stale source override must never reinstall the running
// binary or a different tree than the one just inspected.
replace := map[string]string{
"FELIS_BOOTSTRAP_FROM_TUI": "", "FELIS_BOOTSTRAP_BINARY": "", "FELIS_SKIP_FETCH": "", "FELIS_ARTIFACT_DIR": "",
"FELIS_REF": target.Revision, "FELIS_RELEASE": "", "FELIS_VERSION_BOOTSTRAP": "dev",
"FELIS_REPO_URL": repoURL, "FELIS_NO_SETUP": "1", "FELIS_INSTALL_MODE": "full",
"FELIS_UPGRADE_DEPS": "0", "FELIS_FORCE_UPDATE": "0", "FELIS_IMAGE": "",
"FELIS_GAME_STACK": "pinned", "FELIS_VELOCITY_VERSION": "", "FELIS_JRE_VERSION": "",
"FELIS_K3S_VERSION": "", "FELIS_CLOUDFLARED_VERSION": "", "FELIS_PREFLIGHT": "strict", "FELIS_PRE_MIGRATE_BACKUP": "1",
}
if target.Release != "" {
replace["FELIS_REF"], replace["FELIS_RELEASE"], replace["FELIS_VERSION_BOOTSTRAP"] = "", target.Release, "release"
}
if o.dependencies() {
replace["FELIS_UPGRADE_DEPS"] = "1"
}
if o.force {
replace["FELIS_FORCE_UPDATE"] = "1"
}
out := make([]string, 0, len(env)+len(replace))
for _, item := range env {
key, _, _ := strings.Cut(item, "=")
if _, overridden := replace[key]; !overridden {
out = append(out, item)
}
}
for key, value := range replace {
out = append(out, key+"="+value)
}
return out
}
func runUpdateInstaller(ctx context.Context, script string, env []string, stdout, stderr io.Writer) error {
cmd := exec.CommandContext(ctx, "bash", "-s")
cmd.Env, cmd.Stdin, cmd.Stdout, cmd.Stderr = env, strings.NewReader(script), stdout, stderr
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
cmd.Cancel = func() error {
err := syscall.Kill(-cmd.Process.Pid, syscall.SIGTERM)
if errors.Is(err, syscall.ESRCH) {
return os.ErrProcessDone
}
return err
}
cmd.WaitDelay = 10 * time.Second
return cmd.Run()
}
+215
View File
@@ -0,0 +1,215 @@
package main
import (
"context"
"errors"
"io"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
"time"
"felis.lolicon.best/internal/updater"
"felis.lolicon.best/internal/updates"
)
const updateTestSHA = "0123456789abcdef0123456789abcdef01234567"
func TestUpdateRefPlanPinsTheReviewedCommit(t *testing.T) {
target := updater.InstallTarget{Revision: updateTestSHA}
for _, opts := range []updateOptions{
{cfgPath: "/etc/felis/felis.toml"},
{cfgPath: "/etc/felis/felis.toml", force: true, all: true},
{cfgPath: "/etc/felis/felis.toml", selected: map[string]bool{"k3s": true}},
} {
out := renderInstallPlan(target, opts)
if !strings.Contains(out, "sudo felis update --apply --ref "+updateTestSHA) || strings.Contains(out, "--dev") {
t.Fatalf("moving target in apply command: %s", out)
}
if !strings.Contains(out, "No update is applied") || !strings.Contains(out, "User server images remain pinned") {
t.Fatalf("missing scope: %s", out)
}
if strings.Contains(out, " --all") != opts.dependencies() || strings.Contains(out, "--ref "+updateTestSHA+" --all --force") != opts.force {
t.Fatalf("apply command lost options: %s", out)
}
}
opts := updateOptions{cfgPath: "/path/'literal $(id).toml", repoURL: "https://github.com/example/Felis.git", preserveToken: true}
out := renderInstallPlan(updater.InstallTarget{Release: "v0.2.0", Revision: updateTestSHA, Script: `#!/bin/bash
FELIS_RELEASE="${FELIS_RELEASE:-}"`}, opts)
for _, want := range []string{"--version v0.2.0 --expect-commit " + updateTestSHA, "--preserve-env=FELIS_GITHUB_TOKEN", "FELIS_REPO_URL=" + shellQuote(opts.repoURL), "--config " + shellQuote(opts.cfgPath)} {
if !strings.Contains(out, want) {
t.Fatalf("missing %q: %s", want, out)
}
}
older := renderInstallPlan(updater.InstallTarget{Release: "v0.2.0", Revision: updateTestSHA, Script: "#!/bin/bash\n# FELIS_RELEASE"}, updateOptions{cfgPath: "/etc/felis/felis.toml"})
if !strings.Contains(older, "--apply --ref "+updateTestSHA) || strings.Contains(older, "--version v0.2.0") {
t.Fatalf("old installer must offer a working pinned source apply: %s", older)
}
if strings.Contains(renderInstallPlan(target, updateOptions{apply: true}), "No update is applied") {
t.Fatal("apply must not claim it is only a check")
}
}
func TestUpdateRejectsConflictingOrUnsafeFlagsBeforeDiscovery(t *testing.T) {
for _, args := range [][]string{
{"--apply", "--check"}, {"--apply", "--record"}, {"--now"},
{"--dev", "--version", "v0.2.0"}, {"--dev", "--ref", "main"},
{"--ref", "main", "--version", "v0.2.0"}, {"--record", "--dev"},
{"--version", "v0.2.0-rc.1"}, {"--version", "v0.2.0+gabc1234"},
{"--apply", "--mc"}, {"apply"}, {"--apply", "--expect-commit", "short"}, {"--expect-commit", updateTestSHA},
} {
var out, errb strings.Builder
if got := cmdUpdate(args, &out, &errb); got != 2 || out.Len() != 0 {
t.Errorf("args %v: code %d, out %q, err %q", args, got, out.String(), errb.String())
}
}
o := updateOptions{release: "0.2.0"}
if err := o.validate(false, true, false); err != nil || o.release != "v0.2.0" {
t.Fatalf("normalize version: %v / %q", err, o.release)
}
}
func TestUpdateApplySafetyAndOrdering(t *testing.T) {
now := time.Now()
open := updates.Window{Start: now.Add(-time.Hour), End: now.Add(time.Hour)}
future := updates.Window{Start: now.Add(time.Hour), End: now.Add(2 * time.Hour)}
ended := updates.Window{Start: now.Add(-2 * time.Hour), End: now.Add(-time.Hour)}
failed := errors.New("failed")
for _, tc := range []struct {
name string
opts updateOptions
windows []updates.Window
windowErr, backupErr, installErr, verifyErr error
want []string
wantErr bool
}{
{name: "active", windows: []updates.Window{open, open}, want: []string{"window", "backup", "window", "install", "verify"}},
{name: "unset", windows: []updates.Window{{}}, want: []string{"window"}, wantErr: true},
{name: "future", windows: []updates.Window{future}, want: []string{"window"}, wantErr: true},
{name: "ended", windows: []updates.Window{ended}, want: []string{"window"}, wantErr: true},
{name: "force cannot bypass", opts: updateOptions{force: true}, windows: []updates.Window{future}, want: []string{"window"}, wantErr: true},
{name: "manual maintenance", opts: updateOptions{now: true}, windows: []updates.Window{{}, {}}, want: []string{"window", "backup", "window", "install", "verify"}},
{name: "manual cannot bypass unreadable window", opts: updateOptions{now: true}, windowErr: failed, want: []string{"window"}, wantErr: true},
{name: "backup failure", windows: []updates.Window{open}, backupErr: failed, want: []string{"window", "backup"}, wantErr: true},
{name: "expires during backup", windows: []updates.Window{open, ended}, want: []string{"window", "backup", "window"}, wantErr: true},
{name: "install failure", windows: []updates.Window{open, open}, installErr: failed, want: []string{"window", "backup", "window", "install"}, wantErr: true},
{name: "verification failure", windows: []updates.Window{open, open}, verifyErr: failed, want: []string{"window", "backup", "window", "install", "verify"}, wantErr: true},
} {
t.Run(tc.name, func(t *testing.T) {
var calls []string
reads := 0
steps := updateApplySteps{
window: func(context.Context) (updates.Window, error) {
calls = append(calls, "window")
if tc.windowErr != nil {
return updates.Window{}, tc.windowErr
}
w := tc.windows[reads]
reads++
return w, nil
},
backup: func(context.Context) error { calls = append(calls, "backup"); return tc.backupErr },
install: func(context.Context) error { calls = append(calls, "install"); return tc.installErr },
verify: func(context.Context) error { calls = append(calls, "verify"); return tc.verifyErr },
}
err := runUpdateApply(context.Background(), tc.opts, steps)
if (err != nil) != tc.wantErr || !reflect.DeepEqual(calls, tc.want) {
t.Fatalf("calls %v, err %v; want %v, error %v", calls, err, tc.want, tc.wantErr)
}
})
}
}
func TestUpdateInstallerCannotUseStaleSourceOrBypassGuards(t *testing.T) {
env := []string{"FELIS_REF=stale", "FELIS_REF=duplicate", "FELIS_BOOTSTRAP_BINARY=/old", "FELIS_BOOTSTRAP_FROM_TUI=1", "FELIS_SKIP_FETCH=1", "FELIS_RELEASE=v0.1.0", "FELIS_GAME_STACK=latest", "FELIS_PREFLIGHT=warn", "FELIS_PRE_MIGRATE_BACKUP=0", "FELIS_GITHUB_TOKEN=private-token", "PATH=/usr/bin"}
for _, release := range []string{"", "v0.2.0"} {
out := updateInstallerEnv(env, updater.InstallTarget{Release: release, Revision: updateTestSHA}, updateOptions{force: true, all: true}, "https://github.com/FelisMC/Felis.git")
got := map[string]string{}
for _, item := range out {
key, value, _ := strings.Cut(item, "=")
if _, exists := got[key]; exists {
t.Fatalf("duplicate environment key %s", key)
}
got[key] = value
}
for key, want := range map[string]string{"FELIS_BOOTSTRAP_BINARY": "", "FELIS_BOOTSTRAP_FROM_TUI": "", "FELIS_SKIP_FETCH": "", "FELIS_RELEASE": release, "FELIS_GAME_STACK": "pinned", "FELIS_PREFLIGHT": "strict", "FELIS_PRE_MIGRATE_BACKUP": "1", "FELIS_FORCE_UPDATE": "1", "FELIS_UPGRADE_DEPS": "1", "FELIS_GITHUB_TOKEN": "private-token"} {
if got[key] != want {
t.Errorf("%s = %q; want %q", key, got[key], want)
}
}
wantRef := updateTestSHA
if release != "" {
wantRef = ""
}
if got["FELIS_REF"] != wantRef {
t.Fatalf("wrong source: %v", got)
}
}
}
func TestUpdateExecutesInstallerAndPropagatesFailure(t *testing.T) {
var stdout, stderr strings.Builder
err := runUpdateInstaller(context.Background(), "#!/bin/bash\nprintf 'target:%s' \"$FELIS_REF\"\nprintf 'diagnostic' >&2\nexit 7\n", append(os.Environ(), "FELIS_REF="+updateTestSHA), &stdout, &stderr)
if err == nil || stdout.String() != "target:"+updateTestSHA || stderr.String() != "diagnostic" {
t.Fatalf("out %q, stderr %q, err %v", stdout.String(), stderr.String(), err)
}
}
func TestUpdateCancellationStopsInstallerChildren(t *testing.T) {
dir := t.TempDir()
ready, stopped := filepath.Join(dir, "ready"), filepath.Join(dir, "child-stopped")
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
done := make(chan error, 1)
script := `#!/bin/bash
trap 'exit 0' TERM
(
trap 'echo stopped > "$STOPPED"; exit 0' TERM
echo ready > "$READY"
sleep 30
) &
wait
`
go func() {
done <- runUpdateInstaller(ctx, script, append(os.Environ(), "READY="+ready, "STOPPED="+stopped), io.Discard, io.Discard)
}()
deadline := time.Now().Add(3 * time.Second)
for {
if _, err := os.Stat(ready); err == nil {
break
}
if time.Now().After(deadline) {
t.Fatal("installer did not become ready")
}
time.Sleep(10 * time.Millisecond)
}
cancel()
select {
case err := <-done:
if err == nil {
t.Fatal("interrupted installation must not report success")
}
case <-time.After(3 * time.Second):
t.Fatal("installer children kept running after cancellation")
}
if _, err := os.Stat(stopped); err != nil {
t.Fatalf("installer child did not receive cancellation: %v", err)
}
}
func TestInstalledUpdateMustMatchExactTarget(t *testing.T) {
for _, tc := range []struct {
version, release string
want bool
}{
{"v0.2.0", "v0.2.0", true}, {"v0.2.1", "v0.2.0", false},
{"v0.0.0+g0123456", "", true}, {"v0.0.0+gunknown", "", false},
{"v0.0.0+g012", "", false}, {"v0.0.0+g9876543", "", false},
} {
if got := installedUpdateMatches(tc.version, updater.InstallTarget{Release: tc.release, Revision: updateTestSHA}); got != tc.want {
t.Errorf("%s / %s matched = %v", tc.version, tc.release, got)
}
}
}
-178
View File
@@ -60,60 +60,6 @@ func TestUpdateReportNamesBothFailureCauses(t *testing.T) {
} }
} }
func TestApplyGuidanceUpToDateNeedsForce(t *testing.T) {
res := planResult([]updates.Action{{Component: "velocity", Kind: updates.ActionNone, LatestKnown: true}})
sel := map[string]bool{"velocity": true}
quiet := renderApplyGuidance(res, sel, false)
if !strings.Contains(quiet, "already up to date") {
t.Fatalf("want an up-to-date notice:\n%s", quiet)
}
if strings.Contains(quiet, "run:") {
t.Fatalf("must not offer a command for an up-to-date component without --force:\n%s", quiet)
}
forced := renderApplyGuidance(res, sel, true)
if !strings.Contains(forced, "run:") {
t.Fatalf("--force must offer the reinstall command:\n%s", forced)
}
}
// An undiscoverable latest version must never be reported as "up to date". Both
// states arrive as ActionNone and only LatestKnown separates them, so this is a live
// confusion, not a hypothetical one — it shipped that way until a smoke test showed
// `--panel` calling felis-api current right after the release feed returned 404.
func TestApplyGuidanceUnknownLatestIsNotUpToDate(t *testing.T) {
res := planResult([]updates.Action{{Component: "velocity", Kind: updates.ActionNone, LatestKnown: false}})
out := renderApplyGuidance(res, map[string]bool{"velocity": true}, false)
if strings.Contains(out, "already up to date") {
t.Fatalf("must not claim currency when the latest version is unknown:\n%s", out)
}
if !strings.Contains(out, "cannot tell") {
t.Fatalf("want the uncertainty stated plainly:\n%s", out)
}
// One header per selector: the uncertainty is a note under it, not a second block.
if n := strings.Count(out, "--velocity:"); n != 1 {
t.Fatalf("want exactly 1 selector header, got %d:\n%s", n, out)
}
// The operator asked about this component, so the repair command still belongs.
if !strings.Contains(out, "run:") {
t.Fatalf("want the reinstall command offered despite the unknown latest:\n%s", out)
}
}
// Minecraft is pinned and has no planner entry, so --mc explains the pin and offers
// NO command — and therefore must not print the trailer that explains the command.
func TestApplyGuidanceMinecraftOffersNoCommand(t *testing.T) {
out := renderApplyGuidance(planResult(nil), map[string]bool{"mc": true}, true)
if !strings.Contains(out, "pinned by policy") {
t.Fatalf("want the pin explained:\n%s", out)
}
if strings.Contains(out, "run:") || strings.Contains(out, "Re-running the installer") {
t.Fatalf("--mc must offer no command and no command trailer:\n%s", out)
}
}
// Every selector in the table must be a real flag on the FlagSet, and every // Every selector in the table must be a real flag on the FlagSet, and every
// planner-backed selector must name a component the topology actually tracks — // planner-backed selector must name a component the topology actually tracks —
// otherwise a selector silently matches nothing at runtime. // otherwise a selector silently matches nothing at runtime.
@@ -129,76 +75,6 @@ func TestUpdateTargetsMatchTopology(t *testing.T) {
if !tracked[target.component] { if !tracked[target.component] {
t.Fatalf("selector --%s maps to %q, which Topology() does not track", target.selector, target.component) t.Fatalf("selector --%s maps to %q, which Topology() does not track", target.selector, target.component)
} }
if target.command == "" {
t.Fatalf("selector --%s is planner-backed but offers no apply command", target.selector)
}
}
}
// Re-running the installer is the one apply path this table may hand out. setup is NOT an
// updater on a completed install -- its host-bootstrap phase only runs while an install
// marker is missing, so it opens the config console and moves no component -- and even on
// the bootstrap path it re-images felis-api from the binary setup is already running. The
// table used to answer with "sudo felis setup" and scope a felis-api-only exception; both
// taught a model that does not survive contact with an installed host.
func TestApplyGuidancePointsEveryComponentAtTheInstaller(t *testing.T) {
api := renderApplyGuidance(
planResult([]updates.Action{{Component: "felis-api", Kind: updates.ActionNotify, LatestKnown: true}}),
map[string]bool{"panel": true}, false)
for _, want := range []string{"deploy/bootstrap.sh", "FELIS_VERSION_BOOTSTRAP=dev", "felis setup is not this path"} {
if !strings.Contains(api, want) {
t.Fatalf("--panel guidance missing %q:\n%s", want, api)
}
}
if strings.Contains(api, "run: sudo felis setup") {
t.Fatalf("setup must never be offered as the apply command:\n%s", api)
}
// The same path serves velocity; a scoped caveat would re-teach the old model that
// setup fixes velocity.
vel := renderApplyGuidance(
planResult([]updates.Action{{Component: "velocity", Kind: updates.ActionNotify, LatestKnown: true}}),
map[string]bool{"velocity": true}, false)
if !strings.Contains(vel, "run: curl -fsSL") || !strings.Contains(vel, "felis setup is not this path") {
t.Fatalf("velocity gets the same installer path:\n%s", vel)
}
// --mc offers no command at all, so neither trailer belongs.
mc := renderApplyGuidance(planResult(nil), map[string]bool{"mc": true}, true)
if strings.Contains(mc, "deploy/bootstrap.sh") || strings.Contains(mc, "FELIS_VERSION_BOOTSTRAP") {
t.Fatalf("--mc offers no command; the trailer is a non-sequitur:\n%s", mc)
}
}
// The re-run reads bootstrap.sh at the tag whose binary it installs. main can carry
// installer changes no release was tested with.
func TestApplyGuidanceReadsTheInstallerAtTheReleaseTag(t *testing.T) {
v := func(s string) updates.Version {
t.Helper()
out, err := updates.Parse(s)
if err != nil {
t.Fatal(err)
}
return out
}
cases := []struct {
name string
api []updates.Action
want string
}{
{"latest known", []updates.Action{{Component: "felis-api", Kind: updates.ActionNotify, Current: v("v1.3.0"), Latest: v("v1.4.0"), LatestKnown: true}}, "/FelisMC/Felis/v1.4.0/deploy/bootstrap.sh"},
{"latest unknown", []updates.Action{{Component: "felis-api", Kind: updates.ActionNone, Current: v("v1.3.0")}}, "/FelisMC/Felis/v1.3.0/deploy/bootstrap.sh"},
{"prerelease latest", []updates.Action{{Component: "felis-api", Kind: updates.ActionNone, Current: v("v1.3.0"), Latest: v("v1.4.0-rc.1"), LatestKnown: true}}, "/FelisMC/Felis/v1.3.0/deploy/bootstrap.sh"},
{"nothing known", nil, "/FelisMC/Felis/main/deploy/bootstrap.sh"},
}
for _, c := range cases {
out := renderApplyGuidance(planResult(c.api), map[string]bool{"velocity": true, "panel": true}, true)
if !strings.Contains(out, c.want) {
t.Errorf("%s: want %q in:\n%s", c.name, c.want, out)
}
if strings.Contains(out, "{ref}") {
t.Errorf("%s: placeholder left in:\n%s", c.name, out)
}
} }
} }
@@ -215,28 +91,6 @@ func TestUpdateSelectorsAreFlags(t *testing.T) {
} }
} }
// k3s and cloudflared move only when the re-run is told to; the release pins the JRE
// build and the PostgreSQL image, so their guidance is the plain re-run.
func TestApplyGuidanceForHostDependencies(t *testing.T) {
notify := func(c string) updater.Result {
return planResult([]updates.Action{{Component: c, Kind: updates.ActionNotify, LatestKnown: true}})
}
for _, sel := range []string{"k3s", "cloudflared"} {
out := renderApplyGuidance(notify(sel), map[string]bool{sel: true}, false)
if !strings.Contains(out, "sudo FELIS_UPGRADE_DEPS=1 bash") || !strings.Contains(out, "Re-running the installer") {
t.Errorf("--%s guidance must re-run the installer with FELIS_UPGRADE_DEPS=1:\n%s", sel, out)
}
}
jre := renderApplyGuidance(notify("jre"), map[string]bool{"jre": true}, false)
if !strings.Contains(jre, "| sudo bash") || strings.Contains(jre, "FELIS_UPGRADE_DEPS") {
t.Errorf("--jre guidance is the plain installer re-run:\n%s", jre)
}
pg := renderApplyGuidance(notify("postgresql"), map[string]bool{"postgres": true}, false)
if !strings.Contains(pg, "| sudo bash") || strings.Contains(pg, "FELIS_UPGRADE_DEPS") || strings.Contains(pg, "apt-get") || strings.Contains(pg, "systemctl") {
t.Errorf("--postgres guidance is the plain installer re-run that moves the image pin:\n%s", pg)
}
}
func TestRenderNotesHonoursSelectors(t *testing.T) { func TestRenderNotesHonoursSelectors(t *testing.T) {
notes := map[string]string{"postgresql": "PostgreSQL 13 reached end of life on 2025-11-13"} notes := map[string]string{"postgresql": "PostgreSQL 13 reached end of life on 2025-11-13"}
if out := renderNotes(notes, nil); !strings.Contains(out, "postgresql") || !strings.Contains(out, "note: PostgreSQL 13 reached end of life") { if out := renderNotes(notes, nil); !strings.Contains(out, "postgresql") || !strings.Contains(out, "note: PostgreSQL 13 reached end of life") {
@@ -250,38 +104,6 @@ func TestRenderNotesHonoursSelectors(t *testing.T) {
} }
} }
// A source build's v0.0.0+g<commit> names no tag, so the installer one-liner has to
// fall back to main instead of a 404ing ref.
func TestInstallerRefNamesATag(t *testing.T) {
v := func(s string) updates.Version {
t.Helper()
x, err := updates.Parse(s)
if err != nil {
t.Fatal(err)
}
return x
}
cases := []struct {
name string
api updates.Action
want string
}{
{"newest release", updates.Action{Current: v("v1.2.0"), Latest: v("v1.3.0"), LatestKnown: true}, "v1.3.0"},
{"feed down, host on a release", updates.Action{Current: v("v1.2.0")}, "v1.2.0"},
{"source build", updates.Action{Current: v("v0.0.0+gunknown")}, "main"},
{"source build with commit", updates.Action{Current: v("v0.0.0+g1a2b3c4")}, "main"},
{"prerelease", updates.Action{Current: v("v1.3.0-rc.1")}, "main"},
}
for _, c := range cases {
if got := installerRef(map[string]updates.Action{"felis-api": c.api}); got != c.want {
t.Errorf("%s: installerRef = %q, want %q", c.name, got, c.want)
}
}
if got := installerRef(nil); got != "main" {
t.Errorf("no felis-api row: installerRef = %q, want main", got)
}
}
func mustVersion(t *testing.T, s string) updates.Version { func mustVersion(t *testing.T, s string) updates.Version {
t.Helper() t.Helper()
v, err := updates.Parse(s) v, err := updates.Parse(s)
+4 -4
View File
@@ -25,11 +25,11 @@ func TestRenderWindowLinePlacesNowAgainstTheWindow(t *testing.T) {
want string want string
}{ }{
{"unreadable", updates.Window{}, errors.New("connection refused"), w.Start, "Maintenance window: unknown (connection refused).\n"}, {"unreadable", updates.Window{}, errors.New("connection refused"), w.Start, "Maintenance window: unknown (connection refused).\n"},
{"unset", updates.Window{}, nil, w.Start, "Maintenance window: not set; apply whenever suits you.\n"}, {"unset", updates.Window{}, nil, w.Start, "Maintenance window: not set; configure it in the panel, or explicitly use --apply --now for manual maintenance.\n"},
{"half set", updates.Window{Start: w.Start}, nil, w.Start, "Maintenance window: not set; apply whenever suits you.\n"}, {"half set", updates.Window{Start: w.Start}, nil, w.Start, "Maintenance window: not set; configure it in the panel, or explicitly use --apply --now for manual maintenance.\n"},
{"at the opening instant", w, nil, w.Start, "Maintenance window: open now, until 2026-09-27 04:00 CST.\n"}, {"at the opening instant", w, nil, w.Start, "Maintenance window: open now, until 2026-09-27 04:00 CST.\n"},
{"before", w, nil, w.Start.Add(-time.Minute), "Maintenance window: opens 2026-09-27 02:00 CST, until 2026-09-27 04:00 CST. Felis applies nothing on its own; run the apply commands inside it.\n"}, {"before", w, nil, w.Start.Add(-time.Minute), "Maintenance window: opens 2026-09-27 02:00 CST, until 2026-09-27 04:00 CST. Apply is blocked until this window opens (unless --now explicitly starts manual maintenance).\n"},
{"at the closing instant", w, nil, w.End, "Maintenance window: ended 2026-09-27 04:00 CST; set a new one in the panel before applying.\n"}, {"at the closing instant", w, nil, w.End, "Maintenance window: ended 2026-09-27 04:00 CST; apply is blocked; set a new window in the panel or explicitly use --now.\n"},
} }
for _, tc := range cases { for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
+6 -1
View File
@@ -69,6 +69,7 @@
# FELIS_CLOUDFLARED_VERSION / FELIS_CLOUDFLARED_SHA256 cloudflared release installed # FELIS_CLOUDFLARED_VERSION / FELIS_CLOUDFLARED_SHA256 cloudflared release installed
# when none is present (default: 2026.9.1, digests pinned); the # when none is present (default: 2026.9.1, digests pinned); the
# sha256 is REQUIRED for any other version # sha256 is REQUIRED for any other version
# FELIS_FORCE_UPDATE=1 reinstalls the binary even when its version already matches.
# FELIS_UPGRADE_DEPS 1 moves an installed k3s and cloudflared to the versions above # FELIS_UPGRADE_DEPS 1 moves an installed k3s and cloudflared to the versions above
# (k3s one minor version at a time; neither is ever downgraded) and # (k3s one minor version at a time; neither is ever downgraded) and
# restarts cloudflared-felis onto the new binary (default: 0) # restarts cloudflared-felis onto the new binary (default: 0)
@@ -2612,7 +2613,7 @@ download_release_binary() {
# Convergence check, and the cheapest one available: no API call, no download, and it asks # Convergence check, and the cheapest one available: no API call, no download, and it asks
# the exact question that matters. Reruns are the common case for this installer. # the exact question that matters. Reruns are the common case for this installer.
if [ -x "$HOST_BIN" ] && [ "$("$HOST_BIN" version 2>/dev/null | head -n 1)" = "felis ${FELIS_REF}" ]; then if [ "${FELIS_FORCE_UPDATE:-0}" != 1 ] && [ -x "$HOST_BIN" ] && [ "$("$HOST_BIN" version 2>/dev/null | head -n 1)" = "felis ${FELIS_REF}" ]; then
HAVE_PREBUILT_BINARY=1 HAVE_PREBUILT_BINARY=1
ok "host binary is already ${FELIS_REF}; skipping the download" ok "host binary is already ${FELIS_REF}; skipping the download"
return 0 return 0
@@ -6287,6 +6288,10 @@ main() {
# Before the first change to the host: a problem found here costs a rerun, one found # Before the first change to the host: a problem found here costs a rerun, one found
# halfway through costs an install to unwind. # halfway through costs an install to unwind.
preflight preflight
check_postgres_major
if [ "$FELIS_UPGRADE_DEPS" = 1 ] && [ -x "$K3S_BIN" ]; then
k3s_upgrade_allowed "$("$K3S_BIN" --version 2>/dev/null | awk 'NR == 1 { print $3 }')" "$FELIS_K3S_VERSION" || true
fi
quiet_watchdog quiet_watchdog
pause_package_background_timers pause_package_background_timers
ensure_swap ensure_swap
+47 -1
View File
@@ -1085,9 +1085,10 @@ dsum="$(sha256sum <"$ddir/asset" | cut -d' ' -f1)"
# command inside fetch_source. The log lands in $ddir/log; stdout says what the caller did. # command inside fetch_source. The log lands in $ddir/log; stdout says what the caller did.
run_download() { run_download() {
rm -f "$ddir/bin/felis" rm -f "$ddir/bin/felis"
if [ "${ALREADY_INSTALLED:-0}" = 1 ]; then cp "$ddir/asset" "$ddir/bin/felis"; chmod +x "$ddir/bin/felis"; fi
: > "$ddir/log" : > "$ddir/log"
SUMS="$1" ENTRY="${2:-acquire_felis_binary}" ASSET="$ddir/asset" LOG="$ddir/log" FELIS_REF=v9.9.9 \ SUMS="$1" ENTRY="${2:-acquire_felis_binary}" ASSET="$ddir/asset" LOG="$ddir/log" FELIS_REF=v9.9.9 \
HOST_BIN="$ddir/bin/felis" TMPDIR="$sdir" INSTALL_FAILS="${INSTALL_FAILS:-}" FETCH_FAILS="${FETCH_FAILS:-}" bash -c ' HOST_BIN="$ddir/bin/felis" TMPDIR="$sdir" FELIS_FORCE_UPDATE="${FELIS_FORCE_UPDATE:-0}" INSTALL_FAILS="${INSTALL_FAILS:-}" FETCH_FAILS="${FETCH_FAILS:-}" bash -c '
set -Eeuo pipefail set -Eeuo pipefail
ok() { printf "OK: %s\n" "$*" >> "$LOG"; } ok() { printf "OK: %s\n" "$*" >> "$LOG"; }
warn() { printf "WARN: %s\n" "$*" >> "$LOG"; } warn() { printf "WARN: %s\n" "$*" >> "$LOG"; }
@@ -1143,6 +1144,15 @@ same_log "the release binary is hashed before it is first executed" "$(printf '%
"OK: installed felis-linux-amd64 v9.9.9 at $ddir/bin/felis")" "OK: installed felis-linux-amd64 v9.9.9 at $ddir/bin/felis")"
if cmp -s "$ddir/asset" "$ddir/bin/felis"; then echo "PASS the installed binary is the download"; else echo "FAIL the installed binary is not the download"; fails=$((fails + 1)); fi if cmp -s "$ddir/asset" "$ddir/bin/felis"; then echo "PASS the installed binary is the download"; else echo "FAIL the installed binary is not the download"; fails=$((fails + 1)); fi
out="$(ALREADY_INSTALLED=1 run_download "")"
same_out "the matching host binary skips download" "PREBUILT[1]" $?
out="$(FELIS_FORCE_UPDATE=1 ALREADY_INSTALLED=1 run_download "$(printf '%s felis-linux-amd64\n' "$dsum")")"
same_out "force reinstalls the matching verified binary" "PREBUILT[1]" $?
same_log "force verifies the download before executing it" "$(printf '%s\n' \
"OK: felis-linux-amd64 matches release v9.9.9's SHA256SUMS" \
"RAN: version" \
"OK: installed felis-linux-amd64 v9.9.9 at $ddir/bin/felis")"
out="$(run_download "$(printf '%s felis-linux-amd64\n' deadbeef)")" out="$(run_download "$(printf '%s felis-linux-amd64\n' deadbeef)")"
same_out "a mismatched release binary asks for the source build" "FETCH_SOURCE same_out "a mismatched release binary asks for the source build" "FETCH_SOURCE
PREBUILT[]" $? PREBUILT[]" $?
@@ -5222,6 +5232,42 @@ case "$out" in
esac esac
rm -rf "$credir" "$credcalls" rm -rf "$credir" "$credcalls"
# Existing clusters must pass compatibility before the install changes the host.
compatdir="$(mktemp -d)"
mkdir -p "$compatdir/pg/18/docker"
printf '18' > "$compatdir/pg/18/docker/PG_VERSION"
printf '#!/bin/sh\necho "k3s version v1.36.4+k3s1 (example)"\n' > "$compatdir/k3s"
chmod +x "$compatdir/k3s"
run_compatibility_guard() {
PG_DATA_DIR="$compatdir/pg" K3S_BIN="$compatdir/k3s" WANT_PG="$1" FELIS_K3S_VERSION="$2" FELIS_UPGRADE_DEPS=1 bash -c '
set -Eeuo pipefail
die() { echo "DIE: $*"; exit 1; }
ok() { :; }
version_newer() { return 1; }
postgres_image_major() { echo "$WANT_PG"; }
acquire_run_lock() { :; }
ensure_k3s_on_path() { :; }
resolve_nano_listen() { :; }
validate_settings() { :; }
detect_os() { :; }
prompt_install_mode() { INSTALL_MODE=full; }
detect_node_ip() { :; }
preflight() { :; }
quiet_watchdog() { echo HOST_CHANGE; exit 0; }
'"$(bsfn check_postgres_major)"'
'"$(bsfn k3s_upgrade_allowed)"'
'"$(bsfn main)"'
main
' 2>&1
}
out="$(run_compatibility_guard 19 v1.37.1+k3s1)"
expect "PostgreSQL major mismatch is refused before host changes" 'holds a PostgreSQL 18 cluster' "$out"
case "$out" in *HOST_CHANGE*) echo "FAIL PostgreSQL refusal came after a host change"; fails=$((fails + 1));; esac
out="$(run_compatibility_guard 18 v1.38.1+k3s1)"
expect "k3s minor skip is refused before host changes" 'skips a minor version' "$out"
case "$out" in *HOST_CHANGE*) echo "FAIL k3s refusal came after a host change"; fails=$((fails + 1));; esac
rm -rf "$compatdir"
# Worker admission reuses the installer but must never enter host control-plane setup. # Worker admission reuses the installer but must never enter host control-plane setup.
before "distributed host firewall runs the newly built binary" \ before "distributed host firewall runs the newly built binary" \
' build_image' '"$HOST_BIN" node firewall --controller' "$(bsfn main)" ' build_image' '"$HOST_BIN" node firewall --controller' "$(bsfn main)"
+8 -8
View File
@@ -31,19 +31,19 @@ A grep across `*.md` and `*.go` returns both sets; only the Go ones are seams.
update` passes nil deliberately. The notification is the panel instead: update` passes nil deliberately. The notification is the panel instead:
`felis-update-check.timer` runs `felis update --record` daily on the host, which `felis-update-check.timer` runs `felis update --record` daily on the host, which
stores the report under `platform_settings.update_report`, and **Admin → Updates → stores the report under `platform_settings.update_report`, and **Admin → Updates →
Component versions** shows it with the command that applies each update. Component versions** shows it with the read-only command that prepares an update.
- `internal/updates/seams.go:43` — `Applier`. Nothing applies an update anywhere. A - `internal/updates/seams.go:43` — `Applier`. No unattended runner adapts this interface.
nil applier is not silent — `Run` records `errNoApplier` against every planned The host CLI implements explicit `felis update --apply` separately, using the target
apply, so a mis-scheduled apply is loud rather than lost. installer after window checks and backup. A nil applier in the read-only runner
still records `errNoApplier` against a mistakenly scheduled apply.
- `internal/updater/gatherer_integration.go:22` — the two current-version seams - `internal/updater/gatherer_integration.go:22` — the two current-version seams
`NewSysGatherer` leaves nil, for the in-cluster path: the k8s read of the `NewSysGatherer` leaves nil, for the in-cluster path: the k8s read of the
control-plane Deployment image, and the Velocity jar inspection. Both are answered control-plane Deployment image, and the Velocity jar inspection. Both are answered
on the host path (see "Built" below), so this gap is specific to a caller that has on the host path (see "Built" below), so this gap is specific to a caller that has
a cluster client instead of the node. a cluster client instead of the node.
- `internal/api/handlers_updates.go` — the maintenance window is advisory: no - `internal/api/handlers_updates.go` — no in-cluster runner applies updates. The host
in-cluster runner applies updates. `felis update` reads the stored window, prints `felis update --apply` now consumes the stored window and refuses outside it unless
where now sits against it and warns before an apply outside it; the runner itself explicit `--now` starts manual maintenance. The check/record runner remains read-only.
still runs with a zero window, so no path can claim an apply is under way.
- `internal/submit/blobstore.go` — CLOSED 2026-09-22. The uploads PVC still cannot - `internal/submit/blobstore.go` — CLOSED 2026-09-22. The uploads PVC still cannot
cross namespaces, so the transport went through the API instead of a mount: the cross namespaces, so the transport went through the API instead of a mount: the
derived context ref is now the internal-face URL derived context ref is now the internal-face URL
+6 -9
View File
@@ -4185,15 +4185,12 @@ paths:
operationId: getUpdateWindow operationId: getUpdateWindow
summary: Read the SysAdmin-set auto-update maintenance window (admin). summary: Read the SysAdmin-set auto-update maintenance window (admin).
description: >- description: >-
Advisory: Felis applies no update on its own. `felis update` on the Felis applies no update on its own. `felis update` checks versions and
host reads this window, reports where now sits against it, and warns prints an explicit apply command. `felis update --apply` reads this
before an apply outside it. platform-wide [start,end) window before backup and before installation,
The single platform-wide maintenance window during which Felis may apply a refusing outside it unless `--now` explicitly starts manual maintenance.
Scheduled component's update to itself (decision core internal/updates). An An unreadable window is always a refusal, including with `--now` or
unset window — never set, or explicitly cleared — reads back as `--force`. An unset window reads back as {start:null,end:null}.
{start:null,end:null}. API+persistence only: nothing consumes the window
until the INTEGRATION runner and executors are wired, so setting it changes
no behavior yet.
x-felis-face: [external] x-felis-face: [external]
x-felis-tier: admin x-felis-tier: admin
security: [{ sessionCookie: [] }] security: [{ sessionCookie: [] }]
+41 -10
View File
@@ -501,8 +501,8 @@ store=/var/lib/rancher/k3s/storage
## 4. Upgrading the pieces around Felis ## 4. Upgrading the pieces around Felis
A rerun of the installer upgrades Felis itself (§15). The components it installs keep The host updater reuses the installer to reconcile Felis itself and its core components
the version they were installed with unless noted: (§15). The components it installs keep the version they were installed with unless noted:
| Component | How a rerun treats it | Upgrade | | Component | How a rerun treats it | Upgrade |
|---|---|---| |---|---|---|
@@ -513,23 +513,54 @@ the version they were installed with unless noted:
| PostgreSQL | follows the image the release pins | a minor release comes with a Felis release, and the rerun restarts felis-postgres on it (a few seconds without the API); a major version is a dump and restore (below) | | PostgreSQL | follows the image the release pins | a minor release comes with a Felis release, and the rerun restarts felis-postgres on it (a few seconds without the API); a major version is a dump and restore (below) |
| Docker, git, nftables | distribution packages | the package manager | | Docker, git, nftables | distribution packages | the package manager |
`felis update` is a read-only check. It reports upstream availability, resolves the
Felis target, downloads and syntax-checks its matching installer, and prints an explicit
apply command. The upstream table is advisory: applying uses the target's compatible
pins, rather than installing each component's newest upstream version independently.
`--k3s`, `--cloudflared`, `--jre` and `--postgres` narrow the report; applying any core
selector reconciles the whole platform bundle. `--all` also enables the release-pinned
k3s and cloudflared upgrades. Minecraft user server images stay pinned.
```sh ```sh
curl -fsSL https://raw.githubusercontent.com/FelisMC/Felis/main/deploy/bootstrap.sh \ sudo felis update # newest stable release; no installation
| sudo FELIS_UPGRADE_DEPS=1 bash sudo felis update --all # also plan pinned host dependency upgrades
sudo felis update --version v0.2.0 # inspect a named published release
sudo felis update --dev # inspect the latest main commit
sudo felis update --ref <commit-or-tag> # inspect a specific source tree
``` ```
`sudo felis update` reports Felis, Velocity, k3s, cloudflared, the JRE and PostgreSQL Review the target, full commit, scope and restart impact, then run the exact `--apply`
against their newest releases; `--k3s`, `--cloudflared`, `--jre` and `--postgres` narrow command printed by the check. A source apply uses the full SHA, while a release apply
it to one. PostgreSQL is read from the felis-postgres container and compared within its also includes `--expect-commit` so a moved tag is refused. `--apply --dev` is supported
major, since a minor release arrives with a Felis release, and a major past its end of life for deliberately resolving main at execution time; the printed command pins the commit
gets a note naming the current one. you inspected instead.
Set the maintenance window in **Admin → Updates** first. Application reads that window
before backup and again before installation: unset, future or expired windows refuse
application. `--apply --now` explicitly starts one-off manual maintenance instead;
an unreadable window is always refused. The daily `--record` timer never applies.
`--force` reinstalls the same version or permits an intentional Felis downgrade; it
never bypasses the window, backup or component compatibility guards.
Before installation the running binary takes a database + `/etc/felis` + MinecraftServer
specification backup; failure stops the update. Worlds are covered separately by server
backups (§16), not this control-plane snapshot. Application then streams the existing
installer's progress, reconciles the CLI, API/operator/panel, manifests/RBAC, plugins,
proxy and system images, and verifies the installed binary's version. Installer rollout
checks still gate success. A failed installer can leave some components changed: retain
the pre-update backup and follow troubleshooting §15/§16; schema rollback is not automatic.
PostgreSQL major changes require dump/restore, and k3s upgrades cannot skip a minor version.
Older host binaries whose `update -h` has no `--apply` need one installer run to acquire
this updater. Published assets are checksum-verified; older releases without the required
installer options must be selected through `--ref` for a source build instead.
The installer also sets up `felis-update-check.timer`, which runs `felis update --record` The installer also sets up `felis-update-check.timer`, which runs `felis update --record`
once a day around 05:30 (and at boot after a missed run). `--record` stores the result once a day around 05:30 (and at boot after a missed run). `--record` stores the result
in `platform_settings`, and the panel's **Admin → Updates → Component versions** card in `platform_settings`, and the panel's **Admin → Updates → Component versions** card
shows it: each component's installed and newest version, and for the ones with a newer shows it: each component's installed and newest version, and for the ones with a newer
release the `sudo felis update --<component>` line that prints how to apply it. Felis release the `sudo felis update --<component>` line that prints how to apply it. Felis
applies nothing on its own; the installer re-run above is the apply path. The card turns applies nothing on its own; the explicit `--apply` command above is the apply path. The card turns
red when the newest record is older than 26 hours, meaning the timer stopped: red when the newest record is older than 26 hours, meaning the timer stopped:
```sh ```sh
+12 -8
View File
@@ -2136,14 +2136,18 @@ annotated Service endpoints picks them up as is.
## 15. Control-plane upgrades, and rolling back a bad one ## 15. Control-plane upgrades, and rolling back a bad one
There is no in-place updater: an upgrade is re-running the installer `felis update` checks only; it prints a target and explicit `--apply` command. The host
(`curl -fsSL <installer URL> | sudo bash`), which imports the release's images updater downloads the installer from the target's resolved full commit, backs up the
(or rebuilds them on the host, operations §1 "Where the binary and the images come database and deployment state, then uses that installer to reconcile the CLI, core
from") and re-applies the bundle. `felis update --panel` prints that command with the services, plugins and system images. See operations §4 for `--version`, `--ref`, `--dev`
script read at the newest release's tag, so the installer and the binary it and `--all`. Use the exact printed command to retain the reviewed target. Application
downloads come from the same release. (`sudo felis setup` is not this path; on a completed requires an active maintenance window or explicit `--now`; `--force` never skips the
install it only opens the config console.) The channel is not persisted across backup, an unreadable window or compatibility guards.
the re-run, so pass `FELIS_VERSION_BOOTSTRAP=dev` on a host that tracks main.
A host whose `felis update -h` lacks `--apply` still needs one installer run
(`curl -fsSL <installer URL> | sudo bash`) to acquire the new CLI. `sudo felis setup`
on a completed install opens the configuration console, so it is not an upgrade path.
The updater reuses the same checksum, image import/build and rollout checks as that installer.
Two properties of the control plane matter when you do: Two properties of the control plane matter when you do:
- Both Deployments use strategy **Recreate** (single replica, no leader election: - Both Deployments use strategy **Recreate** (single replica, no leader election:
+5 -7
View File
@@ -19,16 +19,14 @@ import (
// SINGLE GLOBAL WINDOW (deliberate). internal/updates models the window PER // SINGLE GLOBAL WINDOW (deliberate). internal/updates models the window PER
// Component (Component.Window), but this endpoint stores ONE platform-wide window. // Component (Component.Window), but this endpoint stores ONE platform-wide window.
// The task scopes "the SysAdmin-set update Window" as a single maintenance slot, // 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- // and the core's own comment defers recurrence to the caller. The host
// built, INTEGRATION-ONLY) `felis update` runner reads this one window and fans it // `felis update --apply` reads this window before backup and before installation.
// out to every Scheduled+manageable component when it assembles its []Component.
// A future need for per-component windows would layer keys on top; this is the // A future need for per-component windows would layer keys on top; this is the
// platform default. // platform default.
// //
// Felis applies no update on its own, so the window is advisory. Its consumer // Felis applies no update on its own. `felis update` reports this window, and
// is `felis update` on the host (cmd/felis/update.go readUpdateWindow), which // explicit --apply refuses outside it unless --now starts manual maintenance.
// reads this row, prints where now sits against it, and warns before an apply // An unreadable window is always a refusal, including for --now and --force.
// outside it. The panel's Updates page says the same.
// updateWindowKey is the platform_settings key holding the maintenance window as // updateWindowKey is the platform_settings key holding the maintenance window as
// JSON {"start","end"} (RFC3339, or null when unset). It reuses the generic // 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 // 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, // 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 // 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) // Notifier (SMTP + in-game) and unattended Applier — the check/record runner
// — the CLI passes nil for both on purpose, so it reports and never applies — the // 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 // in-cluster CronJob entry point, and the runtime append of the live Pinned
// Minecraft fleet. The scheduled check runs on the host instead: // Minecraft fleet. The scheduled check runs on the host instead:
// felis-update-check.timer runs `felis update --record`, which stores the report // 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. // latestTag fetches the tag of repo's /releases/latest.
func (g github) latestTag(ctx context.Context, repo string) (string, error) { func (g github) latestTag(ctx context.Context, repo string) (string, error) {
url := fmt.Sprintf("%s/repos/%s/releases/latest", g.baseURL, repo) return g.releaseTag(ctx, repo, "releases/latest")
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) }
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 { if err != nil {
return "", fmt.Errorf("github: build request for %s: %w", repo, err) return nil, err
} }
ua := g.userAgent ua := g.userAgent
if ua == "" { if ua == "" {
ua = defaultUserAgent ua = defaultUserAgent
} }
req.Header.Set("User-Agent", ua) req.Header.Set("User-Agent", ua)
req.Header.Set("Accept", "application/vnd.github+json") req.Header.Set("Accept", accept)
if g.token != "" { if g.token != "" {
req.Header.Set("Authorization", "Bearer "+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 { if err != nil {
return "", fmt.Errorf("github: get %s: %w", repo, err) 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 // 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 // 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. // 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) return "", fmt.Errorf("github: %s releases/latest returned HTTP 404 — it has no published stable release", repo)
} }
if resp.StatusCode == http.StatusNotFound && g.token == "" { if resp.StatusCode == http.StatusNotFound && g.token == "" {
return "", fmt.Errorf( 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", "github: %s %s returned HTTP 404 — either no published stable release exists, or the repository is private and %s is unset",
repo, tokenEnv) 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 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) return "", fmt.Errorf("github: decode %s: %w", repo, err)
} }
if rr.Draft || rr.Prerelease { 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 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)
}
}
+4 -4
View File
@@ -90,9 +90,9 @@
"reviewed_at": "Reviewed At", "reviewed_at": "Reviewed At",
"reject_reason": "Rejection Reason", "reject_reason": "Rejection Reason",
"updates_title": "Maintenance & Backups", "updates_title": "Maintenance & Backups",
"updates_subtitle": "Check that the control-plane database backup is fresh, see which components have a newer release, and set the platform-wide maintenance window. Felis never applies an update on its own: `felis update` on the host shows this window and warns before you apply outside it.", "updates_subtitle": "Check database backup freshness, review available versions, and set a maintenance window. Run `felis update` on the host to inspect a target; only an explicit `--apply` installs it.",
"updates_window_advisory": "The window is advisory. Updates happen only when someone runs the apply commands `felis update` prints; it reads this window and warns when run outside it.", "updates_window_advisory": "Apply is allowed inside this window. The host checks it before taking a backup and again before installation. Outside the window, apply is refused unless `--now` explicitly starts manual maintenance; `--force` does not bypass the checks.",
"updates_current_unset": "No maintenance window set. `felis update` will say so and leave the timing to you.", "updates_current_unset": "No maintenance window set. Configure one before applying, or explicitly use `--apply --now` for manual maintenance.",
"updates_start_label": "Start Time", "updates_start_label": "Start Time",
"updates_end_label": "End Time", "updates_end_label": "End Time",
"updates_set_title": "Configure Maintenance Window", "updates_set_title": "Configure Maintenance Window",
@@ -159,7 +159,7 @@
"versions_state_unknown": "Feed unreachable", "versions_state_unknown": "Feed unreachable",
"versions_state_unreadable": "Version unreadable", "versions_state_unreadable": "Version unreadable",
"versions_state_pinned": "Pinned", "versions_state_pinned": "Pinned",
"versions_apply_hint": "To apply, run this on the host, inside the maintenance window below if you set one. It prints the exact command for each component and warns when run outside the window:", "versions_apply_hint": "Run this read-only check on the host. It shows the target, scope and an exact `--apply` command. Review it, then run that command during the maintenance window:",
"versions_never_title": "No version check has been recorded yet", "versions_never_title": "No version check has been recorded yet",
"versions_stale_title": "The newest version check is more than {{hours}} hours old", "versions_stale_title": "The newest version check is more than {{hours}} hours old",
"versions_fix_hint": "The versions below may be out of date. Run a check on the host now, then read the timer's log to find out why it did not run:", "versions_fix_hint": "The versions below may be out of date. Run a check on the host now, then read the timer's log to find out why it did not run:",
+4 -4
View File
@@ -90,9 +90,9 @@
"reviewed_at": "审核时间", "reviewed_at": "审核时间",
"reject_reason": "驳回理由", "reject_reason": "驳回理由",
"updates_title": "维护与备份", "updates_title": "维护与备份",
"updates_subtitle": "查看控制面数据库备份是否新鲜、哪些组件有新版本,并设置全局维护窗口。Felis 从不自行应用更新:宿主机上的 `felis update` 会显示这个窗口,在窗口外应用前给出警告。", "updates_subtitle": "查看数据库备份和组件版本,并设置维护窗口。在宿主机运行 `felis update` 检查目标版本;只有显式执行 `--apply` 才会更新。",
"updates_window_advisory": "维护窗口是提示性的。只有有人执行 `felis update` 打印的应用命令时才会更新;该命令会读取这个窗口,在窗口外运行时给出警告。", "updates_window_advisory": "更新默认只允许在维护窗口内开始。宿主机会在备份前和安装前再次检查窗口;窗口外拒绝执行。临时维护需显式添加 `--now`,`--force` 不会跳过这些检查。",
"updates_current_unset": "当前未设置维护窗口。`felis update` 会提示这一点,何时应用由你决定。", "updates_current_unset": "尚未设置维护窗口。请先设置窗口,或显式使用 `--apply --now` 开始临时维护。",
"updates_start_label": "开始时间", "updates_start_label": "开始时间",
"updates_end_label": "结束时间", "updates_end_label": "结束时间",
"updates_set_title": "配置维护窗口", "updates_set_title": "配置维护窗口",
@@ -158,7 +158,7 @@
"versions_state_unknown": "无法访问发行源", "versions_state_unknown": "无法访问发行源",
"versions_state_unreadable": "读不到版本", "versions_state_unreadable": "读不到版本",
"versions_state_pinned": "已固定", "versions_state_pinned": "已固定",
"versions_apply_hint": "要应用更新,请在主机上运行下面的命令;如果设置了维护窗口,请在窗口内运行。它会列出每个组件的确切命令,并在窗口外运行时发出警告:", "versions_apply_hint": "在宿主机执行下面的只读检查,查看更新目标、范围和确切的 `--apply` 命令。确认后,在维护窗口内执行该命令:",
"versions_never_title": "还没有记录过版本检查", "versions_never_title": "还没有记录过版本检查",
"versions_stale_title": "最近一次版本检查已超过 {{hours}} 小时", "versions_stale_title": "最近一次版本检查已超过 {{hours}} 小时",
"versions_fix_hint": "下面的版本可能已过时。请先在主机上立即检查一次,再查看定时器日志找出它没有运行的原因:", "versions_fix_hint": "下面的版本可能已过时。请先在主机上立即检查一次,再查看定时器日志找出它没有运行的原因:",
+1 -1
View File
@@ -1232,7 +1232,7 @@ export interface paths {
}; };
/** /**
* Read the SysAdmin-set auto-update maintenance window (admin). * Read the SysAdmin-set auto-update maintenance window (admin).
* @description Advisory: Felis applies no update on its own. `felis update` on the host reads this window, reports where now sits against it, and warns before an apply outside it. The single platform-wide maintenance window during which Felis may apply a Scheduled component's update to itself (decision core internal/updates). An unset window — never set, or explicitly cleared — reads back as {start:null,end:null}. API+persistence only: nothing consumes the window until the INTEGRATION runner and executors are wired, so setting it changes no behavior yet. * @description Felis applies no update on its own. `felis update` checks versions and prints an explicit apply command. `felis update --apply` reads this platform-wide [start,end) window before backup and before installation, refusing outside it unless `--now` explicitly starts manual maintenance. An unreadable window is always a refusal, including with `--now` or `--force`. An unset window reads back as {start:null,end:null}.
*/ */
get: operations["getUpdateWindow"]; get: operations["getUpdateWindow"];
/** /**
+3 -3
View File
@@ -24,7 +24,7 @@ afterEach(() => {
}); });
describe("UpdatesPage", () => { describe("UpdatesPage", () => {
it("says the window is advisory, since nothing applies an update on its own", async () => { it("explains the maintenance guard and explicit apply flow", async () => {
render( render(
<MemoryRouter> <MemoryRouter>
<UpdatesPage /> <UpdatesPage />
@@ -32,10 +32,10 @@ describe("UpdatesPage", () => {
); );
expect( expect(
await screen.findByText( await screen.findByText(
"The window is advisory. Updates happen only when someone runs the apply commands `felis update` prints; it reads this window and warns when run outside it.", "Apply is allowed inside this window. The host checks it before taking a backup and again before installation. Outside the window, apply is refused unless `--now` explicitly starts manual maintenance; `--force` does not bypass the checks.",
), ),
).toBeTruthy(); ).toBeTruthy();
expect(screen.getByText("No maintenance window set. `felis update` will say so and leave the timing to you.")).toBeTruthy(); expect(screen.getByText("No maintenance window set. Configure one before applying, or explicitly use `--apply --now` for manual maintenance.")).toBeTruthy();
expect(screen.queryByText(/applied automatically|may apply a Scheduled update/)).toBeNull(); expect(screen.queryByText(/applied automatically|may apply a Scheduled update/)).toBeNull();
}); });
}); });