"""Alerting configuration: which failures go where.""" from pathlib import Path from typing import Any from fastapi import APIRouter, Depends, HTTPException from fastapi.concurrency import run_in_threadpool from pydantic import BaseModel from fluksio.api.deps import FlowControllerDep, get_current_user from fluksio.core.config import settings from fluksio.flow import webpush from fluksio.flow.alerts import Alert, AlertsConfig from fluksio.models import Message router = APIRouter( prefix="/alerts", tags=["alerts"], dependencies=[Depends(get_current_user)] ) def _path() -> Path: return settings.ALERTS_FILE def read_config() -> AlertsConfig: """The stored configuration, or an empty one. Blocking.""" path = _path() if not path.exists(): return AlertsConfig() try: return AlertsConfig.model_validate_json(path.read_text()) except Exception: # A hand-edited file that no longer parses must not stop the engine. return AlertsConfig() def write_config(config: AlertsConfig) -> None: """Blocking.""" path = _path() path.parent.mkdir(parents=True, exist_ok=True) path.write_text(config.model_dump_json(indent=2)) @router.get("/config", response_model=AlertsConfig) async def read_alerts_config() -> Any: """What the engine alerts on, and where it sends it.""" return await run_in_threadpool(read_config) @router.put("/config", response_model=AlertsConfig) async def save_alerts_config(body: AlertsConfig, controller: FlowControllerDep) -> Any: """Replace the configuration. Takes effect immediately.""" known = {channel.name for channel in body.channels} for rule in body.rules: missing = [name for name in rule.channels if name not in known] if missing: raise HTTPException( status_code=422, detail=f"No channel named {', '.join(repr(m) for m in missing)}", ) await run_in_threadpool(write_config, body) if controller.alerts is not None: controller.alerts.config = body return body class WebPushKey(BaseModel): key: str class Unsubscribe(BaseModel): endpoint: str @router.get("/webpush/key", response_model=WebPushKey) async def read_webpush_key() -> Any: """The key a browser subscribes against. Made on the first ask.""" return WebPushKey(key=await run_in_threadpool(webpush.public_key)) @router.post("/webpush/subscriptions", response_model=Message) async def add_webpush_subscription(body: webpush.Subscription) -> Any: """Remember this browser, so a `webpush` channel can reach it.""" if not body.keys.get("p256dh") or not body.keys.get("auth"): raise HTTPException(status_code=422, detail="The subscription has no keys") await run_in_threadpool(webpush.add_subscription, body) return Message(message="This browser will be notified") @router.post("/webpush/unsubscribe", response_model=Message) async def remove_webpush_subscription(body: Unsubscribe) -> Any: """Forget this browser.""" await run_in_threadpool(webpush.remove_subscription, body.endpoint) return Message(message="This browser will no longer be notified") @router.post("/test/{channel_name}", response_model=Message) async def test_channel(channel_name: str, controller: FlowControllerDep) -> Any: """Send one alert, to prove the channel works before relying on it.""" if controller.alerts is None: raise HTTPException(status_code=503, detail="Alerting is not running") config = controller.alerts.config channel = next((c for c in config.channels if c.name == channel_name), None) if channel is None: raise HTTPException(status_code=404, detail=f"No channel '{channel_name}'") try: await controller.alerts.send( channel, Alert( title="Fluksio test alert", body="If you are reading this, the channel works.", severity="warning", ), raise_on_error=True, ) except Exception as exc: # Whatever the sender said, verbatim: it is the only clue the operator # has about why the channel does not work. raise HTTPException(status_code=502, detail=str(exc) or type(exc).__name__) return Message(message=f"Sent a test alert through '{channel_name}'")