From 695c6c451dd5eddee65ec904bf660c8b6b6465d7 Mon Sep 17 00:00:00 2001 From: Jaco Bezuidenhout Date: Tue, 23 Jun 2026 17:18:30 +0000 Subject: [PATCH] feat: add approved stack lifecycle endpoints --- main.py | 82 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 81 insertions(+), 1 deletion(-) diff --git a/main.py b/main.py index 62ea844..c3ee634 100644 --- a/main.py +++ b/main.py @@ -10,7 +10,7 @@ from pathlib import Path import httpx from fastapi import BackgroundTasks, FastAPI, HTTPException, Request -from fastapi.responses import HTMLResponse, RedirectResponse +from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse from pydantic import BaseModel logging.basicConfig(level=logging.INFO, format="%(asctime)s [qyburn] %(message)s") @@ -709,3 +709,83 @@ async def receive_handoff(job_id: str, request: Request): logger.info(f"[{job_id[:8]}] handoff received — pending review") _write_last_run() return {"status": "pending_review", "job_id": job_id} + + +# --------------------------------------------------------------------------- +# Jon Snow approval gate — rebuild/restart endpoints (pure subprocess, no LLM) +# --------------------------------------------------------------------------- + +@app.post("/rebuild/{stack_name}") +async def rebuild_stack(stack_name: str): + """Rebuild a Docker Compose stack (compose up -d --build). Called by Jon Snow approval gate. + No LLM involved — pure subprocess execution.""" + import re + if not re.match(r'^[a-zA-Z0-9_-]+$', stack_name): + return JSONResponse({"ok": False, "error": "invalid stack name"}, status_code=400) + + stack_path = STACKS_DIR / stack_name + if not stack_path.exists(): + return JSONResponse({"ok": False, "error": f"stack not found: {stack_name}"}, status_code=404) + + logger.info(f"[rebuild] {stack_name} — starting compose up -d --build") + try: + result = subprocess.run( + ["docker", "compose", "up", "-d", "--build"], + cwd=str(stack_path), + capture_output=True, + text=True, + timeout=300, + ) + ok = result.returncode == 0 + logger.info(f"[rebuild] {stack_name} — {'ok' if ok else 'failed'} (rc={result.returncode})") + return JSONResponse({ + "ok": ok, + "stack": stack_name, + "returncode": result.returncode, + "stdout": result.stdout[-2000:] if result.stdout else "", + "stderr": result.stderr[-2000:] if result.stderr else "", + }) + except subprocess.TimeoutExpired: + logger.error(f"[rebuild] {stack_name} — timed out after 300s") + return JSONResponse({"ok": False, "error": "rebuild timed out (300s)"}, status_code=408) + except Exception as e: + logger.error(f"[rebuild] {stack_name} — error: {e}") + return JSONResponse({"ok": False, "error": str(e)}, status_code=500) + + +@app.post("/restart/{stack_name}") +async def restart_stack(stack_name: str): + """Restart a Docker Compose stack without rebuilding (compose restart). Called by Jon Snow approval gate. + No LLM involved — pure subprocess execution.""" + import re + if not re.match(r'^[a-zA-Z0-9_-]+$', stack_name): + return JSONResponse({"ok": False, "error": "invalid stack name"}, status_code=400) + + stack_path = STACKS_DIR / stack_name + if not stack_path.exists(): + return JSONResponse({"ok": False, "error": f"stack not found: {stack_name}"}, status_code=404) + + logger.info(f"[restart] {stack_name} — starting compose restart") + try: + result = subprocess.run( + ["docker", "compose", "restart"], + cwd=str(stack_path), + capture_output=True, + text=True, + timeout=120, + ) + ok = result.returncode == 0 + logger.info(f"[restart] {stack_name} — {'ok' if ok else 'failed'} (rc={result.returncode})") + return JSONResponse({ + "ok": ok, + "stack": stack_name, + "returncode": result.returncode, + "stdout": result.stdout[-2000:] if result.stdout else "", + "stderr": result.stderr[-2000:] if result.stderr else "", + }) + except subprocess.TimeoutExpired: + logger.error(f"[restart] {stack_name} — timed out after 120s") + return JSONResponse({"ok": False, "error": "restart timed out (120s)"}, status_code=408) + except Exception as e: + logger.error(f"[restart] {stack_name} — error: {e}") + return JSONResponse({"ok": False, "error": str(e)}, status_code=500)