feat(cli): apply coordinated updates during maintenance

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

No files matched your search

+1 -1
View File
@@ -44,7 +44,7 @@ Commands:
support-bundle Collect status, doctor, logs and cluster state into one redacted tar.gz to share when asking for help (requires root/sudo)
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
View File
@@ -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))
}
}
+245
View File
@@ -0,0 +1,245 @@
package main
import (
"context"
"encoding/hex"
"errors"
"fmt"
"io"
"os"
"os/exec"
"strings"
"syscall"
"time"
"felis.lolicon.best/internal/updater"
"felis.lolicon.best/internal/updates"
)
type updateOptions struct {
release, ref, cfgPath, repoURL, expectedCommit string
preserveToken bool
apply, all, force, now bool
selected map[string]bool
}
func (o *updateOptions) validate(dev, check, record bool) error {
if (o.release != "" && o.ref != "") || (dev && (o.release != "" || o.ref != "")) {
return fmt.Errorf("choose only one of --version, --ref and --dev")
}
if o.apply && (check || record) {
return fmt.Errorf("--apply cannot be combined with --check or --record")
}
if o.expectedCommit != "" {
if _, err := hex.DecodeString(o.expectedCommit); err != nil || len(o.expectedCommit) != 40 || !o.apply {
return fmt.Errorf("--expect-commit requires --apply and a full commit SHA")
}
}
o.expectedCommit = strings.ToLower(o.expectedCommit)
if o.now && !o.apply {
return fmt.Errorf("--now requires --apply")
}
if record && (dev || o.release != "" || o.ref != "") {
return fmt.Errorf("--record checks installed components; use a separate target check")
}
if o.release != "" {
o.release = "v" + strings.TrimPrefix(o.release, "v")
v, err := updates.Parse(o.release)
if err != nil || v.IsPrerelease() || strings.Contains(o.release, "+") || strings.Count(o.release, ".") != 2 {
return fmt.Errorf("--version must be a published stable release such as v0.2.0; use --ref for other tags")
}
}
return nil
}
func (o updateOptions) dependencies() bool {
return o.all || o.selected["k3s"] || o.selected["cloudflared"]
}
func renderInstallPlan(target updater.InstallTarget, o updateOptions) string {
var b strings.Builder
label := target.Release
if label == "" {
label = "source build"
}
fmt.Fprintf(&b, "\nFelis target: %s\nCommit: %s\n", label, target.Revision)
b.WriteString("Scope: host CLI, API, operator, embedded panel, platform manifests/RBAC, Velocity + Felis plugins, login/lobby images, release-pinned JRE and PostgreSQL.\n")
if o.dependencies() {
b.WriteString("Host dependencies: also reconcile k3s and cloudflared to this target's pins.\n")
}
b.WriteString("Upstream availability above is advisory; application uses this target's compatible pins, not each upstream's newest release. User server images remain pinned.\n")
b.WriteString("Before applying: check maintenance, back up the database and host/server configuration, then check maintenance again.\n")
b.WriteString("API, proxy and system spaces may restart.\n")
if o.apply {
return b.String()
}
b.WriteString("No update is applied by this check.\n")
cmd := "sudo"
if o.preserveToken {
cmd += " --preserve-env=FELIS_GITHUB_TOKEN"
}
if o.repoURL != "" {
cmd += " env FELIS_REPO_URL=" + shellQuote(o.repoURL)
}
cmd += " felis update --apply"
assets := target.Release != "" && canUseReleaseAssets(target.Script, o.force)
if target.Release != "" && !assets {
b.WriteString("This release installer cannot pin the requested published-asset install; the apply command builds its exact source commit instead.\n")
}
if assets {
cmd += " --version " + target.Release + " --expect-commit " + target.Revision
} else {
cmd += " --ref " + target.Revision
}
if o.dependencies() {
cmd += " --all"
}
if o.force {
cmd += " --force"
}
if o.cfgPath != "/etc/felis/felis.toml" {
cmd += " --config " + shellQuote(o.cfgPath)
}
fmt.Fprintf(&b, "\nAfter reviewing, run inside the maintenance window:\n %s\n", cmd)
b.WriteString("Without an active window, apply is refused. For an explicit manual maintenance run, add --now. --force never bypasses these checks.\n")
return b.String()
}
// updateApplySteps isolates the three mutating/verification boundaries so the
// ordering and refusal paths can be tested without a live node.
type updateApplySteps struct {
window func(context.Context) (updates.Window, error)
backup, install, verify func(context.Context) error
}
func runUpdateApply(ctx context.Context, o updateOptions, steps updateApplySteps) error {
check := func() error {
win, err := steps.window(ctx)
if err != nil {
return fmt.Errorf("cannot verify maintenance window: %w", err)
}
if !o.now && (win.Start.IsZero() || win.End.IsZero() || !win.Contains(time.Now())) {
return fmt.Errorf("no active maintenance window; set one in the panel or explicitly use --apply --now for manual maintenance")
}
return ctx.Err()
}
if err := check(); err != nil {
return err
}
if err := steps.backup(ctx); err != nil {
return fmt.Errorf("pre-update backup failed; nothing applied: %w", err)
}
if err := check(); err != nil {
return err
}
if err := steps.install(ctx); err != nil {
return fmt.Errorf("installer failed; retain the pre-update backup and inspect its output before retrying: %w", err)
}
if err := steps.verify(ctx); err != nil {
return fmt.Errorf("installed binary verification failed: %w", err)
}
return nil
}
func canUseReleaseAssets(script string, force bool) bool {
return strings.Contains(script, `FELIS_RELEASE="${FELIS_RELEASE:-}"`) &&
(!force || strings.Contains(script, "${FELIS_FORCE_UPDATE:-0}"))
}
func applyHostUpdate(ctx context.Context, target updater.InstallTarget, o updateOptions, repoURL string, stdout, stderr io.Writer) error {
if target.Release != "" {
current, err := updates.Parse(resolvedVersion())
want, _ := updates.Parse(target.Release)
if err == nil && want.Compare(current) < 0 && !o.force {
return fmt.Errorf("%s is older than %s; an intentional Felis downgrade requires --force", target.Release, current)
}
if !canUseReleaseAssets(target.Script, o.force) {
return fmt.Errorf("this release installer cannot pin the requested published-asset install; use --ref %s to rebuild its exact source", target.Revision)
}
}
exe, err := os.Executable()
if err != nil {
return err
}
return runUpdateApply(ctx, o, updateApplySteps{
window: func(ctx context.Context) (updates.Window, error) { return readUpdateWindow(ctx, o.cfgPath) },
backup: func(ctx context.Context) error {
fmt.Fprintln(stdout, "[felis] taking the pre-update database and deployment-state backup")
cmd := exec.CommandContext(ctx, exe, "db", "backup", "--config", o.cfgPath, "--label", "pre-migrate")
cmd.Stdout, cmd.Stderr = stdout, stderr
return cmd.Run()
},
install: func(ctx context.Context) error {
fmt.Fprintln(stdout, "[felis] applying the inspected Felis target")
return runUpdateInstaller(ctx, target.Script, updateInstallerEnv(os.Environ(), target, o, repoURL), stdout, stderr)
},
verify: func(ctx context.Context) error {
out, err := exec.CommandContext(ctx, hostBinDir+"/felis", "version").Output()
if err != nil {
return err
}
line, _, _ := strings.Cut(string(out), "\n")
if !installedUpdateMatches(strings.TrimPrefix(line, "felis "), target) {
return fmt.Errorf("wanted %s / %s, got %q", target.Release, target.Revision, line)
}
fmt.Fprintln(stdout, line)
return nil
},
})
}
func installedUpdateMatches(version string, target updater.InstallTarget) bool {
if target.Release != "" {
return version == target.Release
}
_, sha, ok := strings.Cut(version, "+g")
return ok && len(sha) >= 7 && strings.HasPrefix(target.Revision, sha)
}
func updateInstallerEnv(env []string, target updater.InstallTarget, o updateOptions, repoURL string) []string {
// A setup hand-off or stale source override must never reinstall the running
// binary or a different tree than the one just inspected.
replace := map[string]string{
"FELIS_BOOTSTRAP_FROM_TUI": "", "FELIS_BOOTSTRAP_BINARY": "", "FELIS_SKIP_FETCH": "", "FELIS_ARTIFACT_DIR": "",
"FELIS_REF": target.Revision, "FELIS_RELEASE": "", "FELIS_VERSION_BOOTSTRAP": "dev",
"FELIS_REPO_URL": repoURL, "FELIS_NO_SETUP": "1", "FELIS_INSTALL_MODE": "full",
"FELIS_UPGRADE_DEPS": "0", "FELIS_FORCE_UPDATE": "0", "FELIS_IMAGE": "",
"FELIS_GAME_STACK": "pinned", "FELIS_VELOCITY_VERSION": "", "FELIS_JRE_VERSION": "",
"FELIS_K3S_VERSION": "", "FELIS_CLOUDFLARED_VERSION": "", "FELIS_PREFLIGHT": "strict", "FELIS_PRE_MIGRATE_BACKUP": "1",
}
if target.Release != "" {
replace["FELIS_REF"], replace["FELIS_RELEASE"], replace["FELIS_VERSION_BOOTSTRAP"] = "", target.Release, "release"
}
if o.dependencies() {
replace["FELIS_UPGRADE_DEPS"] = "1"
}
if o.force {
replace["FELIS_FORCE_UPDATE"] = "1"
}
out := make([]string, 0, len(env)+len(replace))
for _, item := range env {
key, _, _ := strings.Cut(item, "=")
if _, overridden := replace[key]; !overridden {
out = append(out, item)
}
}
for key, value := range replace {
out = append(out, key+"="+value)
}
return out
}
func runUpdateInstaller(ctx context.Context, script string, env []string, stdout, stderr io.Writer) error {
cmd := exec.CommandContext(ctx, "bash", "-s")
cmd.Env, cmd.Stdin, cmd.Stdout, cmd.Stderr = env, strings.NewReader(script), stdout, stderr
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
cmd.Cancel = func() error {
err := syscall.Kill(-cmd.Process.Pid, syscall.SIGTERM)
if errors.Is(err, syscall.ESRCH) {
return os.ErrProcessDone
}
return err
}
cmd.WaitDelay = 10 * time.Second
return cmd.Run()
}
+215
View File
@@ -0,0 +1,215 @@
package main
import (
"context"
"errors"
"io"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
"time"
"felis.lolicon.best/internal/updater"
"felis.lolicon.best/internal/updates"
)
const updateTestSHA = "0123456789abcdef0123456789abcdef01234567"
func TestUpdateRefPlanPinsTheReviewedCommit(t *testing.T) {
target := updater.InstallTarget{Revision: updateTestSHA}
for _, opts := range []updateOptions{
{cfgPath: "/etc/felis/felis.toml"},
{cfgPath: "/etc/felis/felis.toml", force: true, all: true},
{cfgPath: "/etc/felis/felis.toml", selected: map[string]bool{"k3s": true}},
} {
out := renderInstallPlan(target, opts)
if !strings.Contains(out, "sudo felis update --apply --ref "+updateTestSHA) || strings.Contains(out, "--dev") {
t.Fatalf("moving target in apply command: %s", out)
}
if !strings.Contains(out, "No update is applied") || !strings.Contains(out, "User server images remain pinned") {
t.Fatalf("missing scope: %s", out)
}
if strings.Contains(out, " --all") != opts.dependencies() || strings.Contains(out, "--ref "+updateTestSHA+" --all --force") != opts.force {
t.Fatalf("apply command lost options: %s", out)
}
}
opts := updateOptions{cfgPath: "/path/'literal $(id).toml", repoURL: "https://github.com/example/Felis.git", preserveToken: true}
out := renderInstallPlan(updater.InstallTarget{Release: "v0.2.0", Revision: updateTestSHA, Script: `#!/bin/bash
FELIS_RELEASE="${FELIS_RELEASE:-}"`}, opts)
for _, want := range []string{"--version v0.2.0 --expect-commit " + updateTestSHA, "--preserve-env=FELIS_GITHUB_TOKEN", "FELIS_REPO_URL=" + shellQuote(opts.repoURL), "--config " + shellQuote(opts.cfgPath)} {
if !strings.Contains(out, want) {
t.Fatalf("missing %q: %s", want, out)
}
}
older := renderInstallPlan(updater.InstallTarget{Release: "v0.2.0", Revision: updateTestSHA, Script: "#!/bin/bash\n# FELIS_RELEASE"}, updateOptions{cfgPath: "/etc/felis/felis.toml"})
if !strings.Contains(older, "--apply --ref "+updateTestSHA) || strings.Contains(older, "--version v0.2.0") {
t.Fatalf("old installer must offer a working pinned source apply: %s", older)
}
if strings.Contains(renderInstallPlan(target, updateOptions{apply: true}), "No update is applied") {
t.Fatal("apply must not claim it is only a check")
}
}
func TestUpdateRejectsConflictingOrUnsafeFlagsBeforeDiscovery(t *testing.T) {
for _, args := range [][]string{
{"--apply", "--check"}, {"--apply", "--record"}, {"--now"},
{"--dev", "--version", "v0.2.0"}, {"--dev", "--ref", "main"},
{"--ref", "main", "--version", "v0.2.0"}, {"--record", "--dev"},
{"--version", "v0.2.0-rc.1"}, {"--version", "v0.2.0+gabc1234"},
{"--apply", "--mc"}, {"apply"}, {"--apply", "--expect-commit", "short"}, {"--expect-commit", updateTestSHA},
} {
var out, errb strings.Builder
if got := cmdUpdate(args, &out, &errb); got != 2 || out.Len() != 0 {
t.Errorf("args %v: code %d, out %q, err %q", args, got, out.String(), errb.String())
}
}
o := updateOptions{release: "0.2.0"}
if err := o.validate(false, true, false); err != nil || o.release != "v0.2.0" {
t.Fatalf("normalize version: %v / %q", err, o.release)
}
}
func TestUpdateApplySafetyAndOrdering(t *testing.T) {
now := time.Now()
open := updates.Window{Start: now.Add(-time.Hour), End: now.Add(time.Hour)}
future := updates.Window{Start: now.Add(time.Hour), End: now.Add(2 * time.Hour)}
ended := updates.Window{Start: now.Add(-2 * time.Hour), End: now.Add(-time.Hour)}
failed := errors.New("failed")
for _, tc := range []struct {
name string
opts updateOptions
windows []updates.Window
windowErr, backupErr, installErr, verifyErr error
want []string
wantErr bool
}{
{name: "active", windows: []updates.Window{open, open}, want: []string{"window", "backup", "window", "install", "verify"}},
{name: "unset", windows: []updates.Window{{}}, want: []string{"window"}, wantErr: true},
{name: "future", windows: []updates.Window{future}, want: []string{"window"}, wantErr: true},
{name: "ended", windows: []updates.Window{ended}, want: []string{"window"}, wantErr: true},
{name: "force cannot bypass", opts: updateOptions{force: true}, windows: []updates.Window{future}, want: []string{"window"}, wantErr: true},
{name: "manual maintenance", opts: updateOptions{now: true}, windows: []updates.Window{{}, {}}, want: []string{"window", "backup", "window", "install", "verify"}},
{name: "manual cannot bypass unreadable window", opts: updateOptions{now: true}, windowErr: failed, want: []string{"window"}, wantErr: true},
{name: "backup failure", windows: []updates.Window{open}, backupErr: failed, want: []string{"window", "backup"}, wantErr: true},
{name: "expires during backup", windows: []updates.Window{open, ended}, want: []string{"window", "backup", "window"}, wantErr: true},
{name: "install failure", windows: []updates.Window{open, open}, installErr: failed, want: []string{"window", "backup", "window", "install"}, wantErr: true},
{name: "verification failure", windows: []updates.Window{open, open}, verifyErr: failed, want: []string{"window", "backup", "window", "install", "verify"}, wantErr: true},
} {
t.Run(tc.name, func(t *testing.T) {
var calls []string
reads := 0
steps := updateApplySteps{
window: func(context.Context) (updates.Window, error) {
calls = append(calls, "window")
if tc.windowErr != nil {
return updates.Window{}, tc.windowErr
}
w := tc.windows[reads]
reads++
return w, nil
},
backup: func(context.Context) error { calls = append(calls, "backup"); return tc.backupErr },
install: func(context.Context) error { calls = append(calls, "install"); return tc.installErr },
verify: func(context.Context) error { calls = append(calls, "verify"); return tc.verifyErr },
}
err := runUpdateApply(context.Background(), tc.opts, steps)
if (err != nil) != tc.wantErr || !reflect.DeepEqual(calls, tc.want) {
t.Fatalf("calls %v, err %v; want %v, error %v", calls, err, tc.want, tc.wantErr)
}
})
}
}
func TestUpdateInstallerCannotUseStaleSourceOrBypassGuards(t *testing.T) {
env := []string{"FELIS_REF=stale", "FELIS_REF=duplicate", "FELIS_BOOTSTRAP_BINARY=/old", "FELIS_BOOTSTRAP_FROM_TUI=1", "FELIS_SKIP_FETCH=1", "FELIS_RELEASE=v0.1.0", "FELIS_GAME_STACK=latest", "FELIS_PREFLIGHT=warn", "FELIS_PRE_MIGRATE_BACKUP=0", "FELIS_GITHUB_TOKEN=private-token", "PATH=/usr/bin"}
for _, release := range []string{"", "v0.2.0"} {
out := updateInstallerEnv(env, updater.InstallTarget{Release: release, Revision: updateTestSHA}, updateOptions{force: true, all: true}, "https://github.com/FelisMC/Felis.git")
got := map[string]string{}
for _, item := range out {
key, value, _ := strings.Cut(item, "=")
if _, exists := got[key]; exists {
t.Fatalf("duplicate environment key %s", key)
}
got[key] = value
}
for key, want := range map[string]string{"FELIS_BOOTSTRAP_BINARY": "", "FELIS_BOOTSTRAP_FROM_TUI": "", "FELIS_SKIP_FETCH": "", "FELIS_RELEASE": release, "FELIS_GAME_STACK": "pinned", "FELIS_PREFLIGHT": "strict", "FELIS_PRE_MIGRATE_BACKUP": "1", "FELIS_FORCE_UPDATE": "1", "FELIS_UPGRADE_DEPS": "1", "FELIS_GITHUB_TOKEN": "private-token"} {
if got[key] != want {
t.Errorf("%s = %q; want %q", key, got[key], want)
}
}
wantRef := updateTestSHA
if release != "" {
wantRef = ""
}
if got["FELIS_REF"] != wantRef {
t.Fatalf("wrong source: %v", got)
}
}
}
func TestUpdateExecutesInstallerAndPropagatesFailure(t *testing.T) {
var stdout, stderr strings.Builder
err := runUpdateInstaller(context.Background(), "#!/bin/bash\nprintf 'target:%s' \"$FELIS_REF\"\nprintf 'diagnostic' >&2\nexit 7\n", append(os.Environ(), "FELIS_REF="+updateTestSHA), &stdout, &stderr)
if err == nil || stdout.String() != "target:"+updateTestSHA || stderr.String() != "diagnostic" {
t.Fatalf("out %q, stderr %q, err %v", stdout.String(), stderr.String(), err)
}
}
func TestUpdateCancellationStopsInstallerChildren(t *testing.T) {
dir := t.TempDir()
ready, stopped := filepath.Join(dir, "ready"), filepath.Join(dir, "child-stopped")
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
done := make(chan error, 1)
script := `#!/bin/bash
trap 'exit 0' TERM
(
trap 'echo stopped > "$STOPPED"; exit 0' TERM
echo ready > "$READY"
sleep 30
) &
wait
`
go func() {
done <- runUpdateInstaller(ctx, script, append(os.Environ(), "READY="+ready, "STOPPED="+stopped), io.Discard, io.Discard)
}()
deadline := time.Now().Add(3 * time.Second)
for {
if _, err := os.Stat(ready); err == nil {
break
}
if time.Now().After(deadline) {
t.Fatal("installer did not become ready")
}
time.Sleep(10 * time.Millisecond)
}
cancel()
select {
case err := <-done:
if err == nil {
t.Fatal("interrupted installation must not report success")
}
case <-time.After(3 * time.Second):
t.Fatal("installer children kept running after cancellation")
}
if _, err := os.Stat(stopped); err != nil {
t.Fatalf("installer child did not receive cancellation: %v", err)
}
}
func TestInstalledUpdateMustMatchExactTarget(t *testing.T) {
for _, tc := range []struct {
version, release string
want bool
}{
{"v0.2.0", "v0.2.0", true}, {"v0.2.1", "v0.2.0", false},
{"v0.0.0+g0123456", "", true}, {"v0.0.0+gunknown", "", false},
{"v0.0.0+g012", "", false}, {"v0.0.0+g9876543", "", false},
} {
if got := installedUpdateMatches(tc.version, updater.InstallTarget{Release: tc.release, Revision: updateTestSHA}); got != tc.want {
t.Errorf("%s / %s matched = %v", tc.version, tc.release, got)
}
}
}
-178
View File
@@ -60,60 +60,6 @@ func TestUpdateReportNamesBothFailureCauses(t *testing.T) {
}
}
func TestApplyGuidanceUpToDateNeedsForce(t *testing.T) {
res := planResult([]updates.Action{{Component: "velocity", Kind: updates.ActionNone, LatestKnown: true}})
sel := map[string]bool{"velocity": true}
quiet := renderApplyGuidance(res, sel, false)
if !strings.Contains(quiet, "already up to date") {
t.Fatalf("want an up-to-date notice:\n%s", quiet)
}
if strings.Contains(quiet, "run:") {
t.Fatalf("must not offer a command for an up-to-date component without --force:\n%s", quiet)
}
forced := renderApplyGuidance(res, sel, true)
if !strings.Contains(forced, "run:") {
t.Fatalf("--force must offer the reinstall command:\n%s", forced)
}
}
// An undiscoverable latest version must never be reported as "up to date". Both
// states arrive as ActionNone and only LatestKnown separates them, so this is a live
// confusion, not a hypothetical one — it shipped that way until a smoke test showed
// `--panel` calling felis-api current right after the release feed returned 404.
func TestApplyGuidanceUnknownLatestIsNotUpToDate(t *testing.T) {
res := planResult([]updates.Action{{Component: "velocity", Kind: updates.ActionNone, LatestKnown: false}})
out := renderApplyGuidance(res, map[string]bool{"velocity": true}, false)
if strings.Contains(out, "already up to date") {
t.Fatalf("must not claim currency when the latest version is unknown:\n%s", out)
}
if !strings.Contains(out, "cannot tell") {
t.Fatalf("want the uncertainty stated plainly:\n%s", out)
}
// One header per selector: the uncertainty is a note under it, not a second block.
if n := strings.Count(out, "--velocity:"); n != 1 {
t.Fatalf("want exactly 1 selector header, got %d:\n%s", n, out)
}
// The operator asked about this component, so the repair command still belongs.
if !strings.Contains(out, "run:") {
t.Fatalf("want the reinstall command offered despite the unknown latest:\n%s", out)
}
}
// Minecraft is pinned and has no planner entry, so --mc explains the pin and offers
// NO command — and therefore must not print the trailer that explains the command.
func TestApplyGuidanceMinecraftOffersNoCommand(t *testing.T) {
out := renderApplyGuidance(planResult(nil), map[string]bool{"mc": true}, true)
if !strings.Contains(out, "pinned by policy") {
t.Fatalf("want the pin explained:\n%s", out)
}
if strings.Contains(out, "run:") || strings.Contains(out, "Re-running the installer") {
t.Fatalf("--mc must offer no command and no command trailer:\n%s", out)
}
}
// Every selector in the table must be a real flag on the FlagSet, and every
// 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)
+4 -4
View File
@@ -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) {