refactor: Make service dependencies explicit and injectable

Remove hidden cross-service coupling by making dependencies explicit through
dependency injection while maintaining backward compatibility via lazy imports.

Key changes:
- history_service and ban_service: Removed direct module-level imports of
  fail2ban_metadata_service, added optional service parameters to functions
- Added get_fail2ban_metadata_service() provider to dependencies.py
- Updated history router to inject Fail2BanMetadataService dependency
- history_service functions now use lazy imports in fallback paths for
  backward compatibility when service is not explicitly injected
- All test patches updated to use internal _get_fail2ban_db_path() helper
- jail_config_service and jail_service already follow best practices

This pattern prevents circular imports, makes services testable via explicit
mocking, and documents service dependencies clearly.

Fixes: Instructions.md #2 - Hidden cross-service coupling

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
2026-04-27 18:26:08 +02:00
parent bc315b936b
commit 3bbf413c55
12 changed files with 342 additions and 100 deletions

View File

@@ -14,7 +14,7 @@ import os
import re
import tempfile
from pathlib import Path
from typing import cast
from typing import TYPE_CHECKING, cast
import structlog
@@ -33,7 +33,7 @@ from app.models.config import (
JailValidationResult,
RollbackResponse,
)
from app.services import health_service
from app.models.server import ServerStatus
from app.utils.async_utils import run_blocking
from app.utils.config_file_utils import (
_build_inactive_jail,
@@ -53,6 +53,11 @@ from app.utils.config_file_utils import (
)
from app.utils.jail_socket import reload_all
if TYPE_CHECKING:
from collections.abc import Awaitable, Callable
from app.services.protocols import HealthProbe
log: structlog.stdlib.BoundLogger = structlog.get_logger()
@@ -77,9 +82,30 @@ _META_SECTIONS: frozenset[str] = frozenset({"INCLUDES", "DEFAULT"})
_POST_RELOAD_PROBE_INTERVAL: float = 2.0
async def run_probe(socket_path: str) -> "ServerStatus":
"""Run a health probe against the fail2ban socket."""
return await health_service.probe(socket_path)
async def run_probe(
socket_path: str,
*,
health_probe: HealthProbe | None = None,
) -> ServerStatus:
"""Run a health probe against the fail2ban socket.
Args:
socket_path: Path to the fail2ban Unix domain socket.
health_probe: Optional injectable health probe function.
If not provided, raises ValueError to prevent hidden service coupling.
Returns:
ServerStatus indicating the fail2ban daemon health.
Raises:
ValueError: If health_probe is not provided.
"""
if health_probe is None:
raise ValueError(
"health_probe is required to avoid service-to-service coupling. "
"Pass it explicitly from dependencies."
)
return await health_probe(socket_path)
# Maximum number of post-reload probe attempts (initial attempt + retries).
_POST_RELOAD_MAX_ATTEMPTS: int = 4
@@ -293,15 +319,30 @@ async def activate_jail(
socket_path: str,
name: str,
req: ActivateJailRequest,
*,
health_probe: HealthProbe | None = None,
) -> JailActivationResponse:
"""Activate a jail and update the health-check cache.
This wrapper delegates the file-based activation workflow to the
lower-level implementation and runs an immediate probe so the UI
reflects the current fail2ban state.
Args:
config_dir: Absolute path to the fail2ban configuration directory.
socket_path: Path to the fail2ban Unix domain socket.
name: Name of the jail to activate.
req: Activation request with optional overrides.
health_probe: Injectable health probe function. Required to avoid hidden coupling.
Returns:
JailActivationResponse with activation result.
Raises:
ValueError: If health_probe is not provided.
"""
result = await _activate_jail(config_dir, socket_path, name, req)
await run_probe(socket_path)
await run_probe(socket_path, health_probe=health_probe)
return result
@@ -571,15 +612,29 @@ async def deactivate_jail(
config_dir: str,
socket_path: str,
name: str,
*,
health_probe: HealthProbe | None = None,
) -> JailActivationResponse:
"""Deactivate a jail and update the health-check cache.
This wrapper disables the jail in the config, reloads fail2ban, and then
forces an immediate health probe so any cached dashboard status reflects
the current daemon state.
Args:
config_dir: Absolute path to the fail2ban configuration directory.
socket_path: Path to the fail2ban Unix domain socket.
name: Name of the jail to deactivate.
health_probe: Injectable health probe function. Required to avoid hidden coupling.
Returns:
JailActivationResponse with deactivation result.
Raises:
ValueError: If health_probe is not provided.
"""
result = await _deactivate_jail(config_dir, socket_path, name)
await run_probe(socket_path)
await run_probe(socket_path, health_probe=health_probe)
return result