chore: initial import
This commit is contained in:
+226
@@ -0,0 +1,226 @@
|
||||
# -*- 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
|
||||
Reference in New Issue
Block a user