Live deploy console: stream compose up output to the browser (0.22.0)
Deploying a local stack from the editor now opens a console modal that streams
the `docker compose up -d` output (image pulls, container creation) live over a
new `/ws/deploy/{stack_id}` WebSocket, replacing the blind "Deploying…" spinner.
The compose subprocess keeps running server-side if the modal is closed early;
the same audit entry + start/error notification as the REST start path is recorded.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
5b59f5e8f9
commit
7592085ce9
@@ -62,7 +62,7 @@ def _map_docker(exc: DockerError):
|
||||
raise HTTPException(status_code=code, detail=exc.detail or exc.error)
|
||||
raise exc # falls through to the global 502 DockerError handler
|
||||
|
||||
AGENT_VERSION = "0.21.6"
|
||||
AGENT_VERSION = "0.22.0"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
+1
-1
@@ -55,7 +55,7 @@ async def lifespan(app: FastAPI):
|
||||
schedule_task.cancel()
|
||||
|
||||
|
||||
app = FastAPI(title="StackPilot", version="0.21.6", lifespan=lifespan)
|
||||
app = FastAPI(title="StackPilot", version="0.22.0", lifespan=lifespan)
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
|
||||
+60
-1
@@ -16,7 +16,8 @@ from sqlmodel import Session
|
||||
from auth import decode_token
|
||||
from database import engine
|
||||
from models.agent import Agent
|
||||
from services import compose_service
|
||||
from models.setting import EVENT_STACK_ERROR, EVENT_STACK_START
|
||||
from services import audit_service, compose_service, notify_service
|
||||
|
||||
logger = logging.getLogger("stackpilot.ws")
|
||||
|
||||
@@ -92,6 +93,64 @@ async def ws_service_logs(
|
||||
pass
|
||||
|
||||
|
||||
@router.websocket("/ws/deploy/{stack_id}")
|
||||
async def ws_deploy(
|
||||
websocket: WebSocket,
|
||||
stack_id: str,
|
||||
token: str | None = Query(default=None),
|
||||
):
|
||||
"""Run `docker compose up -d` and stream its output (image pulls, container
|
||||
creation) to the browser so the user sees deploy progress live. Records the
|
||||
same audit entry and notification as the REST `/start` endpoint."""
|
||||
await websocket.accept()
|
||||
if not await _authorize(websocket, token):
|
||||
return
|
||||
username = decode_token(token, "access").get("sub", "unknown") if token else "unknown"
|
||||
|
||||
rc: int | None = None
|
||||
disconnected = False
|
||||
compose_service.mark_busy(stack_id)
|
||||
try:
|
||||
async for kind, payload in compose_service.stream_up(stack_id):
|
||||
if kind == "log":
|
||||
await websocket.send_text(json.dumps({"type": "log", "line": payload}))
|
||||
else:
|
||||
rc = payload
|
||||
await websocket.send_text(json.dumps({"type": "done", "returncode": rc}))
|
||||
except WebSocketDisconnect:
|
||||
# Client navigated away; the compose subprocess keeps running so the
|
||||
# deploy still completes in the background.
|
||||
disconnected = True
|
||||
except Exception as exc: # noqa: BLE001
|
||||
with contextlib.suppress(Exception):
|
||||
await websocket.send_text(json.dumps({"type": "error", "detail": str(exc)}))
|
||||
finally:
|
||||
compose_service.clear_busy(stack_id)
|
||||
|
||||
ok = rc in (0, None)
|
||||
try:
|
||||
with Session(engine) as session:
|
||||
audit_service.record(
|
||||
session, user=username, action="stack.start", target=stack_id,
|
||||
detail=f"rc={rc} (deploy console)", ip="ws",
|
||||
)
|
||||
if ok:
|
||||
await notify_service.notify(
|
||||
EVENT_STACK_START, f"Stack '{stack_id}' started",
|
||||
"compose up completed successfully.", session,
|
||||
)
|
||||
else:
|
||||
await notify_service.notify(
|
||||
EVENT_STACK_ERROR, f"Stack '{stack_id}' start failed",
|
||||
"compose up returned a non-zero exit code.", session,
|
||||
)
|
||||
except Exception: # noqa: BLE001 - audit/notify are best-effort
|
||||
pass
|
||||
if not disconnected:
|
||||
with contextlib.suppress(Exception):
|
||||
await websocket.close()
|
||||
|
||||
|
||||
@router.websocket("/ws/agent-logs/{agent_id}/{stack_id}")
|
||||
async def ws_agent_logs(
|
||||
websocket: WebSocket,
|
||||
|
||||
@@ -329,6 +329,25 @@ async def stream_compose(
|
||||
await proc.wait()
|
||||
|
||||
|
||||
async def stream_up(stack_id: str, override: Optional[str] = None):
|
||||
"""Run `compose up -d` streaming combined output, so the deploy console can
|
||||
show image-pull and container-create progress live.
|
||||
|
||||
Yields ``("log", line)`` for each output line, then ``("done", returncode)``.
|
||||
"""
|
||||
cmd = _compose_base_cmd(stack_id, override) + ["up", "-d", "--remove-orphans"]
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
*cmd,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.STDOUT,
|
||||
)
|
||||
assert proc.stdout is not None
|
||||
async for raw in proc.stdout:
|
||||
yield ("log", raw.decode("utf-8", "replace").rstrip("\n"))
|
||||
await proc.wait()
|
||||
yield ("done", proc.returncode)
|
||||
|
||||
|
||||
# Convenience lifecycle wrappers ------------------------------------------------
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user