From 1f272dc3484d1ac52dee20b2afebb011cf796a67 Mon Sep 17 00:00:00 2001 From: Lukas Date: Sat, 21 Mar 2026 18:46:29 +0100 Subject: [PATCH] Refactor config regex/log preview into dedicated log_service --- Docs/Tasks.md | 3 +- backend/app/routers/config.py | 6 +- backend/app/services/config_service.py | 122 +-------------------- backend/app/services/log_service.py | 128 ++++++++++++++++++++++ backend/tests/test_routers/test_config.py | 6 +- 5 files changed, 141 insertions(+), 124 deletions(-) create mode 100644 backend/app/services/log_service.py diff --git a/Docs/Tasks.md b/Docs/Tasks.md index 3d5f742..74a86f8 100644 --- a/Docs/Tasks.md +++ b/Docs/Tasks.md @@ -109,12 +109,13 @@ Reference: `Docs/Refactoring.md` for full analysis of each issue. --- -### Task 5 — Extract log-preview / regex-test from `config_service.py` +### Task 5 — Extract log-preview / regex-test from `config_service.py` (✅ completed) **Priority**: Medium **Refactoring ref**: Refactoring.md §2 **Affected files**: - `backend/app/services/config_service.py` (~1845 lines) +- `backend/app/services/log_service.py` (new) - `backend/app/routers/config.py` (routes that call log-preview / regex-test functions) **What to do**: diff --git a/backend/app/routers/config.py b/backend/app/routers/config.py index 0734c96..e41aa44 100644 --- a/backend/app/routers/config.py +++ b/backend/app/routers/config.py @@ -76,7 +76,7 @@ from app.models.config import ( RollbackResponse, ServiceStatusResponse, ) -from app.services import config_service, jail_service +from app.services import config_service, jail_service, log_service from app.services import ( action_config_service, config_file_service, @@ -472,7 +472,7 @@ async def regex_test( Returns: :class:`~app.models.config.RegexTestResponse` with match result and groups. """ - return config_service.test_regex(body) + return log_service.test_regex(body) # --------------------------------------------------------------------------- @@ -578,7 +578,7 @@ async def preview_log( Returns: :class:`~app.models.config.LogPreviewResponse` with per-line results. """ - return await config_service.preview_log(body) + return await log_service.preview_log(body) # --------------------------------------------------------------------------- diff --git a/backend/app/services/config_service.py b/backend/app/services/config_service.py index fe7a357..4e2d0a2 100644 --- a/backend/app/services/config_service.py +++ b/backend/app/services/config_service.py @@ -35,7 +35,6 @@ from app.models.config import ( JailConfigListResponse, JailConfigResponse, JailConfigUpdate, - LogPreviewLine, LogPreviewRequest, LogPreviewResponse, MapColorThresholdsResponse, @@ -45,7 +44,7 @@ from app.models.config import ( ServiceStatusResponse, ) from app.exceptions import ConfigOperationError, ConfigValidationError, JailNotFoundError -from app.services import setup_service +from app.services import log_service, setup_service from app.utils.fail2ban_client import Fail2BanClient log: structlog.stdlib.BoundLogger = structlog.get_logger() @@ -495,27 +494,8 @@ async def update_global_config(socket_path: str, update: GlobalConfigUpdate) -> def test_regex(request: RegexTestRequest) -> RegexTestResponse: - """Test a regex pattern against a sample log line. - - This is a pure in-process operation — no socket communication occurs. - - Args: - request: The :class:`~app.models.config.RegexTestRequest` payload. - - Returns: - :class:`~app.models.config.RegexTestResponse` with match result. - """ - try: - compiled = re.compile(request.fail_regex) - except re.error as exc: - return RegexTestResponse(matched=False, groups=[], error=str(exc)) - - match = compiled.search(request.log_line) - if match is None: - return RegexTestResponse(matched=False) - - groups: list[str] = list(match.groups() or []) - return RegexTestResponse(matched=True, groups=[str(g) for g in groups if g is not None]) + """Proxy to :func:`app.services.log_service.test_regex`.""" + return log_service.test_regex(request) # --------------------------------------------------------------------------- @@ -594,100 +574,8 @@ async def delete_log_path( async def preview_log(req: LogPreviewRequest) -> LogPreviewResponse: - """Read the last *num_lines* of a log file and test *fail_regex* against each. - - This operation reads from the local filesystem — no socket is used. - - Args: - req: :class:`~app.models.config.LogPreviewRequest`. - - Returns: - :class:`~app.models.config.LogPreviewResponse` with line-by-line results. - """ - # Validate the regex first. - try: - compiled = re.compile(req.fail_regex) - except re.error as exc: - return LogPreviewResponse( - lines=[], - total_lines=0, - matched_count=0, - regex_error=str(exc), - ) - - path = Path(req.log_path) - if not path.is_file(): - return LogPreviewResponse( - lines=[], - total_lines=0, - matched_count=0, - regex_error=f"File not found: {req.log_path!r}", - ) - - # Read the last num_lines lines efficiently. - try: - raw_lines = await asyncio.get_event_loop().run_in_executor( - None, - _read_tail_lines, - str(path), - req.num_lines, - ) - except OSError as exc: - return LogPreviewResponse( - lines=[], - total_lines=0, - matched_count=0, - regex_error=f"Cannot read file: {exc}", - ) - - result_lines: list[LogPreviewLine] = [] - matched_count = 0 - for line in raw_lines: - m = compiled.search(line) - groups = [str(g) for g in (m.groups() or []) if g is not None] if m else [] - result_lines.append(LogPreviewLine(line=line, matched=(m is not None), groups=groups)) - if m: - matched_count += 1 - - return LogPreviewResponse( - lines=result_lines, - total_lines=len(result_lines), - matched_count=matched_count, - ) - - -def _read_tail_lines(file_path: str, num_lines: int) -> list[str]: - """Read the last *num_lines* from *file_path* synchronously. - - Uses a memory-efficient approach that seeks from the end of the file. - - Args: - file_path: Absolute path to the log file. - num_lines: Number of lines to return. - - Returns: - A list of stripped line strings. - """ - chunk_size = 8192 - raw_lines: list[bytes] = [] - with open(file_path, "rb") as fh: - fh.seek(0, 2) # seek to end - end_pos = fh.tell() - if end_pos == 0: - return [] - buf = b"" - pos = end_pos - while len(raw_lines) <= num_lines and pos > 0: - read_size = min(chunk_size, pos) - pos -= read_size - fh.seek(pos) - chunk = fh.read(read_size) - buf = chunk + buf - raw_lines = buf.split(b"\n") - # Strip incomplete leading line unless we've read the whole file. - if pos > 0 and len(raw_lines) > 1: - raw_lines = raw_lines[1:] - return [ln.decode("utf-8", errors="replace").rstrip() for ln in raw_lines[-num_lines:] if ln.strip()] + """Proxy to :func:`app.services.log_service.preview_log`.""" + return await log_service.preview_log(req) # --------------------------------------------------------------------------- diff --git a/backend/app/services/log_service.py b/backend/app/services/log_service.py new file mode 100644 index 0000000..e21c50a --- /dev/null +++ b/backend/app/services/log_service.py @@ -0,0 +1,128 @@ +"""Log helper service. + +Contains regex test and log preview helpers that are independent of +fail2ban socket operations. +""" + +from __future__ import annotations + +import asyncio +import re +from pathlib import Path + +from app.models.config import ( + LogPreviewLine, + LogPreviewRequest, + LogPreviewResponse, + RegexTestRequest, + RegexTestResponse, +) + + +def test_regex(request: RegexTestRequest) -> RegexTestResponse: + """Test a regex pattern against a sample log line. + + Args: + request: The regex test payload. + + Returns: + RegexTestResponse with match result, groups and optional error. + """ + try: + compiled = re.compile(request.fail_regex) + except re.error as exc: + return RegexTestResponse(matched=False, groups=[], error=str(exc)) + + match = compiled.search(request.log_line) + if match is None: + return RegexTestResponse(matched=False) + + groups: list[str] = list(match.groups() or []) + return RegexTestResponse(matched=True, groups=[str(g) for g in groups if g is not None]) + + +async def preview_log(req: LogPreviewRequest) -> LogPreviewResponse: + """Inspect the last lines of a log file and evaluate regex matches. + + Args: + req: Log preview request. + + Returns: + LogPreviewResponse with lines, total_lines and matched_count, or error. + """ + try: + compiled = re.compile(req.fail_regex) + except re.error as exc: + return LogPreviewResponse( + lines=[], + total_lines=0, + matched_count=0, + regex_error=str(exc), + ) + + path = Path(req.log_path) + if not path.is_file(): + return LogPreviewResponse( + lines=[], + total_lines=0, + matched_count=0, + regex_error=f"File not found: {req.log_path!r}", + ) + + try: + raw_lines = await asyncio.get_event_loop().run_in_executor( + None, + _read_tail_lines, + str(path), + req.num_lines, + ) + except OSError as exc: + return LogPreviewResponse( + lines=[], + total_lines=0, + matched_count=0, + regex_error=f"Cannot read file: {exc}", + ) + + result_lines: list[LogPreviewLine] = [] + matched_count = 0 + for line in raw_lines: + m = compiled.search(line) + groups = [str(g) for g in (m.groups() or []) if g is not None] if m else [] + result_lines.append( + LogPreviewLine(line=line, matched=(m is not None), groups=groups), + ) + if m: + matched_count += 1 + + return LogPreviewResponse( + lines=result_lines, + total_lines=len(result_lines), + matched_count=matched_count, + ) + + +def _read_tail_lines(file_path: str, num_lines: int) -> list[str]: + """Read the last *num_lines* from *file_path* in a memory-efficient way.""" + chunk_size = 8192 + raw_lines: list[bytes] = [] + with open(file_path, "rb") as fh: + fh.seek(0, 2) + end_pos = fh.tell() + if end_pos == 0: + return [] + + buf = b"" + pos = end_pos + while len(raw_lines) <= num_lines and pos > 0: + read_size = min(chunk_size, pos) + pos -= read_size + fh.seek(pos) + chunk = fh.read(read_size) + buf = chunk + buf + raw_lines = buf.split(b"\n") + + if pos > 0 and len(raw_lines) > 1: + raw_lines = raw_lines[1:] + + return [ln.decode("utf-8", errors="replace").rstrip() for ln in raw_lines[-num_lines:] if ln.strip()] diff --git a/backend/tests/test_routers/test_config.py b/backend/tests/test_routers/test_config.py index 646b064..313bca5 100644 --- a/backend/tests/test_routers/test_config.py +++ b/backend/tests/test_routers/test_config.py @@ -503,7 +503,7 @@ class TestRegexTest: """POST /api/config/regex-test returns matched=true for a valid match.""" mock_response = RegexTestResponse(matched=True, groups=["1.2.3.4"], error=None) with patch( - "app.routers.config.config_service.test_regex", + "app.routers.config.log_service.test_regex", return_value=mock_response, ): resp = await config_client.post( @@ -521,7 +521,7 @@ class TestRegexTest: """POST /api/config/regex-test returns matched=false for no match.""" mock_response = RegexTestResponse(matched=False, groups=[], error=None) with patch( - "app.routers.config.config_service.test_regex", + "app.routers.config.log_service.test_regex", return_value=mock_response, ): resp = await config_client.post( @@ -599,7 +599,7 @@ class TestPreviewLog: matched_count=1, ) with patch( - "app.routers.config.config_service.preview_log", + "app.routers.config.log_service.preview_log", AsyncMock(return_value=mock_response), ): resp = await config_client.post(