Refactor: Move module-level mutable flags to JailServiceState

TASK-004: Replace module-level mutable runtime flags in service layer with
injected state holder, eliminating hidden global state and improving testability
and synchronization boundaries.

Changes:
- Create JailServiceState dataclass in app/utils/runtime_state.py to hold
  backend capability cache and synchronization lock
- Add JailServiceState as a field in RuntimeState (with default_factory)
- Remove module-level _backend_cmd_supported and _backend_cmd_lock from
  jail_service.py
- Refactor _check_backend_cmd_supported() to accept state parameter
- Inject JailServiceState into list_jails() and _fetch_jail_summary() via
  parameters
- Add get_jail_service_state() dependency provider in app/dependencies.py
- Add JailServiceStateDep type alias for router injection
- Update jails router to receive and pass state to service functions
- Update all tests to use jail_service_state fixture and pass state to functions
- Remove duplicate _MAX_PAGE_SIZE constant definition
- Document mutable state management in Backend-Development.md
- Update Architecture.md to describe JailServiceState and state nesting pattern

Benefits:
- Eliminates global mutable state and associated race conditions
- Makes state visible to callers (not hidden in module scope)
- Enables test isolation (each test gets fresh state)
- Prepares codebase for multi-worker deployments (state can be extracted to
  shared backend)
- Synchronization boundaries are now explicit (state.get_backend_cmd_lock())

Compliance:
- All tests pass (17 passed in TestListJails, TestGetJail, TestLockInitialization)
- No ruff linting errors
- Type-safe: JailServiceState properly typed with asyncio.Lock, bool | None

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
2026-04-27 18:42:52 +02:00
parent 79112c0430
commit 2e221f6852
8 changed files with 157 additions and 102 deletions

View File

