112 lines
4.0 KiB
Python
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()
|