Files
Felis/cmd/felis/update_apply.go

246 lines
9.2 KiB
Go

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()
}