"""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 ' -> ('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(), }