Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions server/opensandbox_server/api/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@

from pydantic import BaseModel, Field, RootModel, model_validator

OPEN_SANDBOX_LIFECYCLE_ENV = "OPEN_SANDBOX_LIFECYCLE"
Comment thread
Pangjiping marked this conversation as resolved.
Outdated


# ============================================================================
# Image Specification
Expand Down Expand Up @@ -138,6 +140,48 @@ class Config:
populate_by_name = True


class LifecycleHook(BaseModel):
"""Command executed by execd before the user entrypoint starts."""

command: List[str] = Field(..., min_length=1)
timeout_seconds: Optional[int] = Field(None, alias="timeoutSeconds", ge=1)

class Config:
populate_by_name = True
extra = "forbid"


class PeriodicLifecycleHook(BaseModel):
"""Named command scheduled by execd while the sandbox is running."""

name: str = Field(..., min_length=1)
schedule: str = Field(..., min_length=1)
command: List[str] = Field(..., min_length=1)
timeout_seconds: Optional[int] = Field(None, alias="timeoutSeconds", ge=1)

class Config:
populate_by_name = True
extra = "forbid"


class SandboxLifecycle(BaseModel):
"""Extensible lifecycle configuration transported internally to execd."""

pre_start: Optional[LifecycleHook] = Field(None, alias="preStart")
periodic: Optional[List[PeriodicLifecycleHook]] = None

@model_validator(mode="after")
def validate_periodic_names(self) -> "SandboxLifecycle":
names = [hook.name for hook in self.periodic or []]
if len(names) != len(set(names)):
raise ValueError("Periodic lifecycle hook names must be unique.")
return self

class Config:
populate_by_name = True
extra = "forbid"


