Validate production config, add CI, and build from locked dependencies
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

- 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>
This commit is contained in:
2026-10-12 23:04:46 -04:00
parent 24f6764c64
commit de88679f25
12 changed files with 926 additions and 857 deletions
+317
View File
@@ -0,0 +1,317 @@
"""Startup policy and the environment contract shared by blue/green services."""
import base64
import json
import os
import shutil
import subprocess
import sys
from pathlib import Path
import pytest
from config.validation import ConfigurationError, validate_configuration
ROOT = Path(__file__).resolve().parents[2]
@pytest.fixture
def production_env():
return {
"DATABASE_URL": "postgres://raggr:a-random-test-password@postgres:5432/raggr",
"JWT_SECRET_KEY": "a-long-test-signing-secret-with-32-characters",
"FERNET_KEY": base64.urlsafe_b64encode(bytes(range(32))).decode(),
"OPENAI_API_KEY": "synthetic-test-api-key",
}
def test_optional_integrations_are_not_required(production_env):
validate_configuration(production_env)
validate_configuration({}, production=False)
@pytest.mark.parametrize(
"name", ["DATABASE_URL", "JWT_SECRET_KEY", "FERNET_KEY", "OPENAI_API_KEY"]
)
def test_missing_required_settings(production_env, name):
del production_env[name]
with pytest.raises(ConfigurationError, match=name):
validate_configuration(production_env)
@pytest.mark.parametrize(
"name,value",
[
("JWT_SECRET_KEY", "SECRET_KEY"),
("JWT_SECRET_KEY", "your-" + "x" * 40),
("JWT_SECRET_KEY", "short"),
("OPENAI_API_KEY", "your-openai-api-key"),
("FERNET_KEY", "not-base64"),
("FERNET_KEY", base64.urlsafe_b64encode(b"short").decode()),
("FERNET_KEY", base64.b64encode(b"\xff" * 32).decode()),
("DATABASE_URL", "sqlite://test.db"),
("DATABASE_URL", "postgres://raggr:change%6de@postgres/raggr"),
("DATABASE_URL", "postgres://raggr:password@postgres/raggr"),
("DATABASE_URL", "postgres://raggr:strong@postgres:70000/raggr"),
("DATABASE_URL", "postgres://raggr@postgres/raggr"),
("LLAMA_SERVER_URL", "localhost:8080/v1"),
("EMBEDDING_SERVER_URL", "http://server:invalid/v1"),
("LLAMA_SERVER_URL", "http://user:private@server/v1"),
("LLAMA_MODEL_NAME", ""),
("METRICS_TOKEN", "short"),
("CHECKPOINT_DB_PATH", ":memory:"),
("CHECKPOINT_DB_PATH", "/data/invalid\x00.db"),
("CONVERSATION_SUMMARY_DB_PATH", ""),
("OBSIDIAN_CONTINUOUS_SYNC", "yes"),
],
)
def test_malformed_and_unsafe_settings(production_env, name, value):
production_env[name] = value
with pytest.raises(ConfigurationError, match=name) as error:
validate_configuration(production_env)
if value and value not in name:
assert value not in str(error.value)
@pytest.mark.parametrize("value", ["0", "-1", "abc", "", "nan", "inf", "-inf"])
@pytest.mark.parametrize(
"name",
[
"TTFT_TIMEOUT_SECONDS",
"LLAMA_TIMEOUT_SECONDS",
"PROMPT_BUDGET_TOKENS",
"WHATSAPP_RATE_LIMIT_MAX",
],
)
def test_invalid_numbers(production_env, name, value):
production_env[name] = value
with pytest.raises(ConfigurationError, match=name):
validate_configuration(production_env)
def test_context_budget_boundary(production_env):
production_env.update(
CONTEXT_WINDOW_TOKENS="100",
PROMPT_BUDGET_TOKENS="80",
RESPONSE_RESERVE_TOKENS="20",
)
validate_configuration(production_env)
production_env["RESPONSE_RESERVE_TOKENS"] = "21"
with pytest.raises(ConfigurationError, match="exceeds context window"):
validate_configuration(production_env)
def test_oidc_partial_insecure_and_manual_endpoints(production_env):
production_env["OIDC_ISSUER"] = "https://auth.example.test"
with pytest.raises(ConfigurationError, match="OIDC_CLIENT_ID"):
validate_configuration(production_env)
production_env.update(
OIDC_CLIENT_ID="raggr",
OIDC_CLIENT_SECRET="synthetic-client-secret",
OIDC_REDIRECT_URI="http://localhost:8080/api/user/oidc/callback",
)
with pytest.raises(ConfigurationError, match="OIDC_REDIRECT_URI"):
validate_configuration(production_env)
production_env["OIDC_REDIRECT_URI"] = (
"https://app.example.test/api/user/oidc/callback"
)
validate_configuration(production_env)
production_env["OIDC_USE_DISCOVERY"] = "false"
with pytest.raises(ConfigurationError, match="OIDC_TOKEN_ENDPOINT"):
validate_configuration(production_env)
for name in (
"OIDC_AUTHORIZATION_ENDPOINT",
"OIDC_TOKEN_ENDPOINT",
"OIDC_USERINFO_ENDPOINT",
"OIDC_JWKS_URI",
):
production_env[name] = "https://auth.example.test/endpoint"
validate_configuration(production_env)
@pytest.mark.parametrize(
"trigger,key,flag",
[
("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",
),
],
)
def test_enabled_webhooks_require_signatures(production_env, trigger, key, flag):
production_env[trigger] = "synthetic-test-value"
with pytest.raises(ConfigurationError, match=key):
validate_configuration(production_env)
production_env[key] = "synthetic-signing-key"
validate_configuration(production_env)
production_env[flag] = "false"
with pytest.raises(ConfigurationError, match=flag):
validate_configuration(production_env)
@pytest.mark.parametrize(
"flag",
[
"TWILIO_SIGNATURE_VALIDATION",
"MAILGUN_SIGNATURE_VALIDATION",
"SENDBLUE_SIGNATURE_VALIDATION",
],
)
def test_production_never_disables_webhook_verification(production_env, flag):
production_env[flag] = "false"
with pytest.raises(ConfigurationError, match=flag):
validate_configuration(production_env)
validate_configuration(production_env, production=False)
def test_sync_and_summary_requirements(production_env):
production_env["OBSIDIAN_CONTINUOUS_SYNC"] = "true"
with pytest.raises(ConfigurationError, match="OBSIDIAN_VAULT_ID"):
validate_configuration(production_env)
production_env.update(
OBSIDIAN_EMAIL="test@example.test",
OBSIDIAN_PASSWORD="test-password",
OBSIDIAN_VAULT_ID="test-vault",
)
validate_configuration(production_env)
production_env.update(
CHECKPOINT_DB_PATH="/data/store.db",
CONVERSATION_SUMMARY_DB_PATH="/data/../data/store.db",
)
with pytest.raises(ConfigurationError, match="must differ"):
validate_configuration(production_env)
def test_compose_forwards_runtime_settings():
text = (ROOT / "docker-compose.yml").read_text()
common = text.split("services:")[0]
for name in (
"METRICS_TOKEN",
"TTFT_TIMEOUT_SECONDS",
"LLAMA_TIMEOUT_SECONDS",
"CONTEXT_WINDOW_TOKENS",
"PROMPT_BUDGET_TOKENS",
"RESPONSE_RESERVE_TOKENS",
"CHECKPOINT_DB_PATH",
"CONVERSATION_SUMMARY_DB_PATH",
"OBSIDIAN_SYNC_INTERVAL",
"OIDC_AUTHORIZATION_ENDPOINT",
"OIDC_TOKEN_ENDPOINT",
"OIDC_USERINFO_ENDPOINT",
"OIDC_JWKS_URI",
"EMAIL_HMAC_SECRET",
"WHATSAPP_RATE_LIMIT_MAX",
"WHATSAPP_RATE_LIMIT_WINDOW",
"IMESSAGE_RATE_LIMIT_MAX",
"IMESSAGE_RATE_LIMIT_WINDOW",
):
assert f"- {name}=${{{name}" in common
assert "TAVILY_API_KEY=${TAVILY_API_KEY:-${TAVILIY_API_KEY:-}}" in common
assert text.count("<<: *raggr-common") == 2
@pytest.mark.parametrize(
"canonical,legacy,expected",
[("new-key", "old-key", "new-key"), ("", "old-key", "old-key")],
)
def test_compose_interpolation_without_secrets(canonical, legacy, expected):
if not shutil.which("docker"):
pytest.skip("Docker Compose is unavailable")
env = {
"PATH": os.environ["PATH"],
"HOME": os.environ.get("HOME", "/tmp"),
"TAVILY_API_KEY": canonical,
"TAVILIY_API_KEY": legacy,
"METRICS_TOKEN": "test-metrics",
"TTFT_TIMEOUT_SECONDS": "42",
"CHECKPOINT_DB_PATH": "/data/custom.db",
}
result = subprocess.run(
[
"docker",
"compose",
"--env-file",
"/dev/null",
"--profile",
"blue",
"--profile",
"green",
"config",
"--format",
"json",
],
cwd=ROOT,
env=env,
capture_output=True,
text=True,
)
if result.returncode and "not a docker command" in result.stderr:
pytest.skip("Docker Compose is unavailable")
assert result.returncode == 0, result.stderr
services = json.loads(result.stdout)["services"]
for color in ("blue", "green"):
actual = services[f"raggr-{color}"]["environment"]
assert actual["TAVILY_API_KEY"] == expected
assert actual["METRICS_TOKEN"] == "test-metrics"
assert actual["TTFT_TIMEOUT_SECONDS"] == "42"
assert actual["CONVERSATION_SUMMARY_DB_PATH"] == "/data/custom.db.summaries.db"
@pytest.mark.parametrize(
"validation_status,migration_status,expected", [(0, 19, 19), (23, 0, 23)]
)
def test_startup_stops_before_app_on_failure(
tmp_path, validation_status, migration_status, expected
):
log = tmp_path / "commands"
for name, body in {
"python": f'echo "python $*" >> "$COMMAND_LOG"\nexit {validation_status}\n',
"aerich": f'echo "aerich $*" >> "$COMMAND_LOG"\nexit {migration_status}\n',
}.items():
executable = tmp_path / name
executable.write_text("#!/bin/sh\n" + body)
executable.chmod(0o755)
result = subprocess.run(
["/bin/bash", str(ROOT / "startup.sh")],
cwd=ROOT,
env={"PATH": str(tmp_path), "COMMAND_LOG": str(log)},
capture_output=True,
text=True,
)
assert result.returncode == expected
commands = log.read_text()
assert "python scripts/validate_config.py" in commands
assert "python app.py" not in commands
assert ("aerich upgrade" in commands) == (validation_status == 0)
def test_validation_cli_reports_errors_without_values(tmp_path, production_env):
# A separate project tree avoids loading any real .env in the shared checkout.
(tmp_path / "scripts").mkdir()
(tmp_path / "config").mkdir()
(tmp_path / "config" / "__init__.py").touch()
shutil.copy(ROOT / "scripts" / "validate_config.py", tmp_path / "scripts")
shutil.copy(ROOT / "config" / "validation.py", tmp_path / "config")
command = [sys.executable, str(tmp_path / "scripts" / "validate_config.py")]
env = {"PATH": os.environ["PATH"], **production_env}
result = subprocess.run(command, env=env, capture_output=True, text=True)
assert result.returncode == 0
assert result.stdout == "Configuration valid.\n"
env["JWT_SECRET_KEY"] = "private-invalid-value"
result = subprocess.run(command, env=env, capture_output=True, text=True)
assert result.returncode == 1
assert "JWT_SECRET_KEY" in result.stderr
assert env["JWT_SECRET_KEY"] not in result.stderr
assert env["OPENAI_API_KEY"] not in result.stderr
# dotenv values are validated too, but do not override exported settings.
(tmp_path / ".env").write_text("JWT_SECRET_KEY=invalid-dotenv-value\n")
env["JWT_SECRET_KEY"] = production_env["JWT_SECRET_KEY"]
result = subprocess.run(command, env=env, capture_output=True, text=True)
assert result.returncode == 0