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

486 lines
17 KiB
Python

"""Shared MongoDB integration helpers.
Provides configuration, connectivity validation, and read-only diagnostic
queries for MongoDB instances. All operations are production-safe: read-only,
timeouts enforced, result sizes capped.
"""
from __future__ import annotations
import logging
import os
from dataclasses import dataclass
from typing import Any
from pydantic import Field, field_validator
from config.strict_config import StrictConfigModel
from integrations._validation_helpers import report_classify_failure, report_validation_failure
logger = logging.getLogger(__name__)
DEFAULT_MONGODB_AUTH_SOURCE = "admin"
DEFAULT_MONGODB_TIMEOUT_MS = 5000
DEFAULT_MONGODB_MAX_RESULTS = 50
class MongoDBConfig(StrictConfigModel):
"""Normalized MongoDB connection settings."""
connection_string: str = ""
database: str = ""
auth_source: str = DEFAULT_MONGODB_AUTH_SOURCE
tls: bool = True
timeout_seconds: float = Field(default=10.0, gt=0)
max_results: int = Field(default=DEFAULT_MONGODB_MAX_RESULTS, gt=0, le=200)
integration_id: str = ""
@field_validator("connection_string", mode="before")
@classmethod
def _normalize_connection_string(cls, value: Any) -> str:
return str(value or "").strip()
@field_validator("database", mode="before")
@classmethod
def _normalize_database(cls, value: Any) -> str:
return str(value or "").strip()
@field_validator("auth_source", mode="before")
@classmethod
def _normalize_auth_source(cls, value: Any) -> str:
normalized = str(value or DEFAULT_MONGODB_AUTH_SOURCE).strip()
return normalized or DEFAULT_MONGODB_AUTH_SOURCE
@property
def is_configured(self) -> bool:
return bool(self.connection_string)
@dataclass(frozen=True)
class MongoDBValidationResult:
"""Result of validating a MongoDB integration."""
ok: bool
detail: str
def build_mongodb_config(raw: dict[str, Any] | None) -> MongoDBConfig:
"""Build a normalized MongoDB config object from env/store data."""
return MongoDBConfig.model_validate(raw or {})
def mongodb_config_from_env() -> MongoDBConfig | None:
"""Load a MongoDB config from env vars."""
connection_string = os.getenv("MONGODB_CONNECTION_STRING", "").strip()
if not connection_string:
return None
return build_mongodb_config(
{
"connection_string": connection_string,
"database": os.getenv("MONGODB_DATABASE", "").strip(),
"auth_source": os.getenv("MONGODB_AUTH_SOURCE", DEFAULT_MONGODB_AUTH_SOURCE).strip(),
"tls": os.getenv("MONGODB_TLS", "true").strip().lower() in ("true", "1", "yes"),
}
)
def _get_client(config: MongoDBConfig) -> Any:
"""Create a pymongo MongoClient from config. Caller must close."""
from pymongo import MongoClient
return MongoClient(
config.connection_string,
authSource=config.auth_source,
tls=config.tls,
serverSelectionTimeoutMS=DEFAULT_MONGODB_TIMEOUT_MS,
connectTimeoutMS=DEFAULT_MONGODB_TIMEOUT_MS,
socketTimeoutMS=int(config.timeout_seconds * 1000),
appName="opensre",
)
def validate_mongodb_config(config: MongoDBConfig) -> MongoDBValidationResult:
"""Validate MongoDB connectivity with a lightweight ping command."""
if not config.connection_string:
return MongoDBValidationResult(ok=False, detail="MongoDB connection string is required.")
try:
client = _get_client(config)
try:
result = client.admin.command("ping")
if result.get("ok") != 1:
return MongoDBValidationResult(
ok=False, detail="MongoDB ping returned unexpected result."
)
# Get server info for version
server_info = client.server_info()
version = server_info.get("version", "unknown")
db_name = config.database or "(default)"
return MongoDBValidationResult(
ok=True,
detail=(f"Connected to MongoDB {version}; target database: {db_name}."),
)
finally:
client.close()
except Exception as err:
report_validation_failure(
err,
logger=logger,
integration="mongodb",
method="validate_mongodb_config",
)
return MongoDBValidationResult(ok=False, detail=f"MongoDB connection failed: {err}")
def mongodb_is_available(sources: dict[str, dict]) -> bool:
"""Check if MongoDB integration params are present in available sources."""
return bool(sources.get("mongodb", {}).get("connection_string"))
def mongodb_database_is_available(sources: dict[str, dict]) -> bool:
"""Check if MongoDB integration params including a database name are present.
Required for tools that operate on a specific database (profiler, collection stats).
"""
mg = sources.get("mongodb", {})
return bool(mg.get("connection_string") and mg.get("database"))
def mongodb_extract_params(sources: dict[str, dict]) -> dict[str, Any]:
"""Extract MongoDB connection params from resolved integrations.
Credentials are resolved from the integration store or environment, so the
LLM never needs to supply connection_string directly.
"""
mg = sources.get("mongodb", {})
return {
"connection_string": str(mg.get("connection_string", "")).strip(),
"database": str(mg.get("database", "")).strip(),
"auth_source": str(mg.get("auth_source", DEFAULT_MONGODB_AUTH_SOURCE)).strip(),
"tls": bool(mg.get("tls", True)),
}
def get_server_status(config: MongoDBConfig) -> dict[str, Any]:
"""Retrieve server status (connections, opcounters, memory, uptime).
Read-only: uses the ``serverStatus`` admin command.
"""
if not config.is_configured:
return {"source": "mongodb", "available": False, "error": "Not configured."}
try:
client = _get_client(config)
try:
status = client.admin.command("serverStatus")
return {
"source": "mongodb",
"available": True,
"version": status.get("version", ""),
"uptime_seconds": status.get("uptimeMillis", 0) / 1000,
"connections": {
"current": status.get("connections", {}).get("current", 0),
"available": status.get("connections", {}).get("available", 0),
"total_created": status.get("connections", {}).get("totalCreated", 0),
},
"opcounters": status.get("opcounters", {}),
"memory": {
"resident_mb": status.get("mem", {}).get("resident", 0),
"virtual_mb": status.get("mem", {}).get("virtual", 0),
},
"storage_engine": status.get("storageEngine", {}).get("name", ""),
}
finally:
client.close()
except Exception as err:
if getattr(err, "code", None) == 13:
return {
"source": "mongodb",
"available": False,
"error": "MongoDB user lacks admin privileges for serverStatus. Grant the 'clusterMonitor' role.",
}
report_validation_failure(
err,
logger=logger,
integration="mongodb",
method="get_server_status",
)
return {"source": "mongodb", "available": False, "error": str(err)}
def get_current_ops(
config: MongoDBConfig,
threshold_ms: int = 1000,
) -> dict[str, Any]:
"""Retrieve currently running operations above a duration threshold.
Read-only: uses ``currentOp`` admin command with ``microsecs_running`` filter for sub-second precision.
Results are capped at ``config.max_results``.
"""
if not config.is_configured:
return {"source": "mongodb", "available": False, "error": "Not configured."}
threshold_microsecs = max(0, threshold_ms * 1000)
try:
client = _get_client(config)
try:
result = client.admin.command(
"currentOp", {"microsecs_running": {"$gte": threshold_microsecs}}
)
ops = result.get("inprog", [])
# Cap results and strip potentially sensitive fields
capped_ops = []
for op in ops[: config.max_results]:
capped_ops.append(
{
"opid": op.get("opid"),
"op": op.get("op"),
"ns": op.get("ns", ""),
"secs_running": op.get("secs_running", 0),
"microsecs_running": op.get("microsecs_running", 0),
"desc": op.get("desc", ""),
"wait_for_lock": op.get("waitingForLock", False),
"plan_summary": op.get("planSummary", ""),
}
)
return {
"source": "mongodb",
"available": True,
"threshold_ms": threshold_ms,
"total_ops": len(ops),
"returned_ops": len(capped_ops),
"operations": capped_ops,
}
finally:
client.close()
except Exception as err:
if getattr(err, "code", None) == 13:
return {
"source": "mongodb",
"available": False,
"error": "MongoDB user lacks admin privileges for currentOp. Grant the 'clusterMonitor' role.",
}
report_validation_failure(
err,
logger=logger,
integration="mongodb",
method="get_current_ops",
)
return {"source": "mongodb", "available": False, "error": str(err)}
def get_rs_status(config: MongoDBConfig) -> dict[str, Any]:
"""Retrieve replica set status (member states, oplog lag).
Read-only: uses ``replSetGetStatus`` admin command.
Returns empty members list if the server is not part of a replica set.
"""
if not config.is_configured:
return {"source": "mongodb", "available": False, "error": "Not configured."}
try:
client = _get_client(config)
try:
rs = client.admin.command("replSetGetStatus")
members = []
for member in rs.get("members", []):
members.append(
{
"name": member.get("name", ""),
"state": member.get("stateStr", ""),
"health": member.get("health", 0),
"uptime_seconds": member.get("uptime", 0),
"optime": str(member.get("optimeDate", "")),
"last_heartbeat": str(member.get("lastHeartbeat", "")),
"ping_ms": member.get("pingMs", None),
}
)
return {
"source": "mongodb",
"available": True,
"set_name": rs.get("set", ""),
"my_state": rs.get("myState", 0),
"heartbeat_interval_ms": rs.get("heartbeatIntervalMillis", 0),
"members": members,
}
finally:
client.close()
except Exception as err:
error_str = str(err)
# Not a replica set is not an error per se
if "not running with --replSet" in error_str or "NotYetInitialized" in error_str:
return {
"source": "mongodb",
"available": True,
"set_name": "",
"members": [],
"note": "Server is not part of a replica set.",
}
if getattr(err, "code", None) == 13:
return {
"source": "mongodb",
"available": False,
"error": "MongoDB user lacks admin privileges for replSetGetStatus. Grant the 'clusterMonitor' role.",
}
report_validation_failure(
err,
logger=logger,
integration="mongodb",
method="get_rs_status",
)
return {"source": "mongodb", "available": False, "error": error_str}
def get_profiler_data(
config: MongoDBConfig,
threshold_ms: int = 100,
limit: int | None = None,
) -> dict[str, Any]:
"""Retrieve slow query data from the system.profile collection.
Read-only: reads ``system.profile``. Returns empty results when profiling
is not enabled (level 0). Results capped at ``config.max_results``.
"""
if not config.is_configured:
return {"source": "mongodb", "available": False, "error": "Not configured."}
if not config.database:
return {
"source": "mongodb",
"available": False,
"error": "Database name is required for profiler data.",
}
effective_limit = min(limit or config.max_results, config.max_results)
try:
client = _get_client(config)
try:
db = client[config.database]
# Check profiling level
profile_status = db.command("profile", -1)
profiling_level = profile_status.get("was", 0)
if profiling_level == 0:
return {
"source": "mongodb",
"available": True,
"profiling_level": 0,
"note": (
"Profiling is disabled on this database. "
"Enable it with db.setProfilingLevel(1) for slow queries "
"or db.setProfilingLevel(2) for all queries."
),
"entries": [],
}
cursor = (
db["system.profile"]
.find({"millis": {"$gte": threshold_ms}})
.sort("ts", -1)
.limit(effective_limit)
)
entries = []
for doc in cursor:
entries.append(
{
"op": doc.get("op", ""),
"ns": doc.get("ns", ""),
"millis": doc.get("millis", 0),
"ts": str(doc.get("ts", "")),
"plan_summary": doc.get("planSummary", ""),
"docs_examined": doc.get("docsExamined", 0),
"keys_examined": doc.get("keysExamined", 0),
"n_returned": doc.get("nreturned", 0),
"response_length": doc.get("responseLength", 0),
}
)
return {
"source": "mongodb",
"available": True,
"profiling_level": profiling_level,
"threshold_ms": threshold_ms,
"total_entries": len(entries),
"entries": entries,
}
finally:
client.close()
except Exception as err:
report_validation_failure(
err,
logger=logger,
integration="mongodb",
method="get_profiler_data",
)
return {"source": "mongodb", "available": False, "error": str(err)}
def get_collection_stats(
config: MongoDBConfig,
collection: str,
) -> dict[str, Any]:
"""Retrieve statistics for a specific collection.
Read-only: uses the ``collStats`` command.
"""
if not config.is_configured:
return {"source": "mongodb", "available": False, "error": "Not configured."}
if not config.database:
return {
"source": "mongodb",
"available": False,
"error": "Database name is required for collection stats.",
}
if not collection:
return {
"source": "mongodb",
"available": False,
"error": "Collection name is required.",
}
try:
client = _get_client(config)
try:
db = client[config.database]
stats = db.command("collStats", collection)
return {
"source": "mongodb",
"available": True,
"ns": stats.get("ns", ""),
"count": stats.get("count", 0),
"size_bytes": stats.get("size", 0),
"avg_obj_size_bytes": stats.get("avgObjSize", 0),
"storage_size_bytes": stats.get("storageSize", 0),
"total_index_size_bytes": stats.get("totalIndexSize", 0),
"index_count": stats.get("nindexes", 0),
"capped": stats.get("capped", False),
}
finally:
client.close()
except Exception as err:
report_validation_failure(
err,
logger=logger,
integration="mongodb",
method="get_collection_stats",
)
return {"source": "mongodb", "available": False, "error": str(err)}
def classify(
credentials: dict[str, Any], record_id: str
) -> tuple[MongoDBConfig | None, str | None]:
try:
cfg = build_mongodb_config(
{
"connection_string": credentials.get("connection_string", ""),
"database": credentials.get("database", ""),
"auth_source": credentials.get("auth_source", "admin"),
"tls": credentials.get("tls", True),
}
)
except Exception as exc:
report_classify_failure(exc, logger=logger, integration="mongodb", record_id=record_id)
return None, None
if cfg.connection_string:
return cfg, "mongodb"
return None, None