Files
simbarag/config/validation.py
T
ryan de88679f25
CI / backend (pull_request) Successful in 1m23s
CI / frontend (pull_request) Failing after 37s
CI / backend (push) Successful in 36s
CI / frontend (push) Failing after 7s
Validate production config, add CI, and build from locked dependencies
- Fail startup on unsafe or malformed production settings (scripts/validate_config.py),
  run it before migrations, and stop on a failed `aerich upgrade`.
- Forward the context-budget, timeout and metrics settings through Compose; accept
  the historical TAVILIY_API_KEY spelling.
- Build the image from uv.lock and yarn.lock in a multi-stage Dockerfile.
- Add Gitea Actions CI: Ruff, unit tests, context evaluation, frontend tests,
  typecheck and build.
- Update README and deployment/development docs to match the current stack.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 23:09:22 -04:00

262 lines
9.1 KiB
Python

"""Explicit startup validation; importing this module never requires secrets.
Errors name settings, never their values. This is intentionally independent of
application imports, database access, and optional integration clients.
"""
import base64
import binascii
import math
import posixpath
import re
from collections.abc import Mapping
from urllib.parse import unquote, urlsplit
class ConfigurationError(ValueError):
"""One or more configuration settings are unsafe or malformed."""
def validate_configuration(env: Mapping[str, str], *, production: bool = True) -> None:
errors: list[str] = []
def value(name: str) -> str:
return env.get(name, "").strip()
def problem(name: str, reason: str) -> None:
errors.append(f"{name}: {reason}")
def placeholder(text: str) -> bool:
text = text.lower()
return text in {"secret_key", "changeme", "password", "raggr_dev_password"} or (
text.startswith(("your-", "replace-", "replace_", "example-"))
)
def secret(name: str, *, required: bool = False, minimum: int = 1) -> None:
text = value(name)
if not text:
if required:
problem(name, "required for production startup")
elif production and (placeholder(text) or len(text) < minimum):
problem(
name, f"use a non-placeholder secret of at least {minimum} characters"
)
def url(name: str, *, https: bool = False) -> None:
text = value(name)
if not text:
return
try:
parsed = urlsplit(text)
valid = (
parsed.scheme in ({"https"} if https else {"http", "https"})
and parsed.hostname
and not parsed.username
and not parsed.password
and not parsed.fragment
and not any(c.isspace() for c in text)
)
# Accessing port validates malformed/out-of-range ports too.
if parsed.port == 0:
valid = False
except ValueError:
valid = False
if not valid:
problem(
name,
"must be a valid HTTPS URL" if https else "must be a valid HTTP(S) URL",
)
secret("JWT_SECRET_KEY", required=production, minimum=32)
secret("OPENAI_API_KEY", required=production)
secret("FERNET_KEY", required=production)
if value("FERNET_KEY"):
try:
if not re.fullmatch(r"[A-Za-z0-9_-]{43}=", value("FERNET_KEY")):
raise ValueError
decoded = base64.b64decode(
value("FERNET_KEY"), altchars=b"-_", validate=True
)
if len(decoded) != 32:
raise ValueError
except (ValueError, binascii.Error):
problem("FERNET_KEY", "must encode exactly 32 bytes in URL-safe base64")
database = value("DATABASE_URL")
if production or database:
try:
parsed = urlsplit(database)
valid = (
parsed.scheme == "postgres"
and parsed.hostname
and parsed.username
and parsed.password
and parsed.path not in {"", "/"}
and not parsed.fragment
and not any(c.isspace() for c in database)
)
if parsed.port == 0:
valid = False
if production and placeholder(unquote(parsed.password or "")):
valid = False
except ValueError:
valid = False
if not valid:
problem(
"DATABASE_URL",
"use postgres:// with host, user, non-placeholder password and database",
)
integers = {
"CONTEXT_WINDOW_TOKENS": 32768,
"PROMPT_BUDGET_TOKENS": 16000,
"RESPONSE_RESERVE_TOKENS": 4096,
"YNAB_SYNC_INTERVAL": 900,
"OBSIDIAN_SYNC_INTERVAL": 60,
"EMAIL_RATE_LIMIT_MAX": 5,
"EMAIL_RATE_LIMIT_WINDOW": 300,
"WHATSAPP_RATE_LIMIT_MAX": 10,
"WHATSAPP_RATE_LIMIT_WINDOW": 60,
"IMESSAGE_RATE_LIMIT_MAX": 10,
"IMESSAGE_RATE_LIMIT_WINDOW": 60,
}
numbers: dict[str, int] = {}
for name, default in integers.items():
try:
number = int(env.get(name, str(default)))
if number <= 0:
raise ValueError
numbers[name] = number
except ValueError:
problem(name, "must be a positive integer")
if all(
name in numbers
for name in (
"CONTEXT_WINDOW_TOKENS",
"PROMPT_BUDGET_TOKENS",
"RESPONSE_RESERVE_TOKENS",
)
):
if (
numbers["PROMPT_BUDGET_TOKENS"] + numbers["RESPONSE_RESERVE_TOKENS"]
> numbers["CONTEXT_WINDOW_TOKENS"]
):
problem(
"PROMPT_BUDGET_TOKENS",
"prompt budget plus response reserve exceeds context window",
)
for name, default in {
"TTFT_TIMEOUT_SECONDS": "30",
"LLAMA_TIMEOUT_SECONDS": "120",
}.items():
try:
number = float(env.get(name, default))
if not math.isfinite(number) or number <= 0:
raise ValueError
except ValueError:
problem(name, "must be a finite positive number")
for name in (
"OIDC_USE_DISCOVERY",
"OBSIDIAN_CONTINUOUS_SYNC",
"TWILIO_SIGNATURE_VALIDATION",
"MAILGUN_SIGNATURE_VALIDATION",
"SENDBLUE_SIGNATURE_VALIDATION",
):
if name in env and env[name] not in {"true", "false"}:
problem(name, "must be true or false")
for name in (
"LLAMA_SERVER_URL",
"EMBEDDING_SERVER_URL",
"MEALIE_BASE_URL",
"HOMEBOX_URL",
"BOOKSTACK_BASE_URL",
"S3_ENDPOINT_URL",
"SIMBAKIT_URL",
"CRONICLE_URL",
"OLLAMA_URL",
"OLLAMA_HOST",
"TWILIO_WEBHOOK_URL",
):
url(name)
for name in ("LLAMA_MODEL_NAME", "EMBEDDING_MODEL_NAME"):
if name in env and not value(name):
problem(name, "must not be empty when supplied")
# OIDC is optional; a partially configured login must not pass validation.
oidc = ("OIDC_ISSUER", "OIDC_CLIENT_ID", "OIDC_CLIENT_SECRET", "OIDC_REDIRECT_URI")
if any(value(name) for name in oidc):
for name in oidc:
if not value(name):
problem(name, "required when OIDC is configured")
secret("OIDC_CLIENT_SECRET")
url("OIDC_ISSUER", https=production)
url("OIDC_REDIRECT_URI", https=production)
if env.get("OIDC_USE_DISCOVERY", "true") == "false":
for name in (
"OIDC_AUTHORIZATION_ENDPOINT",
"OIDC_TOKEN_ENDPOINT",
"OIDC_USERINFO_ENDPOINT",
"OIDC_JWKS_URI",
):
if not value(name):
problem(name, "required when OIDC discovery is disabled")
url(name, https=production)
for trigger, signing_key, flag in (
("TWILIO_ACCOUNT_SID", "TWILIO_AUTH_TOKEN", "TWILIO_SIGNATURE_VALIDATION"),
(
"MAILGUN_DOMAIN",
"MAILGUN_WEBHOOK_SIGNING_KEY",
"MAILGUN_SIGNATURE_VALIDATION",
),
(
"SENDBLUE_API_KEY",
"SENDBLUE_WEBHOOK_SECRET",
"SENDBLUE_SIGNATURE_VALIDATION",
),
):
if production:
if value(trigger) or value(signing_key):
secret(signing_key, required=True)
# Routes are registered even when outbound integrations are unused.
# Missing keys fail closed; disabling verification would bypass that.
if env.get(flag, "true") != "true":
problem(flag, "signature validation must remain enabled in production")
for name in ("METRICS_TOKEN", "CRON_SECRET", "EMAIL_HMAC_SECRET"):
secret(name, minimum=32)
if env.get("OBSIDIAN_CONTINUOUS_SYNC") == "true":
for name in ("OBSIDIAN_EMAIL", "OBSIDIAN_PASSWORD", "OBSIDIAN_VAULT_ID"):
if not value(name):
problem(name, "required when continuous sync is enabled")
paths = {
"CHECKPOINT_DB_PATH": env.get(
"CHECKPOINT_DB_PATH", "/app/checkpoints/checkpoints.db"
),
"CONVERSATION_SUMMARY_DB_PATH": env.get(
"CONVERSATION_SUMMARY_DB_PATH",
env.get("CHECKPOINT_DB_PATH", "/app/checkpoints/checkpoints.db")
+ ".summaries.db",
),
}
for name, path in paths.items():
if (
not path
or path == ":memory:"
or "\x00" in path
or (production and not path.startswith("/"))
or path.endswith("/")
):
problem(name, "must be a persistent file path (absolute in production)")
if posixpath.normpath(paths["CHECKPOINT_DB_PATH"]) == posixpath.normpath(
paths["CONVERSATION_SUMMARY_DB_PATH"]
):
problem(
"CONVERSATION_SUMMARY_DB_PATH", "must differ from checkpoint database path"
)
if errors:
raise ConfigurationError(
"Invalid configuration:\n" + "\n".join(f"- {error}" for error in errors)
)