fix(offsite): fetch-db latest 跳过新装机器的空库包,取包后显示包内账号和服务器数

This commit is contained in:
Lemon-miaow committed 2026-09-27 06:52:09 +08:00
1 parent aa73a8ca28
commit fa46733859
11 files changed
+720 -34

No files matched your search

+5 -4
View File
@@ -211,8 +211,8 @@ func dbRestore(fs *flag.FlagSet, dir *string, args []string, stdout, stderr io.W
return 1
}
if !*yes {
fmt.Fprintf(stderr, "felis db restore: this replaces every table in the felis database with %s (%s, taken %s, schema %d).\n",
filepath.Base(bundle), m.Label, m.CreatedAt.Format(time.RFC3339), m.SchemaVersion)
fmt.Fprintf(stderr, "felis db restore: this replaces every table in the felis database with %s (%s, taken %s, schema %d, holding %s).\n",
filepath.Base(bundle), m.Label, m.CreatedAt.Format(time.RFC3339), m.SchemaVersion, m.Counts.String())
fmt.Fprintln(stderr, "Scale felis-api and felis-operator to 0 first, then re-run with -yes.")
return 2
}
@@ -268,8 +268,9 @@ func dbVerify(fs *flag.FlagSet, dir *string, args []string, stdout, stderr io.Wr
fmt.Fprintf(stderr, "felis db verify: %v\n", err)
return 1
}
fmt.Fprintf(stdout, "%s: ok\n taken %s (%s)\n felis %s\n schema %d\n %s\n",
filepath.Base(bundle), m.CreatedAt.Format(time.RFC3339), m.Label, orUnknown(m.FelisVersion), m.SchemaVersion, orUnknown(m.PGDumpVersion))
fmt.Fprintf(stdout, "%s: ok\n taken %s (%s)\n felis %s\n schema %d\n holds %s\n %s\n",
filepath.Base(bundle), m.CreatedAt.Format(time.RFC3339), m.Label, orUnknown(m.FelisVersion), m.SchemaVersion,
m.Counts.String(), orUnknown(m.PGDumpVersion))
for _, f := range m.Files {
if f.Link != "" {
fmt.Fprintf(stdout, " %-40s -> %s\n", f.Name, f.Link)
+22 -1
View File
@@ -29,6 +29,26 @@ func TestDBUsage(t *testing.T) {
}
}
func TestDBVerifySaysWhatTheBundleHolds(t *testing.T) {
dir := newPodRig(t)
cfg := podConfig(t, dir)
bundles := filepath.Join(dir, "bundles")
var out, errBuf bytes.Buffer
if code := run([]string{"db", "backup", "-config", cfg, "-dir", bundles, "-state-dir", "", "-no-servers"}, &out, &errBuf); code != 0 {
t.Fatalf("backup: exit %d: %s", code, errBuf.String())
}
bundle := strings.TrimSpace(strings.TrimPrefix(out.String(), "felis db backup: wrote "))
out.Reset()
if code := run([]string{"db", "verify", "-dir", bundles, filepath.Base(bundle)}, &out, &errBuf); code != 0 {
t.Fatalf("verify: exit %d: %s", code, errBuf.String())
}
for _, want := range []string{filepath.Base(bundle) + ": ok", "schema 3", "holds 4 accounts, 2 servers", "pg_dump (PostgreSQL) 18.6"} {
if !strings.Contains(out.String(), want) {
t.Errorf("verify output lacks %q:\n%s", want, out.String())
}
}
}
// TestDBRestoreNeedsYes: without -yes a restore describes the bundle and stops
// before anything reaches the database, even with -force and
// -no-safety-backup, which would otherwise let the replay run at once.
@@ -49,7 +69,7 @@ func TestDBRestoreNeedsYes(t *testing.T) {
if code != 2 {
t.Fatalf("exit %d, want 2; stderr %q", code, errBuf.String())
}
if want := filepath.Base(bundle) + " (manual, taken "; !strings.Contains(errBuf.String(), want) || !strings.Contains(errBuf.String(), "schema 3).") {
if want := filepath.Base(bundle) + " (manual, taken "; !strings.Contains(errBuf.String(), want) || !strings.Contains(errBuf.String(), "schema 3, holding 4 accounts, 2 servers).") {
t.Errorf("stderr %q does not describe the bundle", errBuf.String())
}
if !strings.Contains(errBuf.String(), "re-run with -yes") {
@@ -238,6 +258,7 @@ printf 'PGDMP-fake-archive'
cat > /dev/null
`,
"psql": `#!/bin/sh
for a in "$@"; do case "$a" in *"FROM users"*) echo "4|2"; exit 0 ;; esac; done
for a in "$@"; do [ "$a" = "-c" ] && { echo 3; exit 0; }; done
cat > /dev/null
`,
+33 -15
View File
@@ -501,10 +501,7 @@ func printOffsiteList(env *offsiteEnv, stdout, stderr io.Writer) int {
fmt.Fprintf(stderr, "felis offsite list: %v\n", err)
return 1
}
fmt.Fprintf(stdout, "database bundles (%d, newest first):\n", len(bundles))
for _, b := range bundles {
fmt.Fprintf(stdout, " %s %s\n", b.Key, offsite.HumanBytes(b.Size))
}
printDBBundles(ctx, env.bucket, env.key, bundles, stdout)
var total int64
for _, w := range worlds {
total += w.Size
@@ -541,6 +538,20 @@ func printOffsiteList(env *offsiteEnv, stdout, stderr io.Writer) int {
return 0
}
// printDBBundles lists the database bundles with what each one's database
// held, read off the front of each, so a restore can pick one by its contents.
func printDBBundles(ctx context.Context, b offsite.Bucket, key []byte, bundles []offsite.Object, stdout io.Writer) {
fmt.Fprintf(stdout, "database bundles (%d, newest first; restore one with fetch-db):\n", len(bundles))
for _, o := range bundles {
m, err := offsite.PeekDB(ctx, b, key, o.Key)
if err != nil {
fmt.Fprintf(stdout, " %s %s unreadable: %v\n", o.Key, offsite.HumanBytes(o.Size), err)
continue
}
fmt.Fprintf(stdout, " %s %s %s\n", o.Key, offsite.HumanBytes(o.Size), m.Counts.String())
}
}
func offsiteFetchDB(fs *flag.FlagSet, args []string, stdout, stderr io.Writer) int {
cfgPath := fs.String("config", "/etc/felis/felis.toml", "path to felis.toml; on a host with no install yet, give -endpoint and -bucket instead")
envFile := fs.String("env-file", defaultOffsiteEnvFile, "file with the [offsite] secrets, for variables not already set")
@@ -583,37 +594,44 @@ func offsiteFetchDB(fs *flag.FlagSet, args []string, stdout, stderr io.Writer) i
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute)
defer cancel()
return fetchDB(ctx, env.bucket, env.key, arg, *dir, time.Now(), stdout, stderr)
}
// fetchDB is fetch-db once the bucket is open: arg is a bundle name or latest.
func fetchDB(ctx context.Context, b offsite.Bucket, key []byte, arg, dir string, now time.Time, stdout, stderr io.Writer) int {
name := arg
if name == "latest" {
bundles, err := offsite.ListDB(ctx, env.bucket)
if err != nil {
var err error
if name, _, err = offsite.ChooseDB(ctx, b, key); err != nil {
fmt.Fprintf(stderr, "felis offsite fetch-db: %v\n", err)
return 1
}
if len(bundles) == 0 {
fmt.Fprintln(stderr, "felis offsite fetch-db: the bucket holds no database bundle")
return 1
}
name = bundles[0].Key
}
if _, _, ok := dbbackup.ParseBundleName(name); !ok {
fmt.Fprintf(stderr, "felis offsite fetch-db: %q is not a bundle name (felis-db-<stamp>-<label>.tar); see `felis offsite list`\n", name)
return 2
}
if err := os.MkdirAll(*dir, 0o700); err != nil {
if err := os.MkdirAll(dir, 0o700); err != nil {
fmt.Fprintf(stderr, "felis offsite fetch-db: %v\n", err)
return 1
}
dst := filepath.Join(*dir, name)
if err := offsite.FetchObject(ctx, env.bucket, env.key, offsite.DBKey(name), dst, 0o600); err != nil {
dst := filepath.Join(dir, name)
if err := offsite.FetchObject(ctx, b, key, offsite.DBKey(name), dst, 0o600); err != nil {
fmt.Fprintf(stderr, "felis offsite fetch-db: %v\n", err)
return 1
}
if _, err := dbbackup.Verify(dst); err != nil {
m, err := dbbackup.Verify(dst)
if err != nil {
fmt.Fprintf(stderr, "felis offsite fetch-db: fetched %s but it does not verify: %v\n", dst, err)
return 1
}
fmt.Fprintf(stdout, "felis offsite fetch-db: wrote %s (verified)\n", dst)
fmt.Fprintf(stdout, " taken %s (%s, %s ago)\n felis %s, schema %d\n holds %s\n",
m.CreatedAt.Format(time.RFC3339), m.Label, dbbackup.Age(now.Sub(m.CreatedAt)),
orUnknown(m.FelisVersion), m.SchemaVersion, m.Counts.String())
if m.Counts.Fresh() {
fmt.Fprintln(stdout, " This database holds no servers and at most one account, like a new install's. Check it is the state to restore before `felis db restore`.")
}
return 0
}
+197
View File
@@ -1,14 +1,23 @@
package main
import (
"archive/tar"
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"io"
"os"
"path/filepath"
"slices"
"strings"
"testing"
"time"
"felis.lolicon.best/internal/config"
"felis.lolicon.best/internal/dbbackup"
"felis.lolicon.best/internal/imagepush"
"felis.lolicon.best/internal/offsite"
)
@@ -120,3 +129,191 @@ func TestRegistryGoneMarksNotFound(t *testing.T) {
t.Fatalf("503 = %v, want it kept an ordinary failure", err)
}
}
// mapBucket is an in-memory offsite.Bucket.
type mapBucket map[string][]byte
func (b mapBucket) Put(_ context.Context, key string, r io.Reader, _ int64) error {
data, err := io.ReadAll(r)
b[key] = data
return err
}
func (b mapBucket) Get(_ context.Context, key string) (io.ReadCloser, error) {
data, ok := b[key]
if !ok {
return nil, offsite.ErrNotFound
}
return io.NopCloser(bytes.NewReader(data)), nil
}
func (b mapBucket) List(_ context.Context, prefix string) ([]offsite.Object, error) {
var out []offsite.Object
for k, v := range b {
if strings.HasPrefix(k, prefix) {
out = append(out, offsite.Object{Key: k, Size: int64(len(v))})
}
}
return out, nil
}
func (b mapBucket) Remove(_ context.Context, key string) error {
delete(b, key)
return nil
}
var fetchT0 = time.Date(2026, 9, 20, 3, 30, 0, 0, time.UTC)
// putBundle seals a bundle that verifies, taken daysAgo days before fetchT0,
// into b and returns its name.
func putBundle(t *testing.T, b mapBucket, key []byte, daysAgo int, counts *dbbackup.Counts) string {
t.Helper()
created := fetchT0.AddDate(0, 0, -daysAgo)
dump := []byte("PGDMP " + created.String())
sum := sha256.Sum256(dump)
manifest, err := json.Marshal(dbbackup.Manifest{
Format: 1, CreatedAt: created, Label: dbbackup.LabelDaily, FelisVersion: "v1.2.3", SchemaVersion: 21, Counts: counts,
Files: []dbbackup.ManifestEntry{{Name: "db.dump", Size: int64(len(dump)), SHA256: hex.EncodeToString(sum[:]), Mode: 0o600}},
})
if err != nil {
t.Fatal(err)
}
var plain bytes.Buffer
tw := tar.NewWriter(&plain)
for _, f := range []struct {
name string
data []byte
}{{"MANIFEST.json", manifest}, {"db.dump", dump}} {
if err := tw.WriteHeader(&tar.Header{Name: f.name, Mode: 0o600, Size: int64(len(f.data)), Typeflag: tar.TypeReg}); err != nil {
t.Fatal(err)
}
if _, err := tw.Write(f.data); err != nil {
t.Fatal(err)
}
}
if err := tw.Close(); err != nil {
t.Fatal(err)
}
var sealed bytes.Buffer
if err := offsite.Encrypt(&sealed, &plain, key); err != nil {
t.Fatal(err)
}
name := dbbackup.BundleName(created, dbbackup.LabelDaily)
b[offsite.DBKey(name)] = sealed.Bytes()
return name
}
func TestOffsiteFetchDB(t *testing.T) {
rawKey, _ := offsite.NewKey()
key, _ := offsite.ParseKey(rawKey)
now := fetchT0.Add(2 * time.Hour)
fetch := func(b mapBucket, arg string) (dir string, code int, stdout, stderr string) {
dir = t.TempDir()
var out, errb bytes.Buffer
code = fetchDB(context.Background(), b, key, arg, dir, now, &out, &errb)
return dir, code, out.String(), errb.String()
}
fetched := func(t *testing.T, dir string) []string {
t.Helper()
var names []string
entries, _ := os.ReadDir(dir)
for _, e := range entries {
names = append(names, e.Name())
}
return names
}
t.Run("latest skips a rebuilt host's empty bundle", func(t *testing.T) {
b := mapBucket{}
full := putBundle(t, b, key, 3, &dbbackup.Counts{Users: 5, Servers: 3})
empty := putBundle(t, b, key, 0, &dbbackup.Counts{})
dir, code, out, errb := fetch(b, "latest")
if code != 1 || !strings.Contains(errb, empty) || !strings.Contains(errb, full+" (5 accounts, 3 servers)") {
t.Fatalf("exit %d, stdout %q, stderr %q; want a refusal naming %s", code, out, errb, full)
}
if got := fetched(t, dir); len(got) != 0 {
t.Errorf("a refused fetch wrote %v", got)
}
})
t.Run("latest takes the newest bundle and says what it holds", func(t *testing.T) {
b := mapBucket{}
putBundle(t, b, key, 3, &dbbackup.Counts{Users: 5, Servers: 2})
newest := putBundle(t, b, key, 1, &dbbackup.Counts{Users: 5, Servers: 3})
dir, code, out, errb := fetch(b, "latest")
if code != 0 {
t.Fatalf("exit %d: %s", code, errb)
}
if got := fetched(t, dir); !slices.Equal(got, []string{newest}) {
t.Errorf("wrote %v, want %s", got, newest)
}
for _, want := range []string{
"wrote " + filepath.Join(dir, newest) + " (verified)",
"taken 2026-09-19T03:30:00Z (daily, 26h0m ago)",
"felis v1.2.3, schema 21",
"holds 5 accounts, 3 servers",
} {
if !strings.Contains(out, want) {
t.Errorf("stdout lacks %q:\n%s", want, out)
}
}
if strings.Contains(out, "new install") {
t.Errorf("a bundle with servers flagged as a new install's:\n%s", out)
}
})
t.Run("an empty bundle named outright is fetched with a warning", func(t *testing.T) {
b := mapBucket{}
putBundle(t, b, key, 3, &dbbackup.Counts{Users: 5, Servers: 3})
empty := putBundle(t, b, key, 0, &dbbackup.Counts{Users: 1})
dir, code, out, errb := fetch(b, empty)
if code != 0 || !slices.Equal(fetched(t, dir), []string{empty}) {
t.Fatalf("exit %d, wrote %v: %s", code, fetched(t, dir), errb)
}
if !strings.Contains(out, "holds 1 account, 0 servers") || !strings.Contains(out, "like a new install's") {
t.Errorf("stdout = %s", out)
}
})
t.Run("a bundle from before counts says so", func(t *testing.T) {
b := mapBucket{}
old := putBundle(t, b, key, 0, nil)
_, code, out, errb := fetch(b, "latest")
if code != 0 || !strings.Contains(out, old) || !strings.Contains(out, "holds not recorded") || strings.Contains(out, "new install") {
t.Errorf("exit %d, stdout %q, stderr %q", code, out, errb)
}
})
}
func TestPrintDBBundlesSaysWhatEachHolds(t *testing.T) {
rawKey, _ := offsite.NewKey()
key, _ := offsite.ParseKey(rawKey)
otherRaw, _ := offsite.NewKey()
other, _ := offsite.ParseKey(otherRaw)
b := mapBucket{}
old := putBundle(t, b, key, 3, nil)
full := putBundle(t, b, key, 2, &dbbackup.Counts{Users: 5, Servers: 3})
sealedElsewhere := putBundle(t, b, other, 1, &dbbackup.Counts{Users: 5, Servers: 3})
empty := putBundle(t, b, key, 0, &dbbackup.Counts{})
bundles, err := offsite.ListDB(context.Background(), b)
if err != nil {
t.Fatal(err)
}
var out bytes.Buffer
printDBBundles(context.Background(), b, key, bundles, &out)
lines := strings.Split(strings.TrimSpace(out.String()), "\n")
want := []struct{ name, holds string }{
{empty, "0 accounts, 0 servers"},
{sealedElsewhere, "unreadable: offsite: object does not decrypt with this key"},
{full, "5 accounts, 3 servers"},
{old, "not recorded"},
}
if len(lines) != len(want)+1 || !strings.HasPrefix(lines[0], "database bundles (4, newest first") {
t.Fatalf("output:\n%s", out.String())
}
for i, w := range want {
if l := lines[i+1]; !strings.HasPrefix(l, " "+w.name+" ") || !strings.Contains(l, w.holds) {
t.Errorf("line %d = %q, want %s with %q", i+1, l, w.name, w.holds)
}
}
}
+13 -1
View File
@@ -2145,9 +2145,21 @@ host yourself, plus the off-site encryption key if the copy is in the bucket.
```
It writes the bundle into `/var/lib/felis/db-backups` (`-dir` to change),
checks it (`felis db verify`) and names it. A wrong key fails with
checks it (`felis db verify`) and names it, with when it was taken, the
release and schema that took it, and how many accounts and servers its
database holds. Read those before going on. A wrong key fails with
`object does not decrypt with this key` and writes nothing. For a copy you
made yourself, check it with `sha256sum -c felis-db-....tar.sha256`.
`latest` is the newest bundle, unless that one holds no servers and at
most one account while an older one holds more. That is the database a
rebuilt host backs up and copies off-site within its first hour, before
anyone restores onto it, so `fetch-db latest` refuses it and names up to
three older bundles with their counts; fetch the one you want by name in
place of `latest` (`felis offsite list` shows them all, each with its
counts). A bundle fetched by name that looks like a new install's is
written with a warning. Bundles from releases before the counts were
recorded show `not recorded`.
2. Put the old host's state in place **before** installing, so the installer
reuses the same DB password, session secret, forwarding secret, the mail
relay password and uploads bucket keys `felis setup` took, and the
+47
View File
@@ -168,10 +168,40 @@ type Manifest struct {
SchemaVersion int `json:"schema_version,omitempty"`
PGDumpVersion string `json:"pg_dump_version,omitempty"`
Files []ManifestEntry `json:"files"`
// Counts is nil in bundles taken before it was recorded, or when the
// database did not answer the count.
Counts *Counts `json:"counts,omitempty"`
// ServersError is why k8s/minecraftservers.json is absent, when it is.
ServersError string `json:"servers_error,omitempty"`
}
// Counts is how much the database held when the bundle was taken: accounts
// and servers, deleted ones left out.
type Counts struct {
Users int `json:"users"`
Servers int `json:"servers"`
}
// Fresh is whether the database looks like a new install's: no servers and
// at most the owner the first-run setup creates. A host rebuilt after a loss
// backs up (and syncs off-site) such a database before anyone restores onto
// it, so a restore that picks bundles by date alone would pick it.
func (c *Counts) Fresh() bool { return c != nil && c.Servers == 0 && c.Users <= 1 }
// String is what the CLI prints for the counts.
func (c *Counts) String() string {
if c == nil {
return "not recorded"
}
count := func(n int, what string) string {
if n == 1 {
return "1 " + what
}
return fmt.Sprintf("%d %ss", n, what)
}
return count(c.Users, "account") + ", " + count(c.Servers, "server")
}
// DatabaseInfo is the connection a bundle was taken from, password excluded.
type DatabaseInfo struct {
Host string `json:"host"`
@@ -476,6 +506,7 @@ func Backup(ctx context.Context, o BackupOptions) (string, error) {
m.PGDumpVersion = strings.TrimSpace(string(out))
}
m.SchemaVersion = schemaVersion(ctx, c, o.Tools)
m.Counts = counts(ctx, c, o.Tools)
var members []member
dm, err := fileMember(dumpEntry, dump)
@@ -613,6 +644,22 @@ func schemaVersion(ctx context.Context, c conn, t Tools) int {
return v
}
// counts reads the database's Counts, or nil when it does not answer.
func counts(ctx context.Context, c conn, t Tools) *Counts {
out, err := run(t.command(ctx, c, t.psql(), "-X", "-q", "-t", "-A", "-w", "-d", t.dsn(c),
"-c", "SELECT (SELECT count(*) FROM users WHERE deleted_at IS NULL), (SELECT count(*) FROM servers WHERE deleted_at IS NULL)"))
if err != nil {
return nil
}
users, servers, _ := strings.Cut(strings.TrimSpace(string(out)), "|")
u, uerr := strconv.Atoi(users)
s, serr := strconv.Atoi(servers)
if uerr != nil || serr != nil {
return nil
}
return &Counts{Users: u, Servers: s}
}
// member is one bundle entry: a file on disk, bytes, or a symlink.
type member struct {
entry ManifestEntry
+90 -4
View File
@@ -2,6 +2,7 @@ package dbbackup
import (
"archive/tar"
"bytes"
"context"
"encoding/json"
"errors"
@@ -12,6 +13,7 @@ import (
"slices"
"strings"
"testing"
"testing/iotest"
"time"
)
@@ -56,6 +58,7 @@ if [ -n "$q" ]; then
case "$q" in
*schema_migrations*) echo 21;;
*pg_stat_activity*) cat "$D/clients" 2>/dev/null || echo 0;;
*"FROM users"*) cat "$D/counts" 2>/dev/null || echo "3|2";;
esac
exit 0
fi
@@ -236,6 +239,9 @@ func TestBackupWritesAVerifiableBundle(t *testing.T) {
if m.Label != LabelDaily || m.FelisVersion != "v1.2.3" || m.SchemaVersion != 21 || !m.CreatedAt.Equal(t0) {
t.Errorf("manifest = %+v", m)
}
if m.Counts == nil || *m.Counts != (Counts{Users: 3, Servers: 2}) {
t.Errorf("counts = %v, want the 3 accounts and 2 servers psql answered", m.Counts)
}
if m.Database != (DatabaseInfo{Host: "127.0.0.1", Port: "5432", Name: "felis", User: "felis"}) {
t.Errorf("database = %+v", m.Database)
}
@@ -320,6 +326,86 @@ func TestBackupRecordsAClusterThatDidNotAnswer(t *testing.T) {
}
}
func TestBackupCounts(t *testing.T) {
for _, tc := range []struct {
reply string
want *Counts
fresh bool
}{
{"0|0", &Counts{}, true},
{"1|0", &Counts{Users: 1}, true},
{"2|0", &Counts{Users: 2}, false},
{"1|1", &Counts{Users: 1, Servers: 1}, false},
{"", nil, false}, // the query failed: psql printed nothing
{"17", nil, false}, // not the two columns asked for
{"x|2", nil, false}, // not numbers
} {
pg := newFakePG(t, "x\n")
if err := os.WriteFile(filepath.Join(pg.dir, "counts"), []byte(tc.reply+"\n"), 0o600); err != nil {
t.Fatal(err)
}
path, err := Backup(context.Background(), BackupOptions{DatabaseURL: testURL, Dir: t.TempDir(), Label: LabelManual, Tools: pg.tools, Now: at(t0)})
if err != nil {
t.Fatal(err)
}
m, err := Verify(path)
if err != nil {
t.Fatal(err)
}
if (m.Counts == nil) != (tc.want == nil) || (m.Counts != nil && *m.Counts != *tc.want) {
t.Errorf("reply %q: counts = %v, want %v", tc.reply, m.Counts, tc.want)
}
if m.Counts.Fresh() != tc.fresh {
t.Errorf("reply %q: Fresh = %v, want %v", tc.reply, m.Counts.Fresh(), tc.fresh)
}
}
if got := (*Counts)(nil).String(); got != "not recorded" {
t.Errorf("nil counts print %q", got)
}
if got := (&Counts{Users: 3, Servers: 2}).String(); got != "3 accounts, 2 servers" {
t.Errorf("counts print %q", got)
}
if got := (&Counts{Users: 1, Servers: 1}).String(); got != "1 account, 1 server" {
t.Errorf("counts print %q", got)
}
}
func TestReadManifestStopsAtTheManifest(t *testing.T) {
pg := newFakePG(t, strings.Repeat("row\n", 64))
path, err := Backup(context.Background(), BackupOptions{DatabaseURL: testURL, Dir: t.TempDir(), Label: LabelDaily, Version: "v1.2.3", Tools: pg.tools, Now: at(t0)})
if err != nil {
t.Fatal(err)
}
raw, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
// Everything after the manifest is withheld: the reader fails past it.
cut := strings.Index(string(raw), "PGDMP")
cutErr := errors.New("the rest has not arrived")
m, err := ReadManifest(io.MultiReader(bytes.NewReader(raw[:cut]), iotest.ErrReader(cutErr)))
if err != nil {
t.Fatalf("ReadManifest read past the manifest: %v", err)
}
if m.Label != LabelDaily || m.FelisVersion != "v1.2.3" || !m.CreatedAt.Equal(t0) || m.Counts == nil || m.Counts.Users != 3 {
t.Errorf("manifest = %+v", m)
}
// A stream that fails before the manifest is whole says why.
if _, err := ReadManifest(io.MultiReader(bytes.NewReader(raw[:100]), iotest.ErrReader(cutErr))); !errors.Is(err, cutErr) {
t.Errorf("stream failing in the header: err = %v, want %v", err, cutErr)
}
for what, r := range map[string]io.Reader{
"empty": bytes.NewReader(nil),
"not tar": strings.NewReader(strings.Repeat("junk", 200)),
"cut tar": bytes.NewReader(raw[:100]),
} {
if _, err := ReadManifest(r); !errors.Is(err, errNotBundle) {
t.Errorf("%s: err = %v, want errNotBundle", what, err)
}
}
}
func TestBackupsWithinOneSecondGetDistinctNames(t *testing.T) {
pg := newFakePG(t, "x\n")
dir := t.TempDir()
@@ -412,7 +498,7 @@ func TestBackupAndRestoreThroughThePod(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if m.SchemaVersion != 21 || !strings.Contains(m.PGDumpVersion, "13.23") {
if m.SchemaVersion != 21 || !strings.Contains(m.PGDumpVersion, "13.23") || m.Counts == nil || m.Counts.Servers != 2 {
t.Errorf("manifest = %+v", m)
}
args, _ := os.ReadFile(filepath.Join(pg.dir, "pg_dump.args"))
@@ -425,9 +511,9 @@ func TestBackupAndRestoreThroughThePod(t *testing.T) {
if rec, _ := os.ReadFile(filepath.Join(pg.dir, "record.args")); !strings.Contains(string(rec), podConn) {
t.Errorf("freshness record args = %s", rec)
}
// dump, list, --version, schema version, record
if n := pg.execRuns(t); n != 5 {
t.Errorf("%d tool runs went through kubectl exec, want 5", n)
// dump, list, --version, schema version, counts, record
if n := pg.execRuns(t); n != 6 {
t.Errorf("%d tool runs went through kubectl exec, want 6", n)
}
pg.setDB(t, "users: alice\nusers: bob\n")
+35 -9
View File
@@ -68,6 +68,39 @@ func Verify(path string) (Manifest, error) {
return readBundle(path, nil)
}
var errNotBundle = fmt.Errorf("not a felis database bundle (no %s)", manifestEntry)
// ReadManifest reads the manifest off the front of a bundle stream and stops
// there, so a bundle can be described before all of it is downloaded. The
// members it lists are not checked; Verify does that. An error from r itself
// is returned as is.
func ReadManifest(r io.Reader) (Manifest, error) {
return readManifest(tar.NewReader(r))
}
func readManifest(tr *tar.Reader) (Manifest, error) {
var m Manifest
first, err := tr.Next()
switch {
case err == nil && first.Name == manifestEntry:
case err == nil, errors.Is(err, io.EOF), errors.Is(err, io.ErrUnexpectedEOF), errors.Is(err, tar.ErrHeader):
return m, errNotBundle
default:
return m, err
}
raw, err := io.ReadAll(io.LimitReader(tr, 1<<20))
if err != nil {
return m, err
}
if err := json.Unmarshal(raw, &m); err != nil {
return m, fmt.Errorf("read %s: %w", manifestEntry, err)
}
if m.Format != formatV1 {
return m, fmt.Errorf("bundle format %d is not one this felis reads (want %d)", m.Format, formatV1)
}
return m, nil
}
// readBundle is Verify that also copies db.dump to dumpTo when non-nil.
func readBundle(path string, dumpTo io.Writer) (Manifest, error) {
var m Manifest
@@ -79,20 +112,13 @@ func readBundle(path string, dumpTo io.Writer) (Manifest, error) {
whole := sha256.New()
tr := tar.NewReader(io.TeeReader(f, whole))
first, err := tr.Next()
if err != nil || first.Name != manifestEntry {
m, err = readManifest(tr)
if errors.Is(err, errNotBundle) {
return m, fmt.Errorf("%s is not a felis database bundle (no %s)", filepath.Base(path), manifestEntry)
}
raw, err := io.ReadAll(io.LimitReader(tr, 1<<20))
if err != nil {
return m, err
}
if err := json.Unmarshal(raw, &m); err != nil {
return m, fmt.Errorf("read %s: %w", manifestEntry, err)
}
if m.Format != formatV1 {
return m, fmt.Errorf("bundle format %d is not one this felis reads (want %d)", m.Format, formatV1)
}
want := map[string]ManifestEntry{}
for _, e := range m.Files {
want[e.Name] = e
+203
View File
@@ -0,0 +1,203 @@
package offsite
import (
"archive/tar"
"bytes"
"context"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"strings"
"testing"
"time"
"felis.lolicon.best/internal/dbbackup"
)
var dbT0 = time.Date(2026, 9, 20, 3, 30, 0, 0, time.UTC)
// dbBundle is a bundle as dbbackup.Backup lays it out: MANIFEST.json, then a
// dump of dumpSize random bytes.
func dbBundle(t *testing.T, created time.Time, counts *dbbackup.Counts, dumpSize int) []byte {
t.Helper()
dump := make([]byte, dumpSize)
rand.Read(dump)
sum := sha256.Sum256(dump)
m := dbbackup.Manifest{
Format: 1, CreatedAt: created, Label: dbbackup.LabelDaily, FelisVersion: "v1.2.3", SchemaVersion: 21, Counts: counts,
Files: []dbbackup.ManifestEntry{{Name: "db.dump", Size: int64(dumpSize), SHA256: hex.EncodeToString(sum[:]), Mode: 0o600}},
}
raw, err := json.Marshal(m)
if err != nil {
t.Fatal(err)
}
var buf bytes.Buffer
tw := tar.NewWriter(&buf)
for _, f := range []struct {
name string
data []byte
}{{"MANIFEST.json", raw}, {"db.dump", dump}} {
if err := tw.WriteHeader(&tar.Header{Name: f.name, Mode: 0o600, Size: int64(len(f.data)), ModTime: created, Typeflag: tar.TypeReg}); err != nil {
t.Fatal(err)
}
if _, err := tw.Write(f.data); err != nil {
t.Fatal(err)
}
}
if err := tw.Close(); err != nil {
t.Fatal(err)
}
return buf.Bytes()
}
// putDB seals a bundle taken daysAgo days before dbT0 into b and returns its name.
func putDB(t *testing.T, b *memBucket, key []byte, daysAgo int, counts *dbbackup.Counts) string {
t.Helper()
created := dbT0.AddDate(0, 0, -daysAgo)
name := dbbackup.BundleName(created, dbbackup.LabelDaily)
b.objs[DBKey(name)] = seal(t, dbBundle(t, created, counts, 100), key)
return name
}
func TestPeekDBReadsOnlyTheManifest(t *testing.T) {
key := testKey(t)
b := newMemBucket()
name := dbbackup.BundleName(dbT0, dbbackup.LabelDaily)
sealed := seal(t, dbBundle(t, dbT0, &dbbackup.Counts{Users: 4, Servers: 2}, 5*segmentSize), key)
// The last segment is damaged: a whole download fails, the peek never gets there.
tail := bytes.Clone(sealed)
tail[len(tail)-1] ^= 1
b.objs[DBKey(name)] = tail
m, err := PeekDB(context.Background(), b, key, name)
if err != nil {
t.Fatalf("PeekDB read past the manifest: %v", err)
}
if !m.CreatedAt.Equal(dbT0) || m.FelisVersion != "v1.2.3" || m.Counts == nil || *m.Counts != (dbbackup.Counts{Users: 4, Servers: 2}) {
t.Errorf("manifest = %+v", m)
}
// The segment holding the manifest is authenticated like any other.
head := bytes.Clone(sealed)
head[headerSize+10] ^= 1
b.objs[DBKey(name)] = head
if _, err := PeekDB(context.Background(), b, key, name); !errors.Is(err, ErrAuth) {
t.Errorf("damaged manifest segment: err = %v, want ErrAuth", err)
}
b.objs[DBKey(name)] = sealed
if _, err := PeekDB(context.Background(), b, testKey(t), name); !errors.Is(err, ErrAuth) {
t.Errorf("another key: err = %v, want ErrAuth", err)
}
b.objs[DBKey(name)] = seal(t, []byte("not a bundle"), key)
if _, err := PeekDB(context.Background(), b, key, name); err == nil || !strings.Contains(err.Error(), "not a felis database bundle") {
t.Errorf("sealed junk: err = %v", err)
}
if _, err := PeekDB(context.Background(), b, key, "felis-db-20260101T000000Z-daily.tar"); !errors.Is(err, ErrNotFound) {
t.Errorf("missing bundle: err = %v, want ErrNotFound", err)
}
}
func TestChooseDB(t *testing.T) {
c := func(users, servers int) *dbbackup.Counts { return &dbbackup.Counts{Users: users, Servers: servers} }
type put struct {
daysAgo int
counts *dbbackup.Counts
}
for _, tc := range []struct {
what string
bundles []put
pick int // index into bundles, or -1 for a refusal
named []int // bundles the refusal names, in order
unnamed []int // bundles it must leave out
says []string // what else the refusal says
}{
{what: "the newest holds data", bundles: []put{{1, c(5, 3)}, {0, c(5, 2)}}, pick: 1},
{what: "the newest predates counts", bundles: []put{{1, c(5, 3)}, {0, nil}}, pick: 1},
{what: "only the owner and a server", bundles: []put{{1, c(5, 3)}, {0, c(1, 1)}}, pick: 1},
{what: "a new install with nothing older", bundles: []put{{0, c(0, 0)}}, pick: 0},
{what: "a new install over older empty ones", bundles: []put{{2, c(0, 0)}, {1, c(1, 0)}, {0, c(1, 0)}}, pick: 2},
{
what: "a rebuilt host's first backup", bundles: []put{{3, c(5, 3)}, {2, c(0, 0)}, {1, c(6, 3)}, {0, c(0, 0)}},
pick: -1, named: []int{2, 0}, unnamed: []int{1}, says: []string{"0 accounts, 0 servers", "6 accounts, 3 servers", "5 accounts, 3 servers"},
},
{
what: "the rebuilt host's owner already set up", bundles: []put{{1, c(2, 0)}, {0, c(1, 0)}},
pick: -1, named: []int{0}, says: []string{"1 account, 0 servers", "2 accounts, 0 servers"},
},
{
what: "servers the owner made before the loss", bundles: []put{{1, c(1, 2)}, {0, c(1, 0)}},
pick: -1, named: []int{0}, says: []string{"1 account, 2 servers"},
},
{
what: "older bundles from before counts", bundles: []put{{1, nil}, {0, c(0, 0)}},
pick: -1, named: []int{0}, says: []string{"(not recorded)"},
},
{
what: "at most three named", bundles: []put{{5, c(9, 9)}, {4, c(8, 8)}, {3, c(7, 7)}, {2, c(6, 6)}, {1, c(0, 0)}},
pick: -1, named: []int{3, 2, 1}, unnamed: []int{0}, says: []string{"felis offsite list"},
},
} {
t.Run(tc.what, func(t *testing.T) {
key := testKey(t)
b := newMemBucket()
var names []string
for _, p := range tc.bundles {
names = append(names, putDB(t, b, key, p.daysAgo, p.counts))
}
name, m, err := ChooseDB(context.Background(), b, key)
if tc.pick >= 0 {
if err != nil || name != names[tc.pick] {
t.Fatalf("ChooseDB = %q, %v; want %s", name, err, names[tc.pick])
}
if want := tc.bundles[tc.pick].counts; (m.Counts == nil) != (want == nil) || (want != nil && *m.Counts != *want) {
t.Errorf("manifest counts = %v, want %v", m.Counts, want)
}
return
}
if err == nil {
t.Fatalf("ChooseDB picked %s, want a refusal", name)
}
msg := err.Error()
last := -1
for _, i := range tc.named {
at := strings.Index(msg, names[i])
if at < 0 {
t.Fatalf("refusal does not name %s: %s", names[i], msg)
}
if at < last {
t.Errorf("refusal names %s out of order (newest first): %s", names[i], msg)
}
last = at
}
for _, i := range tc.unnamed {
if strings.Contains(msg, names[i]) {
t.Errorf("refusal names %s: %s", names[i], msg)
}
}
for _, s := range tc.says {
if !strings.Contains(msg, s) {
t.Errorf("refusal lacks %q: %s", s, msg)
}
}
})
}
if _, _, err := ChooseDB(context.Background(), newMemBucket(), testKey(t)); err == nil || !strings.Contains(err.Error(), "no database bundle") {
t.Errorf("empty bucket: err = %v", err)
}
// A bundle it cannot read stops the choice, named.
key := testKey(t)
b := newMemBucket()
putDB(t, b, key, 2, &dbbackup.Counts{Users: 5, Servers: 3})
other := putDB(t, b, testKey(t), 1, &dbbackup.Counts{Users: 5, Servers: 3})
newest := putDB(t, b, key, 0, &dbbackup.Counts{})
if _, _, err := ChooseDB(context.Background(), b, key); !errors.Is(err, ErrAuth) || !strings.Contains(err.Error(), other) {
t.Errorf("an older bundle under another key: err = %v, want ErrAuth naming %s", err, other)
}
if _, _, err := ChooseDB(context.Background(), b, testKey(t)); !errors.Is(err, ErrAuth) || !strings.Contains(err.Error(), newest) {
t.Errorf("the wrong key: err = %v, want ErrAuth naming %s", err, newest)
}
}
+67
View File
@@ -545,6 +545,73 @@ func ListDB(ctx context.Context, b Bucket) ([]Object, error) {
return out, nil
}
// PeekDB reads the manifest of the database bundle name, downloading and
// decrypting only as far as the manifest. Each segment is authenticated
// before any of its bytes are read, so a manifest read this way is the one
// the bundle was sealed with.
func PeekDB(ctx context.Context, b Bucket, key []byte, name string) (dbbackup.Manifest, error) {
rc, err := b.Get(ctx, DBKey(name))
if err != nil {
return dbbackup.Manifest{}, err
}
defer rc.Close()
pr, pw := io.Pipe()
done := make(chan struct{})
go func() {
defer close(done)
pw.CloseWithError(Decrypt(pw, rc, key))
}()
m, err := dbbackup.ReadManifest(pr)
pr.Close() // Decrypt stops at its next write
<-done
return m, err
}
// dbChoices is how many older bundles ChooseDB names when it refuses the
// newest; `felis offsite list` shows the rest.
const dbChoices = 3
// ChooseDB picks the bundle `fetch-db latest` means: the newest, unless its
// database looks like a new install's (dbbackup.Counts.Fresh) while an older
// bundle holds more. That newest bundle is what a rebuilt host backs up and
// syncs before its restore, so it is refused with the bundles worth naming
// instead. A bundle from before counts were recorded is taken to hold more.
func ChooseDB(ctx context.Context, b Bucket, key []byte) (string, dbbackup.Manifest, error) {
bundles, err := ListDB(ctx, b)
if err != nil {
return "", dbbackup.Manifest{}, err
}
if len(bundles) == 0 {
return "", dbbackup.Manifest{}, errors.New("the bucket holds no database bundle")
}
newest := bundles[0].Key
m, err := PeekDB(ctx, b, key, newest)
if err != nil {
return "", dbbackup.Manifest{}, fmt.Errorf("%s: %w", newest, err)
}
if !m.Counts.Fresh() {
return newest, m, nil
}
var older []string
for _, o := range bundles[1:] {
om, err := PeekDB(ctx, b, key, o.Key)
if err != nil {
return "", dbbackup.Manifest{}, fmt.Errorf("%s: %w", o.Key, err)
}
if c := om.Counts; c == nil || c.Users > m.Counts.Users || c.Servers > m.Counts.Servers {
older = append(older, fmt.Sprintf("%s (%s)", o.Key, c))
if len(older) == dbChoices {
break
}
}
}
if len(older) == 0 {
return newest, m, nil
}
return "", dbbackup.Manifest{}, fmt.Errorf("the newest bundle %s holds %s, like the database a rebuilt host backs up before its restore; "+
"fetch an older one by name instead of latest (all of them: felis offsite list): %s", newest, m.Counts, strings.Join(older, ", "))
}
// HumanBytes formats n in binary units.
func HumanBytes(n int64) string {
const unit = 1024
+8
View File
@@ -269,6 +269,11 @@ func (r *restoreRig) restore(bundle string, mut func(*dbbackup.RestoreOptions))
func TestDBRestoreRoundTrip(t *testing.T) {
r := newRestoreRig(t)
r.seed(t)
// A deleted account and server, which the manifest's counts leave out.
r.exec(t, r.url,
`INSERT INTO users (id, username, role, deleted_at) VALUES ('pgint-carol', 'carol', 'user', now())`,
`INSERT INTO servers (name, cached_cpu_milli, cached_memory_mb, cached_storage_mb, owner_id, deleted_at)
VALUES ('carol-smp', 500, 1024, 2048, 'pgint-carol', now())`)
want := r.fingerprint(t)
bundle := r.backup(t)
m, err := dbbackup.Verify(bundle)
@@ -279,6 +284,9 @@ func TestDBRestoreRoundTrip(t *testing.T) {
if m.SchemaVersion != ms[len(ms)-1].Version || !strings.HasPrefix(m.PGDumpVersion, "pg_dump (PostgreSQL) ") {
t.Errorf("manifest schema %d pg_dump %q, want schema %d and pg_dump's version", m.SchemaVersion, m.PGDumpVersion, ms[len(ms)-1].Version)
}
if m.Counts == nil || *m.Counts != (dbbackup.Counts{Users: 2, Servers: 2}) {
t.Errorf("manifest counts %v, want alice and bob with a server each", m.Counts)
}
r.change(t)
changed := r.fingerprint(t)