boxd-inspect 0.1.0__py3-none-any.whl
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.
- boxd_inspect/__init__.py +7 -0
- boxd_inspect/_commands.py +203 -0
- boxd_inspect/_compose.py +157 -0
- boxd_inspect/_config.py +148 -0
- boxd_inspect/_environment.py +731 -0
- boxd_inspect/_registry.py +5 -0
- boxd_inspect/_version.py +1 -0
- boxd_inspect/py.typed +0 -0
- boxd_inspect-0.1.0.dist-info/METADATA +314 -0
- boxd_inspect-0.1.0.dist-info/RECORD +13 -0
- boxd_inspect-0.1.0.dist-info/WHEEL +4 -0
- boxd_inspect-0.1.0.dist-info/entry_points.txt +2 -0
- boxd_inspect-0.1.0.dist-info/licenses/LICENSE +21 -0
boxd_inspect/__init__.py
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
"""Run Inspect AI sandboxes on boxd machines."""
|
|
2
|
+
|
|
3
|
+
from ._config import BoxdSandboxEnvironmentConfig
|
|
4
|
+
from ._environment import BoxdSandboxEnvironment
|
|
5
|
+
from ._version import __version__
|
|
6
|
+
|
|
7
|
+
__all__ = ["BoxdSandboxEnvironment", "BoxdSandboxEnvironmentConfig", "__version__"]
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
"""Shell lines the machine runs for each sandbox operation.
|
|
2
|
+
|
|
3
|
+
Every exec on a boxd machine runs one ``/bin/sh`` line as the machine's user
|
|
4
|
+
``boxd``. These functions build those lines; they do no I/O, so the unit tests
|
|
5
|
+
check them directly.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
import shlex
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
|
|
14
|
+
SHELL_PATH = "/bin/sh"
|
|
15
|
+
SYSTEM_PATH = "/usr/sbin:/usr/bin:/sbin:/bin"
|
|
16
|
+
|
|
17
|
+
SUDO = "/usr/bin/sudo"
|
|
18
|
+
TIMEOUT = "/usr/bin/timeout"
|
|
19
|
+
ENV = "/usr/bin/env"
|
|
20
|
+
TIMEOUT_KILL_AFTER = 5
|
|
21
|
+
"""Seconds between the TERM and the KILL a timed-out command gets."""
|
|
22
|
+
|
|
23
|
+
_ENV_NAME = re.compile(r"[A-Za-z_][A-Za-z0-9_]*")
|
|
24
|
+
|
|
25
|
+
# cd to $0, then run "$@": the directory and the argv never pass through a
|
|
26
|
+
# shell's word splitting.
|
|
27
|
+
_CD_AND_RUN = 'cd -- "$0" && exec "$@"'
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def check_env(env: dict[str, str] | None) -> None:
|
|
31
|
+
"""Raise ``ValueError`` for a variable name a shell cannot assign."""
|
|
32
|
+
for name in env or {}:
|
|
33
|
+
if not _ENV_NAME.fullmatch(name):
|
|
34
|
+
raise ValueError(f"{name!r} is not an environment variable name")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True)
|
|
38
|
+
class Compose:
|
|
39
|
+
"""A compose project inside the machine, and one of its services."""
|
|
40
|
+
|
|
41
|
+
project: str
|
|
42
|
+
directory: str
|
|
43
|
+
file: str
|
|
44
|
+
service: str
|
|
45
|
+
|
|
46
|
+
def base(self) -> list[str]:
|
|
47
|
+
return [
|
|
48
|
+
"docker",
|
|
49
|
+
"compose",
|
|
50
|
+
"--project-name",
|
|
51
|
+
self.project,
|
|
52
|
+
"--project-directory",
|
|
53
|
+
self.directory,
|
|
54
|
+
"--file",
|
|
55
|
+
self.file,
|
|
56
|
+
]
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def exec_argv(
|
|
60
|
+
cmd: list[str],
|
|
61
|
+
*,
|
|
62
|
+
cwd: str,
|
|
63
|
+
env: dict[str, str] | None,
|
|
64
|
+
user: str | None,
|
|
65
|
+
timeout: int | None,
|
|
66
|
+
compose: Compose | None,
|
|
67
|
+
) -> list[str]:
|
|
68
|
+
"""Build the argv, run by the machine's shell, that runs ``cmd`` as Inspect asks.
|
|
69
|
+
|
|
70
|
+
On the machine: ``timeout`` and ``sudo`` by absolute path, then a shell that
|
|
71
|
+
changes to ``cwd`` (as ``user``, so a directory only they can enter works)
|
|
72
|
+
and ``exec``s ``cmd``. ``env`` goes through ``/usr/bin/env`` so it survives
|
|
73
|
+
``sudo``. In a compose service: ``docker compose exec`` with ``--workdir``,
|
|
74
|
+
``--user`` and ``--env``, and the same ``timeout`` inside the container so
|
|
75
|
+
the command's whole process tree is stopped when it runs out.
|
|
76
|
+
"""
|
|
77
|
+
if not cmd:
|
|
78
|
+
raise ValueError("cmd must not be empty")
|
|
79
|
+
check_env(env)
|
|
80
|
+
limit = [TIMEOUT, "-k", f"{TIMEOUT_KILL_AFTER}s", f"{timeout}s"] if timeout is not None else []
|
|
81
|
+
if compose is not None:
|
|
82
|
+
argv = [*compose.base(), "exec", "-T", "--workdir", cwd]
|
|
83
|
+
if user is not None:
|
|
84
|
+
argv += ["--user", user]
|
|
85
|
+
for name, value in (env or {}).items():
|
|
86
|
+
argv += ["--env", f"{name}={value}"]
|
|
87
|
+
return [*argv, compose.service, *limit, *cmd]
|
|
88
|
+
|
|
89
|
+
run = [SHELL_PATH, "-c", _CD_AND_RUN, cwd]
|
|
90
|
+
if env:
|
|
91
|
+
run += [ENV, *(f"{name}={value}" for name, value in env.items())]
|
|
92
|
+
run += cmd
|
|
93
|
+
if user is not None:
|
|
94
|
+
return [SUDO, "-n", "-H", "-u", _sudo_user(user), "--", *limit, *run]
|
|
95
|
+
return [*limit, *run]
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def privileged_argv(
|
|
99
|
+
script: str,
|
|
100
|
+
*args: str,
|
|
101
|
+
user: str | None,
|
|
102
|
+
compose: Compose | None,
|
|
103
|
+
) -> list[str]:
|
|
104
|
+
"""Run a POSIX ``script`` (``$1``... are ``args``) as ``user`` with a pinned PATH.
|
|
105
|
+
|
|
106
|
+
For the file operations: their helper programs come from the system
|
|
107
|
+
directories, never from a PATH the sandbox's user can change.
|
|
108
|
+
"""
|
|
109
|
+
inner = pinned_shell_command(script, *args)
|
|
110
|
+
if compose is not None:
|
|
111
|
+
argv = [*compose.base(), "exec", "-T"]
|
|
112
|
+
if user is not None:
|
|
113
|
+
argv += ["--user", user]
|
|
114
|
+
return [*argv, compose.service, *inner]
|
|
115
|
+
if user is not None:
|
|
116
|
+
return [SUDO, "-n", "-H", "-u", _sudo_user(user), "--", *inner]
|
|
117
|
+
return inner
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def shell_line(
|
|
121
|
+
argv: list[str],
|
|
122
|
+
*,
|
|
123
|
+
stdin: str | None = None,
|
|
124
|
+
stdout: str | None = None,
|
|
125
|
+
remove: list[str] | None = None,
|
|
126
|
+
) -> str:
|
|
127
|
+
"""One ``/bin/sh`` line: ``argv`` with redirections, then remove temp files.
|
|
128
|
+
|
|
129
|
+
The exit status is ``argv``'s own. ``remove`` lists temp files the line
|
|
130
|
+
deletes after the command, whatever its result.
|
|
131
|
+
"""
|
|
132
|
+
line = shlex.join(argv)
|
|
133
|
+
if stdin is not None:
|
|
134
|
+
line += f" < {shlex.quote(stdin)}"
|
|
135
|
+
if stdout is not None:
|
|
136
|
+
line += f" > {shlex.quote(stdout)}"
|
|
137
|
+
if remove:
|
|
138
|
+
line += f"; rc=$?; /bin/rm -f -- {shlex.join(remove)}; exit $rc"
|
|
139
|
+
return line
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def script_line(script_path: str, *, remove: list[str] | None = None) -> str:
|
|
143
|
+
"""Run an uploaded script (for commands too long for one argument)."""
|
|
144
|
+
line = f"{SHELL_PATH} {shlex.quote(script_path)}"
|
|
145
|
+
if remove:
|
|
146
|
+
line += f"; rc=$?; /bin/rm -f -- {shlex.join(remove)}; exit $rc"
|
|
147
|
+
return line
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def pinned_shell_command(script: str, *args: str) -> list[str]:
|
|
151
|
+
"""``/bin/sh`` running ``script`` with PATH set to the system directories.
|
|
152
|
+
|
|
153
|
+
The same pin Inspect puts on its own privileged commands: what the script
|
|
154
|
+
runs never comes from a directory the sandbox's user can write to.
|
|
155
|
+
"""
|
|
156
|
+
pin = f"PATH={SYSTEM_PATH}\nexport PATH\nunset CDPATH\n"
|
|
157
|
+
return [SHELL_PATH, "-c", pin + script, "sh", *args]
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _sudo_user(user: str) -> str:
|
|
161
|
+
"""``sudo -u`` takes a uid as ``#<uid>``."""
|
|
162
|
+
return f"#{user}" if user.isdigit() else user
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
READ_SCRIPT = """\
|
|
166
|
+
if [ -d "$1" ]; then echo "$1: Is a directory" >&2; exit 21; fi
|
|
167
|
+
if [ -e "$1" ] && [ -r "$1" ]; then
|
|
168
|
+
size=$(stat -L -c %s -- "$1") || exit 1
|
|
169
|
+
if [ "$size" -gt "$2" ]; then echo "$1: larger than $2 bytes" >&2; exit 27; fi
|
|
170
|
+
fi
|
|
171
|
+
exec cat -- "$1"
|
|
172
|
+
"""
|
|
173
|
+
"""Print a file (``$1``) of at most ``$2`` bytes. A directory fails with
|
|
174
|
+
``EISDIR`` (21), a larger file with ``EFBIG`` (27), before anything is sent."""
|
|
175
|
+
|
|
176
|
+
WRITE_SCRIPT = """\
|
|
177
|
+
parent=$(dirname -- "$1") || exit 1
|
|
178
|
+
mkdir -p -- "$parent" || exit 1
|
|
179
|
+
if [ -d "$1" ]; then echo "$1: Is a directory" >&2; exit 21; fi
|
|
180
|
+
exec cat > "$1"
|
|
181
|
+
"""
|
|
182
|
+
"""Write stdin to a file, making its directory first. Writes through the file
|
|
183
|
+
(and a symlink to it), so its owner and mode stay and its permissions apply."""
|
|
184
|
+
|
|
185
|
+
PWD_SCRIPT = "exec pwd"
|
|
186
|
+
|
|
187
|
+
__all__ = [
|
|
188
|
+
"ENV",
|
|
189
|
+
"PWD_SCRIPT",
|
|
190
|
+
"READ_SCRIPT",
|
|
191
|
+
"SHELL_PATH",
|
|
192
|
+
"SUDO",
|
|
193
|
+
"SYSTEM_PATH",
|
|
194
|
+
"TIMEOUT",
|
|
195
|
+
"WRITE_SCRIPT",
|
|
196
|
+
"Compose",
|
|
197
|
+
"check_env",
|
|
198
|
+
"exec_argv",
|
|
199
|
+
"pinned_shell_command",
|
|
200
|
+
"privileged_argv",
|
|
201
|
+
"script_line",
|
|
202
|
+
"shell_line",
|
|
203
|
+
]
|
boxd_inspect/_compose.py
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""A compose project to run with Docker inside the machine.
|
|
2
|
+
|
|
3
|
+
The compose file (or a ``Dockerfile``, wrapped in the same compose file Inspect's
|
|
4
|
+
Docker sandbox makes for one) goes to the machine with its directory, so build
|
|
5
|
+
contexts next to it work as they do locally.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import io
|
|
11
|
+
import os
|
|
12
|
+
import tarfile
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
import yaml
|
|
17
|
+
from inspect_ai.util import ComposeConfig, is_dockerfile, parse_compose_yaml
|
|
18
|
+
|
|
19
|
+
GENERATED_FILE = ".boxd-inspect-compose.yaml"
|
|
20
|
+
"""The compose file this package writes next to a ``Dockerfile``."""
|
|
21
|
+
|
|
22
|
+
MAX_CONTEXT_BYTES = 512 * 1024 * 1024
|
|
23
|
+
"""The largest directory (packed) sent to each machine."""
|
|
24
|
+
|
|
25
|
+
DOCKERFILE_COMPOSE = """\
|
|
26
|
+
services:
|
|
27
|
+
default:
|
|
28
|
+
build:
|
|
29
|
+
context: "."
|
|
30
|
+
dockerfile: "{dockerfile}"
|
|
31
|
+
command: "tail -f /dev/null"
|
|
32
|
+
init: true
|
|
33
|
+
network_mode: none
|
|
34
|
+
stop_grace_period: 1s
|
|
35
|
+
"""
|
|
36
|
+
"""What Inspect's Docker sandbox runs for a lone ``Dockerfile``."""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True)
|
|
40
|
+
class ComposeSource:
|
|
41
|
+
"""What goes to the machine: a packed directory and the compose file in it."""
|
|
42
|
+
|
|
43
|
+
archive: bytes
|
|
44
|
+
file: str
|
|
45
|
+
services: list[str]
|
|
46
|
+
default_service: str
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
_cache: dict[tuple[str, float], ComposeSource] = {}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def compose_source(compose: str | ComposeConfig) -> ComposeSource:
|
|
53
|
+
"""Pack the compose project once per file version; samples reuse the result."""
|
|
54
|
+
if isinstance(compose, ComposeConfig):
|
|
55
|
+
return _from_config(compose)
|
|
56
|
+
path = Path(compose).resolve()
|
|
57
|
+
key = (str(path), _directory_mtime(path.parent))
|
|
58
|
+
cached = _cache.get(key)
|
|
59
|
+
if cached is None:
|
|
60
|
+
cached = _from_file(path)
|
|
61
|
+
_cache[key] = cached
|
|
62
|
+
return cached
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _from_file(path: Path) -> ComposeSource:
|
|
66
|
+
if not path.is_file():
|
|
67
|
+
raise FileNotFoundError(f"{path} does not exist")
|
|
68
|
+
extra: dict[str, bytes] = {}
|
|
69
|
+
if is_dockerfile(str(path)):
|
|
70
|
+
text = DOCKERFILE_COMPOSE.format(dockerfile=path.name)
|
|
71
|
+
extra[GENERATED_FILE] = text.encode()
|
|
72
|
+
config = ComposeConfig.model_validate(yaml.safe_load(text))
|
|
73
|
+
file = GENERATED_FILE
|
|
74
|
+
else:
|
|
75
|
+
config = parse_compose_yaml(str(path), multiple_services=True)
|
|
76
|
+
file = path.name
|
|
77
|
+
services = list(config.services)
|
|
78
|
+
return ComposeSource(
|
|
79
|
+
archive=_pack(path.parent, extra),
|
|
80
|
+
file=file,
|
|
81
|
+
services=services,
|
|
82
|
+
default_service=default_service(config),
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _from_config(config: ComposeConfig) -> ComposeSource:
|
|
87
|
+
text = yaml.safe_dump(
|
|
88
|
+
config.model_dump(mode="json", by_alias=True, exclude_none=True), sort_keys=False
|
|
89
|
+
)
|
|
90
|
+
buffer = io.BytesIO()
|
|
91
|
+
with tarfile.open(fileobj=buffer, mode="w:gz") as tar:
|
|
92
|
+
_add_bytes(tar, GENERATED_FILE, text.encode())
|
|
93
|
+
return ComposeSource(
|
|
94
|
+
archive=buffer.getvalue(),
|
|
95
|
+
file=GENERATED_FILE,
|
|
96
|
+
services=list(config.services),
|
|
97
|
+
default_service=default_service(config),
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def default_service(config: ComposeConfig) -> str:
|
|
102
|
+
"""Pick the service Inspect calls ``default``: ``x-default: true``, else the one named so."""
|
|
103
|
+
for name, service in config.services.items():
|
|
104
|
+
extra = service.model_extra or {}
|
|
105
|
+
if getattr(service, "x_default", None) is True or extra.get("x-default") is True:
|
|
106
|
+
return name
|
|
107
|
+
if "default" in config.services:
|
|
108
|
+
return "default"
|
|
109
|
+
raise ValueError(
|
|
110
|
+
"No 'default' service in the compose file. Name a service 'default' "
|
|
111
|
+
"or add 'x-default: true' to one."
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _pack(directory: Path, extra: dict[str, bytes]) -> bytes:
|
|
116
|
+
buffer = io.BytesIO()
|
|
117
|
+
with tarfile.open(fileobj=buffer, mode="w:gz") as tar:
|
|
118
|
+
tar.add(directory, arcname=".", filter=_portable)
|
|
119
|
+
for name, data in extra.items():
|
|
120
|
+
_add_bytes(tar, name, data)
|
|
121
|
+
data = buffer.getvalue()
|
|
122
|
+
if len(data) > MAX_CONTEXT_BYTES:
|
|
123
|
+
raise ValueError(
|
|
124
|
+
f"{directory} packs to {len(data) // (1024 * 1024)} MiB, more than the "
|
|
125
|
+
f"{MAX_CONTEXT_BYTES // (1024 * 1024)} MiB sent to each machine. Move the "
|
|
126
|
+
"compose file to a smaller directory, or start from a snapshot."
|
|
127
|
+
)
|
|
128
|
+
return data
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _portable(info: tarfile.TarInfo) -> tarfile.TarInfo | None:
|
|
132
|
+
"""Own every entry by the machine's user; skip devices and version-control data."""
|
|
133
|
+
if info.isdev() or "/.git/" in f"/{info.name}/":
|
|
134
|
+
return None
|
|
135
|
+
info.uid = info.gid = 1000
|
|
136
|
+
info.uname = info.gname = "boxd"
|
|
137
|
+
return info
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _add_bytes(tar: tarfile.TarFile, name: str, data: bytes) -> None:
|
|
141
|
+
info = tarfile.TarInfo(name)
|
|
142
|
+
info.size = len(data)
|
|
143
|
+
info.mode = 0o644
|
|
144
|
+
tar.addfile(info, io.BytesIO(data))
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _directory_mtime(directory: Path) -> float:
|
|
148
|
+
"""Find the newest change in a directory tree, so an edited build context is re-packed."""
|
|
149
|
+
newest = directory.stat().st_mtime
|
|
150
|
+
for root, dirs, files in os.walk(directory):
|
|
151
|
+
dirs[:] = [d for d in dirs if d != ".git"]
|
|
152
|
+
for name in files:
|
|
153
|
+
try:
|
|
154
|
+
newest = max(newest, os.stat(os.path.join(root, name)).st_mtime)
|
|
155
|
+
except OSError:
|
|
156
|
+
continue
|
|
157
|
+
return newest
|
boxd_inspect/_config.py
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"""The configuration of a ``boxd`` sandbox."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from inspect_ai.util import ComposeConfig, is_compose_yaml, is_dockerfile
|
|
10
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
11
|
+
|
|
12
|
+
REGIONS = ("fra", "nyc")
|
|
13
|
+
SIZES = {1: "4G", 2: "8G", 4: "16G", 8: "32G"}
|
|
14
|
+
|
|
15
|
+
_PREFIX = re.compile(r"[a-z][a-z0-9-]{0,19}")
|
|
16
|
+
_LABEL_KEY = re.compile(r"[a-z0-9]([a-z0-9._/-]{0,61}[a-z0-9])?")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class BoxdSandboxEnvironmentConfig(BaseModel):
|
|
20
|
+
"""How each sample's boxd machine is made.
|
|
21
|
+
|
|
22
|
+
The API key is never part of the configuration (it would end up in the eval
|
|
23
|
+
log). Set ``BOXD_API_KEY``.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
model_config = ConfigDict(extra="forbid", frozen=True)
|
|
27
|
+
|
|
28
|
+
compose: str | None = None
|
|
29
|
+
"""A ``compose.yaml`` or ``Dockerfile`` to run with Docker inside the machine.
|
|
30
|
+
|
|
31
|
+
Without it, commands run on the machine itself (Ubuntu 24.04, user ``boxd``,
|
|
32
|
+
passwordless ``sudo``, Docker, Python and Node preinstalled).
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
region: str | None = None
|
|
36
|
+
"""``fra`` or ``nyc``. Default: ``BOXD_REGION``, then your account's region."""
|
|
37
|
+
|
|
38
|
+
org: str | None = None
|
|
39
|
+
"""The org to create machines in. Default: ``BOXD_ORG``, then the key's org."""
|
|
40
|
+
|
|
41
|
+
vcpu: int | None = None
|
|
42
|
+
"""1, 2, 4 or 8. Memory follows (4, 8, 16 or 32 GiB) unless ``memory`` is set."""
|
|
43
|
+
|
|
44
|
+
memory: str | None = None
|
|
45
|
+
"""``4G``, ``8G``, ``16G`` or ``32G``. Default: the org's machine size."""
|
|
46
|
+
|
|
47
|
+
from_snapshot: str | None = None
|
|
48
|
+
"""Start each sample from this snapshot (for example one with images already pulled)."""
|
|
49
|
+
|
|
50
|
+
isolated: bool = True
|
|
51
|
+
"""Run the eval's untrusted commands on an isolated machine: no peers on your
|
|
52
|
+
org's private network, and none of the in-machine boxd credentials, CLI or
|
|
53
|
+
connected integrations a non-isolated machine of yours gets. Outbound
|
|
54
|
+
internet still works. Turn it off only for code you trust."""
|
|
55
|
+
|
|
56
|
+
working_dir: str | None = None
|
|
57
|
+
"""Where commands run and relative paths resolve. Default: ``/home/boxd`` on
|
|
58
|
+
the machine, the container's working directory with ``compose``."""
|
|
59
|
+
|
|
60
|
+
name_prefix: str = "inspect"
|
|
61
|
+
"""Machine names start with this, then the task, the sample and a random suffix."""
|
|
62
|
+
|
|
63
|
+
labels: dict[str, str] = Field(default_factory=dict)
|
|
64
|
+
"""Extra labels on every machine, next to ``inspect-run`` and ``inspect-task``."""
|
|
65
|
+
|
|
66
|
+
delete_after: int | None = None
|
|
67
|
+
"""Seconds after its creation at which boxd deletes a machine, whatever it is
|
|
68
|
+
doing: a safety net for machines an eval that was killed never cleaned up.
|
|
69
|
+
Keep it above your longest sample."""
|
|
70
|
+
|
|
71
|
+
ready_timeout: float = 180
|
|
72
|
+
"""Seconds to wait for a new machine to answer."""
|
|
73
|
+
|
|
74
|
+
compose_timeout: float = 900
|
|
75
|
+
"""Seconds ``docker compose up`` may take (pulls and builds included)."""
|
|
76
|
+
|
|
77
|
+
def __hash__(self) -> int:
|
|
78
|
+
# Inspect keeps configurations in dicts; the dict field is not hashable.
|
|
79
|
+
return hash(self.model_dump_json())
|
|
80
|
+
|
|
81
|
+
def check(self) -> None:
|
|
82
|
+
"""Raise ``ValueError`` for a value boxd would refuse, before any request."""
|
|
83
|
+
if self.region is not None and self.region not in REGIONS:
|
|
84
|
+
raise ValueError(f"region is one of {', '.join(REGIONS)}, not {self.region!r}")
|
|
85
|
+
if self.vcpu is not None and self.vcpu not in SIZES:
|
|
86
|
+
raise ValueError(f"vcpu is one of 1, 2, 4 or 8, not {self.vcpu}")
|
|
87
|
+
if self.memory is not None and self.memory not in SIZES.values():
|
|
88
|
+
raise ValueError(f"memory is one of 4G, 8G, 16G or 32G, not {self.memory!r}")
|
|
89
|
+
if not _PREFIX.fullmatch(self.name_prefix):
|
|
90
|
+
raise ValueError(
|
|
91
|
+
"name_prefix is 1 to 20 lowercase letters, digits or '-', starting with a letter"
|
|
92
|
+
)
|
|
93
|
+
if self.working_dir is not None and not self.working_dir.startswith("/"):
|
|
94
|
+
raise ValueError("working_dir is an absolute path")
|
|
95
|
+
for key, value in self.labels.items():
|
|
96
|
+
if not _LABEL_KEY.fullmatch(key) or key.startswith("boxd/"):
|
|
97
|
+
raise ValueError(
|
|
98
|
+
f"label key {key!r} is 1 to 63 of a-z, 0-9, '.', '_', '/', '-', "
|
|
99
|
+
"starting and ending with a letter or digit, and not under 'boxd/'"
|
|
100
|
+
)
|
|
101
|
+
if len(value) > 256 or any(ord(c) < 32 or ord(c) == 127 for c in value):
|
|
102
|
+
raise ValueError(
|
|
103
|
+
f"label {key} has a value over 256 characters or a control character"
|
|
104
|
+
)
|
|
105
|
+
if self.compose is not None and not (
|
|
106
|
+
is_compose_yaml(self.compose) or is_dockerfile(self.compose)
|
|
107
|
+
):
|
|
108
|
+
raise ValueError(f"compose is a compose.yaml or a Dockerfile, not {self.compose!r}")
|
|
109
|
+
|
|
110
|
+
def machine_memory(self) -> str | None:
|
|
111
|
+
"""``memory``, or the size that goes with ``vcpu``."""
|
|
112
|
+
if self.memory is not None:
|
|
113
|
+
return self.memory
|
|
114
|
+
return SIZES[self.vcpu] if self.vcpu is not None else None
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
Resolved = tuple[BoxdSandboxEnvironmentConfig, "str | ComposeConfig | None"]
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def resolve_config(config: object) -> Resolved:
|
|
121
|
+
"""Turn what Inspect passes as ``config`` into our settings and a compose source.
|
|
122
|
+
|
|
123
|
+
Inspect passes nothing, our object, a ``ComposeConfig``, or a path: a
|
|
124
|
+
``compose.yaml`` or ``Dockerfile`` (run with Docker in the machine) or a
|
|
125
|
+
JSON file holding our settings.
|
|
126
|
+
"""
|
|
127
|
+
compose: str | ComposeConfig | None = None
|
|
128
|
+
if config is None:
|
|
129
|
+
resolved = BoxdSandboxEnvironmentConfig()
|
|
130
|
+
elif isinstance(config, BoxdSandboxEnvironmentConfig):
|
|
131
|
+
resolved = config
|
|
132
|
+
elif isinstance(config, ComposeConfig) or (
|
|
133
|
+
isinstance(config, str) and (is_compose_yaml(config) or is_dockerfile(config))
|
|
134
|
+
):
|
|
135
|
+
resolved = BoxdSandboxEnvironmentConfig()
|
|
136
|
+
compose = config
|
|
137
|
+
elif isinstance(config, str) and config.endswith(".json"):
|
|
138
|
+
resolved = BoxdSandboxEnvironmentConfig.model_validate(json.loads(Path(config).read_text()))
|
|
139
|
+
else:
|
|
140
|
+
raise TypeError(
|
|
141
|
+
"The boxd sandbox takes a BoxdSandboxEnvironmentConfig, a ComposeConfig, "
|
|
142
|
+
"or the path of a compose.yaml, a Dockerfile or a JSON file, "
|
|
143
|
+
f"not {config!r}"
|
|
144
|
+
)
|
|
145
|
+
resolved.check()
|
|
146
|
+
if compose is None and resolved.compose is not None:
|
|
147
|
+
compose = resolved.compose
|
|
148
|
+
return resolved, compose
|