Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
4 changes: 2 additions & 2 deletions components/execd/bootstrap.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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=$!

Expand Down
2 changes: 1 addition & 1 deletion components/execd/pkg/isolation/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ var execdConfigEnvBlacklist = []string{
"JUPYTER_TOKEN",
"EXECD_ISOLATION_CONFIG",
"EXECD_ENVS",
"OPEN_SANDBOX_LIFECYCLE",
"OPENSANDBOX_LIFECYCLE",
"EXECD_LIFECYCLE_CONFIG",
}

Expand Down
2 changes: 1 addition & 1 deletion components/execd/pkg/lifecycle/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ import (
)

const (
ConfigEnv = "OPEN_SANDBOX_LIFECYCLE"
ConfigEnv = "OPENSANDBOX_LIFECYCLE"
ConfigPathEnv = "EXECD_LIFECYCLE_CONFIG"

defaultTimeout = 60 * time.Second
Expand Down
2 changes: 1 addition & 1 deletion components/execd/pkg/runtime/initmode_linux.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
}
}
Expand Down
36 changes: 18 additions & 18 deletions components/execd/tests/lifecycle.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"
Expand All @@ -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" \
Expand All @@ -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 \
Expand All @@ -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 \
Expand All @@ -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" \
Expand All @@ -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 \
Expand Down Expand Up @@ -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 \
Expand Down Expand Up @@ -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" \
Expand All @@ -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" \
Expand All @@ -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" \
Expand All @@ -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" \
Expand All @@ -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 \
Expand All @@ -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 \
Expand Down Expand Up @@ -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
Expand All @@ -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" \
Expand All @@ -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" \
Expand All @@ -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" \
Expand Down
2 changes: 1 addition & 1 deletion oseps/0020-sandbox-lifecycle-hooks.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
73 changes: 73 additions & 0 deletions server/opensandbox_server/api/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@

from pydantic import BaseModel, Field, RootModel, model_validator

from opensandbox_server.constants import OPENSANDBOX_LIFECYCLE

# ============================================================================
# Image Specification
Expand Down Expand Up @@ -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
# ============================================================================
Expand Down Expand Up @@ -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.",
)
Comment thread
jianpingpei marked this conversation as resolved.
entrypoint: Optional[List[str]] = Field(
None,
min_length=1,
Expand Down Expand Up @@ -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.")
Expand Down
18 changes: 18 additions & 0 deletions server/opensandbox_server/constants.py
Original file line number Diff line number Diff line change
@@ -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"
3 changes: 3 additions & 0 deletions server/opensandbox_server/services/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -176,6 +178,7 @@ class SnapshotErrorCodes:
"ALLOWED_EGRESS_ENV_VARS",
"OPENSANDBOX_RUNTIME_VOLUME_NAME",
"OPENSANDBOX_RUNTIME_MOUNT_PATH",
"OPENSANDBOX_LIFECYCLE",
"SandboxErrorCodes",
"SnapshotErrorCodes",
]
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
Loading
Loading