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