Handover - The Confluence shift doc becomes the landing page: shift metadata, the top-of-page checks, key updates with their Zendesk/Jira refs and status, and the free-text comments. "Hand over shift" closes the shift, opens the next one and carries the live items across, dropping anything done or marked "remove at end of shift" - the retyping this replaces. - The RunPod table on that page is read from live host state instead of being copied in by hand, with the six-colour key preserved. RunPod - GraphQL client keyed on CX_RUNPOD_API_KEY. The old console login is kept as a fallback but cannot run unattended: the account has 2FA, so Clerk verifies the password and then asks for an emailed code and never issues a session. That is the real cause of the "No active session found" failure, and the client now says so instead of failing opaquely. TOTP is supported if the account moves to an authenticator app. - Hosts and their listing history are persisted, so "most problematic hosts" can be ranked and each machine has a timeline of who listed or unlisted it, with the Zendesk comment and the error hint. - The unlisting emails are parsed for the error block (they arrive quoted-printable) and classified into a likely cause and a next step. Zendesk and Jira - Unlisting raises a Zendesk ticket that follows the format of RunPod's own email, keyed on the machine so one machine keeps one thread, posted as an internal note. - Jira is split in two: the Infrahub/OIE instance and the RunPod/RMA one, which may be a different Atlassian site. Blank RunPod values fall back to the defaults rather than failing. Running in a container - CX-Tools reads its keys from 1Password, which needs a desktop app. Config is a dataclass whose lookups live in per-field default factories, so passing CX_INFRAHUB_TOKEN/CX_INFRAINSIGHT_TOKEN in means those factories never run and CX-Tools itself stays unmodified. - CX-Tools reaches OpenStack with `docker exec <region>-osc`, so the image now carries the Docker client (the static binary, not the docker.io package) and compose mounts the host socket with group_add for it. Verified from inside the container: live OpenStack and Infrahub calls both succeed. Also fixes Settings, which read the environment at class-definition time and so ignored anything set afterwards; a fresh Settings() silently returned stale values. Caught by the Jira scoping tests. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
189 lines
8.2 KiB
Python
189 lines
8.2 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, scope: str = "default") -> 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":
|
|
creds = settings.jira_for(scope)
|
|
if not (settings.feature_jira and creds["base"] and creds["email"] and creds["token"]):
|
|
prefix = "CX_RUNPOD_JIRA_" if scope == "runpod" else "CX_JIRA_"
|
|
raise DeliveryError(
|
|
f"Jira ({scope}) is not configured. Set CX_FEATURE_JIRA plus {prefix}BASE, "
|
|
f"{prefix}EMAIL and {prefix}TOKEN - or leave the {prefix}* values blank to reuse "
|
|
"the default instance."
|
|
)
|
|
|
|
|
|
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,
|
|
scope: str = "default") -> dict[str, Any]:
|
|
"""Raise a Jira issue on the instance that owns this kind of work.
|
|
|
|
`scope="runpod"` targets the RunPod/RMA project, which may live on an
|
|
entirely different Atlassian site - hence separate credentials rather than
|
|
just a different project key.
|
|
"""
|
|
_guard("jira", scope)
|
|
_check_cap(db)
|
|
|
|
creds = settings.jira_for(scope)
|
|
base = creds["base"].rstrip("/")
|
|
auth = (creds["email"], creds["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 creds["project"]},
|
|
"summary": summary[:250],
|
|
"issuetype": {"name": issue_type or creds["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"}
|