pipekit/pipekit/api/app.py
Paul Trowbridge 7de9d83589 Add a groups JSON API and group-run cancellation
The group pages were browser-only, so external schedulers had no way to
trigger a group or read its outcome. Adds /api/groups, /api/group-runs and
their run/list endpoints under Basic auth, following the same async contract
as POST /modules/{id}/run.

Cancellation needed more than an endpoint. run_group deliberately continues
past module failures, so killing the in-flight jrunner alone would just let
the loop march on to the next member. A group-level flag in engine/cancel.py
is now checked before each member, and engine.request_group_cancel sets that
flag *and* signals the running module -- it lives in runner.py rather than
cancel.py because finding the live run needs the DB, and cancel.py is
deliberately DB-free.

Group final status gains 'cancelled', ranked above 'error' since the error is
usually the terminated jrunner process. group_run.status already permitted the
value; no migration needed.

Also adds a "cancel group" button to the group run page (web) and .pill
styling for cancelled/dry_run, neither of which had a color rule.

Note: /runs/{id}/cancel keeps its existing meaning -- cancel one member and
let the group continue -- which is now a deliberate contrast to cancel group.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 02:10:50 -04:00

175 lines
6.9 KiB
Python

"""FastAPI app factory.
JSON endpoints live under ``/api``. HTML pages (added in a later
increment) will live at ``/``. Keeping them separate avoids
content-negotiation complexity and keeps the API curl-testable.
"""
from __future__ import annotations
import logging
import sys
from contextlib import asynccontextmanager
from fastapi import FastAPI, Request
from fastapi.exception_handlers import http_exception_handler
from starlette.exceptions import HTTPException as StarletteHTTPException
import os
from .. import __version__, db, jrunner, repo
from ..web import mount_web
from .routes import connections, groups, introspect, modules, runs, system
_log = logging.getLogger("pipekit.web")
def _configure_logging() -> None:
"""Route pipekit's loggers to stderr (→ journal) with a consistent format.
Uvicorn configures only its own loggers, not the root, so without this our
records would fall through to the last-resort handler (WARNING+ only) and
INFO breadcrumbs would vanish. Idempotent — safe to call per create_app."""
base = logging.getLogger("pipekit")
if not base.handlers:
handler = logging.StreamHandler(sys.stderr)
handler.setFormatter(logging.Formatter(
"%(asctime)s %(levelname)s [%(name)s] %(message)s"))
base.addHandler(handler)
base.setLevel(logging.INFO)
base.propagate = False
_SECRET_KEYS = ("password", "passwd", "pwd", "secret", "token")
def _redact_body(raw: bytes, content_type: str, limit: int = 2000) -> str:
"""Decode a captured request body for logging, masking secret-looking
fields (passwords etc.) so they never reach the journal."""
if not raw:
return ""
text = raw.decode("utf-8", "replace")
ct = (content_type or "").lower()
try:
if "application/json" in ct:
import json as _json
obj = _json.loads(text)
if isinstance(obj, dict):
obj = {k: ("***" if any(s in k.lower() for s in _SECRET_KEYS) else v)
for k, v in obj.items()}
text = _json.dumps(obj)
else:
from urllib.parse import parse_qsl, urlencode
pairs = parse_qsl(text, keep_blank_values=True)
if pairs:
text = urlencode([(k, "***" if any(s in k.lower() for s in _SECRET_KEYS) else v)
for k, v in pairs])
except Exception: # noqa: BLE001
pass
return text[:limit] + ("…[truncated]" if len(text) > limit else "")
class _RequestBodyCapture:
"""Buffer each HTTP request body onto the scope (``pk_raw_body``) so the
exception handler can log the submitted payload on failure, then replay it
downstream so route handlers still read the body normally. Bodies here are
small form/JSON posts — no large uploads go through HTTP."""
def __init__(self, app):
self.app = app
async def __call__(self, scope, receive, send):
if scope["type"] != "http":
await self.app(scope, receive, send)
return
body = bytearray()
while True:
message = await receive()
if message["type"] == "http.request":
body += message.get("body", b"")
if not message.get("more_body", False):
break
else: # http.disconnect
break
scope["pk_raw_body"] = bytes(body)
replayed = False
async def replay():
nonlocal replayed
if not replayed:
replayed = True
return {"type": "http.request", "body": bytes(body), "more_body": False}
return {"type": "http.disconnect"}
await self.app(scope, replay, send)
def _live_pids() -> set[int]:
"""PIDs currently alive on this host (Linux /proc). Empty set elsewhere."""
try:
return {int(p) for p in os.listdir("/proc") if p.isdigit()}
except OSError:
return set()
@asynccontextmanager
async def _lifespan(app: FastAPI):
# Release module locks left behind by a previous process that died mid-run
# (a hard kill skips the engine's finally, so `running` stays 1 and future
# runs would refuse with "already running"). Clears locks held by a dead PID
# or older than 24h.
try:
n = repo.clear_stale_locks(live_pids=_live_pids())
if n:
_log.warning("cleared %d stale module lock(s) at startup", n)
except Exception: # noqa: BLE001
_log.exception("stale-lock cleanup failed")
# Finalise run_log / group_run rows abandoned by a process that died
# mid-run — in a fresh process nothing legitimate is running yet, so any
# `running` row is a leftover that would otherwise poll forever.
try:
runs, group_runs = repo.reconcile_abandoned_runs(
"run abandoned — pipekit process exited before completion "
"(reconciled at startup)")
if runs or group_runs:
_log.warning("reconciled %d abandoned run(s) and %d group run(s) "
"at startup", runs, group_runs)
except Exception: # noqa: BLE001
_log.exception("abandoned-run reconciliation failed")
from ..scheduler import start_scheduler
start_scheduler()
yield
def create_app() -> FastAPI:
_configure_logging()
app = FastAPI(title="Pipekit", version=__version__, lifespan=_lifespan)
app.add_middleware(_RequestBodyCapture)
@app.exception_handler(StarletteHTTPException)
async def _logged_http_exception_handler(request: Request,
exc: StarletteHTTPException):
# FastAPI turns HTTPException into a normal response and never logs it,
# so failures surfaced this way (e.g. wizard dest-provisioning errors)
# were invisible in the journal. Log them — with the submitted payload,
# secrets masked — then defer to the default handler for the response.
body = _redact_body(request.scope.get("pk_raw_body", b""),
request.headers.get("content-type", ""))
if exc.status_code >= 500:
_log.error("%s %s -> %d: %s | body: %s", request.method,
request.url.path, exc.status_code, exc.detail, body,
exc_info=exc)
elif exc.status_code >= 400:
_log.warning("%s %s -> %d: %s | body: %s", request.method,
request.url.path, exc.status_code, exc.detail, body)
return await http_exception_handler(request, exc)
app.include_router(system.router)
app.include_router(connections.router, prefix="/api")
app.include_router(introspect.router, prefix="/api")
app.include_router(groups.router, prefix="/api")
app.include_router(modules.router, prefix="/api")
app.include_router(runs.router, prefix="/api")
mount_web(app)
return app