Fourth section of #23. db-dsn now names the console's runtime user, and the new db-migrate-dsn (or db-migrate-dsn-file) names the user that runs migrations. start opens both, migrates on the migration connection, grants console_runtime to the runtime user there (WITH INHERIT TRUE, the name quoted by the database), closes it, and serves on the runtime pool alone. With db-migrate-dsn left out, start runs no migration and refuses when the schema is behind its code, which is how a host runs migrations as a separate step; migrate up, down and status use the migration string and refuse without it, with no fallback to db-dsn. Both pools are opened from one pgx.ParseConfig: the migration pool's search_path is public and both pools' role is none, set in the startup parameters after every key of either name is removed case-insensitively, so neither a setting the database or role carries nor a key the string repeats in another case moves a session off its login or its path. PendingMigrations reads each stream's ledger by qualified name inside a transaction pinned to search_path pg_catalog, pg_temp. The test stack runs on two users: postgres-seed creates member_console_runtime in new and existing volumes, bootstrap-stack.sh writes MC_DB_DSN as that user and MC_DB_MIGRATE_DSN as member_console, and the four scripts refuse a .env without the migration string, with the command that regenerates it. Scripts and fixtures that need a superuser on the application database (the reset, the screens' timestamp pinning) use the migration string. This worktree's stack was regenerated with that command and the console booted as member_console_runtime: its sessions were the runtime user's only, no migration-user session stayed open, and the runtime user could not insert into core.pool_provisions. Full suite on reset databases: 4277 pass, 15 skip, none failed. The walkthroughs' names, results and skip messages match the previous section's run. make screens lists three changed screens, all data: regenerated ids on operator-person-detail and operator-billing-invoice-detail (the demo snapshot was rebuilt) and a domain's age on operator-domains (294d to 295d); the baseline was not accepted. make lint passes. Deliberate breaks, scripts/mutation-check -side new, all CAUGHT (36): dbRuntime runs the migrations on the runtime pool dbRuntime skips the boot grant dbRuntime leaves the migration pool open dbRuntime skips the pending check when db-migrate-dsn is unset dbRuntime migrates on db-dsn when db-migrate-dsn is unset GrantRuntimeRole without WITH INHERIT TRUE GrantRuntimeRole with %s in place of %I PendingMigrations takes a missing ledger as current PendingMigrations resolves the ledger without the public. qualifier the migration pool opened without search_path=public the migration pool opened without role=none the runtime pool opened without role=none ConnectMigration keeps the search_path and role the string carries dbMigration falls back to db-dsn ValidateStart accepts a malformed db-migrate-dsn db-migrate-dsn-file is not a database secret pair db-migrate-dsn-file is not a database secret pair (the migrate commands read db-dsn-file only) the bootstrap guard removed reset-test-db.sh guard removed seed-demo.sh guard removed reset-app-db.sh guard after its first psql startup parameters match their keys case-sensitively the migration pool opened without role=none (keys and options in the string) the runtime pool opened without role=none (keys and options in the string) the runtime pool replaces the search_path the string carries the string's spaces are written as plus signs ValidateStart reads db-migrate-dsn as set whatever it holds PendingMigrations lists only the versions the ledger shows when it is missing migrationDSN keeps whitespace migrate up accepts a whitespace-only db-migrate-dsn runtimeDSN accepts a whitespace-only db-dsn openDB runs the migrating boot for an unknown mode bootstrap-stack.sh trusts the exported MC_DB_MIGRATE_DSN reset-test-db.sh trusts the exported MC_DB_MIGRATE_DSN reset-app-db.sh trusts the exported MC_DB_MIGRATE_DSN seed-demo.sh trusts the exported MC_DB_MIGRATE_DSN
128 lines
3.3 KiB
Go
128 lines
3.3 KiB
Go
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
|
|
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
|
|
|
|
package cmd
|
|
|
|
import (
|
|
"context"
|
|
"log/slog"
|
|
|
|
"git.coopcloud.tech/wiki-cafe/member-console/internal/db"
|
|
"git.coopcloud.tech/wiki-cafe/member-console/internal/logging"
|
|
"git.coopcloud.tech/wiki-cafe/member-console/internal/migrate"
|
|
"github.com/spf13/cobra"
|
|
)
|
|
|
|
var migrateCmd = &cobra.Command{
|
|
Use: "migrate",
|
|
Short: "Database migration commands",
|
|
Long: "Run database migrations up, down, or check status",
|
|
}
|
|
|
|
var migrateUpCmd = &cobra.Command{
|
|
Use: "up",
|
|
Short: "Run all pending migrations",
|
|
Long: "Apply all pending database migrations",
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
logger := slog.Default()
|
|
if logging.AppLogger != nil {
|
|
logger = logging.AppLogger
|
|
}
|
|
|
|
if err := resolveDatabaseSecretFiles(); err != nil {
|
|
logger.Error("configuration error", slog.Any("error", err))
|
|
return err
|
|
}
|
|
|
|
database, closeDB, err := openDB(context.Background(), logger, dbMigration)
|
|
if err != nil {
|
|
logger.Error("failed to connect to database", slog.Any("error", err))
|
|
return err
|
|
}
|
|
defer closeDB()
|
|
|
|
if err := db.RunMigrations(database, migrationSources()); err != nil {
|
|
logger.Error("failed to run migrations", slog.Any("error", err))
|
|
return err
|
|
}
|
|
|
|
logger.Info("migrations completed successfully")
|
|
return nil
|
|
},
|
|
}
|
|
|
|
var migrateDownCmd = &cobra.Command{
|
|
Use: "down",
|
|
Short: "Rollback the last migration",
|
|
Long: "Rollback the most recently applied migration",
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
logger := slog.Default()
|
|
if logging.AppLogger != nil {
|
|
logger = logging.AppLogger
|
|
}
|
|
|
|
if err := resolveDatabaseSecretFiles(); err != nil {
|
|
logger.Error("configuration error", slog.Any("error", err))
|
|
return err
|
|
}
|
|
|
|
database, closeDB, err := openDB(context.Background(), logger, dbMigration)
|
|
if err != nil {
|
|
logger.Error("failed to connect to database", slog.Any("error", err))
|
|
return err
|
|
}
|
|
defer closeDB()
|
|
|
|
if err := db.RollbackMigration(database, migrationSources()); err != nil {
|
|
logger.Error("failed to rollback migration", slog.Any("error", err))
|
|
return err
|
|
}
|
|
|
|
logger.Info("migration rollback completed successfully")
|
|
return nil
|
|
},
|
|
}
|
|
|
|
var migrateStatusCmd = &cobra.Command{
|
|
Use: "status",
|
|
Short: "Show migration status",
|
|
Long: "Display the status of all migrations",
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
logger := slog.Default()
|
|
if logging.AppLogger != nil {
|
|
logger = logging.AppLogger
|
|
}
|
|
|
|
if err := resolveDatabaseSecretFiles(); err != nil {
|
|
logger.Error("configuration error", slog.Any("error", err))
|
|
return err
|
|
}
|
|
|
|
database, closeDB, err := openDB(context.Background(), logger, dbMigration)
|
|
if err != nil {
|
|
logger.Error("failed to connect to database", slog.Any("error", err))
|
|
return err
|
|
}
|
|
defer closeDB()
|
|
|
|
if err := db.MigrationStatus(database, migrationSources()); err != nil {
|
|
logger.Error("failed to get migration status", slog.Any("error", err))
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
},
|
|
}
|
|
|
|
// migrationSources returns the ordered migration sources for all modules.
|
|
func migrationSources() []db.MigrationSource {
|
|
return migrate.Sources()
|
|
}
|
|
|
|
func init() {
|
|
migrateCmd.AddCommand(migrateUpCmd)
|
|
migrateCmd.AddCommand(migrateDownCmd)
|
|
migrateCmd.AddCommand(migrateStatusCmd)
|
|
rootCmd.AddCommand(migrateCmd)
|
|
}
|