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
333 lines
10 KiB
Python
333 lines
10 KiB
Python
# Copyright 2025 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.
|
|
|
|
"""
|
|
Sandbox service layer for business logic.
|
|
|
|
This module contains the business logic for sandbox lifecycle management.
|
|
This module defines the abstract interface for sandbox services.
|
|
"""
|
|
|
|
from abc import ABC, abstractmethod
|
|
import socket
|
|
from typing import Optional
|
|
from uuid import uuid4
|
|
|
|
from opensandbox_server.api.schema import (
|
|
CreateSandboxRequest,
|
|
CreateSandboxResponse,
|
|
Endpoint,
|
|
ListSandboxesRequest,
|
|
ListSandboxesResponse,
|
|
PatchSandboxMetadataRequest,
|
|
RenewSandboxExpirationRequest,
|
|
RenewSandboxExpirationResponse,
|
|
Sandbox,
|
|
)
|
|
from opensandbox_server.services.validators import ensure_valid_port
|
|
|
|
|
|
class SandboxService(ABC):
|
|
"""
|
|
Abstract service interface for sandbox lifecycle operations.
|
|
|
|
This class defines the interface for all sandbox service implementations.
|
|
Implementations should handle creating, managing, and destroying sandboxes.
|
|
"""
|
|
|
|
def set_tenant_provider(self, provider: object) -> None:
|
|
"""Inject tenant provider (no-op for non-K8s implementations)."""
|
|
|
|
@staticmethod
|
|
def generate_sandbox_id() -> str:
|
|
"""
|
|
Generate a unique sandbox identifier.
|
|
|
|
Returns:
|
|
str: A RFC4122-compliant UUID4 string (with hyphens)
|
|
"""
|
|
return str(uuid4())
|
|
|
|
@staticmethod
|
|
def _resolve_bind_ip(family: int = socket.AF_INET) -> str:
|
|
"""
|
|
Resolve the outward-facing IP for hosts binding to 0.0.0.0.
|
|
|
|
Returns:
|
|
str: Detected local IP address, or 127.0.0.1 as a safe fallback.
|
|
"""
|
|
try:
|
|
target = ("2001:4860:4860::8888", 80, 0, 0) if family == socket.AF_INET6 else ("8.8.8.8", 80)
|
|
with socket.socket(family, socket.SOCK_DGRAM) as sock:
|
|
sock.connect(target)
|
|
ip = sock.getsockname()[0]
|
|
if ip:
|
|
if family == socket.AF_INET or not ip.startswith("fe80"):
|
|
return ip
|
|
except OSError:
|
|
if family == socket.AF_INET6:
|
|
return SandboxService._resolve_bind_ip(socket.AF_INET)
|
|
|
|
try:
|
|
family_name = socket.AF_INET6 if family == socket.AF_INET6 else socket.AF_INET
|
|
hostname = socket.gethostname()
|
|
infos = socket.getaddrinfo(hostname, None, family_name, socket.SOCK_DGRAM)
|
|
if infos:
|
|
addr = infos[0][4][0]
|
|
if addr:
|
|
return addr
|
|
except OSError:
|
|
pass
|
|
|
|
return "::1" if family == socket.AF_INET6 else "127.0.0.1"
|
|
|
|
@staticmethod
|
|
def validate_port(port: int) -> None:
|
|
"""
|
|
Validate that the supplied port falls within the allowed range.
|
|
|
|
Args:
|
|
port: Port to validate
|
|
|
|
Raises:
|
|
ValueError: If port is outside 1-65535
|
|
"""
|
|
ensure_valid_port(port)
|
|
|
|
@abstractmethod
|
|
async def create_sandbox(self, request: CreateSandboxRequest) -> CreateSandboxResponse:
|
|
"""
|
|
Create a new sandbox from a container image.
|
|
|
|
Args:
|
|
request: Sandbox creation request
|
|
|
|
Returns:
|
|
CreateSandboxResponse: Created sandbox information
|
|
|
|
Raises:
|
|
HTTPException: If sandbox creation fails
|
|
"""
|
|
pass
|
|
|
|
@abstractmethod
|
|
def list_sandboxes(self, request: ListSandboxesRequest) -> ListSandboxesResponse:
|
|
"""
|
|
List sandboxes with optional filtering and pagination.
|
|
|
|
Args:
|
|
request: List request with filters and pagination
|
|
|
|
Returns:
|
|
ListSandboxesResponse: Paginated list of sandboxes
|
|
"""
|
|
pass
|
|
|
|
@abstractmethod
|
|
def get_sandbox(self, sandbox_id: str) -> Sandbox:
|
|
"""
|
|
Fetch a sandbox by id.
|
|
|
|
Args:
|
|
sandbox_id: Unique sandbox identifier
|
|
|
|
Returns:
|
|
Sandbox: Complete sandbox information
|
|
|
|
Raises:
|
|
HTTPException: If sandbox not found
|
|
"""
|
|
pass
|
|
|
|
@abstractmethod
|
|
def delete_sandbox(self, sandbox_id: str) -> None:
|
|
"""
|
|
Delete a sandbox.
|
|
|
|
Args:
|
|
sandbox_id: Unique sandbox identifier
|
|
|
|
Raises:
|
|
HTTPException: If sandbox not found or deletion fails
|
|
"""
|
|
pass
|
|
|
|
@abstractmethod
|
|
def pause_sandbox(self, sandbox_id: str) -> None:
|
|
"""
|
|
Pause a running sandbox.
|
|
|
|
Args:
|
|
sandbox_id: Unique sandbox identifier
|
|
|
|
Raises:
|
|
HTTPException: If sandbox not found or cannot be paused
|
|
"""
|
|
pass
|
|
|
|
@abstractmethod
|
|
def resume_sandbox(self, sandbox_id: str) -> None:
|
|
"""
|
|
Resume a paused sandbox.
|
|
|
|
Args:
|
|
sandbox_id: Unique sandbox identifier
|
|
|
|
Raises:
|
|
HTTPException: If sandbox not found or cannot be resumed
|
|
"""
|
|
pass
|
|
|
|
@abstractmethod
|
|
def renew_expiration(
|
|
self,
|
|
sandbox_id: str,
|
|
request: RenewSandboxExpirationRequest,
|
|
) -> RenewSandboxExpirationResponse:
|
|
"""
|
|
Renew sandbox expiration time.
|
|
|
|
Args:
|
|
sandbox_id: Unique sandbox identifier
|
|
request: Renewal request with new expiration time
|
|
|
|
Returns:
|
|
RenewSandboxExpirationResponse: Updated expiration time
|
|
|
|
Raises:
|
|
HTTPException: If sandbox not found or renewal fails
|
|
"""
|
|
pass
|
|
|
|
@abstractmethod
|
|
def patch_sandbox_metadata(self, sandbox_id: str, patch: PatchSandboxMetadataRequest) -> Sandbox:
|
|
"""Patch sandbox metadata via JSON Merge Patch (RFC 7396). Non-null adds/replaces, null deletes, absent keeps."""
|
|
pass
|
|
|
|
@staticmethod
|
|
def _is_system_label(key: str) -> bool:
|
|
return key.startswith("opensandbox.io/")
|
|
|
|
@staticmethod
|
|
def _apply_metadata_patch(labels: dict, patch: dict) -> dict:
|
|
"""Apply JSON Merge Patch to labels: separate user metadata, merge, validate, rebuild."""
|
|
from fastapi import HTTPException
|
|
from opensandbox_server.services.validators import ensure_metadata_labels
|
|
|
|
for key in patch:
|
|
if SandboxService._is_system_label(key):
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail={
|
|
"code": "INVALID_METADATA_LABEL",
|
|
"message": f"Metadata key '{key}' is reserved (opensandbox.io/ prefix).",
|
|
},
|
|
)
|
|
|
|
# Validate only incoming patch values, not existing labels
|
|
patch_additions = {k: str(v) for k, v in patch.items() if v is not None}
|
|
if patch_additions:
|
|
ensure_metadata_labels(patch_additions)
|
|
|
|
current_metadata = {
|
|
k: v for k, v in labels.items() if not SandboxService._is_system_label(k)
|
|
}
|
|
|
|
for key, value in patch.items():
|
|
if value is None:
|
|
current_metadata.pop(key, None)
|
|
else:
|
|
current_metadata[key] = str(value)
|
|
|
|
new_labels = {k: v for k, v in labels.items() if SandboxService._is_system_label(k)}
|
|
for k, v in current_metadata.items():
|
|
new_labels[k] = str(v)
|
|
return new_labels
|
|
|
|
# Diagnostics (DevOps)
|
|
# ------------------------------------------------------------------
|
|
|
|
@abstractmethod
|
|
def get_sandbox_logs(
|
|
self,
|
|
sandbox_id: str,
|
|
tail: int = 100,
|
|
since: str | None = None,
|
|
container: str | None = None,
|
|
) -> str:
|
|
"""
|
|
Retrieve container logs for a sandbox.
|
|
|
|
Args:
|
|
sandbox_id: Unique sandbox identifier
|
|
tail: Number of trailing log lines to return
|
|
since: Only return logs newer than this duration (e.g. "10m", "1h")
|
|
container: Optional container name. When omitted, backends select a
|
|
sensible default (typically the user "sandbox" container).
|
|
|
|
Returns:
|
|
str: Plain-text log output
|
|
"""
|
|
pass
|
|
|
|
@abstractmethod
|
|
def get_sandbox_inspect(self, sandbox_id: str) -> str:
|
|
"""
|
|
Retrieve detailed inspection info for a sandbox container.
|
|
|
|
Args:
|
|
sandbox_id: Unique sandbox identifier
|
|
|
|
Returns:
|
|
str: Plain-text inspection output
|
|
"""
|
|
pass
|
|
|
|
@abstractmethod
|
|
def get_sandbox_events(self, sandbox_id: str, limit: int = 50) -> str:
|
|
"""
|
|
Retrieve events related to a sandbox.
|
|
|
|
Args:
|
|
sandbox_id: Unique sandbox identifier
|
|
limit: Maximum number of events to return
|
|
|
|
Returns:
|
|
str: Plain-text event output
|
|
"""
|
|
pass
|
|
|
|
@abstractmethod
|
|
def get_endpoint(self, sandbox_id: str, port: int, resolve_internal: bool = False,
|
|
expires: Optional[int] = None) -> Endpoint:
|
|
"""
|
|
Get sandbox access endpoint.
|
|
|
|
Args:
|
|
sandbox_id: Unique sandbox identifier
|
|
port: Port number where the service is listening inside the sandbox
|
|
resolve_internal: If True, return the internal container IP (for proxy), ignoring router config.
|
|
expires: Unix epoch seconds for a signed route token. When provided, the
|
|
endpoint is wrapped in a cryptographically signed route per OSEP-0011.
|
|
Requires ingress gateway mode with secure_access keys configured.
|
|
|
|
Returns:
|
|
Endpoint: Public endpoint URL
|
|
|
|
Raises:
|
|
HTTPException: If sandbox not found, endpoint not available,
|
|
or signed routes are not supported by the runtime/configuration.
|
|
"""
|
|
pass
|