fix(compose): wire all documented backend env vars + regression test (0.87.12)

0.87.1 added 12+ env vars to `.env.example` but the compose backend
`environment:` block forwarded none of them — `.env` interpolation
doesn't pass vars to the container. Operators got
anonymous-serving sites, silent ADMIN_* no-ops, and missing
ANALYSIS_* configuration.

Wire 36 backend-consumed vars; also substitute the hardcoded
`BACKEND_URL` on the frontend; emit a startup warning on half-set
`ADMIN_USERNAME`/`ADMIN_PASSWORD`. Regression-test parses
docker-compose.yml and asserts the wiring.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-06-23 07:25:57 +02:00
parent 34d6d570eb
commit 95b98eebf1
5 changed files with 155 additions and 8 deletions

View File

@@ -468,11 +468,30 @@ impl Config {
/// Returns `Some((username, password))` only when BOTH `ADMIN_USERNAME`
/// and `ADMIN_PASSWORD` are set and non-empty. Half-set configuration is
/// treated as "no bootstrap" rather than a hard error, so an operator
/// can comment out one env var without crashing the server.
/// can comment out one env var without crashing the server. Emits a
/// warning in the half-set case — silent no-op startled operators when
/// the documented compose env vars finally landed in 0.87.12 (a typo
/// in ADMIN_USERNAME used to silently disable bootstrap with no log
/// trace explaining why no admin appeared).
fn admin_bootstrap_from_env() -> Option<(String, String)> {
let username = std::env::var("ADMIN_USERNAME").ok().filter(|s| !s.is_empty())?;
let password = std::env::var("ADMIN_PASSWORD").ok().filter(|s| !s.is_empty())?;
Some((username, password))
let username = std::env::var("ADMIN_USERNAME").ok().filter(|s| !s.is_empty());
let password = std::env::var("ADMIN_PASSWORD").ok().filter(|s| !s.is_empty());
match (username, password) {
(Some(u), Some(p)) => Some((u, p)),
(Some(_), None) => {
tracing::warn!(
"ADMIN_USERNAME is set but ADMIN_PASSWORD is empty — admin bootstrap skipped (set both, or unset both to silence this warning)"
);
None
}
(None, Some(_)) => {
tracing::warn!(
"ADMIN_PASSWORD is set but ADMIN_USERNAME is empty — admin bootstrap skipped (set both, or unset both to silence this warning)"
);
None
}
(None, None) => None,
}
}
impl CrawlerConfig {
@@ -803,5 +822,94 @@ mod tests {
let cfg = Config::from_env().expect("from_env");
assert!(!cfg.auth.private_mode);
}
/// Regression test for the 0.87.1 compose env-wire-through gap. The
/// docker-compose backend service used to omit `environment:` entries
/// for `ADMIN_*`, `PRIVATE_MODE`, `ALLOW_SELF_REGISTER`, `CRAWLER_*`,
/// and `ANALYSIS_*`. Operators populating `.env` got an
/// anonymous-serving site, no admin bootstrap, and missing analysis
/// configuration because Docker Compose's `.env` interpolation only
/// expands inside this file — it does NOT pass vars into the
/// container's process env unless `environment:` (or `env_file:`)
/// names them.
///
/// This test pins the wiring: every env var the backend reader
/// consumes must have a matching `KEY:` entry in the backend
/// service's `environment:` block.
#[test]
fn docker_compose_wires_every_documented_backend_env_var() {
// `.env.example` lines that ARE consumed by the backend. Lines
// that aren't (POSTGRES_*, TOR_CONTROL_PASSWORD, BACKEND_URL,
// BACKEND_PROXY_TIMEOUT_MS, VISION_MANAGER_*, the `VISION_*`
// sidecar-only vars) are deliberately excluded — keeping this
// list explicit so we get a compile-broken signal rather than
// a silently-passing test if a new var is added.
const BACKEND_CONSUMED: &[&str] = &[
"BIND_ADDRESS",
"STORAGE_DIR",
"RUST_LOG",
"COOKIE_SECURE",
"COOKIE_DOMAIN",
"SESSION_TTL_DAYS",
"AUTH_RATE_PER_SEC",
"AUTH_RATE_BURST",
"CORS_ALLOWED_ORIGINS",
"ADMIN_ALLOWED_ORIGINS",
"ADMIN_USERNAME",
"ADMIN_PASSWORD",
"PRIVATE_MODE",
"ALLOW_SELF_REGISTER",
"MAX_REQUEST_BYTES",
"MAX_FILE_BYTES",
"CRAWLER_START_URL",
"CRAWLER_CDN_HOST",
"CRAWLER_LIMIT",
"CRAWLER_JOB_TIMEOUT_SECS",
"CRAWLER_METADATA_MAX_CONSECUTIVE_FAILURES",
"CRAWLER_BROWSER_RESTART_THRESHOLD",
"CRAWLER_DOWNLOAD_ALLOWLIST",
"CRAWLER_ALLOW_ANY_HOST",
"CRAWLER_MAX_IMAGE_BYTES",
"CRAWLER_CHROMIUM_BINARY",
"CRAWLER_PROXY",
"CRAWLER_TOR_CONTROL_URL",
"CRAWLER_TOR_RECIRCUIT_MAX_ATTEMPTS",
"ANALYSIS_ENABLED",
"ANALYSIS_VISION_URL",
"ANALYSIS_VISION_MODEL",
"ANALYSIS_WORKERS",
"ANALYSIS_JOB_TIMEOUT_SECS",
"ANALYSIS_API_KEY",
"ANALYSIS_VISION_HEALTH_URL",
];
// Cargo run dir for `cargo test` is `backend/`; compose file lives one up.
let compose = std::fs::read_to_string("../docker-compose.yml")
.expect("read ../docker-compose.yml");
// Extract the backend service block: everything from ` backend:`
// up to (but not including) the next top-level service key.
let backend_block = compose
.split("\n backend:\n")
.nth(1)
.expect("backend service block")
.split("\n frontend:\n")
.next()
.expect("end of backend block (next service)");
let mut missing: Vec<&str> = Vec::new();
for key in BACKEND_CONSUMED {
// Match ` KEY:` indent (6 spaces — under `environment:`).
let needle = format!(" {key}:");
if !backend_block.contains(&needle) {
missing.push(*key);
}
}
assert!(
missing.is_empty(),
"docker-compose.yml backend service is missing env wiring for: {missing:?}. \
Add `KEY: ${{KEY:-default}}` lines under backend.environment so the var \
reaches the container."
);
}
}