Unverified Commit c7db7d41 authored by Lemon-miaow's avatar Lemon-miaow
Browse files

feat(db): 控制面 PG 定时备份、迁移前快照与原子恢复

parent abfe60d6
Loading
Loading
Loading
Loading
+1 −0
Changes for README.md: 1 added line, 0 removed lines.
Original line number Diff line number Diff line
@@ -18,6 +18,7 @@ A Kubernetes-driven Minecraft server hosting platform — one command to deploy,
- **即开即玩**:玩家尝试连接时自动唤醒服务器,空闲后自动休眠,像游戏主机一样省资源。
- **Web 控制面板**:浏览器中查看服务器状态、在线玩家与资源用量,管理备份与恢复。
- **备份与恢复**:一键把整服数据(世界、配置、插件/模组,即整个 /data 卷)打包进集群内的归档库,支持从任意备份点回滚;默认安装就已启用(归档 PVC 与路径由安装器一并生成)。
- **控制面数据库备份**:账号、服务器归属、配额与存档索引所在的数据库每天自动备份,每次升级迁移前先快照,出错可用 `felis db restore` 整库原子回滚;面板「维护与备份」页显示备份是否新鲜(见 [故障排查 §16](docs/troubleshooting.md))。
- **智慧回收(可选开启)**:超过 15 天无人游玩的世界自动备份后删除,释放磁盘空间;安装时设置 `FELIS_WORLDS_HOST_PATH`(k3s 默认 `/var/lib/rancher/k3s/storage`)即启用每日回收,不设置则不删任何世界。
- **多核心支持**:兼容 Paper、Fabric、Forge、NeoForge,经由 Velocity 代理统一入口。
- **模组自助提交**:玩家自行上传模组包,服主审批通过后自动构建;构建产物进入镜像白名单,可直接选用为服务器镜像完成部署。
+1 −0
Changes for README_EN.md: 1 added line, 0 removed lines.
Original line number Diff line number Diff line
@@ -18,6 +18,7 @@ Table of Contents
- **Wake on Join**: Servers start automatically when a player connects, and stop when idle — like hibernate for your server.
- **Web Dashboard**: Monitor server status, online players, and resource usage from your browser, with backup and restore management.
- **Backup & Restore**: One-click snapshots of a server's whole data volume (worlds, config, plugins/mods — the entire /data volume) into the cluster's archive store, with rollback from any backup point — enabled by default (the installer renders the archive PVC and its path).
- **Control-plane database backups**: The database holding accounts, server ownership, quotas and the archive index is backed up daily and snapshotted before every upgrade migrates it; `felis db restore` rolls it back atomically, and the panel's Maintenance & Backups page shows whether the newest backup is fresh (see [troubleshooting §16](docs/troubleshooting.md)).
- **World Reaper** (opt in): Worlds idle for more than 15 days are automatically backed up and removed to free disk space. Enable it by setting `FELIS_WORLDS_HOST_PATH` at install time (on k3s: `/var/lib/rancher/k3s/storage`); without it, no world is ever deleted.
- **Multi-core Support**: Compatible with Paper, Fabric, Forge, and NeoForge, federated behind a Velocity proxy.
- **Modpack Submission**: Players submit custom modpacks; admin approval triggers an automatic build, and the result is whitelisted as a server image you can select to deploy.

cmd/felis/db.go

0 → 100644
+334 −0
Changes for cmd/felis/db.go: 334 added lines, 0 removed lines.
Original line number Diff line number Diff line
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}, "", "  ")
}

cmd/felis/db_test.go

0 → 100644
+151 −0
Changes for cmd/felis/db_test.go: 151 added lines, 0 removed lines.
Original line number Diff line number Diff line
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)
		}
	}
}
+51 −0
Changes for cmd/felis/migrate.go: 51 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -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
}
Loading