Skip to content

Commit ab5ce87

Browse files
committed
Merge branch 'copilot/create-micro-vms-for-python-execution'
2 parents 1fb1dce + 9001cc1 commit ab5ce87

17 files changed

Lines changed: 1886 additions & 21 deletions

README.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,8 +88,20 @@ Common mappings (`cv2` -> `opencv-python`, `PIL` -> `Pillow`, etc.) are built in
8888
- **Retry and timeout** -- `@trace(timeout=30, retries=3)` with exponential backoff.
8989
- **Batch submission** -- `await func.map([(a1, b1), (a2, b2)])` submits and awaits multiple tasks.
9090
- **Pluggable backends** -- Redis (`redis://`) for multi-machine, local (`local://`) for same-machine IPC.
91+
- **Sandboxed execution** -- Run tasks inside Docker containers for isolation. Transparent to clients.
9192
- **Content-hash caching** -- Workers cache compiled functions by content hash. Same code from different clients = cache hit.
9293

94+
## Sandboxed execution
95+
96+
By default, workers execute code in the host process. For isolation, enable sandboxing with `--sandbox`:
97+
98+
```bash
99+
pyfuse sandbox setup
100+
pyfuse worker --backend redis://localhost:6379 --sandbox
101+
```
102+
103+
No changes are needed on the client side — sandboxing is transparent. See the [Sandbox guide](docs/SANDBOX.md) for more information.
104+
93105
## Examples
94106

95107
```bash
@@ -108,6 +120,7 @@ pyfuse run examples/remote_execution.py
108120
## Documentation
109121

110122
- **[Quick Start](docs/QUICK_START.md)** -- Usage guide with detailed examples
123+
- **[Sandbox](docs/SANDBOX.md)** -- Running workers in Docker containers
111124
- **[Technical Overview](docs/TECHNICAL_OVERVIEW.md)** -- Architecture, serialization format, and internals
112125

113126
## License

docs/CONTEXT.md

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -31,10 +31,16 @@ pyfuse/
3131
├── remote.py # connect(), disconnect(), serve(), submit_remote() (async)
3232
├── result.py # Result (awaitable future), ResultEnvelope
3333
├── deps.py # Third-party dependency extraction and pip installation (async)
34-
└── backends/
35-
├── base.py # Backend ABC: async transport interface
36-
├── redis.py # RedisBackend: redis.asyncio with RPUSH/BLPOP pattern
37-
└── local.py # LocalBackend: async-native TCP for same-machine IPC
34+
├── backends/
35+
│ ├── base.py # Backend ABC: async transport interface
36+
│ ├── redis.py # RedisBackend: redis.asyncio with RPUSH/BLPOP pattern
37+
│ └── local.py # LocalBackend: async-native TCP for same-machine IPC
38+
└── sandbox/
39+
├── __init__.py # re-exports DockerSandbox
40+
├── docker.py # DockerSandbox: Docker container isolation
41+
├── guest_agent.py # Stdlib-only agent deployed inside container
42+
├── _protocol.py # Length-prefixed JSON wire protocol
43+
└── Dockerfile # Docker image for the guest agent
3844
```
3945

4046
## Architecture overview
@@ -60,6 +66,7 @@ Handles remote execution. Built entirely on `asyncio`:
6066
- **`result.py`**: `Result` is an awaitable future returned by `.start()`. Simple async polling loop for stall detection. Supports `cancel()` and `progress()` methods.
6167
- **`deps.py`**: Package installation via `asyncio.create_subprocess_exec`.
6268
- **`backends/`**: All backend methods are `async def`. `listen()` and `subscribe_results()` are async generators.
69+
- **`sandbox/`**: Optional Docker-based execution isolation. `DockerSandbox` boots a container, connects to a stdlib-only `guest_agent.py` over TCP (length-prefixed JSON), and delegates code execution. When `--sandbox` is off, the worker runs code directly in the host process.
6370

6471
## Data flow
6572

@@ -203,3 +210,6 @@ pytest # test suite
203210
- `_capture_closure()` in `graph.py` uses a multi-tier strategy: repr validation → traced functions → lambdas (source extraction) → non-traced user functions (auto-registration) → constructor expressions (defaultdict/Counter/deque) → pickle fallback → warning. Returns function objects for auto-registration.
204211
- `_set_class_metadata()` in `graph.py` captures class-level attributes and decorators from the class source AST. Called from both `_auto_register_class` and `_discover_self_call_deps` to handle both constructor-discovered and directly-traced method classes.
205212
- `_resolve_class_bases()` now also extracts class definition keywords (e.g., `metaclass=ABCMeta`) and adds necessary imports for keyword values.
213+
- `guest_agent.py` is intentionally stdlib-only so it can be deployed into containers without installing pyfuse. It duplicates `_protocol.py` wire helpers for this reason.
214+
- `DockerSandbox` is an async context manager (`__aenter__`/`__aexit__`). The `Worker` calls `start()`/`stop()` at lifecycle boundaries.
215+
- `DockerSandbox` auto-builds the Docker image from the bundled `Dockerfile` on first use.

