From 1ed7699437b50b1446bed558d57a6f6737704928 Mon Sep 17 00:00:00 2001 From: kodi Date: Sat, 21 Feb 2026 12:04:21 +0100 Subject: [PATCH] feat(backend): image endpoints toegevoegd --- control/Dockerfile | 1 + control/app.py | 5 +- control/app_images.py | 157 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 162 insertions(+), 1 deletion(-) create mode 100644 control/app_images.py diff --git a/control/Dockerfile b/control/Dockerfile index fb8845d..16c5f77 100644 --- a/control/Dockerfile +++ b/control/Dockerfile @@ -3,4 +3,5 @@ WORKDIR /app RUN apt-get update && apt-get install -y curl systemd && rm -rf /var/lib/apt/lists/* RUN pip install fastapi uvicorn requests-unixsocket pyyaml pytest httpx COPY app.py . +COPY app_images.py . CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/control/app.py b/control/app.py index 8aaea00..cdf0d16 100644 --- a/control/app.py +++ b/control/app.py @@ -1,6 +1,7 @@ import os import sys import subprocess +from app_images import init_images_router from fastapi import FastAPI, HTTPException, Query from pydantic import BaseModel import requests_unixsocket @@ -16,7 +17,9 @@ PODMAN_API_BASE = "http+unix://%2Frun%2Fuser%2F1000%2Fpodman%2Fpodman.sock/v5.4. BASE_DIR = os.path.dirname(os.path.abspath(__file__)) ALLOWLIST_FILE = os.getenv("ALLOWLIST_FILE", os.path.join(BASE_DIR, "allowed_units.txt")) WORKLOADS_DIR = "/app/workloads" - +# --- ROUTERS --- +# Images API lives in a dedicated module to keep this file from growing further. +app.include_router(init_images_router(SESSION, PODMAN_API_BASE)) # --- ADAPTERS (contract-neutral helpers) --- # Centralize Podman socket and systemctl invocation. diff --git a/control/app_images.py b/control/app_images.py new file mode 100644 index 0000000..c5508da --- /dev/null +++ b/control/app_images.py @@ -0,0 +1,157 @@ +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): + # 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), + ): + 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)): + 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