chore(backend): route wiring, error mapping, and a crossbeam-epoch bump
Cargo.lock moves crossbeam-epoch to 0.9.20, clearing RUSTSEC-2026-0204. Targeted rather than a broad `cargo update` across 406 crates, which is not a change to make days before a live event. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -41,6 +41,20 @@ async fn main() -> Result<()> {
|
||||
.init();
|
||||
|
||||
let config = AppConfig::from_env()?;
|
||||
|
||||
// Prove both media directories are writable BEFORE anything else runs. This is first
|
||||
// because everything downstream — the derivative backfill, export recovery, every upload —
|
||||
// assumes it silently.
|
||||
//
|
||||
// This used to be `create_dir_all(&media_path).await.ok()` far below, which discarded the
|
||||
// only signal there was, and EXPORT_PATH was never created or probed at all. The failure
|
||||
// mode that produced: a wrong bind mount or a root-owned volume left the app booting
|
||||
// *green* — `/health` only probes the database — so Caddy routed traffic to it, guests
|
||||
// joined, and every single upload failed with EACCES. Existence is not the property we
|
||||
// need; writability is, and the only way to know is to write.
|
||||
ensure_writable_dir(&config.media_path, "MEDIA_PATH").await?;
|
||||
ensure_writable_dir(&config.export_path, "EXPORT_PATH").await?;
|
||||
|
||||
let pool = db::create_pool(&config.database_url).await?;
|
||||
|
||||
// Reset any rows left mid-flight by a previous (possibly crashed) instance —
|
||||
@@ -86,9 +100,6 @@ async fn main() -> Result<()> {
|
||||
config.media_path.clone(),
|
||||
);
|
||||
|
||||
// Ensure media directories exist
|
||||
tokio::fs::create_dir_all(&config.media_path).await.ok();
|
||||
|
||||
let api = Router::new()
|
||||
// Auth
|
||||
.route("/api/v1/event", get(handlers::public::get_public_event))
|
||||
@@ -138,6 +149,10 @@ async fn main() -> Result<()> {
|
||||
// Current-user endpoints (live quota estimate, profile + privacy note bundle)
|
||||
.route("/api/v1/me/context", get(handlers::me::get_context))
|
||||
.route("/api/v1/me/quota", get(handlers::me::get_quota))
|
||||
// Self-service erasure. There was no user-deletion route at any role, so an erasure
|
||||
// request could only be honoured with hand-written SQL against production — and the join
|
||||
// page's data notice now promises this exists. See `me::delete_account`.
|
||||
.route("/api/v1/me", delete(handlers::me::delete_account))
|
||||
// Feed
|
||||
.route("/api/v1/feed", get(handlers::feed::feed))
|
||||
.route("/api/v1/feed/delta", get(handlers::feed::feed_delta))
|
||||
@@ -278,7 +293,10 @@ async fn main() -> Result<()> {
|
||||
// * The two handlers were the same `SELECT 1` with the same 2s timeout under two
|
||||
// names, so keeping both bought nothing.
|
||||
//
|
||||
// The external uptime monitor the runbook now calls for points at this route.
|
||||
// The external uptime monitor points at this route — DEPLOYMENT_RUNBOOK.md §10.4,
|
||||
// which documents the response table and is the only thing in this deployment that
|
||||
// can page a human. (That section previously did not exist and this comment claimed
|
||||
// it did; if you are removing §10.4, this route loses its only consumer.)
|
||||
.route("/health", get(health))
|
||||
.merge(api)
|
||||
.layer(TraceLayer::new_for_http())
|
||||
@@ -299,6 +317,56 @@ async fn main() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Create `dir` if absent, then prove we can actually write inside it. Hard error otherwise.
|
||||
///
|
||||
/// `create_dir_all` succeeding proves nothing: it is a no-op on an existing directory, so a
|
||||
/// root-owned volume, a read-only bind mount and a full filesystem all "succeed". The probe
|
||||
/// below is the only thing that distinguishes them, and it is worth the two syscalls once per
|
||||
/// boot to turn a silent evening of failed uploads into a container that refuses to start.
|
||||
///
|
||||
/// `label` is the env var name so the operator gets the name of the knob to fix, not a path
|
||||
/// they then have to trace back to a variable.
|
||||
async fn ensure_writable_dir(dir: &std::path::Path, label: &str) -> anyhow::Result<()> {
|
||||
use anyhow::Context;
|
||||
|
||||
tokio::fs::create_dir_all(dir)
|
||||
.await
|
||||
.with_context(|| format!("{label}: cannot create {}", dir.display()))?;
|
||||
|
||||
// A fixed name is fine: this runs once, before the server accepts requests, and two
|
||||
// instances sharing one volume would be a misconfiguration in its own right. Removed on
|
||||
// both the success and failure paths so a crashed boot cannot leave litter behind.
|
||||
let probe = dir.join(".eventsnap-write-probe");
|
||||
let result = async {
|
||||
let mut f = tokio::fs::File::create(&probe)
|
||||
.await
|
||||
.with_context(|| format!("{label}: cannot create a file in {}", dir.display()))?;
|
||||
// Write and fsync rather than just create: a full filesystem lets the create succeed
|
||||
// and fails at the first byte, which is exactly the disk-full endgame this guards.
|
||||
tokio::io::AsyncWriteExt::write_all(&mut f, b"ok")
|
||||
.await
|
||||
.with_context(|| format!("{label}: cannot write to {}", dir.display()))?;
|
||||
f.sync_all()
|
||||
.await
|
||||
.with_context(|| format!("{label}: cannot flush to {}", dir.display()))?;
|
||||
anyhow::Ok(())
|
||||
}
|
||||
.await;
|
||||
let _ = tokio::fs::remove_file(&probe).await;
|
||||
|
||||
result.with_context(|| {
|
||||
format!(
|
||||
"{label} ({}) is not writable. The app refuses to start rather than accept uploads \
|
||||
it cannot store — check the bind mount and that the volume is owned by the \
|
||||
container's non-root user.",
|
||||
dir.display()
|
||||
)
|
||||
})?;
|
||||
|
||||
tracing::info!(path = %dir.display(), "{label} is writable");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// How long `/health` waits for the database before calling the app unhealthy. Deliberately
|
||||
/// short: the point is to answer "can this process actually serve a request right now", and a
|
||||
/// probe that blocks for the acquire timeout is itself a symptom.
|
||||
|
||||
Reference in New Issue
Block a user