Refactor config regex/log preview into dedicated log_service
This commit is contained in:
@@ -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
|
**Priority**: Medium
|
||||||
**Refactoring ref**: Refactoring.md §2
|
**Refactoring ref**: Refactoring.md §2
|
||||||
**Affected files**:
|
**Affected files**:
|
||||||
- `backend/app/services/config_service.py` (~1845 lines)
|
- `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)
|
- `backend/app/routers/config.py` (routes that call log-preview / regex-test functions)
|
||||||
|
|
||||||
**What to do**:
|
**What to do**:
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ from app.models.config import (
|
|||||||
RollbackResponse,
|
RollbackResponse,
|
||||||
ServiceStatusResponse,
|
ServiceStatusResponse,
|
||||||
)
|
)
|
||||||
from app.services import config_service, jail_service
|
from app.services import config_service, jail_service, log_service
|
||||||
from app.services import (
|
from app.services import (
|
||||||
action_config_service,
|
action_config_service,
|
||||||
config_file_service,
|
config_file_service,
|
||||||
@@ -472,7 +472,7 @@ async def regex_test(
|
|||||||
Returns:
|
Returns:
|
||||||
:class:`~app.models.config.RegexTestResponse` with match result and groups.
|
: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:
|
Returns:
|
||||||
:class:`~app.models.config.LogPreviewResponse` with per-line results.
|
:class:`~app.models.config.LogPreviewResponse` with per-line results.
|
||||||
"""
|
"""
|
||||||
return await config_service.preview_log(body)
|
return await log_service.preview_log(body)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -35,7 +35,6 @@ from app.models.config import (
|
|||||||
JailConfigListResponse,
|
JailConfigListResponse,
|
||||||
JailConfigResponse,
|
JailConfigResponse,
|
||||||
JailConfigUpdate,
|
JailConfigUpdate,
|
||||||
LogPreviewLine,
|
|
||||||
LogPreviewRequest,
|
LogPreviewRequest,
|
||||||
LogPreviewResponse,
|
LogPreviewResponse,
|
||||||
MapColorThresholdsResponse,
|
MapColorThresholdsResponse,
|
||||||
@@ -45,7 +44,7 @@ from app.models.config import (
|
|||||||
ServiceStatusResponse,
|
ServiceStatusResponse,
|
||||||
)
|
)
|
||||||
from app.exceptions import ConfigOperationError, ConfigValidationError, JailNotFoundError
|
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
|
from app.utils.fail2ban_client import Fail2BanClient
|
||||||
|
|
||||||
log: structlog.stdlib.BoundLogger = structlog.get_logger()
|
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:
|
def test_regex(request: RegexTestRequest) -> RegexTestResponse:
|
||||||
"""Test a regex pattern against a sample log line.
|
"""Proxy to :func:`app.services.log_service.test_regex`."""
|
||||||
|
return log_service.test_regex(request)
|
||||||
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])
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -594,100 +574,8 @@ async def delete_log_path(
|
|||||||
|
|
||||||
|
|
||||||
async def preview_log(req: LogPreviewRequest) -> LogPreviewResponse:
|
async def preview_log(req: LogPreviewRequest) -> LogPreviewResponse:
|
||||||
"""Read the last *num_lines* of a log file and test *fail_regex* against each.
|
"""Proxy to :func:`app.services.log_service.preview_log`."""
|
||||||
|
return await log_service.preview_log(req)
|
||||||
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()]
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
128
backend/app/services/log_service.py
Normal file
128
backend/app/services/log_service.py
Normal 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()]
|
||||||
@@ -503,7 +503,7 @@ class TestRegexTest:
|
|||||||
"""POST /api/config/regex-test returns matched=true for a valid match."""
|
"""POST /api/config/regex-test returns matched=true for a valid match."""
|
||||||
mock_response = RegexTestResponse(matched=True, groups=["1.2.3.4"], error=None)
|
mock_response = RegexTestResponse(matched=True, groups=["1.2.3.4"], error=None)
|
||||||
with patch(
|
with patch(
|
||||||
"app.routers.config.config_service.test_regex",
|
"app.routers.config.log_service.test_regex",
|
||||||
return_value=mock_response,
|
return_value=mock_response,
|
||||||
):
|
):
|
||||||
resp = await config_client.post(
|
resp = await config_client.post(
|
||||||
@@ -521,7 +521,7 @@ class TestRegexTest:
|
|||||||
"""POST /api/config/regex-test returns matched=false for no match."""
|
"""POST /api/config/regex-test returns matched=false for no match."""
|
||||||
mock_response = RegexTestResponse(matched=False, groups=[], error=None)
|
mock_response = RegexTestResponse(matched=False, groups=[], error=None)
|
||||||
with patch(
|
with patch(
|
||||||
"app.routers.config.config_service.test_regex",
|
"app.routers.config.log_service.test_regex",
|
||||||
return_value=mock_response,
|
return_value=mock_response,
|
||||||
):
|
):
|
||||||
resp = await config_client.post(
|
resp = await config_client.post(
|
||||||
@@ -599,7 +599,7 @@ class TestPreviewLog:
|
|||||||
matched_count=1,
|
matched_count=1,
|
||||||
)
|
)
|
||||||
with patch(
|
with patch(
|
||||||
"app.routers.config.config_service.preview_log",
|
"app.routers.config.log_service.preview_log",
|
||||||
AsyncMock(return_value=mock_response),
|
AsyncMock(return_value=mock_response),
|
||||||
):
|
):
|
||||||
resp = await config_client.post(
|
resp = await config_client.post(
|
||||||
|
|||||||
Reference in New Issue
Block a user