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

This commit is contained in:
Lemon-miaow committed 2026-09-25 21:50:49 +08:00
1 parent 24373823cc
commit 7f160e2feb
28 files changed
+994 -10

No files matched your search

+85
View File
@@ -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
View File
@@ -1,9 +1,11 @@
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)
}
}