From 31db4ede38e44ffcb1b69ae987de284ca5f45745 Mon Sep 17 00:00:00 2001 From: Nils Schneider Date: Tue, 11 Nov 2025 15:54:57 +0100 Subject: [PATCH] admin migrate-database --- backend/src/bin/admin.rs | 38 ++++++++++++++++++++++++++++++-- backend/src/config.rs | 17 +++++++++++++- backend/src/lib.rs | 1 + backend/src/migrations.rs | 3 +++ backend/src/test_support/mod.rs | 5 +++-- backend/src/workers/issued_at.rs | 1 + 6 files changed, 60 insertions(+), 5 deletions(-) create mode 100644 backend/src/migrations.rs diff --git a/backend/src/bin/admin.rs b/backend/src/bin/admin.rs index a108142..665f612 100644 --- a/backend/src/bin/admin.rs +++ b/backend/src/bin/admin.rs @@ -3,18 +3,21 @@ use std::sync::Arc; use anyhow::{anyhow, bail, Context, Result}; use chrono::{Duration as ChronoDuration, Utc}; use clap::{Parser, Subcommand, ValueEnum}; -use diesel::{dsl::exists, prelude::*, select}; +use diesel::{dsl::exists, pg::PgConnection, prelude::*, select}; +use diesel_migrations::MigrationHarness; use rand::{rngs::OsRng, TryRngCore}; use reqwest::{Client, Method, StatusCode}; use sha2::{Digest, Sha256}; +use tokio::task; use uuid::Uuid; use papercrate::{ auth::capability_sets::{ensure_capability_set, owner_capabilities}, - config::AppConfig, + config::{redact_database_url, AppConfig}, db::{self, PgPool}, documents::search::ensure_quickwit_index, jobs::{enqueue_job, JOB_ANALYZE_DOCUMENT, JOB_DELETE_TENANT}, + migrations::MIGRATIONS, models::{ DocumentAsset, DocumentAssetObject, MagicToken, MagicTokenKind, NewUser, NewUserMembership, Tenant, TenantStatus, User, @@ -112,6 +115,14 @@ enum Command { #[arg(long = "kind", value_enum, default_value_t = MagicTokenKindArg::EmailLogin)] kind: MagicTokenKindArg, }, + MigrateDatabase { + #[arg( + long = "database-url", + value_name = "URL", + help = "Override the migrations database URL (defaults to MIGRATIONS_DATABASE_URL or DATABASE_URL)" + )] + database_url: Option, + }, } #[derive(Copy, Clone, Debug, ValueEnum)] @@ -211,11 +222,34 @@ async fn main() -> Result<()> { } => { create_magic_token(&pool, &username, ttl_minutes, max_uses, kind.into())?; } + Command::MigrateDatabase { database_url } => { + let url = database_url.unwrap_or_else(|| config.migrations_database_url().to_string()); + migrate_database(url).await?; + } } Ok(()) } +async fn migrate_database(database_url: String) -> Result<()> { + let redacted = redact_database_url(&database_url); + tracing::info!(database_url = %redacted, "running pending migrations"); + + let result = task::spawn_blocking(move || -> Result<()> { + let mut conn = + PgConnection::establish(&database_url).context("failed to connect to database")?; + conn.run_pending_migrations(MIGRATIONS) + .map_err(|err| anyhow!("failed to run migrations: {err}"))?; + Ok(()) + }) + .await + .context("migration task panicked")?; + + result?; + tracing::info!(database_url = %redacted, "migrations completed"); + Ok(()) +} + fn create_user(pool: &PgPool, username: &str) -> Result<()> { let username = normalize_identifier( username, diff --git a/backend/src/config.rs b/backend/src/config.rs index 6fc2e77..81d0cc5 100644 --- a/backend/src/config.rs +++ b/backend/src/config.rs @@ -10,6 +10,8 @@ use crate::db::DEFAULT_MAX_POOL_SIZE; #[derive(Clone, Debug, Deserialize)] pub struct AppConfig { pub database_url: String, + #[serde(default)] + pub migrations_database_url: Option, #[serde(default = "default_database_max_pool_size")] pub database_max_pool_size: u32, #[serde(default = "default_server_host")] @@ -86,6 +88,7 @@ impl AppConfig { tracing::info!( component, database_url = %config.redacted_database_url(), + migrations_database_url = %config.redacted_migrations_database_url(), pool_size = config.database_max_pool_size, quickwit_enabled = config.quickwit_endpoint.is_some(), passkeys_enabled = config.webauthn_origin.is_some(), @@ -107,6 +110,18 @@ impl AppConfig { pub fn redacted_database_url(&self) -> String { redact_database_url(&self.database_url) } + + pub fn redacted_migrations_database_url(&self) -> String { + redact_database_url(self.migrations_database_url()) + } + + pub fn migrations_database_url(&self) -> &str { + if let Some(ref url) = self.migrations_database_url { + url + } else { + &self.database_url + } + } } impl AppConfig { @@ -244,7 +259,7 @@ fn default_webauthn_rp_name() -> String { "Papercrate".to_string() } -fn redact_database_url(raw: &str) -> String { +pub fn redact_database_url(raw: &str) -> String { match Url::parse(raw) { Ok(mut parsed) => { if parsed.password().is_some() { diff --git a/backend/src/lib.rs b/backend/src/lib.rs index 5162182..fc18edd 100644 --- a/backend/src/lib.rs +++ b/backend/src/lib.rs @@ -18,4 +18,5 @@ pub mod tenants; pub mod utils; pub mod workers; pub use workers::{default_handlers, Worker}; +pub mod migrations; pub mod test_support; diff --git a/backend/src/migrations.rs b/backend/src/migrations.rs new file mode 100644 index 0000000..d10cb83 --- /dev/null +++ b/backend/src/migrations.rs @@ -0,0 +1,3 @@ +use diesel_migrations::{embed_migrations, EmbeddedMigrations}; + +pub const MIGRATIONS: EmbeddedMigrations = embed_migrations!("migrations"); diff --git a/backend/src/test_support/mod.rs b/backend/src/test_support/mod.rs index 1cb90d5..f3b22d1 100644 --- a/backend/src/test_support/mod.rs +++ b/backend/src/test_support/mod.rs @@ -10,6 +10,7 @@ use crate::auth::capability_sets::{ use crate::auth::jwt::{AccessTokenContext, JwtService, PrincipalKind}; use crate::config::AppConfig; use crate::db::{self, PgPool}; +use crate::migrations::MIGRATIONS; use crate::models::{ Job, NewUser, NewUserMembership, NewUserPasskey, NewUserSession, Tenant, TenantStatus, User, UserMembership, @@ -28,7 +29,7 @@ use diesel::connection::SimpleConnection; use diesel::prelude::*; use diesel::OptionalExtension; use diesel::PgConnection; -use diesel_migrations::{embed_migrations, EmbeddedMigrations, MigrationHarness}; +use diesel_migrations::MigrationHarness; use http_body_util::BodyExt; use once_cell::sync::Lazy; use rand::{rngs::OsRng, TryRngCore}; @@ -39,7 +40,6 @@ use tokio::sync::Mutex; use tower::util::ServiceExt; use uuid::Uuid; -const MIGRATIONS: EmbeddedMigrations = embed_migrations!("migrations"); const RESET_DATABASE_SQL: &str = "DROP SCHEMA IF EXISTS tenant CASCADE;\n\ DROP SCHEMA IF EXISTS shared CASCADE;\n\ DROP SCHEMA IF EXISTS public CASCADE;\n\ @@ -170,6 +170,7 @@ impl TestApp { let mut config = AppConfig { database_url: database_url.clone(), + migrations_database_url: None, database_max_pool_size: db::DEFAULT_MAX_POOL_SIZE, server_host: "127.0.0.1".to_string(), server_port: 0, diff --git a/backend/src/workers/issued_at.rs b/backend/src/workers/issued_at.rs index 216597f..0549000 100644 --- a/backend/src/workers/issued_at.rs +++ b/backend/src/workers/issued_at.rs @@ -641,6 +641,7 @@ mod tests { fn base_config() -> AppConfig { AppConfig { database_url: "postgres://test".to_string(), + migrations_database_url: None, database_max_pool_size: 5, server_host: "127.0.0.1".to_string(), server_port: 0,