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.
Files changed (40) hide show
  1. quartermaster_code_runner/__init__.py +38 -0
  2. quartermaster_code_runner/app.py +269 -0
  3. quartermaster_code_runner/config.py +175 -0
  4. quartermaster_code_runner/errors.py +88 -0
  5. quartermaster_code_runner/execution.py +231 -0
  6. quartermaster_code_runner/images.py +397 -0
  7. quartermaster_code_runner/runtime/bun/Dockerfile +22 -0
  8. quartermaster_code_runner/runtime/bun/completions.json +34 -0
  9. quartermaster_code_runner/runtime/bun/entrypoint.sh +32 -0
  10. quartermaster_code_runner/runtime/bun/sdk.ts +87 -0
  11. quartermaster_code_runner/runtime/deno/Dockerfile +22 -0
  12. quartermaster_code_runner/runtime/deno/completions.json +34 -0
  13. quartermaster_code_runner/runtime/deno/entrypoint.sh +32 -0
  14. quartermaster_code_runner/runtime/deno/sdk.ts +88 -0
  15. quartermaster_code_runner/runtime/go/Dockerfile +18 -0
  16. quartermaster_code_runner/runtime/go/completions.json +22 -0
  17. quartermaster_code_runner/runtime/go/entrypoint.sh +50 -0
  18. quartermaster_code_runner/runtime/go/sdk.go +101 -0
  19. quartermaster_code_runner/runtime/node/Dockerfile +31 -0
  20. quartermaster_code_runner/runtime/node/completions.json +34 -0
  21. quartermaster_code_runner/runtime/node/entrypoint.sh +33 -0
  22. quartermaster_code_runner/runtime/node/mcp-client.js +274 -0
  23. quartermaster_code_runner/runtime/node/sdk.js +109 -0
  24. quartermaster_code_runner/runtime/python/Dockerfile +42 -0
  25. quartermaster_code_runner/runtime/python/completions.json +34 -0
  26. quartermaster_code_runner/runtime/python/entrypoint.sh +30 -0
  27. quartermaster_code_runner/runtime/python/mcp-client.py +276 -0
  28. quartermaster_code_runner/runtime/python/sdk.py +103 -0
  29. quartermaster_code_runner/runtime/rust/Cargo.toml.default +9 -0
  30. quartermaster_code_runner/runtime/rust/Dockerfile +27 -0
  31. quartermaster_code_runner/runtime/rust/completions.json +34 -0
  32. quartermaster_code_runner/runtime/rust/entrypoint.sh +38 -0
  33. quartermaster_code_runner/runtime/rust/sdk/Cargo.toml +9 -0
  34. quartermaster_code_runner/runtime/rust/sdk/src/lib.rs +149 -0
  35. quartermaster_code_runner/schemas.py +154 -0
  36. quartermaster_code_runner/security.py +81 -0
  37. quartermaster_code_runner-0.0.1.dist-info/METADATA +322 -0
  38. quartermaster_code_runner-0.0.1.dist-info/RECORD +40 -0
  39. quartermaster_code_runner-0.0.1.dist-info/WHEEL +4 -0
  40. 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