Files
cx-ui/backend/triagelib/integrations.py
Parham Monfared 1262690276
Some checks failed
build-and-deploy / test (push) Has been cancelled
build-and-deploy / image (push) Has been cancelled
build-and-deploy / deploy (push) Has been cancelled
Split into a FastAPI backend and a React frontend, add case state and SSO
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>
2026-08-06 07:11:28 +01:00

197 lines
7.4 KiB
Python

"""Outbound action payloads: Zendesk tickets and Jira issues.
This module *builds* payloads and never sends them. Delivery is a separate,
explicitly configured step - see `outbox.py` - so that a diagnosis can never
contact a customer as a side effect of being viewed.
Every payload carries the evidence that justified it, so the ticket a customer
or the Infrastructure team receives is self-contained.
"""
from __future__ import annotations
import os
from dataclasses import dataclass, field
from typing import Any, Optional
# Set these to enable the Send buttons. Absent = preview only.
ZENDESK_SUBDOMAIN = os.environ.get("CX_ZENDESK_SUBDOMAIN", "")
ZENDESK_EMAIL = os.environ.get("CX_ZENDESK_EMAIL", "")
ZENDESK_TOKEN = os.environ.get("CX_ZENDESK_TOKEN", "")
JIRA_BASE = os.environ.get("CX_JIRA_BASE", "https://nexgencloud.atlassian.net")
JIRA_EMAIL = os.environ.get("CX_JIRA_EMAIL", "")
JIRA_TOKEN = os.environ.get("CX_JIRA_TOKEN", "")
JIRA_PROJECT = os.environ.get("CX_JIRA_PROJECT", "INFRA")
PRIORITY_BY_VERDICT = {"overdue": "high", "real": "normal", "unverified": "low"}
def zendesk_configured() -> bool:
return bool(ZENDESK_SUBDOMAIN and ZENDESK_EMAIL and ZENDESK_TOKEN)
def jira_configured() -> bool:
return bool(JIRA_BASE and JIRA_EMAIL and JIRA_TOKEN)
@dataclass
class Action:
"""One proposed outbound action, ready to send once a human confirms."""
id: str
kind: str # zendesk | jira | manual
label: str
summary: str # one line: what this does
payload: dict[str, Any] = field(default_factory=dict)
recipients: list[str] = field(default_factory=list)
enabled: bool = False # is the integration configured?
blocked_reason: str = ""
requires_confirmation: bool = True
def to_json(self) -> dict[str, Any]:
return {
"id": self.id, "kind": self.kind, "label": self.label, "summary": self.summary,
"payload": self.payload, "recipients": self.recipients, "enabled": self.enabled,
"blocked_reason": self.blocked_reason, "requires_confirmation": self.requires_confirmation,
}
def _parse_owner(owner: str) -> tuple[str, str]:
"""'Name <email@x>' -> ('Name', 'email@x')."""
text = str(owner or "").strip()
if "<" in text and ">" in text:
name = text.split("<", 1)[0].strip()
email = text.split("<", 1)[1].split(">", 1)[0].strip()
return name, email
return ("", text) if "@" in text else (text, "")
def _evidence_block(diagnosis: Any) -> str:
lines = [f"Alert: {diagnosis.alert.title}", f"Verdict: {diagnosis.verdict}", ""]
for finding in diagnosis.findings[:16]:
lines.append(f"- {finding.label}: {finding.value}")
return "\n".join(lines)
def build_zendesk(diagnosis: Any) -> Optional[Action]:
"""A customer ticket, only when the runbook actually calls for contact."""
if not diagnosis.drafts:
return None
draft = diagnosis.drafts[0]
alert = diagnosis.alert
contacts = diagnosis.contacts or {}
owners = contacts.get("owners") or []
if not owners:
return Action(
id="zendesk", kind="zendesk", label="Contact customer (Zendesk)",
summary="No owner contact resolved from Infrahub - look the organization up first.",
enabled=False, blocked_reason="No customer contact could be resolved.",
)
name, email = _parse_owner(owners[0])
verdict = (alert.screen or {}).get("verdict", "real")
payload = {
"ticket": {
"subject": draft.subject,
"comment": {"body": draft.body, "public": True},
"requester": {"name": name or email, "email": email},
"priority": PRIORITY_BY_VERDICT.get(verdict, "normal"),
"type": "incident",
"tags": ["cx-triage", f"alert-{alert.kind}", f"region-{alert.region or 'unknown'}"],
"external_id": f"cx-triage-{alert.fingerprint()}",
"custom_fields_note": {
"instance_name": alert.instance_name,
"openstack_id": alert.openstack_id,
"organization": alert.org_name,
"infrahub_org_id": alert.org_id,
},
},
"_template": draft.template_id,
"_when": draft.when,
"_unfilled": draft.unfilled,
}
blocked = ""
if draft.unfilled:
blocked = f"Template still has placeholders: {', '.join(draft.unfilled)}"
return Action(
id="zendesk", kind="zendesk",
label="Contact customer (Zendesk)",
summary=f"Public reply to {name or email} - {draft.label}",
payload=payload,
recipients=[o for o in owners],
enabled=zendesk_configured() and not blocked,
blocked_reason=blocked or ("" if zendesk_configured() else "Zendesk is not configured."),
)
def build_jira(diagnosis: Any) -> Optional[Action]:
"""An Infrastructure escalation, only when a step is owned by Infra."""
infra_steps = [a for a in diagnosis.actions if a.owner != "CX" and a.kind == "escalate"]
if not infra_steps:
return None
alert = diagnosis.alert
subject = alert.host or alert.instance_name or alert.floating_ip or "unknown"
description = "\n".join([
_evidence_block(diagnosis),
"",
"Requested of Infrastructure:",
*[f"- {s.text}" for s in infra_steps],
"",
f"Raised from CX Triage. Alert has held for {alert.effective_age_text}.",
])
payload = {
"fields": {
"project": {"key": JIRA_PROJECT},
"summary": f"{subject}: {diagnosis.verdict}"[:250],
"description": description,
"issuetype": {"name": "Task"},
"labels": ["cx-triage", f"alert-{alert.kind}", f"region-{alert.region or 'unknown'}"],
}
}
return Action(
id="jira", kind="jira",
label="Escalate to Infrastructure (Jira)",
summary=f"Create a {JIRA_PROJECT} issue for {subject}",
payload=payload,
enabled=jira_configured(),
blocked_reason="" if jira_configured() else "Jira is not configured.",
)
def build_manual(diagnosis: Any) -> list[Action]:
"""Steps a human must perform; surfaced as copyable commands, not buttons."""
out: list[Action] = []
alert = diagnosis.alert
osid = alert.openstack_id
region = alert.region
for step in diagnosis.actions:
if step.kind != "remediate" or step.status == "done":
continue
command = ""
low = step.text.lower()
if "delete the server in openstack" in low and osid and region:
command = f"{region} server delete {osid}"
elif "shelve" in low and osid and region:
command = f"{region} server shelve {osid}"
out.append(Action(
id=f"manual-{len(out)}", kind="manual", label=step.text,
summary=step.guide or "", payload={"command": command} if command else {},
enabled=False, blocked_reason="Perform manually - this tool is read-only.",
requires_confirmation=False,
))
return out
def build_all(diagnosis: Any) -> dict[str, Any]:
actions: list[Action] = []
for builder in (build_zendesk, build_jira):
action = builder(diagnosis)
if action:
actions.append(action)
actions.extend(build_manual(diagnosis))
return {
"actions": [a.to_json() for a in actions],
"zendesk_configured": zendesk_configured(),
"jira_configured": jira_configured(),
}