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

710 lines
25 KiB
Python

"""Shared Redis integration helpers.
Provides configuration, connectivity validation, and read-only diagnostic
queries for Redis instances. All operations are production-safe: read-only,
timeouts enforced, result sizes capped, and key discovery uses the
non-blocking ``SCAN`` cursor.
"""
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
from integrations.config_models import RedisIntegrationConfig
from platform.common.coercion import safe_int
logger = logging.getLogger(__name__)
DEFAULT_REDIS_PORT = 6379
DEFAULT_REDIS_MAX_RESULTS = 50
DEFAULT_REDIS_TIMEOUT_SECONDS = 5.0
# Hard cap on how many keys SCAN will iterate
DEFAULT_REDIS_SCAN_LIMIT = 10_000
# Per-element character cap when sampling list/queue values, so a queue of
# large JSON payloads can never blow up the tool response.
DEFAULT_REDIS_LIST_VALUE_PREVIEW = 256
class RedisConfig(StrictConfigModel):
"""Normalized Redis connection settings."""
host: str = ""
port: int = Field(default=DEFAULT_REDIS_PORT, ge=1, le=65535)
username: str = ""
password: str = ""
db: int = Field(default=0, ge=0)
ssl: bool = False
timeout_seconds: float = Field(default=DEFAULT_REDIS_TIMEOUT_SECONDS, gt=0)
max_results: int = Field(default=DEFAULT_REDIS_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:
return str(value or "").strip()
@property
def is_configured(self) -> bool:
return bool(self.host)
@dataclass(frozen=True)
class RedisValidationResult:
"""Result of validating a Redis integration."""
ok: bool
detail: str
def build_redis_config(raw: dict[str, Any] | None) -> RedisConfig:
"""Build a normalized Redis config object from env/store data."""
return RedisConfig.model_validate(raw or {})
def redis_config_from_env() -> RedisConfig | None:
"""Load a Redis config from env vars."""
host = os.getenv("REDIS_HOST", "").strip()
if not host:
return None
return build_redis_config(
{
"host": host,
"port": safe_int(os.getenv("REDIS_PORT", str(DEFAULT_REDIS_PORT)), DEFAULT_REDIS_PORT),
"username": os.getenv("REDIS_USERNAME", "").strip(),
"password": os.getenv("REDIS_PASSWORD", "").strip(),
"db": safe_int(os.getenv("REDIS_DATABASE", "0"), 0),
"ssl": os.getenv("REDIS_SSL", "false").strip().lower() in ("true", "1", "yes"),
}
)
def _get_client(config: RedisConfig) -> Any:
"""Create a redis client from config. Caller must close.
The client decodes responses to ``str`` so callers receive plain Python
types rather than raw bytes.
"""
import redis
return redis.Redis(
host=config.host,
port=config.port,
db=config.db,
username=config.username or None,
password=config.password or None,
ssl=config.ssl,
socket_timeout=config.timeout_seconds,
socket_connect_timeout=config.timeout_seconds,
decode_responses=True,
client_name="opensre",
)
def validate_redis_config(config: RedisConfig) -> RedisValidationResult:
"""Validate Redis connectivity with a lightweight ``PING`` command."""
if not config.host:
return RedisValidationResult(ok=False, detail="Redis host is required.")
try:
client = _get_client(config)
try:
if client.ping() is not True:
return RedisValidationResult(
ok=False, detail="Redis PING returned an unexpected result."
)
info = client.info("server")
version = info.get("redis_version", "unknown")
return RedisValidationResult(
ok=True,
detail=(
f"Connected to Redis {version} at {config.host}:{config.port}; "
f"database {config.db}."
),
)
finally:
client.close()
except Exception as err:
report_validation_failure(
err,
logger=logger,
integration="redis",
method="validate_redis_config",
)
return RedisValidationResult(ok=False, detail=f"Redis connection failed: {err}")
def redis_is_available(sources: dict[str, dict]) -> bool:
"""Check if Redis integration params are present in available sources."""
return bool(sources.get("redis", {}).get("host"))
def redis_extract_params(sources: dict[str, dict]) -> dict[str, Any]:
"""Extract Redis connection params from resolved integrations.
Credentials are resolved from the integration store or environment, so the
LLM never needs to supply the host or password directly.
"""
rd = sources.get("redis", {})
return {
"host": str(rd.get("host", "")).strip(),
"port": int(rd.get("port", DEFAULT_REDIS_PORT) or DEFAULT_REDIS_PORT),
"username": str(rd.get("username", "")).strip(),
"password": str(rd.get("password", "")).strip(),
"db": int(rd.get("db", 0) or 0),
"ssl": bool(rd.get("ssl", False)),
}
def get_server_info(config: RedisConfig) -> dict[str, Any]:
"""Retrieve server info: memory, connected clients, keyspace, and stats.
Read-only: uses the ``INFO`` command.
"""
if not config.is_configured:
return {"source": "redis", "available": False, "error": "Not configured."}
try:
client = _get_client(config)
try:
info = client.info()
finally:
client.close()
except Exception as err:
return _redis_error(err, "get_server_info")
keyspace = {
db_name: {
"keys": db_stats.get("keys", 0),
"expires": db_stats.get("expires", 0),
"avg_ttl_ms": db_stats.get("avg_ttl", 0),
}
for db_name, db_stats in info.items()
if db_name.startswith("db") and isinstance(db_stats, dict)
}
return {
"source": "redis",
"available": True,
"version": info.get("redis_version", ""),
"mode": info.get("redis_mode", ""),
"uptime_seconds": info.get("uptime_in_seconds", 0),
"memory": {
"used_memory_bytes": info.get("used_memory", 0),
"used_memory_human": info.get("used_memory_human", ""),
"used_memory_rss_bytes": info.get("used_memory_rss", 0),
"used_memory_peak_bytes": info.get("used_memory_peak", 0),
"maxmemory_bytes": info.get("maxmemory", 0),
"maxmemory_policy": info.get("maxmemory_policy", ""),
"mem_fragmentation_ratio": info.get("mem_fragmentation_ratio", 0),
},
"clients": {
"connected_clients": info.get("connected_clients", 0),
"blocked_clients": info.get("blocked_clients", 0),
"tracking_clients": info.get("tracking_clients", 0),
},
"stats": {
"total_connections_received": info.get("total_connections_received", 0),
"total_commands_processed": info.get("total_commands_processed", 0),
"instantaneous_ops_per_sec": info.get("instantaneous_ops_per_sec", 0),
"keyspace_hits": info.get("keyspace_hits", 0),
"keyspace_misses": info.get("keyspace_misses", 0),
"expired_keys": info.get("expired_keys", 0),
"evicted_keys": info.get("evicted_keys", 0),
"rejected_connections": info.get("rejected_connections", 0),
},
"keyspace": keyspace,
}
def get_slowlog(config: RedisConfig, limit: int | None = None) -> dict[str, Any]:
"""Retrieve recent slow log entries.
Read-only: uses ``SLOWLOG GET``. Durations are reported in microseconds
(as Redis stores them). Results are capped at ``config.max_results``.
"""
if not config.is_configured:
return {"source": "redis", "available": False, "error": "Not configured."}
effective_limit = min(limit or config.max_results, config.max_results)
try:
client = _get_client(config)
try:
raw_entries = client.slowlog_get(effective_limit)
finally:
client.close()
except Exception as err:
return _redis_error(err, "get_slowlog")
entries = []
for entry in raw_entries:
command = entry.get("command", "")
if isinstance(command, (bytes, bytearray)):
command = command.decode("utf-8", "replace")
entries.append(
{
"id": entry.get("id"),
"start_time": entry.get("start_time"),
"duration_microseconds": entry.get("duration", 0),
"command": str(command),
"client_address": entry.get("client_address", ""),
"client_name": entry.get("client_name", ""),
}
)
return {
"source": "redis",
"available": True,
"returned_entries": len(entries),
"entries": entries,
}
def get_replication(config: RedisConfig) -> dict[str, Any]:
"""Retrieve replication status and replica lag.
Read-only: uses ``INFO replication``. Reports the node role, master link
health (for replicas), and per-replica offset lag (for masters).
"""
if not config.is_configured:
return {"source": "redis", "available": False, "error": "Not configured."}
try:
client = _get_client(config)
try:
info = client.info("replication")
finally:
client.close()
except Exception as err:
return _redis_error(err, "get_replication")
role = info.get("role", "")
master_repl_offset = info.get("master_repl_offset", 0)
result: dict[str, Any] = {
"source": "redis",
"available": True,
"role": role,
"connected_slaves": info.get("connected_slaves", 0),
"master_repl_offset": master_repl_offset,
}
if role == "slave":
slave_offset = info.get("slave_repl_offset", 0)
result["master"] = {
"host": info.get("master_host", ""),
"port": info.get("master_port", 0),
"link_status": info.get("master_link_status", ""),
"last_io_seconds_ago": info.get("master_last_io_seconds_ago", -1),
"sync_in_progress": bool(info.get("master_sync_in_progress", 0)),
"slave_repl_offset": slave_offset,
}
replicas = []
for key, value in info.items():
if not key.startswith("slave") or not isinstance(value, dict):
continue
replica_offset = value.get("offset", 0)
replicas.append(
{
"id": key,
"ip": value.get("ip", ""),
"port": value.get("port", 0),
"state": value.get("state", ""),
"offset": replica_offset,
"lag_bytes": max(0, master_repl_offset - replica_offset),
}
)
result["replicas"] = replicas
return result
def scan_keys(
config: RedisConfig,
pattern: str = "*",
sample_limit: int | None = None,
) -> dict[str, Any]:
"""Count keys matching a pattern and sample their TTL and type.
Read-only: uses the non-blocking ``SCAN`` cursor (never ``KEYS``) so the
server is not blocked on large keyspaces. Total iteration is capped at
``DEFAULT_REDIS_SCAN_LIMIT``; TTL/type sampling is capped at
``config.max_results``.
"""
if not config.is_configured:
return {"source": "redis", "available": False, "error": "Not configured."}
match = pattern or "*"
sample_cap = min(sample_limit or config.max_results, config.max_results)
try:
client = _get_client(config)
try:
cursor, matched = 0, 0
samples: list[dict[str, Any]] = []
while True:
sampled_key_names: list[str] = []
cursor, batch = client.scan(cursor=cursor, match=match, count=100)
for key in batch:
matched += 1
if len(samples) + len(sampled_key_names) < sample_cap:
sampled_key_names.append(key)
if matched >= DEFAULT_REDIS_SCAN_LIMIT:
break
if sampled_key_names:
pipe = client.pipeline(transaction=False)
for key in sampled_key_names:
pipe.ttl(key)
pipe.type(key)
pipe_results = pipe.execute()
for index, key in enumerate(sampled_key_names):
samples.append(
{
"key": key,
"ttl_seconds": pipe_results[2 * index],
"type": pipe_results[2 * index + 1],
}
)
if cursor == 0 or matched >= DEFAULT_REDIS_SCAN_LIMIT:
break
finally:
client.close()
except Exception as err:
return _redis_error(err, "scan_keys")
return {
"source": "redis",
"available": True,
"pattern": match,
"matched_keys": matched,
"scan_truncated": matched >= DEFAULT_REDIS_SCAN_LIMIT,
"scan_limit": DEFAULT_REDIS_SCAN_LIMIT,
"sampled_keys": len(samples),
"samples": samples,
}
def _truncate_value(value: Any, limit: int = DEFAULT_REDIS_LIST_VALUE_PREVIEW) -> str:
"""Stringify a sampled value and cap its length so payloads stay bounded."""
text = str(value)
return text if len(text) <= limit else f"{text[:limit]}…"
def get_client_list(config: RedisConfig) -> dict[str, Any]:
"""Summarize connected clients via ``CLIENT LIST`` (read-only).
Surfaces connection-pool pressure during an incident: total client count,
how many are blocked (waiting on ``BLPOP``/``BRPOP``/``XREAD`` etc.), how
many are in pub/sub mode, and breakdowns by source address and last
command. The full client list is parsed for the aggregate counts, but only
``config.max_results`` clients are returned in the per-client sample so the
response stays bounded even on a saturated server with thousands of
connections.
"""
if not config.is_configured:
return {"source": "redis", "available": False, "error": "Not configured."}
try:
client = _get_client(config)
try:
raw_clients = client.client_list()
finally:
client.close()
except Exception as err:
return _redis_error(err, "get_client_list")
blocked = 0
pubsub = 0
max_idle_seconds = 0
by_address: dict[str, int] = {}
by_command: dict[str, int] = {}
samples: list[dict[str, Any]] = []
for entry in raw_clients:
flags = str(entry.get("flags", ""))
subscriptions = safe_int(entry.get("sub", 0), 0) + safe_int(entry.get("psub", 0), 0)
idle = safe_int(entry.get("idle", 0), 0)
addr = str(entry.get("addr", ""))
# addr is "ip:port" (or "[v6]:port"); group by the host portion only.
source = addr.rsplit(":", 1)[0] if ":" in addr else (addr or "unknown")
command = str(entry.get("cmd", "") or "unknown")
is_blocked = "b" in flags
is_pubsub = "P" in flags or subscriptions > 0
if is_blocked:
blocked += 1
if is_pubsub:
pubsub += 1
max_idle_seconds = max(max_idle_seconds, idle)
by_address[source] = by_address.get(source, 0) + 1
by_command[command] = by_command.get(command, 0) + 1
if len(samples) < config.max_results:
samples.append(
{
"id": safe_int(entry.get("id", 0), 0),
"addr": addr,
"name": str(entry.get("name", "")),
"age_seconds": safe_int(entry.get("age", 0), 0),
"idle_seconds": idle,
"flags": flags,
"db": safe_int(entry.get("db", 0), 0),
"command": command,
"user": str(entry.get("user", "")),
"blocked": is_blocked,
"pubsub": is_pubsub,
}
)
def _top(counts: dict[str, int]) -> dict[str, int]:
return dict(
sorted(counts.items(), key=lambda kv: kv[1], reverse=True)[: config.max_results]
)
return {
"source": "redis",
"available": True,
"total_clients": len(raw_clients),
"blocked_clients": blocked,
"pubsub_clients": pubsub,
"max_idle_seconds": max_idle_seconds,
"address_breakdown": _top(by_address),
"command_breakdown": _top(by_command),
"returned_clients": len(samples),
"clients": samples,
}
def get_list_depth(
config: RedisConfig,
key: str,
head: int = 0,
tail: int = 0,
) -> dict[str, Any]:
"""Report a list/queue key's depth (``LLEN``) with an optional bounded sample.
Read-only. ``TYPE`` is checked first so a non-list key returns a clear
message instead of a ``WRONGTYPE`` error, and a missing key reports
``exists=False`` rather than being indistinguishable from an empty list.
Head/tail sampling uses bounded ``LRANGE`` (each side capped at
``config.max_results``); every sampled element is truncated so a queue of
large job payloads cannot blow up the response.
"""
if not config.is_configured:
return {"source": "redis", "available": False, "error": "Not configured."}
key = str(key or "").strip()
if not key:
return {"source": "redis", "available": False, "error": "A list key is required."}
head_n = max(0, min(head or 0, config.max_results))
tail_n = max(0, min(tail or 0, config.max_results))
try:
client = _get_client(config)
try:
key_type = client.type(key)
if key_type == "none":
return {
"source": "redis",
"available": True,
"key": key,
"exists": False,
"type": "none",
"depth": 0,
"head": [],
"tail": [],
}
if key_type != "list":
return {
"source": "redis",
"available": True,
"key": key,
"exists": True,
"type": key_type,
"depth": None,
"head": [],
"tail": [],
"error": f"Key '{key}' is a {key_type}, not a list.",
}
pipe = client.pipeline(transaction=False)
pipe.llen(key)
if head_n:
pipe.lrange(key, 0, head_n - 1)
if tail_n:
pipe.lrange(key, -tail_n, -1)
results = pipe.execute()
finally:
client.close()
except Exception as err:
return _redis_error(err, "get_list_depth")
depth = results[0]
cursor = 1
head_values: list[Any] = []
if head_n:
head_values = results[cursor]
cursor += 1
tail_values: list[Any] = results[cursor] if tail_n else []
return {
"source": "redis",
"available": True,
"key": key,
"exists": True,
"type": "list",
"depth": depth,
"head": [_truncate_value(value) for value in head_values],
"tail": [_truncate_value(value) for value in tail_values],
}
def get_latency_doctor(
config: RedisConfig,
event: str = "",
history_limit: int | None = None,
) -> dict[str, Any]:
"""Return ``LATENCY DOCTOR`` analysis plus the latest monitored events.
Read-only. ``LATENCY DOCTOR`` produces a human-readable diagnosis of recent
latency spikes (fork/RDB save, AOF rewrite, blocking commands, slow disk).
``LATENCY LATEST`` lists each monitored event's latest/max spike; when
``event`` is given, ``LATENCY HISTORY`` for that event is included (capped at
``history_limit`` or ``config.max_results``). ``monitoring_active`` reflects
whether latency monitoring is *enabled* (``latency-monitor-threshold`` > 0),
read via ``CONFIG GET`` — so a healthy, enabled-but-quiet server is reported
as active even when no spikes have been recorded yet. The threshold read is
best-effort: if the ACL forbids ``CONFIG GET`` it falls back to whether any
events exist, and ``monitoring_threshold_ms`` is ``None``.
"""
if not config.is_configured:
return {"source": "redis", "available": False, "error": "Not configured."}
event = str(event or "").strip()
# Floor at 0 (mirrors get_list_depth's head/tail clamp): a negative
# history_limit is truthy, so without max(0, ...) it would survive the min()
# and turn ``history_raw[:history_cap]`` into a back-truncating slice that
# silently drops the most recent events instead of capping the count.
history_cap = max(0, min(history_limit or config.max_results, config.max_results))
try:
client = _get_client(config)
try:
# redis-py intentionally does not implement latency_doctor() (its
# output is human-readable, not parseable), so issue the raw command.
report = client.execute_command("LATENCY", "DOCTOR")
latest_raw = client.latency_latest()
history_raw = client.latency_history(event) if event else []
# Best-effort read of whether monitoring is enabled. CONFIG GET is
# read-only (only CONFIG SET is out of scope); guard it separately so
# an ACL that forbids CONFIG doesn't fail the whole tool.
threshold_ms: int | None = None
try:
cfg = client.config_get("latency-monitor-threshold")
threshold_ms = safe_int(cfg.get("latency-monitor-threshold", 0), 0)
except Exception:
threshold_ms = None
finally:
client.close()
except Exception as err:
return _redis_error(err, "get_latency_doctor")
latest = [
{
"event": str(row[0]),
"last_occurrence": row[1],
"latest_ms": row[2],
"max_ms": row[3],
}
for row in latest_raw
if isinstance(row, (list, tuple)) and len(row) >= 4
]
history = [
{"timestamp": row[0], "latency_ms": row[1]}
for row in history_raw[:history_cap]
if isinstance(row, (list, tuple)) and len(row) >= 2
]
monitoring_active = threshold_ms > 0 if threshold_ms is not None else bool(latest)
return {
"source": "redis",
"available": True,
"report": str(report),
"monitoring_active": monitoring_active,
"monitoring_threshold_ms": threshold_ms,
"monitored_events": len(latest),
"latest": latest,
"event": event,
"history": history,
}
def _redis_error(err: Exception, method: str) -> dict[str, Any]:
"""Normalize a Redis exception into a graceful, available=False payload.
Authentication and permission failures return a friendly hint without a
Sentry report; all other errors are reported for diagnosis. Errors are
classified by redis-py's typed exceptions rather than message substrings.
"""
import redis.exceptions as redis_exc
if isinstance(err, redis_exc.AuthenticationError):
return {
"source": "redis",
"available": False,
"error": "Redis authentication failed. Check the credentials in the connection settings.",
}
if isinstance(err, redis_exc.NoPermissionError):
return {
"source": "redis",
"available": False,
"error": (
"Redis user lacks permission for this command. Grant the user read "
"access to the diagnostic commands it needs (e.g. INFO, CLIENT, "
"SLOWLOG, LATENCY, LLEN/LRANGE, TYPE, SCAN)."
),
}
report_validation_failure(
err,
logger=logger,
integration="redis",
method=method,
)
return {"source": "redis", "available": False, "error": str(err)}
def classify(
credentials: dict[str, Any], record_id: str
) -> tuple[RedisIntegrationConfig | None, str | None]:
try:
cfg = RedisIntegrationConfig.model_validate(
{
"host": credentials.get("host", ""),
"port": credentials.get("port", 6379),
"username": credentials.get("username", ""),
"password": credentials.get("password", ""),
"db": credentials.get("db", 0),
"ssl": credentials.get("ssl", False),
"integration_id": record_id,
}
)
except Exception as exc:
report_classify_failure(exc, logger=logger, integration="redis", record_id=record_id)
return None, None
if cfg.host:
return cfg, "redis"
return None, None