docs/QUICK_START.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -257,6 +257,12 @@ pyfuse worker --backend redis://localhost:6379 --no-auto-install
257257

258258
# Run in an isolated temporary venv (auto-cleaned on exit)
259259
pyfuse worker --backend redis://localhost:6379 --tmp
260+
261+
# Run tasks inside a Docker sandbox
262+
pyfuse worker --backend redis://localhost:6379 --sandbox docker
263+
264+
# Run tasks inside a tart micro-VM (macOS Apple Silicon)
265+
pyfuse worker --backend redis://localhost:6379 --sandbox vm
260266
```
261267

262268
Or start a worker programmatically:
@@ -268,6 +274,24 @@ import pyfuse
268274
asyncio.run(pyfuse.serve("redis://localhost:6379", concurrency=4))
269275
```
270276

277+
## Sandboxed execution
278+
279+
By default, workers run tasks in the host process. For security or isolation, you can run tasks inside Docker containers or tart micro-VMs. Sandboxing is fully transparent to clients.
280+
281+
### Quick setup
282+
283+
```bash
284+
# Docker (any platform)
285+
pyfuse sandbox setup --docker
286+
pyfuse worker --backend redis://localhost:6379 --sandbox docker
287+
288+
# tart VM (macOS Apple Silicon only)
289+
pyfuse sandbox setup
290+
pyfuse worker --backend redis://localhost:6379 --sandbox vm
291+
```
292+
293+
See the [Sandbox guide](SANDBOX.md) for full setup and management instructions.
294+
271295
## Running scripts in a temporary venv
272296

273297
The `run` command creates an isolated venv, auto-detects third-party dependencies from the script (including `install_package_as` blocks), installs them, and runs the script:

