fix(offsite): fetch-db latest 跳过新装机器的空库包,取包后显示包内账号和服务器数
This commit is contained in:
11 files changed
+720
-34
No files matched your search
+5
-4
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user