Unverified Commit 983727d9 authored by Lemon-miaow's avatar Lemon-miaow
Browse files

fix(offsite): 复制新归档后补打并上传 DB 包,桶内无记录的归档过最长保留期后清理

parent c146ce84
Loading
Loading
Loading
Loading
+2 −1
Changes for cmd/felis/db.go: 2 added lines, 1 removed line.
Original line number Diff line number Diff line
@@ -36,6 +36,7 @@ var defaultKeep = map[string]int{
	dbbackup.LabelDaily:      14,
	dbbackup.LabelPreMigrate: 10,
	dbbackup.LabelPreRestore: 5,
	dbbackup.LabelOffsite:    1,
}

// cmdDB implements `felis db`: logical backups of the control-plane database
@@ -136,7 +137,7 @@ func libpqQuote(v string) string {
func dbBackup(fs *flag.FlagSet, dir *string, args []string, stdout, stderr io.Writer) int {
	cfgPath := fs.String("config", "/etc/felis/felis.toml", "path to felis.toml")
	label := fs.String("label", dbbackup.LabelManual, "bundle label; daily/pre-migrate/pre-restore bundles are pruned, manual ones never")
	keep := fs.Int("keep", -1, "bundles of this label to keep (default: daily 14, pre-migrate 10, pre-restore 5, manual all)")
	keep := fs.Int("keep", -1, "bundles of this label to keep (default: daily 14, pre-migrate 10, pre-restore 5, offsite 1, manual all)")
	stateDir := fs.String("state-dir", dbbackup.DefaultStateDir, `host state directory to bundle ("" for none)`)
	noServers := fs.Bool("no-servers", false, "leave the MinecraftServer objects out of the bundle")
	metrics := fs.String("metrics-file", "", "node-exporter textfile to rewrite on success (e.g. /var/lib/node_exporter/textfile_collector/felis_db_backup.prom)")
+55 −9
Changes for cmd/felis/offsite.go: 55 added lines, 9 removed lines.
Original line number Diff line number Diff line
@@ -211,6 +211,7 @@ func offsiteSync(fs *flag.FlagSet, args []string, stdout, stderr io.Writer) int
	archiveDir := fs.String("archive-dir", "", "host directory of the world archive volume (default: resolved from the backup PVC through the cluster)")
	backupPVC := fs.String("backup-pvc", "felis-backups", `the world archive PVC, in the [k8s] namespace ("" when backups are off)`)
	dbDir := fs.String("db-dir", dbbackup.DefaultDir, `database bundle directory ("" copies no bundles)`)
	stateDir := fs.String("state-dir", dbbackup.DefaultStateDir, `host state directory bundled into the database bundle taken after archives are copied ("" for none)`)
	registry := fs.String("registry", "", `host[:port] of the registry whose user images are copied (default: the in-cluster registry's loopback hostPort; "off" copies none)`)
	uploadsDir := fs.String("uploads-dir", "", "host directory of the submission uploads volume (default: resolved from the uploads PVC through the cluster)")
	uploadsPVC := fs.String("uploads-pvc", platform.UploadsPVCName, `the submission uploads PVC, in the control-plane namespace ("" copies no uploads)`)
@@ -225,7 +226,7 @@ func offsiteSync(fs *flag.FlagSet, args []string, stdout, stderr io.Writer) int
	}
	st, lease := startRun(env.cfg, env.key, *statusFile, time.Now())
	res, err := runOffsiteSync(cfg, env, offsiteSources{
		archiveDir: *archiveDir, backupPVC: *backupPVC, dbDir: *dbDir,
		archiveDir: *archiveDir, backupPVC: *backupPVC, dbDir: *dbDir, stateDir: *stateDir,
		registry:   offsiteRegistryEndpoint(*registry, cfg.Registry),
		uploadsDir: *uploadsDir, uploadsPVC: *uploadsPVC,
	}, &lease, stderr)
@@ -299,12 +300,13 @@ func recordRun(st *offsite.Status, res offsite.Result, err error, lease offsite.
}

// offsiteSources is where one sync pass reads from: the world archive volume
// (archiveDir, or the backupPVC's directory), the bundle directory, the
// registry's loopback endpoint and the uploads volume (uploadsDir, or the
// uploadsPVC's directory). An empty source is skipped.
// (archiveDir, or the backupPVC's directory), the bundle directory (with the
// host state the pass bundles, stateDir), the registry's loopback endpoint and
// the uploads volume (uploadsDir, or the uploadsPVC's directory). An empty
// source is skipped.
type offsiteSources struct {
	archiveDir, backupPVC  string
	dbDir                  string
	dbDir, stateDir        string
	registry               string
	uploadsDir, uploadsPVC string
}
@@ -346,10 +348,8 @@ func runOffsiteSync(cfg *config.Config, env *offsiteEnv, src offsiteSources, lea
		return offsite.Result{}, fmt.Errorf("open database: %w", err)
	}
	defer drv.Close()
	s := &offsite.Syncer{
		Bucket: env.bucket, Catalog: offsite.PGCatalog{DB: drv.DB()}, Key: env.key,
		ArchiveDir: archiveDir, DBDir: src.dbDir, DBKeep: env.cfg.DBKeep, UploadsDir: uploadsDir, Lease: lease, Log: log,
	}
	s := offsiteSyncer(cfg, env, src, archiveDir, uploadsDir, lease, log)
	s.Catalog = offsite.PGCatalog{DB: drv.DB()}
	if src.registry != "" {
		s.Images = newRegistryImages(src.registry)
		s.ImagePins = imagePins(drv.DB(), cfg.Registry.URL)
@@ -357,6 +357,52 @@ func runOffsiteSync(cfg *config.Config, env *offsiteEnv, src offsiteSources, lea
	return s.Run(ctx)
}

// offsiteSyncer is the pass runOffsiteSync runs over the resolved archive and
// uploads directories, before its catalog and registry are attached. It
// snapshots the database into the bundle directory after copying archives, and
// sweeps world objects no backup records once they outlive every retention in
// [archive]; a retention that does not parse sweeps none.
func offsiteSyncer(cfg *config.Config, env *offsiteEnv, src offsiteSources, archiveDir, uploadsDir string, lease *offsite.Lease, log io.Writer) *offsite.Syncer {
	s := &offsite.Syncer{
		Bucket: env.bucket, Key: env.key,
		ArchiveDir: archiveDir, DBDir: src.dbDir, DBKeep: env.cfg.DBKeep, UploadsDir: uploadsDir, Lease: lease, Log: log,
	}
	if src.dbDir != "" {
		s.Snapshot = offsiteSnapshot(cfg.Database, src.dbDir, src.stateDir, log)
	}
	if rc, err := reaperConfig(cfg); err != nil {
		fmt.Fprintf(log, "felis offsite: world objects no backup records are kept: %v\n", err)
	} else {
		s.OrphanAfter = max(rc.Retention, rc.ManualRetention, rc.ScheduledRetention)
	}
	return s
}

// offsiteSnapshot takes the bundle a pass sends after copying world archives
// (offsite.Syncer.Snapshot): what `felis db backup` takes, labelled offsite,
// with the newest one kept in dir. It is not recorded for the panel, whose
// backup card watches felis-db-backup.timer: snapshots come only when archives
// are copied, and would hide a daily timer that stopped.
func offsiteSnapshot(db config.DatabaseConfig, dir, stateDir string, log io.Writer) func(context.Context) error {
	return func(ctx context.Context) error {
		tools, err := dbTools(db)
		if err != nil {
			return err
		}
		ctx, cancel := context.WithTimeout(ctx, 30*time.Minute)
		defer cancel()
		path, err := dbbackup.Backup(ctx, dbbackup.BackupOptions{
			DatabaseURL: db.URL, Tools: tools, Dir: dir, Label: dbbackup.LabelOffsite,
			Keep: defaultKeep[dbbackup.LabelOffsite], StateDir: stateDir, Version: resolvedVersion(),
			ExportServers: exportMinecraftServers, Log: log,
		})
		if err == nil {
			fmt.Fprintf(log, "felis offsite: took database bundle %s, which lists the archives just copied\n", filepath.Base(path))
		}
		return err
	}
}

// volumeKind names a PVC the off-site copy reads or restores, for messages,
// with the flag that bypasses finding it through the cluster.
type volumeKind struct{ what, dirFlag, empty string }
+64 −0
Changes for cmd/felis/offsite_test.go: 64 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -673,3 +673,67 @@ func TestRestoredHostKeepsStandingBy(t *testing.T) {
		t.Errorf("upgraded host's status = %+v", st)
	}
}

// TestOffsiteSyncerSnapshotsAndSweeps: the pass `offsite sync` runs takes its
// snapshot the way `felis db backup` does, in the database pod, into the
// bundle directory, keeping the newest one there; and it sweeps unrecorded
// world objects only past the longest retention [archive] gives any backup.
func TestOffsiteSyncerSnapshotsAndSweeps(t *testing.T) {
	dir := newPodRig(t)
	bundles := filepath.Join(dir, "bundles")
	env := &offsiteEnv{cfg: config.OffsiteConfig{DBKeep: 5}}
	var log bytes.Buffer
	cfg := &config.Config{Database: podDB, Archive: config.ArchiveConfig{Retention: "120d"}}
	s := offsiteSyncer(cfg, env, offsiteSources{dbDir: bundles}, "/archives", "/uploads", nil, &log)
	if s.DBDir != bundles || s.DBKeep != 5 || s.ArchiveDir != "/archives" || s.UploadsDir != "/uploads" {
		t.Fatalf("syncer = %+v", s)
	}
	if s.OrphanAfter != 120*24*time.Hour {
		t.Errorf("OrphanAfter = %s, want the 120d retention", s.OrphanAfter)
	}
	if s.Snapshot == nil {
		t.Fatal("the pass takes no snapshot after copying archives")
	}
	for i := 0; i < 2; i++ {
		if err := s.Snapshot(context.Background()); err != nil {
			t.Fatalf("snapshot %d: %v", i, err)
		}
	}
	got, err := dbbackup.List(bundles)
	if err != nil || len(got) != 1 || got[0].Label != dbbackup.LabelOffsite {
		t.Fatalf("bundle directory = %+v, %v; want the newest offsite bundle alone", got, err)
	}
	if _, err := dbbackupVerify(got[0].Path); err != nil {
		t.Fatalf("the snapshot does not verify: %v", err)
	}
	// The MinecraftServer objects are exported alongside, as in the daily bundle.
	argv, _ := os.ReadFile(filepath.Join(dir, "k3s.args"))
	if ran := string(argv); !strings.Contains(ran, podExecPrefix+"pg_dump --format=custom") || !strings.Contains(ran, "kubectl get minecraftservers") {
		t.Errorf("k3s ran %q, want pg_dump in the pod and the server export", ran)
	}
	if !strings.Contains(log.String(), "took database bundle "+got[0].Name) {
		t.Errorf("the snapshot is not logged:\n%s", log.String())
	}

	for _, c := range []struct {
		archive config.ArchiveConfig
		want    time.Duration
	}{
		{config.ArchiveConfig{}, 90 * 24 * time.Hour},
		{config.ArchiveConfig{ScheduledRetention: "200d"}, 200 * 24 * time.Hour},
		{config.ArchiveConfig{ManualRetention: "150d", Retention: "30d", ScheduledRetention: "60d"}, 150 * 24 * time.Hour},
	} {
		s := offsiteSyncer(&config.Config{Database: podDB, Archive: c.archive}, env, offsiteSources{dbDir: bundles}, "", "", nil, io.Discard)
		if s.OrphanAfter != c.want {
			t.Errorf("%+v: OrphanAfter = %s, want %s", c.archive, s.OrphanAfter, c.want)
		}
	}
	log.Reset()
	s = offsiteSyncer(&config.Config{Database: podDB, Archive: config.ArchiveConfig{Retention: "soon"}}, env, offsiteSources{}, "", "", nil, &log)
	if s.OrphanAfter != 0 || !strings.Contains(log.String(), "world objects no backup records are kept") {
		t.Errorf("a retention that does not parse: OrphanAfter %s, log %q; want no sweep, said", s.OrphanAfter, log.String())
	}
	if s.Snapshot != nil {
		t.Error("a pass that copies no bundles takes a snapshot")
	}
}
+27 −7
Changes for docs/troubleshooting.md: 27 added lines, 7 removed lines.
Original line number Diff line number Diff line
@@ -2024,8 +2024,8 @@ along). One bundle is `felis-db-<UTC stamp>-<label>.tar`:

