e0e362d700
SDK Tests / changes (push) Successful in 2m29s
Real E2E Tests / changes (push) Successful in 2m29s
Deploy Docs Pages / build (push) Has been cancelled
Deploy Docs Pages / deploy (push) Has been cancelled
Real E2E Tests / JavaScript E2E (docker bridge) (push) Has been cancelled
Real E2E Tests / Python E2E (docker bridge) (push) Has been cancelled
Real E2E Tests / Java E2E (docker bridge) (push) Has been cancelled
Real E2E Tests / C# E2E (docker bridge) (push) Has been cancelled
Real E2E Tests / Go E2E (docker bridge) (push) Has been cancelled
Real E2E Tests / Real E2E CI (push) Has been cancelled
SDK Tests / SDK CI (push) Has been cancelled
SDK Tests / CLI Tests (push) Has been cancelled
SDK Tests / Python SDK Quality (code-interpreter) (push) Has been cancelled
SDK Tests / Python SDK Quality (sandbox) (push) Has been cancelled
SDK Tests / Python SDK Tests (code-interpreter) (push) Has been cancelled
SDK Tests / JavaScript SDK Quality And Tests (code-interpreter) (push) Has been cancelled
SDK Tests / JavaScript SDK Quality And Tests (sandbox) (push) Has been cancelled
SDK Tests / Python SDK Tests (sandbox) (push) Has been cancelled
SDK Tests / CLI Quality (push) Has been cancelled
SDK Tests / Kotlin SDK Quality And Tests (sandbox) (push) Has been cancelled
SDK Tests / Kotlin SDK Quality And Tests (code-interpreter) (push) Has been cancelled
SDK Tests / C# SDK Quality And Tests (code-interpreter) (push) Has been cancelled
SDK Tests / C# SDK Quality And Tests (sandbox) (push) Has been cancelled
SDK Tests / Go SDK Quality And Tests (push) Has been cancelled
315 lines
12 KiB
Python
315 lines
12 KiB
Python
# 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.
|
|
|
|
"""
|
|
Kubernetes diagnostics mixin for DevOps API.
|
|
|
|
Provides get_sandbox_logs, get_sandbox_inspect, and get_sandbox_events
|
|
by querying K8s Pod state and events. Mixed into KubernetesSandboxService.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
|
|
from fastapi import HTTPException, status
|
|
from kubernetes.client.exceptions import ApiException
|
|
|
|
from opensandbox_server.services.constants import (
|
|
SANDBOX_ID_LABEL,
|
|
SandboxErrorCodes,
|
|
)
|
|
|
|
#: Default container to pull logs from when the caller does not specify one.
|
|
#: OSB-managed sandbox pods canonically run the user workload in a container
|
|
#: named "sandbox" alongside sidecars (e.g. "egress") and init containers
|
|
#: (e.g. "execd-installer"). Without this default, Kubernetes returns HTTP 400
|
|
#: on any pod with more than one container.
|
|
DEFAULT_LOG_CONTAINER = "sandbox"
|
|
|
|
|
|
def _parse_since(since: str) -> int:
|
|
"""Parse a human-readable duration string (e.g. '10m', '1h') into seconds."""
|
|
m = re.fullmatch(r"(\d+)\s*([smhd])", since.strip().lower())
|
|
if not m:
|
|
return 600
|
|
value, unit = int(m.group(1)), m.group(2)
|
|
multipliers = {"s": 1, "m": 60, "h": 3600, "d": 86400}
|
|
return value * multipliers[unit]
|
|
|
|
|
|
class K8sDiagnosticsMixin:
|
|
"""Mixin that implements diagnostics methods for the Kubernetes backend."""
|
|
|
|
def _find_pod_for_sandbox(self, sandbox_id: str):
|
|
"""Find the Pod associated with a sandbox ID via label selector."""
|
|
label_selector = f"{SANDBOX_ID_LABEL}={sandbox_id}"
|
|
try:
|
|
pods = self.k8s_client.list_pods(
|
|
namespace=self.namespace,
|
|
label_selector=label_selector,
|
|
)
|
|
except Exception as exc:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail={
|
|
"code": SandboxErrorCodes.K8S_API_ERROR,
|
|
"message": f"Failed to query pods for sandbox {sandbox_id}: {exc}",
|
|
},
|
|
) from exc
|
|
|
|
if not pods:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail={
|
|
"code": SandboxErrorCodes.K8S_SANDBOX_NOT_FOUND,
|
|
"message": f"No pod found for sandbox '{sandbox_id}'",
|
|
},
|
|
)
|
|
return pods[0]
|
|
|
|
def get_sandbox_logs(
|
|
self,
|
|
sandbox_id: str,
|
|
tail: int = 100,
|
|
since: str | None = None,
|
|
container: str | None = None,
|
|
) -> str:
|
|
pod = self._find_pod_for_sandbox(sandbox_id)
|
|
pod_name = pod.metadata.name
|
|
core_v1 = self.k8s_client.get_core_v1_api()
|
|
|
|
target_container = self._resolve_log_container(pod, container)
|
|
|
|
kwargs: dict = {
|
|
"name": pod_name,
|
|
"namespace": self.namespace,
|
|
"container": target_container,
|
|
"tail_lines": tail,
|
|
"timestamps": True,
|
|
}
|
|
if since:
|
|
kwargs["since_seconds"] = _parse_since(since)
|
|
|
|
try:
|
|
log_text = core_v1.read_namespaced_pod_log(**kwargs)
|
|
except ApiException as exc:
|
|
raise _map_pod_log_error(pod_name, target_container, exc) from exc
|
|
|
|
return log_text or "(no logs)"
|
|
|
|
@staticmethod
|
|
def _resolve_log_container(pod, requested: str | None) -> str:
|
|
"""Pick the container to read logs from.
|
|
|
|
Order of preference:
|
|
1. The caller-supplied name, if it matches a container or init container
|
|
on the pod.
|
|
2. ``DEFAULT_LOG_CONTAINER`` ("sandbox") when present.
|
|
3. The first regular container declared in the pod spec.
|
|
"""
|
|
spec = getattr(pod, "spec", None)
|
|
regular = list(getattr(spec, "containers", None) or []) if spec else []
|
|
init = list(getattr(spec, "init_containers", None) or []) if spec else []
|
|
all_names = [c.name for c in regular] + [c.name for c in init]
|
|
|
|
if requested:
|
|
if requested in all_names:
|
|
return requested
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail={
|
|
"code": SandboxErrorCodes.K8S_SANDBOX_NOT_FOUND,
|
|
"message": (
|
|
f"Container '{requested}' not found on pod "
|
|
f"'{pod.metadata.name}'. Available: {all_names or '(none)'}"
|
|
),
|
|
},
|
|
)
|
|
|
|
if DEFAULT_LOG_CONTAINER in all_names:
|
|
return DEFAULT_LOG_CONTAINER
|
|
if regular:
|
|
return regular[0].name
|
|
if init:
|
|
return init[0].name
|
|
# Fall back to the canonical name so the upstream Kubernetes error is
|
|
# explicit instead of silently None-ing the container kwarg.
|
|
return DEFAULT_LOG_CONTAINER
|
|
|
|
def get_sandbox_inspect(self, sandbox_id: str) -> str:
|
|
pod = self._find_pod_for_sandbox(sandbox_id)
|
|
meta = pod.metadata
|
|
spec = pod.spec
|
|
pod_status = pod.status
|
|
|
|
lines: list[str] = []
|
|
lines.append(f"Pod Name: {meta.name}")
|
|
lines.append(f"Namespace: {meta.namespace}")
|
|
lines.append(f"Node: {spec.node_name or 'N/A'}")
|
|
lines.append(f"Phase: {pod_status.phase if pod_status else 'Unknown'}")
|
|
lines.append(f"Pod IP: {pod_status.pod_ip if pod_status else 'N/A'}")
|
|
lines.append(f"Host IP: {pod_status.host_ip if pod_status else 'N/A'}")
|
|
lines.append(f"Start Time: {pod_status.start_time if pod_status else 'N/A'}")
|
|
|
|
if spec.runtime_class_name:
|
|
lines.append(f"Runtime Class: {spec.runtime_class_name}")
|
|
|
|
# Container statuses
|
|
if pod_status and pod_status.container_statuses:
|
|
lines.append("")
|
|
lines.append("Containers:")
|
|
for cs in pod_status.container_statuses:
|
|
lines.append(f" {cs.name}:")
|
|
lines.append(f" Ready: {cs.ready}")
|
|
lines.append(f" Restart Count: {cs.restart_count}")
|
|
lines.append(f" Image: {cs.image}")
|
|
if cs.state:
|
|
if cs.state.running:
|
|
lines.append(f" State: Running (since {cs.state.running.started_at})")
|
|
elif cs.state.waiting:
|
|
lines.append(f" State: Waiting ({cs.state.waiting.reason})")
|
|
if cs.state.waiting.message:
|
|
lines.append(f" Message: {cs.state.waiting.message}")
|
|
elif cs.state.terminated:
|
|
t = cs.state.terminated
|
|
lines.append(f" State: Terminated (exit={t.exit_code}, reason={t.reason})")
|
|
if t.message:
|
|
lines.append(f" Message: {t.message}")
|
|
if cs.last_state and cs.last_state.terminated:
|
|
t = cs.last_state.terminated
|
|
lines.append(f" Last State: Terminated (exit={t.exit_code}, reason={t.reason})")
|
|
|
|
# Init container statuses
|
|
if pod_status and pod_status.init_container_statuses:
|
|
lines.append("")
|
|
lines.append("Init Containers:")
|
|
for cs in pod_status.init_container_statuses:
|
|
lines.append(f" {cs.name}:")
|
|
lines.append(f" Ready: {cs.ready}")
|
|
if cs.state:
|
|
if cs.state.terminated:
|
|
t = cs.state.terminated
|
|
lines.append(f" State: Terminated (exit={t.exit_code}, reason={t.reason})")
|
|
elif cs.state.waiting:
|
|
lines.append(f" State: Waiting ({cs.state.waiting.reason})")
|
|
|
|
# Conditions
|
|
if pod_status and pod_status.conditions:
|
|
lines.append("")
|
|
lines.append("Conditions:")
|
|
for cond in pod_status.conditions:
|
|
lines.append(f" {cond.type}: {cond.status} (reason={cond.reason or 'N/A'})")
|
|
if cond.message:
|
|
lines.append(f" Message: {cond.message}")
|
|
|
|
# Labels
|
|
if meta.labels:
|
|
lines.append("")
|
|
lines.append("Labels:")
|
|
for k, v in sorted(meta.labels.items()):
|
|
lines.append(f" {k}={v}")
|
|
|
|
# Resource requests/limits
|
|
if spec.containers:
|
|
lines.append("")
|
|
lines.append("Resources:")
|
|
for container in spec.containers:
|
|
if container.resources:
|
|
lines.append(f" {container.name}:")
|
|
if container.resources.requests:
|
|
lines.append(f" Requests: {dict(container.resources.requests)}")
|
|
if container.resources.limits:
|
|
lines.append(f" Limits: {dict(container.resources.limits)}")
|
|
|
|
return "\n".join(lines)
|
|
|
|
def get_sandbox_events(self, sandbox_id: str, limit: int = 50) -> str:
|
|
pod = self._find_pod_for_sandbox(sandbox_id)
|
|
pod_name = pod.metadata.name
|
|
core_v1 = self.k8s_client.get_core_v1_api()
|
|
|
|
events_resp = core_v1.list_namespaced_event(
|
|
namespace=self.namespace,
|
|
field_selector=f"involvedObject.name={pod_name}",
|
|
limit=limit,
|
|
)
|
|
|
|
if not events_resp.items:
|
|
return "(no events)"
|
|
|
|
lines: list[str] = []
|
|
for ev in events_resp.items:
|
|
ts = ev.last_timestamp or ev.event_time or ev.first_timestamp or "N/A"
|
|
lines.append(
|
|
f"[{ts}] {ev.type:8s} {ev.reason or 'N/A':20s} {ev.message or ''}"
|
|
)
|
|
return "\n".join(lines)
|
|
|
|
|
|
def _map_pod_log_error(pod_name: str, container: str, exc: ApiException) -> HTTPException:
|
|
"""Translate a Kubernetes pod-log ApiException into a sensible HTTPException.
|
|
|
|
Bare ``ApiException`` instances are not JSON-serialisable, so when they
|
|
escape the request handler FastAPI/uvicorn surfaces them as opaque 500
|
|
responses. Wrap them with structured detail and a status code that
|
|
reflects whether the problem is the request, the credentials, or the
|
|
cluster itself.
|
|
"""
|
|
raw_status = getattr(exc, "status", None) or 0
|
|
body = getattr(exc, "body", None) or str(exc)
|
|
|
|
if raw_status == 400:
|
|
return HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail={
|
|
"code": SandboxErrorCodes.K8S_API_ERROR,
|
|
"message": (
|
|
f"Kubernetes rejected log request for pod '{pod_name}' "
|
|
f"container '{container}': {body}"
|
|
),
|
|
},
|
|
)
|
|
if raw_status in (401, 403):
|
|
return HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail={
|
|
"code": SandboxErrorCodes.K8S_API_ERROR,
|
|
"message": (
|
|
f"Kubernetes denied log access for pod '{pod_name}' "
|
|
f"container '{container}': {body}"
|
|
),
|
|
},
|
|
)
|
|
if raw_status == 404:
|
|
return HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail={
|
|
"code": SandboxErrorCodes.K8S_SANDBOX_NOT_FOUND,
|
|
"message": (
|
|
f"Pod '{pod_name}' or container '{container}' not found: {body}"
|
|
),
|
|
},
|
|
)
|
|
return HTTPException(
|
|
status_code=status.HTTP_502_BAD_GATEWAY,
|
|
detail={
|
|
"code": SandboxErrorCodes.K8S_API_ERROR,
|
|
"message": (
|
|
f"Kubernetes returned {raw_status} when reading logs for pod "
|
|
f"'{pod_name}' container '{container}': {body}"
|
|
),
|
|
},
|
|
)
|