feat(db): felis db 与迁移前快照在 felis-postgres 容器内运行客户端工具
This commit is contained in:
9 files changed
+254
-32
No files matched your search
@@ -158,7 +158,7 @@ func cmdBreakGlass(args []string, stdout, stderr io.Writer) int {
|
||||
host, _ := os.Hostname()
|
||||
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 {
|
||||
fmt.Fprintf(stderr, "felis breakGlass: %v\n", err)
|
||||
return 1
|
||||
@@ -627,18 +627,18 @@ const (
|
||||
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) {
|
||||
return runConsoleTUI(ctx, s, dbURL, rootDomain, adminHostname, panelHostname, accessAud, namespace, osUser, adminExists, consoleModeBreakGlass, recovery)
|
||||
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, db, rootDomain, adminHostname, panelHostname, accessAud, namespace, osUser, adminExists, consoleModeBreakGlass, recovery)
|
||||
}
|
||||
|
||||
// runSetupTUI never reaches recovery: setup with a staff account present lands on
|
||||
// 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) {
|
||||
return runConsoleTUI(ctx, s, dbURL, rootDomain, adminHostname, panelHostname, accessAud, namespace, osUser, adminExists, consoleModeSetup, recoveryConfig{})
|
||||
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, 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) {
|
||||
rm := newRootModel(ctx, s, dbURL, rootDomain, adminHostname, panelHostname, accessAud, namespace, osUser, adminExists, mode)
|
||||
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, db, rootDomain, adminHostname, panelHostname, accessAud, namespace, osUser, adminExists, mode)
|
||||
rm.recovery = recovery
|
||||
final, err := tea.NewProgram(rm, tea.WithAltScreen()).Run()
|
||||
if err != nil {
|
||||
|
||||
+50
-6
@@ -7,6 +7,7 @@ import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
neturl "net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
@@ -15,6 +16,7 @@ import (
|
||||
|
||||
"felis.lolicon.best/internal/config"
|
||||
"felis.lolicon.best/internal/dbbackup"
|
||||
"felis.lolicon.best/internal/platform"
|
||||
"felis.lolicon.best/internal/retention"
|
||||
)
|
||||
|
||||
@@ -92,11 +94,43 @@ func parseWithArg(fs *flag.FlagSet, args []string) (string, bool) {
|
||||
}
|
||||
|
||||
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)
|
||||
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 {
|
||||
@@ -113,7 +147,12 @@ func dbBackup(fs *flag.FlagSet, dir *string, args []string, stdout, stderr io.Wr
|
||||
fmt.Fprint(stderr, dbUsage)
|
||||
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 {
|
||||
fmt.Fprintf(stderr, "felis db backup: %v\n", err)
|
||||
return 1
|
||||
@@ -122,7 +161,7 @@ func dbBackup(fs *flag.FlagSet, dir *string, args []string, stdout, stderr io.Wr
|
||||
*keep = defaultKeep[*label]
|
||||
}
|
||||
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,
|
||||
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.")
|
||||
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 {
|
||||
fmt.Fprintf(stderr, "felis db restore: %v\n", err)
|
||||
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)
|
||||
defer cancel()
|
||||
_, 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,
|
||||
Version: resolvedVersion(), ExportServers: exportMinecraftServers},
|
||||
Log: stderr,
|
||||
|
||||
+169
-1
@@ -6,10 +6,14 @@ import (
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"felis.lolicon.best/internal/config"
|
||||
"felis.lolicon.best/internal/dbbackup"
|
||||
"felis.lolicon.best/internal/store"
|
||||
)
|
||||
|
||||
@@ -141,7 +145,7 @@ func TestPreMigrateBackupOnlyGuardsAPopulatedDatabase(t *testing.T) {
|
||||
{"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)
|
||||
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 {
|
||||
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
|
||||
@@ -58,7 +58,7 @@ func cmdMigrate(args []string, stdout, stderr io.Writer) int {
|
||||
}
|
||||
|
||||
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 {
|
||||
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")
|
||||
@@ -85,7 +85,7 @@ func cmdMigrate(args []string, stdout, stderr io.Writer) int {
|
||||
// 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) {
|
||||
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 {
|
||||
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) {
|
||||
return "", nil
|
||||
}
|
||||
tools, err := dbTools(db)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
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,
|
||||
Version: resolvedVersion(), Log: log, Record: true,
|
||||
})
|
||||
|
||||
+1
-1
@@ -112,7 +112,7 @@ func cmdSetup(args []string, stdout, stderr io.Writer) int {
|
||||
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 {
|
||||
fmt.Fprintf(stderr, "felis setup: %v\n", err)
|
||||
return 1
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"io"
|
||||
"time"
|
||||
|
||||
"felis.lolicon.best/internal/config"
|
||||
"felis.lolicon.best/internal/dbbackup"
|
||||
"felis.lolicon.best/internal/store"
|
||||
)
|
||||
@@ -13,10 +14,10 @@ import (
|
||||
// applyMigrations runs any pending schema migrations and returns the resulting
|
||||
// applied count. Used by the preflight stage to self-heal a freshly bootstrapped
|
||||
// (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)
|
||||
defer cancel()
|
||||
drv, err := store.Open(ctx, dbURL)
|
||||
drv, err := store.Open(ctx, db.URL)
|
||||
if err != nil {
|
||||
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
|
||||
// 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)
|
||||
}
|
||||
if _, err := store.Up(ctx, drv, migrations); err != nil {
|
||||
|
||||
@@ -3,6 +3,8 @@ package main
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"felis.lolicon.best/internal/config"
|
||||
|
||||
"github.com/charmbracelet/bubbles/spinner"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
)
|
||||
@@ -14,7 +16,7 @@ import (
|
||||
// of the wizard can assume a healthy backend. It is non-interactive — it runs
|
||||
// to completion and reports back to the root via preflightDoneMsg.
|
||||
type preflightModel struct {
|
||||
dbURL string
|
||||
db config.DatabaseConfig
|
||||
rootDomain string
|
||||
adminHost string
|
||||
|
||||
@@ -57,11 +59,11 @@ type pfMigApplyMsg struct {
|
||||
|
||||
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 = spinner.Dot
|
||||
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 {
|
||||
@@ -200,7 +202,7 @@ func (m *preflightModel) errText() string {
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -208,7 +210,7 @@ func (m *preflightModel) checkMigrations() tea.Cmd {
|
||||
// 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
|
||||
// "healed" by an older binary.
|
||||
s, err := readSchema(m.dbURL)
|
||||
s, err := readSchema(m.db.URL)
|
||||
if err == nil {
|
||||
err = s.Newer()
|
||||
}
|
||||
@@ -218,7 +220,7 @@ func (m *preflightModel) checkMigrations() tea.Cmd {
|
||||
|
||||
func (m *preflightModel) applyMigrations() tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
applied, err := applyMigrations(m.dbURL)
|
||||
applied, err := applyMigrations(m.db)
|
||||
return pfMigApplyMsg{applied: applied, err: err}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"felis.lolicon.best/internal/cfsetup"
|
||||
"felis.lolicon.best/internal/config"
|
||||
"felis.lolicon.best/internal/platform"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
@@ -133,7 +134,7 @@ type rootModel struct {
|
||||
err error
|
||||
|
||||
mode consoleMode
|
||||
dbURL string
|
||||
db config.DatabaseConfig
|
||||
store ownerStore
|
||||
osUser string
|
||||
rootDomain string
|
||||
@@ -145,11 +146,11 @@ type rootModel struct {
|
||||
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{
|
||||
ctx: ctx,
|
||||
reviewing: -1,
|
||||
dbURL: dbURL,
|
||||
db: db,
|
||||
store: store,
|
||||
osUser: osUser,
|
||||
rootDomain: rootDomain,
|
||||
@@ -180,7 +181,7 @@ func newRootModel(ctx context.Context, store ownerStore, dbURL, rootDomain, admi
|
||||
}
|
||||
} else {
|
||||
rm.stage = stagePreflight
|
||||
rm.screen = newPreflightModel(dbURL, rootDomain, adminHostname)
|
||||
rm.screen = newPreflightModel(db, rootDomain, adminHostname)
|
||||
}
|
||||
return rm
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"felis.lolicon.best/internal/config"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
)
|
||||
|
||||
@@ -30,7 +32,7 @@ func newTestRoot(adminExists bool, mode consoleMode, accessAud string) *rootMode
|
||||
return newRootModel(
|
||||
context.Background(),
|
||||
&fakeOwnerStore{},
|
||||
"postgres://localhost/felis",
|
||||
config.DatabaseConfig{URL: "postgres://localhost/felis"},
|
||||
"felis.example.com",
|
||||
"admin.felis.example.com",
|
||||
"panel.felis.example.com",
|
||||
|
||||
Reference in new issue
Block a user