Files
Felis/cmd/felis/update.go
T

444 lines
16 KiB
Go

package main
import (
"context"
"encoding/json"
"errors"
"flag"
"fmt"
"io"
"os"
"os/exec"
"os/signal"
"sort"
"strings"
"syscall"
"time"
"github.com/jackc/pgx/v5"
"felis.lolicon.best/internal/config"
"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 report selectors onto the components tracked by the planner.
// Application always reuses bootstrap.sh for the compatible platform bundle.
type updateTarget struct {
selector string
help string
component string
note string // explanation for the untracked Minecraft selector
}
// 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",
help: "select the panel + control plane (felis-api)",
component: "felis-api",
},
{
selector: "self",
help: "select the Felis binary and its core services",
component: "felis-api",
},
{
selector: "velocity",
help: "select the Velocity proxy",
component: "velocity",
},
{
selector: "plugins",
help: "select the Felis plugin jars (velocity/paper/limbo)",
component: "felis-api",
},
{
selector: "k3s",
help: "select k3s",
component: "k3s",
},
{
selector: "cloudflared",
help: "select cloudflared",
component: "cloudflared",
},
{
selector: "jre",
help: "select the Temurin JRE Velocity runs on",
component: "jre",
},
{
selector: "postgres",
help: "select PostgreSQL",
component: "postgresql",
},
{
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",
},
}
// 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)
flags := map[string]*bool{}
for _, t := range updateTargets {
flags[t.selector] = fs.Bool(t.selector, false, t.help)
}
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
}
if fs.NArg() > 0 {
fmt.Fprintf(stderr, "felis update: unexpected argument %q (this command takes flags only)\n", fs.Arg(0))
return 2
}
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 || opts.all {
opts.selected[sel] = true
}
}
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
}
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
}
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 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, 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.Fprintln(stdout, "Recorded this check for the panel's Updates page.")
}
return 0
}
// buildStatusReport turns one run into the record the panel shows: every planned
// component in plan order, then each component whose installed version could not
// be read, by name. A component the feed could not answer for is StateUnknown with
// the reason, never StateCurrent: the panel must not call a component current when
// nobody could check.
func buildStatusReport(res updater.Result, notes map[string]string, felis string, now time.Time) updates.StatusReport {
selectorOf := map[string]string{}
for _, t := range updateTargets {
if t.component != "" && selectorOf[t.component] == "" {
selectorOf[t.component] = t.selector
}
}
rep := updates.StatusReport{CheckedAt: now.UTC(), Felis: felis, Components: []updates.ComponentStatus{}}
for _, a := range res.RunResult.Plan {
cs := updates.ComponentStatus{
Name: a.Component,
Current: a.Current.String(),
Selector: selectorOf[a.Component],
Note: notes[a.Component],
}
switch {
case a.Kind == updates.ActionPinned:
cs.State = updates.StatePinned
case a.Kind == updates.ActionNotify || a.Kind == updates.ActionApply:
cs.State = updates.StateAvailable
cs.Latest = a.Latest.String()
case a.LatestKnown:
cs.State = updates.StateCurrent
default:
cs.State = updates.StateUnknown
if err := res.RunResult.SourceErrors[a.Component]; err != nil {
cs.Error = err.Error()
}
}
rep.Components = append(rep.Components, cs)
}
names := make([]string, 0, len(res.GatherErrors))
for name := range res.GatherErrors {
names = append(names, name)
}
sort.Strings(names)
for _, name := range names {
rep.Components = append(rep.Components, updates.ComponentStatus{
Name: name,
State: updates.StateUnreadable,
Selector: selectorOf[name],
Note: notes[name],
Error: res.GatherErrors[name].Error(),
})
}
return rep
}
// recordUpdateStatus upserts rep into platform_settings[updates.StatusKey], the
// row the API serves to the panel's Updates page.
func recordUpdateStatus(ctx context.Context, cfgPath string, rep updates.StatusReport) error {
cfg, err := config.Load(cfgPath)
if err != nil {
return err
}
v, err := json.Marshal(rep)
if err != nil {
return err
}
conn, err := pgx.Connect(ctx, cfg.Database.URL)
if err != nil {
return err
}
defer conn.Close(context.Background())
_, err = conn.Exec(ctx, `INSERT INTO platform_settings (key, value) VALUES ($1, $2::jsonb)
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = now()`, updates.StatusKey, string(v))
return err
}
// 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()
}
// renderNotes prints what the release lookups learned beyond the versions (today: a
// PostgreSQL major past its end of life), for the components the selectors show.
func renderNotes(notes map[string]string, selected map[string]bool) string {
names := make([]string, 0, len(notes))
for name := range notes {
if len(selected) == 0 || selectedCovers(selected, name) {
names = append(names, name)
}
}
sort.Strings(names)
var b strings.Builder
for _, name := range names {
fmt.Fprintf(&b, "%-13s note: %s\n", name, notes[name])
}
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
}
// 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 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 {
return updates.Window{}, err
}
ctx, cancel := context.WithTimeout(ctx, updateWindowTimeout)
defer cancel()
conn, err := pgx.Connect(ctx, cfg.Database.URL)
if err != nil {
return updates.Window{}, err
}
defer conn.Close(context.Background())
var raw []byte
err = conn.QueryRow(ctx, `SELECT value FROM platform_settings WHERE key = 'update_window'`).Scan(&raw)
if errors.Is(err, pgx.ErrNoRows) {
return updates.Window{}, nil
}
if err != nil {
return updates.Window{}, err
}
var w updates.Window
if err := json.Unmarshal(raw, &w); err != nil {
return updates.Window{}, fmt.Errorf("stored window: %w", err)
}
return w, nil
}
// renderWindowLine is the report's first line: where now sits against the
// maintenance window.
func renderWindowLine(w updates.Window, err error, now time.Time) string {
const layout = "2006-01-02 15:04 MST"
switch {
case err != nil:
return fmt.Sprintf("Maintenance window: unknown (%v).\n", err)
case w.Start.IsZero() || w.End.IsZero():
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. 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; apply is blocked; set a new window in the panel or explicitly use --now.\n", w.End.Local().Format(layout))
}
}