README step 2 said to set "DOMAIN, JWT_SECRET, ADMIN_PASSWORD_HASH, EVENT_NAME, etc." -- POSTGRES_PASSWORD was not in that list. .env.example said to set it and keep it in sync with DATABASE_URL. The two documents disagreed, and both readings ended badly. Branch A, README verbatim: the stack came up GREEN and healthy on CHANGE_ME_use_a_strong_password -- a database credential published in the public repo. The production secret guard covered JWT_SECRET and ADMIN_PASSWORD_HASH, and nothing anywhere looked at the Postgres password. Branch B, .env.example verbatim: a permanent restart loop, "password authentication failed for user eventsnap". The guard's own design made Branch B near-certain. It stops the APP on the first `docker compose up -d` -- but not the `db` service in that same command, which initialises its data directory and bakes in whatever password was in .env at that moment. POSTGRES_PASSWORD is honoured ONLY at initdb. So the intended recovery -- see the refusal, fix your secrets, boot again -- was exactly the sequence that broke it. Nothing in the error named the cause, and the remedy (`down -v`) is both unguessable and the one command you must never run once real data exists. Three changes, which have to ship together: the guard alone would just move operators out of Branch A and into Branch B. - The guard now rejects a placeholder DATABASE_URL in production (the password rides in that URL, which is what the app actually reads). Branch A can no longer boot. - It reports EVERY unset secret in one message instead of returning on the first. Fixing two secrets used to cost two boot cycles, on a stack where Caddy waits on the unhealthy app throughout, and each avoidable cycle is another chance to reach for -v. - A 28P01 handler in db.rs turns the unguessable failure into a self-explaining one: it names the initdb semantics, gives `down -v` with an explicit "deletes db + media + exports, no undo", and gives the ALTER ROLE alternative for when data already exists. Docs: step 2 now names POSTGRES_PASSWORD and says every secret must be set BEFORE the first up; the troubleshooting block covers the auth-failure loop and both remedies. Also replaces htpasswd (apache2-utils -- not on a stock VPS) with `docker run --rm caddy:2-alpine caddy hash-password`, an image the stack already pulls, in README, .env.example and the guard's own message. Verified the output ($2a$14) against the shipped $2y$12 example. Verified on a real Postgres, not just in tests: Branch A refuses and names DATABASE_URL; all three placeholders report in one boot; a volume initialised with one password and connected to with another prints the diagnostic; an unrelated connect failure (dead port) stays silent. 8 unit tests, including that non-prod ignores all of it so the e2e stack is unaffected. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
75 lines
3.0 KiB
Rust
75 lines
3.0 KiB
Rust
use anyhow::{Context, Result};
|
|
use sqlx::PgPool;
|
|
use sqlx::postgres::PgPoolOptions;
|
|
|
|
const DEFAULT_MAX_CONNECTIONS: u32 = 10;
|
|
|
|
/// SQLSTATE for `invalid_password`.
|
|
const PG_INVALID_PASSWORD: &str = "28P01";
|
|
|
|
/// Turn the one connect failure with an unguessable cause into a self-explaining one.
|
|
///
|
|
/// `POSTGRES_PASSWORD` is honoured ONLY when Postgres initialises its data directory. Change it in
|
|
/// `.env` afterwards and the app authenticates with the new password against a volume that still
|
|
/// holds the old one — a permanent restart loop whose only symptom is
|
|
/// `password authentication failed`.
|
|
///
|
|
/// The production secret guard makes that sequence NEARLY CERTAIN rather than rare: it stops the
|
|
/// app on the first `docker compose up -d`, but not the `db` service in that same command, which
|
|
/// initialises and bakes in whatever password was in `.env` at that moment. So the intended
|
|
/// recovery — see the refusal, fix your secrets, boot again — is exactly the sequence that breaks
|
|
/// it. Nothing in the error names the cause, and the remedy destroys data, so it is the last thing
|
|
/// an operator should guess at.
|
|
fn explain_auth_failure(err: &sqlx::Error) {
|
|
let is_auth_failure = match err {
|
|
sqlx::Error::Database(db) => db.code().as_deref() == Some(PG_INVALID_PASSWORD),
|
|
_ => false,
|
|
};
|
|
if !is_auth_failure {
|
|
return;
|
|
}
|
|
tracing::error!(
|
|
"Postgres rejected the credentials in DATABASE_URL (SQLSTATE {PG_INVALID_PASSWORD}).\n\
|
|
\n\
|
|
This almost always means POSTGRES_PASSWORD was changed AFTER the database volume was \
|
|
first created. Postgres applies that variable only when it initialises its data \
|
|
directory; editing .env and restarting does not change the stored password, so the two \
|
|
drift apart permanently.\n\
|
|
\n\
|
|
If the event has NOT started and you have no data worth keeping:\n\n \
|
|
docker compose down -v && docker compose up -d\n\n\
|
|
(-v DELETES the database, the uploaded media and the exports. There is no undo.)\n\
|
|
\n\
|
|
If you DO have data: restore the old password into DATABASE_URL instead, or change the \
|
|
stored one with ALTER ROLE inside the running db container. Never reach for -v to fix a \
|
|
login problem on a live event."
|
|
);
|
|
}
|
|
|
|
pub async fn create_pool(database_url: &str) -> Result<PgPool> {
|
|
let max_connections = std::env::var("DATABASE_MAX_CONNECTIONS")
|
|
.ok()
|
|
.and_then(|s| s.parse::<u32>().ok())
|
|
.unwrap_or(DEFAULT_MAX_CONNECTIONS);
|
|
|
|
let pool = match PgPoolOptions::new()
|
|
.max_connections(max_connections)
|
|
.connect(database_url)
|
|
.await
|
|
{
|
|
Ok(pool) => pool,
|
|
Err(e) => {
|
|
explain_auth_failure(&e);
|
|
return Err(e).context("failed to connect to database");
|
|
}
|
|
};
|
|
|
|
sqlx::migrate!()
|
|
.run(&pool)
|
|
.await
|
|
.context("failed to run database migrations")?;
|
|
|
|
tracing::info!(max_connections, "database connected and migrations applied");
|
|
Ok(pool)
|
|
}
|