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

658 lines
24 KiB
Python

"""Shared MySQL integration helpers.
Provides configuration, connectivity validation, and read-only diagnostic
queries for MySQL 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_int,
env_str,
resolve_stored_or_env_config,
)
from integrations._validation_helpers import report_classify_failure, report_validation_failure
from platform.common.truncation import truncate
logger = logging.getLogger(__name__)
DEFAULT_MYSQL_PORT = 3306
DEFAULT_MYSQL_USER = "root"
DEFAULT_MYSQL_SSL_MODE = "preferred"
DEFAULT_MYSQL_TIMEOUT_SECONDS = 10.0
DEFAULT_MYSQL_MAX_RESULTS = 50
_QUERY_TRUNCATE_LEN = 500
class MySQLConfig(RelationalConfigBase):
"""Normalized MySQL connection settings."""
host: str = ""
port: int = DEFAULT_MYSQL_PORT
database: str = ""
username: str = DEFAULT_MYSQL_USER
password: str = ""
ssl_mode: str = DEFAULT_MYSQL_SSL_MODE # preferred, required, disabled
timeout_seconds: float = Field(default=DEFAULT_MYSQL_TIMEOUT_SECONDS, gt=0)
max_results: int = Field(default=DEFAULT_MYSQL_MAX_RESULTS, gt=0, le=200)
integration_id: str = ""
@field_validator("username", mode="before")
@classmethod
def _normalize_username(cls, value: Any) -> str: # type: ignore[override]
normalized = str(value or DEFAULT_MYSQL_USER).strip()
return normalized or DEFAULT_MYSQL_USER
@field_validator("ssl_mode", mode="before")
@classmethod
def _normalize_ssl_mode(cls, value: Any) -> str:
normalized = str(value or DEFAULT_MYSQL_SSL_MODE).strip()
return normalized or DEFAULT_MYSQL_SSL_MODE
@property
def is_configured(self) -> bool:
return bool(self.host and self.database)
@dataclass(frozen=True)
class MySQLValidationResult:
"""Result of validating a MySQL integration."""
ok: bool
detail: str
def build_mysql_config(raw: dict[str, Any] | None) -> MySQLConfig:
"""Build a normalized MySQL config object from env/store data."""
return MySQLConfig.model_validate(raw or {})
def mysql_config_from_env() -> MySQLConfig | None:
"""Load a MySQL config from environment variables."""
host = env_str("MYSQL_HOST")
database = env_str("MYSQL_DATABASE")
if not host or not database:
return None
return build_mysql_config(
{
"host": host,
"port": env_int("MYSQL_PORT", DEFAULT_MYSQL_PORT),
"database": database,
"username": env_str("MYSQL_USERNAME", DEFAULT_MYSQL_USER),
"password": os.getenv("MYSQL_PASSWORD", ""),
"ssl_mode": env_str("MYSQL_SSL_MODE", DEFAULT_MYSQL_SSL_MODE),
}
)
def resolve_mysql_config(host: str, database: str, port: int = DEFAULT_MYSQL_PORT) -> MySQLConfig:
"""Build a config for the given host/database, resolving credentials from store or env.
The LLM supplies only identifying params (host, database, port).
Credentials (username, password, ssl_mode) are resolved from the stored
integration or environment variables so they never appear in tool signatures.
"""
return resolve_stored_or_env_config(
"mysql",
host=host,
database=database,
port=port,
build_config=build_mysql_config,
env_loader=mysql_config_from_env,
extra_from_credentials=lambda credentials: {
"username": credentials.get("username", DEFAULT_MYSQL_USER),
"password": credentials.get("password", ""),
"ssl_mode": credentials.get("ssl_mode", DEFAULT_MYSQL_SSL_MODE),
},
extra_from_env=lambda config: {
"username": config.username,
"password": config.password,
"ssl_mode": config.ssl_mode,
},
)
def _build_ssl_context(ssl_mode: str) -> Any:
"""Return an ssl context suitable for pymysql, or None if SSL is disabled."""
if ssl_mode == "disabled":
return None
import ssl as _ssl
ctx = _ssl.create_default_context()
if ssl_mode == "preferred":
# Allow connections to servers without trusted certificates
ctx.check_hostname = False
ctx.verify_mode = _ssl.CERT_NONE
return ctx
def _get_connection(config: MySQLConfig) -> Any:
"""Create a pymysql connection from config. Caller must close."""
import pymysql
import pymysql.cursors
connect_timeout = max(1, int(config.timeout_seconds))
ssl_ctx = _build_ssl_context(config.ssl_mode)
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,
cursorclass=pymysql.cursors.DictCursor,
)
def validate_mysql_config(config: MySQLConfig) -> MySQLValidationResult:
"""Validate MySQL connectivity with a lightweight query."""
if not config.host:
return MySQLValidationResult(ok=False, detail="MySQL host is required.")
if not config.database:
return MySQLValidationResult(ok=False, detail="MySQL database is required.")
try:
conn = _get_connection(config)
try:
with conn.cursor() as cur:
cur.execute("SELECT VERSION()")
row = cur.fetchone()
version = row["VERSION()"] if row else "unknown"
return MySQLValidationResult(
ok=True,
detail=(f"Connected to MySQL {version}; target database: {config.database}."),
)
finally:
conn.close()
except Exception as err:
report_validation_failure(
err,
logger=logger,
integration="mysql",
method="validate_mysql_config",
)
return MySQLValidationResult(ok=False, detail=f"MySQL connection failed: {err}")
def mysql_is_available(sources: dict[str, dict]) -> bool:
"""Check if MySQL integration identifying params are present."""
my = sources.get("mysql", {})
return bool(my.get("host") and my.get("database"))
def mysql_extract_params(sources: dict[str, dict]) -> dict[str, Any]:
"""Extract MySQL identifying params (host, database, port) from resolved integrations.
Credentials (username, password, ssl_mode) are resolved internally by
``resolve_mysql_config`` from the integration store or environment, so
they never appear in tool signatures and are never seen by the LLM.
"""
my = sources.get("mysql", {})
return {
"host": str(my.get("host", "")).strip(),
"database": str(my.get("database", "")).strip(),
"port": int(my.get("port") or DEFAULT_MYSQL_PORT),
}
def get_server_status(config: MySQLConfig) -> dict[str, Any]:
"""Retrieve server status (connections, uptime, InnoDB buffer pool metrics).
Read-only: uses SHOW GLOBAL STATUS and SHOW VARIABLES.
"""
if not config.is_configured:
return {"source": "mysql", "available": False, "error": "Not configured."}
_STATUS_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",
}
)
_VARIABLE_KEYS = frozenset(
{
"max_connections",
"innodb_buffer_pool_size",
"version",
"version_comment",
}
)
try:
conn = _get_connection(config)
try:
with conn.cursor() as cur:
cur.execute("SHOW GLOBAL STATUS")
all_status = {row["Variable_name"]: row["Value"] for row in cur.fetchall()}
metrics = {k: all_status[k] for k in sorted(_STATUS_KEYS) if k in all_status}
cur.execute(
"SHOW VARIABLES WHERE Variable_name IN ("
+ ", ".join(f"'{k}'" for k in sorted(_VARIABLE_KEYS))
+ ")"
)
variables = {row["Variable_name"]: row["Value"] for row in cur.fetchall()}
# Calculate InnoDB buffer pool hit ratio
pool_reads = int(all_status.get("Innodb_buffer_pool_reads", 0))
pool_requests = int(all_status.get("Innodb_buffer_pool_read_requests", 0))
pool_hit_ratio = 0.0
if pool_requests > 0:
pool_hit_ratio = round((1 - pool_reads / pool_requests) * 100, 2)
return {
"source": "mysql",
"available": True,
"version": variables.get("version", "unknown"),
"version_comment": variables.get("version_comment", ""),
"uptime_seconds": int(all_status.get("Uptime", 0)),
"connections": {
"current": int(all_status.get("Threads_connected", 0)),
"running": int(all_status.get("Threads_running", 0)),
"max": int(variables.get("max_connections", 0)),
"max_used": int(all_status.get("Max_used_connections", 0)),
"aborted_clients": int(all_status.get("Aborted_clients", 0)),
"aborted_connects": int(all_status.get("Aborted_connects", 0)),
},
"queries": {
"total": int(all_status.get("Questions", 0)),
"slow": int(all_status.get("Slow_queries", 0)),
},
"innodb": {
"buffer_pool_size_bytes": int(variables.get("innodb_buffer_pool_size", 0)),
"buffer_pool_hit_ratio_percent": pool_hit_ratio,
"row_lock_waits": int(all_status.get("Innodb_row_lock_waits", 0)),
"deadlocks": int(all_status.get("Innodb_deadlocks", 0)),
},
"metrics": metrics,
}
finally:
conn.close()
except Exception as err:
report_validation_failure(
err,
logger=logger,
integration="mysql",
method="get_server_status",
)
return {"source": "mysql", "available": False, "error": str(err)}
def get_current_processes(
config: MySQLConfig,
threshold_seconds: int = 1,
) -> dict[str, Any]:
"""Retrieve currently active processes above a duration threshold.
Read-only: queries information_schema.PROCESSLIST.
Results are capped at config.max_results.
"""
if not config.is_configured:
return {"source": "mysql", "available": False, "error": "Not configured."}
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()
AND TIME >= %s
ORDER BY TIME DESC
LIMIT %s
""",
(threshold_seconds, config.max_results),
)
processes = []
for row in cur.fetchall():
processes.append(
{
"id": row["ID"],
"user": row["USER"],
"host": row["HOST"] or "",
"database": row["DB"] or "",
"command": row["COMMAND"],
"time_seconds": row["TIME"] or 0,
"state": row["STATE"] or "",
"query": truncate(row["INFO"] or "", _QUERY_TRUNCATE_LEN),
}
)
return {
"source": "mysql",
"available": True,
"threshold_seconds": threshold_seconds,
"total_processes": len(processes),
"processes": processes,
}
finally:
conn.close()
except Exception as err:
report_validation_failure(
err,
logger=logger,
integration="mysql",
method="get_current_processes",
)
return {"source": "mysql", "available": False, "error": str(err)}
def get_replication_status(config: MySQLConfig) -> dict[str, Any]:
"""Retrieve replication status (replica IO/SQL thread health, lag).
Read-only: uses SHOW REPLICA STATUS (MySQL 8.0.22+) with fallback to
SHOW SLAVE STATUS for older versions.
Returns a note if the server is not configured as a replica.
"""
if not config.is_configured:
return {"source": "mysql", "available": False, "error": "Not configured."}
# Curated fields — includes both old (Slave_*) and new (Replica_*) column names
_REPLICA_KEYS = (
"Replica_IO_Running",
"Replica_SQL_Running",
"Seconds_Behind_Source",
"Slave_IO_Running",
"Slave_SQL_Running",
"Seconds_Behind_Master",
"Last_Error",
"Last_Errno",
"Source_Host",
"Master_Host",
"Source_Port",
"Master_Port",
"Retrieved_Gtid_Set",
"Executed_Gtid_Set",
"Relay_Log_Space",
"Exec_Master_Log_Pos",
)
try:
conn = _get_connection(config)
try:
with conn.cursor() as cur:
rows: list[dict[str, Any]] = []
# MySQL 8.0.22+ uses SHOW REPLICA STATUS; older uses SHOW SLAVE STATUS
for stmt in ("SHOW REPLICA STATUS", "SHOW SLAVE STATUS"):
try:
cur.execute(stmt)
rows = list(cur.fetchall())
break
except Exception as stmt_err:
import pymysql as _pymysql
if isinstance(stmt_err, _pymysql.err.ProgrammingError):
# SHOW REPLICA STATUS not supported on MySQL < 8.0.22; try SHOW SLAVE STATUS fallback
continue
raise
if not rows:
return {
"source": "mysql",
"available": True,
"note": "This server is not configured as a replica.",
"replicas": [],
}
replicas = []
for row in rows:
replicas.append({k: row[k] for k in _REPLICA_KEYS if k in row})
return {
"source": "mysql",
"available": True,
"replica_count": len(replicas),
"replicas": replicas,
}
finally:
conn.close()
except Exception as err:
report_validation_failure(
err,
logger=logger,
integration="mysql",
method="get_replication_status",
)
return {"source": "mysql", "available": False, "error": str(err)}
def get_slow_queries(
config: MySQLConfig,
threshold_ms: float = 1000.0,
) -> dict[str, Any]:
"""Retrieve slow query statistics from performance_schema.
Read-only: queries events_statements_summary_by_digest.
Results capped at config.max_results.
Returns an informative note if performance_schema is disabled.
"""
if not config.is_configured:
return {"source": "mysql", "available": False, "error": "Not configured."}
# performance_schema timer uses picoseconds; convert threshold to picoseconds
threshold_ps = int(threshold_ms * 1_000_000_000)
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 list(row.values())[0]:
return {
"source": "mysql",
"available": True,
"performance_schema_available": False,
"note": (
"performance_schema is disabled. "
"Enable it in my.cnf to collect slow query data."
),
"queries": [],
}
cur.execute(
"""
SELECT
DIGEST_TEXT,
SCHEMA_NAME,
COUNT_STAR,
ROUND(AVG_TIMER_WAIT / 1000000000, 3) AS avg_time_ms,
ROUND(SUM_TIMER_WAIT / 1000000000, 3) AS total_time_ms,
ROUND(MIN_TIMER_WAIT / 1000000000, 3) AS min_time_ms,
ROUND(MAX_TIMER_WAIT / 1000000000, 3) AS max_time_ms,
SUM_ROWS_EXAMINED,
SUM_ROWS_SENT,
SUM_NO_INDEX_USED,
SUM_NO_GOOD_INDEX_USED
FROM performance_schema.events_statements_summary_by_digest
WHERE AVG_TIMER_WAIT >= %s
ORDER BY AVG_TIMER_WAIT DESC
LIMIT %s
""",
(threshold_ps, config.max_results),
)
queries = []
for row in cur.fetchall():
queries.append(
{
"digest_text": truncate(row["DIGEST_TEXT"] or "", _QUERY_TRUNCATE_LEN),
"schema_name": row["SCHEMA_NAME"] or "",
"count": row["COUNT_STAR"] or 0,
"avg_time_ms": float(row["avg_time_ms"])
if row["avg_time_ms"] is not None
else 0.0,
"total_time_ms": float(row["total_time_ms"])
if row["total_time_ms"] is not None
else 0.0,
"min_time_ms": float(row["min_time_ms"])
if row["min_time_ms"] is not None
else 0.0,
"max_time_ms": float(row["max_time_ms"])
if row["max_time_ms"] is not None
else 0.0,
"rows_examined": row["SUM_ROWS_EXAMINED"] or 0,
"rows_sent": row["SUM_ROWS_SENT"] or 0,
"no_index_used": row["SUM_NO_INDEX_USED"] or 0,
"no_good_index_used": row["SUM_NO_GOOD_INDEX_USED"] or 0,
}
)
return {
"source": "mysql",
"available": True,
"performance_schema_available": True,
"threshold_ms": threshold_ms,
"total_queries": len(queries),
"queries": queries,
}
finally:
conn.close()
except Exception as err:
report_validation_failure(
err,
logger=logger,
integration="mysql",
method="get_slow_queries",
)
return {"source": "mysql", "available": False, "error": str(err)}
def get_table_stats(
config: MySQLConfig,
) -> dict[str, Any]:
"""Retrieve table statistics (size, row counts) from information_schema.
Read-only: queries information_schema.TABLES for the configured database.
Results capped at config.max_results.
"""
if not config.is_configured:
return {"source": "mysql", "available": False, "error": "Not configured."}
try:
conn = _get_connection(config)
try:
with conn.cursor() as cur:
cur.execute(
"""
SELECT
TABLE_NAME,
ENGINE,
TABLE_ROWS,
ROUND(DATA_LENGTH / 1024 / 1024, 3) AS data_mb,
ROUND(INDEX_LENGTH / 1024 / 1024, 3) AS index_mb,
ROUND((DATA_LENGTH + INDEX_LENGTH) / 1024 / 1024, 3) AS total_mb,
AUTO_INCREMENT,
TABLE_COLLATION,
CREATE_TIME,
UPDATE_TIME
FROM information_schema.TABLES
WHERE TABLE_SCHEMA = %s
AND TABLE_TYPE = 'BASE TABLE'
ORDER BY (DATA_LENGTH + INDEX_LENGTH) DESC
LIMIT %s
""",
(config.database, config.max_results),
)
tables = []
for row in cur.fetchall():
tables.append(
{
"table_name": row["TABLE_NAME"],
"engine": row["ENGINE"] or "",
"row_count_estimate": row["TABLE_ROWS"] or 0,
"size": {
"data_mb": float(row["data_mb"])
if row["data_mb"] is not None
else 0.0,
"index_mb": float(row["index_mb"])
if row["index_mb"] is not None
else 0.0,
"total_mb": float(row["total_mb"])
if row["total_mb"] is not None
else 0.0,
},
"auto_increment": row["AUTO_INCREMENT"],
"collation": row["TABLE_COLLATION"] or "",
"created_at": str(row["CREATE_TIME"]) if row["CREATE_TIME"] else None,
"updated_at": str(row["UPDATE_TIME"]) if row["UPDATE_TIME"] else None,
}
)
return {
"source": "mysql",
"available": True,
"database": config.database,
"total_tables": len(tables),
"tables": tables,
}
finally:
conn.close()
except Exception as err:
report_validation_failure(
err,
logger=logger,
integration="mysql",
method="get_table_stats",
)
return {"source": "mysql", "available": False, "error": str(err)}
def classify(credentials: dict[str, Any], record_id: str) -> tuple[MySQLConfig | None, str | None]:
try:
cfg = build_mysql_config(
{
"host": credentials.get("host", ""),
"port": credentials.get("port", 3306),
"database": credentials.get("database", ""),
"username": credentials.get("username", "root"),
"password": credentials.get("password", ""),
"ssl_mode": credentials.get("ssl_mode", "preferred"),
"integration_id": record_id,
}
)
except Exception as exc:
report_classify_failure(exc, logger=logger, integration="mysql", record_id=record_id)
return None, None
if cfg.host and cfg.database:
return cfg, "mysql"
return None, None