@@ -16,6 +16,7 @@ from app.models.jail import JailDetailResponse, JailListResponse
from app.services import ban_service, jail_service
from app.services.jail_service import JailNotFoundError, JailOperationError
from app.utils import jail_socket
from app.utils.runtime_state import JailServiceState
# ---------------------------------------------------------------------------
# Helpers
@@ -80,6 +81,12 @@ def _patch_client(responses: dict[str, Any]) -> Any:
return stack
@pytest.fixture
def jail_service_state() -> JailServiceState:
"""Provide a fresh JailServiceState for each test."""
return JailServiceState()
# ---------------------------------------------------------------------------
# list_jails
# ---------------------------------------------------------------------------
@@ -88,7 +95,7 @@ def _patch_client(responses: dict[str, Any]) -> Any:
class TestListJails:
"""Unit tests for :func:`~app.services.jail_service.list_jails`."""
async def test_returns_jail_list_response(self) -> None:
async def test_returns_jail_list_response(self, jail_service_state: JailServiceState) -> None:
"""list_jails returns a JailListResponse."""
responses = {
"status": _make_global_status("sshd"),
@@ -100,22 +107,22 @@ class TestListJails:
"get|sshd|idle": (0, False),
}
with _patch_client(responses):
result = await jail_service.list_jails(_SOCKET)
result = await jail_service.list_jails(_SOCKET, jail_service_state)
assert isinstance(result, JailListResponse)
assert result.total == 1
assert result.jails[0].name == "sshd"
async def test_empty_jail_list(self) -> None:
async def test_empty_jail_list(self, jail_service_state: JailServiceState) -> None:
"""list_jails returns empty response when no jails are active."""
responses = {"status": (0, [("Number of jail", 0), ("Jail list", "")])}
with _patch_client(responses):
result = await jail_service.list_jails(_SOCKET)
result = await jail_service.list_jails(_SOCKET, jail_service_state)
assert result.total == 0
assert result.jails == []
async def test_jail_status_populated(self) -> None:
async def test_jail_status_populated(self, jail_service_state: JailServiceState) -> None:
"""list_jails populates JailStatus with failed/banned counters."""
responses = {
"status": _make_global_status("sshd"),
@@ -127,14 +134,14 @@ class TestListJails:
"get|sshd|idle": (0, False),
}
with _patch_client(responses):
result = await jail_service.list_jails(_SOCKET)
result = await jail_service.list_jails(_SOCKET, jail_service_state)
jail = result.jails[0]
assert jail.status is not None
assert jail.status.currently_banned == 5
assert jail.status.total_banned == 50
async def test_jail_config_populated(self) -> None:
async def test_jail_config_populated(self, jail_service_state: JailServiceState) -> None:
"""list_jails populates ban_time, find_time, max_retry, backend."""
responses = {
"status": _make_global_status("sshd"),
@@ -146,7 +153,7 @@ class TestListJails:
"get|sshd|idle": (0, True),
}
with _patch_client(responses):
result = await jail_service.list_jails(_SOCKET)
result = await jail_service.list_jails(_SOCKET, jail_service_state)
jail = result.jails[0]
assert jail.ban_time == 3600
@@ -155,7 +162,7 @@ class TestListJails:
assert jail.backend == "systemd"
assert jail.idle is True
async def test_multiple_jails_returned(self) -> None:
async def test_multiple_jails_returned(self, jail_service_state: JailServiceState) -> None:
"""list_jails fetches all jails listed in the global status."""
responses = {
"status": _make_global_status("sshd, nginx"),
@@ -173,13 +180,13 @@ class TestListJails:
"get|nginx|idle": (0, False),
}
with _patch_client(responses):
result = await jail_service.list_jails(_SOCKET)
result = await jail_service.list_jails(_SOCKET, jail_service_state)
assert result.total == 2
names = {j.name for j in result.jails}
assert names == {"sshd", "nginx"}
async def test_connection_error_propagates(self) -> None:
async def test_connection_error_propagates(self, jail_service_state: JailServiceState) -> None:
"""list_jails raises Fail2BanConnectionError when socket unreachable."""
async def _raise(*_: Any, **__: Any) -> None:
@@ -190,9 +197,9 @@ class TestListJails:
self.send = AsyncMock(side_effect=Fail2BanConnectionError("no socket", _SOCKET))
with patch("app.services.jail_service.Fail2BanClient", _FailClient), pytest.raises(Fail2BanConnectionError):
await jail_service.list_jails(_SOCKET)
await jail_service.list_jails(_SOCKET, jail_service_state)
async def test_backend_idle_commands_unsupported(self) -> None:
async def test_backend_idle_commands_unsupported(self, jail_service_state: JailServiceState) -> None:
"""list_jails handles unsupported backend and idle commands gracefully.
When the fail2ban daemon does not support get ... backend/idle commands,
@@ -200,7 +207,7 @@ class TestListJails:
fail2ban log.
"""
# Reset the capability cache to test detection.
await jail_service._reset_backend_capability_cache()
await jail_service_state.reset_backend_capability_cache()
responses = {
"status": _make_global_status("sshd"),
@@ -213,19 +220,19 @@ class TestListJails:
"get|sshd|maxretry": (0, 5),
}
with _patch_client(responses):
result = await jail_service.list_jails(_SOCKET)
result = await jail_service.list_jails(_SOCKET, jail_service_state)
# Verify the result uses the default values for backend and idle.
jail = result.jails[0]
assert jail.backend == "polling" # default
assert jail.idle is False # default
# Capability should now be cached as False.
assert jail_service._backend_cmd_supported is False
assert jail_service_state.backend_cmd_supported is False
async def test_backend_idle_commands_supported(self) -> None:
async def test_backend_idle_commands_supported(self, jail_service_state: JailServiceState) -> None:
"""list_jails detects and sends backend/idle commands when supported."""
# Reset the capability cache to test detection.
await jail_service._reset_backend_capability_cache()
await jail_service_state.reset_backend_capability_cache()
responses = {
"status": _make_global_status("sshd"),
@@ -239,19 +246,19 @@ class TestListJails:
"get|sshd|idle": (0, True),
}
with _patch_client(responses):
result = await jail_service.list_jails(_SOCKET)
result = await jail_service.list_jails(_SOCKET, jail_service_state)
# Verify real values are returned.
jail = result.jails[0]
assert jail.backend == "systemd" # real value
assert jail.idle is True # real value
# Capability should now be cached as True.
assert jail_service._backend_cmd_supported is True
assert jail_service_state.backend_cmd_supported is True
async def test_backend_idle_commands_cached_after_first_probe(self) -> None:
async def test_backend_idle_commands_cached_after_first_probe(self, jail_service_state: JailServiceState) -> None:
"""list_jails caches capability result and reuses it across polling cycles."""
# Reset the capability cache.
await jail_service._reset_backend_capability_cache()
await jail_service_state.reset_backend_capability_cache()
responses = {
"status": _make_global_status("sshd, nginx"),
@@ -270,7 +277,7 @@ class TestListJails:
"get|nginx|maxretry": (0, 5),
}
with _patch_client(responses):
result = await jail_service.list_jails(_SOCKET)
result = await jail_service.list_jails(_SOCKET, jail_service_state)
# Both jails should return default values (cached result is False).
for jail in result.jails:
@@ -290,14 +297,15 @@ class TestLockInitialization:
assert isinstance(lock, asyncio.Lock)
assert jail_socket._reload_all_lock is lock
async def test_backend_cmd_lock_is_lazy_initialised(self) -> None:
async def test_backend_cmd_lock_is_lazy_initialised(self, jail_service_state: JailServiceState) -> None:
"""The backend capability probe lock should be created lazily on first use."""
jail_service._backend_cmd_lock = None
# Ensure state starts with no lock.
jail_service_state.backend_cmd_lock = None
lock = _ = jail_service._get_backend_cmd_lock()
lock = jail_service_state.get_backend_cmd_lock()
assert isinstance(lock, asyncio.Lock)
assert jail_service._backend_cmd_lock is lock
assert jail_service_state.backend_cmd_lock is lock
class TestGetJail:
@@ -320,7 +328,7 @@ class TestGetJail:
f"get|{name}|actions": (0, ["iptables-multiport"]),
}
async def test_returns_jail_detail_response(self) -> None:
async def test_returns_jail_detail_response(self, jail_service_state: JailServiceState) -> None:
"""get_jail returns a JailDetailResponse."""
with _patch_client(self._full_responses()):
result = await jail_service.get_jail(_SOCKET, "sshd")
@@ -328,35 +336,35 @@ class TestGetJail:
assert isinstance(result, JailDetailResponse)
assert result.jail.name == "sshd"
async def test_log_paths_parsed(self) -> None:
async def test_log_paths_parsed(self, jail_service_state: JailServiceState) -> None:
"""get_jail populates log_paths from fail2ban."""
with _patch_client(self._full_responses()):
result = await jail_service.get_jail(_SOCKET, "sshd")
assert result.jail.log_paths == ["/var/log/auth.log"]
async def test_fail_regex_parsed(self) -> None:
async def test_fail_regex_parsed(self, jail_service_state: JailServiceState) -> None:
"""get_jail populates fail_regex list."""
with _patch_client(self._full_responses()):
result = await jail_service.get_jail(_SOCKET, "sshd")
assert "^.*Failed.*from <HOST>" in result.jail.fail_regex
async def test_ignore_ips_parsed(self) -> None:
async def test_ignore_ips_parsed(self, jail_service_state: JailServiceState) -> None:
"""get_jail populates ignore_ips list."""
with _patch_client(self._full_responses()):
result = await jail_service.get_jail(_SOCKET, "sshd")
assert "127.0.0.1" in result.jail.ignore_ips
async def test_actions_parsed(self) -> None:
async def test_actions_parsed(self, jail_service_state: JailServiceState) -> None:
"""get_jail populates actions list."""
with _patch_client(self._full_responses()):
result = await jail_service.get_jail(_SOCKET, "sshd")
assert result.jail.actions == ["iptables-multiport"]
async def test_jail_not_found_raises(self) -> None:
async def test_jail_not_found_raises(self, jail_service_state: JailServiceState) -> None:
"""get_jail raises JailNotFoundError when jail is unknown."""
not_found_response = (1, Exception("Unknown jail: 'ghost'"))