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>
197 lines
7.4 KiB
Python
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(),
|
|
}
|