de88679f25
- 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>
318 lines
11 KiB
Python
318 lines
11 KiB
Python
"""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
|