feat(db): 控制面 PG 定时备份、迁移前快照与原子恢复

This commit is contained in:
Lemon-miaow committed 2026-09-24 15:19:42 +08:00
1 parent abfe60d62d
commit c7db7d4126
31 files changed
+3217 -17

No files matched your search

+3
View File
@@ -565,6 +565,9 @@ func (a *API) externalAPIRoutes() []apiRoute {
// only — the runner/executors that consume the window are still INTEGRATION-ONLY.
{Method: "GET", Pattern: "/api/v1/updates/window", Admin: true, h: a.handleGetUpdateWindow},
{Method: "PUT", Pattern: "/api/v1/updates/window", Admin: true, h: a.handleSetUpdateWindow},
// 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},
// User admin (spec §7, owner-only). Every route gates on the admin Zero-Trust
// path AND the owner role: listing, mutating, disabling, or deleting users is
+49
View File
@@ -0,0 +1,49 @@
package api
import (
"encoding/json"
"errors"
"net/http"
"felis.lolicon.best/internal/dbbackup"
)
// Control-plane database backup freshness (admin-tier, read-only). The backups
// themselves run on the host — felis-db-backup.timer calls `felis db backup`,
// which records its newest success in platform_settings[dbbackup.StatusKey] — so
// the API can report them without reaching the host's backup directory. The
// panel shows this next to the update window: both answer "is it safe to change
// something on this install right now".
// dbBackupView is the wire shape. Last is null until the first backup has been
// recorded; Stale is true for a missing record too, so the panel has a single
// flag for "nobody could restore today's state".
type dbBackupView struct {
Last *dbbackup.Status `json:"last"`
Stale bool `json:"stale"`
MaxAgeSeconds int64 `json:"max_age_seconds"`
}
// handleGetDBBackup reports the newest recorded control-plane database backup.
// Only a missing key reads as "never"; any other store error is a 500 so a DB
// blip is never shown as a healthy or an absent backup.
func (a *API) handleGetDBBackup(w http.ResponseWriter, r *http.Request) {
view := dbBackupView{Stale: true, MaxAgeSeconds: int64(dbbackup.StaleAfter.Seconds())}
raw, err := a.Repo.GetSetting(r.Context(), dbbackup.StatusKey)
switch {
case errors.Is(err, ErrNotFound):
writeJSON(w, http.StatusOK, view)
return
case err != nil:
writeError(w, r, err)
return
}
var st dbbackup.Status
if err := json.Unmarshal(raw, &st); err != nil {
writeError(w, r, err)
return
}
view.Last = &st
view.Stale = st.At.IsZero() || a.now().Sub(st.At) > dbbackup.StaleAfter
writeJSON(w, http.StatusOK, view)
}
+87
View File
@@ -0,0 +1,87 @@
package api
import (
"encoding/json"
"errors"
"net/http"
"testing"
"time"
"felis.lolicon.best/internal/dbbackup"
)
// The panel's backup card reads one flag, stale, so these pin when it is set:
// never backed up, too old, and not for a fresh backup. A store outage must
// not read as either answer.
func getDBBackup(t *testing.T, api *API) (int, dbBackupView) {
t.Helper()
w := do(api.ExternalHandler(), "GET", "/api/v1/platform/db-backup", "", nil)
var v dbBackupView
if w.Code == http.StatusOK {
if err := json.Unmarshal(w.Body.Bytes(), &v); err != nil {
t.Fatalf("body not JSON: %v (%s)", err, w.Body.String())
}
}
return w.Code, v
}
func TestDBBackupNeverRecordedIsStale(t *testing.T) {
api, _ := seedUpdatesAPI(t)
code, v := getDBBackup(t, api)
if code != http.StatusOK || v.Last != nil || !v.Stale {
t.Fatalf("never backed up = %d %+v, want 200 last=null stale", code, v)
}
if v.MaxAgeSeconds != int64(dbbackup.StaleAfter/time.Second) {
t.Fatalf("max_age_seconds = %d", v.MaxAgeSeconds)
}
}
func TestDBBackupFreshness(t *testing.T) {
now := time.Date(2026, 9, 24, 12, 0, 0, 0, time.UTC)
for _, tc := range []struct {
name string
age time.Duration
stale bool
}{
{"taken this morning", 8 * time.Hour, false},
{"yesterday's, timer slightly late", 25 * time.Hour, false},
{"missed a day", 27 * time.Hour, true},
} {
t.Run(tc.name, func(t *testing.T) {
api, repo := seedUpdatesAPI(t)
api.Now = func() time.Time { return now }
st := dbbackup.Status{At: now.Add(-tc.age), Name: "felis-db-x-daily.tar", Label: "daily", SizeBytes: 4096, SchemaVersion: 31, Dir: "/var/lib/felis/db-backups"}
raw, _ := json.Marshal(st)
repo.settings[dbbackup.StatusKey] = raw
code, v := getDBBackup(t, api)
if code != http.StatusOK || v.Last == nil {
t.Fatalf("code %d, view %+v", code, v)
}
if v.Stale != tc.stale {
t.Fatalf("stale = %v, want %v", v.Stale, tc.stale)
}
if v.Last.Name != st.Name || v.Last.SizeBytes != 4096 || v.Last.SchemaVersion != 31 || !v.Last.At.Equal(st.At) {
t.Fatalf("record not passed through: %+v", v.Last)
}
})
}
}
func TestDBBackupStoreOutageIsAnError(t *testing.T) {
api, repo := seedUpdatesAPI(t)
repo.failGetSetting = errors.New("connection reset")
if code, _ := getDBBackup(t, api); code == http.StatusOK {
t.Fatal("a failed settings read answered 200")
}
}
func TestDBBackupAdminOnly(t *testing.T) {
repo := newFakeRepo()
api := newTestAPI(repo, newFakeCluster())
api.External = staticExternal{p: &Principal{UserID: "u1", Role: "user"}}
if code, _ := getDBBackup(t, api); code != http.StatusForbidden {
t.Fatalf("player read = %d, want 403", code)
}
}