feat: add approved stack lifecycle endpoints

This commit is contained in:
2026-06-23 17:18:30 +00:00
parent 0c3e88ac2f
commit 695c6c451d
+81 -1
View File
@@ -10,7 +10,7 @@ from pathlib import Path
import httpx import httpx
from fastapi import BackgroundTasks, FastAPI, HTTPException, Request from fastapi import BackgroundTasks, FastAPI, HTTPException, Request
from fastapi.responses import HTMLResponse, RedirectResponse from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
from pydantic import BaseModel from pydantic import BaseModel
logging.basicConfig(level=logging.INFO, format="%(asctime)s [qyburn] %(message)s") 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") logger.info(f"[{job_id[:8]}] handoff received — pending review")
_write_last_run() _write_last_run()
return {"status": "pending_review", "job_id": job_id} 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)