next to a `.sha256` sidecar in `sha256sum` format. **A bundle contains the
secrets; treat it like `/etc/felis` itself.** Retention per label: `daily` 14
(`FELIS_DB_BACKUP_KEEP`), `pre-migrate` 10, `pre-restore` 5, `manual` never
pruned.
(`FELIS_DB_BACKUP_KEEP`), `pre-migrate` 10, `pre-restore` 5, `offsite` 1
(taken by the off-site copy, §16), `manual` never pruned.

Installer knobs: `FELIS_DB_BACKUP_DIR`, `FELIS_DB_BACKUP_KEEP`,
`FELIS_DB_BACKUP_TIME`, `FELIS_DB_BACKUP_METRICS` and
@@ -2175,10 +2175,10 @@ off-site bucket (next sections) plus a fresh install. What the host holds:

| Data | On the host | In the bucket | Brought back by | Lost at most |
|---|---|---|---|---|
| Control-plane database (accounts, passkeys, ownership, quotas, audit, submissions, the `world_backups` index) | felis-postgres, `/var/lib/felis/postgres` | every bundle, copied within the hour of being written | `fetch-db`, `db restore` | changes since the newest bundle: up to a day plus an hour with the daily timer |
| Control-plane database (accounts, passkeys, ownership, quotas, audit, submissions, the `world_backups` index) | felis-postgres, `/var/lib/felis/postgres` | every bundle, copied within the hour of being written, plus a fresh one after every pass that copied a world archive | `fetch-db`, `db restore` | changes since the newest bundle: up to a day plus an hour with the daily timer |
| Host state (`/etc/felis`: secrets, both `felis.toml` copies, `offsite.env`, the mail relay password and uploads bucket keys, panel TLS pair) | `/etc/felis` | inside every bundle | `tar -x` of the bundle's `state/` | as the database |
| MinecraftServer objects | k3s | inside every bundle (`k8s/minecraftservers.json`) | `kubectl apply` | as the database |
| World archives (reaper, "Back up now", pre-restore snapshots) | `felis-backups` volume | each one within the hour | `fetch-worlds` | archives written in the last hour |
| World archives (reaper, "Back up now", pre-restore snapshots) | `felis-backups` volume | each one within the hour, followed by a bundle listing it | `fetch-worlds` | archives written in the last hour |
| Live worlds | `world-*` volumes under `/var/lib/rancher/k3s/storage` | **only as their archives** | a restore from the newest archive (§10) | everything since that world's newest archive |
| User images | `registry` volume | hourly; image lists kept 14 days | `fetch-images` | images pushed in the last hour |
| Submission uploads (modpacks awaiting or past review) | `felis-uploads` volume | hourly; upload lists kept 14 days | `fetch-uploads` | uploads of the last hour |
@@ -2313,7 +2313,9 @@ host yourself, plus the off-site encryption key if the copy is in the bucket.
   and the volume lacks, provisioning the `felis-backups` volume first if
   nothing has used it yet (a short-lived `felis-bind-felis-backups-*` pod). It
   lists any it could not find in the bucket. Restore a world from its archive
   as usual (§10, §13).
   as usual (§10, §13). With the newest bundle restored, every archive in the
   bucket is listed; an older bundle leaves the archives copied after it
   unlisted, and the sync removes those once they pass the longest retention.
