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