feat(db): 控制面 PG 定时备份、迁移前快照与原子恢复
This commit is contained in:
31 files changed
+3217
-17
No files matched your search
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user