8. Make this host the one that writes the bucket, and send its first copy:

   ```
@@ -2418,6 +2420,24 @@ What runs:
  its retention (`expires_at`) has passed. An object already in the bucket at
  the right size is recorded without being sent again, so a run cut short
  resumes. [GO-TESTED: `internal/offsite`]
- A run that copied a world archive then takes a fresh `offsite` database
  bundle (`felis-db-<stamp>-offsite.tar`, the same layout as a daily one, host
  state from `-state-dir`, default `/etc/felis`) and sends it, so the newest
  bundle in the bucket lists every archive there and a restore from it fetches
  them all. The host keeps one such bundle locally, and the bucket keeps it
  only while it is the newest; the `db_keep` count covers the other labels, so
  a busy day of snapshots never pushes the dailies out. A run that copied
  nothing, or whose newest bundle already postdates the last copy, takes none.
  The panel's backup card keeps watching `felis-db-backup.timer` alone.
  [GO-TESTED: `internal/offsite`, `TestOffsiteSyncerSnapshotsAndSweeps`]
  [PG-TESTED]
- A world archive in the bucket that no row lists (the database came back
  from a bundle older than the archive) is removed once it has been in the
  bucket longer than the longest `[archive]` retention (`retention`,
  `manual_retention`, `scheduled_retention`); younger ones stay, and the run
  logs how many and when each goes. The sweep skips a database that lists no
  archive at all, so a sync against a database not restored yet removes
  nothing. [GO-TESTED: `internal/offsite`]
- The same run copies the user images in the platform registry: every
  repository outside `felis/` and `mirror/`, each manifest the registry's index
  lists and every layer it names, read through the loopback hostPort. A layer
@@ -2450,8 +2470,8 @@ What runs:
  truncation, reordering and a wrong key are all refused on the way back.
  `felis-key-id` and `felis-writer` next to them hold the key's id and the
  host writing the bucket in the clear.
- A pass sends the database bundles first, then world archives, images and
  uploads. Each object has its own time limit: 10 minutes plus its size at
- A pass sends the database bundles first, then world archives, the bundle
  listing them, images and uploads. Each object has its own time limit: 10 minutes plus its size at
  512 KiB/s (about 6 hours for 10 GiB). An archive the uplink cannot send in
  that time fails alone, stays pending and is tried again next pass; the rest
  of the pass still goes. A pass over a big archive can run for hours; the
+4 −0
Changes for internal/dbbackup/dbbackup.go: 4 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -62,6 +62,10 @@ const (
	LabelPreMigrate = "pre-migrate"
	LabelPreRestore = "pre-restore"
	LabelManual     = "manual"
	// LabelOffsite is the bundle an off-site copy takes after copying world
	// archives (internal/offsite), so the newest bundle off the machine lists
	// them.
	LabelOffsite = "offsite"

	manifestEntry = "MANIFEST.json"
	dumpEntry     = "db.dump"
Loading