quartermaster-code-runner 0.0.1__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.
- quartermaster_code_runner/__init__.py +38 -0
- quartermaster_code_runner/app.py +269 -0
- quartermaster_code_runner/config.py +175 -0
- quartermaster_code_runner/errors.py +88 -0
- quartermaster_code_runner/execution.py +231 -0
- quartermaster_code_runner/images.py +397 -0
- quartermaster_code_runner/runtime/bun/Dockerfile +22 -0
- quartermaster_code_runner/runtime/bun/completions.json +34 -0
- quartermaster_code_runner/runtime/bun/entrypoint.sh +32 -0
- quartermaster_code_runner/runtime/bun/sdk.ts +87 -0
- quartermaster_code_runner/runtime/deno/Dockerfile +22 -0
- quartermaster_code_runner/runtime/deno/completions.json +34 -0
- quartermaster_code_runner/runtime/deno/entrypoint.sh +32 -0
- quartermaster_code_runner/runtime/deno/sdk.ts +88 -0
- quartermaster_code_runner/runtime/go/Dockerfile +18 -0
- quartermaster_code_runner/runtime/go/completions.json +22 -0
- quartermaster_code_runner/runtime/go/entrypoint.sh +50 -0
- quartermaster_code_runner/runtime/go/sdk.go +101 -0
- quartermaster_code_runner/runtime/node/Dockerfile +31 -0
- quartermaster_code_runner/runtime/node/completions.json +34 -0
- quartermaster_code_runner/runtime/node/entrypoint.sh +33 -0
- quartermaster_code_runner/runtime/node/mcp-client.js +274 -0
- quartermaster_code_runner/runtime/node/sdk.js +109 -0
- quartermaster_code_runner/runtime/python/Dockerfile +42 -0
- quartermaster_code_runner/runtime/python/completions.json +34 -0
- quartermaster_code_runner/runtime/python/entrypoint.sh +30 -0
- quartermaster_code_runner/runtime/python/mcp-client.py +276 -0
- quartermaster_code_runner/runtime/python/sdk.py +103 -0
- quartermaster_code_runner/runtime/rust/Cargo.toml.default +9 -0
- quartermaster_code_runner/runtime/rust/Dockerfile +27 -0
- quartermaster_code_runner/runtime/rust/completions.json +34 -0
- quartermaster_code_runner/runtime/rust/entrypoint.sh +38 -0
- quartermaster_code_runner/runtime/rust/sdk/Cargo.toml +9 -0
- quartermaster_code_runner/runtime/rust/sdk/src/lib.rs +149 -0
- quartermaster_code_runner/schemas.py +154 -0
- quartermaster_code_runner/security.py +81 -0
- quartermaster_code_runner-0.0.1.dist-info/METADATA +322 -0
- quartermaster_code_runner-0.0.1.dist-info/RECORD +40 -0
- quartermaster_code_runner-0.0.1.dist-info/WHEEL +4 -0
- quartermaster_code_runner-0.0.1.dist-info/licenses/LICENSE +190 -0
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""quartermaster-code-runner: Secure sandboxed code execution service.
|
|
2
|
+
|
|
3
|
+
Executes untrusted code in isolated Docker containers with support
|
|
4
|
+
for Python, Node.js, Go, Rust, Deno, and Bun.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from quartermaster_code_runner.config import ResourceLimits, Settings
|
|
8
|
+
from quartermaster_code_runner.errors import (
|
|
9
|
+
CodeRunnerError,
|
|
10
|
+
DockerError,
|
|
11
|
+
ExecutionError,
|
|
12
|
+
InvalidLanguageError,
|
|
13
|
+
ResourceExhaustedError,
|
|
14
|
+
RuntimeNotAvailableError,
|
|
15
|
+
TimeoutError,
|
|
16
|
+
)
|
|
17
|
+
from quartermaster_code_runner.schemas import (
|
|
18
|
+
CodeExecutionRequest,
|
|
19
|
+
CodeExecutionResponse,
|
|
20
|
+
HealthResponse,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
"CodeExecutionRequest",
|
|
25
|
+
"CodeExecutionResponse",
|
|
26
|
+
"CodeRunnerError",
|
|
27
|
+
"DockerError",
|
|
28
|
+
"ExecutionError",
|
|
29
|
+
"HealthResponse",
|
|
30
|
+
"InvalidLanguageError",
|
|
31
|
+
"ResourceExhaustedError",
|
|
32
|
+
"ResourceLimits",
|
|
33
|
+
"RuntimeNotAvailableError",
|
|
34
|
+
"Settings",
|
|
35
|
+
"TimeoutError",
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
"""FastAPI application for sandboxed code execution.
|
|
2
|
+
|
|
3
|
+
Provides REST API endpoints for executing code in isolated Docker containers
|
|
4
|
+
with support for Python, Node.js, Go, Rust, Deno, and Bun.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
import base64
|
|
11
|
+
import io
|
|
12
|
+
import logging
|
|
13
|
+
import os
|
|
14
|
+
import tarfile
|
|
15
|
+
import time
|
|
16
|
+
from typing import Any
|
|
17
|
+
from contextlib import asynccontextmanager
|
|
18
|
+
|
|
19
|
+
from fastapi import Depends, FastAPI, HTTPException, Request
|
|
20
|
+
|
|
21
|
+
from quartermaster_code_runner.config import (
|
|
22
|
+
MAX_CODE_SIZE_BYTES,
|
|
23
|
+
Settings,
|
|
24
|
+
)
|
|
25
|
+
from quartermaster_code_runner.execution import (
|
|
26
|
+
cleanup_orphaned_containers,
|
|
27
|
+
execute_code_in_container,
|
|
28
|
+
get_docker_client,
|
|
29
|
+
)
|
|
30
|
+
from quartermaster_code_runner.images import (
|
|
31
|
+
cleanup_prebuilds,
|
|
32
|
+
configure_images,
|
|
33
|
+
ensure_prebuilt_image,
|
|
34
|
+
router as images_router,
|
|
35
|
+
)
|
|
36
|
+
from quartermaster_code_runner.schemas import CodeExecutionRequest, CodeExecutionResponse
|
|
37
|
+
from quartermaster_code_runner.security import configure_auth, verify_auth
|
|
38
|
+
|
|
39
|
+
logging.basicConfig(
|
|
40
|
+
level=logging.INFO,
|
|
41
|
+
format="%(asctime)s %(name)s %(levelname)s %(message)s",
|
|
42
|
+
)
|
|
43
|
+
logger = logging.getLogger(__name__)
|
|
44
|
+
|
|
45
|
+
# Global settings and docker client
|
|
46
|
+
_settings: Settings | None = None
|
|
47
|
+
_docker_client = None
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
async def _periodic_cleanup(interval_hours: int, max_age_days: int) -> None:
|
|
51
|
+
"""Periodically clean up old prebuilt images."""
|
|
52
|
+
while True:
|
|
53
|
+
await asyncio.sleep(interval_hours * 3600)
|
|
54
|
+
try:
|
|
55
|
+
removed = await asyncio.to_thread(cleanup_prebuilds, max_age_days)
|
|
56
|
+
if removed:
|
|
57
|
+
logger.info(
|
|
58
|
+
"Auto-cleanup removed %d prebuilt images: %s",
|
|
59
|
+
len(removed),
|
|
60
|
+
removed,
|
|
61
|
+
)
|
|
62
|
+
except Exception:
|
|
63
|
+
logger.exception("Auto-cleanup failed")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@asynccontextmanager
|
|
67
|
+
async def lifespan(app: FastAPI): # type: ignore[no-untyped-def]
|
|
68
|
+
"""Application lifespan: setup and teardown."""
|
|
69
|
+
global _settings, _docker_client
|
|
70
|
+
|
|
71
|
+
# Load settings
|
|
72
|
+
_settings = Settings.from_env()
|
|
73
|
+
_settings.validate()
|
|
74
|
+
|
|
75
|
+
# Set log level
|
|
76
|
+
log_level = getattr(logging, _settings.log_level.upper(), logging.INFO)
|
|
77
|
+
logging.getLogger().setLevel(log_level)
|
|
78
|
+
|
|
79
|
+
# Configure authentication
|
|
80
|
+
configure_auth(
|
|
81
|
+
api_keys=_settings.api_keys,
|
|
82
|
+
auth_token=_settings.auth_token,
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
# Initialize Docker client
|
|
86
|
+
_docker_client = get_docker_client()
|
|
87
|
+
|
|
88
|
+
# Configure image management
|
|
89
|
+
configure_images(
|
|
90
|
+
docker_client=_docker_client,
|
|
91
|
+
runtime_dir=_settings.runtime_dir,
|
|
92
|
+
verify_auth_dep=verify_auth,
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
# Cleanup orphaned resources from previous runs
|
|
96
|
+
try:
|
|
97
|
+
cleanup_orphaned_containers(_docker_client)
|
|
98
|
+
logger.info("Startup: orphaned resources cleaned up")
|
|
99
|
+
except Exception:
|
|
100
|
+
logger.exception("Startup: failed to clean orphaned resources")
|
|
101
|
+
|
|
102
|
+
# Start periodic cleanup task
|
|
103
|
+
cleanup_task = asyncio.create_task(
|
|
104
|
+
_periodic_cleanup(
|
|
105
|
+
_settings.cleanup_interval_hours,
|
|
106
|
+
_settings.cleanup_max_age_days,
|
|
107
|
+
)
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
logger.info(
|
|
111
|
+
"Code Runner started (auth=%s, runtime_dir=%s)",
|
|
112
|
+
"enabled" if _settings.auth_enabled else "disabled",
|
|
113
|
+
_settings.runtime_dir,
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
try:
|
|
117
|
+
yield
|
|
118
|
+
finally:
|
|
119
|
+
cleanup_task.cancel()
|
|
120
|
+
logger.info("Code Runner shutting down")
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
app = FastAPI(
|
|
124
|
+
title="quartermaster-code-runner",
|
|
125
|
+
description="Secure sandboxed code execution service",
|
|
126
|
+
version="0.1.0",
|
|
127
|
+
lifespan=lifespan,
|
|
128
|
+
)
|
|
129
|
+
app.include_router(images_router)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
@app.post("/run", response_model=CodeExecutionResponse)
|
|
133
|
+
async def run_code(
|
|
134
|
+
request: Request,
|
|
135
|
+
payload: CodeExecutionRequest,
|
|
136
|
+
_: str | None = Depends(verify_auth),
|
|
137
|
+
) -> dict[str, Any]:
|
|
138
|
+
"""Execute code in a secure, isolated Docker container.
|
|
139
|
+
|
|
140
|
+
Supports Python, Node.js, Go, Rust, Deno, and Bun runtimes.
|
|
141
|
+
"""
|
|
142
|
+
assert _settings is not None
|
|
143
|
+
assert _docker_client is not None
|
|
144
|
+
|
|
145
|
+
logger.info("[run] request image=%s", payload.image)
|
|
146
|
+
|
|
147
|
+
if not payload.code and not payload.entrypoint:
|
|
148
|
+
raise HTTPException(
|
|
149
|
+
status_code=400,
|
|
150
|
+
detail="Either code or entrypoint must be provided.",
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
# Check code size
|
|
154
|
+
total_size = len(payload.code.encode("utf-8"))
|
|
155
|
+
if payload.files:
|
|
156
|
+
for content in payload.files.values():
|
|
157
|
+
total_size += len(content.encode("utf-8"))
|
|
158
|
+
|
|
159
|
+
if total_size > MAX_CODE_SIZE_BYTES:
|
|
160
|
+
raise HTTPException(status_code=413, detail="Code size exceeds the limit.")
|
|
161
|
+
|
|
162
|
+
start_time = time.time()
|
|
163
|
+
|
|
164
|
+
# Apply defaults from settings
|
|
165
|
+
timeout_seconds = (
|
|
166
|
+
payload.timeout if payload.timeout is not None else _settings.default_timeout
|
|
167
|
+
)
|
|
168
|
+
mem_limit = (
|
|
169
|
+
payload.mem_limit if payload.mem_limit is not None else _settings.default_memory
|
|
170
|
+
)
|
|
171
|
+
cpu_shares = (
|
|
172
|
+
payload.cpu_shares
|
|
173
|
+
if payload.cpu_shares is not None
|
|
174
|
+
else _settings.default_cpu_shares
|
|
175
|
+
)
|
|
176
|
+
disk_limit = (
|
|
177
|
+
payload.disk_limit if payload.disk_limit is not None else _settings.default_disk
|
|
178
|
+
)
|
|
179
|
+
network_disabled = not payload.allow_network
|
|
180
|
+
|
|
181
|
+
# Build container environment
|
|
182
|
+
container_env: dict[str, str] = {"HOME": "/tmp"}
|
|
183
|
+
if payload.environment:
|
|
184
|
+
container_env.update(payload.environment)
|
|
185
|
+
if payload.code:
|
|
186
|
+
container_env["ENCODED_CODE"] = base64.b64encode(
|
|
187
|
+
payload.code.encode("utf-8")
|
|
188
|
+
).decode("utf-8")
|
|
189
|
+
if payload.entrypoint:
|
|
190
|
+
container_env["CUSTOM_ENTRYPOINT"] = payload.entrypoint
|
|
191
|
+
|
|
192
|
+
# Package additional files as tar archive
|
|
193
|
+
if payload.files:
|
|
194
|
+
tar_buffer = io.BytesIO()
|
|
195
|
+
with tarfile.open(fileobj=tar_buffer, mode="w:gz") as tar:
|
|
196
|
+
for filename, content in payload.files.items():
|
|
197
|
+
if ".." in filename or os.path.isabs(filename):
|
|
198
|
+
raise HTTPException(
|
|
199
|
+
status_code=400,
|
|
200
|
+
detail=f"Invalid filename: {filename}",
|
|
201
|
+
)
|
|
202
|
+
tarinfo = tarfile.TarInfo(name=filename)
|
|
203
|
+
file_bytes = content.encode("utf-8")
|
|
204
|
+
tarinfo.size = len(file_bytes)
|
|
205
|
+
tar.addfile(tarinfo, io.BytesIO(file_bytes))
|
|
206
|
+
|
|
207
|
+
tar_buffer.seek(0)
|
|
208
|
+
encoded_files = base64.b64encode(tar_buffer.read()).decode("utf-8")
|
|
209
|
+
container_env["ENCODED_FILES"] = encoded_files
|
|
210
|
+
|
|
211
|
+
# Execute
|
|
212
|
+
stdout, stderr, result, metadata = await execute_code_in_container(
|
|
213
|
+
docker_client=_docker_client,
|
|
214
|
+
image=payload.image,
|
|
215
|
+
environment=container_env,
|
|
216
|
+
timeout_seconds=timeout_seconds,
|
|
217
|
+
mem_limit=mem_limit,
|
|
218
|
+
cpu_shares=cpu_shares,
|
|
219
|
+
disk_limit=disk_limit,
|
|
220
|
+
network_disabled=network_disabled,
|
|
221
|
+
prebuild_spec=payload.prebuild_spec,
|
|
222
|
+
ensure_prebuilt_fn=ensure_prebuilt_image,
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
execution_time = time.time() - start_time
|
|
226
|
+
|
|
227
|
+
response: dict[str, Any] = {
|
|
228
|
+
"stdout": stdout,
|
|
229
|
+
"stderr": stderr,
|
|
230
|
+
"exit_code": result.get("StatusCode", -1),
|
|
231
|
+
"execution_time": round(execution_time, 4),
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
if metadata is not None:
|
|
235
|
+
response["metadata"] = metadata
|
|
236
|
+
|
|
237
|
+
return response
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
@app.get("/health")
|
|
241
|
+
def health() -> dict[str, Any]:
|
|
242
|
+
"""Health check endpoint."""
|
|
243
|
+
docker_connected = False
|
|
244
|
+
if _docker_client:
|
|
245
|
+
try:
|
|
246
|
+
_docker_client.ping()
|
|
247
|
+
docker_connected = True
|
|
248
|
+
except Exception:
|
|
249
|
+
pass
|
|
250
|
+
|
|
251
|
+
return {
|
|
252
|
+
"status": "ok" if docker_connected else "degraded",
|
|
253
|
+
"docker_connected": docker_connected,
|
|
254
|
+
"auth_enabled": _settings.auth_enabled if _settings else False,
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
@app.get("/runtimes")
|
|
259
|
+
def list_runtimes() -> dict[str, Any]:
|
|
260
|
+
"""List available runtimes. Alias for /images."""
|
|
261
|
+
from quartermaster_code_runner.images import list_images
|
|
262
|
+
|
|
263
|
+
return list_images()
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
@app.get("/")
|
|
267
|
+
def read_root() -> dict[str, Any]:
|
|
268
|
+
"""Root endpoint."""
|
|
269
|
+
return {"message": "Code Runner is operational."}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
"""Configuration for code runner service.
|
|
2
|
+
|
|
3
|
+
All settings are configurable via environment variables with sensible defaults.
|
|
4
|
+
Supports .env file loading via python-dotenv.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
import os
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from dotenv import load_dotenv
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
# Image naming conventions
|
|
19
|
+
RUNTIME_IMAGE_PREFIX = "code-runner-"
|
|
20
|
+
PREBUILT_IMAGE_PREFIX = "prebuilt-"
|
|
21
|
+
DEFAULT_IMAGE = "python"
|
|
22
|
+
SUPPORTED_IMAGES = ["python", "node", "bun", "go", "deno", "rust"]
|
|
23
|
+
METADATA_FILE_PATH = "/metadata/.quartermaster_metadata.json"
|
|
24
|
+
MAX_CODE_SIZE_BYTES = 1024 * 1024 # 1MB
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def get_image_name(image: str) -> str:
|
|
28
|
+
"""Convert short image name to full Docker image name."""
|
|
29
|
+
if image.startswith(RUNTIME_IMAGE_PREFIX) or image.startswith(
|
|
30
|
+
PREBUILT_IMAGE_PREFIX
|
|
31
|
+
):
|
|
32
|
+
return image
|
|
33
|
+
return f"{RUNTIME_IMAGE_PREFIX}{image}"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def get_short_image_name(image: str) -> str:
|
|
37
|
+
"""Get short image name from full or short name."""
|
|
38
|
+
if image.startswith(RUNTIME_IMAGE_PREFIX):
|
|
39
|
+
return image[len(RUNTIME_IMAGE_PREFIX) :]
|
|
40
|
+
return image
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass
|
|
44
|
+
class ResourceLimits:
|
|
45
|
+
"""Resource limits for a single code execution."""
|
|
46
|
+
|
|
47
|
+
cpu_cores: float = 1.0
|
|
48
|
+
memory_mb: int = 512
|
|
49
|
+
disk_mb: int = 500
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass
|
|
53
|
+
class Settings:
|
|
54
|
+
"""Global service settings loaded from environment variables.
|
|
55
|
+
|
|
56
|
+
All settings have sensible defaults and can be overridden via
|
|
57
|
+
environment variables or a .env file.
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
# Server
|
|
61
|
+
host: str = "0.0.0.0"
|
|
62
|
+
port: int = 8000
|
|
63
|
+
log_level: str = "info"
|
|
64
|
+
|
|
65
|
+
# Execution defaults
|
|
66
|
+
default_timeout: int = 30
|
|
67
|
+
max_timeout: int = 300
|
|
68
|
+
default_memory: str = "256m"
|
|
69
|
+
max_memory_mb: int = 2048
|
|
70
|
+
default_cpu_shares: int = 512
|
|
71
|
+
max_cpu_cores: float = 4.0
|
|
72
|
+
default_disk: str = "512m"
|
|
73
|
+
max_disk_mb: int = 5000
|
|
74
|
+
|
|
75
|
+
# Docker
|
|
76
|
+
docker_socket: str = "/var/run/docker.sock"
|
|
77
|
+
|
|
78
|
+
# Security
|
|
79
|
+
auth_token: str | None = None
|
|
80
|
+
api_keys: list[str] = field(default_factory=list)
|
|
81
|
+
|
|
82
|
+
# Cleanup
|
|
83
|
+
cleanup_interval_hours: int = 24
|
|
84
|
+
cleanup_max_age_days: int = 7
|
|
85
|
+
|
|
86
|
+
# Runtime image build path (relative to package)
|
|
87
|
+
runtime_dir: str = ""
|
|
88
|
+
|
|
89
|
+
@classmethod
|
|
90
|
+
def from_env(cls, env_file: str | None = None) -> Settings:
|
|
91
|
+
"""Load settings from environment variables, with optional .env file.
|
|
92
|
+
|
|
93
|
+
Args:
|
|
94
|
+
env_file: Path to .env file. If None, searches current directory.
|
|
95
|
+
"""
|
|
96
|
+
if env_file:
|
|
97
|
+
load_dotenv(env_file)
|
|
98
|
+
else:
|
|
99
|
+
load_dotenv()
|
|
100
|
+
|
|
101
|
+
api_keys_str = os.getenv("CODE_RUNNER_API_KEYS", "")
|
|
102
|
+
api_keys = [k.strip() for k in api_keys_str.split(",") if k.strip()]
|
|
103
|
+
|
|
104
|
+
# Also support AUTH_TOKEN as single-token auth
|
|
105
|
+
auth_token = os.getenv("AUTH_TOKEN")
|
|
106
|
+
|
|
107
|
+
# Determine runtime directory
|
|
108
|
+
runtime_dir = os.getenv(
|
|
109
|
+
"RUNTIME_DIR",
|
|
110
|
+
str(Path(__file__).parent / "runtime"),
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
return cls(
|
|
114
|
+
host=os.getenv("HOST", "0.0.0.0"),
|
|
115
|
+
port=int(os.getenv("PORT", "8000")),
|
|
116
|
+
log_level=os.getenv("LOG_LEVEL", "info"),
|
|
117
|
+
default_timeout=int(os.getenv("DEFAULT_TIMEOUT", "30")),
|
|
118
|
+
max_timeout=int(os.getenv("MAX_TIMEOUT", "300")),
|
|
119
|
+
default_memory=os.getenv("DEFAULT_MEMORY", "256m"),
|
|
120
|
+
max_memory_mb=int(os.getenv("MAX_MEMORY_MB", "2048")),
|
|
121
|
+
default_cpu_shares=int(os.getenv("DEFAULT_CPU_SHARES", "512")),
|
|
122
|
+
max_cpu_cores=float(os.getenv("MAX_CPU_CORES", "4.0")),
|
|
123
|
+
default_disk=os.getenv("DEFAULT_DISK", "512m"),
|
|
124
|
+
max_disk_mb=int(os.getenv("MAX_DISK_MB", "5000")),
|
|
125
|
+
docker_socket=os.getenv("DOCKER_SOCKET", "/var/run/docker.sock"),
|
|
126
|
+
auth_token=auth_token,
|
|
127
|
+
api_keys=api_keys,
|
|
128
|
+
cleanup_interval_hours=int(os.getenv("CLEANUP_INTERVAL_HOURS", "24")),
|
|
129
|
+
cleanup_max_age_days=int(os.getenv("CLEANUP_MAX_AGE_DAYS", "7")),
|
|
130
|
+
runtime_dir=runtime_dir,
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
def validate(self) -> list[str]:
|
|
134
|
+
"""Validate configuration and return list of warnings/errors.
|
|
135
|
+
|
|
136
|
+
Raises:
|
|
137
|
+
ValueError: If configuration is fatally invalid.
|
|
138
|
+
"""
|
|
139
|
+
errors: list[str] = []
|
|
140
|
+
|
|
141
|
+
if self.default_timeout <= 0:
|
|
142
|
+
errors.append("DEFAULT_TIMEOUT must be positive")
|
|
143
|
+
if self.max_timeout < self.default_timeout:
|
|
144
|
+
errors.append("MAX_TIMEOUT must be >= DEFAULT_TIMEOUT")
|
|
145
|
+
if self.max_memory_mb <= 0:
|
|
146
|
+
errors.append("MAX_MEMORY_MB must be positive")
|
|
147
|
+
if self.max_cpu_cores <= 0:
|
|
148
|
+
errors.append("MAX_CPU_CORES must be positive")
|
|
149
|
+
if self.max_disk_mb <= 0:
|
|
150
|
+
errors.append("MAX_DISK_MB must be positive")
|
|
151
|
+
if self.cleanup_interval_hours <= 0:
|
|
152
|
+
errors.append("CLEANUP_INTERVAL_HOURS must be positive")
|
|
153
|
+
if self.cleanup_max_age_days < 0:
|
|
154
|
+
errors.append("CLEANUP_MAX_AGE_DAYS must be non-negative")
|
|
155
|
+
|
|
156
|
+
if not Path(self.docker_socket).exists():
|
|
157
|
+
logger.warning("Docker socket not found at %s", self.docker_socket)
|
|
158
|
+
|
|
159
|
+
if not self.api_keys and not self.auth_token:
|
|
160
|
+
logger.warning(
|
|
161
|
+
"No authentication configured. API is open to all requests. "
|
|
162
|
+
"Set CODE_RUNNER_API_KEYS or AUTH_TOKEN to secure the API."
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
if errors:
|
|
166
|
+
raise ValueError(
|
|
167
|
+
"Invalid configuration:\n" + "\n".join(f" - {e}" for e in errors)
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
return []
|
|
171
|
+
|
|
172
|
+
@property
|
|
173
|
+
def auth_enabled(self) -> bool:
|
|
174
|
+
"""Whether any authentication method is configured."""
|
|
175
|
+
return bool(self.api_keys) or bool(self.auth_token)
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""Exception types for code runner errors."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class CodeRunnerError(Exception):
|
|
5
|
+
"""Base exception for all code runner errors."""
|
|
6
|
+
|
|
7
|
+
pass
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ExecutionError(CodeRunnerError):
|
|
11
|
+
"""Raised when code execution fails."""
|
|
12
|
+
|
|
13
|
+
def __init__(
|
|
14
|
+
self,
|
|
15
|
+
message: str,
|
|
16
|
+
exit_code: int | None = None,
|
|
17
|
+
stdout: str | None = None,
|
|
18
|
+
stderr: str | None = None,
|
|
19
|
+
) -> None:
|
|
20
|
+
"""Initialize execution error.
|
|
21
|
+
|
|
22
|
+
Args:
|
|
23
|
+
message: Error message.
|
|
24
|
+
exit_code: Exit code from execution.
|
|
25
|
+
stdout: Standard output captured before error.
|
|
26
|
+
stderr: Standard error output.
|
|
27
|
+
"""
|
|
28
|
+
self.exit_code = exit_code
|
|
29
|
+
self.stdout = stdout
|
|
30
|
+
self.stderr = stderr
|
|
31
|
+
super().__init__(message)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class TimeoutError(CodeRunnerError):
|
|
35
|
+
"""Raised when code execution times out."""
|
|
36
|
+
|
|
37
|
+
def __init__(
|
|
38
|
+
self,
|
|
39
|
+
message: str,
|
|
40
|
+
duration: float | None = None,
|
|
41
|
+
stdout: str | None = None,
|
|
42
|
+
) -> None:
|
|
43
|
+
"""Initialize timeout error.
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
message: Error message.
|
|
47
|
+
duration: Duration before timeout.
|
|
48
|
+
stdout: Partial output captured before timeout.
|
|
49
|
+
"""
|
|
50
|
+
self.duration = duration
|
|
51
|
+
self.stdout = stdout
|
|
52
|
+
super().__init__(message)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class ResourceExhaustedError(CodeRunnerError):
|
|
56
|
+
"""Raised when execution exceeds resource limits."""
|
|
57
|
+
|
|
58
|
+
def __init__(
|
|
59
|
+
self,
|
|
60
|
+
message: str,
|
|
61
|
+
resource_type: str | None = None,
|
|
62
|
+
) -> None:
|
|
63
|
+
"""Initialize resource exhausted error.
|
|
64
|
+
|
|
65
|
+
Args:
|
|
66
|
+
message: Error message.
|
|
67
|
+
resource_type: Type of resource (memory, cpu, disk).
|
|
68
|
+
"""
|
|
69
|
+
self.resource_type = resource_type
|
|
70
|
+
super().__init__(message)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class DockerError(CodeRunnerError):
|
|
74
|
+
"""Raised when Docker operations fail."""
|
|
75
|
+
|
|
76
|
+
pass
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class RuntimeNotAvailableError(CodeRunnerError):
|
|
80
|
+
"""Raised when requested runtime is not available."""
|
|
81
|
+
|
|
82
|
+
pass
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class InvalidLanguageError(CodeRunnerError):
|
|
86
|
+
"""Raised when language is not supported."""
|
|
87
|
+
|
|
88
|
+
pass
|