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/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)