package main import ( "context" "flag" "fmt" "io" "sort" "strings" "time" "felis.lolicon.best/internal/updater" "felis.lolicon.best/internal/updates" ) // updateTimeout bounds the whole discovery pass. Each release source already caps // its own HTTP client, but a hung DNS or a stalled TLS handshake would otherwise // 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. type updateTarget struct { // selector is the flag name without dashes. selector string // component is the updates planner's name for this piece, or "" when the planner // deliberately does not track it (Minecraft, which is pinned). component string // note explains what this selector covers, printed above the command. note string // command is the exact, already-tested way to apply it. command string } // installerRerun is the tested apply path for every planner-backed selector: 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 same one-liner both READMEs hand out. While the repo is private it // answers 404 (raw.githubusercontent.com hides private repos), which is why the trailer // below points at the README's token'd form for that case. const installerRerun = "curl -fsSL https://raw.githubusercontent.com/FelisMC/Felis/main/deploy/bootstrap.sh | sudo 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 // same bootstrap run, so all three move together and carry one version. var updateTargets = []updateTarget{ { selector: "panel", 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, }, { selector: "velocity", component: "velocity", note: "re-runs install_velocity: newest BUILD of the pinned minor (FELIS_VELOCITY_VERSION), atomic jar install, then restarts felis-velocity", command: installerRerun, }, { selector: "plugins", 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, }, { selector: "mc", 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/--mc/--plugins/--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. The versions it reads come from this host: k3s and cloudflared // answer `--version`, Velocity's version is read out of the installed jar's // manifest, and felis-api's is this binary's own build stamp — the same value // `felis version` prints, which is what the user asked to be the source of truth. func cmdUpdate(args []string, stdout, stderr io.Writer) int { fs := flag.NewFlagSet("update", flag.ContinueOnError) fs.SetOutput(stderr) panel := fs.Bool("panel", false, "select the panel + control plane (felis-api)") velocity := fs.Bool("velocity", false, "select the Velocity proxy") mc := fs.Bool("mc", false, "select Minecraft (pinned; reported only)") plugins := fs.Bool("plugins", false, "select the Felis plugin jars (velocity/paper/limbo)") 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") if err := fs.Parse(args); err != nil { return 2 } if fs.NArg() > 0 { fmt.Fprintf(stderr, "felis update: unexpected argument %q (this command takes flags only)\n", fs.Arg(0)) return 2 } selected := map[string]bool{} for sel, on := range map[string]bool{"panel": *panel, "velocity": *velocity, "mc": *mc, "plugins": *plugins} { if on || *all { selected[sel] = true } } ctx, cancel := context.WithTimeout(context.Background(), updateTimeout) defer cancel() rn := &updater.Runner{ Gatherer: updater.NewHostGatherer(resolvedVersion(), *velocityJar), Source: updater.NewRoutingSource(updater.Topology()), // Notifier and Applier stay nil on purpose: a human typing this command IS the // notification, and nothing here applies. The zero Window below means every // Scheduled component degrades to a notify, so the report can never claim an // apply is under way. } res, err := rn.Run(ctx, time.Now(), updates.Window{}) if err != nil { fmt.Fprintf(stderr, "felis update: %v\n", err) return 1 } fmt.Fprint(stdout, renderUpdateReport(res, selected)) if len(selected) > 0 { fmt.Fprint(stdout, renderApplyGuidance(res, selected, *force)) } return 0 } // renderUpdateReport renders the component status table. With no selectors it shows // every tracked component; with selectors it shows only the components those // selectors name, so `felis update --velocity` is a focused answer rather than the // whole platform. Components whose current version could not be read are listed // separately rather than silently dropped — a component missing from the table with // no explanation reads as "fine", which is the one thing it is not. func renderUpdateReport(res updater.Result, selected map[string]bool) string { plan := res.RunResult.Plan if len(selected) > 0 { want := map[string]bool{} for _, t := range updateTargets { if selected[t.selector] && t.component != "" { want[t.component] = true } } filtered := make([]updates.Action, 0, len(plan)) for _, a := range plan { if want[a.Component] { filtered = append(filtered, a) } } plan = filtered } var b strings.Builder if len(plan) > 0 { b.WriteString(updates.Report(plan)) } // Two different failures both end up as a component the report cannot speak to, // and both are printed rather than swallowed. A component that simply vanishes // from the table reads as "fine", and a bare "latest unknown" line reads as "there // is nothing newer" — when the truth may be that the release feed was unreachable. // Naming the cause is the difference between a report and a guess. writeErrs(&b, "current version unreadable", res.GatherErrors, selected) writeErrs(&b, "latest version undiscoverable", res.RunResult.SourceErrors, selected) // With selectors active an empty table is not a surprise — `--mc` names a component // the planner deliberately does not track — and the guidance below already explains // it, so stay quiet rather than printing a bare "nothing matched" that reads as an // error. Without selectors an empty table means nothing is tracked at all, which // does need saying. if b.Len() == 0 && len(selected) == 0 { return "No components tracked.\n" } return b.String() } // writeErrs appends one explanatory line per failed component, in a stable order so // the output does not shuffle between runs, honouring the active selector filter. func writeErrs(b *strings.Builder, label string, errs map[string]error, selected map[string]bool) { names := make([]string, 0, len(errs)) for name := range errs { names = append(names, name) } sort.Strings(names) for _, name := range names { if len(selected) > 0 && !selectedCovers(selected, name) { continue } fmt.Fprintf(b, "%-13s %s: %v\n", name, label, errs[name]) } } // selectedCovers reports whether any chosen selector maps to this component. func selectedCovers(selected map[string]bool, component string) bool { for _, t := range updateTargets { if selected[t.selector] && t.component == component { return true } } 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 offeredCommand 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", t.command) offeredCommand = true } // 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 all three components and there is no per-component exception left // to scope. Two caveats stay because following the advice without them bites real // hosts: the channel is not persisted anywhere (a bare re-run on a main host quietly // moves it onto releases), and the private repo's one-liner needs the read token // back in the environment before it can resolve anything. if offeredCommand { b.WriteString("\nRe-running the installer applies everything 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. While\nthis repo is private, the one-liner above 404s without a token; the README's install\nsection has the token'd form that works. felis setup is not this path: on a completed\ninstall it opens the config console and installs nothing newer. Restart game servers\nafterwards.\n") } return b.String() }