The single-file stdlib server became the limit: no way to track what had been done about an alert, no accounts, and a UI that had to be hand-rolled in template strings. This restructures it into something deployable. Backend (FastAPI) - app/ holds config, database, auth, delivery and the routers; triagelib keeps the triage engine unchanged, so the validated screening and runbook logic is untouched. - Cases persist per alert fingerprint with a status workflow (investigating, customer contacted, escalated to Infra, waiting, remediated, resolved, won't fix, false positive), an assignee, notes and an append-only history. An alert that stops and re-fires lands back on the same case and counts as a reopen. - Suppression rules move from a JSON file into the database. Auth - Signed session cookies over PBKDF2 local accounts, plus an OIDC flow ready for Authentik: users are created on first login and admin follows a group claim. Local login can be switched off entirely once SSO is live. Zendesk and Jira - Delivery is now implemented, behind three gates: the integration must be configured, its feature flag on, and CX_FEATURE_SEND_ENABLED on. A demo instance leaves the last off and cannot mail anyone. Both search before creating, so re-diagnosing an alert updates one ticket rather than opening several, and a rolling daily cap stops a loop mailing everybody. Deployment - Multi-stage Dockerfile builds the bundle and serves it from the API origin. - docker-compose for local and single-host use; Gitea Actions runs the tests, builds the image and renders deploy/k8s with envsubst. Two fixes found while testing: assigning a case returned a null assignee, and add_event could leave an already-loaded history collection stale. Known gap: the engine reaches OpenStack via `docker exec <region>-osc`, which does not work in a pod without the CX-Tools containers alongside it. docs/DEPLOYMENT.md sets out the three ways to close that. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
175 lines
7.6 KiB
Python
175 lines
7.6 KiB
Python
"""Outbound delivery to Zendesk and Jira.
|
|
|
|
Three independent gates have to be open before anything leaves this process:
|
|
|
|
1. the integration is configured (subdomain/email/token present)
|
|
2. its feature flag is on - CX_FEATURE_ZENDESK / CX_FEATURE_JIRA
|
|
3. sending is globally enabled - CX_FEATURE_SEND_ENABLED
|
|
|
|
A demo or staging instance simply leaves the third off, and then no combination
|
|
of clicks can email a customer. Every send is recorded as a case event before it
|
|
is attempted, so an audit trail exists even when the call fails.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import datetime as dt
|
|
from typing import Any, Optional
|
|
|
|
import httpx
|
|
from sqlalchemy.orm import Session
|
|
|
|
from .config import get_settings
|
|
from .models import Case, CaseEvent, CaseStatus, User
|
|
from .services import add_event, set_status
|
|
|
|
settings = get_settings()
|
|
|
|
|
|
class DeliveryError(RuntimeError):
|
|
pass
|
|
|
|
|
|
def _guard(kind: str) -> None:
|
|
if not settings.feature_send_enabled:
|
|
raise DeliveryError(
|
|
"Sending is disabled on this instance (CX_FEATURE_SEND_ENABLED is off). "
|
|
"The payload is ready but nothing will leave the server."
|
|
)
|
|
if kind == "zendesk" and not settings.zendesk_ready:
|
|
raise DeliveryError("Zendesk is not configured. Set CX_FEATURE_ZENDESK plus "
|
|
"CX_ZENDESK_SUBDOMAIN, CX_ZENDESK_EMAIL and CX_ZENDESK_TOKEN.")
|
|
if kind == "jira" and not settings.jira_ready:
|
|
raise DeliveryError("Jira is not configured. Set CX_FEATURE_JIRA plus "
|
|
"CX_JIRA_BASE, CX_JIRA_EMAIL, CX_JIRA_TOKEN and CX_JIRA_PROJECT.")
|
|
|
|
|
|
def sends_today(db: Session) -> int:
|
|
since = dt.datetime.now(dt.timezone.utc) - dt.timedelta(days=1)
|
|
return (db.query(CaseEvent)
|
|
.filter(CaseEvent.action.in_(["zendesk_sent", "jira_created"]))
|
|
.filter(CaseEvent.created_at >= since).count())
|
|
|
|
|
|
def _check_cap(db: Session) -> None:
|
|
used = sends_today(db)
|
|
if used >= settings.send_daily_cap:
|
|
raise DeliveryError(
|
|
f"Daily send cap reached ({used}/{settings.send_daily_cap}). Raise CX_SEND_DAILY_CAP "
|
|
"if this is deliberate - the cap exists so a loop cannot mail every customer."
|
|
)
|
|
|
|
|
|
# --- Zendesk ----------------------------------------------------------------
|
|
|
|
async def send_zendesk(db: Session, case: Case, actor: User, *, to: str, subject: str,
|
|
body: str, priority: str = "normal",
|
|
tags: Optional[list[str]] = None, public: Optional[bool] = None) -> dict[str, Any]:
|
|
_guard("zendesk")
|
|
_check_cap(db)
|
|
if not to.strip():
|
|
raise DeliveryError("No recipient address.")
|
|
|
|
base = f"https://{settings.zendesk_subdomain}.zendesk.com/api/v2"
|
|
auth = (f"{settings.zendesk_email}/token", settings.zendesk_token)
|
|
external_id = f"cx-triage-{case.fingerprint}"
|
|
|
|
async with httpx.AsyncClient(timeout=30) as client:
|
|
# Search first so a re-diagnosed alert comments on the existing ticket
|
|
# instead of opening a second one for the same customer.
|
|
found = await client.get(f"{base}/search.json",
|
|
params={"query": f'type:ticket external_id:"{external_id}"'}, auth=auth)
|
|
existing = None
|
|
if found.status_code == 200:
|
|
results = found.json().get("results") or []
|
|
existing = results[0] if results else None
|
|
|
|
comment = {"body": body, "public": settings.zendesk_default_public if public is None else public}
|
|
if existing:
|
|
resp = await client.put(f"{base}/tickets/{existing['id']}.json",
|
|
json={"ticket": {"comment": comment}}, auth=auth)
|
|
action = "updated"
|
|
else:
|
|
payload = {"ticket": {
|
|
"subject": subject,
|
|
"comment": comment,
|
|
"requester": {"name": to.split("@")[0], "email": to},
|
|
"priority": priority,
|
|
"type": "incident",
|
|
"tags": tags or ["cx-triage", f"alert-{case.kind}"],
|
|
"external_id": external_id,
|
|
}}
|
|
resp = await client.post(f"{base}/tickets.json", json=payload, auth=auth)
|
|
action = "created"
|
|
|
|
if resp.status_code not in (200, 201):
|
|
add_event(db, case, actor, "zendesk_failed", f"HTTP {resp.status_code}: {resp.text[:300]}")
|
|
db.commit()
|
|
raise DeliveryError(f"Zendesk returned {resp.status_code}: {resp.text[:300]}")
|
|
|
|
ticket = resp.json().get("ticket") or {}
|
|
ticket_id = str(ticket.get("id") or (existing or {}).get("id") or "")
|
|
url = f"https://{settings.zendesk_subdomain}.zendesk.com/agent/tickets/{ticket_id}"
|
|
|
|
case.zendesk_ticket_id = ticket_id
|
|
case.zendesk_ticket_url = url
|
|
add_event(db, case, actor, "zendesk_sent",
|
|
f"Ticket {ticket_id} {action} for {to}",
|
|
{"ticket_id": ticket_id, "to": to, "subject": subject, "action": action})
|
|
if case.status in (CaseStatus.NEW, CaseStatus.INVESTIGATING):
|
|
set_status(db, case, CaseStatus.CUSTOMER_CONTACTED, actor, f"Zendesk ticket {ticket_id}")
|
|
db.commit()
|
|
return {"ok": True, "ticket_id": ticket_id, "url": url, "action": action}
|
|
|
|
|
|
# --- Jira -------------------------------------------------------------------
|
|
|
|
async def create_jira(db: Session, case: Case, actor: User, *, summary: str, description: str,
|
|
project: str = "", issue_type: str = "",
|
|
labels: Optional[list[str]] = None) -> dict[str, Any]:
|
|
_guard("jira")
|
|
_check_cap(db)
|
|
|
|
base = settings.jira_base.rstrip("/")
|
|
auth = (settings.jira_email, settings.jira_token)
|
|
label = f"cx-triage-{case.fingerprint}"
|
|
all_labels = sorted(set((labels or []) + ["cx-triage", label]))
|
|
|
|
async with httpx.AsyncClient(timeout=30) as client:
|
|
found = await client.get(f"{base}/rest/api/3/search",
|
|
params={"jql": f'labels = "{label}"', "maxResults": 1}, auth=auth)
|
|
if found.status_code == 200 and (found.json().get("issues") or []):
|
|
issue = found.json()["issues"][0]
|
|
key = issue["key"]
|
|
url = f"{base}/browse/{key}"
|
|
case.jira_issue_key, case.jira_issue_url = key, url
|
|
add_event(db, case, actor, "jira_exists", f"Issue {key} already exists for this case")
|
|
db.commit()
|
|
return {"ok": True, "key": key, "url": url, "action": "existing"}
|
|
|
|
payload = {"fields": {
|
|
"project": {"key": project or settings.jira_project},
|
|
"summary": summary[:250],
|
|
"issuetype": {"name": issue_type or settings.jira_issue_type},
|
|
"labels": all_labels,
|
|
"description": {
|
|
"type": "doc", "version": 1,
|
|
"content": [{"type": "paragraph",
|
|
"content": [{"type": "text", "text": description[:30000]}]}],
|
|
},
|
|
}}
|
|
resp = await client.post(f"{base}/rest/api/3/issue", json=payload, auth=auth)
|
|
|
|
if resp.status_code not in (200, 201):
|
|
add_event(db, case, actor, "jira_failed", f"HTTP {resp.status_code}: {resp.text[:300]}")
|
|
db.commit()
|
|
raise DeliveryError(f"Jira returned {resp.status_code}: {resp.text[:300]}")
|
|
|
|
key = resp.json().get("key", "")
|
|
url = f"{base}/browse/{key}"
|
|
case.jira_issue_key, case.jira_issue_url = key, url
|
|
add_event(db, case, actor, "jira_created", f"Issue {key} created", {"key": key, "summary": summary})
|
|
if case.status in (CaseStatus.NEW, CaseStatus.INVESTIGATING):
|
|
set_status(db, case, CaseStatus.ESCALATED_INFRA, actor, f"Jira {key}")
|
|
db.commit()
|
|
return {"ok": True, "key": key, "url": url, "action": "created"}
|