diff --git a/components/execd/bootstrap.sh b/components/execd/bootstrap.sh index 373073f84..35be18222 100755 --- a/components/execd/bootstrap.sh +++ b/components/execd/bootstrap.sh @@ -191,7 +191,7 @@ is_truthy() { has_lifecycle_config() { # Keep this in sync with pkg/lifecycle/config.go's transport env, explicit # path env, and default persisted path. - if [ -n "$(printf '%s' "${OPEN_SANDBOX_LIFECYCLE:-}" | tr -d '[:space:]')" ]; then + if [ -n "$(printf '%s' "${OPENSANDBOX_LIFECYCLE:-}" | tr -d '[:space:]')" ]; then return 0 fi if [ -n "${EXECD_LIFECYCLE_CONFIG:-}" ]; then @@ -665,7 +665,7 @@ if [ -n "$LIFECYCLE_STATUS_FILE" ]; then unset _prestart_status _execd_status fi -unset OPEN_SANDBOX_LIFECYCLE EXECD_LIFECYCLE_CONFIG +unset OPENSANDBOX_LIFECYCLE EXECD_LIFECYCLE_CONFIG "$@" & CMD_PID=$! diff --git a/components/execd/pkg/isolation/config.go b/components/execd/pkg/isolation/config.go index 57536686f..c60abc449 100644 --- a/components/execd/pkg/isolation/config.go +++ b/components/execd/pkg/isolation/config.go @@ -62,7 +62,7 @@ var execdConfigEnvBlacklist = []string{ "JUPYTER_TOKEN", "EXECD_ISOLATION_CONFIG", "EXECD_ENVS", - "OPEN_SANDBOX_LIFECYCLE", + "OPENSANDBOX_LIFECYCLE", "EXECD_LIFECYCLE_CONFIG", } diff --git a/components/execd/pkg/lifecycle/config.go b/components/execd/pkg/lifecycle/config.go index 887288b58..ed2d39955 100644 --- a/components/execd/pkg/lifecycle/config.go +++ b/components/execd/pkg/lifecycle/config.go @@ -30,7 +30,7 @@ import ( ) const ( - ConfigEnv = "OPEN_SANDBOX_LIFECYCLE" + ConfigEnv = "OPENSANDBOX_LIFECYCLE" ConfigPathEnv = "EXECD_LIFECYCLE_CONFIG" defaultTimeout = 60 * time.Second diff --git a/components/execd/pkg/runtime/initmode_linux.go b/components/execd/pkg/runtime/initmode_linux.go index 96520a415..23d046102 100644 --- a/components/execd/pkg/runtime/initmode_linux.go +++ b/components/execd/pkg/runtime/initmode_linux.go @@ -339,7 +339,7 @@ func bootstrapEnv() launchOption { return func(mp *managedProcess) { mp.stripEnv = []string{ "EXECD_ACCESS_TOKEN", - "OPEN_SANDBOX_LIFECYCLE", + "OPENSANDBOX_LIFECYCLE", "EXECD_LIFECYCLE_CONFIG", } } diff --git a/components/execd/tests/lifecycle.sh b/components/execd/tests/lifecycle.sh index ec0c0af20..6d0c16cbc 100755 --- a/components/execd/tests/lifecycle.sh +++ b/components/execd/tests/lifecycle.sh @@ -90,7 +90,7 @@ if [ -n "$status_file" ]; then while true; do sleep 1; done fi lifecycle_config="" - lifecycle_transport="$(printf '%s' "${OPEN_SANDBOX_LIFECYCLE:-}" | tr -d '[:space:]')" + lifecycle_transport="$(printf '%s' "${OPENSANDBOX_LIFECYCLE:-}" | tr -d '[:space:]')" if [ -z "$lifecycle_transport" ]; then lifecycle_config="${EXECD_LIFECYCLE_CONFIG:-}" if [ -z "$lifecycle_config" ]; then @@ -167,7 +167,7 @@ if [ "${EXPECT_PRESTART_MARKER:-1}" = "1" ]; then test -f "$PRESTART_MARKER" fi test -f "$EXECD_READY_MARKER" -test -z "${OPEN_SANDBOX_LIFECYCLE:-}" +test -z "${OPENSANDBOX_LIFECYCLE:-}" test -z "${EXECD_LIFECYCLE_CONFIG:-}" test -f "$EXECD_MARKER" touch "$USER_MARKER" @@ -182,7 +182,7 @@ USER_MARKER="$TESTDIR/user-started" SEQUENCE_FILE="$TESTDIR/sequence" STATUS_DIR="$TESTDIR/status" mkdir "$STATUS_DIR" -OPEN_SANDBOX_LIFECYCLE='{"preStart":{"command":["true"]}}' \ +OPENSANDBOX_LIFECYCLE='{"preStart":{"command":["true"]}}' \ EXECD="$EXECD_STUB" \ PRESTART_MARKER="$PRESTART_MARKER" \ EXECD_MARKER="$EXECD_MARKER" \ @@ -202,7 +202,7 @@ assert_status_dir_empty echo "PASS: preStart completed before the user entrypoint" rm -f "$PRESTART_MARKER" "$EXECD_MARKER" "$EXECD_READY_MARKER" "$USER_MARKER" "$SEQUENCE_FILE" -OPEN_SANDBOX_LIFECYCLE='{"periodic":[{"name":"sync","schedule":"@hourly","command":["true"]}]}' \ +OPENSANDBOX_LIFECYCLE='{"periodic":[{"name":"sync","schedule":"@hourly","command":["true"]}]}' \ EXECD="$EXECD_STUB" \ EXECD_NO_PRESTART=1 \ EXPECT_PRESTART_MARKER=0 \ @@ -223,7 +223,7 @@ echo "PASS: periodic-only lifecycle starts without a preStart running status" rm -f "$PRESTART_MARKER" "$EXECD_MARKER" "$EXECD_READY_MARKER" "$USER_MARKER" "$SEQUENCE_FILE" # Keep this delay above bootstrap's 10-second initial startup watchdog. -OPEN_SANDBOX_LIFECYCLE='{"preStart":{"command":["true"]}}' \ +OPENSANDBOX_LIFECYCLE='{"preStart":{"command":["true"]}}' \ EXECD="$EXECD_STUB" \ PRESTART_TIMEOUT_SECONDS=30 \ PRESTART_DELAY_SECONDS=11 \ @@ -246,7 +246,7 @@ echo "PASS: preStart completion may exceed the initial startup watchdog" rm -f "$PRESTART_MARKER" "$EXECD_MARKER" "$EXECD_READY_MARKER" "$USER_MARKER" "$SEQUENCE_FILE" set +e -OPEN_SANDBOX_LIFECYCLE='{"preStart":{"command":["true"]}}' \ +OPENSANDBOX_LIFECYCLE='{"preStart":{"command":["true"]}}' \ EXECD="$EXECD_STUB" \ PRESTART_EXIT_CODE=42 \ PRESTART_MARKER="$PRESTART_MARKER" \ @@ -269,7 +269,7 @@ echo "PASS: preStart failure stops execd and prevents the user entrypoint from s rm -f "$PRESTART_MARKER" "$EXECD_MARKER" "$EXECD_READY_MARKER" "$USER_MARKER" "$SEQUENCE_FILE" PRESTART_TERMINATED_MARKER="$TESTDIR/prestart-terminated" -OPEN_SANDBOX_LIFECYCLE='{"preStart":{"command":["true"]}}' \ +OPENSANDBOX_LIFECYCLE='{"preStart":{"command":["true"]}}' \ EXECD="$EXECD_STUB" \ PRESTART_BLOCK=1 \ PRESTART_TIMEOUT_SECONDS=300 \ @@ -303,7 +303,7 @@ echo "PASS: termination during preStart is forwarded to the hook" rm -f "$PRESTART_MARKER" "$EXECD_MARKER" "$EXECD_READY_MARKER" "$USER_MARKER" "$SEQUENCE_FILE" EXECD_RUNNING_MARKER="$TESTDIR/execd-running" EXECD_IGNORED_TERM_MARKER="$TESTDIR/execd-ignored-term" -OPEN_SANDBOX_LIFECYCLE='{"preStart":{"command":["true"]}}' \ +OPENSANDBOX_LIFECYCLE='{"preStart":{"command":["true"]}}' \ EXECD="$EXECD_STUB" \ EXECD_HANG_AFTER_RUNNING=1 \ EXECD_IGNORE_TERM=1 \ @@ -349,7 +349,7 @@ echo "PASS: termination during preStart kills an unresponsive execd" rm -f "$PRESTART_MARKER" "$EXECD_MARKER" "$EXECD_READY_MARKER" "$USER_MARKER" "$SEQUENCE_FILE" set +e -OPEN_SANDBOX_LIFECYCLE='{"preStart":{"command":["true"]}}' \ +OPENSANDBOX_LIFECYCLE='{"preStart":{"command":["true"]}}' \ EXECD="$EXECD_STUB" \ EXECD_DIE_BEFORE_STATUS=1 \ PRESTART_MARKER="$PRESTART_MARKER" \ @@ -374,7 +374,7 @@ echo "PASS: execd exit before lifecycle status fails startup without leaking the rm -f "$PRESTART_MARKER" "$EXECD_MARKER" "$EXECD_READY_MARKER" "$USER_MARKER" "$SEQUENCE_FILE" EXECD_HANG_TERMINATED_MARKER="$TESTDIR/execd-hang-terminated" set +e -OPEN_SANDBOX_LIFECYCLE='{"preStart":{"command":["true"]}}' \ +OPENSANDBOX_LIFECYCLE='{"preStart":{"command":["true"]}}' \ EXECD="$EXECD_STUB" \ EXECD_HANG_BEFORE_STATUS=1 \ EXECD_HANG_TERMINATED_MARKER="$EXECD_HANG_TERMINATED_MARKER" \ @@ -399,7 +399,7 @@ echo "PASS: lifecycle startup watchdog terminates a hung execd" rm -f "$PRESTART_MARKER" "$EXECD_MARKER" "$EXECD_READY_MARKER" "$USER_MARKER" "$SEQUENCE_FILE" EXECD_RUNNING_HANG_TERMINATED_MARKER="$TESTDIR/execd-running-hang-terminated" set +e -OPEN_SANDBOX_LIFECYCLE='{"preStart":{"command":["true"]}}' \ +OPENSANDBOX_LIFECYCLE='{"preStart":{"command":["true"]}}' \ EXECD="$EXECD_STUB" \ EXECD_HANG_AFTER_RUNNING=1 \ EXECD_HANG_TERMINATED_MARKER="$EXECD_RUNNING_HANG_TERMINATED_MARKER" \ @@ -426,7 +426,7 @@ echo "PASS: lifecycle hook watchdog timeout cannot be overwritten by a late succ rm -f "$PRESTART_MARKER" "$EXECD_MARKER" "$EXECD_READY_MARKER" "$USER_MARKER" "$SEQUENCE_FILE" EXECD_INVALID_RUNNING_TERMINATED_MARKER="$TESTDIR/execd-invalid-running-terminated" set +e -OPEN_SANDBOX_LIFECYCLE='{"preStart":{"command":["true"]}}' \ +OPENSANDBOX_LIFECYCLE='{"preStart":{"command":["true"]}}' \ EXECD="$EXECD_STUB" \ EXECD_HANG_AFTER_RUNNING=1 \ EXECD_HANG_TERMINATED_MARKER="$EXECD_INVALID_RUNNING_TERMINATED_MARKER" \ @@ -452,7 +452,7 @@ echo "PASS: malformed lifecycle running status fails closed" rm -f "$PRESTART_MARKER" "$EXECD_MARKER" "$EXECD_READY_MARKER" "$USER_MARKER" "$SEQUENCE_FILE" EXECD_MISSING_STATUS_TERMINATED_MARKER="$TESTDIR/execd-missing-status-terminated" set +e -OPEN_SANDBOX_LIFECYCLE='{"preStart":{"command":["true"]}}' \ +OPENSANDBOX_LIFECYCLE='{"preStart":{"command":["true"]}}' \ EXECD="$EXECD_STUB" \ EXECD_HANG_BEFORE_STATUS=1 \ EXECD_REMOVE_STATUS_FILE=1 \ @@ -479,7 +479,7 @@ STATUS_TERMINATED_MARKER="$TESTDIR/status-terminated" for invalid_status in garbled 999 999999999999999999999999; do rm -f "$PRESTART_MARKER" "$EXECD_MARKER" "$EXECD_READY_MARKER" "$USER_MARKER" "$SEQUENCE_FILE" "$STATUS_TERMINATED_MARKER" set +e - OPEN_SANDBOX_LIFECYCLE='{"preStart":{"command":["true"]}}' \ + OPENSANDBOX_LIFECYCLE='{"preStart":{"command":["true"]}}' \ EXECD="$EXECD_STUB" \ PRESTART_STATUS_RAW="$invalid_status" \ EXECD_STATUS_STAY_ALIVE=1 \ @@ -529,7 +529,7 @@ SANITIZE_USER_SCRIPT="$TESTDIR/sanitize-user.sh" cat > "$SANITIZE_USER_SCRIPT" <<'USER' #!/bin/sh set -e -test -z "${OPEN_SANDBOX_LIFECYCLE:-}" +test -z "${OPENSANDBOX_LIFECYCLE:-}" test -z "${EXECD_LIFECYCLE_CONFIG:-}" i=0 while [ ! -f "$EXECD_MARKER" ] && [ "$i" -lt 50 ]; do @@ -541,7 +541,7 @@ touch "$USER_MARKER" USER chmod +x "$SANITIZE_USER_SCRIPT" test -f "$PERSISTED_CONFIG" -OPEN_SANDBOX_LIFECYCLE='' \ +OPENSANDBOX_LIFECYCLE='' \ EXECD_LIFECYCLE_CONFIG="$TESTDIR/missing-lifecycle.toml" \ EXECD="$EXECD_STUB" \ PRESTART_MARKER="$PRESTART_MARKER" \ @@ -562,7 +562,7 @@ rm -f "$PRESTART_MARKER" "$EXECD_MARKER" "$EXECD_READY_MARKER" "$USER_MARKER" "$ CONFIG_DIR_PATH="$TESTDIR/config-as-dir" mkdir -p "$CONFIG_DIR_PATH" set +e -OPEN_SANDBOX_LIFECYCLE='' \ +OPENSANDBOX_LIFECYCLE='' \ EXECD_LIFECYCLE_CONFIG="$CONFIG_DIR_PATH" \ EXECD="$EXECD_STUB" \ PRESTART_MARKER="$PRESTART_MARKER" \ @@ -587,7 +587,7 @@ for lifecycle_home in "$HOME" ''; do [ -z "$lifecycle_home" ] || lifecycle_transport=' ' rm -f "$PRESTART_MARKER" "$EXECD_MARKER" "$EXECD_READY_MARKER" "$USER_MARKER" "$SEQUENCE_FILE" HOME="$lifecycle_home" \ - OPEN_SANDBOX_LIFECYCLE="$lifecycle_transport" \ + OPENSANDBOX_LIFECYCLE="$lifecycle_transport" \ EXECD_LIFECYCLE_CONFIG='' \ EXECD="$EXECD_STUB" \ PRESTART_MARKER="$PRESTART_MARKER" \ diff --git a/oseps/0020-sandbox-lifecycle-hooks.md b/oseps/0020-sandbox-lifecycle-hooks.md index 7505fc6e1..aa7b5bb4b 100644 --- a/oseps/0020-sandbox-lifecycle-hooks.md +++ b/oseps/0020-sandbox-lifecycle-hooks.md @@ -201,7 +201,7 @@ config rides the existing per-provider state: |---|---|---| | Docker | file-backed store (same mechanism as `services/docker/metadata.py`) | labels on running containers are **immutable** | | K8s | `sandbox.opensandbox.io/lifecycle` annotation on BatchSandbox **or AgentSandbox** — whichever workload CR the configured provider manages (`provider_factory.py` registers both) | schemaless, no CRD change; controller ignores the key (server-only contract; add to `kubernetes/AGENTS.md` annotation list) | -| Both | env `OPEN_SANDBOX_LIFECYCLE` (JSON content) at create | **transport only** — execd validates it and atomically persists the effective config; failure to persist aborts lifecycle startup, while provider-held config remains the recovery source | +| Both | env `OPENSANDBOX_LIFECYCLE` (JSON content) at create | **transport only** — execd validates it and atomically persists the effective config; failure to persist aborts lifecycle startup, while provider-held config remains the recovery source | **Pod-creation source stays current.** On PATCH, the server also updates the pod-creation source — BatchSandbox `spec.template` / `taskTemplate` env, or diff --git a/server/opensandbox_server/api/schema.py b/server/opensandbox_server/api/schema.py index df8076765..ee365b8f2 100644 --- a/server/opensandbox_server/api/schema.py +++ b/server/opensandbox_server/api/schema.py @@ -24,6 +24,7 @@ from pydantic import BaseModel, Field, RootModel, model_validator +from opensandbox_server.constants import OPENSANDBOX_LIFECYCLE # ============================================================================ # Image Specification @@ -138,6 +139,66 @@ 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) + + @model_validator(mode="after") + def validate_command(self) -> "LifecycleHook": + if not self.command[0].strip(): + raise ValueError("Lifecycle hook command must not be empty.") + return self + + 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) + + @model_validator(mode="after") + def normalize_and_validate(self) -> "PeriodicLifecycleHook": + self.name = self.name.strip() + self.schedule = self.schedule.strip() + if not self.name: + raise ValueError("Periodic lifecycle hook name must not be blank.") + if not self.schedule: + raise ValueError("Periodic lifecycle hook schedule must not be blank.") + if not self.command[0].strip(): + raise ValueError("Periodic lifecycle hook command must not be empty.") + return self + + 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 # ============================================================================ @@ -443,6 +504,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.", + ) entrypoint: Optional[List[str]] = Field( None, min_length=1, @@ -493,10 +558,18 @@ class CreateSandboxRequest(BaseModel): @model_validator(mode="after") def validate_source_and_entrypoint(self) -> "CreateSandboxRequest": + if self.env and OPENSANDBOX_LIFECYCLE in self.env: + raise ValueError( + f"Environment variable '{OPENSANDBOX_LIFECYCLE}' 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.") diff --git a/server/opensandbox_server/constants.py b/server/opensandbox_server/constants.py new file mode 100644 index 000000000..bb278ecde --- /dev/null +++ b/server/opensandbox_server/constants.py @@ -0,0 +1,18 @@ +# 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. + +"""Dependency-free constants shared across server layers.""" + +# Server-injected transport for execd lifecycle hooks; not user-settable. +OPENSANDBOX_LIFECYCLE = "OPENSANDBOX_LIFECYCLE" diff --git a/server/opensandbox_server/services/constants.py b/server/opensandbox_server/services/constants.py index a85784963..e1af00541 100644 --- a/server/opensandbox_server/services/constants.py +++ b/server/opensandbox_server/services/constants.py @@ -14,6 +14,8 @@ """Shared constants for sandbox services.""" +from opensandbox_server.constants import OPENSANDBOX_LIFECYCLE + RESERVED_LABEL_PREFIX = "opensandbox.io/" SANDBOX_ID_LABEL = "opensandbox.io/id" @@ -176,6 +178,7 @@ class SnapshotErrorCodes: "ALLOWED_EGRESS_ENV_VARS", "OPENSANDBOX_RUNTIME_VOLUME_NAME", "OPENSANDBOX_RUNTIME_MOUNT_PATH", + "OPENSANDBOX_LIFECYCLE", "SandboxErrorCodes", "SnapshotErrorCodes", ] diff --git a/server/opensandbox_server/services/docker/docker_service.py b/server/opensandbox_server/services/docker/docker_service.py index 6e0fda1bd..fb70a9ab2 100644 --- a/server/opensandbox_server/services/docker/docker_service.py +++ b/server/opensandbox_server/services/docker/docker_service.py @@ -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, diff --git a/server/opensandbox_server/services/fleets/create_mapping.py b/server/opensandbox_server/services/fleets/create_mapping.py index 001486950..9d5f9421d 100644 --- a/server/opensandbox_server/services/fleets/create_mapping.py +++ b/server/opensandbox_server/services/fleets/create_mapping.py @@ -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: diff --git a/server/opensandbox_server/services/k8s/create_helpers.py b/server/opensandbox_server/services/k8s/create_helpers.py index f33830f06..89d6eda37 100644 --- a/server/opensandbox_server/services/k8s/create_helpers.py +++ b/server/opensandbox_server/services/k8s/create_helpers.py @@ -23,6 +23,7 @@ from opensandbox_server.config import AppConfig, EGRESS_MODE_DNS from opensandbox_server.services.constants import ( OPENSANDBOX_EGRESS_MITMPROXY_SSL_INSECURE, + OPENSANDBOX_LIFECYCLE, SANDBOX_EGRESS_AUTH_TOKEN_METADATA_KEY, SANDBOX_SECURE_ACCESS_TOKEN_METADATA_KEY, SANDBOX_ID_LABEL, @@ -97,6 +98,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[OPENSANDBOX_LIFECYCLE] = 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( diff --git a/server/tests/k8s/test_lifecycle_hooks.py b/server/tests/k8s/test_lifecycle_hooks.py new file mode 100644 index 000000000..34b73d033 --- /dev/null +++ b/server/tests/k8s/test_lifecycle_hooks.py @@ -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 ( + LifecycleHook, + PeriodicLifecycleHook, + SandboxLifecycle, +) +from opensandbox_server.services.constants import OPENSANDBOX_LIFECYCLE +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[OPENSANDBOX_LIFECYCLE]) == { + "preStart": { + "command": ["/opt/hooks/restore.sh"], + "timeoutSeconds": 30, + }, + "periodic": [ + { + "name": "checkpoint", + "schedule": "*/5 * * * *", + "command": ["/opt/hooks/checkpoint.sh"], + } + ], + } diff --git a/server/tests/test_docker_service.py b/server/tests/test_docker_service.py index 940a923e6..796f19a90 100644 --- a/server/tests/test_docker_service.py +++ b/server/tests/test_docker_service.py @@ -68,6 +68,7 @@ CredentialProxyConfig, Host, ImageSpec, + LifecycleHook, ListSandboxesRequest, NetworkPolicy, OSSFS, @@ -76,6 +77,7 @@ PVC, ResourceLimits, RenewSandboxExpirationRequest, + SandboxLifecycle, SandboxStatus, Volume, ) @@ -421,6 +423,32 @@ 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): diff --git a/server/tests/test_fleets_mapping.py b/server/tests/test_fleets_mapping.py index 3914d31f6..a9420eb47 100644 --- a/server/tests/test_fleets_mapping.py +++ b/server/tests/test_fleets_mapping.py @@ -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 ( @@ -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): diff --git a/server/tests/test_schema.py b/server/tests/test_schema.py index 454085ec1..be9576c58 100644 --- a/server/tests/test_schema.py +++ b/server/tests/test_schema.py @@ -15,6 +15,7 @@ import pytest from pydantic import ValidationError +from opensandbox_server.constants import OPENSANDBOX_LIFECYCLE from opensandbox_server.api.schema import ( CreateSandboxRequest, CreateSnapshotRequest, @@ -22,12 +23,14 @@ Host, ImageSpec, ListSnapshotsRequest, + LifecycleHook, OSSFS, PaginationInfo, PaginationRequest, PlatformSpec, PVC, ResourceLimits, + SandboxLifecycle, Snapshot, SnapshotFilter, SnapshotStatus, @@ -35,6 +38,89 @@ ) +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" + assert request.lifecycle.periodic[0].schedule == "*/5 * * * *" + + 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={OPENSANDBOX_LIFECYCLE: "{}"}, + ) + + 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"]), + ), + ) + + @pytest.mark.parametrize("duplicate_name", ["sync", " sync "]) + def test_lifecycle_rejects_duplicate_periodic_names(self, duplicate_name): + with pytest.raises(ValidationError, match="names must be unique"): + SandboxLifecycle.model_validate( + { + "periodic": [ + {"name": "sync", "schedule": "@hourly", "command": ["true"]}, + {"name": duplicate_name, "schedule": "@daily", "command": ["true"]}, + ] + } + ) + + @pytest.mark.parametrize( + ("payload", "message"), + [ + ({"preStart": {"command": [" "]}}, "command must not be empty"), + ( + {"periodic": [{"name": " ", "schedule": "@hourly", "command": ["true"]}]}, + "name must not be blank", + ), + ( + {"periodic": [{"name": "sync", "schedule": "\t", "command": ["true"]}]}, + "schedule must not be blank", + ), + ( + {"periodic": [{"name": "sync", "schedule": "@hourly", "command": [" "]}]}, + "command must not be empty", + ), + ], + ) + def test_lifecycle_rejects_blank_required_values(self, payload, message): + with pytest.raises(ValidationError, match=message): + SandboxLifecycle.model_validate(payload) + class TestHost: