feat(db): 控制面 PG 定时备份、迁移前快照与原子恢复
This commit is contained in:
31 files changed
+3217
-17
No files matched your search
+334
@@ -0,0 +1,334 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"felis.lolicon.best/internal/config"
|
||||
"felis.lolicon.best/internal/dbbackup"
|
||||
)
|
||||
|
||||
const dbUsage = `usage:
|
||||
felis db backup [-config path] [-dir dir] [-label daily|manual|...] [-keep n] [-state-dir dir]
|
||||
[-no-servers] [-metrics-file path]
|
||||
felis db restore [-config path] [-dir dir] [-yes] [-force] [-no-safety-backup] <bundle>
|
||||
felis db verify [-dir dir] <bundle>
|
||||
felis db list [-dir dir]
|
||||
felis db check [-dir dir] [-max-age 26h]
|
||||
`
|
||||
|
||||
// defaultKeep is how many bundles of a label a backup leaves behind. Manual
|
||||
// bundles are the operator's own and are never pruned.
|
||||
var defaultKeep = map[string]int{
|
||||
dbbackup.LabelDaily: 14,
|
||||
dbbackup.LabelPreMigrate: 10,
|
||||
dbbackup.LabelPreRestore: 5,
|
||||
}
|
||||
|
||||
// cmdDB implements `felis db`: logical backups of the control-plane database
|
||||
// together with the host state a rebuild needs (internal/dbbackup). The verb
|
||||
// comes first for the same reason as `felis migrate up`.
|
||||
func cmdDB(args []string, stdout, stderr io.Writer) int {
|
||||
if len(args) == 0 {
|
||||
fmt.Fprint(stderr, dbUsage)
|
||||
return 2
|
||||
}
|
||||
verb, rest := args[0], args[1:]
|
||||
fs := flag.NewFlagSet("db "+verb, flag.ContinueOnError)
|
||||
fs.SetOutput(stderr)
|
||||
fs.Usage = func() { fmt.Fprint(stderr, dbUsage) }
|
||||
dir := fs.String("dir", dbbackup.DefaultDir, "bundle directory")
|
||||
switch verb {
|
||||
case "backup":
|
||||
return dbBackup(fs, dir, rest, stdout, stderr)
|
||||
case "restore":
|
||||
return dbRestore(fs, dir, rest, stdout, stderr)
|
||||
case "verify":
|
||||
return dbVerify(fs, dir, rest, stdout, stderr)
|
||||
case "list":
|
||||
return dbList(fs, dir, rest, stdout, stderr)
|
||||
case "check":
|
||||
return dbCheck(fs, dir, rest, stdout, stderr)
|
||||
case "-h", "--help", "help":
|
||||
fmt.Fprint(stdout, dbUsage)
|
||||
return 0
|
||||
}
|
||||
fmt.Fprintf(stderr, "felis db: unknown verb %q\n%s", verb, dbUsage)
|
||||
return 2
|
||||
}
|
||||
|
||||
// parseWithArg parses flags that may sit on either side of one positional
|
||||
// argument (`restore -yes x.tar` and `restore x.tar -yes` both work) and
|
||||
// returns that argument.
|
||||
func parseWithArg(fs *flag.FlagSet, args []string) (string, bool) {
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return "", false
|
||||
}
|
||||
if fs.NArg() == 0 {
|
||||
return "", true
|
||||
}
|
||||
arg := fs.Arg(0)
|
||||
if err := fs.Parse(fs.Args()[1:]); err != nil {
|
||||
return "", false
|
||||
}
|
||||
if fs.NArg() > 0 {
|
||||
fmt.Fprintf(fs.Output(), "felis db: unexpected argument %q\n", fs.Arg(0))
|
||||
return "", false
|
||||
}
|
||||
return arg, true
|
||||
}
|
||||
|
||||
func dbDatabaseURL(path string) (string, error) {
|
||||
cfg, err := config.Load(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return cfg.Database.URL, nil
|
||||
}
|
||||
|
||||
func dbBackup(fs *flag.FlagSet, dir *string, args []string, stdout, stderr io.Writer) int {
|
||||
cfgPath := fs.String("config", "/etc/felis/felis.toml", "path to felis.toml")
|
||||
label := fs.String("label", dbbackup.LabelManual, "bundle label; daily/pre-migrate/pre-restore bundles are pruned, manual ones never")
|
||||
keep := fs.Int("keep", -1, "bundles of this label to keep (default: daily 14, pre-migrate 10, pre-restore 5, manual all)")
|
||||
stateDir := fs.String("state-dir", dbbackup.DefaultStateDir, `host state directory to bundle ("" for none)`)
|
||||
noServers := fs.Bool("no-servers", false, "leave the MinecraftServer objects out of the bundle")
|
||||
metrics := fs.String("metrics-file", "", "node-exporter textfile to rewrite on success (e.g. /var/lib/node_exporter/textfile_collector/felis_db_backup.prom)")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return 2
|
||||
}
|
||||
if fs.NArg() > 0 {
|
||||
fmt.Fprint(stderr, dbUsage)
|
||||
return 2
|
||||
}
|
||||
url, err := dbDatabaseURL(*cfgPath)
|
||||
if err != nil {
|
||||
fmt.Fprintf(stderr, "felis db backup: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
if *keep < 0 {
|
||||
*keep = defaultKeep[*label]
|
||||
}
|
||||
o := dbbackup.BackupOptions{
|
||||
DatabaseURL: url, Dir: *dir, Label: *label, Keep: *keep,
|
||||
StateDir: *stateDir, Version: resolvedVersion(), Log: stderr,
|
||||
MetricsFile: *metrics, Record: true,
|
||||
}
|
||||
if !*noServers {
|
||||
o.ExportServers = exportMinecraftServers
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute)
|
||||
defer cancel()
|
||||
path, err := dbbackup.Backup(ctx, o)
|
||||
if err != nil {
|
||||
fmt.Fprintf(stderr, "felis db backup: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
fmt.Fprintf(stdout, "felis db backup: wrote %s\n", path)
|
||||
return 0
|
||||
}
|
||||
|
||||
// resolveBundle accepts a path, or a bare bundle name looked up in dir.
|
||||
func resolveBundle(dir, arg string) string {
|
||||
if strings.ContainsRune(arg, os.PathSeparator) {
|
||||
return arg
|
||||
}
|
||||
if _, err := os.Stat(arg); err == nil {
|
||||
return arg
|
||||
}
|
||||
return filepath.Join(dir, arg)
|
||||
}
|
||||
|
||||
func dbRestore(fs *flag.FlagSet, dir *string, args []string, stdout, stderr io.Writer) int {
|
||||
cfgPath := fs.String("config", "/etc/felis/felis.toml", "path to felis.toml")
|
||||
yes := fs.Bool("yes", false, "replace the database's contents (required)")
|
||||
force := fs.Bool("force", false, "restore even while other clients are connected")
|
||||
noSafety := fs.Bool("no-safety-backup", false, "skip the bundle of the current database taken first")
|
||||
stateDir := fs.String("state-dir", dbbackup.DefaultStateDir, "host state directory for the safety bundle")
|
||||
arg, ok := parseWithArg(fs, args)
|
||||
if !ok {
|
||||
return 2
|
||||
}
|
||||
if arg == "" {
|
||||
fmt.Fprint(stderr, dbUsage)
|
||||
return 2
|
||||
}
|
||||
bundle := resolveBundle(*dir, arg)
|
||||
m, err := dbbackup.Verify(bundle)
|
||||
if err != nil {
|
||||
fmt.Fprintf(stderr, "felis db restore: %v\n", err)
|
||||
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.Fprintln(stderr, "Scale felis-api and felis-operator to 0 first, then re-run with -yes.")
|
||||
return 2
|
||||
}
|
||||
url, err := dbDatabaseURL(*cfgPath)
|
||||
if err != nil {
|
||||
fmt.Fprintf(stderr, "felis db restore: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Minute)
|
||||
defer cancel()
|
||||
_, safety, err := dbbackup.Restore(ctx, dbbackup.RestoreOptions{
|
||||
DatabaseURL: url, Bundle: bundle, Dir: *dir, Force: *force, SkipSafetyBackup: *noSafety,
|
||||
Safety: dbbackup.BackupOptions{Keep: defaultKeep[dbbackup.LabelPreRestore], StateDir: *stateDir,
|
||||
Version: resolvedVersion(), ExportServers: exportMinecraftServers},
|
||||
Log: stderr,
|
||||
})
|
||||
if err != nil {
|
||||
fmt.Fprintf(stderr, "felis db restore: %v\n", err)
|
||||
if errors.Is(err, dbbackup.ErrClientsConnected) {
|
||||
fmt.Fprintln(stderr, " kubectl -n felis scale deployment felis-api felis-operator --replicas=0")
|
||||
}
|
||||
return 1
|
||||
}
|
||||
fmt.Fprintf(stdout, "felis db restore: restored %s (schema %d)\n", filepath.Base(bundle), m.SchemaVersion)
|
||||
if safety != "" {
|
||||
fmt.Fprintf(stdout, " the database as it was before is in %s\n", safety)
|
||||
}
|
||||
// Nothing migrates at startup, so a control plane newer than the bundle needs
|
||||
// its migrations re-applied; rolling back to the release that wrote the bundle
|
||||
// must skip that, or the rollback is undone.
|
||||
fmt.Fprintf(stdout, " next: felis migrate up -config %s (skip it when rolling back to felis %s, which wrote this bundle)\n", *cfgPath, orUnknown(m.FelisVersion))
|
||||
fmt.Fprintln(stdout, " kubectl -n felis scale deployment felis-api felis-operator --replicas=1")
|
||||
return 0
|
||||
}
|
||||
|
||||
func dbVerify(fs *flag.FlagSet, dir *string, args []string, stdout, stderr io.Writer) int {
|
||||
arg, ok := parseWithArg(fs, args)
|
||||
if !ok {
|
||||
return 2
|
||||
}
|
||||
if arg == "" {
|
||||
fmt.Fprint(stderr, dbUsage)
|
||||
return 2
|
||||
}
|
||||
bundle := resolveBundle(*dir, arg)
|
||||
m, err := dbbackup.Verify(bundle)
|
||||
if err != nil {
|
||||
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))
|
||||
for _, f := range m.Files {
|
||||
if f.Link != "" {
|
||||
fmt.Fprintf(stdout, " %-40s -> %s\n", f.Name, f.Link)
|
||||
continue
|
||||
}
|
||||
fmt.Fprintf(stdout, " %-40s %d bytes\n", f.Name, f.Size)
|
||||
}
|
||||
if m.ServersError != "" {
|
||||
fmt.Fprintf(stdout, " (no MinecraftServer objects: %s)\n", m.ServersError)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func orUnknown(s string) string {
|
||||
if s == "" {
|
||||
return "unknown"
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func dbList(fs *flag.FlagSet, dir *string, args []string, stdout, stderr io.Writer) int {
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return 2
|
||||
}
|
||||
all, err := dbbackup.List(*dir)
|
||||
if err != nil {
|
||||
fmt.Fprintf(stderr, "felis db list: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
if len(all) == 0 {
|
||||
fmt.Fprintf(stdout, "no database backups in %s\n", *dir)
|
||||
return 0
|
||||
}
|
||||
now := time.Now()
|
||||
for _, b := range all {
|
||||
fmt.Fprintf(stdout, "%-50s %-12s %10s %s ago\n", b.Name, b.Label, humanBytes(b.Size), dbbackup.Age(now.Sub(b.Created)))
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func humanBytes(n int64) string {
|
||||
const unit = 1024
|
||||
if n < unit {
|
||||
return fmt.Sprintf("%d B", n)
|
||||
}
|
||||
div, exp := int64(unit), 0
|
||||
for m := n / unit; m >= unit; m /= unit {
|
||||
div *= unit
|
||||
exp++
|
||||
}
|
||||
return fmt.Sprintf("%.1f %ciB", float64(n)/float64(div), "KMGTPE"[exp])
|
||||
}
|
||||
|
||||
// dbCheck is the freshness probe: exit 1 when the newest bundle is missing or
|
||||
// older than -max-age, for a monitor or the break-glass console to act on.
|
||||
func dbCheck(fs *flag.FlagSet, dir *string, args []string, stdout, stderr io.Writer) int {
|
||||
maxAge := fs.Duration("max-age", dbbackup.StaleAfter, "oldest acceptable newest bundle")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return 2
|
||||
}
|
||||
b, err := dbbackup.Check(*dir, *maxAge, time.Now())
|
||||
if err != nil {
|
||||
fmt.Fprintf(stderr, "felis db check: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
fmt.Fprintf(stdout, "felis db check: ok, newest backup %s (%s ago)\n", b.Name, dbbackup.Age(time.Since(b.Created)))
|
||||
return 0
|
||||
}
|
||||
|
||||
// exportMinecraftServers reads every MinecraftServer through the host's k3s
|
||||
// kubectl and strips what the API server owns, so the result can be fed back
|
||||
// with `kubectl apply -f` on a rebuilt cluster.
|
||||
func exportMinecraftServers(ctx context.Context) ([]byte, error) {
|
||||
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||
defer cancel()
|
||||
// Output, not the CombinedOutput kubectlOutput uses: a deprecation warning
|
||||
// on stderr must not end up inside the JSON.
|
||||
cmd := exec.CommandContext(ctx, "k3s", "kubectl", "get", "minecraftservers.felis.lolicon.best", "-A", "-o", "json")
|
||||
cmd.Env = append(os.Environ(), "KUBECONFIG="+hostBootstrapKubeconfigPath)
|
||||
var errBuf strings.Builder
|
||||
cmd.Stderr = &errBuf
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("k3s kubectl get minecraftservers: %w: %s", err, strings.TrimSpace(errBuf.String()))
|
||||
}
|
||||
return cleanServerList(out)
|
||||
}
|
||||
|
||||
// cleanServerList drops status and the server-assigned metadata from a
|
||||
// `kubectl get -o json` List.
|
||||
func cleanServerList(raw []byte) ([]byte, error) {
|
||||
var list struct {
|
||||
Items []map[string]any `json:"items"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &list); err != nil {
|
||||
return nil, fmt.Errorf("parse MinecraftServer list: %w", err)
|
||||
}
|
||||
for _, it := range list.Items {
|
||||
delete(it, "status")
|
||||
if md, ok := it["metadata"].(map[string]any); ok {
|
||||
for _, k := range []string{"resourceVersion", "uid", "creationTimestamp", "generation", "managedFields", "selfLink"} {
|
||||
delete(md, k)
|
||||
}
|
||||
}
|
||||
}
|
||||
if list.Items == nil {
|
||||
list.Items = []map[string]any{}
|
||||
}
|
||||
return json.MarshalIndent(map[string]any{"apiVersion": "v1", "kind": "List", "items": list.Items}, "", " ")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,15 +7,24 @@ import (
|
||||
"io"
|
||||
|
||||
"felis.lolicon.best/internal/config"
|
||||
"felis.lolicon.best/internal/dbbackup"
|
||||
"felis.lolicon.best/internal/store"
|
||||
)
|
||||
|
||||
// cmdMigrate implements `felis migrate up`: load config, open the database, and
|
||||
// apply every pending embedded migration under the advisory lock (spec §6).
|
||||
//
|
||||
// Migrations only roll forward, and some drop data (0017_drop_password), so a
|
||||
// database that already holds a schema and has migrations pending is bundled
|
||||
// first (internal/dbbackup, label pre-migrate). A failed snapshot stops the
|
||||
// upgrade; -no-backup is the explicit way past it, e.g. for an external
|
||||
// database whose server is newer than the host's pg_dump.
|
||||
func cmdMigrate(args []string, stdout, stderr io.Writer) int {
|
||||
fs := flag.NewFlagSet("migrate", flag.ContinueOnError)
|
||||
fs.SetOutput(stderr)
|
||||
cfgPath := fs.String("config", "/etc/felis/felis.toml", "path to felis.toml")
|
||||
backupDir := fs.String("backup-dir", dbbackup.DefaultDir, "where the pre-migration snapshot goes")
|
||||
noBackup := fs.Bool("no-backup", false, "apply pending migrations without snapshotting the database first")
|
||||
// The "up" verb precedes any flags (felis migrate up -config path). Go's
|
||||
// flag.Parse stops at the first non-flag token and would never see a flag
|
||||
// placed after "up", silently falling back to the default -config. Pull the
|
||||
@@ -48,6 +57,18 @@ func cmdMigrate(args []string, stdout, stderr io.Writer) int {
|
||||
return 1
|
||||
}
|
||||
|
||||
if !*noBackup {
|
||||
path, err := preMigrateBackup(ctx, drv, migrations, cfg.Database.URL, *backupDir, stderr)
|
||||
if err != nil {
|
||||
fmt.Fprintf(stderr, "felis migrate: pre-migration backup failed, nothing applied: %v\n", err)
|
||||
fmt.Fprintln(stderr, " fix the backup, or re-run with -no-backup to migrate without one")
|
||||
return 1
|
||||
}
|
||||
if path != "" {
|
||||
fmt.Fprintf(stdout, "felis migrate: database snapshot %s\n", path)
|
||||
}
|
||||
}
|
||||
|
||||
applied, err := store.Up(ctx, drv, migrations)
|
||||
if err != nil {
|
||||
fmt.Fprintf(stderr, "felis migrate: %v\n", err)
|
||||
@@ -60,3 +81,33 @@ func cmdMigrate(args []string, stdout, stderr io.Writer) int {
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// preMigrateBackup bundles the database when it already carries a schema and
|
||||
// some of migrations are not applied yet, and returns the bundle's path ("" when
|
||||
// there was nothing to protect: a fresh database, or nothing pending).
|
||||
func preMigrateBackup(ctx context.Context, drv store.Driver, migrations []store.Migration, dbURL, dir string, log io.Writer) (string, error) {
|
||||
if err := drv.EnsureVersionTable(ctx); err != nil {
|
||||
return "", fmt.Errorf("ensure version table: %w", err)
|
||||
}
|
||||
done, err := drv.AppliedVersions(ctx)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("read applied versions: %w", err)
|
||||
}
|
||||
if len(done) == 0 || !hasPending(done, migrations) {
|
||||
return "", nil
|
||||
}
|
||||
return dbbackup.Backup(ctx, dbbackup.BackupOptions{
|
||||
DatabaseURL: dbURL, Dir: dir, Label: dbbackup.LabelPreMigrate,
|
||||
Keep: defaultKeep[dbbackup.LabelPreMigrate], StateDir: dbbackup.DefaultStateDir,
|
||||
Version: resolvedVersion(), Log: log, Record: true,
|
||||
})
|
||||
}
|
||||
|
||||
func hasPending(done map[int]struct{}, migrations []store.Migration) bool {
|
||||
for _, m := range migrations {
|
||||
if _, ok := done[m.Version]; !ok {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
+3
-1
@@ -11,7 +11,8 @@ Usage:
|
||||
felis <command> [flags]
|
||||
|
||||
Commands:
|
||||
migrate up Apply embedded database migrations under an advisory lock
|
||||
migrate up Apply embedded database migrations under an advisory lock (snapshots the database first)
|
||||
db Back up, verify, list and restore the control-plane database (backup|restore|verify|list|check)
|
||||
operator Run the MinecraftServer controller-manager
|
||||
api Run the felis-api HTTP server
|
||||
nano Run the Felis-nano hasJoined multiplexer (multi-Yggdrasil, no control plane)
|
||||
@@ -44,6 +45,7 @@ Run "felis <command> -h" for command-specific flags.
|
||||
// subcommand, and listing them would make the table disagree with the command list.
|
||||
var commands = map[string]func(args []string, stdout, stderr io.Writer) int{
|
||||
"migrate": cmdMigrate,
|
||||
"db": cmdDB,
|
||||
"operator": cmdOperator,
|
||||
"api": cmdAPI,
|
||||
"nano": cmdNano,
|
||||
|
||||
@@ -3,8 +3,10 @@ package main
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"time"
|
||||
|
||||
"felis.lolicon.best/internal/dbbackup"
|
||||
"felis.lolicon.best/internal/store"
|
||||
)
|
||||
|
||||
@@ -12,7 +14,7 @@ import (
|
||||
// applied count. Used by the preflight stage to self-heal a freshly bootstrapped
|
||||
// (or upgraded) database.
|
||||
func applyMigrations(dbURL string) (int, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
|
||||
defer cancel()
|
||||
drv, err := store.Open(ctx, dbURL)
|
||||
if err != nil {
|
||||
@@ -23,6 +25,11 @@ func applyMigrations(dbURL string) (int, error) {
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
// Same guard as `felis migrate up`: never roll a populated database forward
|
||||
// without a snapshot to roll back to.
|
||||
if _, err := preMigrateBackup(ctx, drv, migrations, dbURL, dbbackup.DefaultDir, io.Discard); err != nil {
|
||||
return 0, fmt.Errorf("pre-migration backup: %w", err)
|
||||
}
|
||||
if _, err := store.Up(ctx, drv, migrations); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user