Files
wehub-resource-sync 4b6817381b
CI (OpenClaw E2E) / openclaw test (push) Has been cancelled
CI / coverage-report (push) Has been cancelled
CI / test-kubernetes (push) Has been cancelled
CI / should-run-thorough (push) Has been cancelled
CI / test-thorough (cloudwatch-demo) (push) Has been cancelled
CI / test-thorough (flink-ecs) (push) Has been cancelled
CI / test-thorough (upstream-lambda) (push) Has been cancelled
CI / test-thorough (prefect-ecs-fargate) (push) Has been cancelled
Release / build-binaries (zip, opensre.exe, onefile, windows-latest, windows-x64) (push) Has been cancelled
Benchmark image — build + push to ECR (any adapter) / build + push (push) Has been cancelled
CI / quality (ubuntu-latest) (push) Has been cancelled
CI / test (tools-runtime) (push) Has been cancelled
CI / test (e2e-general) (push) Has been cancelled
CI / test (cli-runtime) (push) Has been cancelled
CI / test (e2e-provider-and-openclaw) (push) Has been cancelled
CI / test (integrations-and-misc) (push) Has been cancelled
Release / verify (push) Has been cancelled
Release / build-python-dist (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, macos-15-intel, darwin-x64) (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, macos-latest, darwin-arm64) (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04, linux-x64) (push) Has been cancelled
Release / publish-release (push) Has been cancelled
Release / publish-main-release (push) Has been cancelled
Interactive Shell Live (PR + post-merge) / turn-checks (no-LLM) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Interactive Shell Live (PR + post-merge) / turn-live shard ${{ matrix.shard_index }} (push) Has been cancelled
Release / prepare (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04-arm, linux-arm64) (push) Has been cancelled
Synthetic Deterministic Tests / Synthetic offline (deterministic) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:10:45 +08:00

1188 lines
38 KiB
Python

