WordPress client improvements: - Add ConnectionError exception for network-level failures - Differentiate DNS, SSL, timeout, and connection refused errors - Add retry with exponential backoff for transient errors (502, 503, 504, 429) - Auth/config errors are never retried - Add request timeout (30s default) Health check improvements: - Detect REST API disabled (403/404 on /wp-json) - Classify errors by type: dns_failure, ssl_error, timeout, connection_refused Bare except cleanup: - Replaced all bare except: with except Exception: across 12 files Tests: 303 passed (13 new tests for error handling) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
583 lines
24 KiB
Python
583 lines
24 KiB
Python
"""
|
|
System Operations Handler
|
|
|
|
Manages WordPress system operations including:
|
|
- System information (PHP, MySQL, WordPress versions)
|
|
- Disk usage statistics
|
|
- Cron job management
|
|
- Cache operations
|
|
- Error log retrieval
|
|
|
|
Most operations require WP-CLI for advanced functionality.
|
|
"""
|
|
|
|
import json
|
|
import re
|
|
from typing import Any
|
|
|
|
from plugins.wordpress.client import WordPressClient
|
|
from plugins.wordpress.wp_cli import WPCLIManager
|
|
|
|
|
|
def get_tool_specifications() -> list[dict[str, Any]]:
|
|
"""Return tool specifications for ToolGenerator"""
|
|
return [
|
|
# System Info
|
|
{
|
|
"name": "system_info",
|
|
"method_name": "system_info",
|
|
"description": "Get comprehensive system information including PHP version, MySQL version, WordPress version, server info, memory limits, and more.",
|
|
"schema": {"type": "object", "properties": {}},
|
|
"scope": "read",
|
|
},
|
|
# PHP Info
|
|
{
|
|
"name": "system_phpinfo",
|
|
"method_name": "system_phpinfo",
|
|
"description": "Get detailed PHP configuration including loaded extensions, ini settings, and disabled functions.",
|
|
"schema": {"type": "object", "properties": {}},
|
|
"scope": "read",
|
|
},
|
|
# Disk Usage
|
|
{
|
|
"name": "system_disk_usage",
|
|
"method_name": "system_disk_usage",
|
|
"description": "Get disk usage statistics including uploads size, plugins size, themes size, and database size.",
|
|
"schema": {"type": "object", "properties": {}},
|
|
"scope": "read",
|
|
},
|
|
# Clear All Caches
|
|
{
|
|
"name": "system_clear_all_caches",
|
|
"method_name": "system_clear_all_caches",
|
|
"description": "Clear all caches including object cache, transients, and opcache (if available). Safe to run anytime.",
|
|
"schema": {"type": "object", "properties": {}},
|
|
"scope": "write",
|
|
},
|
|
# Cron List
|
|
{
|
|
"name": "cron_list",
|
|
"method_name": "cron_list",
|
|
"description": "List all scheduled WordPress cron jobs with schedule, next run time, and arguments.",
|
|
"schema": {"type": "object", "properties": {}},
|
|
"scope": "read",
|
|
},
|
|
# Cron Run
|
|
{
|
|
"name": "cron_run",
|
|
"method_name": "cron_run",
|
|
"description": "Manually trigger a specific cron job by hook name. Useful for testing or forcing scheduled tasks.",
|
|
"schema": {
|
|
"type": "object",
|
|
"properties": {
|
|
"hook": {"type": "string", "description": "Cron hook name to execute"},
|
|
"args": {
|
|
"type": "array",
|
|
"items": {},
|
|
"default": [],
|
|
"description": "Optional arguments to pass to the hook",
|
|
},
|
|
},
|
|
"required": ["hook"],
|
|
},
|
|
"scope": "write",
|
|
},
|
|
# Error Log
|
|
{
|
|
"name": "error_log",
|
|
"method_name": "error_log",
|
|
"description": "Get recent PHP error log entries. Useful for debugging issues. Admin scope recommended for security.",
|
|
"schema": {
|
|
"type": "object",
|
|
"properties": {
|
|
"lines": {
|
|
"type": "integer",
|
|
"default": 100,
|
|
"minimum": 1,
|
|
"maximum": 1000,
|
|
"description": "Number of log lines to retrieve",
|
|
},
|
|
"filter": {
|
|
"type": "string",
|
|
"description": "Filter logs by keyword (case-insensitive)",
|
|
},
|
|
"level": {
|
|
"type": "string",
|
|
"enum": ["error", "warning", "notice", "fatal"],
|
|
"description": "Filter by error level",
|
|
},
|
|
},
|
|
},
|
|
"scope": "read",
|
|
},
|
|
]
|
|
|
|
|
|
class SystemHandler:
|
|
"""Handles WordPress system operations"""
|
|
|
|
def __init__(self, client: WordPressClient, wp_cli: WPCLIManager | None = None):
|
|
"""
|
|
Initialize System Handler
|
|
|
|
Args:
|
|
client: WordPress REST API client
|
|
wp_cli: WP-CLI manager (optional, for advanced operations)
|
|
"""
|
|
self.client = client
|
|
self.wp_cli = wp_cli
|
|
self.logger = client.logger
|
|
|
|
async def wp_cli_version(self) -> dict[str, Any]:
|
|
"""Get WP-CLI version information"""
|
|
if not self.wp_cli:
|
|
return {"success": False, "version": None, "error": "WP-CLI not available"}
|
|
|
|
try:
|
|
result = await self.wp_cli.execute_command("cli version")
|
|
version_output = result.get("output", "").strip()
|
|
|
|
# Parse version (e.g., "WP-CLI 2.10.0")
|
|
version = (
|
|
version_output.replace("WP-CLI ", "").strip()
|
|
if "WP-CLI" in version_output
|
|
else version_output
|
|
)
|
|
|
|
return {"success": True, "version": version, "full_output": version_output}
|
|
|
|
except Exception as e:
|
|
self.logger.error(f"WP-CLI version check failed: {str(e)}")
|
|
return {"success": False, "version": None, "error": str(e)}
|
|
|
|
async def system_info(self) -> dict[str, Any]:
|
|
"""Get comprehensive system information"""
|
|
if not self.wp_cli:
|
|
return {
|
|
"success": False,
|
|
"error": "WP-CLI not available. Container name not configured.",
|
|
}
|
|
|
|
try:
|
|
# Get various system info using WP-CLI
|
|
info_commands = {
|
|
"php_version": "eval 'echo PHP_VERSION;'",
|
|
"wordpress_version": "core version",
|
|
"site_url": "option get siteurl",
|
|
"active_theme": "theme list --status=active --field=name",
|
|
"plugin_count": "plugin list --status=active --format=count",
|
|
"wp_debug": "config get WP_DEBUG",
|
|
"wp_debug_log": "config get WP_DEBUG_LOG",
|
|
"multisite": "config get MULTISITE",
|
|
}
|
|
|
|
results = {}
|
|
for key, cmd in info_commands.items():
|
|
try:
|
|
result = await self.wp_cli.execute_command(cmd)
|
|
results[key] = result.get("output", "").strip()
|
|
except Exception as e:
|
|
self.logger.warning(f"Failed to get {key}: {str(e)}")
|
|
results[key] = "N/A"
|
|
|
|
# Get PHP settings
|
|
php_settings_cmd = """eval 'echo json_encode([
|
|
"memory_limit" => ini_get("memory_limit"),
|
|
"max_execution_time" => ini_get("max_execution_time"),
|
|
"upload_max_filesize" => ini_get("upload_max_filesize"),
|
|
"post_max_size" => ini_get("post_max_size"),
|
|
"max_input_vars" => ini_get("max_input_vars")
|
|
]);'"""
|
|
|
|
php_result = await self.wp_cli.execute_command(php_settings_cmd)
|
|
php_settings = json.loads(php_result.get("output", "{}"))
|
|
|
|
# Get MySQL version
|
|
mysql_cmd = 'db query "SELECT VERSION()" --skip-column-names'
|
|
mysql_result = await self.wp_cli.execute_command(mysql_cmd)
|
|
mysql_version = mysql_result.get("output", "N/A").strip()
|
|
|
|
# Get server software from environment
|
|
server_cmd = 'eval \'echo $_SERVER["SERVER_SOFTWARE"] ?? "Unknown";\''
|
|
server_result = await self.wp_cli.execute_command(server_cmd)
|
|
server_software = server_result.get("output", "Unknown").strip()
|
|
|
|
# Get loaded PHP extensions
|
|
ext_cmd = "eval 'echo json_encode(get_loaded_extensions());'"
|
|
ext_result = await self.wp_cli.execute_command(ext_cmd)
|
|
php_extensions = json.loads(ext_result.get("output", "[]"))
|
|
|
|
return {
|
|
"success": True,
|
|
"php_version": results.get("php_version", "N/A"),
|
|
"mysql_version": mysql_version,
|
|
"wordpress_version": results.get("wordpress_version", "N/A"),
|
|
"server_software": server_software,
|
|
"memory_limit": php_settings.get("memory_limit", "N/A"),
|
|
"max_execution_time": int(php_settings.get("max_execution_time", 0)),
|
|
"upload_max_filesize": php_settings.get("upload_max_filesize", "N/A"),
|
|
"post_max_size": php_settings.get("post_max_size", "N/A"),
|
|
"max_input_vars": int(php_settings.get("max_input_vars", 0)),
|
|
"php_extensions": php_extensions,
|
|
"wp_debug": results.get("wp_debug", "false") == "true",
|
|
"wp_debug_log": results.get("wp_debug_log", "false") == "true",
|
|
"multisite": results.get("multisite", "false") == "true",
|
|
"active_plugins": int(results.get("plugin_count", 0)),
|
|
"active_theme": results.get("active_theme", "N/A"),
|
|
"site_url": results.get("site_url", "N/A"),
|
|
}
|
|
|
|
except Exception as e:
|
|
self.logger.error(f"System info failed: {str(e)}")
|
|
return {"success": False, "error": str(e)}
|
|
|
|
async def system_phpinfo(self) -> dict[str, Any]:
|
|
"""Get detailed PHP configuration"""
|
|
if not self.wp_cli:
|
|
return {"success": False, "error": "WP-CLI not available"}
|
|
|
|
try:
|
|
# Get PHP version and SAPI
|
|
version_cmd = "eval 'echo PHP_VERSION;'"
|
|
version_result = await self.wp_cli.execute_command(version_cmd)
|
|
php_version = version_result.get("output", "").strip()
|
|
|
|
sapi_cmd = "eval 'echo PHP_SAPI;'"
|
|
sapi_result = await self.wp_cli.execute_command(sapi_cmd)
|
|
php_sapi = sapi_result.get("output", "").strip()
|
|
|
|
# Get loaded extensions
|
|
ext_cmd = "eval 'echo json_encode(get_loaded_extensions());'"
|
|
ext_result = await self.wp_cli.execute_command(ext_cmd)
|
|
extensions = json.loads(ext_result.get("output", "[]"))
|
|
|
|
# Get important ini settings
|
|
ini_settings_cmd = """eval 'echo json_encode([
|
|
"display_errors" => ini_get("display_errors"),
|
|
"error_reporting" => ini_get("error_reporting"),
|
|
"log_errors" => ini_get("log_errors"),
|
|
"error_log" => ini_get("error_log"),
|
|
"memory_limit" => ini_get("memory_limit"),
|
|
"max_execution_time" => ini_get("max_execution_time"),
|
|
"upload_max_filesize" => ini_get("upload_max_filesize"),
|
|
"post_max_size" => ini_get("post_max_size"),
|
|
"max_input_vars" => ini_get("max_input_vars"),
|
|
"max_input_time" => ini_get("max_input_time"),
|
|
"default_socket_timeout" => ini_get("default_socket_timeout"),
|
|
"allow_url_fopen" => ini_get("allow_url_fopen"),
|
|
"session.save_handler" => ini_get("session.save_handler")
|
|
]);'"""
|
|
|
|
ini_result = await self.wp_cli.execute_command(ini_settings_cmd)
|
|
ini_settings = json.loads(ini_result.get("output", "{}"))
|
|
|
|
# Get disabled functions
|
|
disabled_cmd = "eval 'echo ini_get(\"disable_functions\");'"
|
|
disabled_result = await self.wp_cli.execute_command(disabled_cmd)
|
|
disabled_raw = disabled_result.get("output", "").strip()
|
|
disabled_functions = [f.strip() for f in disabled_raw.split(",") if f.strip()]
|
|
|
|
return {
|
|
"success": True,
|
|
"version": php_version,
|
|
"sapi": php_sapi,
|
|
"extensions": extensions,
|
|
"ini_settings": ini_settings,
|
|
"disabled_functions": disabled_functions,
|
|
}
|
|
|
|
except Exception as e:
|
|
self.logger.error(f"PHP info failed: {str(e)}")
|
|
return {"success": False, "error": str(e)}
|
|
|
|
async def system_disk_usage(self) -> dict[str, Any]:
|
|
"""Get disk usage statistics"""
|
|
if not self.wp_cli:
|
|
return {"success": False, "error": "WP-CLI not available"}
|
|
|
|
try:
|
|
# Get WordPress root path
|
|
path_cmd = "eval 'echo ABSPATH;'"
|
|
path_result = await self.wp_cli.execute_command(path_cmd)
|
|
wp_path = path_result.get("output", "").strip()
|
|
|
|
# Get directory sizes using du command
|
|
sizes = {}
|
|
|
|
# Uploads directory
|
|
uploads_cmd = f"eval 'echo shell_exec(\"du -sm {wp_path}wp-content/uploads 2>/dev/null | cut -f1\");'"
|
|
try:
|
|
uploads_result = await self.wp_cli.execute_command(uploads_cmd)
|
|
upload_size = uploads_result.get("output", "0").strip()
|
|
sizes["uploads_size_mb"] = (
|
|
float(upload_size) if upload_size and upload_size != "" else 0.0
|
|
)
|
|
except Exception as e:
|
|
self.logger.warning(f"Failed to get uploads size: {e}")
|
|
sizes["uploads_size_mb"] = 0.0
|
|
|
|
# Plugins directory
|
|
plugins_cmd = f"eval 'echo shell_exec(\"du -sm {wp_path}wp-content/plugins 2>/dev/null | cut -f1\");'"
|
|
try:
|
|
plugins_result = await self.wp_cli.execute_command(plugins_cmd)
|
|
plugins_size = plugins_result.get("output", "0").strip()
|
|
sizes["plugins_size_mb"] = (
|
|
float(plugins_size) if plugins_size and plugins_size != "" else 0.0
|
|
)
|
|
except Exception as e:
|
|
self.logger.warning(f"Failed to get plugins size: {e}")
|
|
sizes["plugins_size_mb"] = 0.0
|
|
|
|
# Themes directory
|
|
themes_cmd = f"eval 'echo shell_exec(\"du -sm {wp_path}wp-content/themes 2>/dev/null | cut -f1\");'"
|
|
try:
|
|
themes_result = await self.wp_cli.execute_command(themes_cmd)
|
|
themes_size = themes_result.get("output", "0").strip()
|
|
sizes["themes_size_mb"] = (
|
|
float(themes_size) if themes_size and themes_size != "" else 0.0
|
|
)
|
|
except Exception as e:
|
|
self.logger.warning(f"Failed to get themes size: {e}")
|
|
sizes["themes_size_mb"] = 0.0
|
|
|
|
# Total WordPress directory
|
|
total_cmd = f"eval 'echo shell_exec(\"du -sm {wp_path} 2>/dev/null | cut -f1\");'"
|
|
try:
|
|
total_result = await self.wp_cli.execute_command(total_cmd)
|
|
total_size = total_result.get("output", "0").strip()
|
|
sizes["wordpress_size_mb"] = (
|
|
float(total_size) if total_size and total_size != "" else 0.0
|
|
)
|
|
except Exception as e:
|
|
self.logger.warning(f"Failed to get wordpress total size: {e}")
|
|
sizes["wordpress_size_mb"] = 0.0
|
|
|
|
# Database size (from our database handler logic)
|
|
db_query = """
|
|
SELECT ROUND(SUM(data_length + index_length) / 1024 / 1024, 2) AS size_mb
|
|
FROM information_schema.TABLES
|
|
WHERE table_schema = DATABASE()
|
|
"""
|
|
db_cmd = f'db query "{db_query}" --skip-column-names'
|
|
try:
|
|
db_result = await self.wp_cli.execute_command(db_cmd)
|
|
sizes["database_size_mb"] = float(db_result.get("output", "0").strip())
|
|
except (ValueError, KeyError, Exception):
|
|
sizes["database_size_mb"] = 0.0
|
|
|
|
# Calculate total
|
|
total_size = sum([sizes["wordpress_size_mb"], sizes["database_size_mb"]])
|
|
|
|
return {
|
|
"success": True,
|
|
"total_size_mb": round(total_size, 2),
|
|
**sizes,
|
|
"breakdown": {
|
|
"uploads": sizes["uploads_size_mb"],
|
|
"plugins": sizes["plugins_size_mb"],
|
|
"themes": sizes["themes_size_mb"],
|
|
"database": sizes["database_size_mb"],
|
|
},
|
|
}
|
|
|
|
except Exception as e:
|
|
self.logger.error(f"Disk usage check failed: {str(e)}")
|
|
return {"success": False, "error": str(e)}
|
|
|
|
async def system_clear_all_caches(self) -> dict[str, Any]:
|
|
"""Clear all caches"""
|
|
if not self.wp_cli:
|
|
return {"success": False, "error": "WP-CLI not available"}
|
|
|
|
try:
|
|
cleared = []
|
|
|
|
# Clear object cache
|
|
try:
|
|
await self.wp_cli.execute_command("cache flush")
|
|
cleared.append("object_cache")
|
|
except Exception as e:
|
|
self.logger.warning(f"Object cache flush failed: {str(e)}")
|
|
|
|
# Clear transients
|
|
try:
|
|
await self.wp_cli.execute_command("transient delete --all")
|
|
cleared.append("transients")
|
|
except Exception as e:
|
|
self.logger.warning(f"Transient delete failed: {str(e)}")
|
|
|
|
# Clear OPcache if available
|
|
try:
|
|
opcache_cmd = 'eval \'if (function_exists("opcache_reset")) { opcache_reset(); echo "cleared"; }\''
|
|
opcache_result = await self.wp_cli.execute_command(opcache_cmd)
|
|
if "cleared" in opcache_result.get("output", ""):
|
|
cleared.append("opcache")
|
|
except Exception as e:
|
|
self.logger.warning(f"OPcache clear failed: {str(e)}")
|
|
|
|
return {
|
|
"success": True,
|
|
"message": f"Cleared {len(cleared)} cache types",
|
|
"cleared": cleared,
|
|
}
|
|
|
|
except Exception as e:
|
|
self.logger.error(f"Clear all caches failed: {str(e)}")
|
|
return {"success": False, "error": str(e)}
|
|
|
|
async def cron_list(self) -> dict[str, Any]:
|
|
"""List all WordPress cron jobs"""
|
|
if not self.wp_cli:
|
|
return {"success": False, "error": "WP-CLI not available"}
|
|
|
|
try:
|
|
# Get cron events
|
|
cmd = "cron event list --format=json"
|
|
result = await self.wp_cli.execute_command(cmd)
|
|
|
|
events_data = json.loads(result.get("output", "[]"))
|
|
|
|
# Parse events
|
|
events = []
|
|
for event in events_data:
|
|
events.append(
|
|
{
|
|
"hook": event.get("hook", ""),
|
|
"timestamp": int(event.get("time", 0)),
|
|
"schedule": event.get("recurrence", "single"),
|
|
"interval": event.get("interval"),
|
|
"args": event.get("args", []),
|
|
}
|
|
)
|
|
|
|
# Get available schedules
|
|
schedules_cmd = "cron schedule list --format=json"
|
|
try:
|
|
schedules_result = await self.wp_cli.execute_command(schedules_cmd)
|
|
schedules_data = json.loads(schedules_result.get("output", "[]"))
|
|
schedules = {s.get("name"): s for s in schedules_data}
|
|
except (json.JSONDecodeError, KeyError, Exception):
|
|
schedules = {}
|
|
|
|
return {"success": True, "events": events, "total": len(events), "schedules": schedules}
|
|
|
|
except Exception as e:
|
|
self.logger.error(f"Cron list failed: {str(e)}")
|
|
return {"success": False, "error": str(e)}
|
|
|
|
async def cron_run(self, hook: str, args: list[Any] | None = None) -> dict[str, Any]:
|
|
"""Manually run a cron job"""
|
|
if not self.wp_cli:
|
|
return {"success": False, "error": "WP-CLI not available"}
|
|
|
|
try:
|
|
# Build command
|
|
cmd = f"cron event run {hook}"
|
|
|
|
# Execute cron job
|
|
result = await self.wp_cli.execute_command(cmd)
|
|
|
|
return {
|
|
"success": True,
|
|
"message": f"Cron job '{hook}' executed",
|
|
"hook": hook,
|
|
"output": result.get("output", ""),
|
|
}
|
|
|
|
except Exception as e:
|
|
self.logger.error(f"Cron run failed: {str(e)}")
|
|
return {"success": False, "error": str(e)}
|
|
|
|
async def error_log(
|
|
self, lines: int = 100, filter: str | None = None, level: str | None = None
|
|
) -> dict[str, Any]:
|
|
"""Get PHP error log entries"""
|
|
if not self.wp_cli:
|
|
return {"success": False, "error": "WP-CLI not available"}
|
|
|
|
try:
|
|
# Get error log path
|
|
log_path_cmd = "eval 'echo ini_get(\"error_log\");'"
|
|
log_path_result = await self.wp_cli.execute_command(log_path_cmd)
|
|
log_path = log_path_result.get("output", "").strip()
|
|
|
|
if not log_path or log_path == "":
|
|
# Try WordPress debug log
|
|
wp_path_cmd = "eval 'echo WP_CONTENT_DIR;'"
|
|
wp_path_result = await self.wp_cli.execute_command(wp_path_cmd)
|
|
wp_content = wp_path_result.get("output", "").strip()
|
|
log_path = f"{wp_content}/debug.log"
|
|
|
|
# Get log file size
|
|
size_cmd = f"eval 'echo filesize(\"{log_path}\") ?? 0;'"
|
|
try:
|
|
size_result = await self.wp_cli.execute_command(size_cmd)
|
|
log_size_bytes = int(size_result.get("output", "0").strip())
|
|
log_size_mb = round(log_size_bytes / 1024 / 1024, 2)
|
|
except (ValueError, KeyError, Exception):
|
|
log_size_mb = 0.0
|
|
|
|
# Read log file (last N lines)
|
|
tail_cmd = f"eval 'exec(\"tail -n {lines} {log_path} 2>&1\");'"
|
|
log_result = await self.wp_cli.execute_command(tail_cmd)
|
|
log_lines = log_result.get("output", "").strip().split("\n")
|
|
|
|
# Parse log entries
|
|
entries = []
|
|
for line in log_lines:
|
|
if not line.strip():
|
|
continue
|
|
|
|
# Basic parsing (PHP error log format varies)
|
|
# Format: [timestamp] PHP Error_Type: message in file on line X
|
|
match = re.match(r"\[([^\]]+)\]\s+PHP\s+(\w+):\s+(.+)", line)
|
|
|
|
if match:
|
|
timestamp, error_type, message = match.groups()
|
|
|
|
# Extract file and line if present
|
|
file_match = re.search(r"in\s+(.+)\s+on\s+line\s+(\d+)", message)
|
|
file_path = file_match.group(1) if file_match else None
|
|
line_num = int(file_match.group(2)) if file_match else None
|
|
|
|
entry = {
|
|
"timestamp": timestamp,
|
|
"level": error_type.lower(),
|
|
"message": message,
|
|
"file": file_path,
|
|
"line": line_num,
|
|
}
|
|
|
|
# Apply filters
|
|
if level and entry["level"] != level.lower():
|
|
continue
|
|
|
|
if filter and filter.lower() not in message.lower():
|
|
continue
|
|
|
|
entries.append(entry)
|
|
else:
|
|
# Unparsed line - include as-is
|
|
entries.append(
|
|
{
|
|
"timestamp": "N/A",
|
|
"level": "unknown",
|
|
"message": line,
|
|
"file": None,
|
|
"line": None,
|
|
}
|
|
)
|
|
|
|
return {
|
|
"success": True,
|
|
"entries": entries,
|
|
"total_lines": len(log_lines),
|
|
"filtered_lines": len(entries),
|
|
"log_size_mb": log_size_mb,
|
|
"log_path": log_path,
|
|
}
|
|
|
|
except Exception as e:
|
|
self.logger.error(f"Error log retrieval failed: {str(e)}")
|
|
return {"success": False, "error": str(e)}
|