123 lines
4.2 KiB
Python
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
|