import os import subprocess from fastapi import FastAPI, HTTPException from pydantic import BaseModel import requests_unixsocket import uvicorn 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__)) ALLOWLIST_FILE = os.getenv("ALLOWLIST_FILE", os.path.join(BASE_DIR, "allowed_units.txt")) WORKLOADS_DIR = "/app/workloads" # --- MODELS --- class FileContent(BaseModel): content: str class FileCreate(BaseModel): path: str content: str @app.get("/workloads") def list_workloads(): yaml_files = [] for root, dirs, files in os.walk(WORKLOADS_DIR): for file in files: if file.endswith((".yaml", ".kube")): rel_path = os.path.relpath(os.path.join(root, file), WORKLOADS_DIR) yaml_files.append(rel_path) return {"workloads": sorted(yaml_files)} @app.get("/workloads/read/{filename:path}") def read_workload(filename: str): path = os.path.join(WORKLOADS_DIR, filename) if not os.path.exists(path): raise HTTPException(status_code=404) with open(path, 'r') as f: return {"filename": filename, "content": f.read()} @app.post("/workloads/save-file") def save_file(file_data: FileCreate): target_path = os.path.join(WORKLOADS_DIR, file_data.path) os.makedirs(os.path.dirname(target_path), exist_ok=True) with open(target_path, 'w') as f: f.write(file_data.content) return {"status": "success"} @app.post("/workloads/deploy/{filename:path}") def deploy_workload(filename: str): path = os.path.join(WORKLOADS_DIR, filename) with open(path, 'r') as f: yaml_content = f.read() url = f"{PODMAN_API_BASE}/libpod/kube/play" return SESSION.post(url, data=yaml_content).json() # --- PODS --- @app.get("/pods") def list_pods(): # Cruciaal: ?all=true zorgt dat EXIT_STATE pods ook getoond worden url = f"{PODMAN_API_BASE}/libpod/pods/json?all=true" return SESSION.get(url).json() @app.post("/actions/{action}/{name}") def take_action(action: str, name: str): # Podman gebruikt vaak 'pod' prefix voor pods aangemaakt via kube play possible_names = [f"pod{name}", name] if action == "start": # STAP 1: Probeer direct de pod te starten (de 'Cockpit' methode) for target in possible_names: res = SESSION.post(f"{PODMAN_API_BASE}/libpod/pods/{target}/start") # 204 = Succes, 304 = Draait al (ook succes) if res.status_code in [204, 304, 200]: print(f"DEBUG: {target} direct gestart (Status: {res.status_code})") return {"status": "started", "method": "direct_pod_start", "target": target} # STAP 2: Als direct starten niet kon (omdat de pod echt niet bestaat), gebruik YAML print(f"DEBUG: Pod niet gevonden via API, probeer YAML play voor {name}") target_path = None for root, _, files in os.walk(WORKLOADS_DIR): for f in files: if f.startswith(name) and f.endswith('.yaml'): target_path = os.path.join(root, f) break if target_path: with open(target_path, 'r') as file: yaml_content = file.read() res = SESSION.post(f"{PODMAN_API_BASE}/libpod/kube/play", data=yaml_content) # STAP 3: Laatste redmiddel - Als kube/play zegt dat hij al bestaat (jouw 500 error) # Dan forceren we een delete en een herstart. if res.status_code == 500 and "already exists" in res.text: print(f"DEBUG: Forceer herstart voor {name} wegens conflict") for target in possible_names: SESSION.delete(f"{PODMAN_API_BASE}/libpod/pods/{target}?force=true") # Probeer het nu opnieuw retry_res = SESSION.post(f"{PODMAN_API_BASE}/libpod/kube/play", data=yaml_content) return retry_res.json() return res.json() if action == "stop": for target in possible_names: res = SESSION.post(f"{PODMAN_API_BASE}/libpod/pods/{target}/stop") if res.status_code in [204, 304, 200]: return {"status": "stopped", "target": target} return {"status": "not_found_or_stopped"} return {"status": "unknown_action"} # --- CONTAINERS --- @app.get("/containers") def list_containers(): # Ook hier ?all=true voor gestopte containers url = f"{PODMAN_API_BASE}/libpod/containers/json?all=true" return SESSION.get(url).json() @app.post("/containers/{action}/{name}") def container_action(action: str, name: str): # Podman API pad: /libpod/containers/{name}/{action} url = f"{PODMAN_API_BASE}/libpod/containers/{name}/{action}" res = SESSION.post(url) return {"status": "success", "code": res.status_code} @app.get("/dashboard") def get_dashboard(): try: api_containers = SESSION.get(f"{PODMAN_API_BASE}/libpod/containers/json?all=true").json() except: api_containers = [] dashboard = {} for root, dirs, files in os.walk(WORKLOADS_DIR): for f in files: if f.endswith(('.yaml', '.kube', '.container')): rel_path = os.path.relpath(os.path.join(root, f), WORKLOADS_DIR) # Gebruik de bestandsnaam zonder extensie als unieke sleutel name_base = f.split('.')[0] # Als we mediaserver.yaml EN mediaserver.kube hebben, zien we ze als 1 item if name_base not in dashboard: # Zoek containers die horen bij dit bestand (fuzzy match op naam) matching = [c for c in api_containers if name_base in c['Names'][0]] status = "stopped" ip = "-" container_count = len(matching) if matching: # Als er minstens één container draait, is de status 'running' status = "running" if any(c['State'] == 'running' for c in matching) else "exited" # Pak IP van de eerste container die een IP heeft for c in matching: nets = c.get('Networks', {}) first_net = next(iter(nets.values()), {}) if nets else {} if first_net.get('IPAddress'): ip = first_net['IPAddress'] break dashboard[name_base] = { "name": name_base, "path": rel_path, "status": status, "ip": ip, "containers": container_count } return list(dashboard.values()) @app.get("/test-hybrid") def test_hybrid(): # 1. Check bestanden op schijf try: files = os.listdir(WORKLOADS_DIR) except Exception as e: return {"error": f"Kan map {WORKLOADS_DIR} niet lezen: {str(e)}"} # 2. Check Podman API try: api_res = SESSION.get(f"{PODMAN_API_BASE}/libpod/containers/json?all=true") api_containers = api_res.json() except Exception as e: api_containers = f"API Fout: {str(e)}" return { "bestanden_gevonden": files, "api_containers_aantal": len(api_containers) if isinstance(api_containers, list) else 0, "api_raw_sample": api_containers[0] if isinstance(api_containers, list) and len(api_containers) > 0 else "Geen containers" } @app.get("/files/tree") def get_file_tree(): tree = [] # os.walk gaat door alle mappen heen for root, dirs, files in os.walk(WORKLOADS_DIR): # JUISTE SYNTAX: os.path.relpath relative_path = os.path.relpath(root, WORKLOADS_DIR) if root != WORKLOADS_DIR else "" # Filter alleen de bestanden die we willen zien valid_files = [f for f in files if f.endswith(('.yaml', '.container', '.kube'))] # Voeg de map toe aan de lijst tree.append({ "path": relative_path, "dirs": dirs, "files": valid_files }) return tree @app.get("/files/read") def read_file_managed(path: str): full_path = os.path.normpath(os.path.join(WORKLOADS_DIR, path)) if not full_path.startswith(os.path.abspath(WORKLOADS_DIR)): raise HTTPException(status_code=403) if not os.path.exists(full_path): raise HTTPException(status_code=404) with open(full_path, 'r') as f: return {"content": f.read()} @app.post("/files/save") def save_file_managed(path: str, data: FileContent): full_path = os.path.normpath(os.path.join(WORKLOADS_DIR, path)) if not full_path.startswith(os.path.abspath(WORKLOADS_DIR)): raise HTTPException(status_code=403) os.makedirs(os.path.dirname(full_path), exist_ok=True) with open(full_path, 'w') as f: f.write(data.content) return {"status": "success", "path": path} @app.delete("/files/delete") def delete_item(path: str): full_path = os.path.normpath(os.path.join(WORKLOADS_DIR, path)) if not full_path.startswith(os.path.abspath(WORKLOADS_DIR)): raise HTTPException(status_code=403) if not os.path.exists(full_path): raise HTTPException(status_code=404) try: if os.path.isdir(full_path): # Verwijder een lege map os.rmdir(full_path) else: # Verwijder een bestand os.remove(full_path) return {"status": "deleted", "type": "folder" if os.path.isdir(full_path) else "file"} except OSError as e: # Dit gebeurt o.a. als een map niet leeg is raise HTTPException(status_code=400, detail=f"Kan niet verwijderen: {e.strerror}") @app.post("/files/mkdir") def create_directory(path: str): # We dwingen af dat als het pad niet met systemd begint, we het toevoegen if not path.startswith("systemd"): path = os.path.join("systemd", path) full_path = os.path.normpath(os.path.join(WORKLOADS_DIR, path)) # Veiligheidscheck blijft gelijk if not full_path.startswith(os.path.abspath(WORKLOADS_DIR)): raise HTTPException(status_code=403) os.makedirs(full_path, exist_ok=True) return {"status": "directory created", "path": path} @app.get("/containers/logs/{name}") def get_container_logs(name: str): # We vragen de laatste 100 regels op (tail=100) res = SESSION.get(f"{PODMAN_API_BASE}/libpod/containers/{name}/logs?stdout=true&stderr=true&tail=100") # Podman logs komen vaak met wat binaire metadata, we decoden dit als tekst return {"logs": res.text} @app.get("/containers/inspect/{name}") def inspect_container(name: str): res = SESSION.get(f"{PODMAN_API_BASE}/libpod/containers/{name}/json") return res.json() # SYSTEMCTL FUNCTIONS def run(cmd): r = subprocess.run(cmd, capture_output=True, text=True) out = (r.stdout or "") + (("\n" + r.stderr) if r.stderr else "") return r.returncode, out.strip() def read_allowlist(): """ Return: (set_of_units, allow_mode_bool) allow_mode_bool = True als er minstens 1 unit in allowlist staat. """ if not os.path.exists(ALLOWLIST_FILE): return set(), False allowed = set() with open(ALLOWLIST_FILE, "r", encoding="utf-8") as f: for line in f: line = line.strip() if not line or line.startswith("#"): continue # minimale sanity: alleen .service if line.endswith(".service"): allowed.add(line) return allowed, (len(allowed) > 0) @app.get("/systemd/allowlist") def systemd_allowlist(): allowed, allow_mode = read_allowlist() return {"allow_mode": allow_mode, "units": sorted(allowed)} def list_unit_files(): allowed, allow_mode = read_allowlist() if allow_mode: # whitelist = bron van waarheid return sorted(allowed) # fallback (als allowlist leeg is): probeer systemctl list-unit-files code, out = run(["systemctl", "--user", "list-unit-files", "--type=service", "--no-pager"]) if code != 0: return [] units = [] for line in out.splitlines(): line = line.strip() if not line or line.startswith("UNIT FILE") or line.startswith("UNIT"): continue parts = line.split() if parts and parts[0].endswith(".service"): units.append(parts[0]) return sorted(set(units)) def unit_state(unit): # active state _, active = run(["systemctl", "--user", "is-active", unit]) active = active.splitlines()[0].strip() if active else "unknown" # enabled state (kan falen in container-context) code, enabled_out = run(["systemctl", "--user", "is-enabled", unit]) enabled_out = enabled_out.splitlines()[0].strip() if enabled_out else "" if code != 0 or "Failed to get unit file state" in enabled_out: enabled = "unknown" else: enabled = enabled_out return active, enabled def get_units_for_ui(): all_units = list_unit_files() _, allow_mode = read_allowlist() result = [] for u in all_units: active, enabled = unit_state(u) result.append({"name": u, "active": active, "enabled": enabled}) return result, allow_mode def assert_allowed(unit): allowed, allow_mode = read_allowlist() if allow_mode and unit not in allowed: abort(403, description="Unit not allowed by allowlist") @app.get("/") def index(): units, allow_mode = get_units_for_ui() return render_template_string( HTML, units=units, output=None, allowfile=ALLOWLIST_FILE, allow_mode=allow_mode ) @app.post("/daemon-reload") def api_daemon_reload(): try: code, out = run(["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") # allowlist check allowed, allow_mode = read_allowlist() if allow_mode and unit not in allowed: raise HTTPException(status_code=403, detail="Unit not allowed by allowlist") cmd = ["systemctl", "--user", action, unit] code, out = run(cmd) return {"cmd": " ".join(cmd), "exit": code, "output": out} @app.post("/action") def action_form(): unit = request.form.get("unit", "") action = request.form.get("action", "") if action not in {"status", "start", "stop", "restart"}: return "Invalid action", 400 assert_allowed(unit) cmd = ["systemctl", "--user", action, unit] code, out = run(cmd) units, allow_mode = get_units_for_ui() return render_template_string( HTML, units=units, output=f"$ {' '.join(cmd)}\n(exit {code})\n\n{out}", allowfile=ALLOWLIST_FILE, allow_mode=allow_mode ) @app.post("/api//") def api_action(action, unit): if action not in {"status", "start", "stop", "restart"}: return jsonify({"error": "Invalid action"}), 400 assert_allowed(unit) cmd = ["systemctl", "--user", action, unit] code, out = run(cmd) return jsonify({"cmd": cmd, "exit": code, "output": out}) if __name__ == "__main__": uvicorn.run(app, host="0.0.0.0", port=8000)