feat(backup): 玩过的世界每天在停服后自动打一个定时恢复点,按服按 owner 保留 7 个、90 天过期
This commit is contained in:
29 files changed
+1028
-59
No files matched your search
@@ -440,6 +440,13 @@ func cmdAPI(args []string, stdout, stderr io.Writer) int {
|
||||
// reconciles it, but this loop converges builds nobody is polling.
|
||||
go reconcileBuilds(ctx, builder, stderr)
|
||||
go settleRestoreChains(ctx, a, stderr)
|
||||
// A daily restore point of every world played since its last one, taken
|
||||
// once the server stops ([archive] scheduled_every; 0s turns it off).
|
||||
if backuper != nil && rcfg.ScheduledEvery > 0 {
|
||||
go scheduleBackups(ctx, &api.BackupScheduler{API: a, Store: repo, Jobs: jobStatus, Every: rcfg.ScheduledEvery}, stderr)
|
||||
} else {
|
||||
fmt.Fprintln(stderr, "felis api: scheduled backups off (needs the backup executor and [archive] scheduled_every above 0s)")
|
||||
}
|
||||
|
||||
if pruner := registryPruner(cfg, builder.Store, cluster, stderr); pruner != nil {
|
||||
go pruner.Loop(ctx, registryPruneInterval)
|
||||
@@ -703,6 +710,25 @@ func settleRestoreChains(ctx context.Context, a *api.API, stderr io.Writer) {
|
||||
}
|
||||
}
|
||||
|
||||
// scheduleBackups starts the scheduled backups (api.BackupScheduler). Each tick
|
||||
// starts at most one, so the interval also spaces the worlds that stopped at
|
||||
// the same time: a world that stops waits at most this long for its point to
|
||||
// start once the Jobs ahead of it are done.
|
||||
func scheduleBackups(ctx context.Context, s *api.BackupScheduler, stderr io.Writer) {
|
||||
t := time.NewTicker(2 * time.Minute)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
if err := s.Tick(ctx); err != nil {
|
||||
fmt.Fprintf(stderr, "felis api: scheduled backups: %v\n", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// reapRejectedContexts deletes, once an hour, the uploaded contexts of
|
||||
// submissions rejected more than submit.RejectedContextRetention ago, and the
|
||||
// chunked uploads left untouched for submit.StalePartRetention. Without it a
|
||||
|
||||
+34
-18
@@ -38,7 +38,7 @@ func cmdBackup(args []string, stdout, stderr io.Writer) int {
|
||||
server := fs.String("server", "", "server name whose world is being backed up")
|
||||
formerOwner := fs.String("former-owner", "", "owner recorded on the backup row (empty for an unowned server)")
|
||||
worldsRoot := fs.String("worlds-root", "/world", "mount path of the world PVC being archived")
|
||||
reason := fs.String("reason", reasonManual, "world_backups reason: manual, or pre_restore for the safety snapshot in front of a restore")
|
||||
reason := fs.String("reason", reasonManual, "world_backups reason: manual, pre_restore for the safety snapshot in front of a restore, or scheduled for felis-api's daily restore point")
|
||||
protect := fs.String("protect", "", "backup id the prune must keep (the one a chained restore extracts)")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return 2
|
||||
@@ -47,9 +47,8 @@ func cmdBackup(args []string, stdout, stderr io.Writer) int {
|
||||
fmt.Fprintln(stderr, "felis backup: --server is required")
|
||||
return 2
|
||||
}
|
||||
keep, ok := map[string]int{reasonManual: -1, backupjob.ReasonPreRestore: preRestoreKeep}[*reason]
|
||||
if !ok {
|
||||
fmt.Fprintf(stderr, "felis backup: unknown --reason %q (manual or %s)\n", *reason, backupjob.ReasonPreRestore)
|
||||
if _, _, ok := backupPolicy(*reason, reaper.DefaultConfig()); !ok {
|
||||
fmt.Fprintf(stderr, "felis backup: unknown --reason %q (manual, %s or %s)\n", *reason, backupjob.ReasonPreRestore, backupjob.ReasonScheduled)
|
||||
return 2
|
||||
}
|
||||
|
||||
@@ -62,16 +61,14 @@ func cmdBackup(args []string, stdout, stderr io.Writer) int {
|
||||
fmt.Fprintf(stderr, "felis backup: archive store %q is not implemented in this build (only tarLocal)\n", cfg.Archive.Store)
|
||||
return 1
|
||||
}
|
||||
// The [archive] parse the reaper uses; an on-demand backup takes its
|
||||
// manual_retention and manual_keep.
|
||||
// The [archive] parse the reaper uses: it holds each reason's keep and
|
||||
// retention.
|
||||
rcfg, err := reaperConfig(cfg)
|
||||
if err != nil {
|
||||
fmt.Fprintf(stderr, "felis backup: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
if keep < 0 {
|
||||
keep = rcfg.ManualKeep
|
||||
}
|
||||
keep, retention, _ := backupPolicy(*reason, rcfg)
|
||||
|
||||
// The world PVC is mounted directly at worldsRoot; the resolver returns it for
|
||||
// any target, exactly as in cmdRestore. This is the same TarLocal the reaper
|
||||
@@ -117,7 +114,7 @@ func cmdBackup(args []string, stdout, stderr io.Writer) int {
|
||||
BackupRef: string(ref),
|
||||
SizeBytes: size,
|
||||
Reason: *reason,
|
||||
ExpiresAt: time.Now().Add(rcfg.ManualRetention),
|
||||
ExpiresAt: time.Now().Add(retention),
|
||||
|
||||
SHA256: a.SHA256,
|
||||
SkippedEntries: len(a.Skipped),
|
||||
@@ -136,7 +133,7 @@ func cmdBackup(args []string, stdout, stderr io.Writer) int {
|
||||
}
|
||||
|
||||
fmt.Fprintf(stdout, "felis backup: server=%s archived %d bytes to %s (backup %s)\n", *server, size, ref, rec.ID)
|
||||
pruneBackups(ctx, st, archiver, *server, *reason, keep, *protect, stdout, stderr)
|
||||
pruneBackups(ctx, st, archiver, *server, *formerOwner, *reason, keep, *protect, stdout, stderr)
|
||||
return 0
|
||||
}
|
||||
|
||||
@@ -148,13 +145,32 @@ const (
|
||||
preRestoreKeep = 3
|
||||
)
|
||||
|
||||
// pruneBackups keeps server's newest keep backups of this reason and removes the
|
||||
// rest, oldest first, so repeated backups of one world cannot fill the shared
|
||||
// archive store. protect is never removed: it is the backup a chained restore is
|
||||
// about to extract. The new backup is already recorded; a removal that fails is
|
||||
// reported and retried after the next backup.
|
||||
func pruneBackups(ctx context.Context, st *reaper.PGStore, archiver backup.WorldArchiver, server, reason string, keep int, protect string, stdout, stderr io.Writer) {
|
||||
excess, err := st.ExcessBackups(ctx, server, reason, keep, protect)
|
||||
// backupPolicy is how many backups of one reason a server keeps and how long
|
||||
// each lives: an owner's own backups and the safety snapshots in front of a
|
||||
// restore by [archive] manual_keep / manual_retention (the snapshots capped at
|
||||
// preRestoreKeep), felis-api's daily restore points by scheduled_keep /
|
||||
// scheduled_retention, so neither kind crowds out the other. ok is false for a
|
||||
// reason this command does not record.
|
||||
func backupPolicy(reason string, rcfg reaper.Config) (keep int, retention time.Duration, ok bool) {
|
||||
switch reason {
|
||||
case reasonManual:
|
||||
return rcfg.ManualKeep, rcfg.ManualRetention, true
|
||||
case backupjob.ReasonPreRestore:
|
||||
return preRestoreKeep, rcfg.ManualRetention, true
|
||||
case backupjob.ReasonScheduled:
|
||||
return rcfg.ScheduledKeep, rcfg.ScheduledRetention, true
|
||||
}
|
||||
return 0, 0, false
|
||||
}
|
||||
|
||||
// pruneBackups keeps the newest keep backups of this reason that owner holds of
|
||||
// server and removes the rest, oldest first, so repeated backups of one world
|
||||
// cannot fill the shared archive store and a new owner's backups never remove a
|
||||
// previous owner's. protect is never removed: it is the backup a chained restore
|
||||
// is about to extract. The new backup is already recorded; a removal that fails
|
||||
// is reported and retried after the next backup.
|
||||
func pruneBackups(ctx context.Context, st *reaper.PGStore, archiver backup.WorldArchiver, server, owner, reason string, keep int, protect string, stdout, stderr io.Writer) {
|
||||
excess, err := st.ExcessBackups(ctx, server, owner, reason, keep, protect)
|
||||
if err != nil {
|
||||
fmt.Fprintf(stderr, "felis backup: list older backups of %s: %v\n", server, err)
|
||||
return
|
||||
|
||||
@@ -4,6 +4,9 @@ import (
|
||||
"bytes"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"felis.lolicon.best/internal/reaper"
|
||||
)
|
||||
|
||||
// The reason decides which backups the new one's prune may remove, so an
|
||||
@@ -17,3 +20,29 @@ func TestBackupSubcommandRejectsUnknownReason(t *testing.T) {
|
||||
t.Fatalf("stderr = %q", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
// Each reason is pruned and expired by its own [archive] keys: a daily
|
||||
// restore point must never count against, or take the lifetime of, the
|
||||
// backups an owner asked for.
|
||||
func TestBackupPolicyPerReason(t *testing.T) {
|
||||
rcfg := reaper.DefaultConfig()
|
||||
rcfg.ManualKeep, rcfg.ManualRetention = 5, 30*reaper.Day
|
||||
rcfg.ScheduledKeep, rcfg.ScheduledRetention = 7, 90*reaper.Day
|
||||
for _, tc := range []struct {
|
||||
reason string
|
||||
keep int
|
||||
retention time.Duration
|
||||
}{
|
||||
{"manual", 5, 30 * reaper.Day},
|
||||
{"pre_restore", preRestoreKeep, 30 * reaper.Day},
|
||||
{"scheduled", 7, 90 * reaper.Day},
|
||||
} {
|
||||
keep, retention, ok := backupPolicy(tc.reason, rcfg)
|
||||
if !ok || keep != tc.keep || retention != tc.retention {
|
||||
t.Errorf("backupPolicy(%q) = (%d, %v, %v); want (%d, %v, true)", tc.reason, keep, retention, ok, tc.keep, tc.retention)
|
||||
}
|
||||
}
|
||||
if _, _, ok := backupPolicy("inactive_15d", rcfg); ok {
|
||||
t.Error("backupPolicy accepted inactive_15d; the reaper records those itself")
|
||||
}
|
||||
}
|
||||
+23
-2
@@ -199,8 +199,9 @@ func (w *mailWarner) Warn(ctx context.Context, ownerID, server, remaining string
|
||||
|
||||
// reaperConfig derives the reaper's retention windows from felis.toml. The 15d
|
||||
// idle deadline is fixed by §18; only the warning offsets, retention, the
|
||||
// store soft-cap and the on-demand backup bounds are configurable (§24). The
|
||||
// backup Job and felis-api read the manual_* bounds through it too.
|
||||
// store soft-cap, the on-demand backup bounds and the scheduled restore points
|
||||
// are configurable (§24). The backup Job and felis-api read the manual_* and
|
||||
// scheduled_* keys through it too.
|
||||
func reaperConfig(cfg *config.Config) (reaper.Config, error) {
|
||||
rc := reaper.DefaultConfig()
|
||||
if v := cfg.Archive.Retention; v != "" {
|
||||
@@ -248,6 +249,26 @@ func reaperConfig(cfg *config.Config) (reaper.Config, error) {
|
||||
}
|
||||
rc.ManualCooldown = d
|
||||
}
|
||||
if v := cfg.Archive.ScheduledEvery; v != "" {
|
||||
d, err := parseSpanDuration(v)
|
||||
if err != nil || d < 0 {
|
||||
return rc, fmt.Errorf("[archive] scheduled_every %q: want a span such as 1d (0s for none)", v)
|
||||
}
|
||||
rc.ScheduledEvery = d
|
||||
}
|
||||
switch n := cfg.Archive.ScheduledKeep; {
|
||||
case n < 0:
|
||||
return rc, fmt.Errorf("[archive] scheduled_keep %d: want 1 or more", n)
|
||||
case n > 0:
|
||||
rc.ScheduledKeep = n
|
||||
}
|
||||
if v := cfg.Archive.ScheduledRetention; v != "" {
|
||||
d, err := parseSpanDuration(v)
|
||||
if err != nil || d <= 0 {
|
||||
return rc, fmt.Errorf("[archive] scheduled_retention %q: want a positive span such as 90d", v)
|
||||
}
|
||||
rc.ScheduledRetention = d
|
||||
}
|
||||
rc.RequireOffsite = cfg.Offsite.Enabled()
|
||||
return rc, nil
|
||||
}
|
||||
|
||||
@@ -85,6 +85,42 @@ func TestReaperConfigManualKeys(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestReaperConfigScheduledKeys: the scheduled restore points default to one a
|
||||
// day, seven per server and the reaper's 90 days, accept overrides ("0s" turns
|
||||
// them off), and refuse values that would keep nothing or run backwards.
|
||||
func TestReaperConfigScheduledKeys(t *testing.T) {
|
||||
rc, err := reaperConfig(&config.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rc.ScheduledEvery != reaper.Day || rc.ScheduledKeep != 7 || rc.ScheduledRetention != 90*reaper.Day {
|
||||
t.Fatalf("defaults = %v / %d / %v", rc.ScheduledEvery, rc.ScheduledKeep, rc.ScheduledRetention)
|
||||
}
|
||||
rc, err = reaperConfig(&config.Config{Archive: config.ArchiveConfig{
|
||||
ScheduledEvery: "12h", ScheduledKeep: 3, ScheduledRetention: "14d"}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rc.ScheduledEvery != 12*time.Hour || rc.ScheduledKeep != 3 || rc.ScheduledRetention != 14*reaper.Day {
|
||||
t.Fatalf("overrides = %v / %d / %v", rc.ScheduledEvery, rc.ScheduledKeep, rc.ScheduledRetention)
|
||||
}
|
||||
rc, err = reaperConfig(&config.Config{Archive: config.ArchiveConfig{ScheduledEvery: "0s"}})
|
||||
if err != nil || rc.ScheduledEvery != 0 {
|
||||
t.Fatalf("scheduled_every 0s = %v, %v; want off", rc.ScheduledEvery, err)
|
||||
}
|
||||
for _, bad := range []config.ArchiveConfig{
|
||||
{ScheduledEvery: "-1h"},
|
||||
{ScheduledEvery: "daily"},
|
||||
{ScheduledKeep: -1},
|
||||
{ScheduledRetention: "0d"},
|
||||
{ScheduledRetention: "forever"},
|
||||
} {
|
||||
if _, err := reaperConfig(&config.Config{Archive: bad}); err == nil {
|
||||
t.Errorf("%+v was accepted", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveWorldDir(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
root := t.TempDir()
|
||||
|
||||
Reference in new issue
Block a user