From c48456dc18cdc467f354dfd2725016d3de83bdf4 Mon Sep 17 00:00:00 2001 From: kaffa Date: Sun, 1 Feb 2026 13:58:14 +0000 Subject: [PATCH] feat: Add batch operations, container health, code cleanup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 7. Add haproxy_set_domain_state() tool - Set all servers of a domain to ready/drain/maint at once - Useful for maintenance windows and deployments - Reports changed servers and any errors 8. Add container status to haproxy_health() - Check HAProxy container state via podman inspect - Reports "ok" when running, actual state otherwise - Sets overall health to "unhealthy" on container issues 9. Extract configure_server_slot() helper - Eliminates duplicated server configuration code - Used by haproxy_add_server() and haproxy_add_servers() - Cleaner, more maintainable code MCP Tools: 20 → 21 Co-Authored-By: Claude Opus 4.5 --- mcp/server.py | 124 ++++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 111 insertions(+), 13 deletions(-) diff --git a/mcp/server.py b/mcp/server.py index ab0d86b..0d27913 100644 --- a/mcp/server.py +++ b/mcp/server.py @@ -584,6 +584,28 @@ def get_server_suffixes(http_port: int) -> List[Tuple[str, int]]: return [("", http_port)] +def configure_server_slot(backend: str, server_prefix: str, slot: int, ip: str, http_port: int) -> str: + """Configure a server slot in HAProxy. + + Args: + backend: Backend name (e.g., 'pool_1') + server_prefix: Server name prefix (e.g., 'pool_1') + slot: Slot number (1-10) + ip: Server IP address + http_port: HTTP port + + Returns: + Server name that was configured + + Raises: + HaproxyError: If HAProxy command fails + """ + server = f"{server_prefix}_{slot}" + haproxy_cmd_checked(f"set server {backend}/{server} addr {ip} port {http_port}") + haproxy_cmd_checked(f"set server {backend}/{server} state ready") + return server + + def get_backend_and_prefix(domain: str) -> Tuple[str, str]: """Look up backend and determine server name prefix for a domain. @@ -938,14 +960,8 @@ def haproxy_add_server(domain: str, slot: int, ip: str, http_port: int = 80) -> add_server_to_config(domain, slot, ip, http_port) try: - results = [] - for suffix, port in get_server_suffixes(http_port): - server = f"{server_prefix}{suffix}_{slot}" - haproxy_cmd_checked(f"set server {backend}/{server} addr {ip} port {port}") - haproxy_cmd_checked(f"set server {backend}/{server} state ready") - results.append(f"{server} → {ip}:{port}") - - return f"Added to {domain} ({backend}) slot {slot}:\n" + "\n".join(results) + 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) @@ -1066,11 +1082,7 @@ def haproxy_add_servers(domain: str, servers: str) -> str: http_port = srv["http_port"] try: - for suffix, port in get_server_suffixes(http_port): - server = f"{server_prefix}{suffix}_{slot}" - haproxy_cmd_checked(f"set server {backend}/{server} addr {ip} port {port}") - haproxy_cmd_checked(f"set server {backend}/{server} state ready") - + server = configure_server_slot(backend, server_prefix, slot, ip, http_port) # Save to persistent config add_server_to_config(domain, slot, ip, http_port) added.append(f"slot {slot}: {ip}:{http_port}") @@ -1128,6 +1140,71 @@ def haproxy_remove_server(domain: str, slot: int) -> str: return f"Error: {e}" +@mcp.tool() +def haproxy_set_domain_state(domain: str, state: str) -> str: + """Set state for all servers of a domain at once. + + Useful for maintenance windows or deployments - set all servers to drain/maint + before updates, then back to ready after. + + Args: + domain: The domain name + state: Target state - ready, drain, or maint + + Returns: + Summary of state changes or error + + Example: + # Put all servers in maintenance for deployment + haproxy_set_domain_state("api.example.com", "maint") + + # 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, server_prefix = get_backend_and_prefix(domain) + except ValueError as e: + return f"Error: {e}" + + # Get active servers for this domain + try: + state_output = haproxy_cmd("show servers state") + except HaproxyError as e: + return f"Error: {e}" + + changed = [] + errors = [] + + for line in state_output.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 + + @mcp.tool() def haproxy_stats() -> str: """Get HAProxy status and statistics. @@ -1194,6 +1271,27 @@ def haproxy_health() -> str: result["components"]["haproxy"]["error"] = str(e) result["status"] = "degraded" + # Check container status + try: + container_result = subprocess.run( + ["podman", "inspect", "--format", "{{.State.Status}}", HAPROXY_CONTAINER], + capture_output=True, text=True, timeout=5 + ) + if container_result.returncode == 0: + container_status = container_result.stdout.strip() + result["components"]["container"] = { + "status": "ok" if container_status == "running" else container_status, + "state": container_status + } + else: + result["components"]["container"] = {"status": "error", "error": container_result.stderr.strip()} + result["status"] = "unhealthy" + except subprocess.TimeoutExpired: + result["components"]["container"] = {"status": "timeout"} + result["status"] = "unhealthy" + except Exception as e: + result["components"]["container"] = {"status": "error", "error": str(e)} + # Check configuration files files_ok = True file_status: Dict[str, str] = {}