feat(update): 主机每日记录组件版本比对,面板更新页展示可用更新与应用命令
This commit is contained in:
28 files changed
+994
-10
No files matched your search
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user