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

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

parent 659127b2
Loading
Loading
Loading
Loading
+7 −7
Changes for cmd/felis/breakglass.go: 7 added lines, 7 removed lines.
Original line number Diff line number Diff line
@@ -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
Changes for cmd/felis/db.go: 50 added lines, 6 removed lines.
Original line number Diff line number Diff line
@@ -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, 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
}
	return cfg.Database.URL, 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
Changes for cmd/felis/db_test.go: 169 added lines, 1 removed line.
Original line number Diff line number Diff line
@@ -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
+7 −3
Changes for cmd/felis/migrate.go: 7 added lines, 3 removed lines.
Original line number Diff line number Diff line
@@ -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
Changes for cmd/felis/setup.go: 1 added line, 1 removed line.
Original line number Diff line number Diff line
@@ -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
Loading