Files
han-app/codebase/services/message-safety/app/config_admin.py
T

112 lines
4.0 KiB
Python

from __future__ import annotations
import argparse
import asyncio
import json
import os
import uuid
from datetime import UTC, datetime, timedelta
from pathlib import Path
import yaml
from sqlalchemy import select, text, update
from app.config import validate_config
from app.db import ConfigVersion, SafetyAudit, engine_and_sessions
from app.settings import _secret
async def execute(args: argparse.Namespace) -> None:
url = _secret("MESSAGE_SAFETY_CONFIG_ADMIN_DATABASE_URL")
assert url
artifacts = Path(os.getenv("MESSAGE_SAFETY_ARTIFACTS_DIR", "/app/app/artifacts"))
document = (
yaml.safe_load(await asyncio.to_thread(Path(args.file).read_text, encoding="utf-8"))
if args.file
else None
)
if document:
_, _, digest = validate_config(document, artifacts)
engine, sessions = engine_and_sessions(url)
try:
if args.command == "validate":
print(json.dumps({"valid": True, "config_sha256": digest.hex()}))
return
async with sessions.begin() as session:
await session.execute(
text("SELECT pg_advisory_xact_lock(hashtext('message_safety.config_activation'))")
)
if args.command == "create":
exists = await session.scalar(
select(ConfigVersion.id).where(ConfigVersion.version == args.version)
)
if exists:
raise ValueError("config version already exists")
session.add(
ConfigVersion(
version=args.version,
schema_version=document["schema_version"],
state="draft",
config=document,
config_sha256=digest,
created_by=args.actor,
created_at=datetime.now(UTC),
)
)
elif args.command == "activate":
row = await session.scalar(
select(ConfigVersion)
.where(ConfigVersion.version == args.version)
.with_for_update()
)
if not row or row.state != "draft":
raise ValueError("only a draft config can be activated")
validate_config(row.config, artifacts)
now = datetime.now(UTC)
await session.execute(
update(ConfigVersion)
.where(ConfigVersion.state == "active")
.values(state="retired", retired_at=now)
)
row.state = "active"
row.approved_by = args.approved_by
row.approved_at = now
row.activated_at = now
session.add(
SafetyAudit(
id=uuid.uuid4(),
event="config_activated",
processing_mode="standard",
config_version=row.version,
created_at=now,
purge_after=now + timedelta(days=180),
)
)
print(json.dumps({"ok": True, "version": args.version}))
finally:
await engine.dispose()
def parser() -> argparse.ArgumentParser:
result = argparse.ArgumentParser()
commands = result.add_subparsers(dest="command", required=True)
validate = commands.add_parser("validate")
validate.add_argument("file")
create = commands.add_parser("create")
create.add_argument("file")
create.add_argument("--version", type=int, required=True)
create.add_argument("--actor", required=True)
activate = commands.add_parser("activate")
activate.add_argument("--version", type=int, required=True)
activate.add_argument("--approved-by", required=True)
activate.set_defaults(file=None)
return result
def main() -> None:
asyncio.run(execute(parser().parse_args()))
if __name__ == "__main__":
main()