diff --git a/examples/code-interpreter/README.md b/examples/code-interpreter/README.md index ec5ed6ef..a6dc0131 100644 --- a/examples/code-interpreter/README.md +++ b/examples/code-interpreter/README.md @@ -63,6 +63,141 @@ The script creates a Sandbox + CodeInterpreter, runs a Python code snippet and p 3 + 4 = 7 +=== TypeScript example === +[TypeScript stdout] Hello from TypeScript! + +[TypeScript stdout] sum = 6 +``` + +# Code Interpreter Sandbox from pool + +## Start OpenSandbox server [k8s] + +Install the k8s OpenSandbox operator, and create a pool: +```yaml +apiVersion: sandbox.opensandbox.io/v1alpha1 +kind: Pool +metadata: + labels: + app.kubernetes.io/name: sandbox-k8s + app.kubernetes.io/managed-by: kustomize + name: pool-sample + namespace: opensandbox +spec: + template: + metadata: + labels: + app: example + spec: + volumes: + - name: sandbox-storage + emptyDir: { } + - name: opensandbox-bin + emptyDir: { } + initContainers: + - name: task-executor-installer + image: sandbox-registry.cn-zhangjiakou.cr.aliyuncs.com/opensandbox/task-executor:latest + command: [ "/bin/sh", "-c" ] + args: + - | + cp /workspace/server /opt/opensandbox/bin/task-executor && + chmod +x /opt/opensandbox/bin/task-executor + volumeMounts: + - name: opensandbox-bin + mountPath: /opt/opensandbox/bin + - name: execd-installer + image: sandbox-registry.cn-zhangjiakou.cr.aliyuncs.com/opensandbox/execd:latest + command: [ "/bin/sh", "-c" ] + args: + - | + cp ./execd /opt/opensandbox/bin/execd && + cp ./bootstrap.sh /opt/opensandbox/bin/bootstrap.sh && + chmod +x /opt/opensandbox/bin/execd && + chmod +x /opt/opensandbox/bin/bootstrap.sh + volumeMounts: + - name: opensandbox-bin + mountPath: /opt/opensandbox/bin + containers: + - name: sandbox + image: sandbox-registry.cn-zhangjiakou.cr.aliyuncs.com/opensandbox/code-interpreter:latest + command: + - "/bin/sh" + - "-c" + - | + /opt/opensandbox/bin/task-executor -listen-addr=0.0.0.0:5758 >/tmp/task-executor.log 2>&1 + env: + - name: SANDBOX_MAIN_CONTAINER + value: main + - name: EXECD_ENVS + value: /opt/opensandbox/.env + - name: EXECD + value: /opt/opensandbox/bin/execd + volumeMounts: + - name: sandbox-storage + mountPath: /var/lib/sandbox + - name: opensandbox-bin + mountPath: /opt/opensandbox/bin + tolerations: + - operator: "Exists" + capacitySpec: + bufferMax: 3 + bufferMin: 1 + poolMax: 5 + poolMin: 0 +``` + +Start the k8s OpenSandbox server: + +```shell +git clone git@github.com:alibaba/OpenSandbox.git +cd OpenSandbox/server + +# replace with your k8s cluster config, kubeconfig etc. +cp example.config.k8s.toml ~/.sandbox.toml +cp example.batchsandbox-template.yaml ~/batchsandbox-template.yaml + +uv sync +uv run python -m src.main +``` + +## Create and access the Code Interpreter Sandbox + +```shell +# Install OpenSandbox packages +uv pip install opensandbox opensandbox-code-interpreter + +# Run the example (requires SANDBOX_DOMAIN / SANDBOX_API_KEY) +uv run python examples/code-interpreter/main_use_pool.py +``` + +The script creates a Sandbox + CodeInterpreter, runs a Python code snippet and prints stdout/result, then terminates the remote instance. + +## Environment variables + +- `SANDBOX_DOMAIN`: Sandbox service address (default: `localhost:8080`) +- `SANDBOX_API_KEY`: API key if your server requires authentication +- `SANDBOX_IMAGE`: Sandbox image to use (default: `sandbox-registry.cn-zhangjiakou.cr.aliyuncs.com/opensandbox/code-interpreter:latest`) + +## Example output + +```text +=== Verify Environment Variable === +[ENV Check] TEST_ENV value: test + +[ENV Result] 'test' + +=== Java example === +[Java stdout] Hello from Java! + +[Java stdout] 2 + 3 = 5 + +[Java result] 5 + +=== Go example === +[Go stdout] Hello from Go! +3 + 4 = 7 + + === TypeScript example === [TypeScript stdout] Hello from TypeScript! diff --git a/examples/code-interpreter/main_use_pool.py b/examples/code-interpreter/main_use_pool.py new file mode 100644 index 00000000..4e119387 --- /dev/null +++ b/examples/code-interpreter/main_use_pool.py @@ -0,0 +1,117 @@ +# 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. + +import asyncio +import os +from datetime import timedelta + +from code_interpreter import CodeInterpreter, SupportedLanguage +from opensandbox import Sandbox +from opensandbox.config import ConnectionConfig + + +async def main() -> None: + domain = os.getenv("SANDBOX_DOMAIN", "localhost:8080") + api_key = os.getenv("SANDBOX_API_KEY") + image = os.getenv( + "SANDBOX_IMAGE", + "sandbox-registry.cn-zhangjiakou.cr.aliyuncs.com/opensandbox/code-interpreter:latest", + ) + + config = ConnectionConfig( + domain=domain, + api_key=api_key, + request_timeout=timedelta(seconds=60), + ) + + sandbox = await Sandbox.create( + image, + connection_config=config, + extensions={"poolRef":"pool-sample"}, + entrypoint=["/opt/opensandbox/code-interpreter.sh"], + env={ + "TEST_ENV": "test", + }, + ) + + async with sandbox: + interpreter = await CodeInterpreter.create(sandbox=sandbox) + + # Verify environment variable is set + print("\n=== Verify Environment Variable ===") + env_check = await interpreter.codes.run( + "import os\n" + "test_env = os.getenv('TEST_ENV', 'NOT_SET')\n" + "print(f'TEST_ENV value: {test_env}')\n" + "test_env", + language=SupportedLanguage.PYTHON, + ) + for msg in env_check.logs.stdout: + print(f"[ENV Check] {msg.text}") + if env_check.result: + for res in env_check.result: + print(f"[ENV Result] {res.text}") + + # Java example: print to stdout and return the final result line. + java_exec = await interpreter.codes.run( + "System.out.println(\"Hello from Java!\");\n" + "int result = 2 + 3;\n" + "System.out.println(\"2 + 3 = \" + result);\n" + "result", + language=SupportedLanguage.JAVA, + ) + print("\n=== Java example ===") + for msg in java_exec.logs.stdout: + print(f"[Java stdout] {msg.text}") + if java_exec.result: + for res in java_exec.result: + print(f"[Java result] {res.text}") + if java_exec.error: + print(f"[Java error] {java_exec.error.name}: {java_exec.error.value}") + + # Go example: print logs and demonstrate a main function structure. + go_exec = await interpreter.codes.run( + "package main\n" + "import \"fmt\"\n" + "func main() {\n" + " fmt.Println(\"Hello from Go!\")\n" + " sum := 3 + 4\n" + " fmt.Println(\"3 + 4 =\", sum)\n" + "}", + language=SupportedLanguage.GO, + ) + print("\n=== Go example ===") + for msg in go_exec.logs.stdout: + print(f"[Go stdout] {msg.text}") + if go_exec.error: + print(f"[Go error] {go_exec.error.name}: {go_exec.error.value}") + + # TypeScript example: use typing and sum an array. + ts_exec = await interpreter.codes.run( + "console.log('Hello from TypeScript!');\n" + "const nums: number[] = [1, 2, 3];\n" + "console.log('sum =', nums.reduce((a, b) => a + b, 0));", + language=SupportedLanguage.TYPESCRIPT, + ) + print("\n=== TypeScript example ===") + for msg in ts_exec.logs.stdout: + print(f"[TypeScript stdout] {msg.text}") + if ts_exec.error: + print(f"[TypeScript error] {ts_exec.error.name}: {ts_exec.error.value}") + + await sandbox.kill() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/kubernetes/config/samples/sandbox_v1alpha1_batchsandbox-with-task.yaml b/kubernetes/config/samples/sandbox_v1alpha1_batchsandbox-with-task.yaml index 5a52cf59..19df51a8 100644 --- a/kubernetes/config/samples/sandbox_v1alpha1_batchsandbox-with-task.yaml +++ b/kubernetes/config/samples/sandbox_v1alpha1_batchsandbox-with-task.yaml @@ -5,7 +5,7 @@ metadata: app.kubernetes.io/name: sandbox-k8s app.kubernetes.io/managed-by: kustomize name: batchsandbox-sample - namespace: sandbox-k8s + namespace: opensandbox spec: replicas: 2 template: diff --git a/kubernetes/config/samples/sandbox_v1alpha1_batchsandbox.yaml b/kubernetes/config/samples/sandbox_v1alpha1_batchsandbox.yaml index 39d405d5..30e69b1c 100644 --- a/kubernetes/config/samples/sandbox_v1alpha1_batchsandbox.yaml +++ b/kubernetes/config/samples/sandbox_v1alpha1_batchsandbox.yaml @@ -5,7 +5,7 @@ metadata: app.kubernetes.io/name: sandbox-k8s app.kubernetes.io/managed-by: kustomize name: batchsandbox-sample - namespace: sandbox-k8s + namespace: opensandbox spec: replicas: 1 poolRef: pool-sample diff --git a/kubernetes/config/samples/sandbox_v1alpha1_pool.yaml b/kubernetes/config/samples/sandbox_v1alpha1_pool.yaml index 01f0ec81..8112905a 100644 --- a/kubernetes/config/samples/sandbox_v1alpha1_pool.yaml +++ b/kubernetes/config/samples/sandbox_v1alpha1_pool.yaml @@ -5,63 +5,61 @@ metadata: app.kubernetes.io/name: sandbox-k8s app.kubernetes.io/managed-by: kustomize name: pool-sample - namespace: sandbox-k8s + namespace: opensandbox spec: template: metadata: labels: app: example spec: - shareProcessNamespace: true volumes: - name: sandbox-storage emptyDir: { } - name: opensandbox-bin emptyDir: { } initContainers: + - name: task-executor-installer + image: sandbox-registry.cn-zhangjiakou.cr.aliyuncs.com/opensandbox/task-executor:latest + command: [ "/bin/sh", "-c" ] + args: + - | + cp /workspace/server /opt/opensandbox/bin/task-executor && + chmod +x /opt/opensandbox/bin/task-executor + volumeMounts: + - name: opensandbox-bin + mountPath: /opt/opensandbox/bin - name: execd-installer image: sandbox-registry.cn-zhangjiakou.cr.aliyuncs.com/opensandbox/execd:latest command: [ "/bin/sh", "-c" ] args: - | - cp ./execd /opt/opensandbox/execd/execd && - chmod +x /opt/opensandbox/execd/execd + cp ./execd /opt/opensandbox/bin/execd && + cp ./bootstrap.sh /opt/opensandbox/bin/bootstrap.sh && + chmod +x /opt/opensandbox/bin/execd && + chmod +x /opt/opensandbox/bin/bootstrap.sh volumeMounts: - name: opensandbox-bin - mountPath: /opt/opensandbox/execd + mountPath: /opt/opensandbox/bin containers: - - name: main + - name: sandbox image: sandbox-registry.cn-zhangjiakou.cr.aliyuncs.com/opensandbox/code-interpreter:latest command: - "/bin/sh" - "-c" - args: - - "/opt/opensandbox/execd/execd > /tmp/execd.log 2>&1" + - | + /opt/opensandbox/bin/task-executor -listen-addr=0.0.0.0:5758 >/tmp/task-executor.log 2>&1 env: - name: SANDBOX_MAIN_CONTAINER value: main + - name: EXECD_ENVS + value: /opt/opensandbox/.env + - name: EXECD + value: /opt/opensandbox/bin/execd volumeMounts: - name: sandbox-storage mountPath: /var/lib/sandbox - name: opensandbox-bin - mountPath: /opt/opensandbox/execd - - command: - - /workspace/server - - -listen-addr=0.0.0.0:5758 - - -enable-sidecar-mode=true - image: task-executor:dev - name: task-executor - securityContext: - capabilities: - add: - - SYS_PTRACE - - SYS_ADMIN - - NET_ADMIN - volumeMounts: - - name: sandbox-storage - mountPath: /var/lib/sandbox - - name: opensandbox-bin - mountPath: /opt/opensandbox/execd + mountPath: /opt/opensandbox/bin tolerations: - operator: "Exists" capacitySpec: diff --git a/kubernetes/config/samples/sandbox_v1alpha1_pooled_batchsandbox.yaml b/kubernetes/config/samples/sandbox_v1alpha1_pooled_batchsandbox.yaml index e57927fd..efbb84d1 100644 --- a/kubernetes/config/samples/sandbox_v1alpha1_pooled_batchsandbox.yaml +++ b/kubernetes/config/samples/sandbox_v1alpha1_pooled_batchsandbox.yaml @@ -5,7 +5,7 @@ metadata: app.kubernetes.io/name: sandbox-k8s app.kubernetes.io/managed-by: kustomize name: batchsandbox-pool-sample - namespace: sandbox-k8s + namespace: opensandbox spec: poolRef: pool-sample replicas: 2 diff --git a/server/example.batchsandbox-template.yaml b/server/example.batchsandbox-template.yaml index 49deb011..d207d9c0 100644 --- a/server/example.batchsandbox-template.yaml +++ b/server/example.batchsandbox-template.yaml @@ -7,10 +7,6 @@ # Metadata template (will be merged with runtime-generated metadata) metadata: - annotations: - template-source: "batchsandbox-template.yaml" - managed-by: "opensandbox" - # Spec template spec: replicas: 1 diff --git a/server/src/services/constants.py b/server/src/services/constants.py index 8036459e..afb9c0f3 100644 --- a/server/src/services/constants.py +++ b/server/src/services/constants.py @@ -55,6 +55,7 @@ class SandboxErrorCodes: UNKNOWN_ERROR = "SANDBOX::UNKNOWN_ERROR" API_NOT_SUPPORTED = "SANDBOX::API_NOT_SUPPORTED" INVALID_METADATA_LABEL = "SANDBOX::INVALID_METADATA_LABEL" + INVALID_PARAMETER = "SANDBOX::INVALID_PARAMETER" __all__ = [ diff --git a/server/src/services/k8s/batchsandbox_provider.py b/server/src/services/k8s/batchsandbox_provider.py index 81d056a1..4d73e7cc 100644 --- a/server/src/services/k8s/batchsandbox_provider.py +++ b/server/src/services/k8s/batchsandbox_provider.py @@ -17,6 +17,7 @@ """ import logging +import shlex from datetime import datetime from typing import Dict, List, Any, Optional @@ -75,9 +76,47 @@ def create_workload( labels: Dict[str, str], expires_at: datetime, execd_image: str, + extensions: Optional[Dict[str, str]] = None, ) -> Dict[str, Any]: - """Create a BatchSandbox workload.""" + """ + Create a BatchSandbox workload. + + Supports both template-based and pool-based creation: + - Template mode (default): Creates workload with user-specified image, resources, and env + - Pool mode (when extensions contains 'poolRef'): Creates workload from pre-warmed pool, + only entrypoint and env can be customized + + Args: + sandbox_id: Unique sandbox identifier + namespace: Kubernetes namespace + image_spec: Container image specification (not used in pool mode) + entrypoint: Container entrypoint command + env: Environment variables + resource_limits: Resource limits (not used in pool mode) + labels: Labels to apply + expires_at: Expiration time + execd_image: execd daemon image (not used in pool mode) + extensions: General extension field for additional configuration. + When contains 'poolRef', enables pool-based creation. + + Returns: + Dict with 'name' and 'uid' of created BatchSandbox + """ batchsandbox_name = f"sandbox-{sandbox_id}" + extensions = extensions or {} + + # If poolRef is provided and not empty, create workload from pool + if extensions.get("poolRef"): + # When using pool, only entrypoint and env can be customized + return self._create_workload_from_pool( + batchsandbox_name=batchsandbox_name, + namespace=namespace, + labels=labels, + pool_ref=extensions["poolRef"], + expires_at=expires_at, + entrypoint=entrypoint, + env=env, + ) # Build init container for execd installation init_container = self._build_execd_init_container(execd_image) @@ -138,27 +177,136 @@ def create_workload( "uid": created["metadata"]["uid"], } + def _create_workload_from_pool( + self, + batchsandbox_name: str, + namespace: str, + labels: Dict[str, str], + pool_ref: str, + expires_at: datetime, + entrypoint: List[str], + env: Dict[str, str], + ) -> Dict[str, Any]: + """ + Create BatchSandbox workload from a pre-warmed resource pool. + + Pool-based creation uses poolRef to reference an existing pool. + The pool already defines the pod template, so no additional template is needed. + Only entrypoint and env can be customized. + + Args: + batchsandbox_name: Name of the BatchSandbox resource + namespace: Kubernetes namespace + labels: Labels to apply + pool_ref: Reference to the resource pool + expires_at: Expiration time + entrypoint: Container entrypoint command (can be customized) + env: Environment variables (can be customized) + + Returns: + Dict with 'name' and 'uid' of created BatchSandbox + + Raises: + SandboxError: If required parameters are invalid + """ + runtime_manifest = { + "apiVersion": f"{self.group}/{self.version}", + "kind": "BatchSandbox", + "metadata": { + "name": batchsandbox_name, + "namespace": namespace, + "labels": labels, + }, + "spec": { + "replicas": 1, + "poolRef": pool_ref, + "expireTime": expires_at.isoformat(), + "taskTemplate": self._build_task_template(entrypoint, env), + }, + } + + # Pool-based creation does not need template merging + # Create BatchSandbox directly + created = self.custom_api.create_namespaced_custom_object( + group=self.group, + version=self.version, + namespace=namespace, + plural=self.plural, + body=runtime_manifest, + ) + + return { + "name": created["metadata"]["name"], + "uid": created["metadata"]["uid"], + } + + # Todo support empty cmd or env + def _build_task_template( + self, + entrypoint: List[str], + env: Dict[str, str], + ) -> Dict[str, Any]: + """ + Build taskTemplate for pool-based BatchSandbox. + + In pool mode, task should use bootstrap.sh to start execd and business process. + + Generated command example: + /bin/sh -c "/opt/opensandbox/bin/bootstrap.sh python app.py &" + + Note: All entrypoint arguments are properly shell-escaped using shlex.quote + to prevent shell injection and preserve arguments with spaces or special characters. + + Args: + entrypoint: Container entrypoint command + env: Environment variables + + Returns: + Dict: taskTemplate specification with TaskSpec structure + """ + # Build command: execute bootstrap.sh with entrypoint in background + # Use shlex.quote to safely escape each entrypoint argument to prevent shell injection + escaped_entrypoint = ' '.join(shlex.quote(arg) for arg in entrypoint) + user_process_cmd = f"/opt/opensandbox/bin/bootstrap.sh {escaped_entrypoint} &" + + wrapped_command = ["/bin/sh", "-c", user_process_cmd] + + # Convert env dict to k8s EnvVar format + env_list = [{"name": k, "value": v} for k, v in env.items()] if env else [] + + # Return TaskTemplateSpec structure + return { + "spec": { + "process": { + "command": wrapped_command, + "env": env_list, + } + } + } + def _build_execd_init_container(self, execd_image: str) -> V1Container: """ Build init container for execd installation. + This init container copies execd binary and bootstrap.sh script from + execd image to shared volume, making them available to the main container. + + The bootstrap.sh script (from execd image) will: + - Start execd in background (redirects logs to /tmp/execd.log) + - Use exec to replace current process with user's command + Args: execd_image: execd container image Returns: V1Container: Init container spec """ - # Build the script with proper shell syntax + # Copy execd binary and bootstrap.sh from image to shared volume script = ( - "cp ./execd /opt/opensandbox/execd/execd && " - "chmod +x /opt/opensandbox/execd/execd && " - "cat > /opt/opensandbox/execd/bootstrap.sh << 'BOOTSTRAP_EOF'\n" - "#!/bin/sh\n" - "set -e\n" - "/opt/opensandbox/execd/execd >/tmp/execd.log 2>&1 &\n" - 'exec "$@"\n' - "BOOTSTRAP_EOF\n" - "chmod +x /opt/opensandbox/execd/bootstrap.sh" + "cp ./execd /opt/opensandbox/bin/execd && " + "cp ./bootstrap.sh /opt/opensandbox/bin/bootstrap.sh && " + "chmod +x /opt/opensandbox/bin/execd && " + "chmod +x /opt/opensandbox/bin/bootstrap.sh" ) return V1Container( @@ -169,7 +317,7 @@ def _build_execd_init_container(self, execd_image: str) -> V1Container: volume_mounts=[ V1VolumeMount( name="opensandbox-bin", - mount_path="/opt/opensandbox/execd" + mount_path="/opt/opensandbox/bin" ) ], ) @@ -196,8 +344,10 @@ def _build_main_container( Returns: V1Container: Main container spec """ - # Convert env dict to V1EnvVar list + # Convert env dict to V1EnvVar list and inject EXECD path env_vars = [V1EnvVar(name=k, value=v) for k, v in env.items()] + # Add EXECD environment variable to specify execd binary path + env_vars.append(V1EnvVar(name="EXECD", value="/opt/opensandbox/bin/execd")) # Build resource requirements resources = None @@ -208,7 +358,7 @@ def _build_main_container( ) # Wrap entrypoint with bootstrap script to start execd - wrapped_command = ["/opt/opensandbox/execd/bootstrap.sh"] + entrypoint + wrapped_command = ["/opt/opensandbox/bin/bootstrap.sh"] + entrypoint return V1Container( name="sandbox", @@ -219,7 +369,7 @@ def _build_main_container( volume_mounts=[ V1VolumeMount( name="opensandbox-bin", - mount_path="/opt/opensandbox/execd" + mount_path="/opt/opensandbox/bin" ) ], ) diff --git a/server/src/services/k8s/kubernetes_service.py b/server/src/services/k8s/kubernetes_service.py index 725e13ab..ca8fd443 100644 --- a/server/src/services/k8s/kubernetes_service.py +++ b/server/src/services/k8s/kubernetes_service.py @@ -226,13 +226,13 @@ def create_sandbox(self, request: CreateSandboxRequest) -> CreateSandboxResponse Wait for the Pod to be Running and have an IP address before returning. Args: - request: Sandbox creation request + request: Sandbox creation request. Returns: CreateSandboxResponse: Created sandbox information with Running state Raises: - HTTPException: If creation fails or timeout + HTTPException: If creation fails, timeout, or invalid parameters """ # Validate request ensure_entrypoint(request.entrypoint) @@ -271,6 +271,7 @@ def create_sandbox(self, request: CreateSandboxRequest) -> CreateSandboxResponse labels=labels, expires_at=expires_at, execd_image=self.execd_image, + extensions=request.extensions, ) logger.info( @@ -317,6 +318,16 @@ def create_sandbox(self, request: CreateSandboxRequest) -> CreateSandboxResponse except HTTPException: raise + except ValueError as e: + # Handle parameter validation errors from provider + logger.error(f"Invalid parameters for sandbox creation: {e}") + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail={ + "code": SandboxErrorCodes.INVALID_PARAMETER, + "message": str(e), + }, + ) from e except Exception as e: logger.error(f"Error creating sandbox: {e}") raise HTTPException( diff --git a/server/src/services/k8s/workload_provider.py b/server/src/services/k8s/workload_provider.py index 44debb99..146135bf 100644 --- a/server/src/services/k8s/workload_provider.py +++ b/server/src/services/k8s/workload_provider.py @@ -43,6 +43,7 @@ def create_workload( labels: Dict[str, str], expires_at: datetime, execd_image: str, + extensions: Optional[Dict[str, str]] = None, ) -> Dict[str, Any]: """ Create a new workload resource. @@ -57,6 +58,8 @@ def create_workload( labels: Labels to apply to the workload expires_at: Expiration time execd_image: execd daemon image + extensions: General extension field for passing additional configuration. + This is a flexible field for various use cases (e.g., ``poolRef`` for pool-based creation). Returns: Dict containing workload metadata (name, uid, etc.) diff --git a/server/tests/k8s/test_batchsandbox_provider.py b/server/tests/k8s/test_batchsandbox_provider.py index dbb020ae..90cc1e2a 100644 --- a/server/tests/k8s/test_batchsandbox_provider.py +++ b/server/tests/k8s/test_batchsandbox_provider.py @@ -163,14 +163,15 @@ def test_create_workload_wraps_entrypoint_with_bootstrap(self, mock_k8s_client): main_container = body["spec"]["template"]["spec"]["containers"][0] assert main_container["command"] == [ - "/opt/opensandbox/execd/bootstrap.sh", + "/opt/opensandbox/bin/bootstrap.sh", "/usr/bin/python", "app.py" ] def test_create_workload_converts_env_to_list(self, mock_k8s_client): """ - Test case: Verify environment variable dict converted to list + Test case: Verify environment variable dict converted to list. + Also verifies EXECD environment variable is automatically injected. """ provider = BatchSandboxProvider(mock_k8s_client) mock_api = mock_k8s_client.get_custom_objects_api() @@ -193,9 +194,13 @@ def test_create_workload_converts_env_to_list(self, mock_k8s_client): body = mock_api.create_namespaced_custom_object.call_args.kwargs["body"] env_vars = body["spec"]["template"]["spec"]["containers"][0]["env"] - assert len(env_vars) == 2 + # Should have user env vars plus EXECD + assert len(env_vars) == 3 env_dict = {e["name"]: e["value"] for e in env_vars} - assert env_dict == {"FOO": "bar", "BAZ": "qux"} + assert env_dict["FOO"] == "bar" + assert env_dict["BAZ"] == "qux" + # Verify EXECD is automatically injected + assert env_dict["EXECD"] == "/opt/opensandbox/bin/execd" def test_create_workload_sets_resource_limits_and_requests(self, mock_k8s_client): """ @@ -622,3 +627,282 @@ def test_get_endpoint_info_returns_none_on_empty_array(self): result = provider.get_endpoint_info(workload, 8080) assert result is None + + # ===== Pool-based Creation Tests ===== + + def test_create_workload_poolref_ignores_image_spec(self, mock_k8s_client): + """ + Test that pool-based creation ignores image_spec parameter. + + Pool already defines the image, so image_spec is not used even if provided. + This verifies backward compatibility - no error is raised. + """ + provider = BatchSandboxProvider(mock_k8s_client) + mock_api = mock_k8s_client.get_custom_objects_api() + mock_api.create_namespaced_custom_object.return_value = { + "metadata": {"name": "sandbox-test-id", "uid": "test-uid"} + } + + result = provider.create_workload( + sandbox_id="test-id", + namespace="test-ns", + image_spec=ImageSpec(uri="python:3.11"), + entrypoint=["python", "app.py"], + env={}, + resource_limits={}, + labels={}, + expires_at=datetime(2025, 12, 31, tzinfo=timezone.utc), + execd_image="execd:latest", + extensions={"poolRef": "my-pool"} + ) + + # Should succeed and return workload info + assert result == {"name": "sandbox-test-id", "uid": "test-uid"} + + # Verify poolRef is used + body = mock_api.create_namespaced_custom_object.call_args.kwargs["body"] + assert body["spec"]["poolRef"] == "my-pool" + + def test_create_workload_poolref_ignores_resource_limits(self, mock_k8s_client): + """ + Test that pool-based creation ignores resource_limits parameter. + + Pool already defines the resources, so resource_limits is not used even if provided. + This verifies backward compatibility - no error is raised. + """ + provider = BatchSandboxProvider(mock_k8s_client) + mock_api = mock_k8s_client.get_custom_objects_api() + mock_api.create_namespaced_custom_object.return_value = { + "metadata": {"name": "sandbox-test-id", "uid": "test-uid"} + } + + result = provider.create_workload( + sandbox_id="test-id", + namespace="test-ns", + image_spec=ImageSpec(uri=""), + entrypoint=["python", "app.py"], + env={}, + resource_limits={"cpu": "1", "memory": "1Gi"}, + labels={}, + expires_at=datetime(2025, 12, 31, tzinfo=timezone.utc), + execd_image="execd:latest", + extensions={"poolRef": "my-pool"} + ) + + # Should succeed and return workload info + assert result == {"name": "sandbox-test-id", "uid": "test-uid"} + + # Verify poolRef is used + body = mock_api.create_namespaced_custom_object.call_args.kwargs["body"] + assert body["spec"]["poolRef"] == "my-pool" + + def test_create_workload_poolref_allows_entrypoint_and_env(self, mock_k8s_client): + """ + Test that pool-based creation allows customizing entrypoint and env. + + Verifies taskTemplate structure is correctly generated with user's entrypoint and env. + """ + provider = BatchSandboxProvider(mock_k8s_client) + mock_api = mock_k8s_client.get_custom_objects_api() + mock_api.create_namespaced_custom_object.return_value = { + "metadata": {"name": "sandbox-test-id", "uid": "test-uid"} + } + + result = provider.create_workload( + sandbox_id="test-id", + namespace="test-ns", + image_spec=ImageSpec(uri=""), + entrypoint=["python", "app.py"], + env={"FOO": "bar"}, + resource_limits={}, + labels={}, + expires_at=datetime(2025, 12, 31, tzinfo=timezone.utc), + execd_image="execd:latest", + extensions={"poolRef": "my-pool"} + ) + + assert result == {"name": "sandbox-test-id", "uid": "test-uid"} + + # Verify the call + body = mock_api.create_namespaced_custom_object.call_args.kwargs["body"] + assert body["spec"]["poolRef"] == "my-pool" + assert "taskTemplate" in body["spec"] + + # Verify taskTemplate structure + task_template = body["spec"]["taskTemplate"] + assert "spec" in task_template + assert "process" in task_template["spec"] + command = task_template["spec"]["process"]["command"] + assert command[0] == "/bin/sh" + assert command[1] == "-c" + # Command should contain bootstrap.sh execution + # Example: /opt/opensandbox/bin/bootstrap.sh python app.py & + assert "/opt/opensandbox/bin/bootstrap.sh python app.py" in command[2] + assert command[2].endswith(" &") + assert task_template["spec"]["process"]["env"] == [{"name": "FOO", "value": "bar"}] + + def test_build_task_template_with_env(self, mock_k8s_client): + """ + Test _build_task_template with environment variables. + + Verifies: + - Command uses shell wrapper: /bin/sh -c "..." + - Entrypoint executed via bootstrap.sh in background (&) + - Env list formatted correctly for K8s + + Generated command example: + /bin/sh -c "/opt/opensandbox/bin/bootstrap.sh /usr/bin/python app.py &" + """ + provider = BatchSandboxProvider(mock_k8s_client) + + result = provider._build_task_template( + entrypoint=["/usr/bin/python", "app.py"], + env={"KEY1": "value1", "KEY2": "value2"} + ) + + assert "spec" in result + assert "process" in result["spec"] + process_task = result["spec"]["process"] + + # Verify command structure + command = process_task["command"] + assert command[0] == "/bin/sh" + assert command[1] == "-c" + # Should execute via bootstrap.sh in background (&) + assert "/opt/opensandbox/bin/bootstrap.sh" in command[2] + assert "/usr/bin/python" in command[2] + assert "app.py" in command[2] + # Should end with & (run in background) + assert command[2].endswith("&") + + # Verify env list + assert process_task["env"] == [ + {"name": "KEY1", "value": "value1"}, + {"name": "KEY2", "value": "value2"} + ] + + def test_build_task_template_without_env(self, mock_k8s_client): + """ + Test _build_task_template without environment variables. + + Verifies command is wrapped in shell and executes via bootstrap.sh in background. + + Generated command example: + /bin/sh -c "/opt/opensandbox/bin/bootstrap.sh /usr/bin/python app.py &" + """ + provider = BatchSandboxProvider(mock_k8s_client) + + result = provider._build_task_template( + entrypoint=["/usr/bin/python", "app.py"], + env={} + ) + + assert "spec" in result + assert "process" in result["spec"] + process_task = result["spec"]["process"] + assert process_task["env"] == [] + # Without env, command directly calls bootstrap.sh in background + command = process_task["command"] + assert command[0] == "/bin/sh" + assert command[1] == "-c" + # Check escaped entrypoint + assert "/opt/opensandbox/bin/bootstrap.sh" in command[2] + assert "/usr/bin/python" in command[2] + assert "app.py" in command[2] + assert command[2].endswith(" &") + + def test_build_task_template_uses_default_env_path(self, mock_k8s_client): + """ + Test that taskTemplate executes bootstrap.sh properly. + + Verifies: + - Entrypoint is properly escaped + - Command runs in background + """ + provider = BatchSandboxProvider(mock_k8s_client) + + result = provider._build_task_template( + entrypoint=["python", "app.py"], + env={"TEST_VAR": "test_value"} + ) + + command = result["spec"]["process"]["command"][2] + # Should execute bootstrap.sh in background + assert "/opt/opensandbox/bin/bootstrap.sh" in command + assert "python" in command + assert "app.py" in command + assert command.endswith(" &") + + def test_build_task_template_escapes_special_characters(self, mock_k8s_client): + """ + Test that taskTemplate properly escapes arguments with spaces, quotes, and special chars. + + This prevents shell injection and ensures arguments are preserved correctly. + For example: ['python', '-c', 'print("a b")'] should work correctly. + """ + provider = BatchSandboxProvider(mock_k8s_client) + + result = provider._build_task_template( + entrypoint=["python", "-c", 'print("hello world")'], + env={"KEY": "value with spaces", "QUOTE": "it's fine"} + ) + + command = result["spec"]["process"]["command"][2] + + # Verify entrypoint args are properly escaped + assert "python" in command + assert "-c" in command + # The python code with spaces and quotes should be properly escaped + assert "'print(" in command or '"print(' in command # Escaped + + # Verify env is passed through env list, not in command + env_list = result["spec"]["process"]["env"] + assert {"name": "KEY", "value": "value with spaces"} in env_list + assert {"name": "QUOTE", "value": "it's fine"} in env_list + + def test_create_workload_poolref_builds_correct_manifest(self, mock_k8s_client): + """ + Test complete pool-based BatchSandbox manifest structure. + + Verifies: + - Basic metadata (apiVersion, kind, name, labels) + - Pool-specific fields (poolRef, taskTemplate, expireTime) + - No template field (pool mode doesn't use pod template) + """ + provider = BatchSandboxProvider(mock_k8s_client) + mock_api = mock_k8s_client.get_custom_objects_api() + mock_api.create_namespaced_custom_object.return_value = { + "metadata": {"name": "sandbox-test-id", "uid": "test-uid"} + } + + expires_at = datetime(2025, 12, 31, 10, 0, 0, tzinfo=timezone.utc) + + provider.create_workload( + sandbox_id="test-id", + namespace="test-ns", + image_spec=ImageSpec(uri=""), + entrypoint=["python", "app.py"], + env={"FOO": "bar"}, + resource_limits={}, + labels={"test": "label"}, + expires_at=expires_at, + execd_image="execd:latest", + extensions={"poolRef": "test-pool"} + ) + + body = mock_api.create_namespaced_custom_object.call_args.kwargs["body"] + + # Verify basic structure + assert body["apiVersion"] == "sandbox.opensandbox.io/v1alpha1" + assert body["kind"] == "BatchSandbox" + assert body["metadata"]["name"] == "sandbox-test-id" + assert body["metadata"]["labels"] == {"test": "label"} + + # Verify pool-specific fields + assert body["spec"]["replicas"] == 1 + assert body["spec"]["poolRef"] == "test-pool" + assert body["spec"]["expireTime"] == "2025-12-31T10:00:00+00:00" + assert "taskTemplate" in body["spec"] + + # Verify no template field (pool-based doesn't use template) + assert "template" not in body["spec"]