# -*- coding: utf-8 -*- """ Job-Runner für Plugins, die ein bestehendes Kommandozeilen-Tool kapseln. Die Original-Skripte bleiben unverändert. Sie werden als Subprozess gestartet, ihre Ausgabe wird zeilenweise gepuffert und per Server-Sent-Events live in die Oberfläche gestreamt. Damit sieht ein CLI-Tool im Dashboard genauso aus wie ein Tool mit eigenem Webinterface. Sicherheit: Die Argumentliste baut immer das Plugin-Backend aus geprüften Eingaben. Es gibt bewusst keinen Endpunkt, der beliebige Kommandos annimmt. """ from __future__ import annotations import asyncio import json import os import time import uuid from dataclasses import dataclass, field from pathlib import Path from typing import Any, Iterable from fastapi import APIRouter, HTTPException from fastapi.responses import StreamingResponse MAX_LINES = 4000 @dataclass class Job: id: str plugin: str label: str argv: list[str] cwd: str status: str = "running" # running | done | failed | cancelled returncode: int | None = None started_at: float = field(default_factory=time.time) ended_at: float | None = None lines: list[dict[str, str]] = field(default_factory=list) artifacts: list[dict[str, str]] = field(default_factory=list) _process: Any = None _waiters: list[asyncio.Queue] = field(default_factory=list) def add(self, stream: str, text: str) -> None: entry = {"stream": stream, "text": text, "t": round(time.time() - self.started_at, 2)} self.lines.append(entry) if len(self.lines) > MAX_LINES: del self.lines[: len(self.lines) - MAX_LINES] for queue in list(self._waiters): queue.put_nowait(entry) def finish(self, status: str, returncode: int | None) -> None: self.status = status self.returncode = returncode self.ended_at = time.time() for queue in list(self._waiters): queue.put_nowait(None) def info(self) -> dict[str, Any]: return { "id": self.id, "plugin": self.plugin, "label": self.label, "command": " ".join(self.argv), "status": self.status, "returncode": self.returncode, "started_at": self.started_at, "ended_at": self.ended_at, "duration": round((self.ended_at or time.time()) - self.started_at, 1), "artifacts": self.artifacts, } class JobManager: """Hält alle Läufe der Suite im Speicher (bewusst kein Zustand auf Platte).""" def __init__(self, keep: int = 40) -> None: self._jobs: dict[str, Job] = {} self._keep = keep # ------------------------------------------------------------- Starten async def start( self, *, plugin: str, label: str, argv: Iterable[str], cwd: Path, env: dict[str, str] | None = None, ) -> Job: argv = [str(a) for a in argv] job = Job(id=uuid.uuid4().hex[:12], plugin=plugin, label=label, argv=argv, cwd=str(cwd)) self._jobs[job.id] = job self._prune() run_env = dict(os.environ) run_env.update(env or {}) run_env.setdefault("PYTHONUNBUFFERED", "1") run_env.setdefault("PYTHONIOENCODING", "utf-8") job.add("meta", "$ " + " ".join(argv)) try: process = await asyncio.create_subprocess_exec( *argv, cwd=str(cwd), env=run_env, stdin=asyncio.subprocess.DEVNULL, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, ) except Exception as exc: job.add("stderr", f"Start fehlgeschlagen: {exc}") job.finish("failed", None) return job job._process = process asyncio.create_task(self._pump(job, process)) return job async def _pump(self, job: Job, process) -> None: async def read(stream, name): while True: raw = await stream.readline() if not raw: break job.add(name, raw.decode("utf-8", "replace").rstrip("\n")) await asyncio.gather(read(process.stdout, "stdout"), read(process.stderr, "stderr")) code = await process.wait() if job.status == "cancelled": job.finish("cancelled", code) else: job.finish("done" if code == 0 else "failed", code) # -------------------------------------------------------------- Zugriff def get(self, job_id: str) -> Job | None: return self._jobs.get(job_id) def list(self, plugin: str | None = None) -> list[Job]: jobs = [j for j in self._jobs.values() if plugin is None or j.plugin == plugin] return sorted(jobs, key=lambda j: j.started_at, reverse=True) def running(self, plugin: str | None = None) -> list[Job]: return [j for j in self.list(plugin) if j.status == "running"] async def cancel(self, job_id: str) -> bool: job = self._jobs.get(job_id) if not job or job.status != "running" or job._process is None: return False job.status = "cancelled" job.add("meta", "Abbruch angefordert …") try: job._process.terminate() except ProcessLookupError: pass return True def _prune(self) -> None: finished = [j for j in self.list() if j.status != "running"] for job in finished[self._keep:]: self._jobs.pop(job.id, None) def job_router(jobs: JobManager, plugin: str) -> APIRouter: """Standard-Endpunkte, die jedes CLI-Plugin einbinden kann.""" router = APIRouter() @router.get("/api/jobs") def list_jobs() -> dict[str, Any]: return {"jobs": [j.info() for j in jobs.list(plugin)]} @router.get("/api/jobs/{job_id}") def job_detail(job_id: str) -> dict[str, Any]: job = jobs.get(job_id) if not job or job.plugin != plugin: raise HTTPException(404, "Lauf nicht gefunden.") return {**job.info(), "lines": job.lines} @router.post("/api/jobs/{job_id}/cancel") async def cancel_job(job_id: str) -> dict[str, Any]: job = jobs.get(job_id) if not job or job.plugin != plugin: raise HTTPException(404, "Lauf nicht gefunden.") ok = await jobs.cancel(job_id) return {"ok": ok, "status": job.status} @router.get("/api/jobs/{job_id}/events") async def job_events(job_id: str): job = jobs.get(job_id) if not job or job.plugin != plugin: raise HTTPException(404, "Lauf nicht gefunden.") async def stream(): queue: asyncio.Queue = asyncio.Queue() backlog = list(job.lines) job._waiters.append(queue) try: for entry in backlog: yield "event: line\ndata: " + json.dumps(entry, ensure_ascii=False) + "\n\n" if job.status != "running": yield "event: end\ndata: " + json.dumps(job.info(), ensure_ascii=False) + "\n\n" return while True: try: entry = await asyncio.wait_for(queue.get(), timeout=20) except asyncio.TimeoutError: yield ": ping\n\n" continue if entry is None: yield "event: end\ndata: " + json.dumps(job.info(), ensure_ascii=False) + "\n\n" return yield "event: line\ndata: " + json.dumps(entry, ensure_ascii=False) + "\n\n" finally: if queue in job._waiters: job._waiters.remove(queue) return StreamingResponse( stream(), media_type="text/event-stream", headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}, ) return router