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) {
|
||||
|
||||
Reference in new issue
Block a user