feat(cli): apply coordinated updates during maintenance
This commit is contained in:
21 files changed
+990
-457
No files matched your search
+1
-1
@@ -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)
|
||||
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
|
||||
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)
|
||||
|
||||
Run "felis <command> -h" for command-specific flags.
|
||||
|
||||
+123
-206
@@ -7,8 +7,12 @@ import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/signal"
|
||||
"sort"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
@@ -23,52 +27,15 @@ import (
|
||||
// leave the operator staring at a silent terminal.
|
||||
const updateTimeout = 60 * time.Second
|
||||
|
||||
// updateTarget maps a user-facing selector (`--panel`) onto the planner's component
|
||||
// name and the command that actually performs the update.
|
||||
//
|
||||
// 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.
|
||||
// updateTarget maps report selectors onto the components tracked by the planner.
|
||||
// Application always reuses bootstrap.sh for the compatible platform bundle.
|
||||
type updateTarget struct {
|
||||
// selector is the flag name without dashes.
|
||||
selector string
|
||||
// help is the flag's usage line.
|
||||
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).
|
||||
selector string
|
||||
help string
|
||||
component string
|
||||
// note explains what this selector covers, printed above the command.
|
||||
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
|
||||
note string // explanation for the untracked Minecraft selector
|
||||
}
|
||||
|
||||
// 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
|
||||
// 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
|
||||
@@ -78,82 +45,52 @@ var updateTargets = []updateTarget{
|
||||
selector: "panel",
|
||||
help: "select the panel + control plane (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",
|
||||
help: "select the Velocity proxy",
|
||||
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",
|
||||
help: "select the Felis plugin jars (velocity/paper/limbo)",
|
||||
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",
|
||||
help: "select 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",
|
||||
help: "select 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",
|
||||
help: "select the Temurin JRE Velocity runs on",
|
||||
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",
|
||||
help: "select 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",
|
||||
help: "select Minecraft (pinned; reported only)",
|
||||
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",
|
||||
command: "",
|
||||
},
|
||||
}
|
||||
|
||||
// cmdUpdate reports what can be updated and what is already current.
|
||||
//
|
||||
// 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.
|
||||
// cmdUpdate checks by default; only --apply changes the host. --record remains
|
||||
// read-only so the daily timer cannot start an unattended upgrade.
|
||||
func cmdUpdate(args []string, stdout, stderr io.Writer) int {
|
||||
fs := flag.NewFlagSet("update", flag.ContinueOnError)
|
||||
fs.SetOutput(stderr)
|
||||
@@ -161,11 +98,19 @@ func cmdUpdate(args []string, stdout, stderr io.Writer) int {
|
||||
for _, t := range updateTargets {
|
||||
flags[t.selector] = fs.Bool(t.selector, false, t.help)
|
||||
}
|
||||
all := fs.Bool("all", false, "select every component above")
|
||||
force := fs.Bool("force", false, "print the apply command for a selected component even when it is already up to date")
|
||||
velocityJar := fs.String("velocity-jar", updater.DefaultVelocityJarPath, "path to the installed Velocity jar to read the current version from")
|
||||
cfgPath := fs.String("config", "/etc/felis/felis.toml", "path to felis.toml, read for the maintenance window the panel stores")
|
||||
record := fs.Bool("record", false, "also store this check for the panel's Updates page (felis-update-check.timer runs it daily)")
|
||||
var opts updateOptions
|
||||
fs.BoolVar(&opts.all, "all", false, "include the release-pinned k3s and cloudflared updates")
|
||||
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)")
|
||||
fs.BoolVar(&opts.apply, "apply", false, "apply the inspected target after checking maintenance and taking a database/state backup")
|
||||
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 {
|
||||
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))
|
||||
return 2
|
||||
}
|
||||
|
||||
selected := map[string]bool{}
|
||||
if err := opts.validate(*dev, *check, *record); err != nil {
|
||||
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 {
|
||||
if *on || *all {
|
||||
selected[sel] = true
|
||||
if *on || opts.all {
|
||||
opts.selected[sel] = true
|
||||
}
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), updateTimeout)
|
||||
defer cancel()
|
||||
|
||||
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.
|
||||
if len(opts.selected) == 1 && opts.selected["mc"] {
|
||||
if opts.apply {
|
||||
fmt.Fprintln(stderr, "felis update: Minecraft is managed through each server's image, not a platform update")
|
||||
return 2
|
||||
}
|
||||
for _, t := range updateTargets {
|
||||
if t.selector == "mc" {
|
||||
fmt.Fprintln(stdout, t.note)
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
res, err := rn.Run(ctx, time.Now(), updates.Window{})
|
||||
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 {
|
||||
fmt.Fprintf(stderr, "felis update: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
win, winErr := readUpdateWindow(ctx, *cfgPath)
|
||||
fmt.Fprint(stdout, renderWindowLine(win, winErr, now))
|
||||
fmt.Fprint(stdout, renderUpdateReport(res, selected))
|
||||
fmt.Fprint(stdout, renderNotes(src.Notes(), selected))
|
||||
if len(selected) > 0 {
|
||||
if winErr == nil && !win.Start.IsZero() && !win.Contains(now) {
|
||||
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))
|
||||
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 *record {
|
||||
// A fresh context: the discovery pass may have spent most of updateTimeout.
|
||||
rctx, rcancel := context.WithTimeout(context.Background(), updateWindowTimeout)
|
||||
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()
|
||||
win, winErr := readUpdateWindow(ctx, opts.cfgPath)
|
||||
fmt.Fprint(stdout, renderWindowLine(win, winErr, now))
|
||||
fmt.Fprint(stdout, renderUpdateReport(res, opts.selected))
|
||||
fmt.Fprint(stdout, renderNotes(src.Notes(), opts.selected))
|
||||
if record {
|
||||
rctx, rcancel := context.WithTimeout(ctx, updateWindowTimeout)
|
||||
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)
|
||||
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
|
||||
}
|
||||
@@ -388,98 +389,14 @@ func selectedCovers(selected map[string]bool, component string) bool {
|
||||
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
|
||||
// database costs the report a line and never the report itself.
|
||||
const updateWindowTimeout = 3 * time.Second
|
||||
|
||||
// readUpdateWindow reads the maintenance window the panel stores
|
||||
// (platform_settings "update_window"). Felis applies nothing on its own: this
|
||||
// command is the window's consumer, showing it and warning before an apply
|
||||
// outside it. A missing row is an unset window.
|
||||
// command consumes it for both reporting and admission to an explicit apply.
|
||||
// A missing row is an unset window.
|
||||
func readUpdateWindow(ctx context.Context, cfgPath string) (updates.Window, error) {
|
||||
cfg, err := config.Load(cfgPath)
|
||||
if err != nil {
|
||||
@@ -515,12 +432,12 @@ func renderWindowLine(w updates.Window, err error, now time.Time) string {
|
||||
case err != nil:
|
||||
return fmt.Sprintf("Maintenance window: unknown (%v).\n", err)
|
||||
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):
|
||||
return fmt.Sprintf("Maintenance window: open now, until %s.\n", w.End.Local().Format(layout))
|
||||
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:
|
||||
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))
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
// planner-backed selector must name a component the topology actually tracks —
|
||||
// otherwise a selector silently matches nothing at runtime.
|
||||
@@ -129,76 +75,6 @@ func TestUpdateTargetsMatchTopology(t *testing.T) {
|
||||
if !tracked[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) {
|
||||
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") {
|
||||
@@ -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 {
|
||||
t.Helper()
|
||||
v, err := updates.Parse(s)
|
||||
|
||||
@@ -25,11 +25,11 @@ func TestRenderWindowLinePlacesNowAgainstTheWindow(t *testing.T) {
|
||||
want string
|
||||
}{
|
||||
{"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"},
|
||||
{"half set", updates.Window{Start: w.Start}, 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; 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"},
|
||||
{"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"},
|
||||
{"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"},
|
||||
{"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; apply is blocked; set a new window in the panel or explicitly use --now.\n"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
|
||||
+6
-1
@@ -69,6 +69,7 @@
|
||||
# FELIS_CLOUDFLARED_VERSION / FELIS_CLOUDFLARED_SHA256 cloudflared release installed
|
||||
# when none is present (default: 2026.9.1, digests pinned); the
|
||||
# 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
|
||||
# (k3s one minor version at a time; neither is ever downgraded) and
|
||||
# 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
|
||||
# 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
|
||||
ok "host binary is already ${FELIS_REF}; skipping the download"
|
||||
return 0
|
||||
@@ -6287,6 +6288,10 @@ main() {
|
||||
# Before the first change to the host: a problem found here costs a rerun, one found
|
||||
# halfway through costs an install to unwind.
|
||||
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
|
||||
pause_package_background_timers
|
||||
ensure_swap
|
||||
|
||||
@@ -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.
|
||||
run_download() {
|
||||
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"
|
||||
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
|
||||
ok() { printf "OK: %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")"
|
||||
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)")"
|
||||
same_out "a mismatched release binary asks for the source build" "FETCH_SOURCE
|
||||
PREBUILT[]" $?
|
||||
@@ -5222,6 +5232,42 @@ case "$out" in
|
||||
esac
|
||||
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.
|
||||
before "distributed host firewall runs the newly built binary" \
|
||||
' build_image' '"$HOST_BIN" node firewall --controller' "$(bsfn main)"
|
||||
|
||||
@@ -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:
|
||||
`felis-update-check.timer` runs `felis update --record` daily on the host, which
|
||||
stores the report under `platform_settings.update_report`, and **Admin → Updates →
|
||||
Component versions** shows it with the command that applies each update.
|
||||
- `internal/updates/seams.go:43` — `Applier`. Nothing applies an update anywhere. A
|
||||
nil applier is not silent — `Run` records `errNoApplier` against every planned
|
||||
apply, so a mis-scheduled apply is loud rather than lost.
|
||||
Component versions** shows it with the read-only command that prepares an update.
|
||||
- `internal/updates/seams.go:43` — `Applier`. No unattended runner adapts this interface.
|
||||
The host CLI implements explicit `felis update --apply` separately, using the target
|
||||
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
|
||||
`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
|
||||
on the host path (see "Built" below), so this gap is specific to a caller that has
|
||||
a cluster client instead of the node.
|
||||
- `internal/api/handlers_updates.go` — the maintenance window is advisory: no
|
||||
in-cluster runner applies updates. `felis update` reads the stored window, prints
|
||||
where now sits against it and warns before an apply outside it; the runner itself
|
||||
still runs with a zero window, so no path can claim an apply is under way.
|
||||
- `internal/api/handlers_updates.go` — no in-cluster runner applies updates. The host
|
||||
`felis update --apply` now consumes the stored window and refuses outside it unless
|
||||
explicit `--now` starts manual maintenance. The check/record runner remains read-only.
|
||||
- `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
|
||||
derived context ref is now the internal-face URL
|
||||
|
||||
+6
-9
@@ -4185,15 +4185,12 @@ paths:
|
||||
operationId: getUpdateWindow
|
||||
summary: 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.
|
||||
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}.
|
||||
x-felis-face: [external]
|
||||
x-felis-tier: admin
|
||||
security: [{ sessionCookie: [] }]
|
||||
|
||||
+41
-10
@@ -501,8 +501,8 @@ store=/var/lib/rancher/k3s/storage
|
||||
|
||||
## 4. Upgrading the pieces around Felis
|
||||
|
||||
A rerun of the installer upgrades Felis itself (§15). The components it installs keep
|
||||
the version they were installed with unless noted:
|
||||
The host updater reuses the installer to reconcile Felis itself and its core components
|
||||
(§15). The components it installs keep the version they were installed with unless noted:
|
||||
|
||||
| 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) |
|
||||
| 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
|
||||
curl -fsSL https://raw.githubusercontent.com/FelisMC/Felis/main/deploy/bootstrap.sh \
|
||||
| sudo FELIS_UPGRADE_DEPS=1 bash
|
||||
sudo felis update # newest stable release; no installation
|
||||
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
|
||||
against their newest releases; `--k3s`, `--cloudflared`, `--jre` and `--postgres` narrow
|
||||
it to one. PostgreSQL is read from the felis-postgres container and compared within its
|
||||
major, since a minor release arrives with a Felis release, and a major past its end of life
|
||||
gets a note naming the current one.
|
||||
Review the target, full commit, scope and restart impact, then run the exact `--apply`
|
||||
command printed by the check. A source apply uses the full SHA, while a release apply
|
||||
also includes `--expect-commit` so a moved tag is refused. `--apply --dev` is supported
|
||||
for deliberately resolving main at execution time; the printed command pins the commit
|
||||
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`
|
||||
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
|
||||
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
|
||||
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:
|
||||
|
||||
```sh
|
||||
|
||||
+12
-8
@@ -2136,14 +2136,18 @@ annotated Service endpoints picks them up as is.
|
||||
|
||||
## 15. Control-plane upgrades, and rolling back a bad one
|
||||
|
||||
There is no in-place updater: an upgrade is re-running the installer
|
||||
(`curl -fsSL <installer URL> | sudo bash`), which imports the release's images
|
||||
(or rebuilds them on the host, operations §1 "Where the binary and the images come
|
||||
from") and re-applies the bundle. `felis update --panel` prints that command with the
|
||||
script read at the newest release's tag, so the installer and the binary it
|
||||
downloads come from the same release. (`sudo felis setup` is not this path; on a completed
|
||||
install it only opens the config console.) The channel is not persisted across
|
||||
the re-run, so pass `FELIS_VERSION_BOOTSTRAP=dev` on a host that tracks main.
|
||||
`felis update` checks only; it prints a target and explicit `--apply` command. The host
|
||||
updater downloads the installer from the target's resolved full commit, backs up the
|
||||
database and deployment state, then uses that installer to reconcile the CLI, core
|
||||
services, plugins and system images. See operations §4 for `--version`, `--ref`, `--dev`
|
||||
and `--all`. Use the exact printed command to retain the reviewed target. Application
|
||||
requires an active maintenance window or explicit `--now`; `--force` never skips the
|
||||
backup, an unreadable window or compatibility guards.
|
||||
|
||||
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:
|
||||
|
||||
- Both Deployments use strategy **Recreate** (single replica, no leader election:
|
||||
|
||||
@@ -19,16 +19,14 @@ import (
|
||||
// SINGLE GLOBAL WINDOW (deliberate). internal/updates models the window PER
|
||||
// Component (Component.Window), but this endpoint stores ONE platform-wide window.
|
||||
// The task scopes "the SysAdmin-set update Window" as a single maintenance slot,
|
||||
// and the core's own comment defers recurrence to the caller — so the (not-yet-
|
||||
// built, INTEGRATION-ONLY) `felis update` runner reads this one window and fans it
|
||||
// out to every Scheduled+manageable component when it assembles its []Component.
|
||||
// and the core's own comment defers recurrence to the caller. The host
|
||||
// `felis update --apply` reads this window before backup and before installation.
|
||||
// A future need for per-component windows would layer keys on top; this is the
|
||||
// platform default.
|
||||
//
|
||||
// Felis applies no update on its own, so the window is advisory. Its consumer
|
||||
// is `felis update` on the host (cmd/felis/update.go readUpdateWindow), which
|
||||
// reads this row, prints where now sits against it, and warns before an apply
|
||||
// outside it. The panel's Updates page says the same.
|
||||
// Felis applies no update on its own. `felis update` reports this window, and
|
||||
// explicit --apply refuses outside it unless --now starts manual maintenance.
|
||||
// An unreadable window is always a refusal, including for --now and --force.
|
||||
|
||||
// updateWindowKey is the platform_settings key holding the maintenance window as
|
||||
// JSON {"start","end"} (RFC3339, or null when unset). It reuses the generic
|
||||
|
||||
@@ -47,8 +47,11 @@
|
||||
// ON-HOST caller has both: NewHostGatherer (gatherer_host.go) answers felis-api from
|
||||
// the running binary's build stamp and Velocity from the installed jar's manifest,
|
||||
// and that is what the built `felis update` CLI runs on. Still absent: the concrete
|
||||
// Notifier (SMTP + in-game) and Applier (control-plane image bump, cloudflared swap)
|
||||
// — the CLI passes nil for both on purpose, so it reports and never applies — the
|
||||
// Notifier (SMTP + in-game) and unattended Applier — the check/record runner
|
||||
// passes nil for both. Explicit host application uses the target installer in
|
||||
// cmd/felis/update_apply.go after window checks and a database/state backup.
|
||||
// InstallerSource pins that script and the source checkout to a full commit;
|
||||
// its HTTP boundaries and the host execution order are tested with fakes. Still absent: the
|
||||
// in-cluster CronJob entry point, and the runtime append of the live Pinned
|
||||
// Minecraft fleet. The scheduled check runs on the host instead:
|
||||
// felis-update-check.timer runs `felis update --record`, which stores the report
|
||||
|
||||
+17
-10
@@ -125,22 +125,29 @@ func parseStableTag(repo, tag string) (updates.Version, error) {
|
||||
|
||||
// latestTag fetches the tag of repo's /releases/latest.
|
||||
func (g github) latestTag(ctx context.Context, repo string) (string, error) {
|
||||
url := fmt.Sprintf("%s/repos/%s/releases/latest", g.baseURL, repo)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
return g.releaseTag(ctx, repo, "releases/latest")
|
||||
}
|
||||
|
||||
func (g github) get(ctx context.Context, path, accept string) (*http.Response, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, g.baseURL+path, nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("github: build request for %s: %w", repo, err)
|
||||
return nil, err
|
||||
}
|
||||
ua := g.userAgent
|
||||
if ua == "" {
|
||||
ua = defaultUserAgent
|
||||
}
|
||||
req.Header.Set("User-Agent", ua)
|
||||
req.Header.Set("Accept", "application/vnd.github+json")
|
||||
req.Header.Set("Accept", accept)
|
||||
if g.token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+g.token)
|
||||
}
|
||||
|
||||
resp, err := g.hc.Do(req)
|
||||
return g.hc.Do(req)
|
||||
}
|
||||
|
||||
func (g github) releaseTag(ctx context.Context, repo, endpoint string) (string, error) {
|
||||
resp, err := g.get(ctx, "/repos/"+repo+"/"+endpoint, "application/vnd.github+json")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("github: get %s: %w", repo, err)
|
||||
}
|
||||
@@ -150,15 +157,15 @@ func (g github) latestTag(ctx context.Context, repo string) (string, error) {
|
||||
// answering 401/403, so an unauthenticated miss and a repo with no stable release
|
||||
// are the same status. Name both causes, and name the fix for the one an operator
|
||||
// can act on. The official repository is public, so there only the first applies.
|
||||
if resp.StatusCode == http.StatusNotFound && strings.EqualFold(repo, officialRepo) {
|
||||
if endpoint == "releases/latest" && resp.StatusCode == http.StatusNotFound && strings.EqualFold(repo, officialRepo) {
|
||||
return "", fmt.Errorf("github: %s releases/latest returned HTTP 404 — it has no published stable release", repo)
|
||||
}
|
||||
if resp.StatusCode == http.StatusNotFound && g.token == "" {
|
||||
return "", fmt.Errorf(
|
||||
"github: %s releases/latest returned HTTP 404 — either it has no published stable release, or it is private and %s is unset",
|
||||
repo, tokenEnv)
|
||||
"github: %s %s returned HTTP 404 — either no published stable release exists, or the repository is private and %s is unset",
|
||||
repo, endpoint, tokenEnv)
|
||||
}
|
||||
return "", fmt.Errorf("github: %s releases/latest returned HTTP %d", repo, resp.StatusCode)
|
||||
return "", fmt.Errorf("github: %s %s returned HTTP %d", repo, endpoint, resp.StatusCode)
|
||||
}
|
||||
|
||||
var rr releaseResponse
|
||||
@@ -166,7 +173,7 @@ func (g github) latestTag(ctx context.Context, repo string) (string, error) {
|
||||
return "", fmt.Errorf("github: decode %s: %w", repo, err)
|
||||
}
|
||||
if rr.Draft || rr.Prerelease {
|
||||
return "", fmt.Errorf("github: %s releases/latest is unexpectedly draft/prerelease (tag %q)", repo, rr.TagName)
|
||||
return "", fmt.Errorf("github: %s %s is unexpectedly draft/prerelease (tag %q)", repo, endpoint, rr.TagName)
|
||||
}
|
||||
|
||||
return rr.TagName, nil
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -90,9 +90,9 @@
|
||||
"reviewed_at": "Reviewed At",
|
||||
"reject_reason": "Rejection Reason",
|
||||
"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_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_current_unset": "No maintenance window set. `felis update` will say so and leave the timing to you.",
|
||||
"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": "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. Configure one before applying, or explicitly use `--apply --now` for manual maintenance.",
|
||||
"updates_start_label": "Start Time",
|
||||
"updates_end_label": "End Time",
|
||||
"updates_set_title": "Configure Maintenance Window",
|
||||
@@ -159,7 +159,7 @@
|
||||
"versions_state_unknown": "Feed unreachable",
|
||||
"versions_state_unreadable": "Version unreadable",
|
||||
"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_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:",
|
||||
|
||||
@@ -90,9 +90,9 @@
|
||||
"reviewed_at": "审核时间",
|
||||
"reject_reason": "驳回理由",
|
||||
"updates_title": "维护与备份",
|
||||
"updates_subtitle": "查看控制面数据库备份是否新鲜、哪些组件有新版本,并设置全局维护窗口。Felis 从不自行应用更新:宿主机上的 `felis update` 会显示这个窗口,在窗口外应用前给出警告。",
|
||||
"updates_window_advisory": "维护窗口是提示性的。只有有人执行 `felis update` 打印的应用命令时才会更新;该命令会读取这个窗口,在窗口外运行时给出警告。",
|
||||
"updates_current_unset": "当前未设置维护窗口。`felis update` 会提示这一点,何时应用由你决定。",
|
||||
"updates_subtitle": "查看数据库备份和组件版本,并设置维护窗口。在宿主机运行 `felis update` 检查目标版本;只有显式执行 `--apply` 才会更新。",
|
||||
"updates_window_advisory": "更新默认只允许在维护窗口内开始。宿主机会在备份前和安装前再次检查窗口;窗口外拒绝执行。临时维护需显式添加 `--now`,`--force` 不会跳过这些检查。",
|
||||
"updates_current_unset": "尚未设置维护窗口。请先设置窗口,或显式使用 `--apply --now` 开始临时维护。",
|
||||
"updates_start_label": "开始时间",
|
||||
"updates_end_label": "结束时间",
|
||||
"updates_set_title": "配置维护窗口",
|
||||
@@ -158,7 +158,7 @@
|
||||
"versions_state_unknown": "无法访问发行源",
|
||||
"versions_state_unreadable": "读不到版本",
|
||||
"versions_state_pinned": "已固定",
|
||||
"versions_apply_hint": "要应用更新,请在主机上运行下面的命令;如果设置了维护窗口,请在窗口内运行。它会列出每个组件的确切命令,并在窗口外运行时发出警告:",
|
||||
"versions_apply_hint": "在宿主机执行下面的只读检查,查看更新目标、范围和确切的 `--apply` 命令。确认后,在维护窗口内执行该命令:",
|
||||
"versions_never_title": "还没有记录过版本检查",
|
||||
"versions_stale_title": "最近一次版本检查已超过 {{hours}} 小时",
|
||||
"versions_fix_hint": "下面的版本可能已过时。请先在主机上立即检查一次,再查看定时器日志找出它没有运行的原因:",
|
||||
|
||||
@@ -1232,7 +1232,7 @@ export interface paths {
|
||||
};
|
||||
/**
|
||||
* 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"];
|
||||
/**
|
||||
|
||||
@@ -24,7 +24,7 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
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(
|
||||
<MemoryRouter>
|
||||
<UpdatesPage />
|
||||
@@ -32,10 +32,10 @@ describe("UpdatesPage", () => {
|
||||
);
|
||||
expect(
|
||||
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();
|
||||
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();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user