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>
This commit is contained in:
@@ -21,56 +21,114 @@ def _int(name: str, default: int) -> int:
|
||||
|
||||
|
||||
class Settings:
|
||||
# --- app ---------------------------------------------------------------
|
||||
app_name = os.environ.get("CX_APP_NAME", "CX Triage")
|
||||
base_url = os.environ.get("CX_BASE_URL", "http://localhost:8080")
|
||||
secret_key = os.environ.get("CX_SECRET_KEY", "dev-only-change-me")
|
||||
session_hours = _int("CX_SESSION_HOURS", 12)
|
||||
static_dir = os.environ.get("CX_STATIC_DIR", "/app/static")
|
||||
"""Read once per instance, at construction time.
|
||||
|
||||
# --- database ----------------------------------------------------------
|
||||
# sqlite for local/compose, postgres in the cluster.
|
||||
database_url = os.environ.get("CX_DATABASE_URL", "sqlite:////data/cx-triage.db")
|
||||
These were class attributes, which meant the environment was read when the
|
||||
module was first imported and never again - so a fresh Settings() silently
|
||||
returned stale values. Reading in __init__ makes construction mean what it
|
||||
looks like it means.
|
||||
"""
|
||||
|
||||
# --- data sources ------------------------------------------------------
|
||||
prometheus_base = os.environ.get("CX_PROMETHEUS_BASE", "http://10.11.254.250:9090")
|
||||
prometheus_relay = os.environ.get("CX_PROMETHEUS_RELAY", "")
|
||||
cx_tools_path = os.environ.get("CX_TOOLS_PATH", "")
|
||||
def __init__(self) -> None:
|
||||
# --- app ---------------------------------------------------------------
|
||||
self.app_name = os.environ.get("CX_APP_NAME", "CX Triage")
|
||||
self.base_url = os.environ.get("CX_BASE_URL", "http://localhost:8080")
|
||||
self.secret_key = os.environ.get("CX_SECRET_KEY", "dev-only-change-me")
|
||||
self.session_hours = _int("CX_SESSION_HOURS", 12)
|
||||
self.static_dir = os.environ.get("CX_STATIC_DIR", "/app/static")
|
||||
|
||||
# --- auth --------------------------------------------------------------
|
||||
# Local accounts are for development and for a cluster without SSO yet.
|
||||
# When CX_OIDC_ENABLED is on, Authentik becomes the source of truth.
|
||||
auth_local_enabled = _bool("CX_AUTH_LOCAL_ENABLED", True)
|
||||
bootstrap_admin_email = os.environ.get("CX_BOOTSTRAP_ADMIN_EMAIL", "admin@localhost")
|
||||
bootstrap_admin_password = os.environ.get("CX_BOOTSTRAP_ADMIN_PASSWORD", "")
|
||||
# --- database ----------------------------------------------------------
|
||||
# sqlite for local/compose, postgres in the cluster.
|
||||
self.database_url = os.environ.get("CX_DATABASE_URL", "sqlite:////data/cx-triage.db")
|
||||
|
||||
oidc_enabled = _bool("CX_OIDC_ENABLED", False)
|
||||
oidc_issuer = os.environ.get("CX_OIDC_ISSUER", "") # e.g. https://sso/application/o/cx-triage/
|
||||
oidc_client_id = os.environ.get("CX_OIDC_CLIENT_ID", "")
|
||||
oidc_client_secret = os.environ.get("CX_OIDC_CLIENT_SECRET", "")
|
||||
oidc_scopes = os.environ.get("CX_OIDC_SCOPES", "openid email profile")
|
||||
oidc_admin_group = os.environ.get("CX_OIDC_ADMIN_GROUP", "cx-triage-admins")
|
||||
oidc_groups_claim = os.environ.get("CX_OIDC_GROUPS_CLAIM", "groups")
|
||||
# --- data sources ------------------------------------------------------
|
||||
self.prometheus_base = os.environ.get("CX_PROMETHEUS_BASE", "http://10.11.254.250:9090")
|
||||
self.prometheus_relay = os.environ.get("CX_PROMETHEUS_RELAY", "")
|
||||
self.cx_tools_path = os.environ.get("CX_TOOLS_PATH", "")
|
||||
|
||||
# --- feature flags -----------------------------------------------------
|
||||
# Sending must be switched on deliberately; a demo instance cannot email.
|
||||
feature_send_enabled = _bool("CX_FEATURE_SEND_ENABLED", False)
|
||||
feature_zendesk = _bool("CX_FEATURE_ZENDESK", False)
|
||||
feature_jira = _bool("CX_FEATURE_JIRA", False)
|
||||
feature_linkage_scan = _bool("CX_FEATURE_LINKAGE_SCAN", True)
|
||||
send_daily_cap = _int("CX_SEND_DAILY_CAP", 25)
|
||||
# --- auth --------------------------------------------------------------
|
||||
# Local accounts are for development and for a cluster without SSO yet.
|
||||
# When CX_OIDC_ENABLED is on, Authentik becomes the source of truth.
|
||||
self.auth_local_enabled = _bool("CX_AUTH_LOCAL_ENABLED", True)
|
||||
self.bootstrap_admin_email = os.environ.get("CX_BOOTSTRAP_ADMIN_EMAIL", "admin@localhost")
|
||||
self.bootstrap_admin_password = os.environ.get("CX_BOOTSTRAP_ADMIN_PASSWORD", "")
|
||||
|
||||
# --- integrations ------------------------------------------------------
|
||||
zendesk_subdomain = os.environ.get("CX_ZENDESK_SUBDOMAIN", "")
|
||||
zendesk_email = os.environ.get("CX_ZENDESK_EMAIL", "")
|
||||
zendesk_token = os.environ.get("CX_ZENDESK_TOKEN", "")
|
||||
zendesk_default_public = _bool("CX_ZENDESK_PUBLIC_REPLY", True)
|
||||
self.oidc_enabled = _bool("CX_OIDC_ENABLED", False)
|
||||
self.oidc_issuer = os.environ.get("CX_OIDC_ISSUER", "") # e.g. https://sso/application/o/cx-triage/
|
||||
self.oidc_client_id = os.environ.get("CX_OIDC_CLIENT_ID", "")
|
||||
self.oidc_client_secret = os.environ.get("CX_OIDC_CLIENT_SECRET", "")
|
||||
self.oidc_scopes = os.environ.get("CX_OIDC_SCOPES", "openid email profile")
|
||||
self.oidc_admin_group = os.environ.get("CX_OIDC_ADMIN_GROUP", "cx-triage-admins")
|
||||
self.oidc_groups_claim = os.environ.get("CX_OIDC_GROUPS_CLAIM", "groups")
|
||||
|
||||
jira_base = os.environ.get("CX_JIRA_BASE", "")
|
||||
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")
|
||||
jira_issue_type = os.environ.get("CX_JIRA_ISSUE_TYPE", "Task")
|
||||
# --- feature flags -----------------------------------------------------
|
||||
# Sending must be switched on deliberately; a demo instance cannot email.
|
||||
self.feature_send_enabled = _bool("CX_FEATURE_SEND_ENABLED", False)
|
||||
self.feature_zendesk = _bool("CX_FEATURE_ZENDESK", False)
|
||||
self.feature_jira = _bool("CX_FEATURE_JIRA", False)
|
||||
self.feature_linkage_scan = _bool("CX_FEATURE_LINKAGE_SCAN", True)
|
||||
self.send_daily_cap = _int("CX_SEND_DAILY_CAP", 25)
|
||||
|
||||
# --- integrations ------------------------------------------------------
|
||||
self.zendesk_subdomain = os.environ.get("CX_ZENDESK_SUBDOMAIN", "")
|
||||
self.zendesk_email = os.environ.get("CX_ZENDESK_EMAIL", "")
|
||||
self.zendesk_token = os.environ.get("CX_ZENDESK_TOKEN", "")
|
||||
self.zendesk_default_public = _bool("CX_ZENDESK_PUBLIC_REPLY", True)
|
||||
|
||||
# Jira, instance 1: the Infrahub/OpenStack side (OIE).
|
||||
self.jira_base = os.environ.get("CX_JIRA_BASE", "")
|
||||
self.jira_email = os.environ.get("CX_JIRA_EMAIL", "")
|
||||
self.jira_token = os.environ.get("CX_JIRA_TOKEN", "")
|
||||
self.jira_project = os.environ.get("CX_JIRA_PROJECT", "OIE")
|
||||
self.jira_issue_type = os.environ.get("CX_JIRA_ISSUE_TYPE", "Task")
|
||||
|
||||
# Jira, instance 2: RunPod hosts and RMAs. These may be a different
|
||||
# Atlassian site entirely, so they get their own credentials; anything left
|
||||
# blank falls back to the values above rather than failing.
|
||||
self.runpod_jira_base = os.environ.get("CX_RUNPOD_JIRA_BASE", "")
|
||||
self.runpod_jira_email = os.environ.get("CX_RUNPOD_JIRA_EMAIL", "")
|
||||
self.runpod_jira_token = os.environ.get("CX_RUNPOD_JIRA_TOKEN", "")
|
||||
self.runpod_jira_project = os.environ.get("CX_RUNPOD_JIRA_PROJECT", "RMA")
|
||||
self.runpod_jira_issue_type = os.environ.get("CX_RUNPOD_JIRA_ISSUE_TYPE", "Task")
|
||||
|
||||
# --- RunPod ------------------------------------------------------------
|
||||
# The API key is the supported path. Email/password only reaches a console
|
||||
# login that now requires a second factor, so it cannot run unattended -
|
||||
# see app/runpod/client.py.
|
||||
self.runpod_api_key = os.environ.get("CX_RUNPOD_API_KEY", "")
|
||||
self.runpod_email = os.environ.get("CX_RUNPOD_EMAIL", "")
|
||||
self.runpod_password = os.environ.get("CX_RUNPOD_PASSWORD", "")
|
||||
self.runpod_team_id = os.environ.get("CX_RUNPOD_TEAM_ID", "")
|
||||
self.runpod_totp_secret = os.environ.get("CX_RUNPOD_TOTP_SECRET", "")
|
||||
self.feature_runpod = _bool("CX_FEATURE_RUNPOD", True)
|
||||
self.feature_handover = _bool("CX_FEATURE_HANDOVER", True)
|
||||
# Unlisting a machine is destructive to earnings; gated separately.
|
||||
self.feature_runpod_write = _bool("CX_FEATURE_RUNPOD_WRITE", False)
|
||||
|
||||
def jira_for(self, scope: str) -> dict:
|
||||
"""Credentials for a Jira scope: 'runpod' or anything else (default)."""
|
||||
if scope == "runpod":
|
||||
return {
|
||||
"base": self.runpod_jira_base or self.jira_base,
|
||||
"email": self.runpod_jira_email or self.jira_email,
|
||||
"token": self.runpod_jira_token or self.jira_token,
|
||||
"project": self.runpod_jira_project,
|
||||
"issue_type": self.runpod_jira_issue_type,
|
||||
}
|
||||
return {
|
||||
"base": self.jira_base, "email": self.jira_email, "token": self.jira_token,
|
||||
"project": self.jira_project, "issue_type": self.jira_issue_type,
|
||||
}
|
||||
|
||||
@property
|
||||
def runpod_ready(self) -> bool:
|
||||
return bool(self.feature_runpod and (self.runpod_api_key or
|
||||
(self.runpod_email and self.runpod_password)))
|
||||
|
||||
@property
|
||||
def runpod_jira_ready(self) -> bool:
|
||||
creds = self.jira_for("runpod")
|
||||
return bool(self.feature_jira and creds["base"] and creds["email"] and creds["token"])
|
||||
|
||||
@property
|
||||
def zendesk_ready(self) -> bool:
|
||||
@@ -93,6 +151,11 @@ class Settings:
|
||||
"send_enabled": self.feature_send_enabled,
|
||||
"linkage_scan": self.feature_linkage_scan,
|
||||
"jira_project": self.jira_project if self.jira_ready else "",
|
||||
"runpod_ready": self.runpod_ready,
|
||||
"runpod_write": self.feature_runpod_write,
|
||||
"runpod_jira_ready": self.runpod_jira_ready,
|
||||
"runpod_jira_project": self.runpod_jira_project if self.runpod_jira_ready else "",
|
||||
"handover": self.feature_handover,
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user