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

581 lines
23 KiB
Python

"""Shared RabbitMQ integration helpers.
Provides configuration, connectivity validation, and read-only diagnostic
queries for RabbitMQ brokers via the HTTP Management API.
All operations are production-safe: read-only, timeouts enforced, result sizes
capped. The Management API (port 15672 by default) is strongly preferred over
AMQP introspection because it returns rich JSON describing queues, consumers,
connections, channels, nodes, and alarms — exactly the operational state an
incident investigation needs. AMQP would require a new dependency and expose
far less diagnostic data.
The management plugin (``rabbitmq_management``) must be enabled on the target
broker; the validation helper surfaces that specifically so users aren't left
chasing a generic 404.
"""
from __future__ import annotations
import logging
import os
import urllib.parse
from dataclasses import dataclass
from typing import Any
import httpx
from pydantic import Field, field_validator
from config.strict_config import StrictConfigModel
from integrations._validation_helpers import report_classify_failure, report_validation_failure
from platform.common.coercion import safe_int
logger = logging.getLogger(__name__)
DEFAULT_RABBITMQ_MANAGEMENT_PORT = 15672
DEFAULT_RABBITMQ_VHOST = "/"
DEFAULT_RABBITMQ_TIMEOUT_S = 10
DEFAULT_RABBITMQ_MAX_RESULTS = 50
class RabbitMQConfig(StrictConfigModel):
"""Normalized RabbitMQ Management API connection settings."""
host: str = ""
management_port: int = DEFAULT_RABBITMQ_MANAGEMENT_PORT
username: str = ""
password: str = ""
vhost: str = DEFAULT_RABBITMQ_VHOST
ssl: bool = False
verify_ssl: bool = True
timeout_seconds: int = Field(default=DEFAULT_RABBITMQ_TIMEOUT_S, gt=0)
max_results: int = Field(default=DEFAULT_RABBITMQ_MAX_RESULTS, gt=0, le=200)
integration_id: str = ""
@field_validator("host", mode="before")
@classmethod
def _normalize_host(cls, value: Any) -> str:
return str(value or "").strip()
@field_validator("username", mode="before")
@classmethod
def _normalize_username(cls, value: Any) -> str:
return str(value or "").strip()
@field_validator("password", mode="before")
@classmethod
def _normalize_password(cls, value: Any) -> str:
# Do NOT strip passwords — leading/trailing whitespace is valid.
return str(value or "")
@field_validator("vhost", mode="before")
@classmethod
def _normalize_vhost(cls, value: Any) -> str:
raw = str(value or "").strip()
return raw or DEFAULT_RABBITMQ_VHOST
@field_validator("management_port", mode="before")
@classmethod
def _normalize_management_port(cls, value: Any) -> int:
return safe_int(value, DEFAULT_RABBITMQ_MANAGEMENT_PORT)
@property
def is_configured(self) -> bool:
return bool(self.host and self.username)
@property
def base_url(self) -> str:
scheme = "https" if self.ssl else "http"
return f"{scheme}://{self.host}:{self.management_port}"
@dataclass(frozen=True)
class RabbitMQValidationResult:
"""Result of validating a RabbitMQ integration."""
ok: bool
detail: str
def build_rabbitmq_config(raw: dict[str, Any] | None) -> RabbitMQConfig:
"""Build a normalized RabbitMQ config object from env/store data."""
return RabbitMQConfig.model_validate(raw or {})
def rabbitmq_config_from_env() -> RabbitMQConfig | None:
"""Load a RabbitMQ config from env vars."""
host = os.getenv("RABBITMQ_HOST", "").strip()
username = os.getenv("RABBITMQ_USERNAME", "").strip()
if not host or not username:
return None
return build_rabbitmq_config(
{
"host": host,
"management_port": os.getenv(
"RABBITMQ_MANAGEMENT_PORT",
str(DEFAULT_RABBITMQ_MANAGEMENT_PORT),
).strip(),
"username": username,
"password": os.getenv("RABBITMQ_PASSWORD", ""),
"vhost": os.getenv("RABBITMQ_VHOST", DEFAULT_RABBITMQ_VHOST).strip(),
"ssl": os.getenv("RABBITMQ_SSL", "false").strip().lower() in ("true", "1", "yes"),
"verify_ssl": os.getenv("RABBITMQ_VERIFY_SSL", "true").strip().lower()
in ("true", "1", "yes"),
}
)
def _get_client(config: RabbitMQConfig) -> httpx.Client:
"""Build an authenticated httpx client for the management API."""
return httpx.Client(
base_url=config.base_url,
auth=(config.username, config.password),
timeout=float(config.timeout_seconds),
verify=config.verify_ssl,
)
def _error_evidence(err: str) -> dict[str, Any]:
return {"source": "rabbitmq", "available": False, "error": err}
def _vhost_path(base: str, vhost: str) -> str:
"""Build a vhost-scoped API path, URL-encoding the vhost segment."""
encoded = urllib.parse.quote(vhost, safe="")
return f"{base}/{encoded}"
def _http_get(client: httpx.Client, path: str) -> tuple[Any | None, str | None]:
"""Fetch a JSON endpoint; return (data, error_message)."""
try:
response = client.get(path)
except httpx.RequestError as err:
return None, f"RabbitMQ request failed: {err}"
if response.status_code == 401:
return None, "RabbitMQ authentication failed (check username/password)."
if response.status_code == 404:
if path == "/api/overview":
return None, (
"RabbitMQ Management API not found — enable the "
"`rabbitmq_management` plugin on the broker: "
"`rabbitmq-plugins enable rabbitmq_management`."
)
return None, f"RabbitMQ endpoint not found: {path}"
if response.status_code >= 400:
return None, (
f"RabbitMQ management API returned HTTP {response.status_code}: {response.text[:200]}"
)
try:
return response.json(), None
except ValueError as err:
return None, f"RabbitMQ management API returned non-JSON body: {err}"
def validate_rabbitmq_config(config: RabbitMQConfig) -> RabbitMQValidationResult:
"""Validate RabbitMQ management API reachability with a lightweight call."""
if not config.host or not config.username:
return RabbitMQValidationResult(ok=False, detail="RabbitMQ host and username are required.")
try:
with _get_client(config) as client:
data, err = _http_get(client, "/api/overview")
if err is not None:
return RabbitMQValidationResult(ok=False, detail=err)
if not isinstance(data, dict):
return RabbitMQValidationResult(
ok=False,
detail="RabbitMQ management API returned an unexpected response.",
)
version = str(data.get("rabbitmq_version", "unknown"))
cluster = str(data.get("cluster_name", "unknown"))
return RabbitMQValidationResult(
ok=True,
detail=(
f"Connected to RabbitMQ {version} (cluster: {cluster}, vhost: {config.vhost})."
),
)
except Exception as err:
report_validation_failure(
err,
logger=logger,
integration="rabbitmq",
method="validate_rabbitmq_config",
)
return RabbitMQValidationResult(ok=False, detail=f"RabbitMQ connection failed: {err}")
def rabbitmq_is_available(sources: dict[str, dict]) -> bool:
"""Check if RabbitMQ integration credentials are present."""
rmq = sources.get("rabbitmq", {})
return bool(rmq.get("host") and rmq.get("username"))
def rabbitmq_extract_params(sources: dict[str, dict]) -> dict[str, Any]:
"""Extract RabbitMQ credentials from resolved integrations."""
rmq = sources.get("rabbitmq", {})
return {
"host": rmq.get("host", ""),
"management_port": rmq.get("management_port", DEFAULT_RABBITMQ_MANAGEMENT_PORT),
"username": rmq.get("username", ""),
"password": rmq.get("password", ""),
"vhost": rmq.get("vhost", DEFAULT_RABBITMQ_VHOST),
"ssl": rmq.get("ssl", False),
"verify_ssl": rmq.get("verify_ssl", True),
}
# ---------------------------------------------------------------------------
# Diagnostic query functions. Each returns the standard evidence dict shape:
# {"source": "rabbitmq", "available": bool, ...diagnostic-specific keys}
# Errors return {"source": "rabbitmq", "available": False, "error": "..."}.
# ---------------------------------------------------------------------------
def _summarize_queue(queue: dict[str, Any]) -> dict[str, Any]:
stats = queue.get("message_stats") or {}
# The Management API returns these count fields as an explicit JSON ``null``
# (not just absent) for queues without live stats — e.g. a queue whose home
# node is down, or right after a broker restart before the stats DB is
# populated. ``dict.get(key, 0)`` only substitutes the default when the key
# is missing, so ``null`` would flow through as ``None`` and crash the
# backlog sort below (``None + None``). Coerce with ``or 0``, mirroring the
# ``message_stats or {}`` guard above.
return {
"name": queue.get("name", ""),
"vhost": queue.get("vhost", ""),
"state": queue.get("state", "unknown"),
"messages_ready": queue.get("messages_ready") or 0,
"messages_unacknowledged": queue.get("messages_unacknowledged") or 0,
"messages_total": queue.get("messages") or 0,
"messages_persistent": queue.get("messages_persistent") or 0,
"consumers": queue.get("consumers") or 0,
"consumer_utilisation": queue.get("consumer_utilisation"),
"memory_bytes": queue.get("memory") or 0,
"publish_rate": (stats.get("publish_details") or {}).get("rate", 0.0),
"deliver_rate": (stats.get("deliver_get_details") or {}).get("rate", 0.0),
"ack_rate": (stats.get("ack_details") or {}).get("rate", 0.0),
}
def get_queue_backlog(
config: RabbitMQConfig,
max_results: int | None = None,
) -> dict[str, Any]:
"""Return the top N queues ranked by backlog (unacked + ready)."""
if not config.is_configured:
return _error_evidence("Not configured.")
effective_limit = min(max_results or config.max_results, config.max_results)
try:
with _get_client(config) as client:
path = _vhost_path("/api/queues", config.vhost)
data, err = _http_get(client, path)
if err is not None:
return _error_evidence(err)
if not isinstance(data, list):
return _error_evidence(f"Unexpected {path} response.")
summarized = [_summarize_queue(q) for q in data]
summarized.sort(
key=lambda q: q["messages_ready"] + q["messages_unacknowledged"],
reverse=True,
)
truncated = summarized[:effective_limit]
return {
"source": "rabbitmq",
"available": True,
"total_queues": len(summarized),
"returned": len(truncated),
"queues": truncated,
}
except Exception as err:
report_validation_failure(
err,
logger=logger,
integration="rabbitmq",
method="get_queue_backlog",
)
return _error_evidence(str(err))
def get_consumer_health(
config: RabbitMQConfig,
max_results: int | None = None,
) -> dict[str, Any]:
"""Return consumer-level diagnostics across vhosts."""
if not config.is_configured:
return _error_evidence("Not configured.")
effective_limit = min(max_results or config.max_results, config.max_results)
try:
with _get_client(config) as client:
path = _vhost_path("/api/consumers", config.vhost)
data, err = _http_get(client, path)
if err is not None:
return _error_evidence(err)
if not isinstance(data, list):
return _error_evidence(f"Unexpected {path} response.")
consumers = []
for entry in data[:effective_limit]:
queue = entry.get("queue") or {}
channel = entry.get("channel_details") or {}
consumers.append(
{
"queue": queue.get("name", ""),
"vhost": queue.get("vhost", ""),
"consumer_tag": entry.get("consumer_tag", ""),
"ack_required": bool(entry.get("ack_required", True)),
"prefetch_count": entry.get("prefetch_count", 0),
"active": entry.get("active", True),
"channel_name": channel.get("name", ""),
"connection_name": channel.get("connection_name", ""),
"peer_host": channel.get("peer_host", ""),
}
)
return {
"source": "rabbitmq",
"available": True,
"total_consumers": len(data),
"returned": len(consumers),
"consumers": consumers,
}
except Exception as err:
report_validation_failure(
err,
logger=logger,
integration="rabbitmq",
method="get_consumer_health",
)
return _error_evidence(str(err))
def get_broker_overview(config: RabbitMQConfig) -> dict[str, Any]:
"""Return cluster-wide overview + alarm status."""
if not config.is_configured:
return _error_evidence("Not configured.")
try:
with _get_client(config) as client:
overview, err = _http_get(client, "/api/overview")
if err is not None:
return _error_evidence(err)
if not isinstance(overview, dict):
return _error_evidence("Unexpected /api/overview response.")
totals = overview.get("queue_totals") or {}
msg_stats = overview.get("message_stats") or {}
object_totals = overview.get("object_totals") or {}
# /api/healthchecks/alarms returns HTTP 503 (not 200) when alarms
# are active, so we can't use _http_get which treats >=400 as error.
alarm_payload: dict[str, Any] = {"ok": False, "detail": "unknown"}
try:
alarm_resp = client.get("/api/healthchecks/alarms")
except httpx.RequestError as exc:
alarm_payload = {"ok": False, "detail": str(exc)}
else:
if alarm_resp.status_code == 200:
alarm_payload = {"ok": True, "detail": "ok"}
elif alarm_resp.status_code in (401, 403):
alarm_payload = {
"ok": None,
"detail": "alarm status unknown (insufficient permissions)",
}
elif alarm_resp.status_code == 404:
alarm_payload = {
"ok": None,
"detail": "alarm endpoint not available on this broker version",
}
else:
# 503 = alarms active; parse the structured reason.
try:
alarm_body = alarm_resp.json()
alarm_payload = {
"ok": False,
"detail": str(alarm_body.get("reason", alarm_resp.text[:200])),
}
except ValueError:
alarm_payload = {
"ok": False,
"detail": alarm_resp.text[:200],
}
return {
"source": "rabbitmq",
"available": True,
"cluster_name": overview.get("cluster_name", ""),
"rabbitmq_version": overview.get("rabbitmq_version", ""),
"erlang_version": overview.get("erlang_version", ""),
"messages_ready": totals.get("messages_ready", 0),
"messages_unacknowledged": totals.get("messages_unacknowledged", 0),
"messages_total": totals.get("messages", 0),
"publish_rate": (msg_stats.get("publish_details") or {}).get("rate", 0.0),
"deliver_rate": (msg_stats.get("deliver_get_details") or {}).get("rate", 0.0),
"queues": object_totals.get("queues", 0),
"consumers": object_totals.get("consumers", 0),
"connections": object_totals.get("connections", 0),
"channels": object_totals.get("channels", 0),
"alarms": alarm_payload,
}
except Exception as err:
report_validation_failure(
err,
logger=logger,
integration="rabbitmq",
method="get_broker_overview",
)
return _error_evidence(str(err))
def get_node_health(config: RabbitMQConfig) -> dict[str, Any]:
"""Return per-node resource + partition state (cluster diagnostics)."""
if not config.is_configured:
return _error_evidence("Not configured.")
try:
with _get_client(config) as client:
data, err = _http_get(client, "/api/nodes")
if err is not None:
return _error_evidence(err)
if not isinstance(data, list):
return _error_evidence("Unexpected /api/nodes response.")
nodes = []
for node in data:
nodes.append(
{
"name": node.get("name", ""),
"running": bool(node.get("running", False)),
"type": node.get("type", ""),
"mem_used_bytes": node.get("mem_used", 0),
"mem_limit_bytes": node.get("mem_limit", 0),
"mem_alarm": bool(node.get("mem_alarm", False)),
"disk_free_bytes": node.get("disk_free", 0),
"disk_free_limit_bytes": node.get("disk_free_limit", 0),
"disk_free_alarm": bool(node.get("disk_free_alarm", False)),
"fd_used": node.get("fd_used", 0),
"fd_total": node.get("fd_total", 0),
"sockets_used": node.get("sockets_used", 0),
"sockets_total": node.get("sockets_total", 0),
"proc_used": node.get("proc_used", 0),
"proc_total": node.get("proc_total", 0),
"partitions": list(node.get("partitions") or []),
}
)
any_partitioned = any(n["partitions"] for n in nodes)
return {
"source": "rabbitmq",
"available": True,
"node_count": len(nodes),
"any_partitioned": any_partitioned,
"nodes": nodes,
}
except Exception as err:
report_validation_failure(
err,
logger=logger,
integration="rabbitmq",
method="get_node_health",
)
return _error_evidence(str(err))
def get_connection_stats(
config: RabbitMQConfig,
max_results: int | None = None,
) -> dict[str, Any]:
"""Return active connection metadata sorted by receive rate."""
if not config.is_configured:
return _error_evidence("Not configured.")
effective_limit = min(max_results or config.max_results, config.max_results)
try:
with _get_client(config) as client:
# /api/connections has no vhost-scoped variant; filter client-side.
data, err = _http_get(client, "/api/connections")
if err is not None:
return _error_evidence(err)
if not isinstance(data, list):
return _error_evidence("Unexpected /api/connections response.")
connections = []
for conn in data:
if conn.get("vhost", "/") != config.vhost:
continue
recv_oct = conn.get("recv_oct_details") or {}
send_oct = conn.get("send_oct_details") or {}
connections.append(
{
"name": conn.get("name", ""),
"user": conn.get("user", ""),
"vhost": conn.get("vhost", ""),
"state": conn.get("state", "unknown"),
"protocol": conn.get("protocol", ""),
"channels": conn.get("channels", 0),
"peer_host": conn.get("peer_host", ""),
"peer_port": conn.get("peer_port", 0),
"ssl": bool(conn.get("ssl", False)),
"recv_rate_bytes_per_sec": recv_oct.get("rate", 0.0),
"send_rate_bytes_per_sec": send_oct.get("rate", 0.0),
}
)
connections.sort(key=lambda c: c["recv_rate_bytes_per_sec"], reverse=True)
truncated = connections[:effective_limit]
return {
"source": "rabbitmq",
"available": True,
"broker_total_connections": len(data),
"vhost_connections": len(connections),
"returned": len(truncated),
"connections": truncated,
}
except Exception as err:
report_validation_failure(
err,
logger=logger,
integration="rabbitmq",
method="get_connection_stats",
)
return _error_evidence(str(err))
__all__ = [
"DEFAULT_RABBITMQ_MANAGEMENT_PORT",
"DEFAULT_RABBITMQ_VHOST",
"RabbitMQConfig",
"RabbitMQValidationResult",
"build_rabbitmq_config",
"get_broker_overview",
"get_connection_stats",
"get_consumer_health",
"get_node_health",
"get_queue_backlog",
"rabbitmq_config_from_env",
"rabbitmq_extract_params",
"rabbitmq_is_available",
"validate_rabbitmq_config",
]
def classify(
credentials: dict[str, Any], record_id: str
) -> tuple[RabbitMQConfig | None, str | None]:
try:
cfg = build_rabbitmq_config(
{
"host": credentials.get("host", ""),
"management_port": credentials.get("management_port", 15672),
"username": credentials.get("username", ""),
"password": credentials.get("password", ""),
"vhost": credentials.get("vhost", "/"),
"ssl": credentials.get("ssl", False),
"verify_ssl": credentials.get("verify_ssl", True),
"integration_id": record_id,
}
)
except Exception as exc:
report_classify_failure(exc, logger=logger, integration="rabbitmq", record_id=record_id)
return None, None
if cfg.host and cfg.username:
return cfg, "rabbitmq"
return None, None