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:
2
backend/Cargo.lock
generated
2
backend/Cargo.lock
generated
@@ -1517,7 +1517,7 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mangalord"
|
name = "mangalord"
|
||||||
version = "0.87.11"
|
version = "0.87.12"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"argon2",
|
"argon2",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "mangalord"
|
name = "mangalord"
|
||||||
version = "0.87.11"
|
version = "0.87.12"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
default-run = "mangalord"
|
default-run = "mangalord"
|
||||||
|
|
||||||
|
|||||||
@@ -468,11 +468,30 @@ impl Config {
|
|||||||
/// Returns `Some((username, password))` only when BOTH `ADMIN_USERNAME`
|
/// Returns `Some((username, password))` only when BOTH `ADMIN_USERNAME`
|
||||||
/// and `ADMIN_PASSWORD` are set and non-empty. Half-set configuration is
|
/// and `ADMIN_PASSWORD` are set and non-empty. Half-set configuration is
|
||||||
/// treated as "no bootstrap" rather than a hard error, so an operator
|
/// 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)> {
|
fn admin_bootstrap_from_env() -> Option<(String, String)> {
|
||||||
let username = std::env::var("ADMIN_USERNAME").ok().filter(|s| !s.is_empty())?;
|
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())?;
|
let password = std::env::var("ADMIN_PASSWORD").ok().filter(|s| !s.is_empty());
|
||||||
Some((username, password))
|
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 {
|
impl CrawlerConfig {
|
||||||
@@ -803,5 +822,94 @@ mod tests {
|
|||||||
let cfg = Config::from_env().expect("from_env");
|
let cfg = Config::from_env().expect("from_env");
|
||||||
assert!(!cfg.auth.private_mode);
|
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."
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -75,12 +75,39 @@ services:
|
|||||||
COOKIE_SECURE: ${COOKIE_SECURE:-true}
|
COOKIE_SECURE: ${COOKIE_SECURE:-true}
|
||||||
COOKIE_DOMAIN: ${COOKIE_DOMAIN:-}
|
COOKIE_DOMAIN: ${COOKIE_DOMAIN:-}
|
||||||
SESSION_TTL_DAYS: ${SESSION_TTL_DAYS:-30}
|
SESSION_TTL_DAYS: ${SESSION_TTL_DAYS:-30}
|
||||||
|
AUTH_RATE_PER_SEC: ${AUTH_RATE_PER_SEC:-5}
|
||||||
|
AUTH_RATE_BURST: ${AUTH_RATE_BURST:-10}
|
||||||
# CORS — same-origin by default; populate when serving the API on
|
# CORS — same-origin by default; populate when serving the API on
|
||||||
# a different host than the frontend.
|
# a different host than the frontend.
|
||||||
CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS:-}
|
CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS:-}
|
||||||
|
# Admin CSRF allowlist. Empty in default deploy → cookie-auth admin
|
||||||
|
# mutations are refused (fail-closed since 0.87.2). Set to the
|
||||||
|
# SvelteKit origin (e.g. https://app.example.com) for browser deploys.
|
||||||
|
ADMIN_ALLOWED_ORIGINS: ${ADMIN_ALLOWED_ORIGINS:-}
|
||||||
|
# Admin bootstrap. Half-set is treated as "skip bootstrap"; either
|
||||||
|
# both or neither. Set on first boot, then unset for subsequent
|
||||||
|
# deploys so the password isn't re-applied accidentally (existing
|
||||||
|
# users' passwords are never overwritten by this).
|
||||||
|
ADMIN_USERNAME: ${ADMIN_USERNAME:-}
|
||||||
|
ADMIN_PASSWORD: ${ADMIN_PASSWORD:-}
|
||||||
|
# Site-wide auth gates (env-only; never persisted to app_settings).
|
||||||
|
PRIVATE_MODE: ${PRIVATE_MODE:-false}
|
||||||
|
ALLOW_SELF_REGISTER: ${ALLOW_SELF_REGISTER:-true}
|
||||||
# Upload limits.
|
# Upload limits.
|
||||||
MAX_REQUEST_BYTES: ${MAX_REQUEST_BYTES:-209715200}
|
MAX_REQUEST_BYTES: ${MAX_REQUEST_BYTES:-209715200}
|
||||||
MAX_FILE_BYTES: ${MAX_FILE_BYTES:-20971520}
|
MAX_FILE_BYTES: ${MAX_FILE_BYTES:-20971520}
|
||||||
|
# Crawler boot seeds (first-boot only; switch to dashboard-editable
|
||||||
|
# once the app_settings row exists).
|
||||||
|
CRAWLER_START_URL: ${CRAWLER_START_URL:-}
|
||||||
|
CRAWLER_CDN_HOST: ${CRAWLER_CDN_HOST:-}
|
||||||
|
CRAWLER_LIMIT: ${CRAWLER_LIMIT:-0}
|
||||||
|
CRAWLER_JOB_TIMEOUT_SECS: ${CRAWLER_JOB_TIMEOUT_SECS:-600}
|
||||||
|
CRAWLER_METADATA_MAX_CONSECUTIVE_FAILURES: ${CRAWLER_METADATA_MAX_CONSECUTIVE_FAILURES:-10}
|
||||||
|
CRAWLER_BROWSER_RESTART_THRESHOLD: ${CRAWLER_BROWSER_RESTART_THRESHOLD:-3}
|
||||||
|
# Crawler download safety — env-only, never persisted.
|
||||||
|
CRAWLER_DOWNLOAD_ALLOWLIST: ${CRAWLER_DOWNLOAD_ALLOWLIST:-}
|
||||||
|
CRAWLER_ALLOW_ANY_HOST: ${CRAWLER_ALLOW_ANY_HOST:-false}
|
||||||
|
CRAWLER_MAX_IMAGE_BYTES: ${CRAWLER_MAX_IMAGE_BYTES:-33554432}
|
||||||
# System-chromium override for the crawler. Leave blank to use the
|
# System-chromium override for the crawler. Leave blank to use the
|
||||||
# bundled fetcher; set to e.g. /usr/bin/chromium-headless-shell on
|
# bundled fetcher; set to e.g. /usr/bin/chromium-headless-shell on
|
||||||
# arm64 deployments. Pair with `--build-arg INSTALL_CHROMIUM=true`
|
# arm64 deployments. Pair with `--build-arg INSTALL_CHROMIUM=true`
|
||||||
@@ -96,6 +123,15 @@ services:
|
|||||||
CRAWLER_TOR_CONTROL_URL: ${CRAWLER_TOR_CONTROL_URL-tcp://tor:9051}
|
CRAWLER_TOR_CONTROL_URL: ${CRAWLER_TOR_CONTROL_URL-tcp://tor:9051}
|
||||||
CRAWLER_TOR_CONTROL_PASSWORD: ${TOR_CONTROL_PASSWORD:?TOR_CONTROL_PASSWORD must be set in .env}
|
CRAWLER_TOR_CONTROL_PASSWORD: ${TOR_CONTROL_PASSWORD:?TOR_CONTROL_PASSWORD must be set in .env}
|
||||||
CRAWLER_TOR_RECIRCUIT_MAX_ATTEMPTS: ${CRAWLER_TOR_RECIRCUIT_MAX_ATTEMPTS:-3}
|
CRAWLER_TOR_RECIRCUIT_MAX_ATTEMPTS: ${CRAWLER_TOR_RECIRCUIT_MAX_ATTEMPTS:-3}
|
||||||
|
# Analysis worker (vision) — boot seeds + env-only API key.
|
||||||
|
ANALYSIS_ENABLED: ${ANALYSIS_ENABLED:-false}
|
||||||
|
ANALYSIS_VISION_URL: ${ANALYSIS_VISION_URL:-}
|
||||||
|
ANALYSIS_VISION_MODEL: ${ANALYSIS_VISION_MODEL:-}
|
||||||
|
ANALYSIS_WORKERS: ${ANALYSIS_WORKERS:-1}
|
||||||
|
ANALYSIS_JOB_TIMEOUT_SECS: ${ANALYSIS_JOB_TIMEOUT_SECS:-600}
|
||||||
|
# API key never gets persisted to app_settings; bearer-attached to
|
||||||
|
# every vision call.
|
||||||
|
ANALYSIS_API_KEY: ${ANALYSIS_API_KEY:-}
|
||||||
# Vision readiness gate (env-ONLY, not a dashboard setting). When set,
|
# Vision readiness gate (env-ONLY, not a dashboard setting). When set,
|
||||||
# the analysis worker refuses to lease a page until this answers 2xx, so
|
# the analysis worker refuses to lease a page until this answers 2xx, so
|
||||||
# the vision-manager autoscaler can idle-stop the vision container
|
# the vision-manager autoscaler can idle-stop the vision container
|
||||||
@@ -120,7 +156,10 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
# SvelteKit's hooks.server.ts proxies /api/* to this URL so the
|
# SvelteKit's hooks.server.ts proxies /api/* to this URL so the
|
||||||
# browser only ever talks to :3000 and cookies stay same-origin.
|
# browser only ever talks to :3000 and cookies stay same-origin.
|
||||||
BACKEND_URL: http://backend:8080
|
# `.env` override exists for deploys where the SvelteKit container
|
||||||
|
# needs to reach a backend on a different hostname (typical with
|
||||||
|
# an external reverse proxy in front of axum).
|
||||||
|
BACKEND_URL: ${BACKEND_URL:-http://backend:8080}
|
||||||
# Per-request wall-clock cap for the /api/* reverse proxy. Defaults
|
# Per-request wall-clock cap for the /api/* reverse proxy. Defaults
|
||||||
# to 300000 (5 min) in hooks.server.ts; raise/lower via .env.
|
# to 300000 (5 min) in hooks.server.ts; raise/lower via .env.
|
||||||
BACKEND_PROXY_TIMEOUT_MS: ${BACKEND_PROXY_TIMEOUT_MS:-300000}
|
BACKEND_PROXY_TIMEOUT_MS: ${BACKEND_PROXY_TIMEOUT_MS:-300000}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "mangalord-frontend",
|
"name": "mangalord-frontend",
|
||||||
"version": "0.87.11",
|
"version": "0.87.12",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
Reference in New Issue
Block a user