fix(dbbackup): 备份新鲜度按最新的 daily 包算,手动或异地包不再掩盖停掉的定时任务
This commit is contained in:
18 files changed
+262
-59
No files matched your search
@@ -16,8 +16,8 @@ import (
|
||||
// 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".
|
||||
// recorded; Stale is true for a missing record or daily backup too, so the
|
||||
// panel has a single flag for "the daily backups have stopped".
|
||||
type dbBackupView struct {
|
||||
Last *dbbackup.Status `json:"last"`
|
||||
Stale bool `json:"stale"`
|
||||
@@ -43,7 +43,14 @@ func (a *API) handleGetDBBackup(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, r, err)
|
||||
return
|
||||
}
|
||||
// Staleness goes by the daily timer's newest bundle: a manual or
|
||||
// pre-migrate one recorded since would hide a timer that has stopped. A
|
||||
// daily record is its own daily bundle, also when written before daily_at
|
||||
// existed. No daily bundle leaves the zero time, centuries past the limit.
|
||||
if st.Label == dbbackup.LabelDaily {
|
||||
st.DailyAt = st.At
|
||||
}
|
||||
view.Last = &st
|
||||
view.Stale = st.At.IsZero() || a.now().Sub(st.At) > dbbackup.StaleAfter
|
||||
view.Stale = a.now().Sub(st.DailyAt) > dbbackup.StaleAfter
|
||||
writeJSON(w, http.StatusOK, view)
|
||||
}
|
||||
@@ -69,6 +69,41 @@ func TestDBBackupFreshness(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestDBBackupDailyDecides: a fresh manual record says nothing about the
|
||||
// timer, so the daily bundle it carries decides; a daily record from before
|
||||
// daily_at existed is its own daily bundle.
|
||||
func TestDBBackupDailyDecides(t *testing.T) {
|
||||
now := time.Date(2026, 9, 24, 12, 0, 0, 0, time.UTC)
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
label string
|
||||
dailyAge time.Duration // 0: no daily_at in the record
|
||||
stale bool
|
||||
wantDaily time.Time
|
||||
}{
|
||||
{"manual over a 30h-old daily", "manual", 30 * time.Hour, true, now.Add(-30 * time.Hour)},
|
||||
{"manual over this morning's daily", "manual", 8 * time.Hour, false, now.Add(-8 * time.Hour)},
|
||||
{"manual with no daily at all", "pre-migrate", 0, true, time.Time{}},
|
||||
{"a daily record written before daily_at", "daily", 0, false, now.Add(-time.Hour)},
|
||||
} {
|
||||
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(-time.Hour), Name: "felis-db-x-" + tc.label + ".tar", Label: tc.label, Dir: "/var/lib/felis/db-backups"}
|
||||
if tc.dailyAge > 0 {
|
||||
st.DailyAt = now.Add(-tc.dailyAge)
|
||||
}
|
||||
raw, _ := json.Marshal(st)
|
||||
repo.settings[dbbackup.StatusKey] = raw
|
||||
|
||||
code, v := getDBBackup(t, api)
|
||||
if code != http.StatusOK || v.Last == nil || v.Stale != tc.stale || !v.Last.DailyAt.Equal(tc.wantDaily) || !v.Last.At.Equal(st.At) {
|
||||
t.Fatalf("code %d, view %+v, want stale %v daily_at %s", code, v, tc.stale, tc.wantDaily)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDBBackupStoreOutageIsAnError(t *testing.T) {
|
||||
api, repo := seedUpdatesAPI(t)
|
||||
repo.failGetSetting = errors.New("connection reset")
|
||||
|
||||
@@ -17,7 +17,7 @@ import (
|
||||
// response bodies. Each named schema is compared with the Go struct the handler
|
||||
// actually encodes: the property set must equal the struct's JSON field set, and
|
||||
// `required` must list exactly the fields that are always on the wire (no
|
||||
// omitempty). The panel's types are checked against the same schemas at compile
|
||||
// omitempty or omitzero). The panel's types are checked against the same schemas at compile
|
||||
// time (panel/src/lib/types.parity.ts), so a field added here without the docs
|
||||
// fails in Go, and one added to the docs without the panel fails in tsc.
|
||||
func TestOpenAPISchemasMatchWireStructs(t *testing.T) {
|
||||
@@ -125,7 +125,10 @@ func wireFields(t reflect.Type) map[string]wireField {
|
||||
if name == "" {
|
||||
name = f.Name
|
||||
}
|
||||
out[name] = wireField{omitempty: strings.Contains(","+opts+",", ",omitempty,"), typ: f.Type}
|
||||
// omitzero leaves a field out too, and is the one that does for a
|
||||
// struct such as time.Time.
|
||||
opts = "," + opts + ","
|
||||
out[name] = wireField{omitempty: strings.Contains(opts, ",omitempty,") || strings.Contains(opts, ",omitzero,"), typ: f.Type}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -157,9 +157,10 @@ const StatusKey = "db_backup_last"
|
||||
// failed every try: a restore from it brings back no servers.
|
||||
var ErrServersMissing = errors.New("the bundle holds the database but not the MinecraftServer objects")
|
||||
|
||||
// StaleAfter is how old the newest backup may get before it counts as missed:
|
||||
// a day plus the timer's randomized delay and a slow dump. `felis db check`,
|
||||
// the admin panel and the FelisDBBackupStale alert (deploy/alerts) share it.
|
||||
// StaleAfter is how old the newest daily backup may get before it counts as
|
||||
// missed: a day plus the timer's randomized delay and a slow dump. `felis db
|
||||
// check`, the watchdog, the admin panel and the FelisDBBackupStale alert
|
||||
// (deploy/alerts) share it.
|
||||
const StaleAfter = 26 * time.Hour
|
||||
|
||||
// Status is the value stored under StatusKey.
|
||||
@@ -174,6 +175,11 @@ type Status struct {
|
||||
// ServersError is why the bundle lacks the MinecraftServer objects, when
|
||||
// it does.
|
||||
ServersError string `json:"servers_error,omitempty"`
|
||||
// DailyAt is when the newest daily bundle in Dir was written, as of this
|
||||
// record: the timer's own freshness, which a manual, pre-migrate or
|
||||
// off-site bundle taken since leaves as it was. Zero when Dir held none,
|
||||
// and in records written before the field existed.
|
||||
DailyAt time.Time `json:"daily_at,omitzero"`
|
||||
}
|
||||
|
||||
// Manifest describes a bundle.
|
||||
@@ -402,6 +408,17 @@ func List(dir string) ([]Bundle, error) {
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Newest is the first bundle of label in bundles, which List orders newest
|
||||
// first.
|
||||
func Newest(bundles []Bundle, label string) (Bundle, bool) {
|
||||
for _, b := range bundles {
|
||||
if b.Label == label {
|
||||
return b, true
|
||||
}
|
||||
}
|
||||
return Bundle{}, false
|
||||
}
|
||||
|
||||
// Prune deletes all but the newest keep bundles of label (and their sidecars)
|
||||
// and returns what it removed. keep <= 0 removes nothing.
|
||||
func Prune(dir, label string, keep int) ([]string, error) {
|
||||
@@ -626,9 +643,15 @@ func listArchive(ctx context.Context, c conn, t Tools, path string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// record upserts st into platform_settings. The JSON travels as a psql
|
||||
// variable, quoted by psql itself, over stdin (-c does not interpolate).
|
||||
// record upserts st into platform_settings, with DailyAt read off st.Dir. The
|
||||
// JSON travels as a psql variable, quoted by psql itself, over stdin (-c does
|
||||
// not interpolate).
|
||||
func record(ctx context.Context, c conn, t Tools, st Status) error {
|
||||
if all, err := List(st.Dir); err == nil {
|
||||
if d, ok := Newest(all, LabelDaily); ok {
|
||||
st.DailyAt = d.Created
|
||||
}
|
||||
}
|
||||
v, err := json.Marshal(st)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -835,19 +858,21 @@ func Age(d time.Duration) string {
|
||||
}
|
||||
}
|
||||
|
||||
// Check reports the newest bundle in dir and an error when there is none or it
|
||||
// is older than maxAge.
|
||||
// Check reports the newest daily bundle in dir and an error when there is none
|
||||
// or it is older than maxAge. The daily timer is what keeps the backups
|
||||
// current, so a manual, pre-migrate or off-site bundle taken since is left out:
|
||||
// it would hide a timer that has stopped.
|
||||
func Check(dir string, maxAge time.Duration, now time.Time) (*Bundle, error) {
|
||||
all, err := List(dir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(all) == 0 {
|
||||
return nil, fmt.Errorf("no database backup in %s", dir)
|
||||
daily, ok := Newest(all, LabelDaily)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("no daily database backup in %s (felis-db-backup.timer writes them)", dir)
|
||||
}
|
||||
newest := all[0]
|
||||
if age := now.Sub(newest.Created); age > maxAge {
|
||||
return &newest, fmt.Errorf("newest database backup %s is %s old (limit %s)", newest.Name, Age(age), maxAge)
|
||||
if age := now.Sub(daily.Created); age > maxAge {
|
||||
return &daily, fmt.Errorf("newest daily database backup %s is %s old (limit %s)", daily.Name, Age(age), maxAge)
|
||||
}
|
||||
return &newest, nil
|
||||
return &daily, nil
|
||||
}
|
||||
@@ -290,9 +290,19 @@ func TestBackupRecordsFreshness(t *testing.T) {
|
||||
}
|
||||
st := pg.recorded(t)
|
||||
info, _ := os.Stat(path)
|
||||
if st.Name != filepath.Base(path) || st.Label != LabelDaily || !st.At.Equal(t0) || st.SizeBytes != info.Size() || st.SchemaVersion != 21 {
|
||||
if st.Name != filepath.Base(path) || st.Label != LabelDaily || !st.At.Equal(t0) || st.SizeBytes != info.Size() || st.SchemaVersion != 21 || !st.DailyAt.Equal(t0) {
|
||||
t.Fatalf("recorded %+v", st)
|
||||
}
|
||||
// A manual bundle an hour later is the newest record, and carries the
|
||||
// daily one's time for the panel to judge the timer by.
|
||||
if _, err := Backup(context.Background(), BackupOptions{
|
||||
DatabaseURL: testURL, Dir: dir, Label: LabelManual, Tools: pg.tools, Now: at(t0.Add(time.Hour)), Record: true,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if st := pg.recorded(t); st.Label != LabelManual || !st.At.Equal(t0.Add(time.Hour)) || !st.DailyAt.Equal(t0) {
|
||||
t.Fatalf("recorded after a manual backup %+v, want daily_at %s", st, t0)
|
||||
}
|
||||
|
||||
prom, err := os.ReadFile(metrics)
|
||||
if err != nil {
|
||||
@@ -701,15 +711,30 @@ func TestListPruneCheck(t *testing.T) {
|
||||
t.Error("a pruned bundle's sidecar survived")
|
||||
}
|
||||
|
||||
if b, err := Check(dir, 26*time.Hour, t0); err != nil || b.Label != LabelPreMigrate {
|
||||
// Check goes by the newest daily bundle: the pre-migrate one taken an hour
|
||||
// ago says nothing about the timer.
|
||||
if b, err := Check(dir, 26*time.Hour, t0); err != nil || b.Name != "felis-db-20260923T033000Z-daily.tar" {
|
||||
t.Errorf("Check fresh = %v, %v", b, err)
|
||||
}
|
||||
if _, err := Check(dir, 26*time.Hour, t0.Add(30*time.Hour)); err == nil {
|
||||
t.Error("Check accepted a 31h-old newest bundle")
|
||||
if b, err := Check(dir, 26*time.Hour, t0.Add(3*time.Hour)); err == nil || b.Name != "felis-db-20260923T033000Z-daily.tar" ||
|
||||
err.Error() != "newest daily database backup felis-db-20260923T033000Z-daily.tar is 27h0m old (limit 26h0m0s)" {
|
||||
t.Errorf("Check with a fresh pre-migrate bundle over a 27h-old daily one = %v, %v", b, err)
|
||||
}
|
||||
if _, err := Check(filepath.Join(dir, "none"), time.Hour, t0); err == nil {
|
||||
t.Error("Check accepted an empty directory")
|
||||
}
|
||||
only := t.TempDir()
|
||||
if _, err := Backup(context.Background(), BackupOptions{DatabaseURL: testURL, Dir: only, Label: LabelManual, Tools: pg.tools, Now: at(t0), Record: true}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// No daily bundle: the record leaves daily_at out (a zero time would read
|
||||
// as year 1), so the panel shows none.
|
||||
if args, _ := os.ReadFile(filepath.Join(pg.dir, "record.args")); !strings.Contains(string(args), `"label":"manual"`) || strings.Contains(string(args), "daily_at") {
|
||||
t.Errorf("record without a daily bundle = %s", args)
|
||||
}
|
||||
if _, err := Check(only, 26*time.Hour, t0); err == nil || err.Error() != "no daily database backup in "+only+" (felis-db-backup.timer writes them)" {
|
||||
t.Errorf("Check with a fresh manual bundle alone = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseBundleName(t *testing.T) {
|
||||
|
||||
@@ -341,8 +341,12 @@ func PostgresDown(err error) Finding {
|
||||
}
|
||||
}
|
||||
|
||||
// BackupFinding reports a control-plane database backup older than a day, or
|
||||
// none at all, in dir, and a fresh one that would restore no servers.
|
||||
// BackupFinding reports a daily control-plane database backup older than a
|
||||
// day, or none at all, in dir, and a fresh one that would restore no servers.
|
||||
// The age goes by the daily bundles alone: a manual or off-site bundle taken
|
||||
// since would keep a stopped timer quiet until it too is a day old. The
|
||||
// servers check reads the newest bundle of any label, the one a lost host
|
||||
// restores from.
|
||||
func BackupFinding(dir string, now time.Time) *Finding {
|
||||
bundles, err := dbbackup.List(dir)
|
||||
if err != nil {
|
||||
@@ -353,19 +357,28 @@ func BackupFinding(dir string, now time.Time) *Finding {
|
||||
Hint: "docs/troubleshooting.md §16",
|
||||
}
|
||||
}
|
||||
if len(bundles) > 0 && now.Sub(bundles[0].Created) <= maxBackupAge {
|
||||
daily, ok := dbbackup.Newest(bundles, dbbackup.LabelDaily)
|
||||
if ok && now.Sub(daily.Created) <= maxBackupAge {
|
||||
return serversFinding(bundles[0])
|
||||
}
|
||||
f := &Finding{
|
||||
Key: "db-backup", Severity: Critical, For: backupFor,
|
||||
Summary: fmt.Sprintf("%s 里没有任何控制面数据库备份", dir),
|
||||
SummaryEN: fmt.Sprintf("no control-plane database backup in %s", dir),
|
||||
Hint: "journalctl -u felis-db-backup -n 50; take one now with `sudo felis db backup` (docs/troubleshooting.md §16)",
|
||||
Hint: "journalctl -u felis-db-backup -n 50; once fixed, run it now with `sudo systemctl start felis-db-backup` (docs/troubleshooting.md §16)",
|
||||
}
|
||||
if len(bundles) > 0 {
|
||||
age := roundHours(now.Sub(bundles[0].Created))
|
||||
f.Summary = fmt.Sprintf("最新的控制面数据库备份已是 %s 前(%s)", age, bundles[0].Name)
|
||||
f.SummaryEN = fmt.Sprintf("the newest control-plane database backup is %s old (%s)", age, bundles[0].Name)
|
||||
switch {
|
||||
case ok:
|
||||
age := roundHours(now.Sub(daily.Created))
|
||||
f.Summary = fmt.Sprintf("最新的每日控制面数据库备份已是 %s 前(%s)", age, daily.Name)
|
||||
f.SummaryEN = fmt.Sprintf("the newest daily control-plane database backup is %s old (%s)", age, daily.Name)
|
||||
case len(bundles) > 0:
|
||||
f.Summary = fmt.Sprintf("%s 里没有每日定时的控制面数据库备份", dir)
|
||||
f.SummaryEN = fmt.Sprintf("no daily control-plane database backup in %s", dir)
|
||||
}
|
||||
if len(bundles) > 0 && bundles[0].Label != dbbackup.LabelDaily {
|
||||
f.Summary += fmt.Sprintf(";更新的 %s 来自手动或其他任务,定时任务修好之前它会一天天变旧", bundles[0].Name)
|
||||
f.SummaryEN += fmt.Sprintf("; the newer %s came from a manual run or another job and ages while the timer stays broken", bundles[0].Name)
|
||||
}
|
||||
return f
|
||||
}
|
||||
|
||||
@@ -149,16 +149,34 @@ func TestBackupFinding(t *testing.T) {
|
||||
if f := BackupFinding(dir, t0); f == nil || !strings.Contains(f.SummaryEN, "no control-plane database backup") {
|
||||
t.Fatalf("empty dir: %+v", f)
|
||||
}
|
||||
touch := func(at time.Time) {
|
||||
if err := os.WriteFile(filepath.Join(dir, dbbackup.BundleName(at, "daily")), []byte("x"), 0o600); err != nil {
|
||||
touch := func(at time.Time, label string) {
|
||||
if err := os.WriteFile(filepath.Join(dir, dbbackup.BundleName(at, label)), []byte("x"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
touch(t0.Add(-30 * time.Hour))
|
||||
if f := BackupFinding(dir, t0); f == nil || !strings.Contains(f.SummaryEN, "30h old") {
|
||||
t.Fatalf("stale: %+v", f)
|
||||
// A manual bundle alone: the timer has never written one.
|
||||
touch(t0.Add(-time.Hour), "manual")
|
||||
if f := BackupFinding(dir, t0); f == nil || f.Key != "db-backup" ||
|
||||
f.SummaryEN != "no daily control-plane database backup in "+dir+"; the newer felis-db-20260924T110000Z-manual.tar came from a manual run or another job and ages while the timer stays broken" ||
|
||||
f.Summary != dir+" 里没有每日定时的控制面数据库备份;更新的 felis-db-20260924T110000Z-manual.tar 来自手动或其他任务,定时任务修好之前它会一天天变旧" {
|
||||
t.Fatalf("manual only: %+v", f)
|
||||
}
|
||||
touch(t0.Add(-2 * time.Hour))
|
||||
// A stale daily bundle under that fresh manual one: still the timer's alarm.
|
||||
touch(t0.Add(-30*time.Hour), "daily")
|
||||
if f := BackupFinding(dir, t0); f == nil || f.Key != "db-backup" ||
|
||||
f.SummaryEN != "the newest daily control-plane database backup is 30h old (felis-db-20260923T060000Z-daily.tar); the newer felis-db-20260924T110000Z-manual.tar came from a manual run or another job and ages while the timer stays broken" ||
|
||||
!strings.Contains(f.Hint, "sudo systemctl start felis-db-backup") {
|
||||
t.Fatalf("stale daily under a fresh manual: %+v", f)
|
||||
}
|
||||
stale := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(stale, dbbackup.BundleName(t0.Add(-30*time.Hour), "daily")), []byte("x"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if f := BackupFinding(stale, t0); f == nil || f.SummaryEN != "the newest daily control-plane database backup is 30h old (felis-db-20260923T060000Z-daily.tar)" ||
|
||||
f.Summary != "最新的每日控制面数据库备份已是 30h 前(felis-db-20260923T060000Z-daily.tar)" {
|
||||
t.Fatalf("stale daily alone: %+v", f)
|
||||
}
|
||||
touch(t0.Add(-2*time.Hour), "daily")
|
||||
if f := BackupFinding(dir, t0); f != nil {
|
||||
t.Fatalf("fresh backup reported: %+v", f)
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user