# ============================================================================
# Volume Definitions
# ============================================================================
Expand Down Expand Up @@ -443,6 +487,10 @@ class CreateSandboxRequest(BaseModel):
None,
description="Custom key-value metadata for management, filtering, and tagging",
)
lifecycle: Optional[SandboxLifecycle] = Field(
None,
description="Optional declarative lifecycle hooks executed by execd.",
)
Comment thread
jianpingpei marked this conversation as resolved.
entrypoint: Optional[List[str]] = Field(
None,
min_length=1,
Expand Down Expand Up @@ -493,10 +541,18 @@ class CreateSandboxRequest(BaseModel):

@model_validator(mode="after")
def validate_source_and_entrypoint(self) -> "CreateSandboxRequest":
if self.env and OPEN_SANDBOX_LIFECYCLE_ENV in self.env:
raise ValueError(
f"Environment variable '{OPEN_SANDBOX_LIFECYCLE_ENV}' is reserved. "
"Use the lifecycle request field instead."
)

# When poolRef is set, image/snapshotId/entrypoint/resourceLimits are
# all defined in the Pool CRD and not required from the caller.
has_pool_ref = bool((self.extensions or {}).get("poolRef", "").strip())
if has_pool_ref:
if self.lifecycle is not None:
raise ValueError("lifecycle cannot be used together with poolRef.")
# Reject conflicting fields that would be ignored in pool mode
if bool((self.snapshot_id or "").strip()):
raise ValueError("snapshotId cannot be used together with poolRef.")
Expand Down
8 changes: 8 additions & 0 deletions server/opensandbox_server/services/docker/docker_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -637,6 +637,14 @@ async def create_sandbox(self, request: CreateSandboxRequest) -> CreateSandboxRe
Raises:
HTTPException: If sandbox creation fails
"""
if request.lifecycle is not None:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail={
"code": SandboxErrorCodes.INVALID_PARAMETER,
"message": "lifecycle hooks are not supported by the Docker provider.",
},
)
if (request.extensions or {}).get("poolRef", "").strip():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
Expand Down
5 changes: 5 additions & 0 deletions server/opensandbox_server/services/fleets/create_mapping.py
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,11 @@ def _canonical_quantity(value: str) -> decimal.Decimal:

def _reject_unsupported_fields(request: CreateSandboxRequest) -> None:
"""Reject pod-identity-dependent fields that have no shared-Fastlet meaning."""
if request.lifecycle is not None:
raise UnsupportedFieldError(
"lifecycle",
"lifecycle hooks are not supported by the fleets backend",
)
if request.snapshot_id:
raise UnsupportedFieldError("snapshotId", "snapshots are not supported on fleets")
if request.platform is not None:
Expand Down
10 changes: 9 additions & 1 deletion server/opensandbox_server/services/k8s/create_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,10 @@
from datetime import datetime
from typing import Callable, Dict, Optional

from opensandbox_server.api.schema import CreateSandboxRequest
from opensandbox_server.api.schema import (
OPEN_SANDBOX_LIFECYCLE_ENV,
CreateSandboxRequest,
)
from opensandbox_server.config import AppConfig, EGRESS_MODE_DNS
from opensandbox_server.services.constants import (
OPENSANDBOX_EGRESS_MITMPROXY_SSL_INSECURE,
Expand Down Expand Up @@ -97,6 +100,11 @@ def _build_create_workload_context(
resource_requests = request.resource_requests.root

sandbox_env, egress_env = split_egress_env(request.env)
if request.lifecycle is not None:
sandbox_env[OPEN_SANDBOX_LIFECYCLE_ENV] = request.lifecycle.model_dump_json(
by_alias=True,
exclude_none=True,
)

if credential_proxy_enabled and egress_env.get(OPENSANDBOX_EGRESS_MITMPROXY_SSL_INSECURE):
raise ValueError(
Expand Down
70 changes: 70 additions & 0 deletions server/tests/k8s/test_lifecycle_hooks.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
# 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.

import json
from datetime import datetime, timezone

from opensandbox_server.api.schema import (
OPEN_SANDBOX_LIFECYCLE_ENV,
LifecycleHook,
PeriodicLifecycleHook,
SandboxLifecycle,
)
from opensandbox_server.services.k8s.create_helpers import (
_build_create_workload_context,
)


def test_create_context_transports_lifecycle_as_reserved_execd_env(
k8s_app_config,
create_sandbox_request,
):
create_sandbox_request.env = {"USER_ENV": "value"}
create_sandbox_request.lifecycle = SandboxLifecycle(
preStart=LifecycleHook(
command=["/opt/hooks/restore.sh"],
timeoutSeconds=30,
),
periodic=[
PeriodicLifecycleHook(
name="checkpoint",
schedule="*/5 * * * *",
command=["/opt/hooks/checkpoint.sh"],
)
],
)

context = _build_create_workload_context(
k8s_app_config,
create_sandbox_request,
"sandbox-1",
datetime.now(timezone.utc),
lambda: "egress-token",
lambda: "secure-token",
)

assert context.sandbox_env["USER_ENV"] == "value"
assert json.loads(context.sandbox_env[OPEN_SANDBOX_LIFECYCLE_ENV]) == {
"preStart": {
"command": ["/opt/hooks/restore.sh"],
"timeoutSeconds": 30,
},
"periodic": [
{
"name": "checkpoint",
"schedule": "*/5 * * * *",
"command": ["/opt/hooks/checkpoint.sh"],
}
],
}
27 changes: 27 additions & 0 deletions server/tests/test_docker_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@
CredentialProxyConfig,
Host,
ImageSpec,
LifecycleHook,
ListSandboxesRequest,
NetworkPolicy,
OSSFS,
Expand All @@ -76,6 +77,7 @@
PVC,
ResourceLimits,
RenewSandboxExpirationRequest,
SandboxLifecycle,
SandboxStatus,
Volume,
)
Expand Down Expand Up @@ -421,6 +423,31 @@ async def test_create_sandbox_rejects_pool_ref_on_docker(mock_docker):
assert exc.value.detail["code"] == "SANDBOX::UNSUPPORTED_POOL_REF"
mock_client.containers.create.assert_not_called()


@pytest.mark.asyncio
@patch("opensandbox_server.services.docker.docker_service.docker")
async def test_create_sandbox_rejects_lifecycle_hooks_on_docker(mock_docker):
mock_client = MagicMock()
mock_client.containers.list.return_value = []
mock_docker.from_env.return_value = mock_client

service = DockerSandboxService(config=_app_config())
request = CreateSandboxRequest(
image=ImageSpec(uri="python:3.11"),
entrypoint=["python"],
resourceLimits=ResourceLimits(root={}),
lifecycle=SandboxLifecycle(
preStart=LifecycleHook(command=["true"]),
),
)

with pytest.raises(HTTPException) as exc:
await service.create_sandbox(request)

assert exc.value.status_code == status.HTTP_400_BAD_REQUEST
assert exc.value.detail["code"] == SandboxErrorCodes.INVALID_PARAMETER
mock_client.containers.create.assert_not_called()

@pytest.mark.asyncio
@patch("opensandbox_server.services.docker.docker_service.docker")
async def test_create_sandbox_rejects_timeout_above_configured_maximum(mock_docker):
Expand Down
10 changes: 10 additions & 0 deletions server/tests/test_fleets_mapping.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,12 @@
from opensandbox_server.api.schema import (
CredentialProxyConfig,
ImageSpec,
LifecycleHook,
NetworkPolicy,
NetworkRule,
PlatformSpec,
ResourceLimits,
SandboxLifecycle,
Volume,
)
from opensandbox_server.services.fleets.create_mapping import (
Expand Down Expand Up @@ -148,6 +150,14 @@ def test_map_create_request_renew_extension_goes_to_reserved_metadata():
),
("secureAccess", {"secure_access": True}),
("volumes", {"volumes": [_host_volume()]}),
(
"lifecycle",
{
"lifecycle": SandboxLifecycle(
preStart=LifecycleHook(command=["true"]),
)
},
),
],
)
def test_map_create_request_rejects_unsupported_fields(field_name, payload):
Expand Down
62 changes: 62 additions & 0 deletions server/tests/test_schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,25 +16,87 @@
from pydantic import ValidationError

from opensandbox_server.api.schema import (
OPEN_SANDBOX_LIFECYCLE_ENV,
CreateSandboxRequest,
CreateSnapshotRequest,
CredentialProxyConfig,
Host,
ImageSpec,
ListSnapshotsRequest,
LifecycleHook,
OSSFS,
PaginationInfo,
PaginationRequest,
PlatformSpec,
PVC,
ResourceLimits,
SandboxLifecycle,
Snapshot,
SnapshotFilter,
SnapshotStatus,
Volume,
)


class TestSandboxLifecycle:

def test_create_request_parses_lifecycle_aliases(self):
request = CreateSandboxRequest.model_validate(
{
"image": {"uri": "python:3.11"},
"entrypoint": ["python"],
"resourceLimits": {},
"lifecycle": {
"preStart": {
"command": ["/opt/hooks/restore.sh"],
"timeoutSeconds": 30,
},
"periodic": [
{
"name": "checkpoint",
"schedule": "*/5 * * * *",
"command": ["/opt/hooks/checkpoint.sh"],
}
],
},
}
)

assert request.lifecycle is not None
assert request.lifecycle.pre_start is not None
assert request.lifecycle.pre_start.timeout_seconds == 30
assert request.lifecycle.periodic is not None
assert request.lifecycle.periodic[0].name == "checkpoint"

def test_create_request_rejects_reserved_lifecycle_env(self):
with pytest.raises(ValidationError, match="is reserved"):
CreateSandboxRequest(
image=ImageSpec(uri="python:3.11"),
entrypoint=["python"],
resourceLimits=ResourceLimits(root={}),
env={OPEN_SANDBOX_LIFECYCLE_ENV: "{}"},
)

def test_create_request_rejects_lifecycle_with_pool_ref(self):
with pytest.raises(ValidationError, match="lifecycle cannot be used together with poolRef"):
CreateSandboxRequest(
extensions={"poolRef": "default/pool"},
lifecycle=SandboxLifecycle(
preStart=LifecycleHook(command=["true"]),
),
)

def test_lifecycle_rejects_duplicate_periodic_names(self):
with pytest.raises(ValidationError, match="names must be unique"):
SandboxLifecycle.model_validate(
{
"periodic": [
{"name": "sync", "schedule": "@hourly", "command": ["true"]},
{"name": "sync", "schedule": "@daily", "command": ["true"]},
]
}
)


class TestHost:

Expand Down
Loading