refactor: Improve code quality, error handling, and test coverage

- Add file_lock context manager to eliminate duplicate locking patterns
- Add ValidationError, ConfigurationError, CertificateError exceptions
- Improve rollback logic in haproxy_add_servers (track successful ops only)
- Decompose haproxy_add_domain into smaller helper functions
- Consolidate certificate constants (CERTS_DIR, ACME_HOME) to config.py
- Enhance docstrings for internal functions and magic numbers
- Add pytest framework with 48 new tests (269 -> 317 total)
- Increase test coverage from 76% to 86%
  - servers.py: 58% -> 82%
  - certificates.py: 67% -> 86%
  - configuration.py: 69% -> 94%

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
kaffa
2026-02-03 12:50:00 +09:00
parent 18ce812920
commit 6bcfee519c
25 changed files with 6852 additions and 125 deletions

View File

@@ -0,0 +1,749 @@
"""Unit tests for configuration management tools."""
import json
from unittest.mock import patch, MagicMock
import pytest
class TestRestoreServersFromConfig:
"""Tests for restore_servers_from_config function."""
def test_restore_empty_config(self, patch_config_paths):
"""No servers to restore when config is empty."""
from haproxy_mcp.tools.configuration import restore_servers_from_config
result = restore_servers_from_config()
assert result == 0
def test_restore_servers_success(self, mock_socket_class, mock_select, patch_config_paths, sample_servers_config):
"""Restore servers successfully."""
# Write config and map
with open(patch_config_paths["servers_file"], "w") as f:
json.dump(sample_servers_config, f)
with open(patch_config_paths["map_file"], "w") as f:
f.write("example.com pool_1\n")
f.write("api.example.com pool_2\n")
mock_sock = mock_socket_class(responses={
"set server": "",
})
with patch("socket.socket", return_value=mock_sock):
from haproxy_mcp.tools.configuration import restore_servers_from_config
result = restore_servers_from_config()
# example.com has 2 servers, api.example.com has 1
assert result == 3
def test_restore_servers_skip_missing_domain(self, mock_socket_class, mock_select, patch_config_paths):
"""Skip domains not in map file."""
config = {"unknown.com": {"1": {"ip": "10.0.0.1", "http_port": 80}}}
with open(patch_config_paths["servers_file"], "w") as f:
json.dump(config, f)
mock_sock = mock_socket_class(responses={"set server": ""})
with patch("socket.socket", return_value=mock_sock):
from haproxy_mcp.tools.configuration import restore_servers_from_config
result = restore_servers_from_config()
assert result == 0
def test_restore_servers_skip_empty_ip(self, mock_socket_class, mock_select, patch_config_paths):
"""Skip servers with empty IP."""
config = {"example.com": {"1": {"ip": "", "http_port": 80}}}
with open(patch_config_paths["servers_file"], "w") as f:
json.dump(config, f)
with open(patch_config_paths["map_file"], "w") as f:
f.write("example.com pool_1\n")
mock_sock = mock_socket_class(responses={"set server": ""})
with patch("socket.socket", return_value=mock_sock):
from haproxy_mcp.tools.configuration import restore_servers_from_config
result = restore_servers_from_config()
assert result == 0
class TestStartupRestore:
"""Tests for startup_restore function."""
def test_startup_restore_haproxy_not_ready(self, mock_select):
"""Skip restore if HAProxy is not ready."""
call_count = 0
def raise_error(*args, **kwargs):
nonlocal call_count
call_count += 1
raise ConnectionRefusedError()
with patch("socket.socket", side_effect=raise_error):
with patch("haproxy_mcp.tools.configuration.STARTUP_RETRY_COUNT", 2):
from haproxy_mcp.tools.configuration import startup_restore
startup_restore()
# Should have tried multiple times
assert call_count >= 2
def test_startup_restore_success(self, mock_socket_class, mock_select, patch_config_paths, response_builder):
"""Successfully restore servers and certificates on startup."""
mock_sock = mock_socket_class(responses={
"show info": response_builder.info(),
"set server": "",
"show ssl cert": "",
})
with patch("socket.socket", return_value=mock_sock):
with patch("haproxy_mcp.tools.configuration.restore_servers_from_config", return_value=0):
with patch("haproxy_mcp.tools.certificates.restore_certificates", return_value=0):
from haproxy_mcp.tools.configuration import startup_restore
startup_restore()
# No assertions needed - just verify no exceptions
class TestHaproxyReload:
"""Tests for haproxy_reload tool function."""
def test_reload_success(self, mock_socket_class, mock_select, mock_subprocess, response_builder):
"""Reload HAProxy successfully."""
mock_subprocess.return_value = MagicMock(returncode=0, stdout="", stderr="")
mock_sock = mock_socket_class(responses={
"show info": response_builder.info(),
"set server": "",
})
with patch("socket.socket", return_value=mock_sock):
with patch("haproxy_mcp.tools.configuration.restore_servers_from_config", return_value=5):
from haproxy_mcp.tools.configuration import register_config_tools
mcp = MagicMock()
registered_tools = {}
def capture_tool():
def decorator(func):
registered_tools[func.__name__] = func
return func
return decorator
mcp.tool = capture_tool
register_config_tools(mcp)
result = registered_tools["haproxy_reload"]()
assert "reloaded successfully" in result
assert "5 servers restored" in result
def test_reload_validation_failure(self, mock_subprocess):
"""Reload fails on config validation error."""
mock_subprocess.return_value = MagicMock(
returncode=1,
stdout="",
stderr="Configuration error"
)
from haproxy_mcp.tools.configuration import register_config_tools
mcp = MagicMock()
registered_tools = {}
def capture_tool():
def decorator(func):
registered_tools[func.__name__] = func
return func
return decorator
mcp.tool = capture_tool
register_config_tools(mcp)
result = registered_tools["haproxy_reload"]()
assert "validation failed" in result.lower() or "Configuration error" in result
class TestHaproxyCheckConfig:
"""Tests for haproxy_check_config tool function."""
def test_check_config_valid(self, mock_subprocess):
"""Configuration is valid."""
mock_subprocess.return_value = MagicMock(returncode=0, stdout="", stderr="")
from haproxy_mcp.tools.configuration import register_config_tools
mcp = MagicMock()
registered_tools = {}
def capture_tool():
def decorator(func):
registered_tools[func.__name__] = func
return func
return decorator
mcp.tool = capture_tool
register_config_tools(mcp)
result = registered_tools["haproxy_check_config"]()
assert "valid" in result.lower()
def test_check_config_invalid(self, mock_subprocess):
"""Configuration has errors."""
mock_subprocess.return_value = MagicMock(
returncode=1,
stdout="",
stderr="[ALERT] Syntax error"
)
from haproxy_mcp.tools.configuration import register_config_tools
mcp = MagicMock()
registered_tools = {}
def capture_tool():
def decorator(func):
registered_tools[func.__name__] = func
return func
return decorator
mcp.tool = capture_tool
register_config_tools(mcp)
result = registered_tools["haproxy_check_config"]()
assert "error" in result.lower()
assert "Syntax error" in result
def test_check_config_timeout(self, mock_subprocess):
"""Configuration check times out."""
import subprocess
mock_subprocess.side_effect = subprocess.TimeoutExpired("podman", 30)
from haproxy_mcp.tools.configuration import register_config_tools
mcp = MagicMock()
registered_tools = {}
def capture_tool():
def decorator(func):
registered_tools[func.__name__] = func
return func
return decorator
mcp.tool = capture_tool
register_config_tools(mcp)
result = registered_tools["haproxy_check_config"]()
assert "timed out" in result.lower()
def test_check_config_podman_not_found(self, mock_subprocess):
"""Podman not found."""
mock_subprocess.side_effect = FileNotFoundError()
from haproxy_mcp.tools.configuration import register_config_tools
mcp = MagicMock()
registered_tools = {}
def capture_tool():
def decorator(func):
registered_tools[func.__name__] = func
return func
return decorator
mcp.tool = capture_tool
register_config_tools(mcp)
result = registered_tools["haproxy_check_config"]()
assert "podman" in result.lower()
assert "not found" in result.lower()
class TestHaproxySaveState:
"""Tests for haproxy_save_state tool function."""
def test_save_state_success(self, mock_socket_class, mock_select, patch_config_paths, response_builder):
"""Save state successfully."""
mock_sock = mock_socket_class(responses={
"show servers state": response_builder.servers_state([
{"be_name": "pool_1", "srv_name": "pool_1_1", "srv_addr": "10.0.0.1", "srv_port": 80},
]),
})
with patch("haproxy_mcp.tools.configuration.STATE_FILE", patch_config_paths["state_file"]):
with patch("socket.socket", return_value=mock_sock):
from haproxy_mcp.tools.configuration import register_config_tools
mcp = MagicMock()
registered_tools = {}
def capture_tool():
def decorator(func):
registered_tools[func.__name__] = func
return func
return decorator
mcp.tool = capture_tool
register_config_tools(mcp)
result = registered_tools["haproxy_save_state"]()
assert "saved" in result.lower()
def test_save_state_haproxy_error(self, mock_select):
"""Handle HAProxy connection error."""
def raise_error(*args, **kwargs):
raise ConnectionRefusedError()
with patch("socket.socket", side_effect=raise_error):
from haproxy_mcp.tools.configuration import register_config_tools
mcp = MagicMock()
registered_tools = {}
def capture_tool():
def decorator(func):
registered_tools[func.__name__] = func
return func
return decorator
mcp.tool = capture_tool
register_config_tools(mcp)
result = registered_tools["haproxy_save_state"]()
assert "Error" in result
class TestHaproxyRestoreState:
"""Tests for haproxy_restore_state tool function."""
def test_restore_state_success(self, mock_socket_class, mock_select, patch_config_paths, sample_servers_config):
"""Restore state successfully."""
with open(patch_config_paths["servers_file"], "w") as f:
json.dump(sample_servers_config, f)
with open(patch_config_paths["map_file"], "w") as f:
f.write("example.com pool_1\n")
f.write("api.example.com pool_2\n")
mock_sock = mock_socket_class(responses={"set server": ""})
with patch("socket.socket", return_value=mock_sock):
from haproxy_mcp.tools.configuration import register_config_tools
mcp = MagicMock()
registered_tools = {}
def capture_tool():
def decorator(func):
registered_tools[func.__name__] = func
return func
return decorator
mcp.tool = capture_tool
register_config_tools(mcp)
result = registered_tools["haproxy_restore_state"]()
assert "restored" in result.lower()
assert "3 servers" in result
def test_restore_state_no_servers(self, patch_config_paths):
"""No servers to restore."""
from haproxy_mcp.tools.configuration import register_config_tools
mcp = MagicMock()
registered_tools = {}
def capture_tool():
def decorator(func):
registered_tools[func.__name__] = func
return func
return decorator
mcp.tool = capture_tool
register_config_tools(mcp)
result = registered_tools["haproxy_restore_state"]()
assert "No servers to restore" in result
class TestRestoreServersFromConfigBatchFailure:
"""Tests for restore_servers_from_config batch failure and fallback."""
def test_restore_servers_batch_failure_fallback(self, mock_socket_class, mock_select, patch_config_paths):
"""Fall back to individual commands when batch fails."""
# Create config with servers
config = {
"example.com": {
"1": {"ip": "10.0.0.1", "http_port": 80},
"2": {"ip": "10.0.0.2", "http_port": 80},
}
}
with open(patch_config_paths["servers_file"], "w") as f:
json.dump(config, f)
with open(patch_config_paths["map_file"], "w") as f:
f.write("example.com pool_1\n")
# Track call count to simulate batch failure then individual success
call_count = [0]
class BatchFailMockSocket:
def __init__(self):
self.sent_commands = []
self._response_buffer = b""
self._closed = False
def connect(self, address):
pass
def settimeout(self, timeout):
pass
def setblocking(self, blocking):
pass
def sendall(self, data):
command = data.decode().strip()
self.sent_commands.append(command)
call_count[0] += 1
# First batch call fails (contains multiple commands)
if call_count[0] == 1 and "\n" in data.decode():
self._response_buffer = b"error: batch command failed"
else:
self._response_buffer = b""
def shutdown(self, how):
pass
def recv(self, bufsize):
if self._response_buffer:
data = self._response_buffer[:bufsize]
self._response_buffer = self._response_buffer[bufsize:]
return data
return b""
def close(self):
self._closed = True
def fileno(self):
return 999
def __enter__(self):
return self
def __exit__(self, *args):
self.close()
mock_sock = BatchFailMockSocket()
with patch("socket.socket", return_value=mock_sock):
from haproxy_mcp.tools.configuration import restore_servers_from_config
from haproxy_mcp.exceptions import HaproxyError
# Mock batch to raise error
with patch("haproxy_mcp.tools.configuration.haproxy_cmd_batch") as mock_batch:
# First call (batch) fails, subsequent calls succeed
mock_batch.side_effect = [
HaproxyError("Batch failed"), # Initial batch fails
None, # Individual server 1 succeeds
None, # Individual server 2 succeeds
]
result = restore_servers_from_config()
# Should have restored servers via individual commands
assert result == 2
def test_restore_servers_invalid_slot(self, mock_socket_class, mock_select, patch_config_paths):
"""Skip servers with invalid slot number."""
config = {
"example.com": {
"invalid": {"ip": "10.0.0.1", "http_port": 80}, # Invalid slot
"1": {"ip": "10.0.0.2", "http_port": 80}, # Valid slot
}
}
with open(patch_config_paths["servers_file"], "w") as f:
json.dump(config, f)
with open(patch_config_paths["map_file"], "w") as f:
f.write("example.com pool_1\n")
mock_sock = mock_socket_class(responses={"set server": ""})
with patch("socket.socket", return_value=mock_sock):
from haproxy_mcp.tools.configuration import restore_servers_from_config
result = restore_servers_from_config()
# Should only restore the valid server
assert result == 1
def test_restore_servers_invalid_port(self, mock_socket_class, mock_select, patch_config_paths, caplog):
"""Skip servers with invalid port."""
import logging
config = {
"example.com": {
"1": {"ip": "10.0.0.1", "http_port": "invalid"}, # Invalid port
"2": {"ip": "10.0.0.2", "http_port": 80}, # Valid port
}
}
with open(patch_config_paths["servers_file"], "w") as f:
json.dump(config, f)
with open(patch_config_paths["map_file"], "w") as f:
f.write("example.com pool_1\n")
mock_sock = mock_socket_class(responses={"set server": ""})
with patch("socket.socket", return_value=mock_sock):
with caplog.at_level(logging.WARNING, logger="haproxy_mcp"):
from haproxy_mcp.tools.configuration import restore_servers_from_config
result = restore_servers_from_config()
# Should only restore the valid server
assert result == 1
class TestStartupRestoreFailures:
"""Tests for startup_restore failure scenarios."""
def test_startup_restore_haproxy_timeout(self, mock_select):
"""Skip restore if HAProxy doesn't become ready in time."""
from haproxy_mcp.exceptions import HaproxyError
# Mock haproxy_cmd to always fail
with patch("haproxy_mcp.tools.configuration.haproxy_cmd", side_effect=HaproxyError("Connection refused")):
with patch("haproxy_mcp.tools.configuration.STARTUP_RETRY_COUNT", 2):
with patch("time.sleep", return_value=None):
from haproxy_mcp.tools.configuration import startup_restore
# Should not raise, just log warning
startup_restore()
def test_startup_restore_server_restore_failure(self, mock_socket_class, mock_select, patch_config_paths, response_builder, caplog):
"""Handle server restore failure during startup."""
import logging
mock_sock = mock_socket_class(responses={
"show info": response_builder.info(),
})
with patch("socket.socket", return_value=mock_sock):
with patch("haproxy_mcp.tools.configuration.restore_servers_from_config", side_effect=OSError("Disk error")):
with patch("haproxy_mcp.tools.certificates.restore_certificates", return_value=0):
with caplog.at_level(logging.WARNING, logger="haproxy_mcp"):
from haproxy_mcp.tools.configuration import startup_restore
startup_restore()
# Should have logged the failure
assert any("Failed to restore servers" in record.message for record in caplog.records)
def test_startup_restore_certificate_failure(self, mock_socket_class, mock_select, patch_config_paths, response_builder, caplog):
"""Handle certificate restore failure during startup."""
import logging
mock_sock = mock_socket_class(responses={
"show info": response_builder.info(),
})
with patch("socket.socket", return_value=mock_sock):
with patch("haproxy_mcp.tools.configuration.restore_servers_from_config", return_value=0):
with patch("haproxy_mcp.tools.certificates.restore_certificates", side_effect=Exception("Certificate error")):
with caplog.at_level(logging.WARNING, logger="haproxy_mcp"):
from haproxy_mcp.tools.configuration import startup_restore
startup_restore()
# Should have logged the failure
assert any("Failed to restore certificates" in record.message for record in caplog.records)
class TestHaproxyReloadFailures:
"""Tests for haproxy_reload failure scenarios."""
def test_reload_haproxy_not_responding_after_reload(self, mock_subprocess, response_builder):
"""Handle HAProxy not responding after reload."""
from haproxy_mcp.exceptions import HaproxyError
mock_subprocess.return_value = MagicMock(returncode=0, stdout="", stderr="")
# Mock haproxy_cmd to fail after reload
with patch("haproxy_mcp.haproxy_client.reload_haproxy", return_value=(True, "Reloaded")):
with patch("haproxy_mcp.tools.configuration.haproxy_cmd", side_effect=HaproxyError("Not responding")):
with patch("haproxy_mcp.tools.configuration.STARTUP_RETRY_COUNT", 2):
with patch("time.sleep", return_value=None):
from haproxy_mcp.tools.configuration import register_config_tools
mcp = MagicMock()
registered_tools = {}
def capture_tool():
def decorator(func):
registered_tools[func.__name__] = func
return func
return decorator
mcp.tool = capture_tool
register_config_tools(mcp)
result = registered_tools["haproxy_reload"]()
assert "not responding" in result.lower()
def test_reload_server_restore_failure(self, mock_subprocess, mock_socket_class, mock_select, response_builder):
"""Handle server restore failure after reload."""
mock_subprocess.return_value = MagicMock(returncode=0, stdout="", stderr="")
mock_sock = mock_socket_class(responses={
"show info": response_builder.info(),
})
with patch("socket.socket", return_value=mock_sock):
with patch("haproxy_mcp.haproxy_client.reload_haproxy", return_value=(True, "Reloaded")):
with patch("haproxy_mcp.tools.configuration.restore_servers_from_config", side_effect=Exception("Restore failed")):
with patch("time.sleep", return_value=None):
from haproxy_mcp.tools.configuration import register_config_tools
mcp = MagicMock()
registered_tools = {}
def capture_tool():
def decorator(func):
registered_tools[func.__name__] = func
return func
return decorator
mcp.tool = capture_tool
register_config_tools(mcp)
result = registered_tools["haproxy_reload"]()
assert "reloaded" in result.lower()
assert "failed" in result.lower()
class TestHaproxySaveStateFailures:
"""Tests for haproxy_save_state failure scenarios."""
def test_save_state_io_error(self, mock_socket_class, mock_select, patch_config_paths, response_builder):
"""Handle IO error when saving state."""
mock_sock = mock_socket_class(responses={
"show servers state": response_builder.servers_state([
{"be_name": "pool_1", "srv_name": "pool_1_1", "srv_addr": "10.0.0.1", "srv_port": 80},
]),
})
with patch("haproxy_mcp.tools.configuration.STATE_FILE", patch_config_paths["state_file"]):
with patch("socket.socket", return_value=mock_sock):
with patch("haproxy_mcp.tools.configuration.atomic_write_file", side_effect=IOError("Disk full")):
from haproxy_mcp.tools.configuration import register_config_tools
mcp = MagicMock()
registered_tools = {}
def capture_tool():
def decorator(func):
registered_tools[func.__name__] = func
return func
return decorator
mcp.tool = capture_tool
register_config_tools(mcp)
result = registered_tools["haproxy_save_state"]()
assert "Error" in result
class TestHaproxyRestoreStateFailures:
"""Tests for haproxy_restore_state failure scenarios."""
def test_restore_state_haproxy_error(self, mock_socket_class, mock_select, patch_config_paths, sample_servers_config):
"""Handle HAProxy error when restoring state."""
from haproxy_mcp.exceptions import HaproxyError
with open(patch_config_paths["servers_file"], "w") as f:
json.dump(sample_servers_config, f)
with open(patch_config_paths["map_file"], "w") as f:
f.write("example.com pool_1\n")
f.write("api.example.com pool_2\n")
with patch("haproxy_mcp.tools.configuration.restore_servers_from_config", side_effect=HaproxyError("Connection refused")):
from haproxy_mcp.tools.configuration import register_config_tools
mcp = MagicMock()
registered_tools = {}
def capture_tool():
def decorator(func):
registered_tools[func.__name__] = func
return func
return decorator
mcp.tool = capture_tool
register_config_tools(mcp)
result = registered_tools["haproxy_restore_state"]()
assert "Error" in result
def test_restore_state_os_error(self, patch_config_paths):
"""Handle OS error when restoring state."""
with patch("haproxy_mcp.tools.configuration.restore_servers_from_config", side_effect=OSError("File not found")):
from haproxy_mcp.tools.configuration import register_config_tools
mcp = MagicMock()
registered_tools = {}
def capture_tool():
def decorator(func):
registered_tools[func.__name__] = func
return func
return decorator
mcp.tool = capture_tool
register_config_tools(mcp)
result = registered_tools["haproxy_restore_state"]()
assert "Error" in result
def test_restore_state_value_error(self, patch_config_paths):
"""Handle ValueError when restoring state."""
with patch("haproxy_mcp.tools.configuration.restore_servers_from_config", side_effect=ValueError("Invalid config")):
from haproxy_mcp.tools.configuration import register_config_tools
mcp = MagicMock()
registered_tools = {}
def capture_tool():
def decorator(func):
registered_tools[func.__name__] = func
return func
return decorator
mcp.tool = capture_tool
register_config_tools(mcp)
result = registered_tools["haproxy_restore_state"]()
assert "Error" in result
class TestHaproxyCheckConfigOSError:
"""Tests for haproxy_check_config OS error handling."""
def test_check_config_os_error(self, mock_subprocess):
"""Handle OS error during config check."""
mock_subprocess.side_effect = OSError("Permission denied")
from haproxy_mcp.tools.configuration import register_config_tools
mcp = MagicMock()
registered_tools = {}
def capture_tool():
def decorator(func):
registered_tools[func.__name__] = func
return func
return decorator
mcp.tool = capture_tool
register_config_tools(mcp)
result = registered_tools["haproxy_check_config"]()
assert "Error" in result
assert "OS error" in result