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
486 lines
17 KiB
Python
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
|