refactor: migrate data storage from JSON/map files to SQLite

Replace servers.json, certificates.json, and map file parsing with
SQLite (WAL mode) as single source of truth. HAProxy map files are
now generated from SQLite via sync_map_files().

Key changes:
- Add db.py with schema, connection management, and JSON migration
- Add DB_FILE config constant
- Delegate file_ops.py functions to db.py
- Refactor domains.py to use file_ops instead of direct list manipulation
- Fix subprocess.TimeoutExpired not caught (doesn't inherit TimeoutError)
- Add DB health check in health.py
- Init DB on startup in server.py and __main__.py
- Update all 359 tests to use SQLite-backed functions

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
kappa
2026-02-08 11:07:29 +09:00
parent 05bff61b85
commit cf554f3f89
19 changed files with 1525 additions and 564 deletions

View File

@@ -5,6 +5,8 @@ from unittest.mock import patch, MagicMock
import pytest
from haproxy_mcp.file_ops import add_domain_to_map, add_server_to_config
class TestRestoreServersFromConfig:
"""Tests for restore_servers_from_config function."""
@@ -19,12 +21,12 @@ class TestRestoreServersFromConfig:
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")
# Add domains and servers to database
add_domain_to_map("example.com", "pool_1")
add_server_to_config("example.com", 1, "10.0.0.1", 80)
add_server_to_config("example.com", 2, "10.0.0.2", 80)
add_domain_to_map("api.example.com", "pool_2")
add_server_to_config("api.example.com", 1, "10.0.0.10", 8080)
mock_sock = mock_socket_class(responses={
"set server": "",
@@ -40,9 +42,8 @@ class TestRestoreServersFromConfig:
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)
# Add server for unknown.com but no map entry (simulates missing domain)
add_server_to_config("unknown.com", 1, "10.0.0.1", 80)
mock_sock = mock_socket_class(responses={"set server": ""})
@@ -55,11 +56,9 @@ class TestRestoreServersFromConfig:
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")
# Add domain to map and server with empty IP (will be skipped during restore)
add_domain_to_map("example.com", "pool_1")
add_server_to_config("example.com", 1, "", 80)
mock_sock = mock_socket_class(responses={"set server": ""})
@@ -321,11 +320,12 @@ class TestHaproxyRestoreState:
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")
# Add domains and servers to database
add_domain_to_map("example.com", "pool_1")
add_server_to_config("example.com", 1, "10.0.0.1", 80)
add_server_to_config("example.com", 2, "10.0.0.2", 80)
add_domain_to_map("api.example.com", "pool_2")
add_server_to_config("api.example.com", 1, "10.0.0.10", 8080)
mock_sock = mock_socket_class(responses={"set server": ""})
@@ -373,17 +373,10 @@ class TestRestoreServersFromConfigBatchFailure:
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")
# Add domain and servers to database
add_domain_to_map("example.com", "pool_1")
add_server_to_config("example.com", 1, "10.0.0.1", 80)
add_server_to_config("example.com", 2, "10.0.0.2", 80)
# Track call count to simulate batch failure then individual success
call_count = [0]
@@ -457,51 +450,51 @@ class TestRestoreServersFromConfigBatchFailure:
def test_restore_servers_invalid_slot(self, mock_socket_class, mock_select, patch_config_paths):
"""Skip servers with invalid slot number."""
# Add domain to map
add_domain_to_map("example.com", "pool_1")
# Mock load_servers_config to return config with invalid slot
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")
with patch("haproxy_mcp.tools.configuration.load_servers_config", return_value=config):
mock_sock = mock_socket_class(responses={"set server": ""})
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
with patch("socket.socket", return_value=mock_sock):
from haproxy_mcp.tools.configuration import restore_servers_from_config
result = restore_servers_from_config()
result = restore_servers_from_config()
# Should only restore the valid server
assert result == 1
# 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
# Add domain to map
add_domain_to_map("example.com", "pool_1")
# Mock load_servers_config to return config with invalid port
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")
with patch("haproxy_mcp.tools.configuration.load_servers_config", return_value=config):
mock_sock = mock_socket_class(responses={"set server": ""})
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
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()
result = restore_servers_from_config()
# Should only restore the valid server
assert result == 1
# Should only restore the valid server
assert result == 1
class TestStartupRestoreFailures:
@@ -658,11 +651,12 @@ class TestHaproxyRestoreStateFailures:
"""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")
# Add domains and servers to database
add_domain_to_map("example.com", "pool_1")
add_server_to_config("example.com", 1, "10.0.0.1", 80)
add_server_to_config("example.com", 2, "10.0.0.2", 80)
add_domain_to_map("api.example.com", "pool_2")
add_server_to_config("api.example.com", 1, "10.0.0.10", 8080)
with patch("haproxy_mcp.tools.configuration.restore_servers_from_config", side_effect=HaproxyError("Connection refused")):
from haproxy_mcp.tools.configuration import register_config_tools