e469508570
- Swagger UI v5.32.1 lokaal in assets/swagger-ui/ (geen CDN, offline bruikbaar) - webui/html/docs/index.html: custom pagina die /api/openapi.json laadt met requestInterceptor zodat Try it out via same-origin werkt - Link toegevoegd aan dashboard "Snel acties": API docs ↗ (opent in nieuw tabblad) - Docstrings toegevoegd aan destructieve endpoints (app_containers, app_images): container stop/restart, image remove (batch + single), image prune geven nu ⚠️-waarschuwingen in de Swagger UI beschrijving - Backend rebuild nodig voor docstrings zichtbaar in spec Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
161 lines
5.5 KiB
Python
161 lines
5.5 KiB
Python
from __future__ import annotations
|
|
|
|
import io
|
|
import os
|
|
import tarfile
|
|
import tempfile
|
|
from pathlib import Path
|
|
from typing import List
|
|
|
|
from fastapi import APIRouter, HTTPException, Query
|
|
from pydantic import BaseModel
|
|
|
|
class ImageRemoveRequest(BaseModel):
|
|
images: List[str]
|
|
force: bool = False
|
|
ignore: bool = False
|
|
|
|
class ImageBuildRequest(BaseModel):
|
|
# paden RELATIEF t.o.v. /app/workloads (Files tab)
|
|
context_dir: str
|
|
dockerfile: str # bv "Dockerfile" of "subdir/Dockerfile" binnen context
|
|
tag: str # bv "localhost/testimg:latest"
|
|
pull: bool = False
|
|
nocache: bool = False
|
|
|
|
## Helpers ##
|
|
|
|
def _safe_join(root: Path, rel: str) -> Path:
|
|
p = (root / rel).resolve()
|
|
root_resolved = root.resolve()
|
|
if root_resolved not in p.parents and p != root_resolved:
|
|
raise HTTPException(status_code=400, detail="Path escapes workloads root")
|
|
return p
|
|
|
|
def _create_context_tar(context_dir: Path) -> str:
|
|
# Maak tar in /tmp om niet alles in RAM te houden
|
|
tmp = tempfile.NamedTemporaryFile(prefix="podman-mvp-buildctx-", suffix=".tar", delete=False)
|
|
tmp_path = tmp.name
|
|
tmp.close()
|
|
|
|
with tarfile.open(tmp_path, "w") as tf:
|
|
# Voeg alles toe uit context_dir
|
|
for root, dirs, files in os.walk(context_dir):
|
|
root_path = Path(root)
|
|
for name in files:
|
|
fp = root_path / name
|
|
# tar-path moet relatief zijn aan context_dir
|
|
arcname = fp.relative_to(context_dir)
|
|
tf.add(fp, arcname=str(arcname))
|
|
return tmp_path
|
|
|
|
## Einde Helpers
|
|
|
|
|
|
|
|
def _raise_on_error(resp):
|
|
if 200 <= resp.status_code < 300:
|
|
return
|
|
# Podman API geeft vaak JSON error-body; maar text is altijd safe
|
|
raise HTTPException(status_code=resp.status_code, detail=resp.text)
|
|
|
|
|
|
def init_images_router(session, podman_api_base: str) -> APIRouter:
|
|
router = APIRouter(prefix="/images", tags=["images"])
|
|
|
|
@router.get("")
|
|
def list_images():
|
|
url = f"{podman_api_base}/libpod/images/json"
|
|
resp = session.get(url)
|
|
_raise_on_error(resp)
|
|
return resp.json()
|
|
|
|
# --- STAP 2: remove selected (batch) ---
|
|
@router.post("/remove")
|
|
def remove_images(req: ImageRemoveRequest):
|
|
"""⚠️ Destructief: verwijdert één of meerdere images permanent. Niet terug te draaien."""
|
|
# Libpod heeft batch remove via query params (images=...).
|
|
url = f"{podman_api_base}/libpod/images/remove"
|
|
params = {
|
|
"images": req.images,
|
|
"force": str(req.force).lower(),
|
|
"ignore": str(req.ignore).lower(),
|
|
}
|
|
resp = session.delete(url, params=params)
|
|
_raise_on_error(resp)
|
|
return resp.json()
|
|
|
|
# Convenience: delete single image (handig voor UI per-row)
|
|
@router.delete("/{image_ref:path}")
|
|
def remove_image(
|
|
image_ref: str,
|
|
force: bool = Query(False),
|
|
ignore: bool = Query(False),
|
|
):
|
|
"""⚠️ Destructief: verwijdert één image permanent op basis van naam of ID."""
|
|
url = f"{podman_api_base}/libpod/images/remove"
|
|
params = {
|
|
"images": [image_ref],
|
|
"force": str(force).lower(),
|
|
"ignore": str(ignore).lower(),
|
|
}
|
|
resp = session.delete(url, params=params)
|
|
_raise_on_error(resp)
|
|
return resp.json()
|
|
|
|
# --- STAP 2: prune (dangling default, all=true => unused) ---
|
|
@router.post("/prune")
|
|
def prune_images(all: bool = Query(False)):
|
|
"""⚠️ Destructief: verwijdert dangling images (standaard) of alle ongebruikte images (`all=true`)."""
|
|
url = f"{podman_api_base}/libpod/images/prune"
|
|
params = {"all": str(all).lower()}
|
|
resp = session.post(url, params=params)
|
|
_raise_on_error(resp)
|
|
return resp.json()
|
|
|
|
@router.post("/build")
|
|
def build_image(req: ImageBuildRequest):
|
|
if not req.context_dir.startswith("systemd/"):
|
|
raise HTTPException(status_code=400, detail="context_dir must start with systemd/")
|
|
|
|
workloads_root = Path("/app/workloads")
|
|
|
|
context_dir = _safe_join(workloads_root, req.context_dir)
|
|
if not context_dir.is_dir():
|
|
raise HTTPException(status_code=400, detail="context_dir is not a directory")
|
|
|
|
dockerfile_path = (context_dir / req.dockerfile).resolve()
|
|
if context_dir.resolve() not in dockerfile_path.parents:
|
|
raise HTTPException(status_code=400, detail="dockerfile must be inside context_dir")
|
|
if not dockerfile_path.is_file():
|
|
raise HTTPException(status_code=400, detail="dockerfile not found")
|
|
|
|
tar_path = _create_context_tar(context_dir)
|
|
try:
|
|
url = f"{podman_api_base}/build"
|
|
params = {
|
|
"dockerfile": str(Path(req.dockerfile)),
|
|
"t": req.tag,
|
|
"pull": str(req.pull).lower(),
|
|
"nocache": str(req.nocache).lower(),
|
|
}
|
|
|
|
with open(tar_path, "rb") as f:
|
|
resp = session.post(
|
|
url,
|
|
params=params,
|
|
data=f,
|
|
headers={"Content-Type": "application/x-tar"},
|
|
)
|
|
_raise_on_error(resp)
|
|
|
|
# Build API geeft doorgaans JSON-lines/stream tekst terug; voor MVP geven we raw text terug.
|
|
return {"ok": True, "output": resp.text}
|
|
finally:
|
|
try:
|
|
os.unlink(tar_path)
|
|
except OSError:
|
|
pass
|
|
|
|
return router
|