refactor: Extract large functions, improve exception handling, remove duplicates
## Large function extraction - servers.py: Extract 8 _impl functions from register_server_tools (449 lines) - certificates.py: Extract 7 _impl functions from register_certificate_tools (386 lines) - MCP tool wrappers now delegate to module-level implementation functions ## Exception handling improvements - Replace 11 broad `except Exception` with specific types - health.py: (OSError, subprocess.SubprocessError) - configuration.py: (HaproxyError, IOError, OSError, ValueError) - servers.py: (IOError, OSError, ValueError) - certificates.py: FileNotFoundError, (subprocess.SubprocessError, OSError) ## Duplicate code extraction - Add parse_servers_state() to utils.py (replaces 4 duplicate parsers) - Add disable_server_slot() to utils.py (replaces duplicate patterns) - Update health.py, servers.py, domains.py to use new helpers ## Other improvements - Add TypedDict types in file_ops.py and health.py - Set file permissions (0o600) for sensitive files - Update tests to use specific exception types Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -10,13 +10,11 @@ from ..config import (
|
||||
MAX_SLOTS,
|
||||
MAX_BULK_SERVERS,
|
||||
MAX_SERVERS_JSON_SIZE,
|
||||
StateField,
|
||||
StatField,
|
||||
STATE_MIN_COLUMNS,
|
||||
logger,
|
||||
)
|
||||
from ..exceptions import HaproxyError
|
||||
from ..validation import validate_domain, validate_ip, validate_backend_name
|
||||
from ..validation import validate_domain, validate_ip, validate_backend_name, validate_port_int
|
||||
from ..haproxy_client import haproxy_cmd, haproxy_cmd_checked, haproxy_cmd_batch
|
||||
from ..file_ops import (
|
||||
get_backend_and_prefix,
|
||||
@@ -24,6 +22,7 @@ from ..file_ops import (
|
||||
add_server_to_config,
|
||||
remove_server_from_config,
|
||||
)
|
||||
from ..utils import parse_servers_state, disable_server_slot
|
||||
|
||||
|
||||
def configure_server_slot(backend: str, server_prefix: str, slot: int, ip: str, http_port: int) -> str:
|
||||
@@ -51,6 +50,390 @@ def configure_server_slot(backend: str, server_prefix: str, slot: int, ip: str,
|
||||
return server
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Implementation functions (module-level)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def _haproxy_list_servers_impl(domain: str) -> str:
|
||||
"""Implementation of haproxy_list_servers."""
|
||||
if not validate_domain(domain):
|
||||
return "Error: Invalid domain format"
|
||||
|
||||
try:
|
||||
backend, _ = get_backend_and_prefix(domain)
|
||||
state = haproxy_cmd("show servers state")
|
||||
parsed_state = parse_servers_state(state)
|
||||
backend_servers = parsed_state.get(backend, {})
|
||||
|
||||
if not backend_servers:
|
||||
return f"Backend {backend} not found"
|
||||
|
||||
servers = []
|
||||
for server_name, srv_info in backend_servers.items():
|
||||
addr = srv_info["addr"]
|
||||
port = srv_info["port"]
|
||||
status = "active" if addr != "0.0.0.0" else "disabled"
|
||||
servers.append(f"• {server_name}: {addr}:{port} ({status})")
|
||||
|
||||
return f"Servers for {domain} ({backend}):\n" + "\n".join(servers)
|
||||
except (HaproxyError, ValueError) as e:
|
||||
return f"Error: {e}"
|
||||
|
||||
|
||||
def _haproxy_add_server_impl(domain: str, slot: int, ip: str, http_port: int) -> str:
|
||||
"""Implementation of haproxy_add_server."""
|
||||
if not validate_domain(domain):
|
||||
return "Error: Invalid domain format"
|
||||
if not ip:
|
||||
return "Error: IP address is required"
|
||||
if not validate_ip(ip):
|
||||
return "Error: Invalid IP address format"
|
||||
if not validate_port_int(http_port):
|
||||
return "Error: Port must be between 1 and 65535"
|
||||
|
||||
try:
|
||||
backend, server_prefix = get_backend_and_prefix(domain)
|
||||
|
||||
# Auto-select slot if slot <= 0
|
||||
if slot <= 0:
|
||||
state = haproxy_cmd("show servers state")
|
||||
parsed_state = parse_servers_state(state)
|
||||
backend_servers = parsed_state.get(backend, {})
|
||||
used_slots: set[int] = set()
|
||||
for server_name, srv_info in backend_servers.items():
|
||||
if srv_info["addr"] != "0.0.0.0":
|
||||
# Extract slot number from server name (e.g., pool_1_3 -> 3)
|
||||
try:
|
||||
used_slots.add(int(server_name.rsplit("_", 1)[1]))
|
||||
except (ValueError, IndexError):
|
||||
pass
|
||||
for s in range(1, MAX_SLOTS + 1):
|
||||
if s not in used_slots:
|
||||
slot = s
|
||||
break
|
||||
else:
|
||||
return f"Error: No available slots (all {MAX_SLOTS} slots in use)"
|
||||
elif not (1 <= slot <= MAX_SLOTS):
|
||||
return f"Error: Slot must be between 1 and {MAX_SLOTS}, or 0/-1 for auto-select"
|
||||
|
||||
# Save to persistent config FIRST (disk-first pattern)
|
||||
add_server_to_config(domain, slot, ip, http_port)
|
||||
|
||||
try:
|
||||
server = configure_server_slot(backend, server_prefix, slot, ip, http_port)
|
||||
return f"Added to {domain} ({backend}) slot {slot}:\n{server} → {ip}:{http_port}"
|
||||
except HaproxyError as e:
|
||||
# Rollback config on HAProxy failure
|
||||
remove_server_from_config(domain, slot)
|
||||
return f"Error: {e}"
|
||||
except (ValueError, IOError) as e:
|
||||
return f"Error: {e}"
|
||||
|
||||
|
||||
def _haproxy_add_servers_impl(domain: str, servers: str) -> str:
|
||||
"""Implementation of haproxy_add_servers."""
|
||||
if not validate_domain(domain):
|
||||
return "Error: Invalid domain format"
|
||||
|
||||
# Check JSON size before parsing
|
||||
if len(servers) > MAX_SERVERS_JSON_SIZE:
|
||||
return f"Error: servers JSON exceeds maximum size ({MAX_SERVERS_JSON_SIZE} bytes)"
|
||||
|
||||
# Parse JSON array
|
||||
try:
|
||||
server_list = json.loads(servers)
|
||||
except json.JSONDecodeError as e:
|
||||
return f"Error: Invalid JSON - {e}"
|
||||
|
||||
if not isinstance(server_list, list):
|
||||
return "Error: servers must be a JSON array"
|
||||
|
||||
if not server_list:
|
||||
return "Error: servers array is empty"
|
||||
|
||||
if len(server_list) > MAX_BULK_SERVERS:
|
||||
return f"Error: Cannot add more than {MAX_BULK_SERVERS} servers at once"
|
||||
|
||||
# Validate all servers first before adding any
|
||||
validated_servers = []
|
||||
validation_errors = []
|
||||
|
||||
for i, srv in enumerate(server_list):
|
||||
if not isinstance(srv, dict):
|
||||
validation_errors.append(f"Server {i+1}: must be an object")
|
||||
continue
|
||||
|
||||
# Extract and validate slot
|
||||
slot = srv.get("slot")
|
||||
if slot is None:
|
||||
validation_errors.append(f"Server {i+1}: missing 'slot' field")
|
||||
continue
|
||||
try:
|
||||
slot = int(slot)
|
||||
except (ValueError, TypeError):
|
||||
validation_errors.append(f"Server {i+1}: slot must be an integer")
|
||||
continue
|
||||
if not (1 <= slot <= MAX_SLOTS):
|
||||
validation_errors.append(f"Server {i+1}: slot must be between 1 and {MAX_SLOTS}")
|
||||
continue
|
||||
|
||||
# Extract and validate IP
|
||||
ip = srv.get("ip")
|
||||
if not ip:
|
||||
validation_errors.append(f"Server {i+1}: missing 'ip' field")
|
||||
continue
|
||||
if not validate_ip(ip):
|
||||
validation_errors.append(f"Server {i+1}: invalid IP address '{ip}'")
|
||||
continue
|
||||
|
||||
# Extract and validate port
|
||||
http_port = srv.get("http_port", 80)
|
||||
try:
|
||||
http_port = int(http_port)
|
||||
except (ValueError, TypeError):
|
||||
validation_errors.append(f"Server {i+1}: http_port must be an integer")
|
||||
continue
|
||||
if not validate_port_int(http_port):
|
||||
validation_errors.append(f"Server {i+1}: port must be between 1 and 65535")
|
||||
continue
|
||||
|
||||
validated_servers.append({"slot": slot, "ip": ip, "http_port": http_port})
|
||||
|
||||
# Return validation errors if any
|
||||
if validation_errors:
|
||||
return "Validation errors:\n" + "\n".join(f" • {e}" for e in validation_errors)
|
||||
|
||||
# Check for duplicate slots
|
||||
slots = [s["slot"] for s in validated_servers]
|
||||
if len(slots) != len(set(slots)):
|
||||
return "Error: Duplicate slot numbers in servers array"
|
||||
|
||||
# Get backend info
|
||||
try:
|
||||
backend, server_prefix = get_backend_and_prefix(domain)
|
||||
except ValueError as e:
|
||||
return f"Error: {e}"
|
||||
|
||||
# Save ALL servers to config FIRST (disk-first pattern)
|
||||
for server_config in validated_servers:
|
||||
slot = server_config["slot"]
|
||||
ip = server_config["ip"]
|
||||
http_port = server_config["http_port"]
|
||||
add_server_to_config(domain, slot, ip, http_port)
|
||||
|
||||
# Then update HAProxy
|
||||
added = []
|
||||
errors = []
|
||||
failed_slots = []
|
||||
successfully_added_slots = []
|
||||
|
||||
try:
|
||||
for server_config in validated_servers:
|
||||
slot = server_config["slot"]
|
||||
ip = server_config["ip"]
|
||||
http_port = server_config["http_port"]
|
||||
try:
|
||||
configure_server_slot(backend, server_prefix, slot, ip, http_port)
|
||||
successfully_added_slots.append(slot)
|
||||
added.append(f"slot {slot}: {ip}:{http_port}")
|
||||
except HaproxyError as e:
|
||||
failed_slots.append(slot)
|
||||
errors.append(f"slot {slot}: {e}")
|
||||
except (IOError, OSError, ValueError) as e:
|
||||
# Rollback only successfully added configs on unexpected error
|
||||
logger.error("Unexpected error during bulk server add for %s: %s", domain, e)
|
||||
for slot in successfully_added_slots:
|
||||
try:
|
||||
remove_server_from_config(domain, slot)
|
||||
except (IOError, OSError, ValueError) as rollback_error:
|
||||
logger.error(
|
||||
"Failed to rollback server config for %s slot %d: %s",
|
||||
domain, slot, rollback_error
|
||||
)
|
||||
# Also rollback configs that weren't yet processed
|
||||
for server_config in validated_servers:
|
||||
slot = server_config["slot"]
|
||||
if slot not in successfully_added_slots:
|
||||
try:
|
||||
remove_server_from_config(domain, slot)
|
||||
except (IOError, OSError, ValueError) as rollback_error:
|
||||
logger.error(
|
||||
"Failed to rollback server config for %s slot %d: %s",
|
||||
domain, slot, rollback_error
|
||||
)
|
||||
return f"Error: {e}"
|
||||
|
||||
# Rollback failed slots from config
|
||||
for slot in failed_slots:
|
||||
try:
|
||||
remove_server_from_config(domain, slot)
|
||||
except (IOError, OSError, ValueError) as rollback_error:
|
||||
logger.error(
|
||||
"Failed to rollback server config for %s slot %d: %s",
|
||||
domain, slot, rollback_error
|
||||
)
|
||||
|
||||
# Build result message
|
||||
result_parts = []
|
||||
if added:
|
||||
result_parts.append(f"Added {len(added)} servers to {domain} ({backend}):")
|
||||
result_parts.extend(f" • {s}" for s in added)
|
||||
if errors:
|
||||
result_parts.append(f"Failed to add {len(errors)} servers:")
|
||||
result_parts.extend(f" • {e}" for e in errors)
|
||||
|
||||
return "\n".join(result_parts) if result_parts else "No servers added"
|
||||
|
||||
|
||||
def _haproxy_remove_server_impl(domain: str, slot: int) -> str:
|
||||
"""Implementation of haproxy_remove_server."""
|
||||
if not validate_domain(domain):
|
||||
return "Error: Invalid domain format"
|
||||
if not (1 <= slot <= MAX_SLOTS):
|
||||
return f"Error: Slot must be between 1 and {MAX_SLOTS}"
|
||||
|
||||
try:
|
||||
backend, server_prefix = get_backend_and_prefix(domain)
|
||||
|
||||
# Get current server info for potential rollback
|
||||
config = load_servers_config()
|
||||
old_config = config.get(domain, {}).get(str(slot), {})
|
||||
|
||||
# Remove from persistent config FIRST (disk-first pattern)
|
||||
remove_server_from_config(domain, slot)
|
||||
|
||||
try:
|
||||
# HTTP only - single server per slot
|
||||
server = f"{server_prefix}_{slot}"
|
||||
disable_server_slot(backend, server)
|
||||
return f"Removed server at slot {slot} from {domain} ({backend})"
|
||||
except HaproxyError as e:
|
||||
# Rollback: re-add config if HAProxy command failed
|
||||
if old_config:
|
||||
add_server_to_config(domain, slot, old_config.get("ip", ""), old_config.get("http_port", 80))
|
||||
return f"Error: {e}"
|
||||
except (ValueError, IOError) as e:
|
||||
return f"Error: {e}"
|
||||
|
||||
|
||||
def _haproxy_set_domain_state_impl(domain: str, state: str) -> str:
|
||||
"""Implementation of haproxy_set_domain_state."""
|
||||
if not validate_domain(domain):
|
||||
return "Error: Invalid domain format"
|
||||
if state not in ["ready", "drain", "maint"]:
|
||||
return "Error: State must be 'ready', 'drain', or 'maint'"
|
||||
|
||||
try:
|
||||
backend, _ = get_backend_and_prefix(domain)
|
||||
except ValueError as e:
|
||||
return f"Error: {e}"
|
||||
|
||||
# Get active servers for this domain
|
||||
try:
|
||||
servers_state = haproxy_cmd("show servers state")
|
||||
except HaproxyError as e:
|
||||
return f"Error: {e}"
|
||||
|
||||
parsed_state = parse_servers_state(servers_state)
|
||||
backend_servers = parsed_state.get(backend, {})
|
||||
|
||||
changed = []
|
||||
errors = []
|
||||
|
||||
for server_name, srv_info in backend_servers.items():
|
||||
# Only change state for configured servers (not 0.0.0.0)
|
||||
if srv_info["addr"] != "0.0.0.0":
|
||||
try:
|
||||
haproxy_cmd_checked(f"set server {backend}/{server_name} state {state}")
|
||||
changed.append(server_name)
|
||||
except HaproxyError as e:
|
||||
errors.append(f"{server_name}: {e}")
|
||||
|
||||
if not changed and not errors:
|
||||
return f"No active servers found for {domain}"
|
||||
|
||||
result = f"Set {len(changed)} servers to '{state}' for {domain}"
|
||||
if changed:
|
||||
result += ":\n" + "\n".join(f" • {s}" for s in changed)
|
||||
if errors:
|
||||
result += f"\n\nErrors ({len(errors)}):\n" + "\n".join(f" • {e}" for e in errors)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def _haproxy_wait_drain_impl(domain: str, timeout: int) -> str:
|
||||
"""Implementation of haproxy_wait_drain."""
|
||||
if not validate_domain(domain):
|
||||
return "Error: Invalid domain format"
|
||||
if not (1 <= timeout <= 300):
|
||||
return "Error: Timeout must be between 1 and 300 seconds"
|
||||
|
||||
try:
|
||||
backend, _ = get_backend_and_prefix(domain)
|
||||
except ValueError as e:
|
||||
return f"Error: {e}"
|
||||
|
||||
start_time = time.time()
|
||||
while time.time() - start_time < timeout:
|
||||
try:
|
||||
stats = haproxy_cmd("show stat")
|
||||
total_connections = 0
|
||||
for line in stats.split("\n"):
|
||||
parts = line.split(",")
|
||||
if len(parts) > StatField.SCUR and parts[0] == backend and parts[1] not in ["FRONTEND", "BACKEND", ""]:
|
||||
try:
|
||||
scur = int(parts[StatField.SCUR]) if parts[StatField.SCUR] else 0
|
||||
total_connections += scur
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
if total_connections == 0:
|
||||
elapsed = int(time.time() - start_time)
|
||||
return f"All connections drained for {domain} ({elapsed}s)"
|
||||
|
||||
time.sleep(1)
|
||||
except HaproxyError as e:
|
||||
return f"Error checking connections: {e}"
|
||||
|
||||
return f"Timeout: Connections still active after {timeout}s"
|
||||
|
||||
|
||||
def _haproxy_set_server_state_impl(backend: str, server: str, state: str) -> str:
|
||||
"""Implementation of haproxy_set_server_state."""
|
||||
if not validate_backend_name(backend):
|
||||
return "Error: Invalid backend name (use alphanumeric, underscore, hyphen only)"
|
||||
if not validate_backend_name(server):
|
||||
return "Error: Invalid server name (use alphanumeric, underscore, hyphen only)"
|
||||
if state not in ["ready", "drain", "maint"]:
|
||||
return "Error: state must be 'ready', 'drain', or 'maint'"
|
||||
try:
|
||||
haproxy_cmd_checked(f"set server {backend}/{server} state {state}")
|
||||
return f"Server {backend}/{server} set to {state}"
|
||||
except HaproxyError as e:
|
||||
return f"Error: {e}"
|
||||
|
||||
|
||||
def _haproxy_set_server_weight_impl(backend: str, server: str, weight: int) -> str:
|
||||
"""Implementation of haproxy_set_server_weight."""
|
||||
if not validate_backend_name(backend):
|
||||
return "Error: Invalid backend name (use alphanumeric, underscore, hyphen only)"
|
||||
if not validate_backend_name(server):
|
||||
return "Error: Invalid server name (use alphanumeric, underscore, hyphen only)"
|
||||
if not (0 <= weight <= 256):
|
||||
return "Error: weight must be between 0 and 256"
|
||||
try:
|
||||
haproxy_cmd_checked(f"set server {backend}/{server} weight {weight}")
|
||||
return f"Server {backend}/{server} weight set to {weight}"
|
||||
except HaproxyError as e:
|
||||
return f"Error: {e}"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# MCP Tool Registration
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def register_server_tools(mcp):
|
||||
"""Register server management tools with MCP server."""
|
||||
|
||||
@@ -65,29 +448,7 @@ def register_server_tools(mcp):
|
||||
# Output: pool_1_1: 10.0.0.1:8080 (UP)
|
||||
# pool_1_2: 10.0.0.2:8080 (UP)
|
||||
"""
|
||||
if not validate_domain(domain):
|
||||
return "Error: Invalid domain format"
|
||||
|
||||
try:
|
||||
backend, _ = get_backend_and_prefix(domain)
|
||||
servers = []
|
||||
state = haproxy_cmd("show servers state")
|
||||
|
||||
for line in state.split("\n"):
|
||||
parts = line.split()
|
||||
if len(parts) >= STATE_MIN_COLUMNS and parts[StateField.BE_NAME] == backend:
|
||||
addr = parts[StateField.SRV_ADDR]
|
||||
status = "active" if addr != "0.0.0.0" else "disabled"
|
||||
servers.append(
|
||||
f"• {parts[StateField.SRV_NAME]}: {addr}:{parts[StateField.SRV_PORT]} ({status})"
|
||||
)
|
||||
|
||||
if not servers:
|
||||
return f"Backend {backend} not found"
|
||||
|
||||
return f"Servers for {domain} ({backend}):\n" + "\n".join(servers)
|
||||
except (HaproxyError, ValueError) as e:
|
||||
return f"Error: {e}"
|
||||
return _haproxy_list_servers_impl(domain)
|
||||
|
||||
@mcp.tool()
|
||||
def haproxy_add_server(
|
||||
@@ -103,53 +464,7 @@ def register_server_tools(mcp):
|
||||
|
||||
Example: haproxy_add_server("api.example.com", slot=1, ip="10.0.0.1", http_port=8080)
|
||||
"""
|
||||
if not validate_domain(domain):
|
||||
return "Error: Invalid domain format"
|
||||
if not ip:
|
||||
return "Error: IP address is required"
|
||||
if not validate_ip(ip):
|
||||
return "Error: Invalid IP address format"
|
||||
if not (1 <= http_port <= 65535):
|
||||
return "Error: Port must be between 1 and 65535"
|
||||
|
||||
try:
|
||||
backend, server_prefix = get_backend_and_prefix(domain)
|
||||
|
||||
# Auto-select slot if slot <= 0
|
||||
if slot <= 0:
|
||||
state = haproxy_cmd("show servers state")
|
||||
used_slots: set[int] = set()
|
||||
for line in state.split("\n"):
|
||||
parts = line.split()
|
||||
if len(parts) >= STATE_MIN_COLUMNS and parts[StateField.BE_NAME] == backend:
|
||||
if parts[StateField.SRV_ADDR] != "0.0.0.0":
|
||||
# Extract slot number from server name (e.g., pool_1_3 -> 3)
|
||||
server_name = parts[StateField.SRV_NAME]
|
||||
try:
|
||||
used_slots.add(int(server_name.rsplit("_", 1)[1]))
|
||||
except (ValueError, IndexError):
|
||||
pass
|
||||
for s in range(1, MAX_SLOTS + 1):
|
||||
if s not in used_slots:
|
||||
slot = s
|
||||
break
|
||||
else:
|
||||
return f"Error: No available slots (all {MAX_SLOTS} slots in use)"
|
||||
elif not (1 <= slot <= MAX_SLOTS):
|
||||
return f"Error: Slot must be between 1 and {MAX_SLOTS}, or 0/-1 for auto-select"
|
||||
|
||||
# Save to persistent config FIRST (disk-first pattern)
|
||||
add_server_to_config(domain, slot, ip, http_port)
|
||||
|
||||
try:
|
||||
server = configure_server_slot(backend, server_prefix, slot, ip, http_port)
|
||||
return f"Added to {domain} ({backend}) slot {slot}:\n{server} → {ip}:{http_port}"
|
||||
except HaproxyError as e:
|
||||
# Rollback config on HAProxy failure
|
||||
remove_server_from_config(domain, slot)
|
||||
return f"Error: {e}"
|
||||
except (ValueError, IOError) as e:
|
||||
return f"Error: {e}"
|
||||
return _haproxy_add_server_impl(domain, slot, ip, http_port)
|
||||
|
||||
@mcp.tool()
|
||||
def haproxy_add_servers(
|
||||
@@ -160,156 +475,7 @@ def register_server_tools(mcp):
|
||||
|
||||
Example: haproxy_add_servers("api.example.com", '[{"slot":1,"ip":"10.0.0.1"},{"slot":2,"ip":"10.0.0.2"}]')
|
||||
"""
|
||||
if not validate_domain(domain):
|
||||
return "Error: Invalid domain format"
|
||||
|
||||
# Check JSON size before parsing
|
||||
if len(servers) > MAX_SERVERS_JSON_SIZE:
|
||||
return f"Error: servers JSON exceeds maximum size ({MAX_SERVERS_JSON_SIZE} bytes)"
|
||||
|
||||
# Parse JSON array
|
||||
try:
|
||||
server_list = json.loads(servers)
|
||||
except json.JSONDecodeError as e:
|
||||
return f"Error: Invalid JSON - {e}"
|
||||
|
||||
if not isinstance(server_list, list):
|
||||
return "Error: servers must be a JSON array"
|
||||
|
||||
if not server_list:
|
||||
return "Error: servers array is empty"
|
||||
|
||||
if len(server_list) > MAX_BULK_SERVERS:
|
||||
return f"Error: Cannot add more than {MAX_BULK_SERVERS} servers at once"
|
||||
|
||||
# Validate all servers first before adding any
|
||||
validated_servers = []
|
||||
validation_errors = []
|
||||
|
||||
for i, srv in enumerate(server_list):
|
||||
if not isinstance(srv, dict):
|
||||
validation_errors.append(f"Server {i+1}: must be an object")
|
||||
continue
|
||||
|
||||
# Extract and validate slot
|
||||
slot = srv.get("slot")
|
||||
if slot is None:
|
||||
validation_errors.append(f"Server {i+1}: missing 'slot' field")
|
||||
continue
|
||||
try:
|
||||
slot = int(slot)
|
||||
except (ValueError, TypeError):
|
||||
validation_errors.append(f"Server {i+1}: slot must be an integer")
|
||||
continue
|
||||
if not (1 <= slot <= MAX_SLOTS):
|
||||
validation_errors.append(f"Server {i+1}: slot must be between 1 and {MAX_SLOTS}")
|
||||
continue
|
||||
|
||||
# Extract and validate IP
|
||||
ip = srv.get("ip")
|
||||
if not ip:
|
||||
validation_errors.append(f"Server {i+1}: missing 'ip' field")
|
||||
continue
|
||||
if not validate_ip(ip):
|
||||
validation_errors.append(f"Server {i+1}: invalid IP address '{ip}'")
|
||||
continue
|
||||
|
||||
# Extract and validate port
|
||||
http_port = srv.get("http_port", 80)
|
||||
try:
|
||||
http_port = int(http_port)
|
||||
except (ValueError, TypeError):
|
||||
validation_errors.append(f"Server {i+1}: http_port must be an integer")
|
||||
continue
|
||||
if not (1 <= http_port <= 65535):
|
||||
validation_errors.append(f"Server {i+1}: port must be between 1 and 65535")
|
||||
continue
|
||||
|
||||
validated_servers.append({"slot": slot, "ip": ip, "http_port": http_port})
|
||||
|
||||
# Return validation errors if any
|
||||
if validation_errors:
|
||||
return "Validation errors:\n" + "\n".join(f" • {e}" for e in validation_errors)
|
||||
|
||||
# Check for duplicate slots
|
||||
slots = [s["slot"] for s in validated_servers]
|
||||
if len(slots) != len(set(slots)):
|
||||
return "Error: Duplicate slot numbers in servers array"
|
||||
|
||||
# Get backend info
|
||||
try:
|
||||
backend, server_prefix = get_backend_and_prefix(domain)
|
||||
except ValueError as e:
|
||||
return f"Error: {e}"
|
||||
|
||||
# Save ALL servers to config FIRST (disk-first pattern)
|
||||
for server_config in validated_servers:
|
||||
slot = server_config["slot"]
|
||||
ip = server_config["ip"]
|
||||
http_port = server_config["http_port"]
|
||||
add_server_to_config(domain, slot, ip, http_port)
|
||||
|
||||
# Then update HAProxy
|
||||
added = []
|
||||
errors = []
|
||||
failed_slots = []
|
||||
successfully_added_slots = []
|
||||
|
||||
try:
|
||||
for server_config in validated_servers:
|
||||
slot = server_config["slot"]
|
||||
ip = server_config["ip"]
|
||||
http_port = server_config["http_port"]
|
||||
try:
|
||||
configure_server_slot(backend, server_prefix, slot, ip, http_port)
|
||||
successfully_added_slots.append(slot)
|
||||
added.append(f"slot {slot}: {ip}:{http_port}")
|
||||
except HaproxyError as e:
|
||||
failed_slots.append(slot)
|
||||
errors.append(f"slot {slot}: {e}")
|
||||
except Exception as e:
|
||||
# Rollback only successfully added configs on unexpected error
|
||||
for slot in successfully_added_slots:
|
||||
try:
|
||||
remove_server_from_config(domain, slot)
|
||||
except Exception as rollback_error:
|
||||
logger.error(
|
||||
"Failed to rollback server config for %s slot %d: %s",
|
||||
domain, slot, rollback_error
|
||||
)
|
||||
# Also rollback configs that weren't yet processed
|
||||
for server_config in validated_servers:
|
||||
slot = server_config["slot"]
|
||||
if slot not in successfully_added_slots:
|
||||
try:
|
||||
remove_server_from_config(domain, slot)
|
||||
except Exception as rollback_error:
|
||||
logger.error(
|
||||
"Failed to rollback server config for %s slot %d: %s",
|
||||
domain, slot, rollback_error
|
||||
)
|
||||
return f"Error: {e}"
|
||||
|
||||
# Rollback failed slots from config
|
||||
for slot in failed_slots:
|
||||
try:
|
||||
remove_server_from_config(domain, slot)
|
||||
except Exception as rollback_error:
|
||||
logger.error(
|
||||
"Failed to rollback server config for %s slot %d: %s",
|
||||
domain, slot, rollback_error
|
||||
)
|
||||
|
||||
# Build result message
|
||||
result_parts = []
|
||||
if added:
|
||||
result_parts.append(f"Added {len(added)} servers to {domain} ({backend}):")
|
||||
result_parts.extend(f" • {s}" for s in added)
|
||||
if errors:
|
||||
result_parts.append(f"Failed to add {len(errors)} servers:")
|
||||
result_parts.extend(f" • {e}" for e in errors)
|
||||
|
||||
return "\n".join(result_parts) if result_parts else "No servers added"
|
||||
return _haproxy_add_servers_impl(domain, servers)
|
||||
|
||||
@mcp.tool()
|
||||
def haproxy_remove_server(
|
||||
@@ -320,37 +486,7 @@ def register_server_tools(mcp):
|
||||
|
||||
Example: haproxy_remove_server("api.example.com", slot=2)
|
||||
"""
|
||||
if not validate_domain(domain):
|
||||
return "Error: Invalid domain format"
|
||||
if not (1 <= slot <= MAX_SLOTS):
|
||||
return f"Error: Slot must be between 1 and {MAX_SLOTS}"
|
||||
|
||||
try:
|
||||
backend, server_prefix = get_backend_and_prefix(domain)
|
||||
|
||||
# Get current server info for potential rollback
|
||||
config = load_servers_config()
|
||||
old_config = config.get(domain, {}).get(str(slot), {})
|
||||
|
||||
# Remove from persistent config FIRST (disk-first pattern)
|
||||
remove_server_from_config(domain, slot)
|
||||
|
||||
try:
|
||||
# HTTP only - single server per slot
|
||||
server = f"{server_prefix}_{slot}"
|
||||
# Batch both commands in single TCP connection
|
||||
haproxy_cmd_batch([
|
||||
f"set server {backend}/{server} state maint",
|
||||
f"set server {backend}/{server} addr 0.0.0.0 port 0"
|
||||
])
|
||||
return f"Removed server at slot {slot} from {domain} ({backend})"
|
||||
except HaproxyError as e:
|
||||
# Rollback: re-add config if HAProxy command failed
|
||||
if old_config:
|
||||
add_server_to_config(domain, slot, old_config.get("ip", ""), old_config.get("http_port", 80))
|
||||
return f"Error: {e}"
|
||||
except (ValueError, IOError) as e:
|
||||
return f"Error: {e}"
|
||||
return _haproxy_remove_server_impl(domain, slot)
|
||||
|
||||
@mcp.tool()
|
||||
def haproxy_set_domain_state(
|
||||
@@ -368,48 +504,7 @@ def register_server_tools(mcp):
|
||||
# Re-enable all servers after deployment
|
||||
haproxy_set_domain_state("api.example.com", "ready")
|
||||
"""
|
||||
if not validate_domain(domain):
|
||||
return "Error: Invalid domain format"
|
||||
if state not in ["ready", "drain", "maint"]:
|
||||
return "Error: State must be 'ready', 'drain', or 'maint'"
|
||||
|
||||
try:
|
||||
backend, _ = get_backend_and_prefix(domain)
|
||||
except ValueError as e:
|
||||
return f"Error: {e}"
|
||||
|
||||
# Get active servers for this domain
|
||||
try:
|
||||
servers_state = haproxy_cmd("show servers state")
|
||||
except HaproxyError as e:
|
||||
return f"Error: {e}"
|
||||
|
||||
changed = []
|
||||
errors = []
|
||||
|
||||
for line in servers_state.split("\n"):
|
||||
parts = line.split()
|
||||
if len(parts) >= STATE_MIN_COLUMNS and parts[StateField.BE_NAME] == backend:
|
||||
server_name = parts[StateField.SRV_NAME]
|
||||
addr = parts[StateField.SRV_ADDR]
|
||||
# Only change state for configured servers (not 0.0.0.0)
|
||||
if addr != "0.0.0.0":
|
||||
try:
|
||||
haproxy_cmd_checked(f"set server {backend}/{server_name} state {state}")
|
||||
changed.append(server_name)
|
||||
except HaproxyError as e:
|
||||
errors.append(f"{server_name}: {e}")
|
||||
|
||||
if not changed and not errors:
|
||||
return f"No active servers found for {domain}"
|
||||
|
||||
result = f"Set {len(changed)} servers to '{state}' for {domain}"
|
||||
if changed:
|
||||
result += ":\n" + "\n".join(f" • {s}" for s in changed)
|
||||
if errors:
|
||||
result += f"\n\nErrors ({len(errors)}):\n" + "\n".join(f" • {e}" for e in errors)
|
||||
|
||||
return result
|
||||
return _haproxy_set_domain_state_impl(domain, state)
|
||||
|
||||
@mcp.tool()
|
||||
def haproxy_wait_drain(
|
||||
@@ -422,39 +517,7 @@ def register_server_tools(mcp):
|
||||
|
||||
Example: haproxy_wait_drain("api.example.com", timeout=60)
|
||||
"""
|
||||
if not validate_domain(domain):
|
||||
return "Error: Invalid domain format"
|
||||
if not (1 <= timeout <= 300):
|
||||
return "Error: Timeout must be between 1 and 300 seconds"
|
||||
|
||||
try:
|
||||
backend, _ = get_backend_and_prefix(domain)
|
||||
except ValueError as e:
|
||||
return f"Error: {e}"
|
||||
|
||||
start_time = time.time()
|
||||
while time.time() - start_time < timeout:
|
||||
try:
|
||||
stats = haproxy_cmd("show stat")
|
||||
total_connections = 0
|
||||
for line in stats.split("\n"):
|
||||
parts = line.split(",")
|
||||
if len(parts) > StatField.SCUR and parts[0] == backend and parts[1] not in ["FRONTEND", "BACKEND", ""]:
|
||||
try:
|
||||
scur = int(parts[StatField.SCUR]) if parts[StatField.SCUR] else 0
|
||||
total_connections += scur
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
if total_connections == 0:
|
||||
elapsed = int(time.time() - start_time)
|
||||
return f"All connections drained for {domain} ({elapsed}s)"
|
||||
|
||||
time.sleep(1)
|
||||
except HaproxyError as e:
|
||||
return f"Error checking connections: {e}"
|
||||
|
||||
return f"Timeout: Connections still active after {timeout}s"
|
||||
return _haproxy_wait_drain_impl(domain, timeout)
|
||||
|
||||
@mcp.tool()
|
||||
def haproxy_set_server_state(
|
||||
@@ -466,17 +529,7 @@ def register_server_tools(mcp):
|
||||
|
||||
Example: haproxy_set_server_state("pool_1", "pool_1_2", "maint")
|
||||
"""
|
||||
if not validate_backend_name(backend):
|
||||
return "Error: Invalid backend name (use alphanumeric, underscore, hyphen only)"
|
||||
if not validate_backend_name(server):
|
||||
return "Error: Invalid server name (use alphanumeric, underscore, hyphen only)"
|
||||
if state not in ["ready", "drain", "maint"]:
|
||||
return "Error: state must be 'ready', 'drain', or 'maint'"
|
||||
try:
|
||||
haproxy_cmd_checked(f"set server {backend}/{server} state {state}")
|
||||
return f"Server {backend}/{server} set to {state}"
|
||||
except HaproxyError as e:
|
||||
return f"Error: {e}"
|
||||
return _haproxy_set_server_state_impl(backend, server, state)
|
||||
|
||||
@mcp.tool()
|
||||
def haproxy_set_server_weight(
|
||||
@@ -488,14 +541,4 @@ def register_server_tools(mcp):
|
||||
|
||||
Example: haproxy_set_server_weight("pool_1", "pool_1_1", weight=2)
|
||||
"""
|
||||
if not validate_backend_name(backend):
|
||||
return "Error: Invalid backend name (use alphanumeric, underscore, hyphen only)"
|
||||
if not validate_backend_name(server):
|
||||
return "Error: Invalid server name (use alphanumeric, underscore, hyphen only)"
|
||||
if not (0 <= weight <= 256):
|
||||
return "Error: weight must be between 0 and 256"
|
||||
try:
|
||||
haproxy_cmd_checked(f"set server {backend}/{server} weight {weight}")
|
||||
return f"Server {backend}/{server} weight set to {weight}"
|
||||
except HaproxyError as e:
|
||||
return f"Error: {e}"
|
||||
return _haproxy_set_server_weight_impl(backend, server, weight)
|
||||
|
||||
Reference in New Issue
Block a user