inspect-sandboxes 0.1.1.dev1__tar.gz → 0.2.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {inspect_sandboxes-0.1.1.dev1 → inspect_sandboxes-0.2.0}/PKG-INFO +1 -1
- {inspect_sandboxes-0.1.1.dev1 → inspect_sandboxes-0.2.0}/pyproject.toml +5 -1
- inspect_sandboxes-0.2.0/src/inspect_sandboxes/_version.py +24 -0
- inspect_sandboxes-0.2.0/src/inspect_sandboxes/daytona/README.md +167 -0
- inspect_sandboxes-0.2.0/src/inspect_sandboxes/daytona/__init__.py +1 -0
- {inspect_sandboxes-0.1.1.dev1 → inspect_sandboxes-0.2.0}/src/inspect_sandboxes/daytona/_compose.py +99 -18
- inspect_sandboxes-0.2.0/src/inspect_sandboxes/daytona/_daytona.py +314 -0
- inspect_sandboxes-0.2.0/src/inspect_sandboxes/daytona/_dind_env.py +359 -0
- inspect_sandboxes-0.2.0/src/inspect_sandboxes/daytona/_dind_project.py +509 -0
- inspect_sandboxes-0.2.0/src/inspect_sandboxes/daytona/_retry.py +79 -0
- inspect_sandboxes-0.2.0/src/inspect_sandboxes/daytona/_sandbox_utils.py +108 -0
- inspect_sandboxes-0.2.0/src/inspect_sandboxes/daytona/_single_env.py +239 -0
- {inspect_sandboxes-0.1.1.dev1 → inspect_sandboxes-0.2.0}/src/inspect_sandboxes/modal/README.md +8 -0
- {inspect_sandboxes-0.1.1.dev1 → inspect_sandboxes-0.2.0}/src/inspect_sandboxes/modal/_compose.py +39 -4
- {inspect_sandboxes-0.1.1.dev1 → inspect_sandboxes-0.2.0}/src/inspect_sandboxes/modal/_modal.py +44 -9
- inspect_sandboxes-0.1.1.dev1/src/inspect_sandboxes/_version.py +0 -34
- inspect_sandboxes-0.1.1.dev1/src/inspect_sandboxes/daytona/README.md +0 -92
- inspect_sandboxes-0.1.1.dev1/src/inspect_sandboxes/daytona/__init__.py +0 -1
- inspect_sandboxes-0.1.1.dev1/src/inspect_sandboxes/daytona/_daytona.py +0 -490
- {inspect_sandboxes-0.1.1.dev1 → inspect_sandboxes-0.2.0}/.gitignore +0 -0
- {inspect_sandboxes-0.1.1.dev1 → inspect_sandboxes-0.2.0}/LICENSE +0 -0
- {inspect_sandboxes-0.1.1.dev1 → inspect_sandboxes-0.2.0}/README.md +0 -0
- {inspect_sandboxes-0.1.1.dev1 → inspect_sandboxes-0.2.0}/src/inspect_sandboxes/__init__.py +0 -0
- {inspect_sandboxes-0.1.1.dev1 → inspect_sandboxes-0.2.0}/src/inspect_sandboxes/_registry.py +0 -0
- {inspect_sandboxes-0.1.1.dev1 → inspect_sandboxes-0.2.0}/src/inspect_sandboxes/_util/__init__.py +0 -0
- {inspect_sandboxes-0.1.1.dev1 → inspect_sandboxes-0.2.0}/src/inspect_sandboxes/_util/compose.py +0 -0
- {inspect_sandboxes-0.1.1.dev1 → inspect_sandboxes-0.2.0}/src/inspect_sandboxes/modal/__init__.py +0 -0
- {inspect_sandboxes-0.1.1.dev1 → inspect_sandboxes-0.2.0}/src/inspect_sandboxes/py.typed +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: inspect_sandboxes
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: Collection of sandboxes for Inspect AI
|
|
5
5
|
Project-URL: Source Code, https://github.com/meridianlabs-ai/inspect_sandboxes
|
|
6
6
|
Project-URL: Issue Tracker, https://github.com/meridianlabs-ai/inspect_sandboxes/issues
|
|
@@ -102,6 +102,9 @@ ignore = [
|
|
|
102
102
|
"D415", # first line should end with a period, question mark, or exclamation point
|
|
103
103
|
]
|
|
104
104
|
|
|
105
|
+
[tool.ruff.lint.per-file-ignores]
|
|
106
|
+
"tests/**" = ["D103"] # missing docstring in public function
|
|
107
|
+
|
|
105
108
|
[tool.ruff.lint.pydocstyle]
|
|
106
109
|
convention = "google"
|
|
107
110
|
|
|
@@ -111,6 +114,7 @@ relative_files = true
|
|
|
111
114
|
[tool.pytest.ini_options]
|
|
112
115
|
pythonpath = ["src"]
|
|
113
116
|
testpaths = ["tests"]
|
|
117
|
+
addopts = "-m 'not integration'"
|
|
114
118
|
markers = [
|
|
115
|
-
"integration: marks tests that require real Modal resources (
|
|
119
|
+
"integration: marks tests that require real Modal/Daytona resources (run with '-m integration')",
|
|
116
120
|
]
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# file generated by vcs-versioning
|
|
2
|
+
# don't change, don't track in version control
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"__version__",
|
|
7
|
+
"__version_tuple__",
|
|
8
|
+
"version",
|
|
9
|
+
"version_tuple",
|
|
10
|
+
"__commit_id__",
|
|
11
|
+
"commit_id",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
version: str
|
|
15
|
+
__version__: str
|
|
16
|
+
__version_tuple__: tuple[int | str, ...]
|
|
17
|
+
version_tuple: tuple[int | str, ...]
|
|
18
|
+
commit_id: str | None
|
|
19
|
+
__commit_id__: str | None
|
|
20
|
+
|
|
21
|
+
__version__ = version = '0.2.0'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 2, 0)
|
|
23
|
+
|
|
24
|
+
__commit_id__ = commit_id = None
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
# Daytona Sandbox
|
|
2
|
+
|
|
3
|
+
Cloud development environment sandbox for [Inspect AI](https://inspect.ai-safety-institute.org.uk/) using [Daytona](https://www.daytona.io/).
|
|
4
|
+
|
|
5
|
+
## Setup
|
|
6
|
+
|
|
7
|
+
Create a Daytona account and set your API key:
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
export DAYTONA_API_KEY=your_api_key
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Usage
|
|
14
|
+
|
|
15
|
+
### Default snapshot
|
|
16
|
+
|
|
17
|
+
```python
|
|
18
|
+
from inspect_ai import Task, eval
|
|
19
|
+
from inspect_ai.solver import generate, system_message
|
|
20
|
+
|
|
21
|
+
task = Task(
|
|
22
|
+
dataset=[{"input": "What is 2+2?", "target": "4"}],
|
|
23
|
+
solver=[
|
|
24
|
+
system_message("You are a helpful assistant."),
|
|
25
|
+
generate(),
|
|
26
|
+
],
|
|
27
|
+
sandbox="daytona", # Uses Daytona's default snapshot
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
eval(task)
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
### Dockerfile
|
|
34
|
+
|
|
35
|
+
```python
|
|
36
|
+
task = Task(
|
|
37
|
+
dataset=[...],
|
|
38
|
+
solver=[...],
|
|
39
|
+
sandbox=("daytona", "path/to/Dockerfile"),
|
|
40
|
+
)
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
### Single-service Docker Compose
|
|
44
|
+
|
|
45
|
+
```yaml
|
|
46
|
+
# compose.yaml
|
|
47
|
+
services:
|
|
48
|
+
default:
|
|
49
|
+
image: python:3.12
|
|
50
|
+
environment:
|
|
51
|
+
- MY_VAR=hello
|
|
52
|
+
deploy:
|
|
53
|
+
resources:
|
|
54
|
+
limits:
|
|
55
|
+
cpus: "2.0"
|
|
56
|
+
memory: 4g
|
|
57
|
+
reservations:
|
|
58
|
+
devices:
|
|
59
|
+
- capabilities: [gpu]
|
|
60
|
+
count: 1
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
```python
|
|
64
|
+
task = Task(
|
|
65
|
+
dataset=[...],
|
|
66
|
+
solver=[...],
|
|
67
|
+
sandbox=("daytona", "compose.yaml"),
|
|
68
|
+
)
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
### Multi-service Docker Compose (DinD)
|
|
72
|
+
|
|
73
|
+
When a compose file defines more than one service, the provider automatically
|
|
74
|
+
uses Docker-in-Docker: a single Daytona sandbox runs a Docker daemon, and
|
|
75
|
+
services are brought up via `docker compose` inside it. Each service is
|
|
76
|
+
exposed as a separate `SandboxEnvironment`.
|
|
77
|
+
|
|
78
|
+
```yaml
|
|
79
|
+
# compose.yaml
|
|
80
|
+
services:
|
|
81
|
+
default:
|
|
82
|
+
image: python:3.12
|
|
83
|
+
x-default: true
|
|
84
|
+
helper:
|
|
85
|
+
image: redis:7
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
```python
|
|
89
|
+
task = Task(
|
|
90
|
+
dataset=[...],
|
|
91
|
+
solver=[...],
|
|
92
|
+
sandbox=("daytona", "compose.yaml"),
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
# In a solver, access services by name:
|
|
96
|
+
default_env = sandbox() # the x-default service
|
|
97
|
+
helper_env = sandbox("helper")
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
**Default service selection** (priority): `x-default: true` > service named
|
|
101
|
+
`"default"` or `"main"` > first service in the file.
|
|
102
|
+
|
|
103
|
+
**Resources**: Per-service resources are summed across all services plus 1 CPU
|
|
104
|
+
and 1 GiB overhead for the Docker daemon. Ensure the total fits within your
|
|
105
|
+
Daytona per-sandbox limits.
|
|
106
|
+
|
|
107
|
+
**DinD image**: The DinD sandbox uses `docker:28.3.3-dind` as the base image.
|
|
108
|
+
|
|
109
|
+
**DinD snapshots**: The provider auto-creates a Daytona snapshot for the DinD
|
|
110
|
+
base image. You can also provide a pre-created snapshot:
|
|
111
|
+
|
|
112
|
+
```yaml
|
|
113
|
+
x-daytona:
|
|
114
|
+
snapshot: "my-dind-snapshot"
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
**Image caching**: Each DinD sandbox gets a fresh Docker daemon with no image
|
|
118
|
+
cache. `docker compose build` rebuilds from scratch every sample. For faster
|
|
119
|
+
startup, pre-build your images and push them to a registry, then use `image:`
|
|
120
|
+
instead of `build:` in your compose file:
|
|
121
|
+
|
|
122
|
+
```yaml
|
|
123
|
+
services:
|
|
124
|
+
default:
|
|
125
|
+
image: myregistry/myapp:latest # pulled, not built
|
|
126
|
+
verifier:
|
|
127
|
+
image: myregistry/verifier:latest
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
*Daytona does not support snapshotting a running sandbox (to capture Docker's
|
|
131
|
+
layer cache) or shared volumes across sandboxes, so registry-based caching is
|
|
132
|
+
the recommended approach.*
|
|
133
|
+
|
|
134
|
+
### x-daytona extensions
|
|
135
|
+
|
|
136
|
+
```yaml
|
|
137
|
+
x-daytona:
|
|
138
|
+
auto_stop_interval: 10 # Minutes of inactivity before auto-stop (0 = disabled)
|
|
139
|
+
auto_archive_interval: 60 # Minutes before stopped sandbox auto-archives
|
|
140
|
+
auto_delete_interval: 1440 # Minutes before stopped sandbox auto-deletes
|
|
141
|
+
network_block_all: false # Block all network access
|
|
142
|
+
network_allow_list: "10.0.0.0/8,192.168.0.0/16" # Comma-separated CIDR allowlist
|
|
143
|
+
language: "python" # Hint for language-aware features
|
|
144
|
+
os_user: "ubuntu" # OS user for commands (overrides service-level user)
|
|
145
|
+
public: false # Publicly accessible sandbox
|
|
146
|
+
ephemeral: true # Auto-delete when stopped
|
|
147
|
+
timeout: 60.0 # Seconds to wait for sandbox creation
|
|
148
|
+
snapshot: "my-snapshot" # Pre-created snapshot (skips image build)
|
|
149
|
+
resources: # Sandbox-level resource overrides
|
|
150
|
+
cpu: 4 # (DinD: overrides per-service aggregation)
|
|
151
|
+
memory: 8
|
|
152
|
+
gpu: 1
|
|
153
|
+
env_vars: # Extra env vars (single-service: merged with service
|
|
154
|
+
EXTRA_VAR: "value" # environment; DinD: set on the VM, not compose services)
|
|
155
|
+
labels: # Custom labels, merged with inspect's own labels
|
|
156
|
+
team: "research"
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
## Notes and Limitations
|
|
160
|
+
|
|
161
|
+
- **Default user**: The default sandbox user is `daytona` (not root), with passwordless `sudo`.
|
|
162
|
+
- **user**: The `user` parameter to `exec()` is supported via `sudo -u` in single-service mode and `docker compose exec --user` in DinD mode. Numeric UIDs are supported. Requires `sudo` with passwordless access (configured by default).
|
|
163
|
+
- **stdin**: The `input` parameter to `exec()` is supported via input redirection from a temp file. POSIX-compatible.
|
|
164
|
+
- **stderr**: The Daytona API returns combined stdout+stderr; `stderr` is always empty.
|
|
165
|
+
- **Architecture**: Daytona runners use `linux/amd64`. arm64-only images are not supported.
|
|
166
|
+
- **Network**: Outbound internet depends on your Daytona subscription tier. Tiers 1-2 restrict to essential services (Docker Hub, npm, PyPI, GitHub, AI providers). Tiers 3-4 have full internet. Docker Compose `network_mode` is translated to Daytona's `network_block_all`; `x-daytona.network_block_all` takes precedence. For DinD, the VM always has network enabled (for `docker pull`); service-level isolation is via compose `network_mode`.
|
|
167
|
+
- **DinD startup latency**: Docker daemon boot + image pulls + compose up can take 30s+. Use pre-built images and snapshots to reduce this.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Daytona sandbox implementation for Inspect AI"""
|
{inspect_sandboxes-0.1.1.dev1 → inspect_sandboxes-0.2.0}/src/inspect_sandboxes/daytona/_compose.py
RENAMED
|
@@ -2,7 +2,12 @@ import math
|
|
|
2
2
|
from pathlib import Path
|
|
3
3
|
from typing import Any
|
|
4
4
|
|
|
5
|
-
from daytona_sdk import
|
|
5
|
+
from daytona_sdk import (
|
|
6
|
+
CreateSandboxFromImageParams,
|
|
7
|
+
CreateSandboxFromSnapshotParams,
|
|
8
|
+
Image,
|
|
9
|
+
Resources,
|
|
10
|
+
)
|
|
6
11
|
from inspect_ai.util import ComposeConfig, ComposeService
|
|
7
12
|
|
|
8
13
|
from inspect_sandboxes._util.compose import (
|
|
@@ -12,25 +17,20 @@ from inspect_sandboxes._util.compose import (
|
|
|
12
17
|
)
|
|
13
18
|
|
|
14
19
|
|
|
15
|
-
def
|
|
16
|
-
config: ComposeConfig,
|
|
17
|
-
|
|
18
|
-
|
|
20
|
+
def create_single_service_params(
|
|
21
|
+
config: ComposeConfig,
|
|
22
|
+
compose_path: str | None,
|
|
23
|
+
labels: dict[str, str],
|
|
24
|
+
) -> CreateSandboxFromImageParams | CreateSandboxFromSnapshotParams:
|
|
25
|
+
"""Create Daytona sandbox params from a single-service compose config.
|
|
19
26
|
|
|
20
27
|
Args:
|
|
21
28
|
config: Parsed compose configuration.
|
|
22
29
|
compose_path: Path to the compose file for resolving relative paths.
|
|
23
30
|
Pass None when using a ComposeConfig object directly.
|
|
24
|
-
|
|
25
|
-
Returns:
|
|
26
|
-
Tuple of (image, resources, sandbox_params) where:
|
|
27
|
-
- image: Docker image name or Image object
|
|
28
|
-
- resources: Resources config, or None if not specified
|
|
29
|
-
- sandbox_params: Extra sandbox params (env_vars, network settings, etc.)
|
|
31
|
+
labels: Labels to apply (merged with x-daytona labels).
|
|
30
32
|
"""
|
|
31
|
-
service =
|
|
32
|
-
if service is None:
|
|
33
|
-
service = config.services.get("default") or next(iter(config.services.values()))
|
|
33
|
+
_, service = find_default_service(config)
|
|
34
34
|
|
|
35
35
|
compose_dir = Path(compose_path).parent if compose_path else Path.cwd()
|
|
36
36
|
|
|
@@ -45,7 +45,7 @@ def convert_compose_to_daytona_params(
|
|
|
45
45
|
else:
|
|
46
46
|
raise ValueError("Compose service must specify either 'image' or 'build'")
|
|
47
47
|
|
|
48
|
-
# Resources
|
|
48
|
+
# Resources (from service, may be overridden by x-daytona.resources below)
|
|
49
49
|
resources = _service_to_resources(service)
|
|
50
50
|
|
|
51
51
|
# Sandbox-level params
|
|
@@ -57,12 +57,87 @@ def convert_compose_to_daytona_params(
|
|
|
57
57
|
if service.user:
|
|
58
58
|
sandbox_params["os_user"] = service.user
|
|
59
59
|
|
|
60
|
-
|
|
60
|
+
# Translate Docker network_mode to Daytona network_block_all.
|
|
61
|
+
# Only set as a default; x-daytona extensions can override below.
|
|
62
|
+
if service.network_mode is not None and "network_block_all" not in sandbox_params:
|
|
63
|
+
sandbox_params["network_block_all"] = service.network_mode == "none"
|
|
64
|
+
|
|
65
|
+
apply_daytona_extensions(sandbox_params, config.extensions)
|
|
66
|
+
|
|
67
|
+
sandbox_params.setdefault("auto_stop_interval", 0)
|
|
68
|
+
x_labels = sandbox_params.pop("labels", {})
|
|
69
|
+
merged_labels = {**x_labels, **labels}
|
|
70
|
+
|
|
71
|
+
# x-daytona.resources overrides service-level resources
|
|
72
|
+
resources_override = sandbox_params.pop("resources", None)
|
|
73
|
+
if resources_override:
|
|
74
|
+
resources = Resources(
|
|
75
|
+
cpu=resources_override.get("cpu"),
|
|
76
|
+
memory=resources_override.get("memory"),
|
|
77
|
+
gpu=resources_override.get("gpu"),
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
# x-daytona.snapshot: use pre-built snapshot instead of building from image
|
|
81
|
+
snapshot = sandbox_params.pop("snapshot", None)
|
|
82
|
+
if snapshot:
|
|
83
|
+
return CreateSandboxFromSnapshotParams(
|
|
84
|
+
snapshot=snapshot,
|
|
85
|
+
labels=merged_labels,
|
|
86
|
+
**sandbox_params,
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
return CreateSandboxFromImageParams(
|
|
90
|
+
image=image,
|
|
91
|
+
resources=resources,
|
|
92
|
+
labels=merged_labels,
|
|
93
|
+
**sandbox_params,
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def find_default_service(config: ComposeConfig) -> tuple[str, ComposeService]:
|
|
98
|
+
"""Find the default service in a compose config.
|
|
99
|
+
|
|
100
|
+
Priority: x-default: true -> service named "default" or "main" -> first service.
|
|
101
|
+
|
|
102
|
+
Returns:
|
|
103
|
+
Tuple of (service_name, service_config).
|
|
104
|
+
"""
|
|
105
|
+
for name, svc in config.services.items():
|
|
106
|
+
if svc.x_default:
|
|
107
|
+
return name, svc
|
|
108
|
+
for candidate in ("default", "main"):
|
|
109
|
+
if candidate in config.services:
|
|
110
|
+
return candidate, config.services[candidate]
|
|
111
|
+
name = next(iter(config.services))
|
|
112
|
+
return name, config.services[name]
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def aggregate_resources(config: ComposeConfig) -> Resources | None:
|
|
116
|
+
"""Sum per-service resources across all services + (Docker daemon) overhead."""
|
|
117
|
+
total_cpu = 0
|
|
118
|
+
total_memory = 0
|
|
119
|
+
total_gpu = 0
|
|
120
|
+
has_any = False
|
|
121
|
+
|
|
122
|
+
for svc in config.services.values():
|
|
123
|
+
r = _service_to_resources(svc)
|
|
124
|
+
if r:
|
|
125
|
+
has_any = True
|
|
126
|
+
total_cpu += r.cpu or 0
|
|
127
|
+
total_memory += r.memory or 0
|
|
128
|
+
total_gpu += r.gpu or 0
|
|
129
|
+
|
|
130
|
+
if not has_any:
|
|
131
|
+
return None
|
|
61
132
|
|
|
62
|
-
return
|
|
133
|
+
return Resources(
|
|
134
|
+
cpu=total_cpu + 1,
|
|
135
|
+
memory=total_memory + 1,
|
|
136
|
+
gpu=total_gpu or None,
|
|
137
|
+
)
|
|
63
138
|
|
|
64
139
|
|
|
65
|
-
def
|
|
140
|
+
def apply_daytona_extensions(
|
|
66
141
|
params: dict[str, Any], extensions: dict[str, Any]
|
|
67
142
|
) -> None:
|
|
68
143
|
"""Apply Daytona-specific extensions from x-daytona compose key.
|
|
@@ -83,6 +158,10 @@ def _apply_daytona_extensions(
|
|
|
83
158
|
x-daytona values take precedence over service-level environment.
|
|
84
159
|
- labels (dict): Custom labels. Merged by the caller with inspect's own labels,
|
|
85
160
|
which take precedence.
|
|
161
|
+
- snapshot (str): Pre-created Daytona snapshot name. For single-service,
|
|
162
|
+
skips image building. For DinD, uses as the DinD VM snapshot.
|
|
163
|
+
- resources (dict): Sandbox-level resource overrides (cpu, memory, gpu).
|
|
164
|
+
For DinD, overrides the per-service aggregation.
|
|
86
165
|
- volumes: Not yet supported.
|
|
87
166
|
|
|
88
167
|
Args:
|
|
@@ -103,6 +182,8 @@ def _apply_daytona_extensions(
|
|
|
103
182
|
"ephemeral",
|
|
104
183
|
"timeout",
|
|
105
184
|
"labels",
|
|
185
|
+
"snapshot",
|
|
186
|
+
"resources",
|
|
106
187
|
]
|
|
107
188
|
|
|
108
189
|
for key in simple_keys:
|
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
"""Daytona sandbox provider"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
import uuid
|
|
8
|
+
from contextvars import ContextVar
|
|
9
|
+
from logging import getLogger
|
|
10
|
+
|
|
11
|
+
from daytona_sdk import (
|
|
12
|
+
AsyncDaytona,
|
|
13
|
+
CreateSandboxFromImageParams,
|
|
14
|
+
CreateSandboxFromSnapshotParams,
|
|
15
|
+
DaytonaNotFoundError,
|
|
16
|
+
Image,
|
|
17
|
+
)
|
|
18
|
+
from inspect_ai.util import (
|
|
19
|
+
ComposeConfig,
|
|
20
|
+
SandboxEnvironment,
|
|
21
|
+
SandboxEnvironmentConfigType,
|
|
22
|
+
is_compose_yaml,
|
|
23
|
+
is_dockerfile,
|
|
24
|
+
parse_compose_yaml,
|
|
25
|
+
sandboxenv,
|
|
26
|
+
trace_message,
|
|
27
|
+
)
|
|
28
|
+
from rich import box, print
|
|
29
|
+
from rich.prompt import Confirm
|
|
30
|
+
from rich.table import Table
|
|
31
|
+
from typing_extensions import override
|
|
32
|
+
|
|
33
|
+
from ._compose import create_single_service_params
|
|
34
|
+
from ._dind_env import DaytonaDinDServiceEnvironment
|
|
35
|
+
from ._sandbox_utils import (
|
|
36
|
+
close_client,
|
|
37
|
+
create_sandbox,
|
|
38
|
+
delete_sandbox,
|
|
39
|
+
list_sandboxes,
|
|
40
|
+
)
|
|
41
|
+
from ._single_env import DaytonaSingleServiceEnvironment
|
|
42
|
+
|
|
43
|
+
logger = getLogger(__name__)
|
|
44
|
+
|
|
45
|
+
INSPECT_SANDBOX_LABEL = {"created_by": "inspect-ai"}
|
|
46
|
+
|
|
47
|
+
_daytona_client: ContextVar[AsyncDaytona | None] = ContextVar(
|
|
48
|
+
"daytona_client", default=None
|
|
49
|
+
)
|
|
50
|
+
_running_sandboxes: ContextVar[list[str]] = ContextVar("daytona_running_sandboxes")
|
|
51
|
+
_run_id: ContextVar[str] = ContextVar("daytona_run_id")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _init_context() -> None:
|
|
55
|
+
_running_sandboxes.set([])
|
|
56
|
+
_run_id.set(uuid.uuid4().hex)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _run_labels() -> dict[str, str]:
|
|
60
|
+
return {**INSPECT_SANDBOX_LABEL, "inspect_run_id": _run_id.get()}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@sandboxenv(name="daytona")
|
|
64
|
+
class DaytonaSandboxEnvironment(SandboxEnvironment):
|
|
65
|
+
"""Daytona sandbox provider
|
|
66
|
+
|
|
67
|
+
Owns all lifecycle class methods. sample_init returns instances of
|
|
68
|
+
DaytonaSingleServiceEnvironment or DaytonaDinDServiceEnvironment.
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
@classmethod
|
|
72
|
+
def config_files(cls) -> list[str]:
|
|
73
|
+
return [
|
|
74
|
+
"compose.yaml",
|
|
75
|
+
"compose.yml",
|
|
76
|
+
"docker-compose.yaml",
|
|
77
|
+
"docker-compose.yml",
|
|
78
|
+
"Dockerfile",
|
|
79
|
+
]
|
|
80
|
+
|
|
81
|
+
@classmethod
|
|
82
|
+
def is_docker_compatible(cls) -> bool:
|
|
83
|
+
return True
|
|
84
|
+
|
|
85
|
+
@override
|
|
86
|
+
@classmethod
|
|
87
|
+
async def task_init(
|
|
88
|
+
cls,
|
|
89
|
+
task_name: str,
|
|
90
|
+
config: SandboxEnvironmentConfigType | None,
|
|
91
|
+
) -> None:
|
|
92
|
+
_init_context()
|
|
93
|
+
client = AsyncDaytona()
|
|
94
|
+
_daytona_client.set(client)
|
|
95
|
+
|
|
96
|
+
@override
|
|
97
|
+
@classmethod
|
|
98
|
+
async def sample_init(
|
|
99
|
+
cls,
|
|
100
|
+
task_name: str,
|
|
101
|
+
config: SandboxEnvironmentConfigType | None,
|
|
102
|
+
metadata: dict[str, str],
|
|
103
|
+
) -> dict[str, SandboxEnvironment]:
|
|
104
|
+
client = _daytona_client.get()
|
|
105
|
+
if client is None:
|
|
106
|
+
raise RuntimeError(
|
|
107
|
+
"Daytona client not initialized. task_init must be called first."
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
params: CreateSandboxFromSnapshotParams | CreateSandboxFromImageParams
|
|
111
|
+
|
|
112
|
+
if config is None:
|
|
113
|
+
params = CreateSandboxFromSnapshotParams(
|
|
114
|
+
snapshot=None,
|
|
115
|
+
labels=_run_labels(),
|
|
116
|
+
auto_stop_interval=0,
|
|
117
|
+
)
|
|
118
|
+
elif is_dockerfile(config):
|
|
119
|
+
image = Image.from_dockerfile(config)
|
|
120
|
+
params = CreateSandboxFromImageParams(
|
|
121
|
+
image=image,
|
|
122
|
+
labels=_run_labels(),
|
|
123
|
+
auto_stop_interval=0,
|
|
124
|
+
)
|
|
125
|
+
elif is_compose_yaml(config) or isinstance(config, ComposeConfig):
|
|
126
|
+
compose_config: ComposeConfig
|
|
127
|
+
compose_file: str | None
|
|
128
|
+
if is_compose_yaml(config):
|
|
129
|
+
compose_config = parse_compose_yaml(config, multiple_services=True)
|
|
130
|
+
compose_file = str(config)
|
|
131
|
+
else:
|
|
132
|
+
assert isinstance(config, ComposeConfig)
|
|
133
|
+
compose_config = config
|
|
134
|
+
compose_file = None
|
|
135
|
+
|
|
136
|
+
if len(compose_config.services) > 1:
|
|
137
|
+
envs = await DaytonaDinDServiceEnvironment.sample_init_dind(
|
|
138
|
+
client, compose_config, compose_file, _run_labels()
|
|
139
|
+
)
|
|
140
|
+
any_env = next(iter(envs.values())).as_type(
|
|
141
|
+
DaytonaDinDServiceEnvironment
|
|
142
|
+
)
|
|
143
|
+
_running_sandboxes.get().append(any_env.project.sandbox.id)
|
|
144
|
+
return envs
|
|
145
|
+
params = create_single_service_params(
|
|
146
|
+
compose_config, compose_file, _run_labels()
|
|
147
|
+
)
|
|
148
|
+
else:
|
|
149
|
+
raise ValueError(
|
|
150
|
+
f"Unrecognized config: {config}. "
|
|
151
|
+
"Expected a compose file (*.yaml/*.yml), Dockerfile, "
|
|
152
|
+
"ComposeConfig object, or None."
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
sandbox = await create_sandbox(client, params)
|
|
156
|
+
_running_sandboxes.get().append(sandbox.id)
|
|
157
|
+
|
|
158
|
+
return {"default": DaytonaSingleServiceEnvironment(sandbox)}
|
|
159
|
+
|
|
160
|
+
@override
|
|
161
|
+
@classmethod
|
|
162
|
+
async def sample_cleanup(
|
|
163
|
+
cls,
|
|
164
|
+
task_name: str,
|
|
165
|
+
config: SandboxEnvironmentConfigType | None,
|
|
166
|
+
environments: dict[str, SandboxEnvironment],
|
|
167
|
+
interrupted: bool,
|
|
168
|
+
) -> None:
|
|
169
|
+
if not environments:
|
|
170
|
+
return
|
|
171
|
+
|
|
172
|
+
any_env = next(iter(environments.values()))
|
|
173
|
+
if isinstance(any_env, DaytonaDinDServiceEnvironment):
|
|
174
|
+
await DaytonaDinDServiceEnvironment.sample_cleanup(
|
|
175
|
+
task_name, config, environments, interrupted
|
|
176
|
+
)
|
|
177
|
+
else:
|
|
178
|
+
await DaytonaSingleServiceEnvironment.sample_cleanup(
|
|
179
|
+
task_name, config, environments, interrupted
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
@override
|
|
183
|
+
@classmethod
|
|
184
|
+
async def task_cleanup(
|
|
185
|
+
cls,
|
|
186
|
+
task_name: str,
|
|
187
|
+
config: SandboxEnvironmentConfigType | None,
|
|
188
|
+
cleanup: bool,
|
|
189
|
+
) -> None:
|
|
190
|
+
if not cleanup:
|
|
191
|
+
return
|
|
192
|
+
|
|
193
|
+
client = _daytona_client.get()
|
|
194
|
+
if client is None:
|
|
195
|
+
return
|
|
196
|
+
|
|
197
|
+
failed_ids: list[str] = []
|
|
198
|
+
deleted_ids: set[str] = set()
|
|
199
|
+
|
|
200
|
+
for sandbox_id in _running_sandboxes.get().copy():
|
|
201
|
+
try:
|
|
202
|
+
sandbox = await client.get(sandbox_id)
|
|
203
|
+
await delete_sandbox(client, sandbox)
|
|
204
|
+
deleted_ids.add(sandbox_id)
|
|
205
|
+
trace_message(logger, "daytona", f"Deleted sandbox {sandbox_id}")
|
|
206
|
+
except DaytonaNotFoundError:
|
|
207
|
+
# Already deleted (e.g. by sample_cleanup) — nothing to do.
|
|
208
|
+
deleted_ids.add(sandbox_id)
|
|
209
|
+
trace_message(
|
|
210
|
+
logger,
|
|
211
|
+
"daytona",
|
|
212
|
+
f"Sandbox {sandbox_id} already deleted, skipping.",
|
|
213
|
+
)
|
|
214
|
+
except Exception as e:
|
|
215
|
+
failed_ids.append(sandbox_id)
|
|
216
|
+
logger.error(f"Failed to delete sandbox {sandbox_id}: {e}")
|
|
217
|
+
|
|
218
|
+
# Second pass: find orphaned sandboxes by run label.
|
|
219
|
+
# This catches sandboxes where creation failed (e.g. build_failed)
|
|
220
|
+
# and the ID was never tracked in _running_sandboxes.
|
|
221
|
+
run_id = _run_id.get()
|
|
222
|
+
if run_id:
|
|
223
|
+
try:
|
|
224
|
+
orphans = await list_sandboxes(client, {"inspect_run_id": run_id})
|
|
225
|
+
for sb in orphans:
|
|
226
|
+
if sb.id in deleted_ids:
|
|
227
|
+
continue
|
|
228
|
+
try:
|
|
229
|
+
await delete_sandbox(client, sb)
|
|
230
|
+
trace_message(
|
|
231
|
+
logger,
|
|
232
|
+
"daytona",
|
|
233
|
+
f"Deleted orphaned sandbox {sb.id} (state={sb.state})",
|
|
234
|
+
)
|
|
235
|
+
except Exception as e:
|
|
236
|
+
failed_ids.append(sb.id)
|
|
237
|
+
logger.error(f"Failed to delete orphaned sandbox {sb.id}: {e}")
|
|
238
|
+
except Exception as e:
|
|
239
|
+
logger.warning(f"Failed to list sandboxes for cleanup: {e}")
|
|
240
|
+
|
|
241
|
+
if failed_ids:
|
|
242
|
+
logger.warning(
|
|
243
|
+
f"Failed to cleanup {len(failed_ids)} sandbox(es). "
|
|
244
|
+
f"Failed IDs: {', '.join(failed_ids)}"
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
_running_sandboxes.get().clear()
|
|
248
|
+
|
|
249
|
+
await close_client(client)
|
|
250
|
+
_daytona_client.set(None)
|
|
251
|
+
|
|
252
|
+
@override
|
|
253
|
+
@classmethod
|
|
254
|
+
async def cli_cleanup(cls, id: str | None) -> None:
|
|
255
|
+
client = AsyncDaytona()
|
|
256
|
+
try:
|
|
257
|
+
if id is not None:
|
|
258
|
+
# Single sandbox cleanup
|
|
259
|
+
try:
|
|
260
|
+
sandbox = await client.get(id)
|
|
261
|
+
await delete_sandbox(client, sandbox)
|
|
262
|
+
print(f"Successfully deleted sandbox {id}")
|
|
263
|
+
except Exception as e:
|
|
264
|
+
print(f"[red]Error deleting sandbox {id}: {e}[/red]")
|
|
265
|
+
sys.exit(1)
|
|
266
|
+
else:
|
|
267
|
+
# Bulk cleanup
|
|
268
|
+
sandboxes = await list_sandboxes(client, INSPECT_SANDBOX_LABEL)
|
|
269
|
+
|
|
270
|
+
if not sandboxes:
|
|
271
|
+
print("No Daytona sandboxes found to clean up.")
|
|
272
|
+
return
|
|
273
|
+
|
|
274
|
+
table = Table(
|
|
275
|
+
box=box.SQUARE,
|
|
276
|
+
show_lines=False,
|
|
277
|
+
title_style="bold",
|
|
278
|
+
title_justify="left",
|
|
279
|
+
)
|
|
280
|
+
table.add_column("Sandbox ID")
|
|
281
|
+
for sb in sandboxes:
|
|
282
|
+
table.add_row(sb.id)
|
|
283
|
+
print(table)
|
|
284
|
+
|
|
285
|
+
is_interactive = sys.stdin.isatty()
|
|
286
|
+
is_ci = "CI" in os.environ
|
|
287
|
+
is_pytest = "PYTEST_CURRENT_TEST" in os.environ
|
|
288
|
+
|
|
289
|
+
if is_interactive and not is_ci and not is_pytest:
|
|
290
|
+
if not Confirm.ask(
|
|
291
|
+
f"Are you sure you want to delete ALL {len(sandboxes)} sandbox(es) above?"
|
|
292
|
+
):
|
|
293
|
+
print("Cancelled.")
|
|
294
|
+
return
|
|
295
|
+
|
|
296
|
+
success_count = 0
|
|
297
|
+
failure_count = 0
|
|
298
|
+
|
|
299
|
+
for sb in sandboxes:
|
|
300
|
+
try:
|
|
301
|
+
await delete_sandbox(client, sb)
|
|
302
|
+
success_count += 1
|
|
303
|
+
except Exception as e:
|
|
304
|
+
print(f"[yellow]Error deleting sandbox {sb.id}: {e}[/yellow]")
|
|
305
|
+
failure_count += 1
|
|
306
|
+
|
|
307
|
+
print(f"\n[green]Successfully deleted: {success_count}[/green]")
|
|
308
|
+
if failure_count > 0:
|
|
309
|
+
print(f"[red]Failed to delete: {failure_count}[/red]")
|
|
310
|
+
sys.exit(1)
|
|
311
|
+
else:
|
|
312
|
+
print("Complete.")
|
|
313
|
+
finally:
|
|
314
|
+
await close_client(client)
|