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:
@@ -1,6 +1,5 @@
|
||||
"""Unit tests for file_ops module."""
|
||||
"""Unit tests for file_ops module (SQLite-backed)."""
|
||||
|
||||
import json
|
||||
import os
|
||||
from unittest.mock import patch
|
||||
|
||||
@@ -16,14 +15,19 @@ from haproxy_mcp.file_ops import (
|
||||
get_legacy_backend_name,
|
||||
get_backend_and_prefix,
|
||||
load_servers_config,
|
||||
save_servers_config,
|
||||
add_server_to_config,
|
||||
remove_server_from_config,
|
||||
remove_domain_from_config,
|
||||
load_certs_config,
|
||||
save_certs_config,
|
||||
add_cert_to_config,
|
||||
remove_cert_from_config,
|
||||
add_domain_to_map,
|
||||
remove_domain_from_map,
|
||||
find_available_pool,
|
||||
add_shared_domain_to_config,
|
||||
get_shared_domain,
|
||||
is_shared_domain,
|
||||
get_domains_sharing_pool,
|
||||
)
|
||||
|
||||
|
||||
@@ -62,7 +66,7 @@ class TestAtomicWriteFile:
|
||||
def test_unicode_content(self, tmp_path):
|
||||
"""Unicode content is properly written."""
|
||||
file_path = str(tmp_path / "unicode.txt")
|
||||
content = "Hello, \u4e16\u754c!" # "Hello, World!" in Chinese
|
||||
content = "Hello, \u4e16\u754c!"
|
||||
|
||||
atomic_write_file(file_path, content)
|
||||
|
||||
@@ -81,66 +85,33 @@ class TestAtomicWriteFile:
|
||||
|
||||
|
||||
class TestGetMapContents:
|
||||
"""Tests for get_map_contents function."""
|
||||
"""Tests for get_map_contents function (SQLite-backed)."""
|
||||
|
||||
def test_read_map_file(self, patch_config_paths):
|
||||
"""Read entries from map file."""
|
||||
# Write test content to map file
|
||||
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")
|
||||
def test_empty_db(self, patch_config_paths):
|
||||
"""Empty database returns empty list."""
|
||||
entries = get_map_contents()
|
||||
assert entries == []
|
||||
|
||||
def test_read_domains(self, patch_config_paths):
|
||||
"""Read entries from database."""
|
||||
add_domain_to_map("example.com", "pool_1")
|
||||
add_domain_to_map("api.example.com", "pool_2")
|
||||
|
||||
entries = get_map_contents()
|
||||
|
||||
assert ("example.com", "pool_1") in entries
|
||||
assert ("api.example.com", "pool_2") in entries
|
||||
|
||||
def test_read_both_map_files(self, patch_config_paths):
|
||||
"""Read entries from both domains.map and wildcards.map."""
|
||||
with open(patch_config_paths["map_file"], "w") as f:
|
||||
f.write("example.com pool_1\n")
|
||||
|
||||
with open(patch_config_paths["wildcards_file"], "w") as f:
|
||||
f.write(".example.com pool_1\n")
|
||||
def test_read_with_wildcards(self, patch_config_paths):
|
||||
"""Read entries including wildcards."""
|
||||
add_domain_to_map("example.com", "pool_1")
|
||||
add_domain_to_map(".example.com", "pool_1", is_wildcard=True)
|
||||
|
||||
entries = get_map_contents()
|
||||
|
||||
assert ("example.com", "pool_1") in entries
|
||||
assert (".example.com", "pool_1") in entries
|
||||
|
||||
def test_skip_comments(self, patch_config_paths):
|
||||
"""Comments are skipped."""
|
||||
with open(patch_config_paths["map_file"], "w") as f:
|
||||
f.write("# This is a comment\n")
|
||||
f.write("example.com pool_1\n")
|
||||
f.write("# Another comment\n")
|
||||
|
||||
entries = get_map_contents()
|
||||
|
||||
assert len(entries) == 1
|
||||
assert entries[0] == ("example.com", "pool_1")
|
||||
|
||||
def test_skip_empty_lines(self, patch_config_paths):
|
||||
"""Empty lines are skipped."""
|
||||
with open(patch_config_paths["map_file"], "w") as f:
|
||||
f.write("\n")
|
||||
f.write("example.com pool_1\n")
|
||||
f.write("\n")
|
||||
f.write("api.example.com pool_2\n")
|
||||
|
||||
entries = get_map_contents()
|
||||
|
||||
assert len(entries) == 2
|
||||
|
||||
def test_file_not_found(self, patch_config_paths):
|
||||
"""Missing file returns empty list."""
|
||||
os.unlink(patch_config_paths["map_file"])
|
||||
os.unlink(patch_config_paths["wildcards_file"])
|
||||
|
||||
entries = get_map_contents()
|
||||
|
||||
assert entries == []
|
||||
|
||||
|
||||
class TestSplitDomainEntries:
|
||||
"""Tests for split_domain_entries function."""
|
||||
@@ -182,36 +153,30 @@ class TestSplitDomainEntries:
|
||||
|
||||
|
||||
class TestSaveMapFile:
|
||||
"""Tests for save_map_file function."""
|
||||
"""Tests for save_map_file function (syncs from DB to map files)."""
|
||||
|
||||
def test_save_entries(self, patch_config_paths):
|
||||
"""Save entries to separate map files."""
|
||||
entries = [
|
||||
("example.com", "pool_1"),
|
||||
(".example.com", "pool_1"),
|
||||
]
|
||||
add_domain_to_map("example.com", "pool_1")
|
||||
add_domain_to_map(".example.com", "pool_1", is_wildcard=True)
|
||||
|
||||
save_map_file(entries)
|
||||
save_map_file([]) # Entries param ignored, syncs from DB
|
||||
|
||||
# Check exact domains file
|
||||
with open(patch_config_paths["map_file"]) as f:
|
||||
content = f.read()
|
||||
assert "example.com pool_1" in content
|
||||
|
||||
# Check wildcards file
|
||||
with open(patch_config_paths["wildcards_file"]) as f:
|
||||
content = f.read()
|
||||
assert ".example.com pool_1" in content
|
||||
|
||||
def test_sorted_output(self, patch_config_paths):
|
||||
"""Entries are sorted in output."""
|
||||
entries = [
|
||||
("z.example.com", "pool_3"),
|
||||
("a.example.com", "pool_1"),
|
||||
("m.example.com", "pool_2"),
|
||||
]
|
||||
add_domain_to_map("z.example.com", "pool_3")
|
||||
add_domain_to_map("a.example.com", "pool_1")
|
||||
add_domain_to_map("m.example.com", "pool_2")
|
||||
|
||||
save_map_file(entries)
|
||||
save_map_file([])
|
||||
|
||||
with open(patch_config_paths["map_file"]) as f:
|
||||
lines = [l.strip() for l in f if l.strip() and not l.startswith("#")]
|
||||
@@ -222,12 +187,11 @@ class TestSaveMapFile:
|
||||
|
||||
|
||||
class TestGetDomainBackend:
|
||||
"""Tests for get_domain_backend function."""
|
||||
"""Tests for get_domain_backend function (SQLite-backed)."""
|
||||
|
||||
def test_find_existing_domain(self, patch_config_paths):
|
||||
"""Find backend for existing domain."""
|
||||
with open(patch_config_paths["map_file"], "w") as f:
|
||||
f.write("example.com pool_1\n")
|
||||
add_domain_to_map("example.com", "pool_1")
|
||||
|
||||
backend = get_domain_backend("example.com")
|
||||
|
||||
@@ -235,8 +199,7 @@ class TestGetDomainBackend:
|
||||
|
||||
def test_domain_not_found(self, patch_config_paths):
|
||||
"""Non-existent domain returns None."""
|
||||
with open(patch_config_paths["map_file"], "w") as f:
|
||||
f.write("example.com pool_1\n")
|
||||
add_domain_to_map("example.com", "pool_1")
|
||||
|
||||
backend = get_domain_backend("other.com")
|
||||
|
||||
@@ -271,8 +234,7 @@ class TestGetBackendAndPrefix:
|
||||
|
||||
def test_pool_backend(self, patch_config_paths):
|
||||
"""Pool backend returns pool-based prefix."""
|
||||
with open(patch_config_paths["map_file"], "w") as f:
|
||||
f.write("example.com pool_5\n")
|
||||
add_domain_to_map("example.com", "pool_5")
|
||||
|
||||
backend, prefix = get_backend_and_prefix("example.com")
|
||||
|
||||
@@ -288,48 +250,33 @@ class TestGetBackendAndPrefix:
|
||||
|
||||
|
||||
class TestLoadServersConfig:
|
||||
"""Tests for load_servers_config function."""
|
||||
"""Tests for load_servers_config function (SQLite-backed)."""
|
||||
|
||||
def test_load_existing_config(self, patch_config_paths, sample_servers_config):
|
||||
"""Load existing config file."""
|
||||
with open(patch_config_paths["servers_file"], "w") as f:
|
||||
json.dump(sample_servers_config, f)
|
||||
def test_load_empty_config(self, patch_config_paths):
|
||||
"""Empty database returns empty dict."""
|
||||
config = load_servers_config()
|
||||
assert config == {}
|
||||
|
||||
def test_load_with_servers(self, patch_config_paths):
|
||||
"""Load config with server entries."""
|
||||
add_server_to_config("example.com", 1, "10.0.0.1", 80)
|
||||
add_server_to_config("example.com", 2, "10.0.0.2", 80)
|
||||
|
||||
config = load_servers_config()
|
||||
|
||||
assert "example.com" in config
|
||||
assert config["example.com"]["1"]["ip"] == "10.0.0.1"
|
||||
assert config["example.com"]["2"]["ip"] == "10.0.0.2"
|
||||
|
||||
def test_file_not_found(self, patch_config_paths):
|
||||
"""Missing file returns empty dict."""
|
||||
os.unlink(patch_config_paths["servers_file"])
|
||||
def test_load_with_shared_domain(self, patch_config_paths):
|
||||
"""Load config with shared domain reference."""
|
||||
add_domain_to_map("example.com", "pool_1")
|
||||
add_domain_to_map("www.example.com", "pool_1")
|
||||
add_shared_domain_to_config("www.example.com", "example.com")
|
||||
|
||||
config = load_servers_config()
|
||||
|
||||
assert config == {}
|
||||
|
||||
def test_invalid_json(self, patch_config_paths):
|
||||
"""Invalid JSON returns empty dict."""
|
||||
with open(patch_config_paths["servers_file"], "w") as f:
|
||||
f.write("not valid json {{{")
|
||||
|
||||
config = load_servers_config()
|
||||
|
||||
assert config == {}
|
||||
|
||||
|
||||
class TestSaveServersConfig:
|
||||
"""Tests for save_servers_config function."""
|
||||
|
||||
def test_save_config(self, patch_config_paths):
|
||||
"""Save config to file."""
|
||||
config = {"example.com": {"1": {"ip": "10.0.0.1", "http_port": 80}}}
|
||||
|
||||
save_servers_config(config)
|
||||
|
||||
with open(patch_config_paths["servers_file"]) as f:
|
||||
loaded = json.load(f)
|
||||
assert loaded == config
|
||||
assert config["www.example.com"]["_shares"] == "example.com"
|
||||
|
||||
|
||||
class TestAddServerToConfig:
|
||||
@@ -373,17 +320,18 @@ class TestRemoveServerFromConfig:
|
||||
remove_server_from_config("example.com", 1)
|
||||
|
||||
config = load_servers_config()
|
||||
assert "1" not in config["example.com"]
|
||||
assert "1" not in config.get("example.com", {})
|
||||
assert "2" in config["example.com"]
|
||||
|
||||
def test_remove_last_server_removes_domain(self, patch_config_paths):
|
||||
"""Removing last server removes domain entry."""
|
||||
"""Removing last server removes domain entry from servers."""
|
||||
add_server_to_config("example.com", 1, "10.0.0.1", 80)
|
||||
|
||||
remove_server_from_config("example.com", 1)
|
||||
|
||||
config = load_servers_config()
|
||||
assert "example.com" not in config
|
||||
# Domain may or may not exist (no servers = no entry)
|
||||
assert config.get("example.com", {}).get("1") is None
|
||||
|
||||
def test_remove_nonexistent_server(self, patch_config_paths):
|
||||
"""Removing non-existent server is a no-op."""
|
||||
@@ -399,14 +347,14 @@ class TestRemoveDomainFromConfig:
|
||||
"""Tests for remove_domain_from_config function."""
|
||||
|
||||
def test_remove_existing_domain(self, patch_config_paths):
|
||||
"""Remove existing domain."""
|
||||
"""Remove existing domain's servers."""
|
||||
add_server_to_config("example.com", 1, "10.0.0.1", 80)
|
||||
add_server_to_config("other.com", 1, "10.0.0.2", 80)
|
||||
|
||||
remove_domain_from_config("example.com")
|
||||
|
||||
config = load_servers_config()
|
||||
assert "example.com" not in config
|
||||
assert config.get("example.com", {}).get("1") is None
|
||||
assert "other.com" in config
|
||||
|
||||
def test_remove_nonexistent_domain(self, patch_config_paths):
|
||||
@@ -420,40 +368,23 @@ class TestRemoveDomainFromConfig:
|
||||
|
||||
|
||||
class TestLoadCertsConfig:
|
||||
"""Tests for load_certs_config function."""
|
||||
"""Tests for load_certs_config function (SQLite-backed)."""
|
||||
|
||||
def test_load_existing_config(self, patch_config_paths):
|
||||
"""Load existing certs config."""
|
||||
with open(patch_config_paths["certs_file"], "w") as f:
|
||||
json.dump({"domains": ["example.com", "other.com"]}, f)
|
||||
def test_load_empty(self, patch_config_paths):
|
||||
"""Empty database returns empty list."""
|
||||
domains = load_certs_config()
|
||||
assert domains == []
|
||||
|
||||
def test_load_with_certs(self, patch_config_paths):
|
||||
"""Load certs from database."""
|
||||
add_cert_to_config("example.com")
|
||||
add_cert_to_config("other.com")
|
||||
|
||||
domains = load_certs_config()
|
||||
|
||||
assert "example.com" in domains
|
||||
assert "other.com" in domains
|
||||
|
||||
def test_file_not_found(self, patch_config_paths):
|
||||
"""Missing file returns empty list."""
|
||||
os.unlink(patch_config_paths["certs_file"])
|
||||
|
||||
domains = load_certs_config()
|
||||
|
||||
assert domains == []
|
||||
|
||||
|
||||
class TestSaveCertsConfig:
|
||||
"""Tests for save_certs_config function."""
|
||||
|
||||
def test_save_domains(self, patch_config_paths):
|
||||
"""Save domains to certs config."""
|
||||
save_certs_config(["z.com", "a.com"])
|
||||
|
||||
with open(patch_config_paths["certs_file"]) as f:
|
||||
data = json.load(f)
|
||||
|
||||
# Should be sorted
|
||||
assert data["domains"] == ["a.com", "z.com"]
|
||||
|
||||
|
||||
class TestAddCertToConfig:
|
||||
"""Tests for add_cert_to_config function."""
|
||||
@@ -496,3 +427,87 @@ class TestRemoveCertFromConfig:
|
||||
|
||||
domains = load_certs_config()
|
||||
assert "example.com" in domains
|
||||
|
||||
|
||||
class TestAddDomainToMap:
|
||||
"""Tests for add_domain_to_map function."""
|
||||
|
||||
def test_add_domain(self, patch_config_paths):
|
||||
"""Add a domain and verify map files are synced."""
|
||||
add_domain_to_map("example.com", "pool_1")
|
||||
|
||||
assert get_domain_backend("example.com") == "pool_1"
|
||||
|
||||
with open(patch_config_paths["map_file"]) as f:
|
||||
assert "example.com pool_1" in f.read()
|
||||
|
||||
def test_add_wildcard(self, patch_config_paths):
|
||||
"""Add a wildcard domain."""
|
||||
add_domain_to_map(".example.com", "pool_1", is_wildcard=True)
|
||||
|
||||
entries = get_map_contents()
|
||||
assert (".example.com", "pool_1") in entries
|
||||
|
||||
|
||||
class TestRemoveDomainFromMap:
|
||||
"""Tests for remove_domain_from_map function."""
|
||||
|
||||
def test_remove_domain(self, patch_config_paths):
|
||||
"""Remove a domain and its wildcard."""
|
||||
add_domain_to_map("example.com", "pool_1")
|
||||
add_domain_to_map(".example.com", "pool_1", is_wildcard=True)
|
||||
|
||||
remove_domain_from_map("example.com")
|
||||
|
||||
assert get_domain_backend("example.com") is None
|
||||
entries = get_map_contents()
|
||||
assert (".example.com", "pool_1") not in entries
|
||||
|
||||
|
||||
class TestFindAvailablePool:
|
||||
"""Tests for find_available_pool function."""
|
||||
|
||||
def test_first_pool_available(self, patch_config_paths):
|
||||
"""When no domains exist, pool_1 is returned."""
|
||||
pool = find_available_pool()
|
||||
assert pool == "pool_1"
|
||||
|
||||
def test_skip_used_pools(self, patch_config_paths):
|
||||
"""Used pools are skipped."""
|
||||
add_domain_to_map("example.com", "pool_1")
|
||||
add_domain_to_map("other.com", "pool_2")
|
||||
|
||||
pool = find_available_pool()
|
||||
assert pool == "pool_3"
|
||||
|
||||
|
||||
class TestSharedDomains:
|
||||
"""Tests for shared domain functions."""
|
||||
|
||||
def test_get_shared_domain(self, patch_config_paths):
|
||||
"""Get parent domain for shared domain."""
|
||||
add_domain_to_map("example.com", "pool_1")
|
||||
add_domain_to_map("www.example.com", "pool_1")
|
||||
add_shared_domain_to_config("www.example.com", "example.com")
|
||||
|
||||
assert get_shared_domain("www.example.com") == "example.com"
|
||||
|
||||
def test_is_shared_domain(self, patch_config_paths):
|
||||
"""Check if domain is shared."""
|
||||
add_domain_to_map("example.com", "pool_1")
|
||||
add_domain_to_map("www.example.com", "pool_1")
|
||||
add_shared_domain_to_config("www.example.com", "example.com")
|
||||
|
||||
assert is_shared_domain("www.example.com") is True
|
||||
assert is_shared_domain("example.com") is False
|
||||
|
||||
def test_get_domains_sharing_pool(self, patch_config_paths):
|
||||
"""Get all domains using a pool."""
|
||||
add_domain_to_map("example.com", "pool_1")
|
||||
add_domain_to_map("www.example.com", "pool_1")
|
||||
add_domain_to_map(".example.com", "pool_1", is_wildcard=True)
|
||||
|
||||
domains = get_domains_sharing_pool("pool_1")
|
||||
assert "example.com" in domains
|
||||
assert "www.example.com" in domains
|
||||
assert ".example.com" not in domains # Wildcards excluded
|
||||
|
||||
Reference in New Issue
Block a user