diff --git a/server/src/api/lifecycle.py b/server/src/api/lifecycle.py index 51819294..af7a77bd 100644 --- a/server/src/api/lifecycle.py +++ b/server/src/api/lifecycle.py @@ -418,6 +418,50 @@ async def get_sandbox_endpoint( return endpoint +@router.get( + "/sandboxes/{sandbox_id}/logs", + responses={ + 200: {"description": "Sandbox log stream", "content": {"text/plain": {}}}, + 401: {"model": ErrorResponse, "description": "Authentication credentials are missing or invalid"}, + 403: {"model": ErrorResponse, "description": "The authenticated user lacks permission for this operation"}, + 404: {"model": ErrorResponse, "description": "The requested resource does not exist"}, + 500: {"model": ErrorResponse, "description": "An unexpected server error occurred"}, + }, +) +def get_sandbox_logs( + sandbox_id: str, + follow: bool = Query(False, description="If true, stream logs until the sandbox exits"), + tail: Optional[int] = Query(None, description="Return only the last N lines. Omit to return all lines.", ge=1), + timestamps: bool = Query(False, description="If true, prefix each log line with an RFC3339 timestamp"), +) -> StreamingResponse: + """ + Stream stdout/stderr logs for a sandbox. + + Returns a plain-text stream of the combined stdout and stderr output of the + sandbox's main process. Use ``follow=true`` to keep the connection open and + receive new log lines as they are produced (similar to ``docker logs -f``). + + Args: + sandbox_id: Unique sandbox identifier + follow: If true, keep the connection open and stream new log lines. + tail: Number of lines from the end of the log to return. + timestamps: If true, prefix each log line with an RFC3339 timestamp. + + Returns: + StreamingResponse: Plain-text log stream. + + Raises: + HTTPException: If the sandbox is not found or logs cannot be retrieved. + """ + log_gen = sandbox_service.get_logs( + sandbox_id, + follow=follow, + tail=tail, + timestamps=timestamps, + ) + return StreamingResponse(content=log_gen, media_type="text/plain") + + @router.api_route( "/sandboxes/{sandbox_id}/proxy/{port}/{full_path:path}", methods=["GET", "POST", "PUT", "DELETE", "PATCH"], diff --git a/server/src/services/docker.py b/server/src/services/docker.py index 69015208..029d95f7 100644 --- a/server/src/services/docker.py +++ b/server/src/services/docker.py @@ -36,7 +36,7 @@ from dataclasses import dataclass from datetime import datetime, timedelta, timezone from threading import Lock, Timer -from typing import Any, Dict, Optional +from typing import Any, Dict, Iterator, Optional from uuid import uuid4 import docker @@ -1550,6 +1550,49 @@ def get_endpoint(self, sandbox_id: str, port: int, resolve_internal: bool = Fals }, ) + def get_logs( + self, + sandbox_id: str, + follow: bool = False, + tail: Optional[int] = None, + timestamps: bool = False, + ) -> Iterator[bytes]: + """ + Stream stdout/stderr logs for a Docker sandbox container. + + Args: + sandbox_id: Unique sandbox identifier + follow: If True, keep streaming until the container exits. + tail: Number of lines from the end to return. None means all lines. + timestamps: If True, prepend each log line with an RFC3339 timestamp. + + Yields: + bytes: Raw log output chunks (Docker multiplexed-stream format). + + Raises: + HTTPException: If the sandbox is not found or logs cannot be retrieved. + """ + container = self._get_container_by_sandbox_id(sandbox_id) + tail_arg: int | str = tail if tail is not None else "all" + try: + log_gen = container.logs( + stream=True, + follow=follow, + stdout=True, + stderr=True, + timestamps=timestamps, + tail=tail_arg, + ) + yield from log_gen + except DockerException as exc: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail={ + "code": SandboxErrorCodes.CONTAINER_QUERY_FAILED, + "message": f"Failed to stream logs for sandbox {sandbox_id}: {str(exc)}", + }, + ) from exc + def _get_docker_host_ip(self) -> Optional[str]: """When running inside a container, return [docker].host_ip for endpoint URLs (if set).""" ip = (self.app_config.docker.host_ip or "").strip() diff --git a/server/src/services/k8s/kubernetes_service.py b/server/src/services/k8s/kubernetes_service.py index 772a409b..bc2a61c6 100644 --- a/server/src/services/k8s/kubernetes_service.py +++ b/server/src/services/k8s/kubernetes_service.py @@ -22,7 +22,7 @@ import logging import time from datetime import datetime, timedelta, timezone -from typing import Optional, Dict, Any +from typing import Iterator, Optional, Dict, Any from fastapi import HTTPException, status @@ -679,6 +679,87 @@ def get_endpoint( }, ) from e + def get_logs( + self, + sandbox_id: str, + follow: bool = False, + tail: Optional[int] = None, + timestamps: bool = False, + ) -> Iterator[bytes]: + """ + Stream logs from the Kubernetes Pod(s) that back a sandbox. + + Finds pods labelled with the sandbox ID and streams their logs via + the Kubernetes API. When *follow* is True the generator keeps + streaming until the pod terminates. + + Args: + sandbox_id: Unique sandbox identifier + follow: If True, keep streaming until the pod exits. + tail: Number of lines from the end to return. None means all lines. + timestamps: If True, prepend each log line with a timestamp. + + Yields: + bytes: Log output chunks. + + Raises: + HTTPException: If no pod is found for the sandbox or the API call + fails. + """ + try: + core_v1_api = self.k8s_client.get_core_v1_api() + pods = core_v1_api.list_namespaced_pod( + namespace=self.namespace, + label_selector=f"{SANDBOX_ID_LABEL}={sandbox_id}", + ) + + if not pods.items: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail={ + "code": SandboxErrorCodes.K8S_SANDBOX_NOT_FOUND, + "message": f"No pods found for sandbox '{sandbox_id}'", + }, + ) + + pod_name = pods.items[0].metadata.name + + if follow: + # Streaming mode: _preload_content=False gives a raw HTTP response. + response = core_v1_api.read_namespaced_pod_log( + name=pod_name, + namespace=self.namespace, + follow=True, + tail_lines=tail, + timestamps=timestamps, + _preload_content=False, + ) + for chunk in response.stream(amt=4096): + if chunk: + yield chunk + else: + log_text = core_v1_api.read_namespaced_pod_log( + name=pod_name, + namespace=self.namespace, + follow=False, + tail_lines=tail, + timestamps=timestamps, + ) + if log_text: + yield log_text.encode("utf-8") if isinstance(log_text, str) else log_text + + except HTTPException: + raise + except Exception as exc: + logger.error("Error retrieving logs for sandbox %s: %s", sandbox_id, exc) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail={ + "code": SandboxErrorCodes.K8S_API_ERROR, + "message": f"Failed to retrieve logs for sandbox '{sandbox_id}': {str(exc)}", + }, + ) from exc + def _build_sandbox_from_workload(self, workload: Any) -> Sandbox: """ Build Sandbox object from Kubernetes workload. diff --git a/server/src/services/sandbox_service.py b/server/src/services/sandbox_service.py index 6831d939..2bdb38c6 100644 --- a/server/src/services/sandbox_service.py +++ b/server/src/services/sandbox_service.py @@ -21,6 +21,7 @@ from abc import ABC, abstractmethod import socket +from typing import Iterator, Optional from uuid import uuid4 from src.api.schema import ( @@ -222,3 +223,28 @@ def get_endpoint(self, sandbox_id: str, port: int, resolve_internal: bool = Fals HTTPException: If sandbox not found or endpoint not available """ pass + + @abstractmethod + def get_logs( + self, + sandbox_id: str, + follow: bool = False, + tail: Optional[int] = None, + timestamps: bool = False, + ) -> Iterator[bytes]: + """ + Stream sandbox logs (stdout and stderr). + + Args: + sandbox_id: Unique sandbox identifier + follow: If True, keep streaming until the sandbox exits. + tail: Number of lines from the end to return. None means all logs. + timestamps: If True, prefix each log line with an RFC3339 timestamp. + + Returns: + Iterator[bytes]: A byte-stream of log output chunks. + + Raises: + HTTPException: If the sandbox is not found or logs cannot be retrieved. + """ + pass diff --git a/server/tests/test_routes_logs.py b/server/tests/test_routes_logs.py new file mode 100644 index 00000000..a755f6cb --- /dev/null +++ b/server/tests/test_routes_logs.py @@ -0,0 +1,107 @@ +# Copyright 2026 Alibaba Group Holding Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Tests for GET /sandboxes/{sandbox_id}/logs endpoint.""" + +from fastapi.testclient import TestClient +from fastapi import HTTPException, status + +from src.api import lifecycle + + +def _make_log_gen(*chunks: bytes): + """Return a generator that yields the given byte chunks.""" + def _gen(): + yield from chunks + return _gen() + + +def test_get_sandbox_logs_returns_plain_text(client: TestClient, auth_headers: dict, monkeypatch): + """Happy path: service returns log chunks and they are streamed as text/plain.""" + log_chunks = [b"line one\n", b"line two\n"] + monkeypatch.setattr( + lifecycle.sandbox_service, + "get_logs", + lambda sandbox_id, follow, tail, timestamps: _make_log_gen(*log_chunks), + ) + + resp = client.get("/sandboxes/abc-123/logs", headers=auth_headers) + + assert resp.status_code == 200 + assert "text/plain" in resp.headers["content-type"] + assert resp.content == b"line one\nline two\n" + + +def test_get_sandbox_logs_passes_query_params(client: TestClient, auth_headers: dict, monkeypatch): + """Query parameters (follow, tail, timestamps) are forwarded to the service.""" + captured = {} + + def _fake_get_logs(sandbox_id, follow, tail, timestamps): + captured["sandbox_id"] = sandbox_id + captured["follow"] = follow + captured["tail"] = tail + captured["timestamps"] = timestamps + return _make_log_gen(b"log\n") + + monkeypatch.setattr(lifecycle.sandbox_service, "get_logs", _fake_get_logs) + + resp = client.get( + "/sandboxes/my-sandbox/logs", + params={"follow": "true", "tail": 50, "timestamps": "true"}, + headers=auth_headers, + ) + + assert resp.status_code == 200 + assert captured["sandbox_id"] == "my-sandbox" + assert captured["follow"] is True + assert captured["tail"] == 50 + assert captured["timestamps"] is True + + +def test_get_sandbox_logs_empty_stream(client: TestClient, auth_headers: dict, monkeypatch): + """When the service returns an empty generator, a 200 with empty body is returned.""" + monkeypatch.setattr( + lifecycle.sandbox_service, + "get_logs", + lambda sandbox_id, follow, tail, timestamps: _make_log_gen(), + ) + + resp = client.get("/sandboxes/empty-sandbox/logs", headers=auth_headers) + + assert resp.status_code == 200 + assert resp.content == b"" + + +def test_get_sandbox_logs_not_found(client: TestClient, auth_headers: dict, monkeypatch): + """When the service raises 404, the endpoint propagates it.""" + def _raise_not_found(sandbox_id, follow, tail, timestamps): + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Sandbox not found") + + monkeypatch.setattr(lifecycle.sandbox_service, "get_logs", _raise_not_found) + + resp = client.get("/sandboxes/missing/logs", headers=auth_headers) + + assert resp.status_code == 404 + + +def test_get_sandbox_logs_requires_auth(client: TestClient): + """Requests without an API key are rejected with 401.""" + resp = client.get("/sandboxes/abc-123/logs") + assert resp.status_code == 401 + + +def test_get_sandbox_logs_tail_must_be_positive(client: TestClient, auth_headers: dict): + """tail=0 is invalid (minimum is 1); FastAPI should return 422.""" + resp = client.get("/sandboxes/abc-123/logs", params={"tail": 0}, headers=auth_headers) + assert resp.status_code == 422 diff --git a/specs/sandbox-lifecycle.yml b/specs/sandbox-lifecycle.yml index cb9b4575..03f3ceae 100644 --- a/specs/sandbox-lifecycle.yml +++ b/specs/sandbox-lifecycle.yml @@ -375,6 +375,61 @@ paths: $ref: '#/components/responses/NotFound' '500': $ref: '#/components/responses/InternalServerError' + /sandboxes/{sandboxId}/logs: + get: + tags: [Sandboxes] + summary: Stream sandbox logs + description: | + Returns a plain-text stream of the combined stdout and stderr output of + the sandbox's main process. + + Use `follow=true` to keep the connection open and receive new log lines + as they are produced (similar to `docker logs -f`). The server closes + the stream when the sandbox exits. + parameters: + - $ref: '#/components/parameters/SandboxId' + - name: follow + in: query + description: | + If true, keep the connection open and stream new log lines until + the sandbox exits. + schema: + type: boolean + default: false + - name: tail + in: query + description: | + Return only the last *N* lines of existing log output before + streaming new lines. Omit or set to 0 to return all lines. + schema: + type: integer + minimum: 1 + - name: timestamps + in: query + description: | + If true, prefix each log line with an RFC3339Nano timestamp. + schema: + type: boolean + default: false + responses: + '200': + description: | + Log stream started successfully. + + The response body is a plain-text stream of log lines. Each chunk + may contain one or more newline-terminated lines. + content: + text/plain: + schema: + type: string + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '500': + $ref: '#/components/responses/InternalServerError' components: securitySchemes: apiKeyAuth: