feat(db): 控制面 PG 定时备份、迁移前快照与原子恢复
This commit is contained in:
31 files changed
+3217
-17
No files matched your search
@@ -0,0 +1,151 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"felis.lolicon.best/internal/store"
|
||||
)
|
||||
|
||||
func TestDBUsage(t *testing.T) {
|
||||
for _, args := range [][]string{{"db"}, {"db", "frobnicate"}, {"db", "restore"}, {"db", "verify"}, {"db", "backup", "extra"}} {
|
||||
var out, errBuf bytes.Buffer
|
||||
if code := run(args, &out, &errBuf); code != 2 {
|
||||
t.Errorf("%v: exit %d, want 2", args, code)
|
||||
}
|
||||
if !strings.Contains(errBuf.String(), "felis db restore") {
|
||||
t.Errorf("%v: no usage on stderr: %q", args, errBuf.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDBRestoreNeedsYes(t *testing.T) {
|
||||
// A bundle that does not exist fails verification (1) before -yes matters;
|
||||
// the -yes gate itself is exercised against a real bundle in internal/dbbackup
|
||||
// and on the VM. Here: the refusal path never reaches the config or database.
|
||||
var out, errBuf bytes.Buffer
|
||||
if code := run([]string{"db", "restore", "-dir", t.TempDir(), "missing.tar"}, &out, &errBuf); code != 1 {
|
||||
t.Fatalf("exit %d, stderr %q", code, errBuf.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseWithArg(t *testing.T) {
|
||||
for _, args := range [][]string{{"-yes", "b.tar"}, {"b.tar", "-yes"}} {
|
||||
fs := flag.NewFlagSet("t", flag.ContinueOnError)
|
||||
fs.SetOutput(io.Discard)
|
||||
yes := fs.Bool("yes", false, "")
|
||||
arg, ok := parseWithArg(fs, args)
|
||||
if !ok || arg != "b.tar" || !*yes {
|
||||
t.Errorf("%v -> %q ok=%v yes=%v", args, arg, ok, *yes)
|
||||
}
|
||||
}
|
||||
fs := flag.NewFlagSet("t", flag.ContinueOnError)
|
||||
fs.SetOutput(io.Discard)
|
||||
if _, ok := parseWithArg(fs, []string{"a.tar", "b.tar"}); ok {
|
||||
t.Error("two positional arguments accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveBundle(t *testing.T) {
|
||||
if got := resolveBundle("/var/lib/felis/db-backups", "felis-db-x.tar"); got != "/var/lib/felis/db-backups/felis-db-x.tar" {
|
||||
t.Errorf("bare name -> %s", got)
|
||||
}
|
||||
if got := resolveBundle("/var/lib/felis/db-backups", "/root/copy.tar"); got != "/root/copy.tar" {
|
||||
t.Errorf("path -> %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCleanServerList(t *testing.T) {
|
||||
raw := `{"apiVersion":"v1","kind":"List","metadata":{"resourceVersion":""},"items":[{
|
||||
"apiVersion":"felis.lolicon.best/v1alpha1","kind":"MinecraftServer",
|
||||
"metadata":{"name":"survival","namespace":"minecraft","uid":"u","resourceVersion":"42","generation":3,
|
||||
"creationTimestamp":"2026-09-01T00:00:00Z","managedFields":[{}],"labels":{"a":"b"}},
|
||||
"spec":{"desiredState":"Running"},"status":{"phase":"Running"}}]}`
|
||||
out, err := cleanServerList([]byte(raw))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var got struct {
|
||||
Kind string `json:"kind"`
|
||||
Items []map[string]any `json:"items"`
|
||||
}
|
||||
if err := json.Unmarshal(out, &got); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Kind != "List" || len(got.Items) != 1 {
|
||||
t.Fatalf("got %s", out)
|
||||
}
|
||||
it := got.Items[0]
|
||||
if _, ok := it["status"]; ok {
|
||||
t.Error("status kept")
|
||||
}
|
||||
md := it["metadata"].(map[string]any)
|
||||
for _, k := range []string{"uid", "resourceVersion", "generation", "creationTimestamp", "managedFields"} {
|
||||
if _, ok := md[k]; ok {
|
||||
t.Errorf("metadata.%s kept", k)
|
||||
}
|
||||
}
|
||||
if md["name"] != "survival" || md["namespace"] != "minecraft" || md["labels"] == nil {
|
||||
t.Errorf("identity lost: %v", md)
|
||||
}
|
||||
if it["spec"].(map[string]any)["desiredState"] != "Running" {
|
||||
t.Error("spec lost")
|
||||
}
|
||||
|
||||
empty, err := cleanServerList([]byte(`{"items":null}`))
|
||||
if err != nil || !strings.Contains(string(empty), `"items": []`) {
|
||||
t.Errorf("empty list -> %s, %v", empty, err)
|
||||
}
|
||||
if _, err := cleanServerList([]byte("Warning: x\n{")); err == nil {
|
||||
t.Error("garbage parsed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHasPending(t *testing.T) {
|
||||
ms := []store.Migration{{Version: 1}, {Version: 2}, {Version: 3}}
|
||||
if hasPending(map[int]struct{}{1: {}, 2: {}, 3: {}}, ms) {
|
||||
t.Error("fully applied reported pending")
|
||||
}
|
||||
if !hasPending(map[int]struct{}{1: {}, 2: {}}, ms) {
|
||||
t.Error("missing 3 not reported")
|
||||
}
|
||||
}
|
||||
|
||||
type appliedDriver struct {
|
||||
store.Driver
|
||||
done map[int]struct{}
|
||||
}
|
||||
|
||||
func (d appliedDriver) EnsureVersionTable(context.Context) error { return nil }
|
||||
func (d appliedDriver) AppliedVersions(context.Context) (map[int]struct{}, error) {
|
||||
return d.done, nil
|
||||
}
|
||||
|
||||
func TestPreMigrateBackupOnlyGuardsAPopulatedDatabase(t *testing.T) {
|
||||
ms := []store.Migration{{Version: 1}, {Version: 2}}
|
||||
// An unusable URL makes an attempted backup observable as an error without
|
||||
// any PostgreSQL tooling.
|
||||
const badURL = "not-a-url"
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
done map[int]struct{}
|
||||
attempt bool
|
||||
}{
|
||||
{"fresh database", map[int]struct{}{}, false},
|
||||
{"up to date", map[int]struct{}{1: {}, 2: {}}, false},
|
||||
{"pending on a populated database", map[int]struct{}{1: {}}, true},
|
||||
} {
|
||||
path, err := preMigrateBackup(context.Background(), appliedDriver{done: tc.done}, ms, badURL, t.TempDir(), io.Discard)
|
||||
if attempted := err != nil; attempted != tc.attempt {
|
||||
t.Errorf("%s: attempted = %v (err %v), want %v", tc.name, attempted, err, tc.attempt)
|
||||
}
|
||||
if path != "" {
|
||||
t.Errorf("%s: path = %q", tc.name, path)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user