docs/SANDBOX.md

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
# Sandbox
2+
3+
Run worker tasks inside isolated Docker containers. Sandboxing is transparent to clients -- no code changes required on the sender side.
4+
5+
## Overview
6+
7+
By default, workers execute tasks in the host process. With `--sandbox`, execution is delegated to a guest agent running inside a Docker container. The worker still handles caching, dependency resolution, and retries on the host; only the `compile()``exec()` → call step moves into the sandbox.
8+
9+
## Requirements
10+
11+
- Docker installed and running (`docker info` should succeed)
12+
- The current user can run `docker` commands (docker group or rootless Docker)
13+
14+
## Setup
15+
16+
```bash
17+
pyfuse sandbox setup
18+
```
19+
20+
This builds the `pyfuse-sandbox` Docker image from the bundled Dockerfile. The image is based on `python:3.12-slim` and contains only the stdlib-only guest agent -- no pyfuse installation needed inside the container.
21+
22+
You can also build manually:
23+
24+
```bash
25+
bash scripts/setup_sandbox_docker.sh
26+
```
27+
28+
Or let the worker build automatically on first use (the image is built lazily if it doesn't exist).
29+
30+
## Start a sandboxed worker
31+
32+
```bash
33+
pyfuse worker --backend redis://localhost:6379 --sandbox
34+
```
35+
36+
The worker starts a container on first task, keeps it running for the worker's lifetime, and stops it on shutdown.
37+
38+
## Customization
39+
40+
Override the image and container names via environment variables:
41+
42+
```bash
43+
export PYFUSE_SANDBOX_DOCKER_IMAGE=my-custom-image
44+
export PYFUSE_SANDBOX_DOCKER_CONTAINER=my-sandbox
45+
pyfuse worker --backend redis://localhost:6379 --sandbox
46+
```
47+
48+
## Management commands
49+
50+
```bash
51+
# Check sandbox status
52+
pyfuse sandbox status
53+
54+
# Remove the Docker container and image
55+
pyfuse sandbox teardown
56+
```
57+
58+
Example `pyfuse sandbox status` output:
59+
60+
```
61+
Docker:
62+
docker: installed
63+
Image 'pyfuse-sandbox': exists
64+
Container 'pyfuse-sandbox': not found
65+
```
66+
67+
## Programmatic usage
68+
69+
```python
70+
from pyfuse.worker.sandbox import DockerSandbox
71+
72+
async with DockerSandbox() as sandbox:
73+
result = await sandbox.execute(source, "my_func", (arg1,), {})
74+
```
75+
76+
Pass `sandbox=True` to `serve()`:
77+
78+
```python
79+
await pyfuse.serve("redis://localhost:6379", sandbox=True)
80+
```
81+
82+
Or provide a `DockerSandbox` instance for custom settings:
83+
84+
```python
85+
from pyfuse.worker.sandbox import DockerSandbox
86+
87+
sandbox = DockerSandbox(cpus=4, memory_gb=4)
88+
await pyfuse.serve("redis://localhost:6379", sandbox=sandbox)
89+
```
90+
91+
## Configuration reference
92+
93+
`DockerSandbox` accepts the following keyword arguments:
94+
95+
| Parameter | Default | Description |
96+
|-----------|---------|-------------|
97+
| `image` | `"pyfuse-sandbox"` | Docker image name |
98+
| `container_name` | `"pyfuse-sandbox"` | Container name |
99+
| `guest_port` | `9749` | TCP port the guest agent listens on |
100+
| `cpus` | `2` | vCPUs allocated to the container |
101+
| `memory_gb` | `2` | RAM (GB) allocated to the container |
102+
| `timeout` | `60.0` | Max seconds per function execution |
103+
| `boot_timeout` | `30.0` | Max seconds to wait for container to start |
104+
105+
## How it works
106+
107+
The sandbox uses a guest agent (`guest_agent.py`) — a lightweight, stdlib-only Python script deployed inside the container. The worker communicates with it over TCP using a length-prefixed JSON protocol:
108+
109+
1. Worker sends the reconstructed source code, function name, and arguments
110+
2. Guest agent `exec`s the source, calls the function, and returns the result
111+
3. Errors are serialized with type, message, and traceback
112+
113+
The sandbox stays running between tasks. The first execution incurs a startup cost (container boot + agent connection), but subsequent calls reuse the same connection.
114+
115+
## Troubleshooting
116+
117+
**Image build fails**
118+
- Ensure Docker daemon is running: `docker info`
119+
- Check permissions: your user should be in the `docker` group or use rootless Docker
120+
121+
**Sandbox timeout**
122+
- Increase `timeout` in `DockerSandbox` for long-running functions
123+
- Default is 60 seconds per execution

docs/TECHNICAL_OVERVIEW.md

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,11 @@ pyfuse/
4444
base.py Backend ABC: async pluggable transport interface
4545
redis.py RedisBackend: redis.asyncio with RPUSH/BLPOP pattern
4646
local.py LocalBackend: async-native TCP for same-machine IPC
47+
sandbox/
48+
docker.py DockerSandbox: Docker container isolation
49+
guest_agent.py Stdlib-only agent deployed inside container
50+
_protocol.py Length-prefixed JSON wire protocol
51+
Dockerfile Container image for the guest agent
4752
```
4853

4954
## Remote execution flow
@@ -487,6 +492,74 @@ When a module contains `from X import *`:
487492
2. Create individual `ImportInfo` entries per exported name.
488493
3. Filter to only names the function actually uses.
489494

495+
## Sandbox execution
496+
497+
Workers can optionally execute tasks inside an isolated Docker container instead of the host process. This is controlled by the `--sandbox` CLI flag or by passing `sandbox=True` (or a `DockerSandbox` instance) programmatically.
498+
499+
### How it works
500+
501+
When sandboxing is enabled, only the `exec → call` step moves into the container. The worker's own control logic — caching, dependency resolution, retry — stays on the host.
502+
503+
```mermaid
504+
sequenceDiagram
505+
participant Client
506+
participant Worker as Worker (host)
507+
participant Container as Docker Container
508+
participant Agent as Guest Agent
509+
510+
Client->>Worker: Task (graph JSON + args)
511+
Worker->>Worker: Deserialize graph, install deps, reconstruct source
512+
513+
alt --sandbox enabled
514+
Worker->>Container: TCP connect (first use boots container)
515+
Worker->>Agent: {source, function_name, args, kwargs}
516+
Agent->>Agent: compile() → exec() → call function
517+
Agent->>Worker: {status: "ok", result: value}
518+
else default (no sandbox)
519+
Worker->>Worker: compile() → exec() → call function
520+
end
521+
522+
Worker->>Client: Result
523+
```
524+
525+
A lightweight **guest agent** (`guest_agent.py`) runs inside the container. It is stdlib-only (no pyfuse install required) and communicates with the worker over TCP using a **length-prefixed JSON protocol** (4-byte big-endian header + UTF-8 JSON payload).
526+
527+
### Container lifecycle
528+
529+
```mermaid
530+
stateDiagram-v2
531+
[*] --> ImageCheck: worker starts with --sandbox
532+
ImageCheck --> BuildImage: image missing
533+
ImageCheck --> StartContainer: image exists
534+
BuildImage --> StartContainer: docker build
535+
StartContainer --> WaitForAgent: docker run -d
536+
WaitForAgent --> Connected: TCP handshake
537+
Connected --> Execute: task arrives
538+
Execute --> Connected: result returned
539+
Connected --> Stopped: worker shutdown
540+
Stopped --> [*]
541+
```
542+
543+
1. **Start**`DockerSandbox.start()` builds the image (if absent), starts the container, waits for the guest agent to become reachable, then opens a persistent TCP connection.
544+
2. **Execute** — Each task is sent as a JSON request. The guest agent `exec`s the source, calls the function, and returns the result. The connection is reused across tasks.
545+
3. **Stop** — On worker shutdown, the connection is closed and the container is stopped.
546+
547+
### Configuration
548+
549+
`DockerSandbox` accepts the following keyword arguments:
550+
551+
| Parameter | Default | Description |
552+
|-----------|---------|-------------|
553+
| `image` | `"pyfuse-sandbox"` | Docker image name |
554+
| `container_name` | `"pyfuse-sandbox"` | Container name |
555+
| `guest_port` | `9749` | TCP port for the guest agent |
556+
| `cpus` | `2` | vCPUs allocated to the container |
557+
| `memory_gb` | `2` | RAM (GB) allocated to the container |
558+
| `timeout` | `60.0` | Max seconds per function execution |
559+
| `boot_timeout` | `30.0` | Max seconds to wait for container to start |
560+
561+
Environment variables `PYFUSE_SANDBOX_DOCKER_IMAGE` and `PYFUSE_SANDBOX_DOCKER_CONTAINER` override the image and container names.
562+
490563
## Limitations
491564

492565
### Source requirements
@@ -517,6 +590,12 @@ pyfuse worker --backend redis://localhost:6379
517590
pyfuse worker --backend redis://localhost:6379 -c 4
518591
pyfuse worker --backend redis://localhost:6379 --no-auto-install
519592
pyfuse worker --backend redis://localhost:6379 --tmp # isolated temp venv
593+
pyfuse worker --backend redis://localhost:6379 --sandbox # Docker sandbox
594+
595+
# Sandbox management
596+
pyfuse sandbox setup # build Docker sandbox image
597+
pyfuse sandbox status # show Docker sandbox status
598+
pyfuse sandbox teardown # remove Docker sandbox
520599

521600
# Run a script in a temporary venv (auto-detects and installs dependencies)
522601
pyfuse run examples/script.py

examples/large_module.py

Lines changed: 5 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -47,23 +47,18 @@ def full_sensor_report(sensor_count: int, readings_per_sensor: int, seed: int =
4747
stats, anomalies, normalized,
4848
)
4949
report["delta_count"] = len(deltas)
50-
return format_text_report(report)
51-
50+
r = format_text_report(report)
51+
print(r)
52+
return r
5253

5354
async def main() -> None:
5455
# Connect to the worker
55-
pyfuse.connect("local://localhost:9748")
56-
57-
# Local call
58-
local_result = full_sensor_report(3, 50, seed=42)
56+
pyfuse.connect("redis://localhost:6379")
5957

6058
# Remote call (same function, same args, on a worker)
6159
remote_result = await full_sensor_report.run(3, 50, seed=42)
6260

63-
if local_result == remote_result:
64-
print(f"Success! Local and remote results match:\n {repr(local_result)[:80] + '...'}")
65-
else:
66-
print("Mismatch between local and remote results!")
61+
print(f"Success! Local and remote results match:\n {repr(remote_result)[:80] + '...'}")
6762

6863

6964
if __name__ == "__main__":

pyfuse/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
from pyfuse.worker.deps import install_package_as
1616
from pyfuse.worker.remote import connect, disconnect, serve
1717
from pyfuse.worker.result import Result, ResultEnvelope
18+
from pyfuse.worker.sandbox import DockerSandbox
1819
from pyfuse.worker.worker import Worker
1920
from pyfuse.worker.worker import execute as execute
2021

@@ -97,4 +98,6 @@ def pack(func: Callable[..., object], *args: Any, **kwargs: Any) -> Task:
9798
"Task",
9899
"Worker",
99100
"Backend",
101+
# Sandbox
102+
"DockerSandbox",
100103
]

0 commit comments

Comments
 (0)