Files
RelayOps/backend/app/services/log_service.py
2026-06-07 02:32:28 +03:00

123 lines
4.2 KiB
Python

import posixpath
import re
import shlex
from datetime import datetime, timezone
from fastapi import status
from app.core.errors import raise_api_error
from app.integrations.ssh_client import SSHClient, SSHClientError, SSHConnectionConfig
from app.models.managed_service import ManagedService
from app.models.server import Server
from app.schemas.logs import ServerLogRead, ServerLogSourcesRead
SAFE_FILE_LOG_SOURCE = re.compile(r"^file:(/.+)$")
class LogService:
def __init__(self, ssh_client: SSHClient | None = None) -> None:
self.ssh_client = ssh_client or SSHClient()
def list_log_sources(
self,
server: Server,
managed_services: list[ManagedService],
) -> ServerLogSourcesRead:
return ServerLogSourcesRead(
server_id=server.id,
sources=[service.name for service in managed_services],
)
def read_logs(
self,
*,
server: Server,
managed_service: ManagedService,
lines: int,
) -> ServerLogRead:
self._validate_server(server)
self._validate_line_limit(lines)
command, source_type = self._build_log_command(managed_service, lines)
try:
result = self.ssh_client.run_command(self._build_connection(server), command)
except SSHClientError as exc:
raise_api_error(
status_code=status.HTTP_502_BAD_GATEWAY,
code="log_read_failed",
message=f"Unable to read logs over SSH: {exc}",
)
text = result.stdout.strip()
parsed_lines = text.splitlines() if text else []
return ServerLogRead(
server_id=server.id,
service_name=managed_service.name,
lines=parsed_lines,
retrieved_at=datetime.now(timezone.utc),
source_type=source_type,
)
def _validate_server(self, server: Server) -> None:
if server.connection_type != "ssh":
raise_api_error(
status_code=status.HTTP_400_BAD_REQUEST,
code="unsupported_connection_type",
message="Log retrieval is currently implemented only for SSH servers.",
)
if not server.ssh_username:
raise_api_error(
status_code=status.HTTP_400_BAD_REQUEST,
code="ssh_username_required",
message="Server ssh_username is required for log retrieval.",
)
def _validate_line_limit(self, lines: int) -> None:
if lines < 1 or lines > 500:
raise_api_error(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
code="invalid_line_limit",
message="Log line limit must be between 1 and 500.",
)
def _build_connection(self, server: Server) -> SSHConnectionConfig:
return SSHConnectionConfig(
host=server.host,
port=server.port,
username=server.ssh_username or "",
)
def _build_log_command(
self,
managed_service: ManagedService,
lines: int,
) -> tuple[str, str]:
if managed_service.log_source:
path = self._extract_file_path(managed_service.log_source)
quoted_path = shlex.quote(path)
return f"tail -n {lines} -- {quoted_path}", "file"
service_name = shlex.quote(managed_service.name)
if managed_service.service_type == "systemd":
return f"journalctl -u {service_name} -n {lines} --no-pager", "journalctl"
return f"docker logs --tail {lines} {service_name}", "docker"
def _extract_file_path(self, log_source: str) -> str:
match = SAFE_FILE_LOG_SOURCE.fullmatch(log_source)
if match is None:
raise_api_error(
status_code=status.HTTP_400_BAD_REQUEST,
code="invalid_log_source",
message="Custom log_source must use the format 'file:/absolute/path'.",
)
file_path = match.group(1)
if not posixpath.isabs(file_path):
raise_api_error(
status_code=status.HTTP_400_BAD_REQUEST,
code="invalid_log_source",
message="Custom log_source must point to an absolute path.",
)
return file_path