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

+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