227 lines
7.9 KiB
Python
227 lines
7.9 KiB
Python
# -*- 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
|