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
483 lines
16 KiB
Python
483 lines
16 KiB
Python
"""Shared MariaDB integration helpers.
|
|
|
|
Provides configuration, connectivity validation, and read-only diagnostic
|
|
queries for MariaDB 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 integrations._relational import RelationalConfigBase, env_bool, env_str
|
|
from integrations._validation_helpers import report_classify_failure, report_validation_failure
|
|
from platform.common.coercion import safe_int
|
|
from platform.common.truncation import truncate
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
DEFAULT_MARIADB_PORT = 3306
|
|
DEFAULT_MARIADB_TIMEOUT_S = 5
|
|
DEFAULT_MARIADB_MAX_RESULTS = 50
|
|
_QUERY_TRUNCATE_LEN = 200
|
|
|
|
|
|
class MariaDBConfig(RelationalConfigBase):
|
|
"""Normalized MariaDB connection settings."""
|
|
|
|
host: str = ""
|
|
port: int = DEFAULT_MARIADB_PORT
|
|
database: str = ""
|
|
username: str = ""
|
|
password: str = ""
|
|
ssl: bool = True
|
|
timeout_seconds: int = Field(default=DEFAULT_MARIADB_TIMEOUT_S, gt=0)
|
|
max_results: int = Field(default=DEFAULT_MARIADB_MAX_RESULTS, gt=0, le=200)
|
|
integration_id: str = ""
|
|
|
|
@field_validator("password", mode="before")
|
|
@classmethod
|
|
def _normalize_password(cls, value: Any) -> str:
|
|
return str(value or "").strip()
|
|
|
|
@field_validator("port", mode="before")
|
|
@classmethod
|
|
def _normalize_port(cls, value: Any) -> int:
|
|
return safe_int(value, DEFAULT_MARIADB_PORT)
|
|
|
|
@property
|
|
def is_configured(self) -> bool:
|
|
return bool(self.host and self.database)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class MariaDBValidationResult:
|
|
"""Result of validating a MariaDB integration."""
|
|
|
|
ok: bool
|
|
detail: str
|
|
|
|
|
|
def build_mariadb_config(raw: dict[str, Any] | None) -> MariaDBConfig:
|
|
"""Build a normalized MariaDB config object from env/store data."""
|
|
return MariaDBConfig.model_validate(raw or {})
|
|
|
|
|
|
def mariadb_config_from_env() -> MariaDBConfig | None:
|
|
"""Load a MariaDB config from env vars."""
|
|
host = env_str("MARIADB_HOST")
|
|
if not host:
|
|
return None
|
|
return build_mariadb_config(
|
|
{
|
|
"host": host,
|
|
"port": env_str("MARIADB_PORT", str(DEFAULT_MARIADB_PORT)),
|
|
"database": env_str("MARIADB_DATABASE"),
|
|
"username": env_str("MARIADB_USERNAME"),
|
|
"password": os.getenv("MARIADB_PASSWORD", "").strip(),
|
|
"ssl": env_bool("MARIADB_SSL", True),
|
|
}
|
|
)
|
|
|
|
|
|
def _get_connection(config: MariaDBConfig) -> Any:
|
|
"""Create a pymysql connection from config. Caller must close."""
|
|
import ssl as _ssl
|
|
|
|
import pymysql
|
|
|
|
ssl_ctx: Any = None
|
|
if config.ssl:
|
|
ssl_ctx = _ssl.create_default_context()
|
|
|
|
connect_timeout = max(1, int(config.timeout_seconds))
|
|
|
|
return pymysql.connect(
|
|
host=config.host,
|
|
port=config.port,
|
|
database=config.database,
|
|
user=config.username,
|
|
password=config.password,
|
|
ssl=ssl_ctx,
|
|
connect_timeout=connect_timeout,
|
|
read_timeout=int(config.timeout_seconds),
|
|
write_timeout=int(config.timeout_seconds),
|
|
charset="utf8mb4",
|
|
autocommit=True,
|
|
)
|
|
|
|
|
|
def validate_mariadb_config(config: MariaDBConfig) -> MariaDBValidationResult:
|
|
"""Validate MariaDB connectivity with a lightweight version query."""
|
|
if not config.host or not config.database:
|
|
return MariaDBValidationResult(ok=False, detail="MariaDB host and database are required.")
|
|
|
|
try:
|
|
conn = _get_connection(config)
|
|
try:
|
|
with conn.cursor() as cur:
|
|
cur.execute("SELECT VERSION()")
|
|
row = cur.fetchone()
|
|
version = row[0] if row else "unknown"
|
|
db_name = config.database or "(default)"
|
|
return MariaDBValidationResult(
|
|
ok=True,
|
|
detail=f"Connected to MariaDB {version}; target database: {db_name}.",
|
|
)
|
|
finally:
|
|
conn.close()
|
|
except Exception as err:
|
|
report_validation_failure(
|
|
err,
|
|
logger=logger,
|
|
integration="mariadb",
|
|
method="validate_mariadb_config",
|
|
)
|
|
return MariaDBValidationResult(ok=False, detail=f"MariaDB connection failed: {err}")
|
|
|
|
|
|
def mariadb_is_available(sources: dict[str, dict]) -> bool:
|
|
"""Check if MariaDB integration credentials are present."""
|
|
mdb = sources.get("mariadb", {})
|
|
return bool(mdb.get("host") and mdb.get("database"))
|
|
|
|
|
|
def mariadb_extract_params(sources: dict[str, dict]) -> dict[str, Any]:
|
|
"""Extract MariaDB credentials from resolved integrations."""
|
|
mdb = sources.get("mariadb", {})
|
|
return {
|
|
"host": mdb.get("host", ""),
|
|
"database": mdb.get("database", ""),
|
|
"username": mdb.get("username", ""),
|
|
"password": mdb.get("password", ""),
|
|
"port": mdb.get("port", DEFAULT_MARIADB_PORT),
|
|
"ssl": mdb.get("ssl", True),
|
|
}
|
|
|
|
|
|
def get_process_list(
|
|
config: MariaDBConfig,
|
|
max_results: int | None = None,
|
|
) -> dict[str, Any]:
|
|
"""Retrieve active threads from information_schema.PROCESSLIST.
|
|
|
|
Read-only: queries the ``information_schema.PROCESSLIST`` table.
|
|
Excludes sleeping connections. Results capped at ``config.max_results``.
|
|
"""
|
|
if not config.is_configured:
|
|
return {"source": "mariadb", "available": False, "error": "Not configured."}
|
|
|
|
effective_limit = min(max_results or config.max_results, config.max_results)
|
|
try:
|
|
conn = _get_connection(config)
|
|
try:
|
|
with conn.cursor() as cur:
|
|
cur.execute(
|
|
"""
|
|
SELECT ID, USER, HOST, DB, COMMAND, TIME, STATE, INFO
|
|
FROM information_schema.PROCESSLIST
|
|
WHERE COMMAND != 'Sleep'
|
|
AND ID != CONNECTION_ID()
|
|
ORDER BY TIME DESC
|
|
LIMIT %s
|
|
""",
|
|
(effective_limit,),
|
|
)
|
|
processes = []
|
|
for row in cur.fetchall():
|
|
processes.append(
|
|
{
|
|
"id": row[0],
|
|
"user": row[1],
|
|
"host": row[2],
|
|
"database": row[3] or "",
|
|
"command": row[4],
|
|
"time_secs": row[5] or 0,
|
|
"state": row[6] or "",
|
|
"query": truncate(row[7] or "", _QUERY_TRUNCATE_LEN),
|
|
}
|
|
)
|
|
return {
|
|
"source": "mariadb",
|
|
"available": True,
|
|
"total_processes": len(processes),
|
|
"processes": processes,
|
|
}
|
|
finally:
|
|
conn.close()
|
|
except Exception as err:
|
|
report_validation_failure(
|
|
err,
|
|
logger=logger,
|
|
integration="mariadb",
|
|
method="get_process_list",
|
|
)
|
|
return {"source": "mariadb", "available": False, "error": str(err)}
|
|
|
|
|
|
def get_global_status(config: MariaDBConfig) -> dict[str, Any]:
|
|
"""Retrieve key server metrics from SHOW GLOBAL STATUS.
|
|
|
|
Read-only: uses ``SHOW GLOBAL STATUS``.
|
|
Returns a curated subset of important metrics.
|
|
"""
|
|
if not config.is_configured:
|
|
return {"source": "mariadb", "available": False, "error": "Not configured."}
|
|
|
|
_IMPORTANT_KEYS = frozenset(
|
|
{
|
|
"Threads_connected",
|
|
"Threads_running",
|
|
"Threads_created",
|
|
"Connections",
|
|
"Max_used_connections",
|
|
"Slow_queries",
|
|
"Questions",
|
|
"Queries",
|
|
"Aborted_clients",
|
|
"Aborted_connects",
|
|
"Bytes_received",
|
|
"Bytes_sent",
|
|
"Innodb_buffer_pool_reads",
|
|
"Innodb_buffer_pool_read_requests",
|
|
"Innodb_row_lock_waits",
|
|
"Innodb_row_lock_time",
|
|
"Innodb_deadlocks",
|
|
"Uptime",
|
|
}
|
|
)
|
|
|
|
try:
|
|
conn = _get_connection(config)
|
|
try:
|
|
with conn.cursor() as cur:
|
|
cur.execute("SHOW GLOBAL STATUS")
|
|
all_status = {row[0]: row[1] for row in cur.fetchall()}
|
|
metrics = {k: all_status[k] for k in sorted(_IMPORTANT_KEYS) if k in all_status}
|
|
return {
|
|
"source": "mariadb",
|
|
"available": True,
|
|
"metrics": metrics,
|
|
}
|
|
finally:
|
|
conn.close()
|
|
except Exception as err:
|
|
report_validation_failure(
|
|
err,
|
|
logger=logger,
|
|
integration="mariadb",
|
|
method="get_global_status",
|
|
)
|
|
return {"source": "mariadb", "available": False, "error": str(err)}
|
|
|
|
|
|
def get_innodb_status(config: MariaDBConfig) -> dict[str, Any]:
|
|
"""Retrieve InnoDB engine status.
|
|
|
|
Read-only: uses ``SHOW ENGINE INNODB STATUS``.
|
|
The output text is truncated to prevent excessive result sizes.
|
|
"""
|
|
if not config.is_configured:
|
|
return {"source": "mariadb", "available": False, "error": "Not configured."}
|
|
|
|
_MAX_STATUS_LEN = 4000
|
|
|
|
try:
|
|
conn = _get_connection(config)
|
|
try:
|
|
with conn.cursor() as cur:
|
|
cur.execute("SHOW ENGINE INNODB STATUS")
|
|
row = cur.fetchone()
|
|
status_text = row[2] if row and len(row) > 2 else ""
|
|
if len(status_text) > _MAX_STATUS_LEN:
|
|
status_text = status_text[:_MAX_STATUS_LEN] + "\n... (truncated)"
|
|
return {
|
|
"source": "mariadb",
|
|
"available": True,
|
|
"innodb_status": status_text,
|
|
}
|
|
finally:
|
|
conn.close()
|
|
except Exception as err:
|
|
report_validation_failure(
|
|
err,
|
|
logger=logger,
|
|
integration="mariadb",
|
|
method="get_innodb_status",
|
|
)
|
|
return {"source": "mariadb", "available": False, "error": str(err)}
|
|
|
|
|
|
def get_slow_queries(
|
|
config: MariaDBConfig,
|
|
max_results: int | None = None,
|
|
) -> dict[str, Any]:
|
|
"""Retrieve slow queries from performance_schema.
|
|
|
|
Read-only: queries ``events_statements_summary_by_digest``.
|
|
Returns an informative message if performance_schema is not available.
|
|
Results ordered by average wait time descending.
|
|
"""
|
|
if not config.is_configured:
|
|
return {"source": "mariadb", "available": False, "error": "Not configured."}
|
|
|
|
effective_limit = min(max_results or config.max_results, config.max_results)
|
|
try:
|
|
conn = _get_connection(config)
|
|
try:
|
|
with conn.cursor() as cur:
|
|
# Check if performance_schema is enabled
|
|
cur.execute("SELECT @@performance_schema")
|
|
row = cur.fetchone()
|
|
if not row or not row[0]:
|
|
return {
|
|
"source": "mariadb",
|
|
"available": True,
|
|
"note": "performance_schema is disabled. Enable it in my.cnf to collect slow query data.",
|
|
"queries": [],
|
|
}
|
|
|
|
cur.execute(
|
|
"""
|
|
SELECT DIGEST_TEXT, COUNT_STAR,
|
|
ROUND(AVG_TIMER_WAIT / 1000000000, 4) AS avg_time_ms,
|
|
ROUND(SUM_TIMER_WAIT / 1000000000, 4) AS total_time_ms,
|
|
SUM_ROWS_EXAMINED, SUM_ROWS_SENT
|
|
FROM performance_schema.events_statements_summary_by_digest
|
|
WHERE SCHEMA_NAME = %s
|
|
ORDER BY AVG_TIMER_WAIT DESC
|
|
LIMIT %s
|
|
""",
|
|
(config.database, effective_limit),
|
|
)
|
|
queries = []
|
|
for row in cur.fetchall():
|
|
queries.append(
|
|
{
|
|
"digest_text": truncate(row[0] or "", _QUERY_TRUNCATE_LEN),
|
|
"count": row[1] or 0,
|
|
"avg_time_ms": float(row[2]) if row[2] is not None else 0,
|
|
"total_time_ms": float(row[3]) if row[3] is not None else 0,
|
|
"rows_examined": row[4] or 0,
|
|
"rows_sent": row[5] or 0,
|
|
}
|
|
)
|
|
return {
|
|
"source": "mariadb",
|
|
"available": True,
|
|
"total_queries": len(queries),
|
|
"queries": queries,
|
|
}
|
|
finally:
|
|
conn.close()
|
|
except Exception as err:
|
|
report_validation_failure(
|
|
err,
|
|
logger=logger,
|
|
integration="mariadb",
|
|
method="get_slow_queries",
|
|
)
|
|
return {"source": "mariadb", "available": False, "error": str(err)}
|
|
|
|
|
|
def get_replication_status(config: MariaDBConfig) -> dict[str, Any]:
|
|
"""Retrieve replication status.
|
|
|
|
Read-only: uses ``SHOW ALL SLAVES STATUS`` (MariaDB multi-source
|
|
replication syntax), falling back to ``SHOW SLAVE STATUS`` for
|
|
older versions.
|
|
"""
|
|
if not config.is_configured:
|
|
return {"source": "mariadb", "available": False, "error": "Not configured."}
|
|
|
|
try:
|
|
conn = _get_connection(config)
|
|
try:
|
|
with conn.cursor() as cur:
|
|
rows: list[Any] = []
|
|
columns: list[str] = []
|
|
# MariaDB-specific multi-source syntax first, then legacy
|
|
for stmt in ("SHOW ALL SLAVES STATUS", "SHOW SLAVE STATUS"):
|
|
try:
|
|
cur.execute(stmt)
|
|
rows = list(cur.fetchall())
|
|
if cur.description:
|
|
columns = [d[0] for d in cur.description]
|
|
break
|
|
except Exception as stmt_err:
|
|
import pymysql as _pymysql
|
|
|
|
if isinstance(stmt_err, _pymysql.err.ProgrammingError):
|
|
continue
|
|
raise
|
|
|
|
if not rows:
|
|
return {
|
|
"source": "mariadb",
|
|
"available": True,
|
|
"note": "This server is not configured as a replica.",
|
|
"channels": [],
|
|
}
|
|
|
|
# Return curated subset for each replication channel
|
|
_KEYS = (
|
|
"Slave_IO_Running",
|
|
"Slave_SQL_Running",
|
|
"Seconds_Behind_Master",
|
|
"Last_Error",
|
|
"Last_Errno",
|
|
"Master_Host",
|
|
"Master_Port",
|
|
"Master_Log_File",
|
|
"Relay_Log_Space",
|
|
"Exec_Master_Log_Pos",
|
|
"Connection_name",
|
|
)
|
|
channels = []
|
|
for row in rows:
|
|
full = dict(zip(columns, row))
|
|
channels.append({k: full[k] for k in _KEYS if k in full})
|
|
return {
|
|
"source": "mariadb",
|
|
"available": True,
|
|
"channels": channels,
|
|
}
|
|
finally:
|
|
conn.close()
|
|
except Exception as err:
|
|
report_validation_failure(
|
|
err,
|
|
logger=logger,
|
|
integration="mariadb",
|
|
method="get_replication_status",
|
|
)
|
|
return {"source": "mariadb", "available": False, "error": str(err)}
|
|
|
|
|
|
def classify(
|
|
credentials: dict[str, Any], record_id: str
|
|
) -> tuple[MariaDBConfig | None, str | None]:
|
|
try:
|
|
cfg = build_mariadb_config(
|
|
{
|
|
"host": credentials.get("host", ""),
|
|
"port": credentials.get("port", 3306),
|
|
"database": credentials.get("database", ""),
|
|
"username": credentials.get("username", ""),
|
|
"password": credentials.get("password", ""),
|
|
"ssl": credentials.get("ssl", True),
|
|
"integration_id": record_id,
|
|
}
|
|
)
|
|
except Exception as exc:
|
|
report_classify_failure(exc, logger=logger, integration="mariadb", record_id=record_id)
|
|
return None, None
|
|
if cfg.host and cfg.database:
|
|
return cfg, "mariadb"
|
|
return None, None
|