import os import sys import subprocess from app_images import init_images_router from app_files import init_files_router from app_pods import init_pods_router from app_containers import init_containers_router, start_stats_poller from app_networks import init_networks_router from fastapi import FastAPI, HTTPException, Query from pydantic import BaseModel import requests_unixsocket import uvicorn import asyncio import json from pathlib import Path from fastapi.responses import StreamingResponse app = FastAPI(title="Podman MVP Control Plane", root_path="/api") SESSION = requests_unixsocket.Session() PODMAN_API_BASE = "http+unix://%2Frun%2Fuser%2F1000%2Fpodman%2Fpodman.sock/v5.4.2" BASE_DIR = os.path.dirname(os.path.abspath(__file__)) WORKLOADS_DIR = "/app/workloads" @app.on_event("startup") async def _startup_stats_poller(): await start_stats_poller() # --- ADAPTERS (contract-neutral helpers) --- # Centralize Podman socket and systemctl invocation. # MUST NOT change endpoint outputs, status codes, or side-effects. def _podman_get_json(url: str): return SESSION.get(url).json() def _podman_get_text(url: str) -> str: return SESSION.get(url).text def _podman_post(url: str, **kwargs): return SESSION.post(url, **kwargs) def _podman_action_post(kind: str, name: str, action: str): if kind == "pods": url = f"{PODMAN_API_BASE}/libpod/pods/{name}/{action}" else: url = f"{PODMAN_API_BASE}/libpod/containers/{name}/{action}" return _podman_post(url) def _podman_delete(url: str): return SESSION.delete(url) def _systemctl(cmd): # Proxy to existing run() to avoid behavioral changes. return run(cmd) def _run_systemctl_action(action: str, unit: str): cmd = ["systemctl", "--user", action, unit] return _systemctl(cmd) @app.get("/health") def health(): podman_ok = False try: r = SESSION.get(f"{PODMAN_API_BASE}/libpod/info", timeout=2) if r.status_code == 200: try: r.json() podman_ok = True except Exception: podman_ok = False except Exception: podman_ok = False systemd_reachable = False try: res = subprocess.run( ["systemctl", "--user", "list-units", "--no-pager", "--no-legend"], capture_output=True, text=True, check=False, timeout=2, ) systemd_reachable = (res.returncode == 0) except Exception: systemd_reachable = False ok = podman_ok and systemd_reachable return {"ok": ok, "podman": {"ok": podman_ok}, "systemd_user": {"reachable": systemd_reachable}} # --- DASHBOARD HELPERS (contract-neutral, no ordering/sorting changes) --- def _build_pod_to_containers_map(containers: list): # preserves original order of containers processing; no sorting added pod_to_containers = {} for c in containers: pod_name = c.get("PodName") or "" if pod_name: pod_to_containers.setdefault(pod_name, []).append((c.get("Names") or ["?"])[0]) return pod_to_containers def _map_pod_to_unit(podname: str) -> str | None: """ HOTFIX 3.1 FIX 1: If podname starts with "pod", map to .service (e.g. podmediaserver -> mediaserver.service) Else: .service """ if not podname: return None if podname.startswith("pod"): return f"{podname[3:]}.service" return f"{podname}.service" def _systemd_then_podman(systemd_callable, podman_callable): systemd_res = systemd_callable() if systemd_res is not None: if isinstance(systemd_res, dict) and systemd_res.get("exit", 1) == 0: return systemd_res return podman_callable(systemd_res) return podman_callable(None) # --- ROUTERS --- # Images API lives in dedicated modules to keep this file from growing further. app.include_router(init_images_router(SESSION, PODMAN_API_BASE)) app.include_router(init_files_router(SESSION, PODMAN_API_BASE, WORKLOADS_DIR)) app.include_router(init_networks_router(SESSION, PODMAN_API_BASE)) app.include_router(init_containers_router( SESSION, PODMAN_API_BASE, WORKLOADS_DIR, _podman_get_json, _podman_get_text, _podman_post, _podman_action_post, _podman_delete, _systemctl, _systemd_then_podman, _map_pod_to_unit, _build_pod_to_containers_map, )) app.include_router(init_pods_router( SESSION, PODMAN_API_BASE, WORKLOADS_DIR, _podman_get_json, _podman_post, _podman_delete, _systemctl, _podman_action_post, _map_pod_to_unit, _systemd_then_podman, _build_pod_to_containers_map, )) @app.get("/test-hybrid") def test_hybrid(): # 1. Check filesystem try: bestanden = [] for root, _, files in os.walk(WORKLOADS_DIR): for f in files: bestanden.append(os.path.join(root, f)) except Exception as e: bestanden = f"FS Fout: {str(e)}" # 2. Check Podman API try: api_containers = _podman_get_json(f"{PODMAN_API_BASE}/libpod/containers/json?all=true") except Exception as e: api_containers = f"API Fout: {str(e)}" return { "bestanden_gevonden": bestanden if isinstance(bestanden, list) else [], "api_containers_aantal": len(api_containers) if isinstance(api_containers, list) else -1, "api_raw_sample": api_containers[0] if isinstance(api_containers, list) and api_containers else api_containers, } @app.post("/daemon-reload") def api_daemon_reload(): try: code, out = _systemctl(["systemctl", "--user", "daemon-reload"]) return { "cmd": "systemctl --user daemon-reload", "exit": code, "output": out, } except Exception as e: raise HTTPException(status_code=500, detail=str(e)) @app.post("/{action}/{unit}") def api_action(action: str, unit: str): if action not in ("status", "start", "stop", "restart"): raise HTTPException(status_code=400, detail="Invalid action") cmd = ["systemctl", "--user", action, unit] code, out = _run_systemctl_action(action, unit) return {"cmd": " ".join(cmd), "exit": code, "output": out} @app.post("/api//") def legacy_api_action(action: str, unit: str): # legacy flask-like path; keep behavior (even if not used by index.html) if action not in ("status", "start", "stop", "restart"): return {"error": "Invalid action"}, 400 cmd = ["systemctl", "--user", action, unit] code, out = _run_systemctl_action(action, unit) return {"cmd": " ".join(cmd), "exit": code, "output": out} def run(cmd): try: result = subprocess.run(cmd, capture_output=True, text=True, check=False) output = (result.stdout or "") + (result.stderr or "") return result.returncode, output.strip() except Exception as e: return 1, str(e) if __name__ == "__main__": uvicorn.run(app, host="0.0.0.0", port=8000)