Skip to content
Draft
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
28 changes: 28 additions & 0 deletions examples/jupyter-sandbox/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0

FROM ghcr.io/astral-sh/uv:0.11.28 AS uv
FROM python:3.13-slim

COPY --from=uv /uv /uvx /bin/

# OpenShell requires iproute2 for network isolation. nftables enables bypass
# detection, while procps is useful for inspecting the notebook processes.
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates iproute2 nftables procps \
&& rm -rf /var/lib/apt/lists/*

RUN uv pip install --system --no-cache \
jupyter-server==2.20.0 \
ipykernel==7.3.0

RUN groupadd --gid 1000 sandbox \
&& useradd --uid 1000 --gid sandbox --home-dir /sandbox \
--no-create-home --shell /bin/sh sandbox \
&& install -d -o sandbox -g sandbox /sandbox
WORKDIR /sandbox

EXPOSE 8888

# OpenShell replaces the image command with the sandbox supervisor. The
# example starts Jupyter after sandbox creation through the Python SDK.
129 changes: 129 additions & 0 deletions examples/jupyter-sandbox/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
# Jupyter Sandbox Fleet

Launch several OpenShell sandboxes, expose each Jupyter API server as a named
OpenShell service, and submit Python code to one member through Jupyter's REST
and kernel WebSocket APIs.

The example keeps the fleet abstraction separate from the sandbox type:
`Fleet[T]` can compose any context-managed resource, while `JupyterSandbox`
owns one sandbox's OpenShell, Jupyter, service, and cleanup lifecycle. It uses
the OpenShell Python SDK for gateway health, sandbox creation, readiness,
command execution, and deletion.

## Prerequisites

- A running local OpenShell gateway (`mise run gateway:docker` for development)
- The `openshell` CLI configured to use that gateway for the temporary service
expose/delete fallback
- Docker to build the example image
- Python 3.11 or later with `openshell`, `pyyaml`, and `websocket-client`
- [uv](https://docs.astral.sh/uv/) to install the Python dependencies

The example intentionally supports a local gateway only. Remote service access
requires handling the gateway's authentication boundary and is outside this
example's scope.

## Launch three sandboxes

Install the Python dependencies, build the Jupyter-ready image once, and run
the script:

```shell
cd examples/jupyter-sandbox
uv venv
source .venv/bin/activate
uv pip install openshell pyyaml websocket-client
docker build -t openshell-jupyter-sandbox:local .
python demo.py
```

The demo performs these operations:

1. Verifies the selected gateway through `SandboxClient.health()`.
2. Creates three sandboxes from the configured image and policy through the
Python SDK.
3. Starts Jupyter Server on `127.0.0.1:8888` in each sandbox.
4. Exposes every server as an OpenShell service named `jupyter`.
5. Creates a kernel in the first sandbox and executes this code over the
Jupyter API:

```python
print(sum(i * i for i in range(10)))
```

6. Deletes every service and sandbox when the context exits, including after
an exception. Sandbox deletion uses the Python SDK.

The expected result is `285`.

## Configure the fleet

Edit the configuration constants at the top of `demo.py` to select the sandbox
image, fleet size, policy, names, gateway, and work:

```python
IMAGE = "registry.example.com/jupyter-sandbox:latest"
SANDBOX_COUNT = 5
POLICY = EXAMPLE_DIR / "my-policy.yaml"
NAME_PREFIX = "analysis"
CODE = 'print("hello from Jupyter")'
GATEWAY = None
```

`IMAGE` accepts an OCI image reference. The Python SDK does not currently
build a Dockerfile the way `openshell sandbox create --from` does, so build or
publish the image first. A custom image must provide `jupyter server` and a
`python3` kernel. It must also contain a non-root `sandbox` user and group
because the included policy selects that process identity. The included
Dockerfile uses `python:3.13-slim`, creates that identity, and installs pinned
Jupyter dependencies.

`policy.yaml` allows the system paths Jupyter needs and writable access to
`/sandbox` and `/tmp`. Its empty `network_policies` map denies outbound network
access. Exposing the loopback Jupyter server through OpenShell is inbound and
does not require an egress rule. Replace the policy when notebook work needs
explicit outbound destinations.

## Reuse the abstraction

The core composition is deliberately small:

```python
with Fleet(
count=3,
factory=lambda index: JupyterSandbox(
client=client,
name=f"jupyter-{run_id}-{index + 1}",
image=image,
policy=policy,
),
) as fleet:
print(fleet[0].execute("print(sum(i * i for i in range(10)))"))
```

Each sandbox gets a unique Jupyter token. The token travels to the sandbox over
standard input, is stored in a mode-restricted file, and is attached internally
to Jupyter API requests. The example never prints token-bearing URLs. OpenShell
service URLs shown by the demo are safe to display but still require the
process-local token for Jupyter API access.

## Proposed Python SDK APIs

This example still has deliberate seams because the SDK does not expose
all of the CLI's functionality:

- Add `SandboxClient.expose_service()`, `get_service()`, `list_services()`, and
`delete_service()`, returning a public `ServiceEndpoint` model. The example
currently uses the `openshell service` CLI only for expose and delete.
- Add a public sandbox configuration builder that accepts `image` and a
`SandboxPolicy`. The low-level client currently requires generated protobuf
types from the private `openshell._proto` package.
- Add `load_sandbox_policy()` with the same canonical YAML validation and
conversion as the CLI. This example only normalizes the
`filesystem_policy` field needed by its policy before parsing the protobuf.
- Add an image-source helper with CLI parity for OCI references, Dockerfiles,
and build directories. Until then, Python SDK callers must prepare the OCI
image before creating a sandbox.

With those APIs, `JupyterSandbox` could remove its CLI subprocess adapter and
all imports from `openshell._proto`.
57 changes: 57 additions & 0 deletions examples/jupyter-sandbox/demo.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0

"""Launch a fleet of Jupyter sandboxes and submit code to the first member."""

from __future__ import annotations

import secrets
from pathlib import Path

from fleet import Fleet
from jupyter_sandbox import JupyterSandbox

from openshell import SandboxClient

EXAMPLE_DIR = Path(__file__).resolve().parent

# Configure the fleet here.
IMAGE = "openshell-jupyter-sandbox:local"
SANDBOX_COUNT = 3
POLICY = EXAMPLE_DIR / "policy.yaml"
NAME_PREFIX = "jupyter"
CODE = "print(sum(i * i for i in range(10)))"
GATEWAY: str | None = None # None selects the active OpenShell gateway.
OPENSHELL_BIN = "openshell" # Used only for service APIs missing from the SDK.


def main() -> None:
with SandboxClient.from_active_cluster(cluster=GATEWAY) as client:
health = client.health()
print(f"Connected to OpenShell {health.version}")

run_id = secrets.token_hex(3)
with Fleet(
count=SANDBOX_COUNT,
factory=lambda index: JupyterSandbox(
client=client,
name=f"{NAME_PREFIX}-{run_id}-{index + 1}",
image=IMAGE,
policy=POLICY,
openshell_bin=OPENSHELL_BIN,
cluster=GATEWAY,
),
) as fleet:
print(f"\nStarted {len(fleet)} Jupyter sandboxes:")
for sandbox in fleet:
print(f" {sandbox.name}: {sandbox.service_url}")

print(f"\nSubmitting code to {fleet[0].name} over the Jupyter API:")
print(CODE)
result = fleet[0].execute(CODE)
print("\nResult:")
print(result, end="" if result.endswith("\n") else "\n")


if __name__ == "__main__":
main()
74 changes: 74 additions & 0 deletions examples/jupyter-sandbox/fleet.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0

"""A small, generic abstraction for managing a fleet of context managers."""

from __future__ import annotations

import sys
from collections.abc import Callable, Iterator, Sequence
from contextlib import AbstractContextManager, ExitStack
from typing import Generic, TypeVar, overload

T = TypeVar("T")
MemberFactory = Callable[[int], AbstractContextManager[T]]


class Fleet(Sequence[T], AbstractContextManager["Fleet[T]"], Generic[T]):
"""Create and clean up a fixed number of context-managed members."""

def __init__(self, *, count: int, factory: MemberFactory[T]) -> None:
if count < 1:
raise ValueError("count must be at least 1")
self._count = count
self._factory = factory
self._members: list[T] = []
self._stack: ExitStack | None = None

def __enter__(self) -> Fleet[T]:
if self._stack is not None:
raise RuntimeError("fleet is already running")

stack = ExitStack()
stack.__enter__()
self._stack = stack
try:
for index in range(self._count):
self._members.append(stack.enter_context(self._factory(index)))
except BaseException:
self._members.clear()
self._stack = None
stack.__exit__(*sys.exc_info())
raise
return self

def __exit__(self, exc_type, exc_value, traceback) -> bool | None:
stack = self._stack
if stack is None:
raise RuntimeError("fleet is not running")

try:
return stack.__exit__(exc_type, exc_value, traceback)
finally:
self._members.clear()
self._stack = None

def _running_members(self) -> list[T]:
if self._stack is None:
raise RuntimeError("fleet members are only available inside the context")
return self._members

@overload
def __getitem__(self, index: int) -> T: ...

@overload
def __getitem__(self, index: slice) -> Sequence[T]: ...

def __getitem__(self, index: int | slice) -> T | Sequence[T]:
return self._running_members()[index]

def __len__(self) -> int:
return len(self._running_members())

def __iter__(self) -> Iterator[T]:
return iter(self._running_members())
Loading
Loading