feat(db): felis db 与迁移前快照在 felis-postgres 容器内运行客户端工具

This commit is contained in:
Lemon-miaow committed 2026-09-26 12:10:32 +08:00
1 parent 659127b271
commit b496e59b99
9 files changed
+254 -32

No files matched your search

+7 -7
View File
@@ -158,7 +158,7 @@ func cmdBreakGlass(args []string, stdout, stderr io.Writer) int {
host, _ := os.Hostname() host, _ := os.Hostname()
recovery := recoveryConfig{open: hostRecoveryMailer(cfg.SMTP, platform.DefaultControlNamespace), host: host} recovery := recoveryConfig{open: hostRecoveryMailer(cfg.SMTP, platform.DefaultControlNamespace), host: host}
res, err := runBreakGlassTUI(ctx, repo, cfg.Database.URL, cfg.Server.RootDomain, cfg.Auth.AdminHostname, cfg.Auth.PanelHostname, cfg.Auth.AccessJWTAud, cfg.K8s.Namespace, accountableOSUser(), adminExists, recovery) res, err := runBreakGlassTUI(ctx, repo, cfg.Database, cfg.Server.RootDomain, cfg.Auth.AdminHostname, cfg.Auth.PanelHostname, cfg.Auth.AccessJWTAud, cfg.K8s.Namespace, accountableOSUser(), adminExists, recovery)
if err != nil { if err != nil {
fmt.Fprintf(stderr, "felis breakGlass: %v\n", err) fmt.Fprintf(stderr, "felis breakGlass: %v\n", err)
return 1 return 1
@@ -627,18 +627,18 @@ const (
cloudflareAPITokenDocsURL = "https://developers.cloudflare.com/fundamentals/api/how-to/account-owned-token-template/" cloudflareAPITokenDocsURL = "https://developers.cloudflare.com/fundamentals/api/how-to/account-owned-token-template/"
) )
func runBreakGlassTUI(ctx context.Context, s ownerStore, dbURL, rootDomain, adminHostname, panelHostname, accessAud, namespace, osUser string, adminExists bool, recovery recoveryConfig) (breakGlassResult, error) { func runBreakGlassTUI(ctx context.Context, s ownerStore, db config.DatabaseConfig, rootDomain, adminHostname, panelHostname, accessAud, namespace, osUser string, adminExists bool, recovery recoveryConfig) (breakGlassResult, error) {
return runConsoleTUI(ctx, s, dbURL, rootDomain, adminHostname, panelHostname, accessAud, namespace, osUser, adminExists, consoleModeBreakGlass, recovery) return runConsoleTUI(ctx, s, db, rootDomain, adminHostname, panelHostname, accessAud, namespace, osUser, adminExists, consoleModeBreakGlass, recovery)
} }
// runSetupTUI never reaches recovery: setup with a staff account present lands on // runSetupTUI never reaches recovery: setup with a staff account present lands on
// the status screen, so it has no relay to hand over. // the status screen, so it has no relay to hand over.
func runSetupTUI(ctx context.Context, s ownerStore, dbURL, rootDomain, adminHostname, panelHostname, accessAud, namespace, osUser string, adminExists bool) (breakGlassResult, error) { func runSetupTUI(ctx context.Context, s ownerStore, db config.DatabaseConfig, rootDomain, adminHostname, panelHostname, accessAud, namespace, osUser string, adminExists bool) (breakGlassResult, error) {
return runConsoleTUI(ctx, s, dbURL, rootDomain, adminHostname, panelHostname, accessAud, namespace, osUser, adminExists, consoleModeSetup, recoveryConfig{}) return runConsoleTUI(ctx, s, db, rootDomain, adminHostname, panelHostname, accessAud, namespace, osUser, adminExists, consoleModeSetup, recoveryConfig{})
} }
func runConsoleTUI(ctx context.Context, s ownerStore, dbURL, rootDomain, adminHostname, panelHostname, accessAud, namespace, osUser string, adminExists bool, mode consoleMode, recovery recoveryConfig) (breakGlassResult, error) { func runConsoleTUI(ctx context.Context, s ownerStore, db config.DatabaseConfig, rootDomain, adminHostname, panelHostname, accessAud, namespace, osUser string, adminExists bool, mode consoleMode, recovery recoveryConfig) (breakGlassResult, error) {
rm := newRootModel(ctx, s, dbURL, rootDomain, adminHostname, panelHostname, accessAud, namespace, osUser, adminExists, mode) rm := newRootModel(ctx, s, db, rootDomain, adminHostname, panelHostname, accessAud, namespace, osUser, adminExists, mode)
rm.recovery = recovery rm.recovery = recovery
final, err := tea.NewProgram(rm, tea.WithAltScreen()).Run() final, err := tea.NewProgram(rm, tea.WithAltScreen()).Run()
if err != nil { if err != nil {
+50 -6
View File
@@ -7,6 +7,7 @@ import (
"flag" "flag"
"fmt" "fmt"
"io" "io"
neturl "net/url"
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
@@ -15,6 +16,7 @@ import (
"felis.lolicon.best/internal/config" "felis.lolicon.best/internal/config"
"felis.lolicon.best/internal/dbbackup" "felis.lolicon.best/internal/dbbackup"
"felis.lolicon.best/internal/platform"
"felis.lolicon.best/internal/retention" "felis.lolicon.best/internal/retention"
) )
@@ -92,11 +94,43 @@ func parseWithArg(fs *flag.FlagSet, args []string) (string, bool) {
} }
func dbDatabaseURL(path string) (string, error) { func dbDatabaseURL(path string) (string, error) {
db, err := dbDatabase(path)
return db.URL, err
}
func dbDatabase(path string) (config.DatabaseConfig, error) {
cfg, err := config.Load(path) cfg, err := config.Load(path)
if err != nil { if err != nil {
return "", err return config.DatabaseConfig{}, err
} }
return cfg.Database.URL, nil return cfg.Database, nil
}
// dbTools places pg_dump, pg_restore and psql. The installer's database runs in
// k3s and the host has no PostgreSQL client, so when the host config names the
// [database] deployment the tools run in its postgres container over the
// container's socket, as the URL's role on the URL's database. Otherwise they
// come from PATH and connect with the URL.
func dbTools(db config.DatabaseConfig) (dbbackup.Tools, error) {
if db.Deployment == "" {
return dbbackup.Tools{}, nil
}
ns, name, _ := strings.Cut(db.Deployment, "/")
u, err := neturl.Parse(db.URL)
if err != nil || u.User == nil || u.User.Username() == "" || strings.TrimPrefix(u.Path, "/") == "" {
return dbbackup.Tools{}, errors.New("[database] url must name the role and the database to run the tools in the database's pod")
}
return dbbackup.Tools{
Exec: []string{"k3s", "kubectl", "exec", "-i", "-n", ns, "deploy/" + name, "-c", platform.PostgresContainer, "--"},
Conn: fmt.Sprintf("host=%s port=%d dbname=%s user=%s connect_timeout=15",
platform.PostgresSocketDir, platform.PostgresPort,
libpqQuote(strings.TrimPrefix(u.Path, "/")), libpqQuote(u.User.Username())),
}, nil
}
// libpqQuote renders v as a single-quoted libpq connection-string value.
func libpqQuote(v string) string {
return "'" + strings.NewReplacer(`\`, `\\`, `'`, `\'`).Replace(v) + "'"
} }
func dbBackup(fs *flag.FlagSet, dir *string, args []string, stdout, stderr io.Writer) int { func dbBackup(fs *flag.FlagSet, dir *string, args []string, stdout, stderr io.Writer) int {
@@ -113,7 +147,12 @@ func dbBackup(fs *flag.FlagSet, dir *string, args []string, stdout, stderr io.Wr
fmt.Fprint(stderr, dbUsage) fmt.Fprint(stderr, dbUsage)
return 2 return 2
} }
url, err := dbDatabaseURL(*cfgPath) db, err := dbDatabase(*cfgPath)
if err != nil {
fmt.Fprintf(stderr, "felis db backup: %v\n", err)
return 1
}
tools, err := dbTools(db)
if err != nil { if err != nil {
fmt.Fprintf(stderr, "felis db backup: %v\n", err) fmt.Fprintf(stderr, "felis db backup: %v\n", err)
return 1 return 1
@@ -122,7 +161,7 @@ func dbBackup(fs *flag.FlagSet, dir *string, args []string, stdout, stderr io.Wr
*keep = defaultKeep[*label] *keep = defaultKeep[*label]
} }
o := dbbackup.BackupOptions{ o := dbbackup.BackupOptions{
DatabaseURL: url, Dir: *dir, Label: *label, Keep: *keep, DatabaseURL: db.URL, Tools: tools, Dir: *dir, Label: *label, Keep: *keep,
StateDir: *stateDir, Version: resolvedVersion(), Log: stderr, StateDir: *stateDir, Version: resolvedVersion(), Log: stderr,
MetricsFile: *metrics, Record: true, MetricsFile: *metrics, Record: true,
} }
@@ -177,7 +216,12 @@ func dbRestore(fs *flag.FlagSet, dir *string, args []string, stdout, stderr io.W
fmt.Fprintln(stderr, "Scale felis-api and felis-operator to 0 first, then re-run with -yes.") fmt.Fprintln(stderr, "Scale felis-api and felis-operator to 0 first, then re-run with -yes.")
return 2 return 2
} }
url, err := dbDatabaseURL(*cfgPath) db, err := dbDatabase(*cfgPath)
if err != nil {
fmt.Fprintf(stderr, "felis db restore: %v\n", err)
return 1
}
tools, err := dbTools(db)
if err != nil { if err != nil {
fmt.Fprintf(stderr, "felis db restore: %v\n", err) fmt.Fprintf(stderr, "felis db restore: %v\n", err)
return 1 return 1
@@ -185,7 +229,7 @@ func dbRestore(fs *flag.FlagSet, dir *string, args []string, stdout, stderr io.W
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Minute) ctx, cancel := context.WithTimeout(context.Background(), 60*time.Minute)
defer cancel() defer cancel()
_, safety, err := dbbackup.Restore(ctx, dbbackup.RestoreOptions{ _, safety, err := dbbackup.Restore(ctx, dbbackup.RestoreOptions{
DatabaseURL: url, Bundle: bundle, Dir: *dir, Force: *force, SkipSafetyBackup: *noSafety, DatabaseURL: db.URL, Tools: tools, Bundle: bundle, Dir: *dir, Force: *force, SkipSafetyBackup: *noSafety,
Safety: dbbackup.BackupOptions{Keep: defaultKeep[dbbackup.LabelPreRestore], StateDir: *stateDir, Safety: dbbackup.BackupOptions{Keep: defaultKeep[dbbackup.LabelPreRestore], StateDir: *stateDir,
Version: resolvedVersion(), ExportServers: exportMinecraftServers}, Version: resolvedVersion(), ExportServers: exportMinecraftServers},
Log: stderr, Log: stderr,
+169 -1
View File
@@ -6,10 +6,14 @@ import (
"encoding/json" "encoding/json"
"flag" "flag"
"io" "io"
"os"
"path/filepath"
"strings" "strings"
"testing" "testing"
"time" "time"
"felis.lolicon.best/internal/config"
"felis.lolicon.best/internal/dbbackup"
"felis.lolicon.best/internal/store" "felis.lolicon.best/internal/store"
) )
@@ -141,7 +145,7 @@ func TestPreMigrateBackupOnlyGuardsAPopulatedDatabase(t *testing.T) {
{"up to date", map[int]struct{}{1: {}, 2: {}}, false}, {"up to date", map[int]struct{}{1: {}, 2: {}}, false},
{"pending on a populated database", map[int]struct{}{1: {}}, true}, {"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) path, err := preMigrateBackup(context.Background(), appliedDriver{done: tc.done}, ms, config.DatabaseConfig{URL: badURL}, t.TempDir(), io.Discard)
if attempted := err != nil; attempted != tc.attempt { if attempted := err != nil; attempted != tc.attempt {
t.Errorf("%s: attempted = %v (err %v), want %v", tc.name, attempted, err, tc.attempt) t.Errorf("%s: attempted = %v (err %v), want %v", tc.name, attempted, err, tc.attempt)
} }
@@ -183,3 +187,167 @@ func TestAuditExportBounds(t *testing.T) {
} }
} }
} }
// podK3s stands in for `k3s kubectl exec ... --`: it logs its argv and runs the
// command after -- from the "container" directory, which is the only place the
// PostgreSQL tools exist, as on an installed host.
const podK3s = `#!/bin/sh
printf '%s\n' "$*" >> "$FAKE_DIR/k3s.args"
while [ $# -gt 0 ] && [ "$1" != "--" ]; do shift; done
shift
tool=$1; shift
exec /usr/bin/env -i FAKE_DIR="$FAKE_DIR" PATH=/usr/bin:/bin "$FAKE_DIR/container/$tool" "$@"
`
var podTools = map[string]string{
"pg_dump": `#!/bin/sh
case "$1" in --version) echo "pg_dump (PostgreSQL) 18.6"; exit 0 ;; esac
printf 'PGDMP-fake-archive'
`,
"pg_restore": `#!/bin/sh
cat > /dev/null
`,
"psql": `#!/bin/sh
for a in "$@"; do [ "$a" = "-c" ] && { echo 3; exit 0; }; done
cat > /dev/null
`,
}
const podPassword = "pw-must-stay-on-the-host"
// podDB is the host config's [database] on an installed host.
var podDB = config.DatabaseConfig{
URL: "postgres://felis:" + podPassword + "@127.0.0.1:15432/felis?sslmode=disable",
Deployment: "felis/felis-postgres",
}
const podExecPrefix = "kubectl exec -i -n felis deploy/felis-postgres -c postgres -- "
// newPodRig puts the fake k3s on PATH, alone, and returns the directory its
// k3s.args log lands in.
func newPodRig(t *testing.T) string {
t.Helper()
dir := t.TempDir()
bin := filepath.Join(dir, "bin")
container := filepath.Join(dir, "container")
for _, d := range []string{bin, container} {
if err := os.Mkdir(d, 0o755); err != nil {
t.Fatal(err)
}
}
writeTestFile(t, filepath.Join(bin, "k3s"), podK3s, 0o755)
for name, body := range podTools {
writeTestFile(t, filepath.Join(container, name), body, 0o755)
}
t.Setenv("PATH", bin)
t.Setenv("FAKE_DIR", dir)
return dir
}
// podRuns returns what the fake k3s ran since the last call, failing on any
// run outside the database container or with the password on its command
// line (visible to every local user in ps).
func podRuns(t *testing.T, dir string) []string {
t.Helper()
log := filepath.Join(dir, "k3s.args")
argv, err := os.ReadFile(log)
if err != nil {
t.Fatalf("nothing ran through k3s: %v", err)
}
os.Remove(log)
var runs []string
for _, line := range strings.Split(strings.TrimSpace(string(argv)), "\n") {
if !strings.HasPrefix(line, podExecPrefix) {
t.Errorf("k3s ran %q, want everything under %q", line, podExecPrefix)
}
if strings.Contains(line, podPassword) {
t.Errorf("the password crossed into the pod on a command line: %q", line)
}
runs = append(runs, strings.TrimPrefix(line, podExecPrefix))
}
return runs
}
func ranIn(runs []string, prefix string) bool {
for _, r := range runs {
if strings.HasPrefix(r, prefix) {
return true
}
}
return false
}
// TestDBBackupAndRestoreRunTheToolsInTheDatabasePod: on an installed host the
// database is a k3s Deployment and no PostgreSQL client exists outside it, so
// `felis db backup` and `restore` must reach the tools through kubectl exec,
// over the pod's socket, and without putting the role's password on a command
// line.
func TestDBBackupAndRestoreRunTheToolsInTheDatabasePod(t *testing.T) {
dir := newPodRig(t)
toml := strings.Replace(installerTOML("example.com", "127.0.0.1"),
`url = "postgres://felis:[email protected]:5432/felis?sslmode=disable"`,
`url = "`+podDB.URL+`"
deployment = "`+podDB.Deployment+`"`, 1)
cfg := filepath.Join(dir, "felis.toml")
writeTestFile(t, cfg, toml, 0o600)
var out, errBuf bytes.Buffer
bundles := filepath.Join(dir, "bundles")
if code := run([]string{"db", "backup", "-config", cfg, "-dir", bundles, "-state-dir", "", "-no-servers"}, &out, &errBuf); code != 0 {
t.Fatalf("backup: exit %d: %s", code, errBuf.String())
}
bundle := strings.TrimSpace(strings.TrimPrefix(out.String(), "felis db backup: wrote "))
if _, err := dbbackupVerify(bundle); err != nil {
t.Fatalf("the bundle does not verify: %v", err)
}
runs := podRuns(t, dir)
if !ranIn(runs, "pg_dump --format=custom --no-password --dbname=host=/var/run/postgresql port=5432 dbname='felis' user='felis'") {
t.Errorf("pg_dump did not dump over the pod's socket as felis on felis: %q", runs)
}
out.Reset()
errBuf.Reset()
if code := run([]string{"db", "restore", "-config", cfg, "-dir", bundles, "-yes", "-force", "-no-safety-backup", bundle}, &out, &errBuf); code != 0 {
t.Fatalf("restore: exit %d: %s", code, errBuf.String())
}
runs = podRuns(t, dir)
if !ranIn(runs, "pg_restore --no-owner --no-privileges --file=-") || !ranIn(runs, "psql -X -q -w -v ON_ERROR_STOP=1 -d host=/var/run/postgresql") {
t.Errorf("the replay did not run in the pod: %q", runs)
}
}
// TestPreMigrateBackupRunsInTheDatabasePod: the snapshot in front of an upgrade
// is the one taken most often, by bootstrap on every rerun.
func TestPreMigrateBackupRunsInTheDatabasePod(t *testing.T) {
dir := newPodRig(t)
ms := []store.Migration{{Version: 1}, {Version: 2}}
// The snapshot also bundles /etc/felis, which a test machine may lack; the
// dump runs first either way.
_, err := preMigrateBackup(context.Background(), appliedDriver{done: map[int]struct{}{1: {}}}, ms, podDB, filepath.Join(dir, "bundles"), io.Discard)
if err != nil && !strings.Contains(err.Error(), "read host state") {
t.Fatalf("snapshot: %v", err)
}
if runs := podRuns(t, dir); !ranIn(runs, "pg_dump --format=custom") {
t.Errorf("pg_dump did not run in the pod: %q", runs)
}
}
func TestDBToolsNeedTheRoleAndDatabase(t *testing.T) {
if tools, err := dbTools(config.DatabaseConfig{URL: "postgres://felis:pw@db:5432/felis"}); err != nil || len(tools.Exec) != 0 {
t.Errorf("no deployment: tools %+v err %v, want the PATH tools", tools, err)
}
for _, u := range []string{"postgres://db:5432/felis", "postgres://felis:pw@db:5432/"} {
if _, err := dbTools(config.DatabaseConfig{URL: u, Deployment: "felis/felis-postgres"}); err == nil {
t.Errorf("%s: no error, want a refusal (the pod connection needs the role and the database)", u)
}
}
tools, err := dbTools(config.DatabaseConfig{URL: `postgres://o%27brien@db/my%20db`, Deployment: "felis/felis-postgres"})
if err != nil {
t.Fatal(err)
}
if !strings.Contains(tools.Conn, `dbname='my db' user='o\'brien'`) {
t.Errorf("Conn = %q, want the values quoted for libpq", tools.Conn)
}
}
var dbbackupVerify = dbbackup.Verify
+7 -3
View File
@@ -58,7 +58,7 @@ func cmdMigrate(args []string, stdout, stderr io.Writer) int {
} }
if !*noBackup { if !*noBackup {
path, err := preMigrateBackup(ctx, drv, migrations, cfg.Database.URL, *backupDir, stderr) path, err := preMigrateBackup(ctx, drv, migrations, cfg.Database, *backupDir, stderr)
if err != nil { if err != nil {
fmt.Fprintf(stderr, "felis migrate: pre-migration backup failed, nothing applied: %v\n", err) 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") fmt.Fprintln(stderr, " fix the backup, or re-run with -no-backup to migrate without one")
@@ -85,7 +85,7 @@ func cmdMigrate(args []string, stdout, stderr io.Writer) int {
// preMigrateBackup bundles the database when it already carries a schema and // 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 // 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). // 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) { func preMigrateBackup(ctx context.Context, drv store.Driver, migrations []store.Migration, db config.DatabaseConfig, dir string, log io.Writer) (string, error) {
if err := drv.EnsureVersionTable(ctx); err != nil { if err := drv.EnsureVersionTable(ctx); err != nil {
return "", fmt.Errorf("ensure version table: %w", err) return "", fmt.Errorf("ensure version table: %w", err)
} }
@@ -96,8 +96,12 @@ func preMigrateBackup(ctx context.Context, drv store.Driver, migrations []store.
if len(done) == 0 || !hasPending(done, migrations) { if len(done) == 0 || !hasPending(done, migrations) {
return "", nil return "", nil
} }
tools, err := dbTools(db)
if err != nil {
return "", err
}
return dbbackup.Backup(ctx, dbbackup.BackupOptions{ return dbbackup.Backup(ctx, dbbackup.BackupOptions{
DatabaseURL: dbURL, Dir: dir, Label: dbbackup.LabelPreMigrate, DatabaseURL: db.URL, Tools: tools, Dir: dir, Label: dbbackup.LabelPreMigrate,
Keep: defaultKeep[dbbackup.LabelPreMigrate], StateDir: dbbackup.DefaultStateDir, Keep: defaultKeep[dbbackup.LabelPreMigrate], StateDir: dbbackup.DefaultStateDir,
Version: resolvedVersion(), Log: log, Record: true, Version: resolvedVersion(), Log: log, Record: true,
}) })
+1 -1
View File
@@ -112,7 +112,7 @@ func cmdSetup(args []string, stdout, stderr io.Writer) int {
return 1 return 1
} }
res, err := runSetupTUI(ctx, setup.repo, setup.cfg.Database.URL, setup.cfg.Server.RootDomain, setup.cfg.Auth.AdminHostname, setup.cfg.Auth.PanelHostname, setup.cfg.Auth.AccessJWTAud, setup.cfg.K8s.Namespace, accountableOSUser(), setup.adminExists) res, err := runSetupTUI(ctx, setup.repo, setup.cfg.Database, setup.cfg.Server.RootDomain, setup.cfg.Auth.AdminHostname, setup.cfg.Auth.PanelHostname, setup.cfg.Auth.AccessJWTAud, setup.cfg.K8s.Namespace, accountableOSUser(), setup.adminExists)
if err != nil { if err != nil {
fmt.Fprintf(stderr, "felis setup: %v\n", err) fmt.Fprintf(stderr, "felis setup: %v\n", err)
return 1 return 1
+4 -3
View File
@@ -6,6 +6,7 @@ import (
"io" "io"
"time" "time"
"felis.lolicon.best/internal/config"
"felis.lolicon.best/internal/dbbackup" "felis.lolicon.best/internal/dbbackup"
"felis.lolicon.best/internal/store" "felis.lolicon.best/internal/store"
) )
@@ -13,10 +14,10 @@ import (
// applyMigrations runs any pending schema migrations and returns the resulting // applyMigrations runs any pending schema migrations and returns the resulting
// applied count. Used by the preflight stage to self-heal a freshly bootstrapped // applied count. Used by the preflight stage to self-heal a freshly bootstrapped
// (or upgraded) database. // (or upgraded) database.
func applyMigrations(dbURL string) (int, error) { func applyMigrations(db config.DatabaseConfig) (int, error) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel() defer cancel()
drv, err := store.Open(ctx, dbURL) drv, err := store.Open(ctx, db.URL)
if err != nil { if err != nil {
return 0, fmt.Errorf("open db: %w", err) return 0, fmt.Errorf("open db: %w", err)
} }
@@ -27,7 +28,7 @@ func applyMigrations(dbURL string) (int, error) {
} }
// Same guard as `felis migrate up`: never roll a populated database forward // Same guard as `felis migrate up`: never roll a populated database forward
// without a snapshot to roll back to. // without a snapshot to roll back to.
if _, err := preMigrateBackup(ctx, drv, migrations, dbURL, dbbackup.DefaultDir, io.Discard); err != nil { if _, err := preMigrateBackup(ctx, drv, migrations, db, dbbackup.DefaultDir, io.Discard); err != nil {
return 0, fmt.Errorf("pre-migration backup: %w", err) return 0, fmt.Errorf("pre-migration backup: %w", err)
} }
if _, err := store.Up(ctx, drv, migrations); err != nil { if _, err := store.Up(ctx, drv, migrations); err != nil {
+8 -6
View File
@@ -3,6 +3,8 @@ package main
import ( import (
"fmt" "fmt"
"felis.lolicon.best/internal/config"
"github.com/charmbracelet/bubbles/spinner" "github.com/charmbracelet/bubbles/spinner"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
) )
@@ -14,7 +16,7 @@ import (
// of the wizard can assume a healthy backend. It is non-interactive — it runs // of the wizard can assume a healthy backend. It is non-interactive — it runs
// to completion and reports back to the root via preflightDoneMsg. // to completion and reports back to the root via preflightDoneMsg.
type preflightModel struct { type preflightModel struct {
dbURL string db config.DatabaseConfig
rootDomain string rootDomain string
adminHost string adminHost string
@@ -57,11 +59,11 @@ type pfMigApplyMsg struct {
type pfPanelMsg struct{ err error } type pfPanelMsg struct{ err error }
func newPreflightModel(dbURL, rootDomain, adminHostname string) *preflightModel { func newPreflightModel(db config.DatabaseConfig, rootDomain, adminHostname string) *preflightModel {
sp := spinner.New() sp := spinner.New()
sp.Spinner = spinner.Dot sp.Spinner = spinner.Dot
sp.Style = tuiLabel sp.Style = tuiLabel
return &preflightModel{dbURL: dbURL, rootDomain: rootDomain, adminHost: adminHostname, sp: sp, state: pfCheckDB} return &preflightModel{db: db, rootDomain: rootDomain, adminHost: adminHostname, sp: sp, state: pfCheckDB}
} }
func (m *preflightModel) Init() tea.Cmd { func (m *preflightModel) Init() tea.Cmd {
@@ -200,7 +202,7 @@ func (m *preflightModel) errText() string {
} }
func (m *preflightModel) checkDB() tea.Cmd { func (m *preflightModel) checkDB() tea.Cmd {
return func() tea.Msg { return pfDBMsg{err: checkPostgres(m.dbURL)} } return func() tea.Msg { return pfDBMsg{err: checkPostgres(m.db.URL)} }
} }
func (m *preflightModel) checkMigrations() tea.Cmd { func (m *preflightModel) checkMigrations() tea.Cmd {
@@ -208,7 +210,7 @@ func (m *preflightModel) checkMigrations() tea.Cmd {
// The sets, not their sizes: a database a newer release migrated can hold as // The sets, not their sizes: a database a newer release migrated can hold as
// many rows as this build has migrations, and must stop here rather than be // many rows as this build has migrations, and must stop here rather than be
// "healed" by an older binary. // "healed" by an older binary.
s, err := readSchema(m.dbURL) s, err := readSchema(m.db.URL)
if err == nil { if err == nil {
err = s.Newer() err = s.Newer()
} }
@@ -218,7 +220,7 @@ func (m *preflightModel) checkMigrations() tea.Cmd {
func (m *preflightModel) applyMigrations() tea.Cmd { func (m *preflightModel) applyMigrations() tea.Cmd {
return func() tea.Msg { return func() tea.Msg {
applied, err := applyMigrations(m.dbURL) applied, err := applyMigrations(m.db)
return pfMigApplyMsg{applied: applied, err: err} return pfMigApplyMsg{applied: applied, err: err}
} }
} }
+5 -4
View File
@@ -5,6 +5,7 @@ import (
"strings" "strings"
"felis.lolicon.best/internal/cfsetup" "felis.lolicon.best/internal/cfsetup"
"felis.lolicon.best/internal/config"
"felis.lolicon.best/internal/platform" "felis.lolicon.best/internal/platform"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
@@ -133,7 +134,7 @@ type rootModel struct {
err error err error
mode consoleMode mode consoleMode
dbURL string db config.DatabaseConfig
store ownerStore store ownerStore
osUser string osUser string
rootDomain string rootDomain string
@@ -145,11 +146,11 @@ type rootModel struct {
recovery recoveryConfig // how the account operations mail a recovery code recovery recoveryConfig // how the account operations mail a recovery code
} }
func newRootModel(ctx context.Context, store ownerStore, dbURL, rootDomain, adminHostname, panelHostname, accessAud, namespace, osUser string, adminExists bool, mode consoleMode) *rootModel { func newRootModel(ctx context.Context, store ownerStore, db config.DatabaseConfig, rootDomain, adminHostname, panelHostname, accessAud, namespace, osUser string, adminExists bool, mode consoleMode) *rootModel {
rm := &rootModel{ rm := &rootModel{
ctx: ctx, ctx: ctx,
reviewing: -1, reviewing: -1,
dbURL: dbURL, db: db,
store: store, store: store,
osUser: osUser, osUser: osUser,
rootDomain: rootDomain, rootDomain: rootDomain,
@@ -180,7 +181,7 @@ func newRootModel(ctx context.Context, store ownerStore, dbURL, rootDomain, admi
} }
} else { } else {
rm.stage = stagePreflight rm.stage = stagePreflight
rm.screen = newPreflightModel(dbURL, rootDomain, adminHostname) rm.screen = newPreflightModel(db, rootDomain, adminHostname)
} }
return rm return rm
} }
+3 -1
View File
@@ -5,6 +5,8 @@ import (
"strings" "strings"
"testing" "testing"
"felis.lolicon.best/internal/config"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
) )
@@ -30,7 +32,7 @@ func newTestRoot(adminExists bool, mode consoleMode, accessAud string) *rootMode
return newRootModel( return newRootModel(
context.Background(), context.Background(),
&fakeOwnerStore{}, &fakeOwnerStore{},
"postgres://localhost/felis", config.DatabaseConfig{URL: "postgres://localhost/felis"},
"felis.example.com", "felis.example.com",
"admin.felis.example.com", "admin.felis.example.com",
"panel.felis.example.com", "panel.felis.example.com",