feat(update): 主机每日记录组件版本比对,面板更新页展示可用更新与应用命令
This commit is contained in:
28 files changed
+994
-10
No files matched your search
+4
-2
@@ -663,10 +663,12 @@ func (a *API) externalAPIRoutes() []apiRoute {
|
||||
{Method: "GET", Pattern: "/api/v1/submissions/{id}/context", Admin: true, h: a.handleAdminSubmissionContext},
|
||||
// Auto-update maintenance window (spec §B; decision core internal/updates).
|
||||
// Admin-tier: it governs whether Felis may apply an update to itself, so setting
|
||||
// it requires the admin Zero-Trust path, not a mere session. API+persistence
|
||||
// only — the runner/executors that consume the window are still INTEGRATION-ONLY.
|
||||
// it requires the admin Zero-Trust path, not a mere session. Advisory: `felis
|
||||
// update` on the host reads it and warns before an apply outside it.
|
||||
{Method: "GET", Pattern: "/api/v1/updates/window", Admin: true, h: a.handleGetUpdateWindow},
|
||||
{Method: "PUT", Pattern: "/api/v1/updates/window", Admin: true, h: a.handleSetUpdateWindow},
|
||||
// The newest version check felis-update-check.timer recorded on the host.
|
||||
{Method: "GET", Pattern: "/api/v1/updates/report", Admin: true, h: a.handleGetUpdateReport},
|
||||
// Control-plane database backup freshness, as the host's felis-db-backup.timer
|
||||
// last recorded it. Admin-tier: it names the host backup directory.
|
||||
{Method: "GET", Pattern: "/api/v1/platform/db-backup", Admin: true, h: a.handleGetDBBackup},
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"felis.lolicon.best/internal/updates"
|
||||
)
|
||||
|
||||
// The Updates page's version card reads the report and one flag, stale: set when
|
||||
// no check was ever recorded and when the daily timer stopped, clear for today's.
|
||||
|
||||
func TestUpdateReportNeverRecorded(t *testing.T) {
|
||||
api, _ := seedUpdatesAPI(t)
|
||||
w := do(api.ExternalHandler(), "GET", "/api/v1/updates/report", "", nil)
|
||||
if w.Code != http.StatusOK || w.Body.String() != `{"report":null,"stale":true,"max_age_seconds":93600}`+"\n" {
|
||||
t.Fatalf("never checked = %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateReportPassesTheRecordThrough(t *testing.T) {
|
||||
now := time.Date(2026, 9, 25, 12, 0, 0, 0, time.UTC)
|
||||
api, repo := seedUpdatesAPI(t)
|
||||
api.Now = func() time.Time { return now }
|
||||
repo.settings[updates.StatusKey] = []byte(`{"checked_at":"2026-09-25T03:00:00Z","felis":"v0.4.0","components":[` +
|
||||
`{"name":"felis-api","current":"v0.4.0","latest":"v0.5.0","state":"available","selector":"panel"},` +
|
||||
`{"name":"k3s","current":"v1.36.2+k3s1","state":"unknown","selector":"k3s","error":"github: HTTP 403"}]}`)
|
||||
w := do(api.ExternalHandler(), "GET", "/api/v1/updates/report", "", nil)
|
||||
want := `{"report":{"checked_at":"2026-09-25T03:00:00Z","felis":"v0.4.0","components":[` +
|
||||
`{"name":"felis-api","current":"v0.4.0","latest":"v0.5.0","state":"available","selector":"panel"},` +
|
||||
`{"name":"k3s","current":"v1.36.2+k3s1","state":"unknown","selector":"k3s","error":"github: HTTP 403"}]},` +
|
||||
`"stale":false,"max_age_seconds":93600}` + "\n"
|
||||
if w.Code != http.StatusOK || w.Body.String() != want {
|
||||
t.Fatalf("report = %d\n%s\nwant\n%s", w.Code, w.Body.String(), want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateReportFreshness(t *testing.T) {
|
||||
now := time.Date(2026, 9, 25, 12, 0, 0, 0, time.UTC)
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
checkedAt string
|
||||
stale bool
|
||||
}{
|
||||
{"checked last night", "2026-09-25T03:00:00Z", false},
|
||||
{"yesterday's, timer slightly late", "2026-09-24T11:00:00Z", false},
|
||||
{"timer missed a day", "2026-09-24T09:00:00Z", true},
|
||||
{"no timestamp", "0001-01-01T00:00:00Z", true},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
api, repo := seedUpdatesAPI(t)
|
||||
api.Now = func() time.Time { return now }
|
||||
repo.settings[updates.StatusKey] = []byte(`{"checked_at":"` + tc.checkedAt + `","felis":"v0.4.0","components":null}`)
|
||||
w := do(api.ExternalHandler(), "GET", "/api/v1/updates/report", "", nil)
|
||||
want := `{"report":{"checked_at":"` + tc.checkedAt + `","felis":"v0.4.0","components":[]},"stale":false,"max_age_seconds":93600}` + "\n"
|
||||
if tc.stale {
|
||||
want = `{"report":{"checked_at":"` + tc.checkedAt + `","felis":"v0.4.0","components":[]},"stale":true,"max_age_seconds":93600}` + "\n"
|
||||
}
|
||||
if w.Code != http.StatusOK || w.Body.String() != want {
|
||||
t.Fatalf("report = %d %s, want %s", w.Code, w.Body.String(), want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateReportStoreOutageIsAnError(t *testing.T) {
|
||||
api, repo := seedUpdatesAPI(t)
|
||||
repo.failGetSetting = errors.New("connection reset")
|
||||
if w := do(api.ExternalHandler(), "GET", "/api/v1/updates/report", "", nil); w.Code != http.StatusInternalServerError {
|
||||
t.Fatalf("a failed settings read answered %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateReportAdminOnly(t *testing.T) {
|
||||
repo := newFakeRepo()
|
||||
api := newTestAPI(repo, newFakeCluster())
|
||||
api.External = staticExternal{p: &Principal{UserID: "u1", Role: "user"}}
|
||||
if w := do(api.ExternalHandler(), "GET", "/api/v1/updates/report", "", nil); w.Code != http.StatusForbidden {
|
||||
t.Fatalf("player read = %d, want 403", w.Code)
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,8 @@ import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"felis.lolicon.best/internal/updates"
|
||||
)
|
||||
|
||||
// SysAdmin-set maintenance window for the auto-update subsystem (task #38; the
|
||||
@@ -115,3 +117,41 @@ func (a *API) handleSetUpdateWindow(w http.ResponseWriter, r *http.Request) {
|
||||
a.audit(r, "updates.window_set", "")
|
||||
writeJSON(w, http.StatusOK, body)
|
||||
}
|
||||
|
||||
// updateReportView is the wire shape of the newest version check. Report is null
|
||||
// until felis-update-check.timer (or `felis update --record`) has run once; Stale
|
||||
// is true for a missing report too, so the panel has one flag for "the versions
|
||||
// shown are not today's".
|
||||
type updateReportView struct {
|
||||
Report *updates.StatusReport `json:"report"`
|
||||
Stale bool `json:"stale"`
|
||||
MaxAgeSeconds int64 `json:"max_age_seconds"`
|
||||
}
|
||||
|
||||
// handleGetUpdateReport returns the newest recorded version check (admin-tier).
|
||||
// The check runs on the host, where the installed versions are readable, and
|
||||
// records itself in platform_settings[updates.StatusKey]. Only a missing key
|
||||
// reads as "never checked"; any other store error is a 500.
|
||||
func (a *API) handleGetUpdateReport(w http.ResponseWriter, r *http.Request) {
|
||||
view := updateReportView{Stale: true, MaxAgeSeconds: int64(updates.StatusStaleAfter.Seconds())}
|
||||
raw, err := a.Repo.GetSetting(r.Context(), updates.StatusKey)
|
||||
switch {
|
||||
case errors.Is(err, ErrNotFound):
|
||||
writeJSON(w, http.StatusOK, view)
|
||||
return
|
||||
case err != nil:
|
||||
writeError(w, r, err)
|
||||
return
|
||||
}
|
||||
var rep updates.StatusReport
|
||||
if err := json.Unmarshal(raw, &rep); err != nil {
|
||||
writeError(w, r, err)
|
||||
return
|
||||
}
|
||||
if rep.Components == nil {
|
||||
rep.Components = []updates.ComponentStatus{}
|
||||
}
|
||||
view.Report = &rep
|
||||
view.Stale = a.now().Sub(rep.CheckedAt) > updates.StatusStaleAfter
|
||||
writeJSON(w, http.StatusOK, view)
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"testing"
|
||||
|
||||
"felis.lolicon.best/internal/build"
|
||||
"felis.lolicon.best/internal/updates"
|
||||
"sigs.k8s.io/yaml"
|
||||
)
|
||||
|
||||
@@ -51,6 +52,8 @@ func TestOpenAPISchemasMatchWireStructs(t *testing.T) {
|
||||
"PasskeyCredential": passkeyCredentialView{},
|
||||
"UpdateWindow": updateWindow{},
|
||||
"DBBackupStatus": dbBackupView{},
|
||||
"UpdateReport": updateReportView{},
|
||||
"UpdateComponent": updates.ComponentStatus{},
|
||||
}
|
||||
for name, v := range pairs {
|
||||
s, ok := doc.Components.Schemas[name]
|
||||
|
||||
Reference in new issue
Block a user