Unverified Commit 8ef7112d authored by Lemon-miaow's avatar Lemon-miaow
Browse files

feat(backup): 玩过的世界每天在停服后自动打一个定时恢复点,按服按 owner 保留 7 个、90 天过期

parent d6e1a646
Loading
Loading
Loading
Loading
+26 −0
Changes for cmd/felis/api.go: 26 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -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
Changes for cmd/felis/backup.go: 34 added lines, 18 removed lines.
Original line number Diff line number Diff line
@@ -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
+29 −0
Changes for cmd/felis/backup_test.go: 29 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -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
Changes for cmd/felis/reaper.go: 23 added lines, 2 removed lines.
Original line number Diff line number Diff line
@@ -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
}
+36 −0
Changes for cmd/felis/reaper_test.go: 36 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -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()
Loading