Initial commit - podman-mvp net na toevoegen cpu en mem kolommen

This commit is contained in:
kodi
2026-02-18 08:17:27 +01:00
commit 62e195c59e
56 changed files with 22164 additions and 0 deletions
+730
View File
@@ -0,0 +1,730 @@
import os
import subprocess
from fastapi import FastAPI, HTTPException, Query
from pydantic import BaseModel
import requests_unixsocket
import uvicorn
from pathlib import Path
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()
def _safe_workloads_path(rel_path: str) -> Path:
"""
rel_path is iets als: "systemd/demo-web"
Zorgt dat je niet buiten WORKLOADS_DIR kunt komen (geen ../)
"""
base = Path(WORKLOADS_DIR).resolve()
rel = (rel_path or "").lstrip("/")
# hard fence: alleen systemd subtree
if rel == "systemd" or rel.startswith("systemd/"):
pass
else:
raise HTTPException(status_code=400, detail="Only systemd subtree is allowed")
# geen systeemd root verwijderen
if rel == "systemd":
raise HTTPException(status_code=400, detail="Refusing to delete systemd root")
target = (base / rel).resolve()
# ensure target binnen base
if not str(target).startswith(str(base) + "/"):
raise HTTPException(status_code=400, detail="Invalid path")
return target
@app.delete("/files/rmdir")
def files_rmdir(path: str = Query(..., description="Directory path under systemd/")):
target = _safe_workloads_path(path)
if not target.exists():
raise HTTPException(status_code=404, detail="Directory not found")
if not target.is_dir():
raise HTTPException(status_code=400, detail="Path is not a directory")
# echte leeg-check (files én subdirs)
entries = list(target.iterdir())
if entries:
# geef wat context terug
files = [p.name for p in entries if p.is_file()]
dirs = [p.name for p in entries if p.is_dir()]
raise HTTPException(
status_code=409,
detail={"error": "directory not empty", "files": files, "dirs": dirs}
)
target.rmdir()
return {"deleted": True, "path": path}
# --- 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"}
@app.get("/pods-dashboard")
def pods_dashboard():
"""
Combineer:
A) pods die echt bestaan (Podman API)
B) pods die gedefinieerd zijn via workloads/systemd (ook als ze niet bestaan)
Extra:
- Voeg per pod een lijst "Containers" toe met container-namen.
"""
# 0) Bouw mapping: pod_name -> [container_names...]
containers = SESSION.get(f"{PODMAN_API_BASE}/libpod/containers/json?all=true").json()
containers_by_pod = {}
for c in containers:
pod_name = c.get("PodName") or ""
if not pod_name:
continue
names = c.get("Names") or []
if isinstance(names, list) and names:
cname = names[0]
else:
cname = c.get("Name") or c.get("name") or ""
cname = (cname or "").lstrip("/")
if not cname:
continue
containers_by_pod.setdefault(pod_name, []).append(cname)
# 1) A) echte pods
api_pods = SESSION.get(f"{PODMAN_API_BASE}/libpod/pods/json?all=true").json()
by_name = {}
for p in api_pods:
name = p.get("Name") or p.get("name")
if not name:
continue
by_name[name] = {
"Name": name,
"Status": p.get("Status") or p.get("status") or "unknown",
"Source": "podman",
"Unit": None,
"Containers": sorted(containers_by_pod.get(name, [])),
}
# 2) B) gedefinieerde pods: scan WORKLOADS_DIR naar *.yaml/*.kube
managed = set()
for root, _, files in os.walk(WORKLOADS_DIR):
for fn in files:
if not fn.endswith((".yaml", ".kube")):
continue
base = os.path.splitext(fn)[0] # mediaserver
pod_name = f"pod{base}" # podmediaserver
unit_name = f"{base}.service" # mediaserver.service
managed.add((pod_name, unit_name))
for pod_name, unit_name in sorted(managed):
if pod_name not in by_name:
# bestaat niet als pod => toch tonen met systemd status
code, out = run(["systemctl", "--user", "is-active", unit_name])
active = (out.strip() if out else "unknown")
status = "Running" if active == "active" else "Stopped (defined)"
by_name[pod_name] = {
"Name": pod_name,
"Status": status,
"Source": "systemd",
"Unit": unit_name,
"Containers": sorted(containers_by_pod.get(pod_name, [])),
}
else:
# bestaat wel: verrijk met unit-info (handig voor UI)
by_name[pod_name]["Unit"] = unit_name
return list(by_name.values())
# helper blok
def podname_to_systemd_unit(podname: str) -> str | None:
"""
Jouw conventie:
podmediaserver -> mediaserver.service
"""
if podname.startswith("pod") and len(podname) > 3:
base = podname[3:]
return f"{base}.service"
return None
def try_systemd_pod_action(action: str, podname: str):
"""
Probeert systemd --user <action> <unit>.
Return dict met exit/output + unit, of None als geen mapping.
"""
unit = podname_to_systemd_unit(podname)
if not unit:
return None
code, out = run(["systemctl", "--user", action, unit])
return {
"method": "systemd",
"pod": podname,
"unit": unit,
"cmd": f"systemctl --user {action} {unit}",
"exit": code,
"output": out,
}
def find_defined_containers():
"""
Zoekt naar *.container onder WORKLOADS_DIR/systemd (recursief).
Return: dict name -> relpath (bijv {"sonarr": "sonarr/sonarr.container"})
"""
base = os.path.join(WORKLOADS_DIR, "systemd")
defined = {}
if not os.path.isdir(base):
return defined
for root, _, files in os.walk(base):
for fn in files:
if not fn.endswith(".container"):
continue
name = fn[:-len(".container")] # sonarr
rel = os.path.relpath(os.path.join(root, fn), base)
defined[name] = rel
return defined
# einde helper blok
# endpoint blok
@app.post("/pods/actions/{action}/{podname}")
def pod_action_prefer_systemd(action: str, podname: str):
"""
Prefer systemd voor pods die beginnen met 'pod' (quadlet/kube-gegenereerd),
anders fallback naar bestaande Podman take_action().
"""
if action not in {"start", "stop", "restart"}:
return {"error": "Invalid action"}, 400
# Als de podnaam begint met "pod" (bv podmediaserver), haal dan "pod" eraf voor fallback
base_name = podname[3:] if podname.startswith("pod") and len(podname) > 3 else podname
# 1) Probeer systemd (alleen als mapping bestaat)
sys_res = try_systemd_pod_action(action, podname)
if sys_res is not None:
if sys_res["exit"] == 0:
return sys_res
# systemd faalde -> fallback naar Podman
return {
"method": "systemd_then_podman",
"systemd": sys_res,
"podman": take_action(action, base_name),
"note": "systemd failed, fell back to podman",
}
# 2) Geen mapping -> fallback naar Podman
return {"method": "podman", "result": take_action(action, base_name)}
@app.get("/containers-dashboard")
def containers_dashboard():
# A) echte containers
real = SESSION.get(f"{PODMAN_API_BASE}/libpod/containers/json?all=true").json()
by_name = {}
for c in real:
# jouw UI pakt meestal Names[0]
names = c.get("Names") or []
name = names[0] if isinstance(names, list) and names else (c.get("Name") or c.get("name"))
if not name:
continue
# normalize: sommige APIs geven "/sonarr"
name = name.lstrip("/")
c["_dashboard_source"] = "podman"
labels = c.get("Labels") or {}
unit = labels.get("PODMAN_SYSTEMD_UNIT")
if unit:
c["_dashboard_source"] = "systemd(kube)"
c["_dashboard_unit"] = unit
if not c.get("Status"):
c["Status"] = c.get("State") or c.get("status") or ""
by_name[name] = c
# B) defined containers uit .container files
defined = find_defined_containers()
for name, relpath in defined.items():
if name in by_name:
# container bestaat al, verrijk met systemd info
by_name[name]["_dashboard_source"] = "podman+systemd"
by_name[name]["_dashboard_unit"] = name
by_name[name]["_dashboard_def_path"] = relpath
continue
# bestaat niet -> voeg defined entry toe
by_name[name] = {
"Names": [name],
"Status": "Stopped (defined)",
"State": "defined",
"Image": "",
"PodName": "",
"Ports": [],
"_dashboard_source": "systemd",
"_dashboard_unit": name,
"_dashboard_def_path": relpath,
}
return list(by_name.values())
# einde endpointblok
# --- 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):
if action not in {"start", "stop", "restart"}:
return {"error": "Invalid action"}, 400
# 1) Eerst: probeer Podman (voor bestaande containers)
url = f"{PODMAN_API_BASE}/libpod/containers/{name}/{action}"
res = SESSION.post(url)
# 200/204/304 zien we als succes (304 = "was al in die state")
if res.status_code in (200, 204, 304):
return {
"method": "podman",
"name": name,
"cmd": f"POST {url}",
"status_code": res.status_code,
}
# 2) Als Podman faalt: check of dit een .container definition is => systemd fallback
defined = find_defined_containers() # deze helper moet je ook hebben
if name in defined:
code, out = run(["systemctl", "--user", action, name])
return {
"method": "systemd",
"name": name,
"unit": name,
"definition": defined[name],
"cmd": f"systemctl --user {action} {name}",
"exit": code,
"output": out,
}
# 3) Anders: echte Podman error teruggeven
return {
"method": "podman",
"name": name,
"cmd": f"POST {url}",
"status_code": res.status_code,
"error": res.text,
}, res.status_code
@app.get("/debug/defined-containers")
def debug_defined_containers():
return find_defined_containers()
@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/<action>/<unit>")
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)