MarneMorgan commited on
Commit
fd7195a
·
verified ·
1 Parent(s): 06205a9

Create server.pu

Browse files
Files changed (1) hide show
  1. server.pu +332 -0
server.pu ADDED
@@ -0,0 +1,332 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os, time, json, uuid, shlex, asyncio
2
+ from pathlib import Path
3
+ from typing import Optional, Dict, Any, List
4
+
5
+ import subprocess
6
+ from fastapi import FastAPI, Header, HTTPException, Request
7
+ from fastapi.responses import StreamingResponse
8
+ from pydantic import BaseModel
9
+ from cryptography.fernet import Fernet
10
+
11
+ # -------------------------
12
+ # Config
13
+ # -------------------------
14
+ APP_TOKEN = os.getenv("APP_TOKEN", "") # required auth token
15
+ BASE = Path(os.getenv("WORKDIR", "/home/appuser/workspace")).resolve()
16
+ BASE.mkdir(parents=True, exist_ok=True)
17
+
18
+ MASTER_KEY = os.getenv("MASTER_KEY", "") # encrypts in-app secrets (project secrets)
19
+ FERNET = Fernet(MASTER_KEY.encode()) if MASTER_KEY else None
20
+
21
+ MAX_LOG_LINES = 5000
22
+ DEFAULT_TIMEOUT = 600 # seconds
23
+
24
+ app = FastAPI(title="Executor API", version="0.2")
25
+
26
+ # -------------------------
27
+ # Helpers
28
+ # -------------------------
29
+ def require_auth(x_token: Optional[str]):
30
+ if not APP_TOKEN:
31
+ raise HTTPException(status_code=500, detail="APP_TOKEN is not set")
32
+ if x_token != APP_TOKEN:
33
+ raise HTTPException(status_code=401, detail="Invalid token")
34
+
35
+ def jail(project_id: str, rel: str = "") -> Path:
36
+ root = (BASE / project_id).resolve()
37
+ target = (root / rel).resolve() if rel else root
38
+ if not str(target).startswith(str(root)):
39
+ raise HTTPException(status_code=400, detail="Path escapes project workspace")
40
+ return target
41
+
42
+ def proj_paths(project_id: str) -> Dict[str, Path]:
43
+ root = jail(project_id)
44
+ return {
45
+ "root": root,
46
+ "log": root / ".executor.log",
47
+ "cfg": root / ".aibuilder.json",
48
+ "secrets": root / ".secrets.json.enc",
49
+ }
50
+
51
+ def log_append(log_file: Path, line: str):
52
+ log_file.parent.mkdir(parents=True, exist_ok=True)
53
+ if not log_file.exists():
54
+ log_file.write_text("", encoding="utf-8")
55
+
56
+ lines = log_file.read_text(encoding="utf-8", errors="ignore").splitlines()
57
+ lines.append(line)
58
+ if len(lines) > MAX_LOG_LINES:
59
+ lines = lines[-MAX_LOG_LINES:]
60
+ log_file.write_text("\n".join(lines) + "\n", encoding="utf-8")
61
+
62
+ def read_cfg(cfg_path: Path) -> Dict[str, Any]:
63
+ if not cfg_path.exists():
64
+ return {}
65
+ try:
66
+ return json.loads(cfg_path.read_text(encoding="utf-8"))
67
+ except Exception:
68
+ return {}
69
+
70
+ def write_cfg(cfg_path: Path, cfg: Dict[str, Any]):
71
+ cfg_path.write_text(json.dumps(cfg, indent=2), encoding="utf-8")
72
+
73
+ def detect_stack(root: Path) -> List[str]:
74
+ stacks = []
75
+ if (root / "package.json").exists():
76
+ stacks.append("node")
77
+ if (root / "pyproject.toml").exists() or (root / "requirements.txt").exists():
78
+ stacks.append("python")
79
+ if (root / "go.mod").exists():
80
+ stacks.append("go")
81
+ if (root / "Cargo.toml").exists():
82
+ stacks.append("rust")
83
+ if (root / "pom.xml").exists() or (root / "build.gradle").exists() or (root / "build.gradle.kts").exists():
84
+ stacks.append("java")
85
+ if (root / "docker-compose.yml").exists() or (root / "compose.yml").exists():
86
+ stacks.append("compose")
87
+ return stacks or ["unknown"]
88
+
89
+ def load_secrets(paths: Dict[str, Path]) -> Dict[str, str]:
90
+ if not FERNET or not paths["secrets"].exists():
91
+ return {}
92
+ token = paths["secrets"].read_bytes()
93
+ raw = FERNET.decrypt(token)
94
+ return json.loads(raw.decode("utf-8"))
95
+
96
+ def save_secrets(paths: Dict[str, Path], secrets: Dict[str, str]):
97
+ if not FERNET:
98
+ raise HTTPException(status_code=500, detail="MASTER_KEY not set; secrets disabled")
99
+ raw = json.dumps(secrets).encode("utf-8")
100
+ token = FERNET.encrypt(raw)
101
+ paths["secrets"].write_bytes(token)
102
+
103
+ def run_process(
104
+ *,
105
+ cmd: str,
106
+ cwd: Path,
107
+ log_file: Path,
108
+ env_extra: Optional[Dict[str, str]] = None,
109
+ timeout: int = DEFAULT_TIMEOUT,
110
+ ) -> Dict[str, Any]:
111
+ run_id = str(uuid.uuid4())[:8]
112
+ started = time.time()
113
+ log_append(log_file, f"\n[{run_id}]$ {cmd}")
114
+
115
+ args = shlex.split(cmd) # no shell by default
116
+ env = os.environ.copy()
117
+ if env_extra:
118
+ env.update(env_extra)
119
+
120
+ try:
121
+ p = subprocess.run(
122
+ args,
123
+ cwd=str(cwd),
124
+ capture_output=True,
125
+ text=True,
126
+ env=env,
127
+ timeout=timeout,
128
+ )
129
+ except subprocess.TimeoutExpired:
130
+ log_append(log_file, f"[{run_id}] TIMEOUT after {timeout}s")
131
+ return {"id": run_id, "code": 124, "stdout": "", "stderr": f"TIMEOUT after {timeout}s", "seconds": timeout}
132
+
133
+ dur = time.time() - started
134
+ if p.stdout:
135
+ log_append(log_file, p.stdout.rstrip())
136
+ if p.stderr:
137
+ log_append(log_file, "[stderr]")
138
+ log_append(log_file, p.stderr.rstrip())
139
+ log_append(log_file, f"[{run_id}] exit={p.returncode} time={dur:.2f}s")
140
+ return {"id": run_id, "code": p.returncode, "stdout": p.stdout, "stderr": p.stderr, "seconds": dur}
141
+
142
+ # -------------------------
143
+ # Models
144
+ # -------------------------
145
+ class CreateProjectReq(BaseModel):
146
+ name: Optional[str] = None
147
+
148
+ class ApplyReq(BaseModel):
149
+ path: str
150
+ content: str
151
+
152
+ class ExecReq(BaseModel):
153
+ cmd: str
154
+ cwd: Optional[str] = None
155
+ timeout_sec: int = DEFAULT_TIMEOUT
156
+
157
+ class SecretSetReq(BaseModel):
158
+ key: str
159
+ value: str
160
+
161
+ class VerifyReq(BaseModel):
162
+ install: Optional[List[str]] = None
163
+ build: Optional[List[str]] = None
164
+ test: Optional[List[str]] = None
165
+
166
+ # -------------------------
167
+ # Routes
168
+ # -------------------------
169
+ @app.get("/")
170
+ def root():
171
+ return {"ok": True, "service": "executor", "docs": "/docs"}
172
+
173
+ @app.post("/projects")
174
+ def create_project(req: CreateProjectReq, x_token: Optional[str] = Header(default=None)):
175
+ require_auth(x_token)
176
+
177
+ pid = str(uuid.uuid4())[:12]
178
+ paths = proj_paths(pid)
179
+ paths["root"].mkdir(parents=True, exist_ok=True)
180
+ paths["log"].write_text("", encoding="utf-8")
181
+
182
+ cfg = {
183
+ "name": req.name or pid,
184
+ "created_at": time.time(),
185
+ # Defaults (override per project via /apply to .aibuilder.json if you want)
186
+ "pip_install_cmd": "python -m pip install -r requirements.txt",
187
+ "pytest_cmd": "pytest -q",
188
+ "node_install_cmd": "npm ci",
189
+ "node_build_cmd": "npm run build",
190
+ "node_test_cmd": "npm test",
191
+ }
192
+ write_cfg(paths["cfg"], cfg)
193
+ log_append(paths["log"], f"[system] project created: {cfg['name']} ({pid})")
194
+ return {"project_id": pid, "name": cfg["name"]}
195
+
196
+ @app.post("/projects/{project_id}/apply")
197
+ def apply_file(project_id: str, req: ApplyReq, x_token: Optional[str] = Header(default=None)):
198
+ require_auth(x_token)
199
+ paths = proj_paths(project_id)
200
+
201
+ fp = jail(project_id, req.path)
202
+ fp.parent.mkdir(parents=True, exist_ok=True)
203
+ fp.write_text(req.content, encoding="utf-8")
204
+ log_append(paths["log"], f"[write] {req.path} ({len(req.content)} bytes)")
205
+ return {"ok": True, "path": req.path}
206
+
207
+ @app.post("/projects/{project_id}/exec")
208
+ def exec_cmd(project_id: str, req: ExecReq, x_token: Optional[str] = Header(default=None)):
209
+ require_auth(x_token)
210
+ paths = proj_paths(project_id)
211
+
212
+ cwd = jail(project_id, req.cwd) if req.cwd else paths["root"]
213
+ secrets = load_secrets(paths)
214
+
215
+ return run_process(
216
+ cmd=req.cmd,
217
+ cwd=cwd,
218
+ log_file=paths["log"],
219
+ env_extra=secrets,
220
+ timeout=req.timeout_sec,
221
+ )
222
+
223
+ @app.get("/projects/{project_id}/logs")
224
+ def get_logs(project_id: str, lines: int = 200, x_token: Optional[str] = Header(default=None)):
225
+ require_auth(x_token)
226
+ paths = proj_paths(project_id)
227
+ text = paths["log"].read_text(encoding="utf-8", errors="ignore").splitlines()
228
+ lines = max(1, min(lines, 2000))
229
+ return {"lines": text[-lines:]}
230
+
231
+ @app.get("/projects/{project_id}/events")
232
+ async def sse_events(project_id: str, request: Request, x_token: Optional[str] = Header(default=None)):
233
+ require_auth(x_token)
234
+ paths = proj_paths(project_id)
235
+
236
+ async def gen():
237
+ last_len = 0
238
+ while True:
239
+ if await request.is_disconnected():
240
+ break
241
+
242
+ content = paths["log"].read_text(encoding="utf-8", errors="ignore")
243
+ if len(content) != last_len:
244
+ chunk = content[last_len:]
245
+ last_len = len(content)
246
+ for line in chunk.splitlines():
247
+ yield f"data: {line}\n\n"
248
+
249
+ await asyncio.sleep(0.5)
250
+
251
+ return StreamingResponse(gen(), media_type="text/event-stream")
252
+
253
+ # In-app secrets (per project)
254
+ @app.post("/projects/{project_id}/secrets/set")
255
+ def set_secret(project_id: str, req: SecretSetReq, x_token: Optional[str] = Header(default=None)):
256
+ require_auth(x_token)
257
+ paths = proj_paths(project_id)
258
+
259
+ secrets = load_secrets(paths)
260
+ secrets[req.key] = req.value
261
+ save_secrets(paths, secrets)
262
+ log_append(paths["log"], f"[secret] set {req.key}")
263
+ return {"ok": True}
264
+
265
+ @app.post("/projects/{project_id}/verify")
266
+ def verify(project_id: str, req: VerifyReq, x_token: Optional[str] = Header(default=None)):
267
+ """
268
+ "Done means done": ok=True only if install+build+test all succeed.
269
+ """
270
+ require_auth(x_token)
271
+ paths = proj_paths(project_id)
272
+ root = paths["root"]
273
+ cfg = read_cfg(paths["cfg"])
274
+ secrets = load_secrets(paths)
275
+ stacks = detect_stack(root)
276
+
277
+ install_cmds: List[str] = []
278
+ build_cmds: List[str] = []
279
+ test_cmds: List[str] = []
280
+
281
+ if "node" in stacks:
282
+ install_cmds.append(cfg.get("node_install_cmd", "npm ci"))
283
+ build_cmds.append(cfg.get("node_build_cmd", "npm run build"))
284
+ test_cmds.append(cfg.get("node_test_cmd", "npm test"))
285
+
286
+ if "python" in stacks:
287
+ if (root / "requirements.txt").exists():
288
+ install_cmds.append(cfg.get("pip_install_cmd", "python -m pip install -r requirements.txt"))
289
+ if (root / "pyproject.toml").exists():
290
+ install_cmds.append("python -m pip install -U pip && python -m pip install .")
291
+ test_cmds.append(cfg.get("pytest_cmd", "pytest -q"))
292
+
293
+ # allow overrides
294
+ if req.install is not None: install_cmds = req.install
295
+ if req.build is not None: build_cmds = req.build
296
+ if req.test is not None: test_cmds = req.test
297
+
298
+ log_append(paths["log"], f"[verify] stacks={stacks}")
299
+
300
+ results = {"install": [], "build": [], "test": []}
301
+ ok = True
302
+
303
+ for cmd in install_cmds:
304
+ r = run_process(cmd=cmd, cwd=root, log_file=paths["log"], env_extra=secrets, timeout=DEFAULT_TIMEOUT)
305
+ results["install"].append(r)
306
+ if r["code"] != 0:
307
+ ok = False
308
+ break
309
+
310
+ if ok:
311
+ for cmd in build_cmds:
312
+ r = run_process(cmd=cmd, cwd=root, log_file=paths["log"], env_extra=secrets, timeout=DEFAULT_TIMEOUT)
313
+ results["build"].append(r)
314
+ if r["code"] != 0:
315
+ ok = False
316
+ break
317
+
318
+ if ok:
319
+ for cmd in test_cmds:
320
+ r = run_process(cmd=cmd, cwd=root, log_file=paths["log"], env_extra=secrets, timeout=DEFAULT_TIMEOUT)
321
+ results["test"].append(r)
322
+ if r["code"] != 0:
323
+ ok = False
324
+ break
325
+
326
+ return {
327
+ "ok": ok,
328
+ "stacks": stacks,
329
+ "pipeline": {"install": install_cmds, "build": build_cmds, "test": test_cmds},
330
+ "results": results,
331
+ "done_definition": "ok==True means install+build+test all exited with code 0"
332
+ }