Refactor config regex/log preview into dedicated log_service

This commit is contained in:
2026-03-21 18:46:29 +01:00
parent 2e3ac5f005
commit 9646b1c119
5 changed files with 141 additions and 124 deletions

View File

@@ -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**:

View File

@@ -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)
# ---------------------------------------------------------------------------

View File

@@ -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()
@@ -494,27 +493,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)
# ---------------------------------------------------------------------------
@@ -593,100 +573,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)
# ---------------------------------------------------------------------------

View File

@@ -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()]

View File

@@ -501,7 +501,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(
@@ -519,7 +519,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(
@@ -597,7 +597,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(