diff --git a/cmd/felis/run.go b/cmd/felis/run.go index ce8748e..310988d 100644 --- a/cmd/felis/run.go +++ b/cmd/felis/run.go @@ -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 -h" for command-specific flags. diff --git a/cmd/felis/update.go b/cmd/felis/update.go index d9b7a69..56c1636 100644 --- a/cmd/felis/update.go +++ b/cmd/felis/update.go @@ -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= 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, 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)) } } diff --git a/cmd/felis/update_apply.go b/cmd/felis/update_apply.go new file mode 100644 index 0000000..cc8f81b --- /dev/null +++ b/cmd/felis/update_apply.go @@ -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() +} diff --git a/cmd/felis/update_apply_test.go b/cmd/felis/update_apply_test.go new file mode 100644 index 0000000..774eae8 --- /dev/null +++ b/cmd/felis/update_apply_test.go @@ -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) + } + } +} diff --git a/cmd/felis/update_test.go b/cmd/felis/update_test.go index 083e4bc..c2ab0d2 100644 --- a/cmd/felis/update_test.go +++ b/cmd/felis/update_test.go @@ -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 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) diff --git a/cmd/felis/update_window_test.go b/cmd/felis/update_window_test.go index 82226e1..a4afba1 100644 --- a/cmd/felis/update_window_test.go +++ b/cmd/felis/update_window_test.go @@ -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) { diff --git a/deploy/bootstrap.sh b/deploy/bootstrap.sh index 3c5e7f2..e93886f 100644 --- a/deploy/bootstrap.sh +++ b/deploy/bootstrap.sh @@ -69,6 +69,7 @@ # FELIS_CLOUDFLARED_VERSION / FELIS_CLOUDFLARED_SHA256 cloudflared release installed # when none is present (default: 2026.9.1, digests pinned); the # sha256 is REQUIRED for any other version +# FELIS_FORCE_UPDATE=1 reinstalls the binary even when its version already matches. # FELIS_UPGRADE_DEPS 1 moves an installed k3s and cloudflared to the versions above # (k3s one minor version at a time; neither is ever downgraded) and # restarts cloudflared-felis onto the new binary (default: 0) @@ -2612,7 +2613,7 @@ download_release_binary() { # Convergence check, and the cheapest one available: no API call, no download, and it asks # the exact question that matters. Reruns are the common case for this installer. - if [ -x "$HOST_BIN" ] && [ "$("$HOST_BIN" version 2>/dev/null | head -n 1)" = "felis ${FELIS_REF}" ]; then + if [ "${FELIS_FORCE_UPDATE:-0}" != 1 ] && [ -x "$HOST_BIN" ] && [ "$("$HOST_BIN" version 2>/dev/null | head -n 1)" = "felis ${FELIS_REF}" ]; then HAVE_PREBUILT_BINARY=1 ok "host binary is already ${FELIS_REF}; skipping the download" return 0 @@ -6287,6 +6288,10 @@ main() { # Before the first change to the host: a problem found here costs a rerun, one found # halfway through costs an install to unwind. preflight + check_postgres_major + if [ "$FELIS_UPGRADE_DEPS" = 1 ] && [ -x "$K3S_BIN" ]; then + k3s_upgrade_allowed "$("$K3S_BIN" --version 2>/dev/null | awk 'NR == 1 { print $3 }')" "$FELIS_K3S_VERSION" || true + fi quiet_watchdog pause_package_background_timers ensure_swap diff --git a/deploy/bootstrap_test.sh b/deploy/bootstrap_test.sh index ee92305..37f24af 100644 --- a/deploy/bootstrap_test.sh +++ b/deploy/bootstrap_test.sh @@ -1085,9 +1085,10 @@ dsum="$(sha256sum <"$ddir/asset" | cut -d' ' -f1)" # command inside fetch_source. The log lands in $ddir/log; stdout says what the caller did. run_download() { rm -f "$ddir/bin/felis" + if [ "${ALREADY_INSTALLED:-0}" = 1 ]; then cp "$ddir/asset" "$ddir/bin/felis"; chmod +x "$ddir/bin/felis"; fi : > "$ddir/log" SUMS="$1" ENTRY="${2:-acquire_felis_binary}" ASSET="$ddir/asset" LOG="$ddir/log" FELIS_REF=v9.9.9 \ - HOST_BIN="$ddir/bin/felis" TMPDIR="$sdir" INSTALL_FAILS="${INSTALL_FAILS:-}" FETCH_FAILS="${FETCH_FAILS:-}" bash -c ' + HOST_BIN="$ddir/bin/felis" TMPDIR="$sdir" FELIS_FORCE_UPDATE="${FELIS_FORCE_UPDATE:-0}" INSTALL_FAILS="${INSTALL_FAILS:-}" FETCH_FAILS="${FETCH_FAILS:-}" bash -c ' set -Eeuo pipefail ok() { printf "OK: %s\n" "$*" >> "$LOG"; } warn() { printf "WARN: %s\n" "$*" >> "$LOG"; } @@ -1143,6 +1144,15 @@ same_log "the release binary is hashed before it is first executed" "$(printf '% "OK: installed felis-linux-amd64 v9.9.9 at $ddir/bin/felis")" if cmp -s "$ddir/asset" "$ddir/bin/felis"; then echo "PASS the installed binary is the download"; else echo "FAIL the installed binary is not the download"; fails=$((fails + 1)); fi +out="$(ALREADY_INSTALLED=1 run_download "")" +same_out "the matching host binary skips download" "PREBUILT[1]" $? +out="$(FELIS_FORCE_UPDATE=1 ALREADY_INSTALLED=1 run_download "$(printf '%s felis-linux-amd64\n' "$dsum")")" +same_out "force reinstalls the matching verified binary" "PREBUILT[1]" $? +same_log "force verifies the download before executing it" "$(printf '%s\n' \ + "OK: felis-linux-amd64 matches release v9.9.9's SHA256SUMS" \ + "RAN: version" \ + "OK: installed felis-linux-amd64 v9.9.9 at $ddir/bin/felis")" + out="$(run_download "$(printf '%s felis-linux-amd64\n' deadbeef)")" same_out "a mismatched release binary asks for the source build" "FETCH_SOURCE PREBUILT[]" $? @@ -5222,6 +5232,42 @@ case "$out" in esac rm -rf "$credir" "$credcalls" +# Existing clusters must pass compatibility before the install changes the host. +compatdir="$(mktemp -d)" +mkdir -p "$compatdir/pg/18/docker" +printf '18' > "$compatdir/pg/18/docker/PG_VERSION" +printf '#!/bin/sh\necho "k3s version v1.36.4+k3s1 (example)"\n' > "$compatdir/k3s" +chmod +x "$compatdir/k3s" +run_compatibility_guard() { + PG_DATA_DIR="$compatdir/pg" K3S_BIN="$compatdir/k3s" WANT_PG="$1" FELIS_K3S_VERSION="$2" FELIS_UPGRADE_DEPS=1 bash -c ' + set -Eeuo pipefail + die() { echo "DIE: $*"; exit 1; } + ok() { :; } + version_newer() { return 1; } + postgres_image_major() { echo "$WANT_PG"; } + acquire_run_lock() { :; } + ensure_k3s_on_path() { :; } + resolve_nano_listen() { :; } + validate_settings() { :; } + detect_os() { :; } + prompt_install_mode() { INSTALL_MODE=full; } + detect_node_ip() { :; } + preflight() { :; } + quiet_watchdog() { echo HOST_CHANGE; exit 0; } + '"$(bsfn check_postgres_major)"' + '"$(bsfn k3s_upgrade_allowed)"' + '"$(bsfn main)"' + main + ' 2>&1 +} +out="$(run_compatibility_guard 19 v1.37.1+k3s1)" +expect "PostgreSQL major mismatch is refused before host changes" 'holds a PostgreSQL 18 cluster' "$out" +case "$out" in *HOST_CHANGE*) echo "FAIL PostgreSQL refusal came after a host change"; fails=$((fails + 1));; esac +out="$(run_compatibility_guard 18 v1.38.1+k3s1)" +expect "k3s minor skip is refused before host changes" 'skips a minor version' "$out" +case "$out" in *HOST_CHANGE*) echo "FAIL k3s refusal came after a host change"; fails=$((fails + 1));; esac +rm -rf "$compatdir" + # Worker admission reuses the installer but must never enter host control-plane setup. before "distributed host firewall runs the newly built binary" \ ' build_image' '"$HOST_BIN" node firewall --controller' "$(bsfn main)" diff --git a/docs/deferred-seams.md b/docs/deferred-seams.md index 4866e46..03118a2 100644 --- a/docs/deferred-seams.md +++ b/docs/deferred-seams.md @@ -31,19 +31,19 @@ A grep across `*.md` and `*.go` returns both sets; only the Go ones are seams. update` passes nil deliberately. The notification is the panel instead: `felis-update-check.timer` runs `felis update --record` daily on the host, which stores the report under `platform_settings.update_report`, and **Admin → Updates → - Component versions** shows it with the command that applies each update. -- `internal/updates/seams.go:43` — `Applier`. Nothing applies an update anywhere. A - nil applier is not silent — `Run` records `errNoApplier` against every planned - apply, so a mis-scheduled apply is loud rather than lost. + Component versions** shows it with the read-only command that prepares an update. +- `internal/updates/seams.go:43` — `Applier`. No unattended runner adapts this interface. + The host CLI implements explicit `felis update --apply` separately, using the target + installer after window checks and backup. A nil applier in the read-only runner + still records `errNoApplier` against a mistakenly scheduled apply. - `internal/updater/gatherer_integration.go:22` — the two current-version seams `NewSysGatherer` leaves nil, for the in-cluster path: the k8s read of the control-plane Deployment image, and the Velocity jar inspection. Both are answered on the host path (see "Built" below), so this gap is specific to a caller that has a cluster client instead of the node. -- `internal/api/handlers_updates.go` — the maintenance window is advisory: no - in-cluster runner applies updates. `felis update` reads the stored window, prints - where now sits against it and warns before an apply outside it; the runner itself - still runs with a zero window, so no path can claim an apply is under way. +- `internal/api/handlers_updates.go` — no in-cluster runner applies updates. The host + `felis update --apply` now consumes the stored window and refuses outside it unless + explicit `--now` starts manual maintenance. The check/record runner remains read-only. - `internal/submit/blobstore.go` — CLOSED 2026-09-22. The uploads PVC still cannot cross namespaces, so the transport went through the API instead of a mount: the derived context ref is now the internal-face URL diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 746e29d..53ada28 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -4185,15 +4185,12 @@ paths: operationId: getUpdateWindow summary: Read the SysAdmin-set auto-update maintenance window (admin). description: >- - Advisory: Felis applies no update on its own. `felis update` on the - host reads this window, reports where now sits against it, and warns - before an apply outside it. - The single platform-wide maintenance window during which Felis may apply a - Scheduled component's update to itself (decision core internal/updates). An - unset window — never set, or explicitly cleared — reads back as - {start:null,end:null}. API+persistence only: nothing consumes the window - until the INTEGRATION runner and executors are wired, so setting it changes - no behavior yet. + Felis applies no update on its own. `felis update` checks versions and + prints an explicit apply command. `felis update --apply` reads this + platform-wide [start,end) window before backup and before installation, + refusing outside it unless `--now` explicitly starts manual maintenance. + An unreadable window is always a refusal, including with `--now` or + `--force`. An unset window reads back as {start:null,end:null}. x-felis-face: [external] x-felis-tier: admin security: [{ sessionCookie: [] }] diff --git a/docs/operations.md b/docs/operations.md index 0c6f772..188a32a 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -501,8 +501,8 @@ store=/var/lib/rancher/k3s/storage ## 4. Upgrading the pieces around Felis -A rerun of the installer upgrades Felis itself (§15). The components it installs keep -the version they were installed with unless noted: +The host updater reuses the installer to reconcile Felis itself and its core components +(§15). The components it installs keep the version they were installed with unless noted: | Component | How a rerun treats it | Upgrade | |---|---|---| @@ -513,23 +513,54 @@ the version they were installed with unless noted: | PostgreSQL | follows the image the release pins | a minor release comes with a Felis release, and the rerun restarts felis-postgres on it (a few seconds without the API); a major version is a dump and restore (below) | | Docker, git, nftables | distribution packages | the package manager | +`felis update` is a read-only check. It reports upstream availability, resolves the +Felis target, downloads and syntax-checks its matching installer, and prints an explicit +apply command. The upstream table is advisory: applying uses the target's compatible +pins, rather than installing each component's newest upstream version independently. +`--k3s`, `--cloudflared`, `--jre` and `--postgres` narrow the report; applying any core +selector reconciles the whole platform bundle. `--all` also enables the release-pinned +k3s and cloudflared upgrades. Minecraft user server images stay pinned. + ```sh -curl -fsSL https://raw.githubusercontent.com/FelisMC/Felis/main/deploy/bootstrap.sh \ - | sudo FELIS_UPGRADE_DEPS=1 bash +sudo felis update # newest stable release; no installation +sudo felis update --all # also plan pinned host dependency upgrades +sudo felis update --version v0.2.0 # inspect a named published release +sudo felis update --dev # inspect the latest main commit +sudo felis update --ref # inspect a specific source tree ``` -`sudo felis update` reports Felis, Velocity, k3s, cloudflared, the JRE and PostgreSQL -against their newest releases; `--k3s`, `--cloudflared`, `--jre` and `--postgres` narrow -it to one. PostgreSQL is read from the felis-postgres container and compared within its -major, since a minor release arrives with a Felis release, and a major past its end of life -gets a note naming the current one. +Review the target, full commit, scope and restart impact, then run the exact `--apply` +command printed by the check. A source apply uses the full SHA, while a release apply +also includes `--expect-commit` so a moved tag is refused. `--apply --dev` is supported +for deliberately resolving main at execution time; the printed command pins the commit +you inspected instead. + +Set the maintenance window in **Admin → Updates** first. Application reads that window +before backup and again before installation: unset, future or expired windows refuse +application. `--apply --now` explicitly starts one-off manual maintenance instead; +an unreadable window is always refused. The daily `--record` timer never applies. +`--force` reinstalls the same version or permits an intentional Felis downgrade; it +never bypasses the window, backup or component compatibility guards. + +Before installation the running binary takes a database + `/etc/felis` + MinecraftServer +specification backup; failure stops the update. Worlds are covered separately by server +backups (§16), not this control-plane snapshot. Application then streams the existing +installer's progress, reconciles the CLI, API/operator/panel, manifests/RBAC, plugins, +proxy and system images, and verifies the installed binary's version. Installer rollout +checks still gate success. A failed installer can leave some components changed: retain +the pre-update backup and follow troubleshooting §15/§16; schema rollback is not automatic. +PostgreSQL major changes require dump/restore, and k3s upgrades cannot skip a minor version. + +Older host binaries whose `update -h` has no `--apply` need one installer run to acquire +this updater. Published assets are checksum-verified; older releases without the required +installer options must be selected through `--ref` for a source build instead. The installer also sets up `felis-update-check.timer`, which runs `felis update --record` once a day around 05:30 (and at boot after a missed run). `--record` stores the result in `platform_settings`, and the panel's **Admin → Updates → Component versions** card shows it: each component's installed and newest version, and for the ones with a newer release the `sudo felis update --` line that prints how to apply it. Felis -applies nothing on its own; the installer re-run above is the apply path. The card turns +applies nothing on its own; the explicit `--apply` command above is the apply path. The card turns red when the newest record is older than 26 hours, meaning the timer stopped: ```sh diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index dd07cae..5ed7773 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -2136,14 +2136,18 @@ annotated Service endpoints picks them up as is. ## 15. Control-plane upgrades, and rolling back a bad one -There is no in-place updater: an upgrade is re-running the installer -(`curl -fsSL | sudo bash`), which imports the release's images -(or rebuilds them on the host, operations §1 "Where the binary and the images come -from") and re-applies the bundle. `felis update --panel` prints that command with the -script read at the newest release's tag, so the installer and the binary it -downloads come from the same release. (`sudo felis setup` is not this path; on a completed -install it only opens the config console.) The channel is not persisted across -the re-run, so pass `FELIS_VERSION_BOOTSTRAP=dev` on a host that tracks main. +`felis update` checks only; it prints a target and explicit `--apply` command. The host +updater downloads the installer from the target's resolved full commit, backs up the +database and deployment state, then uses that installer to reconcile the CLI, core +services, plugins and system images. See operations §4 for `--version`, `--ref`, `--dev` +and `--all`. Use the exact printed command to retain the reviewed target. Application +requires an active maintenance window or explicit `--now`; `--force` never skips the +backup, an unreadable window or compatibility guards. + +A host whose `felis update -h` lacks `--apply` still needs one installer run +(`curl -fsSL | sudo bash`) to acquire the new CLI. `sudo felis setup` +on a completed install opens the configuration console, so it is not an upgrade path. +The updater reuses the same checksum, image import/build and rollout checks as that installer. Two properties of the control plane matter when you do: - Both Deployments use strategy **Recreate** (single replica, no leader election: diff --git a/internal/api/handlers_updates.go b/internal/api/handlers_updates.go index e6dc121..5aad81a 100644 --- a/internal/api/handlers_updates.go +++ b/internal/api/handlers_updates.go @@ -19,16 +19,14 @@ import ( // SINGLE GLOBAL WINDOW (deliberate). internal/updates models the window PER // Component (Component.Window), but this endpoint stores ONE platform-wide window. // The task scopes "the SysAdmin-set update Window" as a single maintenance slot, -// and the core's own comment defers recurrence to the caller — so the (not-yet- -// built, INTEGRATION-ONLY) `felis update` runner reads this one window and fans it -// out to every Scheduled+manageable component when it assembles its []Component. +// and the core's own comment defers recurrence to the caller. The host +// `felis update --apply` reads this window before backup and before installation. // A future need for per-component windows would layer keys on top; this is the // platform default. // -// Felis applies no update on its own, so the window is advisory. Its consumer -// is `felis update` on the host (cmd/felis/update.go readUpdateWindow), which -// reads this row, prints where now sits against it, and warns before an apply -// outside it. The panel's Updates page says the same. +// Felis applies no update on its own. `felis update` reports this window, and +// explicit --apply refuses outside it unless --now starts manual maintenance. +// An unreadable window is always a refusal, including for --now and --force. // updateWindowKey is the platform_settings key holding the maintenance window as // JSON {"start","end"} (RFC3339, or null when unset). It reuses the generic diff --git a/internal/updater/doc.go b/internal/updater/doc.go index 675fc55..32f6c97 100644 --- a/internal/updater/doc.go +++ b/internal/updater/doc.go @@ -47,8 +47,11 @@ // ON-HOST caller has both: NewHostGatherer (gatherer_host.go) answers felis-api from // the running binary's build stamp and Velocity from the installed jar's manifest, // and that is what the built `felis update` CLI runs on. Still absent: the concrete -// Notifier (SMTP + in-game) and Applier (control-plane image bump, cloudflared swap) -// — the CLI passes nil for both on purpose, so it reports and never applies — the +// Notifier (SMTP + in-game) and unattended Applier — the check/record runner +// passes nil for both. Explicit host application uses the target installer in +// cmd/felis/update_apply.go after window checks and a database/state backup. +// InstallerSource pins that script and the source checkout to a full commit; +// its HTTP boundaries and the host execution order are tested with fakes. Still absent: the // in-cluster CronJob entry point, and the runtime append of the live Pinned // Minecraft fleet. The scheduled check runs on the host instead: // felis-update-check.timer runs `felis update --record`, which stores the report diff --git a/internal/updater/github.go b/internal/updater/github.go index 812ad9f..8a40569 100644 --- a/internal/updater/github.go +++ b/internal/updater/github.go @@ -125,22 +125,29 @@ func parseStableTag(repo, tag string) (updates.Version, error) { // latestTag fetches the tag of repo's /releases/latest. func (g github) latestTag(ctx context.Context, repo string) (string, error) { - url := fmt.Sprintf("%s/repos/%s/releases/latest", g.baseURL, repo) - req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + return g.releaseTag(ctx, repo, "releases/latest") +} + +func (g github) get(ctx context.Context, path, accept string) (*http.Response, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, g.baseURL+path, nil) if err != nil { - return "", fmt.Errorf("github: build request for %s: %w", repo, err) + return nil, err } ua := g.userAgent if ua == "" { ua = defaultUserAgent } req.Header.Set("User-Agent", ua) - req.Header.Set("Accept", "application/vnd.github+json") + req.Header.Set("Accept", accept) if g.token != "" { req.Header.Set("Authorization", "Bearer "+g.token) } - resp, err := g.hc.Do(req) + return g.hc.Do(req) +} + +func (g github) releaseTag(ctx context.Context, repo, endpoint string) (string, error) { + resp, err := g.get(ctx, "/repos/"+repo+"/"+endpoint, "application/vnd.github+json") if err != nil { return "", fmt.Errorf("github: get %s: %w", repo, err) } @@ -150,15 +157,15 @@ func (g github) latestTag(ctx context.Context, repo string) (string, error) { // answering 401/403, so an unauthenticated miss and a repo with no stable release // are the same status. Name both causes, and name the fix for the one an operator // can act on. The official repository is public, so there only the first applies. - if resp.StatusCode == http.StatusNotFound && strings.EqualFold(repo, officialRepo) { + if endpoint == "releases/latest" && resp.StatusCode == http.StatusNotFound && strings.EqualFold(repo, officialRepo) { return "", fmt.Errorf("github: %s releases/latest returned HTTP 404 — it has no published stable release", repo) } if resp.StatusCode == http.StatusNotFound && g.token == "" { return "", fmt.Errorf( - "github: %s releases/latest returned HTTP 404 — either it has no published stable release, or it is private and %s is unset", - repo, tokenEnv) + "github: %s %s returned HTTP 404 — either no published stable release exists, or the repository is private and %s is unset", + repo, endpoint, tokenEnv) } - return "", fmt.Errorf("github: %s releases/latest returned HTTP %d", repo, resp.StatusCode) + return "", fmt.Errorf("github: %s %s returned HTTP %d", repo, endpoint, resp.StatusCode) } var rr releaseResponse @@ -166,7 +173,7 @@ func (g github) latestTag(ctx context.Context, repo string) (string, error) { return "", fmt.Errorf("github: decode %s: %w", repo, err) } if rr.Draft || rr.Prerelease { - return "", fmt.Errorf("github: %s releases/latest is unexpectedly draft/prerelease (tag %q)", repo, rr.TagName) + return "", fmt.Errorf("github: %s %s is unexpectedly draft/prerelease (tag %q)", repo, endpoint, rr.TagName) } return rr.TagName, nil diff --git a/internal/updater/installer.go b/internal/updater/installer.go new file mode 100644 index 0000000..13c7101 --- /dev/null +++ b/internal/updater/installer.go @@ -0,0 +1,117 @@ +package updater + +import ( + "context" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "regexp" + "strings" +) + +var releaseTagPattern = regexp.MustCompile(`^v[0-9]+\.[0-9]+\.[0-9]+$`) +var githubRepoPattern = regexp.MustCompile(`^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$`) + +// InstallTarget pins both the installer and the source tree to one revision. +// Release is set only when installing checksum-verified published assets. +type InstallTarget struct { + Release string + Revision string + Script string +} + +// InstallerSource uses the same GitHub client and token as release discovery. +type InstallerSource struct { + github github + repo string +} + +func NewInstallerSource(repoURL string) (InstallerSource, error) { + repo := officialRepo + if repoURL != "" { + if strings.HasPrefix(repoURL, "git@github.com:") { + repo = strings.TrimPrefix(repoURL, "git@github.com:") + } else { + u, err := url.Parse(repoURL) + if err != nil || u.Hostname() != "github.com" || (u.Scheme != "https" && u.Scheme != "ssh") || u.RawQuery != "" || u.Fragment != "" { + return InstallerSource{}, fmt.Errorf("FELIS_REPO_URL must name a GitHub repository over HTTPS or SSH") + } + repo = strings.TrimPrefix(u.Path, "/") + } + repo = strings.TrimSuffix(strings.TrimSuffix(repo, "/"), ".git") + if !githubRepoPattern.MatchString(repo) || strings.Contains(repo, "..") { + return InstallerSource{}, fmt.Errorf("FELIS_REPO_URL must name a GitHub owner/repository") + } + } + return InstallerSource{github: newGitHub(), repo: repo}, nil +} + +func (s InstallerSource) RepoURL() string { return "https://github.com/" + s.repo + ".git" } + +// Prepare downloads the complete script before any host changes. A moving ref +// (including main) is resolved once; the installer receives that same full SHA. +func (s InstallerSource) Prepare(ctx context.Context, release, ref string) (InstallTarget, error) { + if ref == "" { + endpoint := "releases/latest" + if release != "" { + endpoint = "releases/tags/" + url.PathEscape(release) + } + tag, err := s.github.releaseTag(ctx, s.repo, endpoint) + if err != nil { + return InstallTarget{}, err + } + if !releaseTagPattern.MatchString(tag) || (release != "" && tag != release) { + return InstallTarget{}, fmt.Errorf("unexpected Felis release tag %q", tag) + } + release, ref = tag, tag + } + resp, err := s.github.get(ctx, "/repos/"+s.repo+"/commits/"+url.PathEscape(ref), "application/vnd.github+json") + if err != nil { + return InstallTarget{}, err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return InstallTarget{}, fmt.Errorf("github: resolve ref %q: HTTP %d", ref, resp.StatusCode) + } + var commit struct { + SHA string `json:"sha"` + } + if err := json.NewDecoder(resp.Body).Decode(&commit); err != nil { + return InstallTarget{}, fmt.Errorf("github: decode commit: %w", err) + } + if _, err := hex.DecodeString(commit.SHA); err != nil || len(commit.SHA) != 40 { + return InstallTarget{}, fmt.Errorf("github: ref %q did not resolve to a full commit SHA", ref) + } + commit.SHA = strings.ToLower(commit.SHA) + if _, err := hex.DecodeString(ref); err == nil && len(ref) == 40 && !strings.EqualFold(ref, commit.SHA) { + return InstallTarget{}, fmt.Errorf("github: resolved commit %s differs from requested %s", commit.SHA, ref) + } + script, err := s.script(ctx, commit.SHA) + if err != nil { + return InstallTarget{}, err + } + return InstallTarget{Release: release, Revision: commit.SHA, Script: script}, nil +} + +func (s InstallerSource) script(ctx context.Context, revision string) (string, error) { + resp, err := s.github.get(ctx, "/repos/"+s.repo+"/contents/deploy/bootstrap.sh?ref="+revision, "application/vnd.github.raw+json") + if err != nil { + return "", err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("github: download installer at %s: HTTP %d", revision, resp.StatusCode) + } + const limit = 2 << 20 + raw, err := io.ReadAll(io.LimitReader(resp.Body, limit+1)) + if err != nil { + return "", fmt.Errorf("github: read installer: %w", err) + } + if len(raw) > limit || !strings.HasPrefix(string(raw), "#!/") { + return "", fmt.Errorf("github: installer is oversized or is not a shell script") + } + return string(raw), nil +} diff --git a/internal/updater/installer_test.go b/internal/updater/installer_test.go new file mode 100644 index 0000000..b874ad9 --- /dev/null +++ b/internal/updater/installer_test.go @@ -0,0 +1,126 @@ +package updater + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "reflect" + "strings" + "testing" +) + +const installerTestSHA = "0123456789abcdef0123456789abcdef01234567" + +func TestInstallerResolvesMovingRefsOnceAndPinsTheScript(t *testing.T) { + for _, tc := range []struct{ name, release, ref, releasePath, commitRef string }{ + {"latest release", "", "", "releases/latest", "v0.2.0"}, + {"named release", "v0.2.0", "", "releases/tags/v0.2.0", "v0.2.0"}, + {"main", "", "main", "", "main"}, + {"branch with slash", "", "feature/example", "", "feature/example"}, + {"exact commit", "", installerTestSHA, "", installerTestSHA}, + } { + t.Run(tc.name, func(t *testing.T) { + var calls []string + const script = "#!/bin/bash\n# FELIS_RELEASE\necho example\n" + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" || r.Header.Get("Authorization") != "Bearer private-token" || r.Header.Get("User-Agent") == "" { + t.Errorf("unexpected request headers/method: %v", r) + } + calls = append(calls, r.URL.Path) + switch r.URL.Path { + case "/repos/example/Felis/" + tc.releasePath: + fmt.Fprint(w, `{"tag_name":"v0.2.0"}`) + case "/repos/example/Felis/commits/" + tc.commitRef: + fmt.Fprintf(w, `{"sha":%q}`, installerTestSHA) + case "/repos/example/Felis/contents/deploy/bootstrap.sh": + if r.URL.Query().Get("ref") != installerTestSHA || r.Header.Get("Accept") != "application/vnd.github.raw+json" { + t.Errorf("script not pinned/raw: %v", r) + } + fmt.Fprint(w, script) + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + gh := newTestGitHub(srv) + gh.token = "private-token" + target, err := (InstallerSource{github: gh, repo: "example/Felis"}).Prepare(context.Background(), tc.release, tc.ref) + if err != nil { + t.Fatal(err) + } + wantRelease := "" + wantCalls := []string{} + if tc.releasePath != "" { + wantRelease = "v0.2.0" + wantCalls = append(wantCalls, "/repos/example/Felis/"+tc.releasePath) + } + wantCalls = append(wantCalls, "/repos/example/Felis/commits/"+tc.commitRef, "/repos/example/Felis/contents/deploy/bootstrap.sh") + if target.Release != wantRelease || target.Revision != installerTestSHA || target.Script != script || !reflect.DeepEqual(calls, wantCalls) { + t.Fatalf("target %+v, calls %v; want %v", target, calls, wantCalls) + } + }) + } +} + +func TestInstallerFailsClosedBeforeExecutingUnusableTargets(t *testing.T) { + for _, tc := range []struct { + name, releaseBody, commitBody, script string + status int + }{ + {"prerelease", `{"tag_name":"v0.2.0","prerelease":true}`, "", "", 200}, + {"invalid tag", `{"tag_name":"latest"}`, "", "", 200}, + {"short commit", `{"tag_name":"v0.2.0"}`, `{"sha":"abcdef0"}`, "", 200}, + {"invalid commit", `{"tag_name":"v0.2.0"}`, `{"sha":"zz23456789abcdef0123456789abcdef01234567"}`, "", 200}, + {"non-script response", `{"tag_name":"v0.2.0"}`, fmt.Sprintf(`{"sha":%q}`, installerTestSHA), "unavailable", 200}, + {"oversized script", `{"tag_name":"v0.2.0"}`, fmt.Sprintf(`{"sha":%q}`, installerTestSHA), "#!/bin/bash\n" + strings.Repeat("#", 2<<20), 200}, + {"unavailable", "", "", "", 503}, + } { + t.Run(tc.name, func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(tc.status) + switch { + case strings.Contains(r.URL.Path, "/releases/"): + fmt.Fprint(w, tc.releaseBody) + case strings.Contains(r.URL.Path, "/commits/"): + fmt.Fprint(w, tc.commitBody) + default: + fmt.Fprint(w, tc.script) + } + })) + defer srv.Close() + got, err := (InstallerSource{github: newTestGitHub(srv), repo: officialRepo}).Prepare(context.Background(), "", "") + if err == nil || got != (InstallTarget{}) { + t.Fatalf("unsafe target %+v, err %v", got, err) + } + }) + } +} + +func TestInstallerAcceptsGitHubReposWithoutEmbeddingCredentials(t *testing.T) { + for _, repoURL := range []string{"", "https://github.com/FelisMC/Felis.git", "git@github.com:FelisMC/Felis.git", "ssh://git@github.com/FelisMC/Felis.git"} { + s, err := NewInstallerSource(repoURL) + if err != nil || s.RepoURL() != "https://github.com/FelisMC/Felis.git" { + t.Errorf("%s: %v, %+v", repoURL, err, s) + } + } + for _, repoURL := range []string{"https://example.com/FelisMC/Felis", "http://github.com/FelisMC/Felis", "https://github.com/FelisMC/Felis?token=secret", "git@github.com:../../bad"} { + if _, err := NewInstallerSource(repoURL); err == nil { + t.Errorf("accepted %s", repoURL) + } + } +} + +func TestInstallerRefusesACommitDifferentFromTheRequestedSHA(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.Contains(r.URL.Path, "/contents/") { + t.Error("mismatched commit must not reach installer download") + } + fmt.Fprintf(w, `{"sha":%q}`, strings.Repeat("a", 40)) + })) + defer srv.Close() + target, err := (InstallerSource{github: newTestGitHub(srv), repo: officialRepo}).Prepare(context.Background(), "", installerTestSHA) + if err == nil || target != (InstallTarget{}) { + t.Fatalf("accepted a different commit: %+v / %v", target, err) + } +} diff --git a/panel/src/i18n/resources/en-US/admin.json b/panel/src/i18n/resources/en-US/admin.json index 77d0f21..d014e70 100644 --- a/panel/src/i18n/resources/en-US/admin.json +++ b/panel/src/i18n/resources/en-US/admin.json @@ -90,9 +90,9 @@ "reviewed_at": "Reviewed At", "reject_reason": "Rejection Reason", "updates_title": "Maintenance & Backups", - "updates_subtitle": "Check that the control-plane database backup is fresh, see which components have a newer release, and set the platform-wide maintenance window. Felis never applies an update on its own: `felis update` on the host shows this window and warns before you apply outside it.", - "updates_window_advisory": "The window is advisory. Updates happen only when someone runs the apply commands `felis update` prints; it reads this window and warns when run outside it.", - "updates_current_unset": "No maintenance window set. `felis update` will say so and leave the timing to you.", + "updates_subtitle": "Check database backup freshness, review available versions, and set a maintenance window. Run `felis update` on the host to inspect a target; only an explicit `--apply` installs it.", + "updates_window_advisory": "Apply is allowed inside this window. The host checks it before taking a backup and again before installation. Outside the window, apply is refused unless `--now` explicitly starts manual maintenance; `--force` does not bypass the checks.", + "updates_current_unset": "No maintenance window set. Configure one before applying, or explicitly use `--apply --now` for manual maintenance.", "updates_start_label": "Start Time", "updates_end_label": "End Time", "updates_set_title": "Configure Maintenance Window", @@ -159,7 +159,7 @@ "versions_state_unknown": "Feed unreachable", "versions_state_unreadable": "Version unreadable", "versions_state_pinned": "Pinned", - "versions_apply_hint": "To apply, run this on the host, inside the maintenance window below if you set one. It prints the exact command for each component and warns when run outside the window:", + "versions_apply_hint": "Run this read-only check on the host. It shows the target, scope and an exact `--apply` command. Review it, then run that command during the maintenance window:", "versions_never_title": "No version check has been recorded yet", "versions_stale_title": "The newest version check is more than {{hours}} hours old", "versions_fix_hint": "The versions below may be out of date. Run a check on the host now, then read the timer's log to find out why it did not run:", diff --git a/panel/src/i18n/resources/zh-CN/admin.json b/panel/src/i18n/resources/zh-CN/admin.json index a7742fb..1a6fcce 100644 --- a/panel/src/i18n/resources/zh-CN/admin.json +++ b/panel/src/i18n/resources/zh-CN/admin.json @@ -90,9 +90,9 @@ "reviewed_at": "审核时间", "reject_reason": "驳回理由", "updates_title": "维护与备份", - "updates_subtitle": "查看控制面数据库备份是否新鲜、哪些组件有新版本,并设置全局维护窗口。Felis 从不自行应用更新:宿主机上的 `felis update` 会显示这个窗口,在窗口外应用前给出警告。", - "updates_window_advisory": "维护窗口是提示性的。只有有人执行 `felis update` 打印的应用命令时才会更新;该命令会读取这个窗口,在窗口外运行时给出警告。", - "updates_current_unset": "当前未设置维护窗口。`felis update` 会提示这一点,何时应用由你决定。", + "updates_subtitle": "查看数据库备份和组件版本,并设置维护窗口。在宿主机运行 `felis update` 检查目标版本;只有显式执行 `--apply` 才会更新。", + "updates_window_advisory": "更新默认只允许在维护窗口内开始。宿主机会在备份前和安装前再次检查窗口;窗口外拒绝执行。临时维护需显式添加 `--now`,`--force` 不会跳过这些检查。", + "updates_current_unset": "尚未设置维护窗口。请先设置窗口,或显式使用 `--apply --now` 开始临时维护。", "updates_start_label": "开始时间", "updates_end_label": "结束时间", "updates_set_title": "配置维护窗口", @@ -158,7 +158,7 @@ "versions_state_unknown": "无法访问发行源", "versions_state_unreadable": "读不到版本", "versions_state_pinned": "已固定", - "versions_apply_hint": "要应用更新,请在主机上运行下面的命令;如果设置了维护窗口,请在窗口内运行。它会列出每个组件的确切命令,并在窗口外运行时发出警告:", + "versions_apply_hint": "在宿主机执行下面的只读检查,查看更新目标、范围和确切的 `--apply` 命令。确认后,在维护窗口内执行该命令:", "versions_never_title": "还没有记录过版本检查", "versions_stale_title": "最近一次版本检查已超过 {{hours}} 小时", "versions_fix_hint": "下面的版本可能已过时。请先在主机上立即检查一次,再查看定时器日志找出它没有运行的原因:", diff --git a/panel/src/lib/openapi.gen.ts b/panel/src/lib/openapi.gen.ts index 89a593b..de1fa80 100644 --- a/panel/src/lib/openapi.gen.ts +++ b/panel/src/lib/openapi.gen.ts @@ -1232,7 +1232,7 @@ export interface paths { }; /** * Read the SysAdmin-set auto-update maintenance window (admin). - * @description Advisory: Felis applies no update on its own. `felis update` on the host reads this window, reports where now sits against it, and warns before an apply outside it. The single platform-wide maintenance window during which Felis may apply a Scheduled component's update to itself (decision core internal/updates). An unset window — never set, or explicitly cleared — reads back as {start:null,end:null}. API+persistence only: nothing consumes the window until the INTEGRATION runner and executors are wired, so setting it changes no behavior yet. + * @description Felis applies no update on its own. `felis update` checks versions and prints an explicit apply command. `felis update --apply` reads this platform-wide [start,end) window before backup and before installation, refusing outside it unless `--now` explicitly starts manual maintenance. An unreadable window is always a refusal, including with `--now` or `--force`. An unset window reads back as {start:null,end:null}. */ get: operations["getUpdateWindow"]; /** diff --git a/panel/src/pages/admin/UpdatesPage.test.tsx b/panel/src/pages/admin/UpdatesPage.test.tsx index aa24dfc..d60535d 100644 --- a/panel/src/pages/admin/UpdatesPage.test.tsx +++ b/panel/src/pages/admin/UpdatesPage.test.tsx @@ -24,7 +24,7 @@ afterEach(() => { }); describe("UpdatesPage", () => { - it("says the window is advisory, since nothing applies an update on its own", async () => { + it("explains the maintenance guard and explicit apply flow", async () => { render( @@ -32,10 +32,10 @@ describe("UpdatesPage", () => { ); expect( await screen.findByText( - "The window is advisory. Updates happen only when someone runs the apply commands `felis update` prints; it reads this window and warns when run outside it.", + "Apply is allowed inside this window. The host checks it before taking a backup and again before installation. Outside the window, apply is refused unless `--now` explicitly starts manual maintenance; `--force` does not bypass the checks.", ), ).toBeTruthy(); - expect(screen.getByText("No maintenance window set. `felis update` will say so and leave the timing to you.")).toBeTruthy(); + expect(screen.getByText("No maintenance window set. Configure one before applying, or explicitly use `--apply --now` for manual maintenance.")).toBeTruthy(); expect(screen.queryByText(/applied automatically|may apply a Scheduled update/)).toBeNull(); }); });