Unverified Commit 7f160e2f authored by Lemon-miaow's avatar Lemon-miaow
Browse files

feat(update): 主机每日记录组件版本比对,面板更新页展示可用更新与应用命令

parent 24373823
Loading
Loading
Loading
Loading
+85 −0
Changes for cmd/felis/update.go: 85 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -165,6 +165,7 @@ func cmdUpdate(args []string, stdout, stderr io.Writer) int {
	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)")
	if err := fs.Parse(args); err != nil {
		return 2
	}
@@ -209,9 +210,93 @@ func cmdUpdate(args []string, stdout, stderr io.Writer) int {
		}
		fmt.Fprint(stdout, renderApplyGuidance(res, selected, *force))
	}
	if *record {
		// A fresh context: the discovery pass may have spent most of updateTimeout.
		rctx, rcancel := context.WithTimeout(context.Background(), updateWindowTimeout)
		defer rcancel()
		if err := recordUpdateStatus(rctx, *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")
	}
	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
+52 −0
Changes for cmd/felis/update_test.go: 52 added lines, 0 removed lines.
Original line number Diff line number Diff line
package main

import (
	"encoding/json"
	"errors"
	"strings"
	"testing"
	"time"

	"felis.lolicon.best/internal/updater"
	"felis.lolicon.best/internal/updates"
@@ -279,3 +281,53 @@ func TestInstallerRefNamesATag(t *testing.T) {
		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)
	if err != nil {
		t.Fatalf("parse %q: %v", s, err)
	}
	return v
}

// The record the panel shows keeps every component with a state that cannot be
// mistaken: a feed failure is "unknown" with its reason, an unreadable install is
// listed after the plan, and each row carries the selector that prints its apply.
func TestBuildStatusReport(t *testing.T) {
	res := planResult([]updates.Action{
		{Component: "felis-api", Current: mustVersion(t, "v0.4.0"), Latest: mustVersion(t, "v0.5.0"), LatestKnown: true, Kind: updates.ActionNotify},
		{Component: "velocity", Current: mustVersion(t, "3.4.0"), Latest: mustVersion(t, "3.4.0"), LatestKnown: true, Kind: updates.ActionNone},
		{Component: "k3s", Current: mustVersion(t, "v1.36.2+k3s1"), Kind: updates.ActionNone},
		{Component: "cloudflared", Current: mustVersion(t, "2026.6.1"), Latest: mustVersion(t, "2026.9.0"), LatestKnown: true, Kind: updates.ActionApply},
		{Component: "mc-lobby", Current: mustVersion(t, "1.21.4"), Kind: updates.ActionPinned},
	})
	res.RunResult.SourceErrors["k3s"] = errors.New("github: HTTP 403")
	res.GatherErrors["postgresql"] = errors.New("psql: not found")
	res.GatherErrors["jre"] = errors.New("release file missing")
	notes := map[string]string{"postgresql": "PostgreSQL 13 is past its end of life", "velocity": "pinned minor 3.4"}
	now := time.Date(2026, 9, 25, 3, 4, 5, 0, time.FixedZone("CST", 8*3600))

	b, err := json.Marshal(buildStatusReport(res, notes, "v0.4.0", now))
	if err != nil {
		t.Fatal(err)
	}
	want := `{"checked_at":"2026-09-24T19:04:05Z","felis":"v0.4.0","components":[` +
		`{"name":"felis-api","current":"v0.4.0","latest":"v0.5.0","state":"available","selector":"panel"},` +
		`{"name":"velocity","current":"3.4.0","state":"current","selector":"velocity","note":"pinned minor 3.4"},` +
		`{"name":"k3s","current":"v1.36.2+k3s1","state":"unknown","selector":"k3s","error":"github: HTTP 403"},` +
		`{"name":"cloudflared","current":"2026.6.1","latest":"2026.9.0","state":"available","selector":"cloudflared"},` +
		`{"name":"mc-lobby","current":"1.21.4","state":"pinned"},` +
		`{"name":"jre","state":"unreadable","selector":"jre","error":"release file missing"},` +
		`{"name":"postgresql","state":"unreadable","selector":"postgres","note":"PostgreSQL 13 is past its end of life","error":"psql: not found"}]}`
	if string(b) != want {
		t.Errorf("status report =\n%s\nwant\n%s", b, want)
	}

	// Nothing tracked still records an empty list, so the panel can tell "checked,
	// nothing to show" from a report that never arrived.
	b, _ = json.Marshal(buildStatusReport(planResult(nil), nil, "v0.4.0", now))
	if !strings.Contains(string(b), `"components":[]`) {
		t.Errorf("an empty check = %s, want an empty components list", b)
	}
}
+44 −0
Changes for deploy/bootstrap.sh: 44 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -346,6 +346,8 @@ DB_BACKUP_SERVICE="/etc/systemd/system/felis-db-backup.service"
DB_BACKUP_TIMER="/etc/systemd/system/felis-db-backup.timer"
WATCHDOG_SERVICE="/etc/systemd/system/felis-watchdog.service"
WATCHDOG_TIMER="/etc/systemd/system/felis-watchdog.timer"
UPDATE_CHECK_SERVICE="/etc/systemd/system/felis-update-check.service"
UPDATE_CHECK_TIMER="/etc/systemd/system/felis-update-check.timer"
WATCHDOG_STATE="/var/lib/felis/watchdog/state.json"
OFFSITE_ENV="${STATE_DIR}/offsite.env"
OFFSITE_SERVICE="/etc/systemd/system/felis-offsite.service"
@@ -3262,6 +3264,46 @@ summary_offsite() {
# memory, and mails the owners (their verified addresses, over the [smtp] relay) what
# has stayed wrong long enough to matter. It runs on the host so a k3s that is down is
# still reported. The first run happens now, so a broken unit shows up in this install.
# The daily version check. Felis applies no update on its own; `felis update --record`
# compares what this host runs with the newest upstream releases and stores the result,
# which the panel's Updates page shows with the command that applies each update. It runs
# on the host because that is where the installed versions are readable. The first check
# runs in the background: it waits on the release feeds, and nothing in the install
# depends on it.
install_update_check_timer() {
  cat > "$UPDATE_CHECK_SERVICE" <<EOF
[Unit]
Description=Felis component version check (felis update --record)
After=network-online.target postgresql.service k3s.service
Wants=network-online.target

[Service]
Type=oneshot
ExecStart=${HOST_BIN} update --record -config ${STATE_DIR}/felis.host.toml
TimeoutStartSec=5min
Nice=10
PrivateTmp=yes
NoNewPrivileges=yes
ProtectSystem=full
EOF
  cat > "$UPDATE_CHECK_TIMER" <<EOF
[Unit]
Description=Daily Felis component version check

[Timer]
OnCalendar=*-*-* 05:30:00
RandomizedDelaySec=30min
Persistent=true

[Install]
WantedBy=timers.target
EOF
  systemctl daemon-reload
  systemctl enable --now felis-update-check.timer
  systemctl start --no-block felis-update-check.service
  ok "version check: daily; the panel's Updates page shows what has a newer release (journalctl -u felis-update-check)"
}

install_watchdog_timer() {
  local disks="/,/var/lib/rancher/k3s,/var/lib/postgresql,/var/lib/felis" path
  for path in "$FELIS_WORLDS_HOST_PATH" "$FELIS_ARCHIVE_LOCAL_PATH" "$FELIS_DB_BACKUP_DIR"; do
@@ -4216,6 +4258,8 @@ main() {
  install_db_backup_timer
  # After the backup timer: its first bundle is part of the first copy.
  install_offsite_timer
  # After install_velocity: the check reads the installed proxy jar's version.
  install_update_check_timer
  # Last: its first run should see the platform as this install leaves it.
  install_watchdog_timer
  mark_bootstrap_done
+28 −0
Changes for deploy/bootstrap_test.sh: 28 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -1530,6 +1530,34 @@ expect "a failed first backup shows its log" "JOURNAL: pg_dump: connection refus
expect "a failed first backup is a loud warning" "WARN: the first database backup failed" "$out"
rm -rf "$tdir"

ublock="$(awk '/^install_update_check_timer\(\) \{/,/^}/' "$BS")"
[ -n "$ublock" ] || { echo "FAIL: no install_update_check_timer found in $BS"; exit 1; }
[ "$(printf '%s\n' "$ublock" | wc -l)" -lt 60 ] \
  || { echo "FAIL: the extracted block is not install_update_check_timer -- did its closing brace move?"; exit 1; }
udir="$(mktemp -d)"
out="$(UPDATE_CHECK_SERVICE="$udir/felis-update-check.service" UPDATE_CHECK_TIMER="$udir/felis-update-check.timer" \
  HOST_BIN=/usr/local/bin/felis STATE_DIR=/etc/felis bash -c '
  ok() { printf "OK: %s\n" "$*"; }; warn() { printf "WARN: %s\n" "$*"; }
  systemctl() { printf "SYSTEMCTL: %s\n" "$*"; }
  '"$ublock"'
  install_update_check_timer' 2>&1)"
unit="$(cat "$udir/felis-update-check.service")"
timer="$(cat "$udir/felis-update-check.timer")"
expect "the version check records its result for the panel" \
  "ExecStart=/usr/local/bin/felis update --record -config /etc/felis/felis.host.toml" "$unit"
expect "the version check is a oneshot" "Type=oneshot" "$unit"
expect "the version check runs daily" "OnCalendar=*-*-* 05:30:00" "$timer"
expect "a missed check catches up at boot" "Persistent=true" "$timer"
expect "the version check timer is enabled" "SYSTEMCTL: enable --now felis-update-check.timer" "$out"
expect "the first check runs without holding up the install" "SYSTEMCTL: start --no-block felis-update-check.service" "$out"
expect "the install says where the result shows" "OK: version check: daily; the panel's Updates page" "$out"
rm -rf "$udir"
order="$(awk '/^main\(\) \{/,/^}/' "$BS" | grep -nE '^[[:space:]]*(install_velocity|install_update_check_timer)$' | tr '\n' ' ')"
case "$order" in
  *install_velocity*install_update_check_timer*) echo "PASS the version check is installed after the proxy it reads" ;;
  *) echo "FAIL the version check must be installed after install_velocity: $order"; fails=$((fails + 1)) ;;
esac

wblock="$(awk '/^install_watchdog_timer\(\) \{/,/^}/' "$BS")"
[ -n "$wblock" ] || { echo "FAIL: no install_watchdog_timer found in $BS"; exit 1; }
[ "$(printf '%s\n' "$wblock" | wc -l)" -lt 60 ] \
+1 −1
Changes for deploy/e2e_check.sh: 1 added line, 1 removed line.
Original line number Diff line number Diff line
@@ -46,7 +46,7 @@ for unit in k3s postgresql felis-velocity; do
done
# A release may predate a timer; what this commit installs has them all.
if [ "$phase" != release ]; then
  for timer in felis-db-backup.timer felis-watchdog.timer; do
  for timer in felis-db-backup.timer felis-watchdog.timer felis-update-check.timer; do
    check "${timer} is scheduled" systemctl is-enabled --quiet "$timer"
  done
fi
Loading