diff --git a/Dockerfile b/Dockerfile index aec1306..675316f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,4 +3,4 @@ WORKDIR /app COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt COPY main.py . -CMD ["python", "main.py"] +CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8400"] diff --git a/main.py b/main.py index 361451b..3530bbb 100644 --- a/main.py +++ b/main.py @@ -9,6 +9,7 @@ from pathlib import Path import httpx from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware from pydantic import BaseModel logging.basicConfig(level=logging.INFO, format="%(asctime)s [raven] %(message)s") @@ -29,6 +30,26 @@ MAX_HISTORY = 50 SEVERITY_COLORS = {"critical": 15158332, "warning": 16776960, "info": 3066993} +def _state_path() -> Path: + return SITES_DIR / "raven" / "state.json" + + +def _load_state() -> dict: + p = _state_path() + if not p.exists(): + return {"email_enabled": True} + try: + return json.loads(p.read_text()) + except Exception: + return {"email_enabled": True} + + +def _save_state(state: dict): + p = _state_path() + p.parent.mkdir(parents=True, exist_ok=True) + p.write_text(json.dumps(state, indent=2)) + + class NotifyRequest(BaseModel): message: str severity: str = "info" @@ -78,6 +99,9 @@ def _send_discord(message: str, severity: str, source: str, timestamp: str): def _send_email(message: str, severity: str, source: str): + if not _load_state().get("email_enabled", True): + logger.info("email: disabled by toggle — skipping") + return if not all([SMTP_USER, SMTP_PASSWORD, SMTP_TO]): logger.warning("SMTP not configured — skipping email") return @@ -98,7 +122,7 @@ def _send_email(message: str, severity: str, source: str): logger.error(f"email send failed: {e}") -def _render_html(history: list, timestamp: str) -> str: +def _render_html(history: list, timestamp: str, email_enabled: bool = True) -> str: rows = "" for entry in reversed(history[-20:]): sev = entry.get("severity", "info") @@ -114,40 +138,80 @@ def _render_html(history: list, timestamp: str) -> str: """ total = len(history) + email_color = "#3fb950" if email_enabled else "#f85149" + email_label = "ENABLED" if email_enabled else "DISABLED" + btn_label = "Disable Email" if email_enabled else "Enable Email" return f""" Raven — Notifications + + + +

Raven — Notifications

Sends alerts and notifications via Discord and email. Triggered by Varys (service down/up) and Grafana (infrastructure alerts).

-

Updated  ·  {total} total  ·  ← home

+

Updated  ·  {total} total

+
+ Email alerts: {email_label} + +

Recent Notifications (last 20)

{"" + rows + "
" if rows else '

No notifications yet.

'} +
""" @@ -163,9 +227,10 @@ def _render_md(history: list, timestamp: str) -> str: def _write_status(history: list, last_event: dict = None): timestamp = datetime.now(timezone.utc).isoformat() + email_enabled = _load_state().get("email_enabled", True) out_dir = SITES_DIR / "raven" out_dir.mkdir(parents=True, exist_ok=True) - (out_dir / "index.html").write_text(_render_html(history, timestamp)) + (out_dir / "index.html").write_text(_render_html(history, timestamp, email_enabled)) (out_dir / "last-output.md").write_text(_render_md(history, timestamp)) log_dir = AGENT_OS_DIR / "logs" / "raven-notify" @@ -209,6 +274,13 @@ async def lifespan(app: FastAPI): app = FastAPI(title="raven-notify", lifespan=lifespan) +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_methods=["POST"], + allow_headers=["*"], +) + @app.get("/health") def health(): @@ -220,15 +292,32 @@ def handle_notify(req: NotifyRequest): return _do_notify(req.message, req.severity, req.source) +@app.post("/email/toggle") +def toggle_email(): + state = _load_state() + state["email_enabled"] = not state.get("email_enabled", True) + _save_state(state) + _write_status(_load_history()) + logger.info(f"email alerts {'enabled' if state['email_enabled'] else 'disabled'} via toggle") + return {"ok": True, "email_enabled": state["email_enabled"]} + + @app.post("/webhook/grafana") def handle_grafana(payload: dict): status = payload.get("status", "firing") alerts = payload.get("alerts", []) if alerts: for alert in alerts: + alert_status = alert.get("status", status) name = alert.get("labels", {}).get("alertname", "unknown") - msg = alert.get("annotations", {}).get("description", name) - sev = "critical" if alert.get("status", status) == "firing" else "info" + if alert_status == "resolved": + # Grafana always sends the firing annotation text in resolved payloads — + # override with a clear recovery message so Discord/email isn't confusing. + msg = f"✅ RESOLVED: {name} — alert has cleared, service is back online." + sev = "info" + else: + msg = alert.get("annotations", {}).get("description", name) + sev = "critical" _do_notify(msg, sev, f"grafana/{name}") return {"ok": True, "processed": len(alerts)} msg = payload.get("message", "") or payload.get("title", "Grafana Alert") @@ -239,3 +328,45 @@ def handle_grafana(payload: dict): if __name__ == "__main__": import uvicorn uvicorn.run(app, host="0.0.0.0", port=PORT) + + +# --------------------------------------------------------------------------- +# Jon Snow approval gate — action notification endpoint +# --------------------------------------------------------------------------- + +class ActionNotifyRequest(BaseModel): + description: str + approve_url: str + reject_url: str + action_type: str + source: str = "jon-snow" + + +@app.post("/notify-with-actions") +def handle_action_notify(req: ActionNotifyRequest): + """Send a Discord embed with clickable approve/reject links for Jon Snow's approval gate.""" + if not DISCORD_WEBHOOK_URL: + logger.warning("DISCORD_WEBHOOK_URL not set — skipping Discord") + return {"ok": False, "error": "no webhook configured"} + + payload = { + "embeds": [{ + "title": "⚡ Action Approval Required", + "description": f"**{req.source}** wants to: `{req.description}`", + "color": 16776960, # yellow + "fields": [ + {"name": "Action Type", "value": f"`{req.action_type}`", "inline": True}, + {"name": "Expires", "value": "15 minutes", "inline": True}, + ], + "footer": {"text": "jon-snow approval gate · nxm.co.za"}, + }], + "content": f"✅ **Approve:** {req.approve_url}\n❌ **Reject:** {req.reject_url}", + } + try: + r = httpx.post(DISCORD_WEBHOOK_URL, json=payload, timeout=10) + r.raise_for_status() + logger.info(f"discord: action approval request sent for {req.action_type}") + return {"ok": True} + except Exception as e: + logger.error(f"discord action notify failed: {e}") + return {"ok": False, "error": str(e)}