diff --git a/.env.example b/.env.example index 358f996..5354dac 100644 --- a/.env.example +++ b/.env.example @@ -2,9 +2,10 @@ APP_ENV=development -# Overrides config/*.toml. Nested keys use a double underscore. +# Overrides config/*.toml. Nested keys use a double underscore after APP. +# The running server reads APP__DATABASE__URL, not DATABASE_URL. # APP__SERVER__PORT=8080 -# APP__DATABASE__URL=postgres://app:app@127.0.0.1:5432/app +APP__DATABASE__URL=postgres://app:app@127.0.0.1:5432/app -# Used by sqlx-cli if you install it. +# Used by sqlx-cli if you install it. The Axum process ignores this key. DATABASE_URL=postgres://app:app@127.0.0.1:5432/app diff --git a/apps/server/src/config.rs b/apps/server/src/config.rs index f787579..0c489ff 100644 --- a/apps/server/src/config.rs +++ b/apps/server/src/config.rs @@ -12,7 +12,11 @@ use serde::{Deserialize, Serialize}; /// 1. struct defaults /// 2. `config/default.toml` /// 3. `config/{APP_ENV}.toml` (`development` when unset) -/// 4. `APP__SECTION__KEY` environment variables +/// 4. `config/local.toml` (gitignored) +/// 5. `APP__SECTION__KEY` environment variables +/// +/// Figment prefix is `APP__` (two underscores). `APP__DATABASE__URL` maps to +/// `database.url`. `APP_DATABASE__URL` does not. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AppConfig { pub app: AppSection, @@ -106,14 +110,15 @@ impl Default for AppConfig { impl AppConfig { pub fn load() -> Result> { - let _ = dotenvy::dotenv(); + load_dotenv(); let env = current_env(); let config_dir = discover_config_dir(); Figment::from(Serialized::defaults(Self::default())) .merge(Toml::file(config_dir.join("default.toml"))) .merge(Toml::file(config_dir.join(format!("{env}.toml")))) - .merge(Env::prefixed("APP_").split("__")) + .merge(Toml::file(config_dir.join("local.toml"))) + .merge(Env::prefixed("APP__").split("__")) .extract() .map_err(Box::new) } @@ -135,6 +140,39 @@ pub fn current_env() -> String { std::env::var("APP_ENV").unwrap_or_else(|_| "development".into()) } +/// Walk from cwd / crate dir so RustRover can start the binary from `apps/server`. +fn load_dotenv() { + let mut candidates = Vec::new(); + if let Ok(cwd) = std::env::current_dir() { + candidates.extend(cwd.ancestors().map(|dir| dir.join(".env"))); + } + if let Ok(manifest) = std::env::var("CARGO_MANIFEST_DIR") { + let manifest = PathBuf::from(manifest); + candidates.push(manifest.join(".env")); + candidates.push(manifest.join("../../.env")); + } + + for path in candidates { + if path.is_file() { + let _ = dotenvy::from_path(&path); + return; + } + } +} + +/// Drop the password so logs can show which role/host we actually used. +pub fn redact_db_url(url: &str) -> String { + let Some(scheme_end) = url.find("://") else { + return url.to_string(); + }; + let rest = &url[scheme_end + 3..]; + let Some(at) = rest.find('@') else { + return url.to_string(); + }; + let user = rest[..at].split(':').next().unwrap_or(""); + format!("{}{user}{}", &url[..=scheme_end + 2], &rest[at..]) +} + /// Walks from cwd (and the server crate dir) until `config/default.toml` is found. pub fn discover_config_dir() -> PathBuf { if let Ok(explicit) = std::env::var("APP_CONFIG_DIR") { diff --git a/apps/server/src/main.rs b/apps/server/src/main.rs index f240edc..73fa843 100644 --- a/apps/server/src/main.rs +++ b/apps/server/src/main.rs @@ -19,7 +19,11 @@ async fn main() -> anyhow::Result<()> { ); // 3. Database + migrations - let pool = db::connect(&config).await?; + let database = server::config::redact_db_url(&config.database.url); + tracing::info!(%database, "connecting"); + let pool = db::connect(&config) + .await + .map_err(|err| anyhow::anyhow!("database connect failed ({database}): {err}"))?; if config.database.run_migrations { db::migrate(&pool).await?; tracing::info!("migrations applied"); diff --git a/apps/server/tests/config.rs b/apps/server/tests/config.rs index ff21044..4805dff 100644 --- a/apps/server/tests/config.rs +++ b/apps/server/tests/config.rs @@ -1,4 +1,9 @@ +use std::sync::Mutex; + use server::AppConfig; +use server::config::redact_db_url; + +static ENV_LOCK: Mutex<()> = Mutex::new(()); #[test] fn default_config_binds_loopback() { @@ -10,8 +15,30 @@ fn default_config_binds_loopback() { #[test] fn layered_config_loads_from_workspace() { + let _guard = ENV_LOCK.lock().expect("env lock"); let config = AppConfig::load().expect("load config from workspace"); assert_eq!(config.server.host, "127.0.0.1"); assert_eq!(config.frontend.host, "127.0.0.1"); assert!(!config.database.url.is_empty()); } + +#[test] +fn app_double_underscore_overrides_database_url() { + let _guard = ENV_LOCK.lock().expect("env lock"); + unsafe { + std::env::set_var("APP__DATABASE__URL", "postgres://jared:secret@127.0.0.1:5432/mydb"); + } + let config = AppConfig::load().expect("load config with env override"); + unsafe { + std::env::remove_var("APP__DATABASE__URL"); + } + assert_eq!(config.database.url, "postgres://jared:secret@127.0.0.1:5432/mydb"); +} + +#[test] +fn redact_db_url_strips_password() { + assert_eq!( + redact_db_url("postgres://app:hunter2@127.0.0.1:5432/app"), + "postgres://app@127.0.0.1:5432/app" + ); +} diff --git a/docs/runbook.md b/docs/runbook.md index 4936dae..2272641 100644 --- a/docs/runbook.md +++ b/docs/runbook.md @@ -44,4 +44,11 @@ Vite also regenerates on dev/build if `packages/contracts/openapi.json` changes. Default: `postgres://app:app@127.0.0.1:5432/app` -Override with `APP__DATABASE__URL` or `config/local.toml` (gitignored). +That user only exists if you start the bundled compose file. For a local cluster, override with `APP__DATABASE__URL` (two underscores after `APP`) or `config/local.toml`: + +```toml +[database] +url = "postgres://YOUR_USER:YOUR_PASS@127.0.0.1:5432/YOUR_DB" +``` + +`DATABASE_URL` is for sqlx-cli only. The server does not read it.