Merge pull request '[contract] Add opt-in MAX configuration and lifecycle' (#84) from feat/81-max-contract into dev
CI / ruff · pytest (push) Successful in 2m4s
CI / ruff · pytest (push) Successful in 2m4s
This commit was merged in pull request #84.
This commit is contained in:
@@ -37,3 +37,13 @@ GITEA_URL=https://gitea.24studbank.ru
|
||||
GITEA_REPO=MedITron/PTSRaiAgent
|
||||
GITEA_USER=
|
||||
GITEA_TOKEN=
|
||||
# MAX notifications: keep disabled until the backend/channel PRs and a real bot are installed.
|
||||
MAX_ENABLED=false
|
||||
MAX_BOT_TOKEN=
|
||||
MAX_BOT_USERNAME=
|
||||
MAX_WEBHOOK_SECRET=
|
||||
MAX_WEBHOOK_URL=
|
||||
MAX_SITE_URL=
|
||||
# Optional path to a trusted PEM CA bundle; never disable TLS verification.
|
||||
MAX_CA_BUNDLE=
|
||||
MAX_LINK_TTL_SECONDS=600
|
||||
|
||||
+33
-1
@@ -1,10 +1,12 @@
|
||||
"""Typed application settings. The only place that reads environment variables."""
|
||||
|
||||
import re
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
from pydantic import Field
|
||||
from pydantic import Field, SecretStr, model_validator
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
ROOT_DIR = Path(__file__).resolve().parent.parent
|
||||
@@ -43,6 +45,36 @@ class Settings(BaseSettings):
|
||||
tg_bot_token: str = ""
|
||||
doctor_notify_chat_id: int | None = None
|
||||
|
||||
# MAX is opt-in. Its adapter is delivered in the related backend/channel changes.
|
||||
max_enabled: bool = False
|
||||
max_bot_token: SecretStr = SecretStr("")
|
||||
max_bot_username: str = ""
|
||||
max_webhook_secret: SecretStr = SecretStr("")
|
||||
max_webhook_url: str = ""
|
||||
max_site_url: str = ""
|
||||
max_ca_bundle: Path | None = None
|
||||
max_link_ttl_seconds: int = Field(default=600, ge=60, le=1800)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_max(self) -> "Settings":
|
||||
if not self.max_enabled:
|
||||
return self
|
||||
if not self.max_bot_token.get_secret_value().strip():
|
||||
raise ValueError("MAX_BOT_TOKEN is required when MAX_ENABLED=true")
|
||||
if not re.fullmatch(r"[A-Za-z0-9_]{1,128}", self.max_bot_username):
|
||||
raise ValueError("MAX_BOT_USERNAME must be the bot nickname without @ or a URL")
|
||||
if not re.fullmatch(r"[A-Za-z0-9_-]{32,256}", self.max_webhook_secret.get_secret_value()):
|
||||
raise ValueError("MAX_WEBHOOK_SECRET must contain 32..256 URL-safe characters")
|
||||
for name, value in (("MAX_SITE_URL", self.max_site_url), ("MAX_WEBHOOK_URL", self.max_webhook_url)):
|
||||
url = urlsplit(value)
|
||||
if url.scheme != "https" or not url.hostname or url.username or url.password or url.port not in (None, 443):
|
||||
raise ValueError(f"{name} must use HTTPS on port 443 without credentials")
|
||||
if url.query or (name == "MAX_WEBHOOK_URL" and url.fragment):
|
||||
raise ValueError(f"{name} must not contain a query string (or a fragment for webhook)")
|
||||
if self.max_ca_bundle is not None and not self.max_ca_bundle.is_file():
|
||||
raise ValueError("MAX_CA_BUNDLE must point to an existing trusted CA bundle")
|
||||
return self
|
||||
|
||||
# Virtual clock for the demo (ADR-005).
|
||||
demo_clock: bool = True
|
||||
tz: str = "Europe/Moscow"
|
||||
|
||||
+18
-6
@@ -30,13 +30,25 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]:
|
||||
# In compose Postgres is required and healthy before the app starts; this branch is for running the
|
||||
# API (and its tests) without a database. Endpoints touching the DB will fail until it is reachable.
|
||||
log.error("db init failed, continuing without db: %s", type(exc).__name__)
|
||||
if settings.max_enabled:
|
||||
raise # MAX requires persistent bindings and a delivery queue.
|
||||
# Lanes fill these in (#13, #11); main.py itself stays frozen (#34).
|
||||
await scheduler.start()
|
||||
await telegram.start()
|
||||
yield
|
||||
await telegram.stop()
|
||||
await scheduler.stop()
|
||||
await dispose()
|
||||
max_channel = None
|
||||
try:
|
||||
await telegram.start()
|
||||
if settings.max_enabled:
|
||||
from app.channels import max_bot
|
||||
|
||||
max_channel = max_bot
|
||||
await max_channel.start()
|
||||
await scheduler.start()
|
||||
yield
|
||||
finally:
|
||||
await scheduler.stop()
|
||||
if max_channel is not None:
|
||||
await max_channel.stop()
|
||||
await telegram.stop()
|
||||
await dispose()
|
||||
|
||||
|
||||
app = FastAPI(title="PTSRaiAgent API", version="0.1.0", lifespan=lifespan)
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
"""MAX stays off without credentials; enabled deployments fail closed on incomplete settings."""
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from app.config import Settings
|
||||
|
||||
|
||||
def configured(**overrides):
|
||||
fields = {
|
||||
"max_enabled": True,
|
||||
"max_bot_token": "test-only-token",
|
||||
"max_bot_username": "test_bot",
|
||||
"max_webhook_secret": "test-only-secret-" * 3,
|
||||
"max_webhook_url": "https://api.example.org/api/v1/webhooks/max",
|
||||
"max_site_url": "https://patient.example.org/#/today",
|
||||
}
|
||||
return Settings(_env_file=None, **(fields | overrides))
|
||||
|
||||
|
||||
def test_max_disabled_by_default():
|
||||
assert not Settings(_env_file=None).max_enabled
|
||||
|
||||
|
||||
def test_max_secrets_are_redacted():
|
||||
settings = configured()
|
||||
assert "test-only-token" not in repr(settings)
|
||||
assert "test-only-secret" not in repr(settings)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"overrides",
|
||||
[
|
||||
{"max_bot_token": ""},
|
||||
{"max_bot_username": "@test_bot"},
|
||||
{"max_webhook_secret": "short"},
|
||||
{"max_site_url": "http://patient.example.org"},
|
||||
{"max_webhook_url": "http://api.example.org"},
|
||||
{"max_webhook_url": "https://api.example.org:8443/hook"},
|
||||
{"max_webhook_url": "https://user:password@api.example.org/hook"},
|
||||
{"max_webhook_url": "https://api.example.org/hook#fragment"},
|
||||
{"max_site_url": "https://patient.example.org?token=credential"},
|
||||
],
|
||||
)
|
||||
def test_max_rejects_incomplete_or_unsafe_settings(overrides):
|
||||
with pytest.raises(ValidationError):
|
||||
configured(**overrides)
|
||||
Reference in New Issue
Block a user