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,7 @@
|
||||
"""Certificate management tools for HAProxy MCP Server."""
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
from datetime import datetime
|
||||
from typing import Annotated
|
||||
|
||||
@@ -152,7 +153,7 @@ def _haproxy_list_certs_impl() -> str:
|
||||
certs.append(f"• {domain} ({ca})\n Created: {created}\n Renew: {renew}\n Status: {status}")
|
||||
|
||||
return "\n\n".join(certs) if certs else "No certificates found"
|
||||
except TimeoutError:
|
||||
except (TimeoutError, subprocess.TimeoutExpired):
|
||||
return "Error: Command timed out"
|
||||
except FileNotFoundError:
|
||||
return "Error: acme.sh not found"
|
||||
@@ -203,7 +204,7 @@ def _haproxy_cert_info_impl(domain: str) -> str:
|
||||
result.stdout.strip()
|
||||
]
|
||||
return "\n".join(info)
|
||||
except TimeoutError:
|
||||
except (TimeoutError, subprocess.TimeoutExpired):
|
||||
return "Error: Command timed out"
|
||||
except OSError as e:
|
||||
logger.error("Error getting certificate info for %s: %s", domain, e)
|
||||
@@ -250,7 +251,7 @@ def _haproxy_issue_cert_impl(domain: str, wildcard: bool) -> str:
|
||||
else:
|
||||
return f"Certificate issued but PEM file not created. Check {host_path}"
|
||||
|
||||
except TimeoutError:
|
||||
except (TimeoutError, subprocess.TimeoutExpired):
|
||||
return f"Error: Certificate issuance timed out after {CERT_TIMEOUT}s"
|
||||
except OSError as e:
|
||||
logger.error("Error issuing certificate for %s: %s", domain, e)
|
||||
@@ -289,7 +290,7 @@ def _haproxy_renew_cert_impl(domain: str, force: bool) -> str:
|
||||
else:
|
||||
return f"Error renewing certificate:\n{output}"
|
||||
|
||||
except TimeoutError:
|
||||
except (TimeoutError, subprocess.TimeoutExpired):
|
||||
return f"Error: Certificate renewal timed out after {CERT_TIMEOUT}s"
|
||||
except FileNotFoundError:
|
||||
return "Error: acme.sh not found"
|
||||
@@ -323,7 +324,7 @@ def _haproxy_renew_all_certs_impl() -> str:
|
||||
else:
|
||||
return "Renewal check completed"
|
||||
|
||||
except TimeoutError:
|
||||
except (TimeoutError, subprocess.TimeoutExpired):
|
||||
return "Error: Renewal cron timed out"
|
||||
except FileNotFoundError:
|
||||
return "Error: acme.sh not found"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Configuration management tools for HAProxy MCP Server."""
|
||||
|
||||
import subprocess
|
||||
import time
|
||||
|
||||
from ..config import (
|
||||
@@ -177,7 +178,7 @@ def register_config_tools(mcp):
|
||||
if result.returncode == 0:
|
||||
return "Configuration is valid"
|
||||
return f"Configuration errors:\n{result.stderr}"
|
||||
except TimeoutError:
|
||||
except (TimeoutError, subprocess.TimeoutExpired):
|
||||
return f"Error: Command timed out after {SUBPROCESS_TIMEOUT} seconds"
|
||||
except FileNotFoundError:
|
||||
return "Error: ssh/podman command not found"
|
||||
|
||||
@@ -6,7 +6,6 @@ from typing import Annotated, Optional
|
||||
from pydantic import Field
|
||||
|
||||
from ..config import (
|
||||
MAP_FILE,
|
||||
MAP_FILE_CONTAINER,
|
||||
WILDCARDS_MAP_FILE_CONTAINER,
|
||||
POOL_COUNT,
|
||||
@@ -22,7 +21,6 @@ from ..validation import validate_domain, validate_ip, validate_port_int
|
||||
from ..haproxy_client import haproxy_cmd
|
||||
from ..file_ops import (
|
||||
get_map_contents,
|
||||
save_map_file,
|
||||
get_domain_backend,
|
||||
is_legacy_backend,
|
||||
add_server_to_config,
|
||||
@@ -31,30 +29,13 @@ from ..file_ops import (
|
||||
add_shared_domain_to_config,
|
||||
get_domains_sharing_pool,
|
||||
is_shared_domain,
|
||||
add_domain_to_map,
|
||||
remove_domain_from_map,
|
||||
find_available_pool,
|
||||
)
|
||||
from ..utils import parse_servers_state, disable_server_slot
|
||||
|
||||
|
||||
def _find_available_pool(entries: list[tuple[str, str]], used_pools: set[str]) -> Optional[str]:
|
||||
"""Find an available pool backend from the pool list.
|
||||
|
||||
Iterates through pool_1 to pool_N and returns the first pool
|
||||
that is not currently in use.
|
||||
|
||||
Args:
|
||||
entries: List of (domain, backend) tuples from the map file.
|
||||
used_pools: Set of pool names already in use.
|
||||
|
||||
Returns:
|
||||
Available pool name (e.g., "pool_5") or None if all pools are in use.
|
||||
"""
|
||||
for i in range(1, POOL_COUNT + 1):
|
||||
pool_name = f"pool_{i}"
|
||||
if pool_name not in used_pools:
|
||||
return pool_name
|
||||
return None
|
||||
|
||||
|
||||
def _check_subdomain(domain: str, registered_domains: set[str]) -> tuple[bool, Optional[str]]:
|
||||
"""Check if a domain is a subdomain of an existing registered domain.
|
||||
|
||||
@@ -95,24 +76,19 @@ def _update_haproxy_maps(domain: str, pool: str, is_subdomain: bool) -> None:
|
||||
haproxy_cmd(f"add map {WILDCARDS_MAP_FILE_CONTAINER} .{domain} {pool}")
|
||||
|
||||
|
||||
def _rollback_domain_addition(
|
||||
domain: str,
|
||||
entries: list[tuple[str, str]]
|
||||
) -> None:
|
||||
"""Rollback a failed domain addition by removing entries from map file.
|
||||
def _rollback_domain_addition(domain: str) -> None:
|
||||
"""Rollback a failed domain addition by removing from SQLite + map files.
|
||||
|
||||
Called when HAProxy Runtime API update fails after the map file
|
||||
has already been saved.
|
||||
Called when HAProxy Runtime API update fails after the domain
|
||||
has already been saved to the database.
|
||||
|
||||
Args:
|
||||
domain: Domain name that was added.
|
||||
entries: Current list of map entries to rollback from.
|
||||
"""
|
||||
rollback_entries = [(d, b) for d, b in entries if d != domain and d != f".{domain}"]
|
||||
try:
|
||||
save_map_file(rollback_entries)
|
||||
except IOError:
|
||||
logger.error("Failed to rollback map file after HAProxy error")
|
||||
remove_domain_from_map(domain)
|
||||
except (IOError, Exception):
|
||||
logger.error("Failed to rollback domain %s after HAProxy error", domain)
|
||||
|
||||
|
||||
def _file_exists(path: str) -> bool:
|
||||
@@ -242,93 +218,86 @@ def register_domain_tools(mcp):
|
||||
if share_with and ip:
|
||||
return "Error: Cannot specify both ip and share_with (shared domains use existing servers)"
|
||||
|
||||
# Use file locking for the entire pool allocation operation
|
||||
from ..file_ops import file_lock
|
||||
with file_lock(f"{MAP_FILE}.lock"):
|
||||
# Read map contents once for both existence check and pool lookup
|
||||
entries = get_map_contents()
|
||||
# Read current entries for existence check and subdomain detection
|
||||
entries = get_map_contents()
|
||||
|
||||
# Check if domain already exists (using cached entries)
|
||||
for domain_entry, backend in entries:
|
||||
if domain_entry == domain:
|
||||
return f"Error: Domain {domain} already exists (mapped to {backend})"
|
||||
# Check if domain already exists
|
||||
for domain_entry, backend in entries:
|
||||
if domain_entry == domain:
|
||||
return f"Error: Domain {domain} already exists (mapped to {backend})"
|
||||
|
||||
# Build used pools and registered domains sets
|
||||
used_pools: set[str] = set()
|
||||
registered_domains: set[str] = set()
|
||||
for entry_domain, backend in entries:
|
||||
if backend.startswith("pool_"):
|
||||
used_pools.add(backend)
|
||||
if not entry_domain.startswith("."):
|
||||
registered_domains.add(entry_domain)
|
||||
# Build registered domains set for subdomain check
|
||||
registered_domains: set[str] = set()
|
||||
for entry_domain, _ in entries:
|
||||
if not entry_domain.startswith("."):
|
||||
registered_domains.add(entry_domain)
|
||||
|
||||
# Handle share_with: reuse existing domain's pool
|
||||
if share_with:
|
||||
share_backend = get_domain_backend(share_with)
|
||||
if not share_backend:
|
||||
return f"Error: Domain {share_with} not found"
|
||||
if not share_backend.startswith("pool_"):
|
||||
return f"Error: Cannot share with legacy backend {share_backend}"
|
||||
pool = share_backend
|
||||
else:
|
||||
# Find available pool
|
||||
pool = _find_available_pool(entries, used_pools)
|
||||
if not pool:
|
||||
return f"Error: All {POOL_COUNT} pool backends are in use"
|
||||
# Handle share_with: reuse existing domain's pool
|
||||
if share_with:
|
||||
share_backend = get_domain_backend(share_with)
|
||||
if not share_backend:
|
||||
return f"Error: Domain {share_with} not found"
|
||||
if not share_backend.startswith("pool_"):
|
||||
return f"Error: Cannot share with legacy backend {share_backend}"
|
||||
pool = share_backend
|
||||
else:
|
||||
# Find available pool (SQLite query, O(1))
|
||||
pool = find_available_pool()
|
||||
if not pool:
|
||||
return f"Error: All {POOL_COUNT} pool backends are in use"
|
||||
|
||||
# Check if this is a subdomain of an existing domain
|
||||
is_subdomain, parent_domain = _check_subdomain(domain, registered_domains)
|
||||
# Check if this is a subdomain of an existing domain
|
||||
is_subdomain, parent_domain = _check_subdomain(domain, registered_domains)
|
||||
|
||||
try:
|
||||
# Save to SQLite + sync map files (atomic via SQLite transaction)
|
||||
try:
|
||||
# Save to disk first (atomic write for persistence)
|
||||
entries.append((domain, pool))
|
||||
add_domain_to_map(domain, pool)
|
||||
if not is_subdomain:
|
||||
entries.append((f".{domain}", pool))
|
||||
try:
|
||||
save_map_file(entries)
|
||||
except IOError as e:
|
||||
return f"Error: Failed to save map file: {e}"
|
||||
|
||||
# Update HAProxy maps via Runtime API
|
||||
try:
|
||||
_update_haproxy_maps(domain, pool, is_subdomain)
|
||||
except HaproxyError as e:
|
||||
_rollback_domain_addition(domain, entries)
|
||||
return f"Error: Failed to update HAProxy map: {e}"
|
||||
|
||||
# Handle server configuration based on mode
|
||||
if share_with:
|
||||
# Save shared domain reference
|
||||
add_shared_domain_to_config(domain, share_with)
|
||||
result = f"Domain {domain} added, sharing pool {pool} with {share_with}"
|
||||
elif ip:
|
||||
# Add server to slot 1
|
||||
add_server_to_config(domain, 1, ip, http_port)
|
||||
try:
|
||||
server = f"{pool}_1"
|
||||
haproxy_cmd(f"set server {pool}/{server} addr {ip} port {http_port}")
|
||||
haproxy_cmd(f"set server {pool}/{server} state ready")
|
||||
except HaproxyError as e:
|
||||
remove_server_from_config(domain, 1)
|
||||
return f"Domain {domain} added to {pool} but server config failed: {e}"
|
||||
result = f"Domain {domain} added to {pool} with server {ip}:{http_port}"
|
||||
else:
|
||||
result = f"Domain {domain} added to {pool} (no servers configured)"
|
||||
|
||||
if is_subdomain:
|
||||
result += f" (subdomain of {parent_domain}, no wildcard)"
|
||||
|
||||
# Check certificate coverage
|
||||
cert_covered, cert_info = check_certificate_coverage(domain)
|
||||
if cert_covered:
|
||||
result += f"\nSSL: Using certificate {cert_info}"
|
||||
else:
|
||||
result += f"\nSSL: No certificate found. Use haproxy_issue_cert(\"{domain}\") to issue one."
|
||||
|
||||
return result
|
||||
add_domain_to_map(f".{domain}", pool, is_wildcard=True)
|
||||
except (IOError, Exception) as e:
|
||||
return f"Error: Failed to save domain: {e}"
|
||||
|
||||
# Update HAProxy maps via Runtime API
|
||||
try:
|
||||
_update_haproxy_maps(domain, pool, is_subdomain)
|
||||
except HaproxyError as e:
|
||||
return f"Error: {e}"
|
||||
_rollback_domain_addition(domain)
|
||||
return f"Error: Failed to update HAProxy map: {e}"
|
||||
|
||||
# Handle server configuration based on mode
|
||||
if share_with:
|
||||
# Save shared domain reference
|
||||
add_shared_domain_to_config(domain, share_with)
|
||||
result = f"Domain {domain} added, sharing pool {pool} with {share_with}"
|
||||
elif ip:
|
||||
# Add server to slot 1
|
||||
add_server_to_config(domain, 1, ip, http_port)
|
||||
try:
|
||||
server = f"{pool}_1"
|
||||
haproxy_cmd(f"set server {pool}/{server} addr {ip} port {http_port}")
|
||||
haproxy_cmd(f"set server {pool}/{server} state ready")
|
||||
except HaproxyError as e:
|
||||
remove_server_from_config(domain, 1)
|
||||
return f"Domain {domain} added to {pool} but server config failed: {e}"
|
||||
result = f"Domain {domain} added to {pool} with server {ip}:{http_port}"
|
||||
else:
|
||||
result = f"Domain {domain} added to {pool} (no servers configured)"
|
||||
|
||||
if is_subdomain:
|
||||
result += f" (subdomain of {parent_domain}, no wildcard)"
|
||||
|
||||
# Check certificate coverage
|
||||
cert_covered, cert_info = check_certificate_coverage(domain)
|
||||
if cert_covered:
|
||||
result += f"\nSSL: Using certificate {cert_info}"
|
||||
else:
|
||||
result += f"\nSSL: No certificate found. Use haproxy_issue_cert(\"{domain}\") to issue one."
|
||||
|
||||
return result
|
||||
|
||||
except HaproxyError as e:
|
||||
return f"Error: {e}"
|
||||
|
||||
@mcp.tool()
|
||||
def haproxy_remove_domain(
|
||||
@@ -355,10 +324,8 @@ def register_domain_tools(mcp):
|
||||
domains_using_pool = get_domains_sharing_pool(backend)
|
||||
other_domains = [d for d in domains_using_pool if d != domain]
|
||||
|
||||
# Save to disk first (atomic write for persistence)
|
||||
entries = get_map_contents()
|
||||
new_entries = [(d, b) for d, b in entries if d != domain and d != f".{domain}"]
|
||||
save_map_file(new_entries)
|
||||
# Remove from SQLite + sync map files
|
||||
remove_domain_from_map(domain)
|
||||
|
||||
# Remove from persistent server config
|
||||
remove_domain_from_config(domain)
|
||||
|
||||
@@ -10,6 +10,7 @@ from pydantic import Field
|
||||
from ..config import (
|
||||
MAP_FILE,
|
||||
SERVERS_FILE,
|
||||
DB_FILE,
|
||||
HAPROXY_CONTAINER,
|
||||
)
|
||||
from ..exceptions import HaproxyError
|
||||
@@ -88,7 +89,7 @@ def register_health_tools(mcp):
|
||||
# Check configuration files
|
||||
files_ok = True
|
||||
file_status: dict[str, str] = {}
|
||||
for name, path in [("map_file", MAP_FILE), ("servers_file", SERVERS_FILE)]:
|
||||
for name, path in [("map_file", MAP_FILE), ("db_file", DB_FILE)]:
|
||||
exists = remote_file_exists(path) if REMOTE_MODE else __import__('os').path.exists(path)
|
||||
if exists:
|
||||
file_status[name] = "ok"
|
||||
|
||||
Reference in New Issue
Block a user