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,231 @@
|
|
|
1
|
+
"""Docker container execution logic.
|
|
2
|
+
|
|
3
|
+
Handles creating, running, and cleaning up Docker containers
|
|
4
|
+
for sandboxed code execution.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
import contextlib
|
|
11
|
+
import io
|
|
12
|
+
import json
|
|
13
|
+
import logging
|
|
14
|
+
import tarfile
|
|
15
|
+
import time
|
|
16
|
+
import uuid
|
|
17
|
+
from typing import Any, Callable, Optional
|
|
18
|
+
|
|
19
|
+
import docker
|
|
20
|
+
import docker.models.containers
|
|
21
|
+
from docker.errors import ContainerError, ImageNotFound
|
|
22
|
+
from fastapi import HTTPException
|
|
23
|
+
|
|
24
|
+
from quartermaster_code_runner.config import (
|
|
25
|
+
METADATA_FILE_PATH,
|
|
26
|
+
PREBUILT_IMAGE_PREFIX,
|
|
27
|
+
get_image_name,
|
|
28
|
+
)
|
|
29
|
+
from quartermaster_code_runner.schemas import PrebuildSpec
|
|
30
|
+
|
|
31
|
+
logger = logging.getLogger(__name__)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def get_docker_client(timeout: int = 600) -> docker.DockerClient:
|
|
35
|
+
"""Create a Docker client from environment."""
|
|
36
|
+
return docker.from_env(timeout=timeout)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
async def execute_code_in_container(
|
|
40
|
+
docker_client: docker.DockerClient,
|
|
41
|
+
image: str,
|
|
42
|
+
environment: dict[str, str],
|
|
43
|
+
timeout_seconds: int,
|
|
44
|
+
mem_limit: str,
|
|
45
|
+
cpu_shares: int,
|
|
46
|
+
disk_limit: str,
|
|
47
|
+
network_disabled: bool,
|
|
48
|
+
prebuild_spec: Optional[PrebuildSpec] = None,
|
|
49
|
+
ensure_prebuilt_fn: Optional[Callable[..., Any]] = None,
|
|
50
|
+
) -> tuple[str, str, dict[str, Any], Optional[dict[str, Any]]]:
|
|
51
|
+
"""Execute code in a Docker container.
|
|
52
|
+
|
|
53
|
+
Args:
|
|
54
|
+
docker_client: Docker client instance.
|
|
55
|
+
image: Short or full image name.
|
|
56
|
+
environment: Environment variables for the container.
|
|
57
|
+
timeout_seconds: Hard timeout in seconds.
|
|
58
|
+
mem_limit: Memory limit string (e.g., "256m").
|
|
59
|
+
cpu_shares: CPU shares allocation.
|
|
60
|
+
disk_limit: Disk limit for tmpfs mounts.
|
|
61
|
+
network_disabled: Whether to disable networking.
|
|
62
|
+
prebuild_spec: Optional prebuild specification.
|
|
63
|
+
ensure_prebuilt_fn: Function to ensure prebuilt image exists.
|
|
64
|
+
|
|
65
|
+
Returns:
|
|
66
|
+
Tuple of (stdout, stderr, result_dict, metadata).
|
|
67
|
+
"""
|
|
68
|
+
container = None
|
|
69
|
+
stdout = ""
|
|
70
|
+
stderr = ""
|
|
71
|
+
result: dict[str, Any] = {}
|
|
72
|
+
metadata = None
|
|
73
|
+
full_image_name = get_image_name(image)
|
|
74
|
+
|
|
75
|
+
# Handle prebuilt image rebuilds
|
|
76
|
+
if image.startswith(PREBUILT_IMAGE_PREFIX) and prebuild_spec and ensure_prebuilt_fn:
|
|
77
|
+
try:
|
|
78
|
+
await asyncio.to_thread(
|
|
79
|
+
ensure_prebuilt_fn,
|
|
80
|
+
image,
|
|
81
|
+
prebuild_spec,
|
|
82
|
+
)
|
|
83
|
+
except Exception as e:
|
|
84
|
+
raise HTTPException(
|
|
85
|
+
status_code=503,
|
|
86
|
+
detail=f"Prebuilt image '{full_image_name}' could not be rebuilt: {e}",
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
# Create a temporary volume for metadata exchange
|
|
90
|
+
volume_name = f"quartermaster_metadata_{uuid.uuid4().hex[:12]}"
|
|
91
|
+
volume = None
|
|
92
|
+
|
|
93
|
+
try:
|
|
94
|
+
volume = await asyncio.to_thread(
|
|
95
|
+
docker_client.volumes.create,
|
|
96
|
+
name=volume_name,
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
volumes_dict = {volume_name: {"bind": "/metadata", "mode": "rw"}}
|
|
100
|
+
|
|
101
|
+
t_run = time.monotonic()
|
|
102
|
+
container = await asyncio.to_thread(
|
|
103
|
+
docker_client.containers.run,
|
|
104
|
+
image=full_image_name,
|
|
105
|
+
environment=environment,
|
|
106
|
+
working_dir="/tmp",
|
|
107
|
+
mem_limit=mem_limit,
|
|
108
|
+
cpu_shares=cpu_shares,
|
|
109
|
+
detach=True,
|
|
110
|
+
network_disabled=network_disabled,
|
|
111
|
+
read_only=True,
|
|
112
|
+
tmpfs={
|
|
113
|
+
"/tmp": f"size={disk_limit},exec",
|
|
114
|
+
"/workspace": f"size={disk_limit},exec",
|
|
115
|
+
},
|
|
116
|
+
volumes=volumes_dict,
|
|
117
|
+
)
|
|
118
|
+
logger.info(
|
|
119
|
+
"[exec] container started in %.2fs image=%s",
|
|
120
|
+
time.monotonic() - t_run,
|
|
121
|
+
full_image_name,
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
# Wait for container to finish
|
|
125
|
+
t_wait = time.monotonic()
|
|
126
|
+
wait_response = await asyncio.to_thread(container.wait, timeout=timeout_seconds)
|
|
127
|
+
logger.info(
|
|
128
|
+
"[exec] container finished in %.2fs exit=%s",
|
|
129
|
+
time.monotonic() - t_wait,
|
|
130
|
+
wait_response.get("StatusCode"),
|
|
131
|
+
)
|
|
132
|
+
result = {"StatusCode": wait_response["StatusCode"]}
|
|
133
|
+
|
|
134
|
+
# Collect output
|
|
135
|
+
stdout_bytes = await asyncio.to_thread(
|
|
136
|
+
container.logs, stdout=True, stderr=False
|
|
137
|
+
)
|
|
138
|
+
stdout = stdout_bytes.decode("utf-8")
|
|
139
|
+
stderr_bytes = await asyncio.to_thread(
|
|
140
|
+
container.logs, stdout=False, stderr=True
|
|
141
|
+
)
|
|
142
|
+
stderr = stderr_bytes.decode("utf-8")
|
|
143
|
+
|
|
144
|
+
# Read metadata from volume
|
|
145
|
+
metadata = await _read_metadata(container)
|
|
146
|
+
|
|
147
|
+
except ContainerError as e:
|
|
148
|
+
if e.container:
|
|
149
|
+
stderr_bytes = await asyncio.to_thread(
|
|
150
|
+
e.container.logs, stdout=False, stderr=True
|
|
151
|
+
)
|
|
152
|
+
stderr = stderr_bytes.decode("utf-8")
|
|
153
|
+
result = {"StatusCode": e.exit_status}
|
|
154
|
+
else:
|
|
155
|
+
stderr = str(e)
|
|
156
|
+
except ImageNotFound:
|
|
157
|
+
raise HTTPException(
|
|
158
|
+
status_code=404,
|
|
159
|
+
detail=f"Image '{full_image_name}' not found. "
|
|
160
|
+
f"Build it with: make build-runtime lang={image}",
|
|
161
|
+
)
|
|
162
|
+
except Exception as e:
|
|
163
|
+
if container:
|
|
164
|
+
with contextlib.suppress(Exception):
|
|
165
|
+
stdout_bytes = await asyncio.to_thread(
|
|
166
|
+
container.logs, stdout=True, stderr=False
|
|
167
|
+
)
|
|
168
|
+
stdout = stdout_bytes.decode("utf-8")
|
|
169
|
+
with contextlib.suppress(Exception):
|
|
170
|
+
stderr_bytes = await asyncio.to_thread(
|
|
171
|
+
container.logs, stdout=False, stderr=True
|
|
172
|
+
)
|
|
173
|
+
stderr = stderr_bytes.decode("utf-8")
|
|
174
|
+
with contextlib.suppress(Exception):
|
|
175
|
+
await asyncio.to_thread(container.stop, timeout=2)
|
|
176
|
+
result = {"StatusCode": -1}
|
|
177
|
+
else:
|
|
178
|
+
raise HTTPException(
|
|
179
|
+
status_code=500,
|
|
180
|
+
detail=f"An unexpected error occurred: {e}",
|
|
181
|
+
)
|
|
182
|
+
finally:
|
|
183
|
+
if container:
|
|
184
|
+
with contextlib.suppress(Exception):
|
|
185
|
+
await asyncio.to_thread(container.remove, v=True)
|
|
186
|
+
if volume:
|
|
187
|
+
with contextlib.suppress(Exception):
|
|
188
|
+
await asyncio.to_thread(volume.remove)
|
|
189
|
+
|
|
190
|
+
return stdout, stderr, result, metadata
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
async def _read_metadata(
|
|
194
|
+
container: docker.models.containers.Container,
|
|
195
|
+
) -> Optional[dict[str, Any]]:
|
|
196
|
+
"""Read metadata JSON from the container's metadata volume.
|
|
197
|
+
|
|
198
|
+
Returns None if no metadata file exists.
|
|
199
|
+
"""
|
|
200
|
+
try:
|
|
201
|
+
bits, _stat = await asyncio.to_thread(container.get_archive, METADATA_FILE_PATH)
|
|
202
|
+
tar_stream = io.BytesIO()
|
|
203
|
+
for chunk in bits:
|
|
204
|
+
tar_stream.write(chunk)
|
|
205
|
+
tar_stream.seek(0)
|
|
206
|
+
with tarfile.open(fileobj=tar_stream, mode="r") as tar:
|
|
207
|
+
member = tar.getmembers()[0]
|
|
208
|
+
f = tar.extractfile(member)
|
|
209
|
+
if f:
|
|
210
|
+
result: dict[str, Any] = json.loads(f.read().decode("utf-8"))
|
|
211
|
+
return result
|
|
212
|
+
except docker.errors.NotFound:
|
|
213
|
+
pass
|
|
214
|
+
except Exception as e:
|
|
215
|
+
logger.warning(
|
|
216
|
+
"[exec] metadata read failed: %s: %s",
|
|
217
|
+
type(e).__name__,
|
|
218
|
+
e,
|
|
219
|
+
)
|
|
220
|
+
return None
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def cleanup_orphaned_containers(docker_client: docker.DockerClient) -> None:
|
|
224
|
+
"""Remove leftover metadata volumes from previous runs.
|
|
225
|
+
|
|
226
|
+
Called on startup to prevent stale resources.
|
|
227
|
+
"""
|
|
228
|
+
for v in docker_client.volumes.list(filters={"name": "quartermaster_metadata_"}):
|
|
229
|
+
with contextlib.suppress(Exception):
|
|
230
|
+
v.remove(force=True)
|
|
231
|
+
logger.info("Removed orphaned metadata volume %s", v.name)
|
|
@@ -0,0 +1,397 @@
|
|
|
1
|
+
"""Docker image management for runtime and prebuilt images.
|
|
2
|
+
|
|
3
|
+
Handles building, listing, and cleaning up runtime and prebuilt Docker images.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import contextlib
|
|
9
|
+
import json
|
|
10
|
+
import logging
|
|
11
|
+
import os
|
|
12
|
+
import tempfile
|
|
13
|
+
from datetime import datetime, timezone
|
|
14
|
+
from typing import Any, Optional
|
|
15
|
+
|
|
16
|
+
import docker
|
|
17
|
+
from docker.errors import BuildError, ImageNotFound
|
|
18
|
+
from fastapi import APIRouter, HTTPException, Query
|
|
19
|
+
|
|
20
|
+
from quartermaster_code_runner.config import (
|
|
21
|
+
DEFAULT_IMAGE,
|
|
22
|
+
PREBUILT_IMAGE_PREFIX,
|
|
23
|
+
SUPPORTED_IMAGES,
|
|
24
|
+
get_image_name,
|
|
25
|
+
get_short_image_name,
|
|
26
|
+
)
|
|
27
|
+
from quartermaster_code_runner.schemas import PrebuildRequest
|
|
28
|
+
|
|
29
|
+
logger = logging.getLogger(__name__)
|
|
30
|
+
|
|
31
|
+
router = APIRouter()
|
|
32
|
+
|
|
33
|
+
# Module-level docker client, set during app startup
|
|
34
|
+
_docker_client: Optional[docker.DockerClient] = None
|
|
35
|
+
_runtime_dir: str = ""
|
|
36
|
+
_verify_auth: Optional[object] = None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def configure_images(
|
|
40
|
+
docker_client: docker.DockerClient,
|
|
41
|
+
runtime_dir: str,
|
|
42
|
+
verify_auth_dep: object,
|
|
43
|
+
) -> None:
|
|
44
|
+
"""Configure image management module.
|
|
45
|
+
|
|
46
|
+
Args:
|
|
47
|
+
docker_client: Docker client instance.
|
|
48
|
+
runtime_dir: Path to runtime directory containing Dockerfiles.
|
|
49
|
+
verify_auth_dep: FastAPI dependency for auth verification.
|
|
50
|
+
"""
|
|
51
|
+
global _docker_client, _runtime_dir, _verify_auth
|
|
52
|
+
_docker_client = docker_client
|
|
53
|
+
_runtime_dir = runtime_dir
|
|
54
|
+
_verify_auth = verify_auth_dep
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _get_client() -> docker.DockerClient:
|
|
58
|
+
if _docker_client is None:
|
|
59
|
+
raise RuntimeError(
|
|
60
|
+
"Image management not configured. Call configure_images() first."
|
|
61
|
+
)
|
|
62
|
+
return _docker_client
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _safe_list_images() -> list[Any]:
|
|
66
|
+
"""List Docker images, handling race conditions."""
|
|
67
|
+
client = _get_client()
|
|
68
|
+
images = []
|
|
69
|
+
for img_summary in client.api.images():
|
|
70
|
+
img_id = img_summary.get("Id", "")
|
|
71
|
+
try:
|
|
72
|
+
images.append(client.images.get(img_id))
|
|
73
|
+
except ImageNotFound:
|
|
74
|
+
continue
|
|
75
|
+
return images
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def cleanup_prebuilds(max_age_days: int = 7) -> list[str]:
|
|
79
|
+
"""Remove prebuilt images older than max_age_days."""
|
|
80
|
+
client = _get_client()
|
|
81
|
+
now = datetime.now(timezone.utc)
|
|
82
|
+
removed: list[str] = []
|
|
83
|
+
targets: list[str] = []
|
|
84
|
+
|
|
85
|
+
for img in _safe_list_images():
|
|
86
|
+
for tag in img.tags or []:
|
|
87
|
+
tag_name = tag.split(":")[0]
|
|
88
|
+
if not tag_name.startswith(PREBUILT_IMAGE_PREFIX):
|
|
89
|
+
continue
|
|
90
|
+
created_str = img.attrs.get("Created", "")
|
|
91
|
+
if not created_str:
|
|
92
|
+
continue
|
|
93
|
+
created = datetime.fromisoformat(created_str.replace("Z", "+00:00"))
|
|
94
|
+
age_days = (now - created).total_seconds() / 86400
|
|
95
|
+
if age_days >= max_age_days:
|
|
96
|
+
targets.append(tag_name)
|
|
97
|
+
|
|
98
|
+
for tag_name in targets:
|
|
99
|
+
with contextlib.suppress(Exception):
|
|
100
|
+
client.images.remove(image=tag_name, force=True)
|
|
101
|
+
removed.append(tag_name)
|
|
102
|
+
|
|
103
|
+
return removed
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def ensure_runtime_image(short_name: str) -> bool:
|
|
107
|
+
"""Build a single runtime image if it's missing. Returns True if rebuilt."""
|
|
108
|
+
client = _get_client()
|
|
109
|
+
full_name = get_image_name(short_name)
|
|
110
|
+
try:
|
|
111
|
+
client.images.get(full_name)
|
|
112
|
+
return False
|
|
113
|
+
except ImageNotFound:
|
|
114
|
+
pass
|
|
115
|
+
|
|
116
|
+
build_path = os.path.join(_runtime_dir, short_name)
|
|
117
|
+
if not os.path.exists(build_path):
|
|
118
|
+
return False
|
|
119
|
+
|
|
120
|
+
logger.info("Auto-building missing runtime image '%s'...", full_name)
|
|
121
|
+
client.images.build(
|
|
122
|
+
path=build_path,
|
|
123
|
+
dockerfile="Dockerfile",
|
|
124
|
+
tag=full_name,
|
|
125
|
+
rm=True,
|
|
126
|
+
)
|
|
127
|
+
logger.info("Runtime image '%s' built successfully.", full_name)
|
|
128
|
+
return True
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def ensure_prebuilt_image(image_name: str, spec: PrebuildRequest | object) -> bool:
|
|
132
|
+
"""Rebuild a prebuilt image on the fly if it's missing."""
|
|
133
|
+
client = _get_client()
|
|
134
|
+
try:
|
|
135
|
+
client.images.get(image_name)
|
|
136
|
+
return False
|
|
137
|
+
except ImageNotFound:
|
|
138
|
+
pass
|
|
139
|
+
|
|
140
|
+
base_image = getattr(spec, "base_image", "python")
|
|
141
|
+
setup_script = getattr(spec, "setup_script", "")
|
|
142
|
+
|
|
143
|
+
tag = image_name.removeprefix(PREBUILT_IMAGE_PREFIX)
|
|
144
|
+
logger.info("Auto-rebuilding missing prebuilt image '%s'...", image_name)
|
|
145
|
+
ensure_runtime_image(get_short_image_name(base_image))
|
|
146
|
+
_build_prebuilt_image(tag, base_image, setup_script)
|
|
147
|
+
logger.info("Prebuilt image '%s' rebuilt successfully.", image_name)
|
|
148
|
+
return True
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def build_runtime_images() -> None:
|
|
152
|
+
"""Build all runtime Docker images."""
|
|
153
|
+
client = _get_client()
|
|
154
|
+
for image_name in SUPPORTED_IMAGES:
|
|
155
|
+
full_name = get_image_name(image_name)
|
|
156
|
+
build_path = os.path.join(_runtime_dir, image_name)
|
|
157
|
+
|
|
158
|
+
if not os.path.exists(build_path):
|
|
159
|
+
logger.warning(
|
|
160
|
+
"Skipping '%s' - directory not found at %s",
|
|
161
|
+
image_name,
|
|
162
|
+
build_path,
|
|
163
|
+
)
|
|
164
|
+
continue
|
|
165
|
+
|
|
166
|
+
try:
|
|
167
|
+
client.images.remove(image=full_name, force=True)
|
|
168
|
+
logger.info("Removed old runtime image '%s'.", full_name)
|
|
169
|
+
except ImageNotFound:
|
|
170
|
+
pass
|
|
171
|
+
|
|
172
|
+
logger.info("Building runtime image '%s'...", full_name)
|
|
173
|
+
try:
|
|
174
|
+
client.images.build(
|
|
175
|
+
path=build_path,
|
|
176
|
+
dockerfile="Dockerfile",
|
|
177
|
+
tag=full_name,
|
|
178
|
+
rm=True,
|
|
179
|
+
)
|
|
180
|
+
logger.info("Runtime image '%s' built successfully.", full_name)
|
|
181
|
+
except BuildError as e:
|
|
182
|
+
logger.error("Failed to build runtime image '%s': %s", full_name, e)
|
|
183
|
+
raise
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
# =============================================================================
|
|
187
|
+
# Prebuilt image helpers
|
|
188
|
+
# =============================================================================
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _get_prebuilt_image_tag(tag: str) -> str:
|
|
192
|
+
if tag.startswith(PREBUILT_IMAGE_PREFIX):
|
|
193
|
+
return tag
|
|
194
|
+
return f"{PREBUILT_IMAGE_PREFIX}{tag}"
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _get_image_size_bytes(image_name: str) -> int:
|
|
198
|
+
client = _get_client()
|
|
199
|
+
try:
|
|
200
|
+
img = client.images.get(image_name)
|
|
201
|
+
return img.attrs.get("Size", 0) # type: ignore[no-any-return]
|
|
202
|
+
except ImageNotFound:
|
|
203
|
+
return 0
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _build_prebuilt_image(
|
|
207
|
+
tag: str, base_image: str, setup_script: str
|
|
208
|
+
) -> dict[str, Any]:
|
|
209
|
+
client = _get_client()
|
|
210
|
+
full_tag = _get_prebuilt_image_tag(tag)
|
|
211
|
+
base_full = get_image_name(base_image)
|
|
212
|
+
|
|
213
|
+
dockerfile_content = (
|
|
214
|
+
f"FROM {base_full}\n"
|
|
215
|
+
f"COPY setup.sh /tmp/setup.sh\n"
|
|
216
|
+
f"RUN sh /tmp/setup.sh && rm /tmp/setup.sh\n"
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
with contextlib.suppress(ImageNotFound):
|
|
220
|
+
client.images.remove(image=full_tag, force=True)
|
|
221
|
+
|
|
222
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
223
|
+
dockerfile_path = os.path.join(tmpdir, "Dockerfile")
|
|
224
|
+
with open(dockerfile_path, "w") as f:
|
|
225
|
+
f.write(dockerfile_content)
|
|
226
|
+
|
|
227
|
+
setup_path = os.path.join(tmpdir, "setup.sh")
|
|
228
|
+
with open(setup_path, "w") as f:
|
|
229
|
+
f.write(setup_script)
|
|
230
|
+
|
|
231
|
+
client.images.build(
|
|
232
|
+
path=tmpdir,
|
|
233
|
+
dockerfile="Dockerfile",
|
|
234
|
+
tag=full_tag,
|
|
235
|
+
rm=True,
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
size_bytes = _get_image_size_bytes(full_tag)
|
|
239
|
+
base_size = _get_image_size_bytes(base_full)
|
|
240
|
+
layer_size = max(size_bytes - base_size, 0)
|
|
241
|
+
|
|
242
|
+
return {
|
|
243
|
+
"tag": full_tag,
|
|
244
|
+
"base_image": base_full,
|
|
245
|
+
"size_bytes": size_bytes,
|
|
246
|
+
"layer_size_bytes": layer_size,
|
|
247
|
+
"status": "ready",
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
# =============================================================================
|
|
252
|
+
# API Endpoints
|
|
253
|
+
# =============================================================================
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
@router.get("/images")
|
|
257
|
+
def list_images() -> dict[str, Any]:
|
|
258
|
+
"""List available runtime images with metadata from Docker labels."""
|
|
259
|
+
client = _get_client()
|
|
260
|
+
images = []
|
|
261
|
+
|
|
262
|
+
for short_name in SUPPORTED_IMAGES:
|
|
263
|
+
full_name = get_image_name(short_name)
|
|
264
|
+
image_data: dict[str, Any] = {
|
|
265
|
+
"id": full_name,
|
|
266
|
+
"name": short_name.capitalize(),
|
|
267
|
+
"description": f"{short_name.capitalize()} runtime",
|
|
268
|
+
"default_entrypoint": f"{short_name} main.py",
|
|
269
|
+
"file_extension": ".py",
|
|
270
|
+
"main_file": "main.py",
|
|
271
|
+
"completions": [],
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
try:
|
|
275
|
+
image = client.images.get(full_name)
|
|
276
|
+
labels = image.labels or {}
|
|
277
|
+
for label_key, data_key in [
|
|
278
|
+
("qm.name", "name"),
|
|
279
|
+
("qm.description", "description"),
|
|
280
|
+
("qm.default_entrypoint", "default_entrypoint"),
|
|
281
|
+
("qm.file_extension", "file_extension"),
|
|
282
|
+
("qm.main_file", "main_file"),
|
|
283
|
+
]:
|
|
284
|
+
if label_key in labels:
|
|
285
|
+
image_data[data_key] = labels[label_key]
|
|
286
|
+
except ImageNotFound:
|
|
287
|
+
pass
|
|
288
|
+
|
|
289
|
+
completions_path = os.path.join(_runtime_dir, short_name, "completions.json")
|
|
290
|
+
if os.path.exists(completions_path):
|
|
291
|
+
with open(completions_path) as f:
|
|
292
|
+
image_data["completions"] = json.load(f)
|
|
293
|
+
|
|
294
|
+
images.append(image_data)
|
|
295
|
+
|
|
296
|
+
return {
|
|
297
|
+
"images": images,
|
|
298
|
+
"default": get_image_name(DEFAULT_IMAGE),
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
@router.post("/prebuild")
|
|
303
|
+
async def prebuild_image(payload: PrebuildRequest) -> dict[str, Any]:
|
|
304
|
+
"""Build a prebuilt Docker image extending a base runtime."""
|
|
305
|
+
import asyncio
|
|
306
|
+
|
|
307
|
+
try:
|
|
308
|
+
result = await asyncio.to_thread(
|
|
309
|
+
_build_prebuilt_image,
|
|
310
|
+
payload.tag,
|
|
311
|
+
payload.base_image,
|
|
312
|
+
payload.setup_script,
|
|
313
|
+
)
|
|
314
|
+
return result
|
|
315
|
+
except BuildError as e:
|
|
316
|
+
build_log = ""
|
|
317
|
+
if hasattr(e, "build_log"):
|
|
318
|
+
for chunk in e.build_log:
|
|
319
|
+
if "stream" in chunk:
|
|
320
|
+
build_log += chunk["stream"]
|
|
321
|
+
elif "error" in chunk:
|
|
322
|
+
build_log += chunk["error"]
|
|
323
|
+
raise HTTPException(
|
|
324
|
+
status_code=400,
|
|
325
|
+
detail=f"Build failed: {build_log or str(e)}",
|
|
326
|
+
)
|
|
327
|
+
except Exception as e:
|
|
328
|
+
raise HTTPException(
|
|
329
|
+
status_code=500,
|
|
330
|
+
detail=f"Prebuild failed: {e}",
|
|
331
|
+
)
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
@router.get("/prebuilds")
|
|
335
|
+
def list_prebuilds() -> dict[str, Any]:
|
|
336
|
+
"""List all prebuilt images with their sizes."""
|
|
337
|
+
prebuilds: list[dict[str, Any]] = []
|
|
338
|
+
for img in _safe_list_images():
|
|
339
|
+
for tag in img.tags or []:
|
|
340
|
+
tag_name = tag.split(":")[0]
|
|
341
|
+
if tag_name.startswith(PREBUILT_IMAGE_PREFIX):
|
|
342
|
+
labels = img.labels or {}
|
|
343
|
+
prebuilds.append(
|
|
344
|
+
{
|
|
345
|
+
"tag": tag_name,
|
|
346
|
+
"size_bytes": img.attrs.get("Size", 0),
|
|
347
|
+
"created": img.attrs.get("Created", ""),
|
|
348
|
+
"labels": labels,
|
|
349
|
+
}
|
|
350
|
+
)
|
|
351
|
+
return {"prebuilds": prebuilds}
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
@router.get("/prebuilds/{tag}")
|
|
355
|
+
def get_prebuild(tag: str) -> dict[str, Any]:
|
|
356
|
+
"""Get details of a specific prebuilt image."""
|
|
357
|
+
client = _get_client()
|
|
358
|
+
full_tag = _get_prebuilt_image_tag(tag)
|
|
359
|
+
try:
|
|
360
|
+
img = client.images.get(full_tag)
|
|
361
|
+
return {
|
|
362
|
+
"tag": full_tag,
|
|
363
|
+
"size_bytes": img.attrs.get("Size", 0),
|
|
364
|
+
"created": img.attrs.get("Created", ""),
|
|
365
|
+
"labels": img.labels or {},
|
|
366
|
+
}
|
|
367
|
+
except ImageNotFound:
|
|
368
|
+
raise HTTPException(
|
|
369
|
+
status_code=404,
|
|
370
|
+
detail=f"Prebuilt image '{full_tag}' not found.",
|
|
371
|
+
)
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
@router.delete("/prebuilds/{tag}")
|
|
375
|
+
def delete_prebuild(tag: str) -> dict[str, Any]:
|
|
376
|
+
"""Delete a prebuilt image."""
|
|
377
|
+
client = _get_client()
|
|
378
|
+
full_tag = _get_prebuilt_image_tag(tag)
|
|
379
|
+
try:
|
|
380
|
+
client.images.remove(image=full_tag, force=True)
|
|
381
|
+
return {"status": "deleted", "tag": full_tag}
|
|
382
|
+
except ImageNotFound:
|
|
383
|
+
raise HTTPException(
|
|
384
|
+
status_code=404,
|
|
385
|
+
detail=f"Prebuilt image '{full_tag}' not found.",
|
|
386
|
+
)
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
@router.post("/prebuilds/cleanup")
|
|
390
|
+
async def cleanup_prebuilt_images(
|
|
391
|
+
max_age_days: int = Query(default=7, ge=0),
|
|
392
|
+
) -> dict[str, Any]:
|
|
393
|
+
"""Remove prebuilt images not used in max_age_days."""
|
|
394
|
+
import asyncio
|
|
395
|
+
|
|
396
|
+
removed = await asyncio.to_thread(cleanup_prebuilds, max_age_days)
|
|
397
|
+
return {"removed": removed, "count": len(removed)}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
FROM oven/bun:1-slim
|
|
2
|
+
|
|
3
|
+
LABEL qm.name="Bun"
|
|
4
|
+
LABEL qm.description="Bun runtime with TypeScript support"
|
|
5
|
+
LABEL qm.default_entrypoint="bun main.ts"
|
|
6
|
+
LABEL qm.file_extension=".ts"
|
|
7
|
+
LABEL qm.main_file="main.ts"
|
|
8
|
+
|
|
9
|
+
WORKDIR /app
|
|
10
|
+
|
|
11
|
+
RUN apt-get update && \
|
|
12
|
+
apt-get install -y tar && \
|
|
13
|
+
rm -rf /var/lib/apt/lists/* && \
|
|
14
|
+
useradd --uid 1001 --no-create-home --shell /bin/sh runner && \
|
|
15
|
+
mkdir -p /home/runner/.bun && \
|
|
16
|
+
chown -R runner:runner /home/runner
|
|
17
|
+
|
|
18
|
+
COPY entrypoint.sh .
|
|
19
|
+
COPY sdk.ts .
|
|
20
|
+
RUN chmod +x entrypoint.sh
|
|
21
|
+
|
|
22
|
+
ENTRYPOINT ["/app/entrypoint.sh"]
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
[
|
|
2
|
+
{
|
|
3
|
+
"caption": "import { setMetadata } from './sdk'",
|
|
4
|
+
"value": "import { setMetadata } from './sdk';",
|
|
5
|
+
"meta": "import",
|
|
6
|
+
"score": 1000
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
"caption": "import { getMetadata } from './sdk'",
|
|
10
|
+
"value": "import { getMetadata } from './sdk';",
|
|
11
|
+
"meta": "import",
|
|
12
|
+
"score": 1000
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
"caption": "import { setMetadata, getMetadata } from './sdk'",
|
|
16
|
+
"value": "import { setMetadata, getMetadata } from './sdk';",
|
|
17
|
+
"meta": "import",
|
|
18
|
+
"score": 1000
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
"caption": "setMetadata",
|
|
22
|
+
"snippet": "setMetadata($1)",
|
|
23
|
+
"meta": "sdk",
|
|
24
|
+
"score": 1000,
|
|
25
|
+
"docText": "Set structured result metadata to be returned to the backend. Accepts any JSON-serializable data."
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
"caption": "getMetadata",
|
|
29
|
+
"snippet": "getMetadata()",
|
|
30
|
+
"meta": "sdk",
|
|
31
|
+
"score": 1000,
|
|
32
|
+
"docText": "Get previously set metadata. Returns null if not set."
|
|
33
|
+
}
|
|
34
|
+
]
|