pactown 0.1.4__py3-none-any.whl → 0.1.47__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.
- pactown/__init__.py +178 -4
- pactown/cli.py +539 -37
- pactown/config.py +12 -11
- pactown/deploy/__init__.py +17 -3
- pactown/deploy/base.py +35 -33
- pactown/deploy/compose.py +59 -58
- pactown/deploy/docker.py +40 -41
- pactown/deploy/kubernetes.py +43 -42
- pactown/deploy/podman.py +55 -56
- pactown/deploy/quadlet.py +1021 -0
- pactown/deploy/quadlet_api.py +533 -0
- pactown/deploy/quadlet_shell.py +557 -0
- pactown/events.py +1066 -0
- pactown/fast_start.py +514 -0
- pactown/generator.py +31 -30
- pactown/llm.py +450 -0
- pactown/markpact_blocks.py +50 -0
- pactown/network.py +59 -38
- pactown/orchestrator.py +90 -93
- pactown/parallel.py +40 -40
- pactown/platform.py +146 -0
- pactown/registry/__init__.py +1 -1
- pactown/registry/client.py +45 -46
- pactown/registry/models.py +25 -25
- pactown/registry/server.py +24 -24
- pactown/resolver.py +30 -30
- pactown/runner_api.py +458 -0
- pactown/sandbox_manager.py +480 -79
- pactown/security.py +682 -0
- pactown/service_runner.py +1201 -0
- pactown/user_isolation.py +458 -0
- {pactown-0.1.4.dist-info → pactown-0.1.47.dist-info}/METADATA +65 -9
- pactown-0.1.47.dist-info/RECORD +36 -0
- pactown-0.1.47.dist-info/entry_points.txt +5 -0
- pactown-0.1.4.dist-info/RECORD +0 -24
- pactown-0.1.4.dist-info/entry_points.txt +0 -3
- {pactown-0.1.4.dist-info → pactown-0.1.47.dist-info}/WHEEL +0 -0
- {pactown-0.1.4.dist-info → pactown-0.1.47.dist-info}/licenses/LICENSE +0 -0
pactown/runner_api.py
ADDED
|
@@ -0,0 +1,458 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import os
|
|
5
|
+
import re
|
|
6
|
+
from dataclasses import asdict
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any, Dict, List, Optional
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
11
|
+
from fastapi import Depends, FastAPI, Header, HTTPException
|
|
12
|
+
from pydantic import BaseModel
|
|
13
|
+
|
|
14
|
+
from .config import ServiceConfig
|
|
15
|
+
from .network import PortAllocator
|
|
16
|
+
from .platform import to_dns_label
|
|
17
|
+
from .security import UserProfile
|
|
18
|
+
from .service_runner import RunResult, ServiceRunner, ValidationResult
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _dns_label(value: str, fallback: str = "user") -> str:
|
|
24
|
+
return to_dns_label(value, fallback=fallback)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _validate_service_id(service_id: str) -> str:
|
|
28
|
+
if not service_id:
|
|
29
|
+
raise HTTPException(status_code=400, detail="service_id required")
|
|
30
|
+
if "/" in service_id or "\\" in service_id:
|
|
31
|
+
raise HTTPException(status_code=400, detail="invalid service_id")
|
|
32
|
+
if ".." in service_id:
|
|
33
|
+
raise HTTPException(status_code=400, detail="invalid service_id")
|
|
34
|
+
return service_id
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _service_name_for(service_id: str) -> str:
|
|
38
|
+
service_id = _validate_service_id(service_id)
|
|
39
|
+
return f"service_{service_id}"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _validate_rel_path(path: str) -> Path:
|
|
43
|
+
if path is None:
|
|
44
|
+
raise HTTPException(status_code=400, detail="path required")
|
|
45
|
+
p = Path(str(path))
|
|
46
|
+
if p.is_absolute():
|
|
47
|
+
raise HTTPException(status_code=400, detail="path must be relative")
|
|
48
|
+
if any(part in {"..", ""} for part in p.parts):
|
|
49
|
+
raise HTTPException(status_code=400, detail="invalid path")
|
|
50
|
+
return p
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _resolve_in_dir(root: Path, rel: Path) -> Path:
|
|
54
|
+
root_r = root.resolve()
|
|
55
|
+
target = (root / rel).resolve()
|
|
56
|
+
if not target.is_relative_to(root_r):
|
|
57
|
+
raise HTTPException(status_code=400, detail="path escapes sandbox")
|
|
58
|
+
return target
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class UserProfileRequest(BaseModel):
|
|
62
|
+
tier: str = "free"
|
|
63
|
+
max_concurrent_services: int = 2
|
|
64
|
+
max_memory_mb: int = 512
|
|
65
|
+
max_cpu_percent: int = 50
|
|
66
|
+
max_requests_per_minute: int = 30
|
|
67
|
+
max_services_per_hour: int = 10
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class RunRequest(BaseModel):
|
|
71
|
+
project_id: int
|
|
72
|
+
readme_content: str
|
|
73
|
+
port: int = 0
|
|
74
|
+
user_id: Optional[str] = None
|
|
75
|
+
username: Optional[str] = None
|
|
76
|
+
service_id: Optional[str] = None
|
|
77
|
+
env: Optional[Dict[str, str]] = None
|
|
78
|
+
user_profile: Optional[UserProfileRequest] = None
|
|
79
|
+
fast_mode: bool = False
|
|
80
|
+
skip_health_check: bool = False
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class StopRequest(BaseModel):
|
|
84
|
+
project_id: int
|
|
85
|
+
user_id: Optional[str] = None
|
|
86
|
+
username: Optional[str] = None
|
|
87
|
+
service_id: Optional[str] = None
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class ValidateRequest(BaseModel):
|
|
91
|
+
readme_content: str
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class SandboxPrepareRequest(BaseModel):
|
|
95
|
+
project_id: int
|
|
96
|
+
readme_content: str
|
|
97
|
+
user_id: Optional[str] = None
|
|
98
|
+
username: Optional[str] = None
|
|
99
|
+
service_id: Optional[str] = None
|
|
100
|
+
port: int = 0
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class SandboxFileWriteRequest(BaseModel):
|
|
104
|
+
content: str
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class RunnerApiSettings:
|
|
108
|
+
def __init__(self):
|
|
109
|
+
self.sandbox_root = Path(os.environ.get("PACTOWN_SANDBOX_ROOT", "/tmp/pactown-sandboxes"))
|
|
110
|
+
self.port_start = int(os.environ.get("PACTOWN_PORT_START", "10000"))
|
|
111
|
+
self.port_end = int(os.environ.get("PACTOWN_PORT_END", "20000"))
|
|
112
|
+
self.require_token = os.environ.get("PACTOWN_RUNNER_REQUIRE_TOKEN", "").lower() in {"1", "true", "yes"}
|
|
113
|
+
self.token = os.environ.get("PACTOWN_RUNNER_TOKEN") or ""
|
|
114
|
+
self.proxy_check_base_url = os.environ.get("PACTOWN_PROXY_CHECK_BASE_URL", "")
|
|
115
|
+
self.domain = os.environ.get("PACTOWN_DOMAIN", "")
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class RunnerService:
|
|
119
|
+
def __init__(
|
|
120
|
+
self,
|
|
121
|
+
*,
|
|
122
|
+
sandbox_root: Path,
|
|
123
|
+
port_start: int,
|
|
124
|
+
port_end: int,
|
|
125
|
+
health_timeout: int = 30,
|
|
126
|
+
):
|
|
127
|
+
self.settings = RunnerApiSettings()
|
|
128
|
+
self.runner = ServiceRunner(sandbox_root=sandbox_root, default_health_check="/health", health_timeout=health_timeout)
|
|
129
|
+
self.port_allocator = PortAllocator(start_port=port_start, end_port=port_end)
|
|
130
|
+
|
|
131
|
+
def _resolve_service_id(self, req_service_id: Optional[str], project_id: int, username: Optional[str]) -> str:
|
|
132
|
+
if req_service_id:
|
|
133
|
+
return _validate_service_id(req_service_id)
|
|
134
|
+
if username:
|
|
135
|
+
return _validate_service_id(f"{int(project_id)}-{_dns_label(username, fallback=str(project_id))}")
|
|
136
|
+
return _validate_service_id(str(int(project_id)))
|
|
137
|
+
|
|
138
|
+
def validate(self, readme_content: str) -> ValidationResult:
|
|
139
|
+
return self.runner.validate_content(readme_content)
|
|
140
|
+
|
|
141
|
+
def _sandbox_path_for(self, service_id: str) -> Path:
|
|
142
|
+
return self.runner.sandbox_manager.get_sandbox_path(_service_name_for(service_id))
|
|
143
|
+
|
|
144
|
+
def list_sandbox_files(self, service_id: str) -> List[Dict[str, Any]]:
|
|
145
|
+
sandbox_path = self._sandbox_path_for(service_id)
|
|
146
|
+
if not sandbox_path.exists():
|
|
147
|
+
raise HTTPException(status_code=404, detail="sandbox not found")
|
|
148
|
+
|
|
149
|
+
files: List[Dict[str, Any]] = []
|
|
150
|
+
for p in sandbox_path.rglob("*"):
|
|
151
|
+
try:
|
|
152
|
+
if p.is_dir():
|
|
153
|
+
continue
|
|
154
|
+
rel = p.relative_to(sandbox_path)
|
|
155
|
+
st = p.stat()
|
|
156
|
+
files.append(
|
|
157
|
+
{
|
|
158
|
+
"path": str(rel),
|
|
159
|
+
"size": int(st.st_size),
|
|
160
|
+
"mtime": int(st.st_mtime),
|
|
161
|
+
}
|
|
162
|
+
)
|
|
163
|
+
except Exception:
|
|
164
|
+
continue
|
|
165
|
+
files.sort(key=lambda d: d.get("path", ""))
|
|
166
|
+
return files
|
|
167
|
+
|
|
168
|
+
def read_sandbox_file(self, service_id: str, path: str, limit: int = 200000) -> str:
|
|
169
|
+
sandbox_path = self._sandbox_path_for(service_id)
|
|
170
|
+
if not sandbox_path.exists():
|
|
171
|
+
raise HTTPException(status_code=404, detail="sandbox not found")
|
|
172
|
+
|
|
173
|
+
rel = _validate_rel_path(path)
|
|
174
|
+
target = _resolve_in_dir(sandbox_path, rel)
|
|
175
|
+
if not target.exists() or not target.is_file():
|
|
176
|
+
raise HTTPException(status_code=404, detail="file not found")
|
|
177
|
+
|
|
178
|
+
data = target.read_text(encoding="utf-8", errors="replace")
|
|
179
|
+
if len(data) > limit:
|
|
180
|
+
return data[:limit]
|
|
181
|
+
return data
|
|
182
|
+
|
|
183
|
+
def write_sandbox_file(self, service_id: str, path: str, content: str) -> None:
|
|
184
|
+
sandbox_path = self._sandbox_path_for(service_id)
|
|
185
|
+
sandbox_path.mkdir(parents=True, exist_ok=True)
|
|
186
|
+
|
|
187
|
+
rel = _validate_rel_path(path)
|
|
188
|
+
target = _resolve_in_dir(sandbox_path, rel)
|
|
189
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
190
|
+
target.write_text(content, encoding="utf-8")
|
|
191
|
+
|
|
192
|
+
def delete_sandbox_file(self, service_id: str, path: str) -> None:
|
|
193
|
+
sandbox_path = self._sandbox_path_for(service_id)
|
|
194
|
+
if not sandbox_path.exists():
|
|
195
|
+
raise HTTPException(status_code=404, detail="sandbox not found")
|
|
196
|
+
|
|
197
|
+
rel = _validate_rel_path(path)
|
|
198
|
+
target = _resolve_in_dir(sandbox_path, rel)
|
|
199
|
+
if not target.exists():
|
|
200
|
+
return
|
|
201
|
+
if target.is_dir():
|
|
202
|
+
raise HTTPException(status_code=400, detail="path is a directory")
|
|
203
|
+
target.unlink()
|
|
204
|
+
|
|
205
|
+
def prepare_sandbox(self, service_id: str, content: str, port: int = 0) -> Dict[str, Any]:
|
|
206
|
+
validation = self.runner.validate_content(content)
|
|
207
|
+
if not validation.valid:
|
|
208
|
+
raise HTTPException(status_code=400, detail=validation.errors or ["validation failed"])
|
|
209
|
+
|
|
210
|
+
service_name = _service_name_for(service_id)
|
|
211
|
+
readme_path = self.runner.sandbox_root / f"{service_name}_README.md"
|
|
212
|
+
readme_path.write_text(content)
|
|
213
|
+
|
|
214
|
+
service_config = ServiceConfig(
|
|
215
|
+
name=service_name,
|
|
216
|
+
readme=str(readme_path),
|
|
217
|
+
port=int(port) if port else 0,
|
|
218
|
+
env={},
|
|
219
|
+
health_check="/health",
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
logs: List[str] = []
|
|
223
|
+
|
|
224
|
+
def on_log(msg: str) -> None:
|
|
225
|
+
logs.append(msg)
|
|
226
|
+
|
|
227
|
+
sandbox = self.runner.sandbox_manager.create_sandbox(
|
|
228
|
+
service=service_config,
|
|
229
|
+
readme_path=readme_path,
|
|
230
|
+
install_dependencies=False,
|
|
231
|
+
on_log=on_log,
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
return {
|
|
235
|
+
"sandbox": str(sandbox.path),
|
|
236
|
+
"files": self.list_sandbox_files(service_id),
|
|
237
|
+
"logs": logs,
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
async def run(
|
|
241
|
+
self,
|
|
242
|
+
*,
|
|
243
|
+
service_id: str,
|
|
244
|
+
content: str,
|
|
245
|
+
port: int,
|
|
246
|
+
env: Optional[Dict[str, str]],
|
|
247
|
+
user_id: Optional[str],
|
|
248
|
+
username: Optional[str],
|
|
249
|
+
user_profile: Optional[Dict[str, Any]],
|
|
250
|
+
fast_mode: bool,
|
|
251
|
+
skip_health_check: bool,
|
|
252
|
+
) -> RunResult:
|
|
253
|
+
effective_port = int(port)
|
|
254
|
+
if effective_port <= 0:
|
|
255
|
+
effective_port = self.port_allocator.allocate()
|
|
256
|
+
else:
|
|
257
|
+
effective_port = self.port_allocator.allocate(preferred_port=effective_port)
|
|
258
|
+
|
|
259
|
+
if user_profile and user_id:
|
|
260
|
+
profile = UserProfile.from_dict({**user_profile, "user_id": user_id})
|
|
261
|
+
self.runner.security_policy.set_user_profile(profile)
|
|
262
|
+
|
|
263
|
+
if fast_mode:
|
|
264
|
+
result = await self.runner.fast_run(
|
|
265
|
+
service_id=service_id,
|
|
266
|
+
content=content,
|
|
267
|
+
port=effective_port,
|
|
268
|
+
env=env or {},
|
|
269
|
+
user_id=user_id,
|
|
270
|
+
user_profile=user_profile,
|
|
271
|
+
skip_health_check=skip_health_check,
|
|
272
|
+
)
|
|
273
|
+
else:
|
|
274
|
+
result = await self.runner.run_from_content(
|
|
275
|
+
service_id=service_id,
|
|
276
|
+
content=content,
|
|
277
|
+
port=effective_port,
|
|
278
|
+
env=env or {},
|
|
279
|
+
restart_if_running=True,
|
|
280
|
+
wait_for_health=not skip_health_check,
|
|
281
|
+
user_id=user_id,
|
|
282
|
+
user_profile=user_profile,
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
result.logs = result.logs or []
|
|
286
|
+
|
|
287
|
+
base_url = self.settings.proxy_check_base_url
|
|
288
|
+
domain = self.settings.domain
|
|
289
|
+
if base_url and domain and (username or user_id):
|
|
290
|
+
host = f"{service_id}.{domain}".lower()
|
|
291
|
+
try:
|
|
292
|
+
async with httpx.AsyncClient(follow_redirects=False, timeout=5.0) as client:
|
|
293
|
+
headers = {"host": host, "connection": "close"}
|
|
294
|
+
async with client.stream("GET", f"{base_url}/", headers=headers) as root_resp:
|
|
295
|
+
root_status = root_resp.status_code
|
|
296
|
+
async with client.stream("GET", f"{base_url}/health", headers=headers) as health_resp:
|
|
297
|
+
health_status = health_resp.status_code
|
|
298
|
+
result.logs.extend(
|
|
299
|
+
[
|
|
300
|
+
f"[subdomain-check] host={host} / -> {root_status}",
|
|
301
|
+
f"[subdomain-check] host={host} /health -> {health_status}",
|
|
302
|
+
]
|
|
303
|
+
)
|
|
304
|
+
except httpx.RemoteProtocolError as e:
|
|
305
|
+
result.logs.append(
|
|
306
|
+
f"[subdomain-check][WARN] failed host={host}: RemoteProtocolError: {e}"
|
|
307
|
+
)
|
|
308
|
+
except Exception as e:
|
|
309
|
+
result.logs.append(f"[subdomain-check][WARN] failed host={host}: {type(e).__name__}: {e}")
|
|
310
|
+
|
|
311
|
+
return result
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def create_runner_api(*, runner_service: RunnerService, settings: RunnerApiSettings) -> FastAPI:
|
|
315
|
+
app = FastAPI(title="pactown-runner-api")
|
|
316
|
+
|
|
317
|
+
def require_token(x_runner_token: Optional[str] = Header(default=None)) -> None:
|
|
318
|
+
if not settings.require_token:
|
|
319
|
+
return
|
|
320
|
+
if not settings.token:
|
|
321
|
+
raise HTTPException(status_code=500, detail="runner token not configured")
|
|
322
|
+
if not x_runner_token or x_runner_token != settings.token:
|
|
323
|
+
raise HTTPException(status_code=401, detail="unauthorized")
|
|
324
|
+
|
|
325
|
+
@app.get("/health")
|
|
326
|
+
async def health() -> Dict[str, Any]:
|
|
327
|
+
return {"ok": True}
|
|
328
|
+
|
|
329
|
+
@app.post("/validate", dependencies=[Depends(require_token)])
|
|
330
|
+
async def validate(req: ValidateRequest) -> Dict[str, Any]:
|
|
331
|
+
res = runner_service.validate(req.readme_content)
|
|
332
|
+
return asdict(res)
|
|
333
|
+
|
|
334
|
+
@app.post("/sandbox/prepare", dependencies=[Depends(require_token)])
|
|
335
|
+
async def prepare_sandbox(req: SandboxPrepareRequest) -> Dict[str, Any]:
|
|
336
|
+
service_id = runner_service._resolve_service_id(req.service_id, req.project_id, req.username)
|
|
337
|
+
return runner_service.prepare_sandbox(service_id, req.readme_content, port=req.port)
|
|
338
|
+
|
|
339
|
+
@app.get("/sandbox/{service_id}/files", dependencies=[Depends(require_token)])
|
|
340
|
+
async def list_files(service_id: str) -> Dict[str, Any]:
|
|
341
|
+
service_id = _validate_service_id(service_id)
|
|
342
|
+
return {"files": runner_service.list_sandbox_files(service_id)}
|
|
343
|
+
|
|
344
|
+
@app.get("/sandbox/{service_id}/file", dependencies=[Depends(require_token)])
|
|
345
|
+
async def read_file(service_id: str, path: str) -> Dict[str, Any]:
|
|
346
|
+
service_id = _validate_service_id(service_id)
|
|
347
|
+
return {"path": path, "content": runner_service.read_sandbox_file(service_id, path)}
|
|
348
|
+
|
|
349
|
+
@app.put("/sandbox/{service_id}/file", dependencies=[Depends(require_token)])
|
|
350
|
+
async def write_file(service_id: str, path: str, body: SandboxFileWriteRequest) -> Dict[str, Any]:
|
|
351
|
+
service_id = _validate_service_id(service_id)
|
|
352
|
+
runner_service.write_sandbox_file(service_id, path, body.content)
|
|
353
|
+
return {"ok": True}
|
|
354
|
+
|
|
355
|
+
@app.delete("/sandbox/{service_id}/file", dependencies=[Depends(require_token)])
|
|
356
|
+
async def delete_file(service_id: str, path: str) -> Dict[str, Any]:
|
|
357
|
+
service_id = _validate_service_id(service_id)
|
|
358
|
+
runner_service.delete_sandbox_file(service_id, path)
|
|
359
|
+
return {"ok": True}
|
|
360
|
+
|
|
361
|
+
@app.post("/run", dependencies=[Depends(require_token)])
|
|
362
|
+
async def run(req: RunRequest) -> Dict[str, Any]:
|
|
363
|
+
service_id = runner_service._resolve_service_id(req.service_id, req.project_id, req.username)
|
|
364
|
+
user_profile_dict = req.user_profile.model_dump() if req.user_profile else None
|
|
365
|
+
result = await runner_service.run(
|
|
366
|
+
service_id=service_id,
|
|
367
|
+
content=req.readme_content,
|
|
368
|
+
port=req.port,
|
|
369
|
+
env=req.env,
|
|
370
|
+
user_id=req.user_id,
|
|
371
|
+
username=req.username,
|
|
372
|
+
user_profile=user_profile_dict,
|
|
373
|
+
fast_mode=req.fast_mode,
|
|
374
|
+
skip_health_check=req.skip_health_check,
|
|
375
|
+
)
|
|
376
|
+
return {
|
|
377
|
+
"success": result.success,
|
|
378
|
+
"port": result.port,
|
|
379
|
+
"pid": result.pid,
|
|
380
|
+
"message": result.message,
|
|
381
|
+
"logs": result.logs or [],
|
|
382
|
+
"error_category": result.error_category.value if hasattr(result.error_category, "value") else str(result.error_category),
|
|
383
|
+
"stderr_output": result.stderr_output,
|
|
384
|
+
"suggestions": [asdict(s) for s in (result.suggestions or [])],
|
|
385
|
+
"diagnostics": asdict(result.diagnostics) if result.diagnostics else None,
|
|
386
|
+
"service_name": result.service_name,
|
|
387
|
+
"sandbox_path": str(result.sandbox_path) if getattr(result, "sandbox_path", None) else None,
|
|
388
|
+
"user_id": req.user_id,
|
|
389
|
+
"service_id": service_id,
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
@app.get("/test/{service_id}", dependencies=[Depends(require_token)])
|
|
393
|
+
async def test_endpoints(service_id: str) -> Dict[str, Any]:
|
|
394
|
+
service_id = _validate_service_id(service_id)
|
|
395
|
+
results = await runner_service.runner.test_endpoints(service_id)
|
|
396
|
+
return {
|
|
397
|
+
"success": len(results) > 0 and results[0].endpoint != "*",
|
|
398
|
+
"results": [asdict(r) for r in results],
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
@app.get("/cache/stats", dependencies=[Depends(require_token)])
|
|
402
|
+
async def cache_stats() -> Dict[str, Any]:
|
|
403
|
+
return runner_service.runner.get_cache_stats()
|
|
404
|
+
|
|
405
|
+
@app.post("/stop", dependencies=[Depends(require_token)])
|
|
406
|
+
async def stop(req: StopRequest) -> Dict[str, Any]:
|
|
407
|
+
service_id = runner_service._resolve_service_id(req.service_id, req.project_id, req.username)
|
|
408
|
+
status = runner_service.runner.get_status(service_id) or {}
|
|
409
|
+
port = status.get("port")
|
|
410
|
+
result = runner_service.runner.stop(service_id)
|
|
411
|
+
if port:
|
|
412
|
+
runner_service.port_allocator.release(int(port))
|
|
413
|
+
return {
|
|
414
|
+
"success": result.success,
|
|
415
|
+
"port": result.port,
|
|
416
|
+
"pid": result.pid,
|
|
417
|
+
"message": result.message,
|
|
418
|
+
"logs": result.logs or [],
|
|
419
|
+
"error_category": result.error_category.value if hasattr(result.error_category, "value") else str(result.error_category),
|
|
420
|
+
"stderr_output": result.stderr_output,
|
|
421
|
+
"service_id": service_id,
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
@app.get("/status", dependencies=[Depends(require_token)])
|
|
425
|
+
async def status(user_id: Optional[str] = None) -> Dict[str, Any]:
|
|
426
|
+
services = runner_service.runner.list_services()
|
|
427
|
+
if user_id:
|
|
428
|
+
services = [s for s in services if s.get("user_id") == user_id]
|
|
429
|
+
return {"services": services}
|
|
430
|
+
|
|
431
|
+
@app.get("/status/{service_id}", dependencies=[Depends(require_token)])
|
|
432
|
+
async def status_one(service_id: str) -> Dict[str, Any]:
|
|
433
|
+
service_id = _validate_service_id(service_id)
|
|
434
|
+
st = runner_service.runner.get_status(service_id)
|
|
435
|
+
if not st:
|
|
436
|
+
return {"running": False}
|
|
437
|
+
return st
|
|
438
|
+
|
|
439
|
+
return app
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
def create_app() -> FastAPI:
|
|
443
|
+
settings = RunnerApiSettings()
|
|
444
|
+
runner_service = RunnerService(
|
|
445
|
+
sandbox_root=settings.sandbox_root,
|
|
446
|
+
port_start=settings.port_start,
|
|
447
|
+
port_end=settings.port_end,
|
|
448
|
+
)
|
|
449
|
+
return create_runner_api(runner_service=runner_service, settings=settings)
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
def main() -> None:
|
|
453
|
+
import uvicorn
|
|
454
|
+
|
|
455
|
+
app = create_app()
|
|
456
|
+
host = os.environ.get("PACTOWN_RUNNER_HOST", "0.0.0.0")
|
|
457
|
+
port = int(os.environ.get("PACTOWN_RUNNER_PORT", "8801"))
|
|
458
|
+
uvicorn.run(app, host=host, port=port)
|