"""Canonical strict models for normalized integration configuration."""
from __future__ import annotations
import re
from urllib.parse import urlparse
from pydantic import AliasChoices, Field, field_validator, model_validator
from config.config import get_tracer_base_url
from config.strict_config import StrictConfigModel
from integrations._validators import (
normalize_bearer,
normalize_bool_str,
normalize_str,
normalize_url,
normalize_with_default,
)
from platform.common.url_validation import validate_https_or_loopback_http_url
_LOCAL_GRAFANA_HOSTS = {"localhost", "127.0.0.1", "0.0.0.0"}
DEFAULT_GROUNDCOVER_MCP_URL = "https://mcp.groundcover.com/api/mcp"
DEFAULT_GROUNDCOVER_TIMEZONE = "UTC"
DEFAULT_HONEYCOMB_BASE_URL = "https://api.honeycomb.io"
DEFAULT_HONEYCOMB_DATASET = "__all__"
DEFAULT_CORALOGIX_BASE_URL = "https://api.coralogix.com"
DEFAULT_OPSGENIE_BASE_URLS: dict[str, str] = {
"us": "https://api.opsgenie.com",
"eu": "https://api.eu.opsgenie.com",
}
DEFAULT_INCIDENT_IO_BASE_URL = "https://api.incident.io"
DEFAULT_PAGERDUTY_BASE_URL = "https://api.pagerduty.com"
# ---------------------------------------------------------------------------
# Observability
# ---------------------------------------------------------------------------
class GrafanaIntegrationConfig(StrictConfigModel):
"""Normalized Grafana credentials used by resolution and verification flows."""
endpoint: str
api_key: str = ""
integration_id: str = ""
username: str = ""
password: str = ""
_normalize_endpoint = field_validator("endpoint", mode="before")(normalize_url())
@property
def is_local(self) -> bool:
host = urlparse(self.endpoint).hostname or ""
return host in _LOCAL_GRAFANA_HOSTS
class DatadogIntegrationConfig(StrictConfigModel):
"""Normalized Datadog credentials used by resolution and verification flows."""
api_key: str
app_key: str
site: str = "datadoghq.com"
integration_id: str = ""
_normalize_site = field_validator("site", mode="before")(
normalize_with_default("datadoghq.com")
)
@property
def base_url(self) -> str:
return f"https://api.{self.site}"
@property
def headers(self) -> dict[str, str]:
return {
"DD-API-KEY": self.api_key,
"DD-APPLICATION-KEY": self.app_key,
"Content-Type": "application/json",
}
class GroundcoverIntegrationConfig(StrictConfigModel):
"""Normalized groundcover credentials used by resolution and verification flows.
groundcover is reached through its public streamable-HTTP MCP endpoint. The
bearer ``api_key`` is a read-only service-account token; ``tenant_uuid`` and
``backend_id`` are optional routing selectors only needed when the account
has multiple workspaces/backends.
"""
api_key: str = ""
mcp_url: str = DEFAULT_GROUNDCOVER_MCP_URL
tenant_uuid: str = ""
backend_id: str = ""
timezone: str = DEFAULT_GROUNDCOVER_TIMEZONE
integration_id: str = ""
_normalize_api_key = field_validator("api_key", mode="before")(normalize_bearer())
_normalize_strs = field_validator("tenant_uuid", "backend_id", "integration_id", mode="before")(
normalize_str()
)
_normalize_timezone = field_validator("timezone", mode="before")(
normalize_with_default(DEFAULT_GROUNDCOVER_TIMEZONE)
)
@field_validator("mcp_url", mode="before")
@classmethod
def _normalize_mcp_url(cls, value: object) -> str:
normalized = normalize_url(DEFAULT_GROUNDCOVER_MCP_URL)(value)
return validate_https_or_loopback_http_url(
normalized, service_name="groundcover", field_name="mcp_url"
)
@property
def is_configured(self) -> bool:
return bool(self.api_key and self.mcp_url)
@property
def request_headers(self) -> dict[str, str]:
"""HTTP headers for the MCP transport.
Only auth and timezone are always sent; tenant/backend routing headers
are added when configured so single-workspace accounts work with no
routing while multi-workspace accounts stay scoped to one context.
"""
headers: dict[str, str] = {
"Authorization": f"Bearer {self.api_key}",
"X-Timezone": self.timezone,
}
if self.tenant_uuid:
headers["X-Tenant-UUID"] = self.tenant_uuid
if self.backend_id:
headers["X-Backend-Id"] = self.backend_id
return headers
class HoneycombIntegrationConfig(StrictConfigModel):
"""Normalized Honeycomb credentials used by resolution and verification flows."""
api_key: str
dataset: str = DEFAULT_HONEYCOMB_DATASET
base_url: str = DEFAULT_HONEYCOMB_BASE_URL
integration_id: str = ""
_normalize_dataset = field_validator("dataset", mode="before")(
normalize_with_default(DEFAULT_HONEYCOMB_DATASET)
)
_normalize_base_url = field_validator("base_url", mode="before")(
normalize_url(DEFAULT_HONEYCOMB_BASE_URL)
)
class CoralogixIntegrationConfig(StrictConfigModel):
"""Normalized Coralogix credentials used by resolution and verification flows."""
api_key: str
base_url: str = DEFAULT_CORALOGIX_BASE_URL
application_name: str = ""
subsystem_name: str = ""
integration_id: str = ""
_normalize_base_url = field_validator("base_url", mode="before")(
normalize_url(DEFAULT_CORALOGIX_BASE_URL)
)
# ---------------------------------------------------------------------------
# Cloud / Infrastructure
# ---------------------------------------------------------------------------
class AWSStaticCredentials(StrictConfigModel):
"""Static AWS access key credentials."""
access_key_id: str
secret_access_key: str
session_token: str = ""
class AWSIntegrationConfig(StrictConfigModel):
"""Normalized AWS integration config supporting role or static keys."""
region: str = "us-east-1"
role_arn: str = ""
external_id: str = ""
credentials: AWSStaticCredentials | None = None
integration_id: str = ""
_normalize_region = field_validator("region", mode="before")(
normalize_with_default("us-east-1")
)
@model_validator(mode="after")
def _require_auth_method(self) -> AWSIntegrationConfig:
if self.role_arn or self.credentials:
return self
raise ValueError(
"AWS integration requires either role_arn or credentials.access_key_id/secret_access_key."
)
class VercelIntegrationConfig(StrictConfigModel):
"""Normalized Vercel credentials used by resolution and verification flows."""
api_token: str
team_id: str = ""
integration_id: str = ""
_normalize_api_token = field_validator("api_token", mode="before")(normalize_str())
_normalize_team_id = field_validator("team_id", mode="before")(normalize_str())
@property
def headers(self) -> dict[str, str]:
return {
"Authorization": f"Bearer {self.api_token}",
"Content-Type": "application/json",
}
@property
def team_params(self) -> dict[str, str]:
return {"teamId": self.team_id} if self.team_id else {}
# ---------------------------------------------------------------------------
# Alerting & Incident Management
# ---------------------------------------------------------------------------
class SlackWebhookConfig(StrictConfigModel):
"""Slack webhook runtime config."""
webhook_url: str
@model_validator(mode="after")
def _require_https_slack_url(self) -> SlackWebhookConfig:
parsed = urlparse(self.webhook_url)
if parsed.scheme != "https" or not parsed.netloc:
raise ValueError("Slack webhook must be a valid HTTPS URL.")
hostname = (parsed.hostname or "").lower()
if hostname != "slack.com" and not hostname.endswith(".slack.com"):
raise ValueError("Slack webhook host must be a Slack domain.")
return self
class OpsGenieIntegrationConfig(StrictConfigModel):
"""Normalized OpsGenie credentials used by resolution and verification flows."""
api_key: str
region: str = "us"
integration_id: str = ""
@field_validator("region", mode="before")
@classmethod
def _normalize_region(cls, value: object) -> str:
raw = str(value or "us").strip().lower()
return raw if raw in DEFAULT_OPSGENIE_BASE_URLS else "us"
@property
def base_url(self) -> str:
return DEFAULT_OPSGENIE_BASE_URLS.get(self.region, DEFAULT_OPSGENIE_BASE_URLS["us"])
@property
def headers(self) -> dict[str, str]:
return {
"Authorization": f"GenieKey {self.api_key}",
"Content-Type": "application/json",
}
class PagerDutyIntegrationConfig(StrictConfigModel):
"""PagerDuty config"""
api_key: str
base_url: str = DEFAULT_PAGERDUTY_BASE_URL
integration_id: str = ""
_normalize_base_url = field_validator("base_url", mode="before")(
normalize_url(DEFAULT_PAGERDUTY_BASE_URL)
)
@property
def headers(self) -> dict[str, str]:
return {
"Authorization": f"Token token={self.api_key}",
"Content-Type": "application/json",
}
class IncidentIoIntegrationConfig(StrictConfigModel):
"""Normalized incident.io credentials used by investigation and verification flows."""
api_key: str
base_url: str = DEFAULT_INCIDENT_IO_BASE_URL
integration_id: str = ""
@field_validator("base_url", mode="before")
@classmethod
def _normalize_base_url(cls, value: object) -> str:
normalized = normalize_url(DEFAULT_INCIDENT_IO_BASE_URL)(value)
return validate_https_or_loopback_http_url(normalized, service_name="incident.io")
@field_validator("api_key", mode="before")
@classmethod
def _normalize_api_key(cls, value: object) -> str:
return normalize_str()(value)
@property
def headers(self) -> dict[str, str]:
return {
"Authorization": f"Bearer {self.api_key}",
"Content-Type": "application/json",
}
class AlertmanagerIntegrationConfig(StrictConfigModel):
"""Normalized Alertmanager credentials used by resolution and verification flows."""
base_url: str
bearer_token: str = ""
username: str = ""
password: str = ""
integration_id: str = ""
_normalize_base_url = field_validator("base_url", mode="before")(normalize_url())
_normalize_strs = field_validator("bearer_token", "username", "password", mode="before")(
normalize_str()
)
@model_validator(mode="after")
def _no_dual_auth(self) -> AlertmanagerIntegrationConfig:
if self.bearer_token and self.username:
raise ValueError(
"Alertmanager config has both bearer_token and username set; "
"use one auth method only."
)
return self
@property
def headers(self) -> dict[str, str]:
headers: dict[str, str] = {"Content-Type": "application/json"}
if self.bearer_token:
headers["Authorization"] = f"Bearer {self.bearer_token}"
return headers
@property
def basic_auth(self) -> tuple[str, str] | None:
if self.username and self.password:
return (self.username, self.password)
return None
class SplunkIntegrationConfig(StrictConfigModel):
"""Normalized Splunk credentials used by resolution and verification flows."""
base_url: str
token: str = ""
index: str = "main"
verify_ssl: bool = True
ca_bundle: str = ""
integration_id: str = ""
_normalize_base_url = field_validator("base_url", mode="before")(normalize_url())
_normalize_token = field_validator("token", mode="before")(normalize_str())
_normalize_index = field_validator("index", mode="before")(normalize_with_default("main"))
_normalize_ca_bundle = field_validator("ca_bundle", mode="before")(normalize_str())
@property
def ssl_verify(self) -> bool | str:
if self.ca_bundle:
return self.ca_bundle
return self.verify_ssl
@property
def is_configured(self) -> bool:
return bool(self.base_url and self.token)
class VictoriaLogsIntegrationConfig(StrictConfigModel):
"""Normalized VictoriaLogs credentials used by resolution and verification flows."""
base_url: str
tenant_id: str | None = None
integration_id: str = ""
_normalize_base_url = field_validator("base_url", mode="before")(normalize_url())
_normalize_integration_id = field_validator("integration_id", mode="before")(normalize_str())
@field_validator("tenant_id", mode="before")
@classmethod
def _normalize_tenant_id(cls, value: object) -> str | None:
# Treat empty / blank / None uniformly as "not configured" so the
# AccountID header is only sent when the user explicitly opts in.
if value is None:
return None
text = str(value).strip()
return text or None
@property
def is_configured(self) -> bool:
return bool(self.base_url)
# ---------------------------------------------------------------------------
# Source Control & CI/CD
# ---------------------------------------------------------------------------
class ArgoCDIntegrationConfig(StrictConfigModel):
"""Normalized Argo CD credentials used by resolution and verification flows."""
base_url: str
bearer_token: str = ""
username: str = ""
password: str = ""
project: str = ""
app_namespace: str = ""
verify_ssl: bool = True
integration_id: str = ""
@field_validator("base_url", mode="before")
@classmethod
def _normalize_base_url(cls, value: object) -> str:
normalized = str(value or "").strip().rstrip("/")
return validate_https_or_loopback_http_url(normalized, service_name="Argo CD")
_normalize_bearer_token = field_validator("bearer_token", mode="before")(normalize_bearer())
_normalize_strs = field_validator(
"username", "password", "project", "app_namespace", "integration_id", mode="before"
)(normalize_str())
_normalize_verify_ssl = field_validator("verify_ssl", mode="before")(normalize_bool_str())
@model_validator(mode="after")
def _no_dual_auth(self) -> ArgoCDIntegrationConfig:
if self.bearer_token and (self.username or self.password):
raise ValueError(
"Argo CD config has both bearer_token and username/password set; "
"use one auth method only."
)
return self
@property
def is_configured(self) -> bool:
return bool(self.base_url and (self.bearer_token or (self.username and self.password)))
class HelmIntegrationConfig(StrictConfigModel):
"""Normalized Helm CLI settings for read-only Kubernetes release inspection."""
helm_path: str = "helm"
kube_context: str = ""
kubeconfig: str = ""
default_namespace: str = ""
integration_id: str = ""
_normalize_helm_path = field_validator("helm_path", mode="before")(
normalize_with_default("helm")
)
_normalize_strs = field_validator(
"kube_context", "kubeconfig", "default_namespace", "integration_id", mode="before"
)(normalize_str())
@property
def is_configured(self) -> bool:
return bool(str(self.helm_path or "").strip())
class GitLabIntegrationConfig(StrictConfigModel):
"""Normalized GitLab credentials used by resolution and verification flows."""
url: str
access_token: str
integration_id: str = ""
# ---------------------------------------------------------------------------
# Error Tracking & APM
# ---------------------------------------------------------------------------
class SentryIntegrationConfig(StrictConfigModel):
"""Normalized Sentry credentials — kept for type-check consumers."""
base_url: str = "https://sentry.io"
organization_slug: str = ""
auth_token: str = ""
project_slug: str = ""
integration_id: str = ""
_normalize_base_url = field_validator("base_url", mode="before")(
normalize_url("https://sentry.io")
)
_normalize_strs = field_validator(
"organization_slug", "auth_token", "project_slug", mode="before"
)(normalize_str())
# ---------------------------------------------------------------------------
# Databases — Relational
# ---------------------------------------------------------------------------
class PostgreSQLIntegrationConfig(StrictConfigModel):
"""Normalized PostgreSQL credentials used by resolution and verification flows."""
host: str
port: int = 5432
database: str
username: str = "postgres"
password: str = ""
ssl_mode: str = "prefer"
integration_id: str = ""
_normalize_host = field_validator("host", mode="before")(normalize_str())
_normalize_database = field_validator("database", mode="before")(normalize_str())
_normalize_username = field_validator("username", mode="before")(
normalize_with_default("postgres")
)
_normalize_ssl_mode = field_validator("ssl_mode", mode="before")(
normalize_with_default("prefer")
)
class MySQLIntegrationConfig(StrictConfigModel):
"""Normalized MySQL credentials used by resolution and verification flows."""
host: str
port: int = 3306
database: str
username: str = "root"
password: str = ""
ssl_mode: str = "preferred"
integration_id: str = ""
_normalize_host = field_validator("host", mode="before")(normalize_str())
_normalize_database = field_validator("database", mode="before")(normalize_str())
_normalize_username = field_validator("username", mode="before")(normalize_with_default("root"))
_normalize_ssl_mode = field_validator("ssl_mode", mode="before")(
normalize_with_default("preferred")
)
class MariaDBIntegrationConfig(StrictConfigModel):
"""Normalized MariaDB credentials used by resolution and verification flows."""
host: str
port: int = 3306
database: str
username: str
password: str = ""
ssl: bool = True
integration_id: str = ""
_normalize_strs = field_validator("host", "database", "username", mode="before")(
normalize_str()
)
class AzureSQLIntegrationConfig(StrictConfigModel):
"""Normalized Azure SQL Database credentials used by resolution and verification flows."""
server: str
port: int = 1433
database: str
username: str = ""
password: str = ""
driver: str = "ODBC Driver 18 for SQL Server"
encrypt: bool = True
integration_id: str = ""
_normalize_strs = field_validator("server", "database", "username", mode="before")(
normalize_str()
)
_normalize_driver = field_validator("driver", mode="before")(
normalize_with_default("ODBC Driver 18 for SQL Server")
)
# ---------------------------------------------------------------------------
# Databases — Document / NoSQL
# ---------------------------------------------------------------------------
class MongoDBIntegrationConfig(StrictConfigModel):
"""Normalized MongoDB credentials used by resolution and verification flows."""
connection_string: str
database: str = ""
auth_source: str = "admin"
tls: bool = True
integration_id: str = ""
_normalize_connection_string = field_validator("connection_string", mode="before")(
normalize_str()
)
_normalize_auth_source = field_validator("auth_source", mode="before")(
normalize_with_default("admin")
)
class RedisIntegrationConfig(StrictConfigModel):
"""Normalized Redis credentials used by resolution and verification flows."""
host: str
port: int = 6379
username: str = ""
password: str = ""
db: int = 0
ssl: bool = False
integration_id: str = ""
_normalize_host = field_validator("host", mode="before")(normalize_str())
_normalize_username = field_validator("username", mode="before")(normalize_str())
_normalize_password = field_validator("password", mode="before")(normalize_str())
class MongoDBAtlasIntegrationConfig(StrictConfigModel):
"""Normalized MongoDB Atlas API credentials used by resolution and verification flows."""
api_public_key: str
api_private_key: str
project_id: str
base_url: str = "https://cloud.mongodb.com/api/atlas/v2"
integration_id: str = ""
_normalize_strs = field_validator(
"api_public_key", "api_private_key", "project_id", mode="before"
)(normalize_str())
_normalize_base_url = field_validator("base_url", mode="before")(
normalize_url("https://cloud.mongodb.com/api/atlas/v2")
)
# ---------------------------------------------------------------------------
# Message Queues
# ---------------------------------------------------------------------------
class RabbitMQIntegrationConfig(StrictConfigModel):
"""Normalized RabbitMQ Management API credentials used by resolution and verification flows."""
host: str
management_port: int = 15672
username: str
password: str = ""
vhost: str = "/"
ssl: bool = False
verify_ssl: bool = True
integration_id: str = ""
_normalize_strs = field_validator("host", "username", mode="before")(normalize_str())
_normalize_vhost = field_validator("vhost", mode="before")(normalize_with_default("/"))
# ---------------------------------------------------------------------------
# Logging & Telemetry
# ---------------------------------------------------------------------------
class BetterStackIntegrationConfig(StrictConfigModel):
"""Normalized Better Stack Telemetry SQL Query API credentials."""
query_endpoint: str
username: str
password: str = ""
sources: list[str] = []
integration_id: str = ""
_normalize_endpoint = field_validator("query_endpoint", mode="before")(normalize_url())
_normalize_username = field_validator("username", mode="before")(normalize_str())
@field_validator("sources", mode="before")
@classmethod
def _normalize_sources(cls, value: object) -> list[str]:
if value in (None, ""):
return []
if isinstance(value, str):
return [part.strip() for part in value.split(",") if part.strip()]
if isinstance(value, list):
return [str(v).strip() for v in value if str(v).strip()]
return []
# ---------------------------------------------------------------------------
# Productivity & Collaboration
# ---------------------------------------------------------------------------
class JiraIntegrationConfig(StrictConfigModel):
"""Normalized Jira credentials used by resolution and verification flows."""
base_url: str
email: str
api_token: str
project_key: str
integration_id: str = ""
_normalize_base_url = field_validator("base_url", mode="before")(normalize_url())
_normalize_strs = field_validator("email", "api_token", "project_key", mode="before")(
normalize_str()
)
@property
def auth(self) -> tuple[str, str]:
return (self.email, self.api_token)
@property
def api_base(self) -> str:
return f"{self.base_url}/rest/api/3"
class NotionIntegrationConfig(StrictConfigModel):
"""Normalized Notion credentials used by resolution and verification flows."""
api_key: str
database_id: str
integration_id: str = ""
_normalize_strs = field_validator("api_key", "database_id", mode="before")(normalize_str())
class TrelloIntegrationConfig(StrictConfigModel):
"""Normalized Trello credentials."""
api_key: str
api_token: str
integration_id: str = ""
_normalize_strs = field_validator("api_key", "api_token", mode="before")(normalize_str())
class GoogleDocsIntegrationConfig(StrictConfigModel):
"""Normalized Google Docs (Drive API) credentials for incident report generation."""
credentials_file: str
folder_id: str
integration_id: str = ""
timeout_seconds: int = 30
_normalize_credentials_file = field_validator("credentials_file", mode="before")(
normalize_str()
)
@field_validator("timeout_seconds", mode="before")
@classmethod
def _validate_timeout(cls, value: object) -> int:
if isinstance(value, str):
try:
timeout = int(value)
except ValueError:
return 30
elif isinstance(value, int | float):
timeout = int(value)
else:
return 30
return max(5, min(timeout, 300))
# ---------------------------------------------------------------------------
# Messaging Bots
# ---------------------------------------------------------------------------
class DiscordBotConfig(StrictConfigModel):
"""Discord runtime config."""
bot_token: str
application_id: str = ""
public_key: str = ""
default_channel_id: str | None = None
identity_policy: dict[str, object] | None = Field(
default=None,
description="Messaging identity policy for inbound security (MessagingIdentityPolicy shape)",
)
@field_validator("bot_token", mode="before")
@classmethod
def _validate_bot_token(cls, value: object) -> str:
stripped = str(value or "").strip()
if not stripped:
raise ValueError("bot_token cannot be empty or just whitespace")
return stripped
@field_validator("public_key", mode="before")
@classmethod
def _validate_public_key(cls, value: object) -> str:
stripped = str(value or "").strip()
if stripped and not re.fullmatch(r"[0-9a-fA-F]+", stripped):
raise ValueError("public_key must be a valid hexadecimal string")
return stripped
class TelegramBotConfig(StrictConfigModel):
"""Telegram Bot runtime config."""
bot_token: str
default_chat_id: str | None = None
identity_policy: dict[str, object] | None = Field(
default=None,
description="Messaging identity policy for inbound security (MessagingIdentityPolicy shape)",
)
@field_validator("bot_token", mode="before")
@classmethod
def _validate_bot_token(cls, value: object) -> str:
stripped = str(value or "").strip()
if not stripped:
raise ValueError("bot_token cannot be empty or just whitespace")
return stripped
class WhatsAppConfig(StrictConfigModel):
"""Twilio WhatsApp runtime config.
WhatsApp delivery is owned entirely by the standalone ``whatsapp``
integration. The unified :class:`TwilioIntegrationConfig` adds SMS as a
separate channel and intentionally does NOT duplicate WhatsApp.
"""
account_sid: str
auth_token: str
from_number: str
default_to: str | None = None
identity_policy: dict[str, object] | None = Field(
default=None,
description="Messaging identity policy for inbound security (MessagingIdentityPolicy shape)",
)
@field_validator("account_sid", mode="before")
@classmethod
def _validate_account_sid(cls, value: object) -> str:
stripped = str(value or "").strip()
if not stripped:
raise ValueError("account_sid cannot be empty or just whitespace")
return stripped
@field_validator("auth_token", mode="before")
@classmethod
def _validate_auth_token(cls, value: object) -> str:
stripped = str(value or "").strip()
if not stripped:
raise ValueError("auth_token cannot be empty or just whitespace")
return stripped
@field_validator("from_number", mode="before")
@classmethod
def _validate_from_number(cls, value: object) -> str:
stripped = str(value or "").strip()
if not stripped:
raise ValueError("from_number cannot be empty or just whitespace")
return stripped
class TwilioSMSChannelConfig(StrictConfigModel):
"""SMS channel sub-config inside a unified Twilio integration.
Either ``from_number`` (a Twilio-provisioned phone number) OR
``messaging_service_sid`` (a Twilio Messaging Service) must be set
for the channel to be considered configured.
"""
enabled: bool = False
from_number: str = ""
default_to: str | None = None
messaging_service_sid: str = ""
_normalize_strs = field_validator("from_number", "messaging_service_sid", mode="before")(
normalize_str()
)
_normalize_enabled = field_validator("enabled", mode="before")(normalize_bool_str())
@property
def is_configured(self) -> bool:
return bool(self.enabled and (self.from_number or self.messaging_service_sid))
class TwilioIntegrationConfig(StrictConfigModel):
"""Unified Twilio runtime config.
Adds SMS as a Twilio-backed outbound channel. WhatsApp is owned by the
standalone ``whatsapp`` integration and is intentionally not duplicated
here. Both can share the same Twilio account credentials.
"""
account_sid: str
auth_token: str
sms: TwilioSMSChannelConfig = Field(default_factory=TwilioSMSChannelConfig)
integration_id: str = ""
@field_validator("account_sid", mode="before")
@classmethod
def _validate_account_sid(cls, value: object) -> str:
stripped = str(value or "").strip()
if not stripped:
raise ValueError("account_sid cannot be empty or just whitespace")
return stripped
@field_validator("auth_token", mode="before")
@classmethod
def _validate_auth_token(cls, value: object) -> str:
stripped = str(value or "").strip()
if not stripped:
raise ValueError("auth_token cannot be empty or just whitespace")
return stripped
@model_validator(mode="after")
def _require_sms_channel(self) -> TwilioIntegrationConfig:
if not self.sms.is_configured:
raise ValueError(
"Twilio integration requires the SMS channel configured "
"(enabled=true with a from_number or messaging_service_sid)."
)
return self
@property
def configured_channels(self) -> list[str]:
return ["sms"] if self.sms.is_configured else []
class SlackBotConfig(StrictConfigModel):
"""Slack Bot (Events API) runtime config for inbound messaging.
NOTE: ``signing_secret`` defaults to empty for backward compatibility,
but MUST be set in production when inbound messaging is enabled.
Without it, the Slack Events API webhook handler cannot verify request
authenticity and will accept forged requests from any source.
"""
bot_token: str
signing_secret: str = Field(
default="",
description="Slack signing secret for webhook HMAC verification. MUST be set for inbound.",
)
app_id: str = ""
identity_policy: dict[str, object] | None = Field(
default=None,
description="Messaging identity policy for inbound security (MessagingIdentityPolicy shape)",
)
@field_validator("bot_token", mode="before")
@classmethod
def _validate_bot_token(cls, value: object) -> str:
stripped = str(value or "").strip()
if not stripped:
raise ValueError("bot_token cannot be empty or just whitespace")
return stripped
class SMTPIntegrationConfig(StrictConfigModel):
"""SMTP runtime config for RCA email delivery."""
host: str
port: int = 587
security: str = "starttls"
username: str = ""
password: str = ""
from_address: str
default_to: str | None = None
_normalize_host = field_validator("host", mode="before")(normalize_str())
_normalize_security = field_validator("security", mode="before")(
normalize_with_default("starttls")
)
_normalize_username = field_validator("username", mode="before")(normalize_str())
_normalize_password = field_validator("password", mode="before")(normalize_str())
_normalize_from_address = field_validator("from_address", mode="before")(normalize_str())
_normalize_default_to = field_validator("default_to", mode="before")(normalize_str())
@field_validator("port", mode="before")
@classmethod
def _normalize_port(cls, value: object) -> int:
if isinstance(value, int):
port = value
elif isinstance(value, str):
stripped = value.strip()
port = int(stripped) if stripped else 587
else:
port = 587
if port <= 0 or port > 65535:
raise ValueError("port must be between 1 and 65535")
return port
@field_validator("security", mode="after")
@classmethod
def _validate_security(cls, value: str) -> str:
normalized = value.strip().lower()
if normalized not in {"starttls", "ssl", "none"}:
raise ValueError("security must be one of: starttls, ssl, none")
return normalized
@model_validator(mode="after")
def _validate_auth_pair(self) -> SMTPIntegrationConfig:
if bool(self.username) != bool(self.password):
raise ValueError("username and password must both be set, or both be empty")
if "@" not in self.from_address:
raise ValueError("from_address must look like an email address")
if self.default_to and "@" not in self.default_to:
raise ValueError("default_to must look like an email address")
return self
# ---------------------------------------------------------------------------
# Cloud Observability Platforms
# ---------------------------------------------------------------------------
class SnowflakeIntegrationConfig(StrictConfigModel):
"""Normalized Snowflake credentials used by resolution and tool flows."""
account_identifier: str
token: str
user: str = ""
password: str = ""
warehouse: str = ""
role: str = ""
database: str = ""
db_schema: str = Field(
default="",
alias="schema",
validation_alias=AliasChoices("schema", "db_schema"),
)
max_results: int = 50
integration_id: str = ""
_normalize_strs = field_validator(
"user",
"password",
"warehouse",
"role",
"database",
"db_schema",
"integration_id",
mode="before",
)(normalize_str())
@field_validator("max_results", mode="before")
@classmethod
def _clamp_max_results(cls, value: object) -> int:
try:
v: int = int(value) # type: ignore[arg-type,call-overload]
except (TypeError, ValueError):
return 50
return max(1, min(v, 200))
class AzureIntegrationConfig(StrictConfigModel):
"""Normalized Azure Monitor Log Analytics credentials."""
workspace_id: str
access_token: str
endpoint: str = "https://api.loganalytics.io"
tenant_id: str = ""
subscription_id: str = ""
max_results: int = 100
integration_id: str = ""
_normalize_endpoint = field_validator("endpoint", mode="before")(
normalize_url("https://api.loganalytics.io")
)
_normalize_strs = field_validator(
"tenant_id", "subscription_id", "integration_id", mode="before"
)(normalize_str())
@field_validator("max_results", mode="before")
@classmethod
def _clamp_max_results(cls, value: object) -> int:
try:
v: int = int(value) # type: ignore[arg-type,call-overload]
except (TypeError, ValueError):
return 100
return max(1, min(v, 500))
class OpenObserveIntegrationConfig(StrictConfigModel):
"""Normalized OpenObserve credentials used by resolution and tool flows."""
base_url: str
org: str = "default"
api_token: str = ""
username: str = ""
password: str = ""
stream: str = ""
max_results: int = 100
integration_id: str = ""
_normalize_base_url = field_validator("base_url", mode="before")(normalize_url())
_normalize_org = field_validator("org", mode="before")(normalize_with_default("default"))
_normalize_strs = field_validator(
"api_token", "username", "password", "stream", "integration_id", mode="before"
)(normalize_str())
@field_validator("max_results", mode="before")
@classmethod
def _clamp_max_results(cls, value: object) -> int:
try:
v: int = int(value) # type: ignore[arg-type,call-overload]
except (TypeError, ValueError):
return 100
return max(1, min(v, 500))
class OpenSearchIntegrationConfig(StrictConfigModel):
"""Normalized OpenSearch credentials used by resolution and tool flows."""
url: str
api_key: str = ""
username: str = ""
password: str = ""
index_pattern: str = "*"
max_results: int = 100
integration_id: str = ""
_normalize_url = field_validator("url", mode="before")(normalize_url())
_normalize_index_pattern = field_validator("index_pattern", mode="before")(
normalize_with_default("*")
)
_normalize_strs = field_validator(
"api_key", "username", "password", "integration_id", mode="before"
)(normalize_str())
@field_validator("max_results", mode="before")
@classmethod
def _clamp_max_results(cls, value: object) -> int:
try:
v: int = int(value) # type: ignore[arg-type,call-overload]
except (TypeError, ValueError):
return 100
return max(1, min(v, 500))
# ---------------------------------------------------------------------------
# Tracer internal
# ---------------------------------------------------------------------------
class TracerIntegrationConfig(StrictConfigModel):
"""Tracer API access config."""
base_url: str = Field(default_factory=get_tracer_base_url)
jwt_token: str
@field_validator("base_url", mode="before")
@classmethod
def _normalize_base_url(cls, value: object) -> str:
return str(value or get_tracer_base_url()).strip() or get_tracer_base_url()
_normalize_token = field_validator("jwt_token", mode="before")(normalize_bearer())
# ---------------------------------------------------------------------------
# SaaS / workflow integrations (config-only, no active verifier)
# ---------------------------------------------------------------------------
class PrefectIntegrationConfig(StrictConfigModel):
api_url: str = "https://api.prefect.cloud/api"
api_key: str = ""
account_id: str = ""
workspace_id: str = ""
integration_id: str = ""
_normalize_api_url = field_validator("api_url", mode="before")(
normalize_url("https://api.prefect.cloud/api")
)
_normalize_strs = field_validator("api_key", "account_id", "workspace_id", mode="before")(
normalize_str()
)
class TemporalIntegrationConfig(StrictConfigModel):
base_url: str = ""
namespace: str = "default"
api_key: str = ""
integration_id: str = ""
_normalize_base_url = field_validator("base_url", mode="before")(normalize_url(""))
_normalize_strs = field_validator("api_key", "namespace", mode="before")(normalize_str())
@property
def headers(self) -> dict[str, str]:
headers = {
"Content-Type": "application/json",
}
if self.api_key:
headers["Authorization"] = f"Bearer {self.api_key}"
return headers