Files
cx-ui/backend/app/runpod/service.py
Parham Monfared 8892144e0a
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
Add shift handover and RunPod, and make CX-Tools work in a container
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>
2026-08-06 08:21:59 +01:00

103 lines
4.2 KiB
Python

"""Turning RunPod machine state into tracked history.
The API only ever reports what is true now. Everything CX cares about - how
often a machine has dropped out, when it drained, who relisted it - only exists
if each sync writes down what changed.
"""
from __future__ import annotations
import datetime as dt
from typing import Any, Optional
from sqlalchemy import select
from sqlalchemy.orm import Session
from ..models import RunpodColour, RunpodEvent, RunpodEventType, RunpodHost
def record_event(db: Session, host: RunpodHost, event_type: RunpodEventType, *,
actor: str = "system", detail: str = "", error_hint: str = "",
zendesk_ticket: str = "", jira_key: str = "",
gpu_reserved: Optional[int] = None,
payload: Optional[dict[str, Any]] = None) -> RunpodEvent:
event = RunpodEvent(
event_type=event_type, actor=actor, detail=detail, error_hint=error_hint,
zendesk_ticket=zendesk_ticket, jira_key=jira_key,
gpu_reserved=host.gpu_reserved if gpu_reserved is None else gpu_reserved,
payload=payload,
)
# Through the relationship so an already-loaded history stays correct.
host.events.append(event)
db.add(event)
return event
def sync_machines(db: Session, client: Any, actor: str = "system") -> dict[str, Any]:
"""Pull current machines and write down every transition since last time."""
machines = client.machines()
existing = {h.machine_id: h for h in db.scalars(select(RunpodHost)).all()}
now = dt.datetime.now(dt.timezone.utc)
created = unlisted = relisted = drained = 0
for machine in machines:
machine_id = str(machine.get("id") or "")
if not machine_id:
continue
listed = bool(machine.get("listed"))
reserved = int(machine.get("gpuReserved") or 0)
host = existing.get(machine_id)
if host is None:
host = RunpodHost(machine_id=machine_id, name=str(machine.get("name") or ""))
db.add(host)
db.flush()
created += 1
record_event(db, host, RunpodEventType.LISTED if listed else RunpodEventType.UNLISTED,
actor="runpod", detail="First seen by CX Triage")
if not listed:
host.unlisted_at = now
was_listed = host.listed
was_reserved = host.gpu_reserved
if was_listed and not listed:
unlisted += 1
host.unlisted_at = now
host.unlist_count += 1
# Nothing in the API says why; the email carries the reason and is
# attached separately through /ingest-email.
record_event(db, host, RunpodEventType.UNLISTED, actor="runpod",
detail="Unlisted (detected on sync)", gpu_reserved=reserved)
elif not was_listed and listed:
relisted += 1
host.last_listed_at = now
host.unlisted_at = None
record_event(db, host, RunpodEventType.LISTED, actor=actor,
detail="Relisted", gpu_reserved=reserved)
# Unlisted and the last renter has gone: the machine is now safe to work on.
if not listed and was_reserved > 0 and reserved == 0:
drained += 1
record_event(db, host, RunpodEventType.DRAINED, actor="runpod",
detail="Unlisted machine has drained - no rented GPUs left",
gpu_reserved=0)
gpu_type = machine.get("gpuType") or {}
host.name = str(machine.get("name") or host.name)
host.listed = listed
host.gpu_reserved = reserved
host.gpu_total = int(machine.get("gpuTotal") or 0)
host.gpu_type = str(gpu_type.get("displayName") or machine.get("gpuTypeId") or "")
host.data_center = str(machine.get("dataCenterId") or "")
host.uptime_one_week = machine.get("uptimePercentListedOneWeek")
host.maintenance_start = str(machine.get("maintenanceStart") or "")
host.maintenance_end = str(machine.get("maintenanceEnd") or "")
db.commit()
return {
"machines": len(machines), "created": created, "unlisted": unlisted,
"relisted": relisted, "drained": drained,
"synced_at": now.isoformat(),
}