Initial commit - podman-mvp net na toevoegen cpu en mem kolommen
This commit is contained in:
@@ -0,0 +1,410 @@
|
||||
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)
|
||||
|
||||
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")
|
||||
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
|
||||
)
|
||||
|
||||
# JSON API
|
||||
#@app.post("/api/daemon-reload")
|
||||
#def api_daemon_reload():
|
||||
# code, out = run(["systemctl", "--user", "daemon-reload"])
|
||||
# return jsonify({"cmd": ["systemctl", "--user", "daemon-reload"], "exit": code, "output": out})
|
||||
|
||||
@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)
|
||||
Reference in New Issue
Block a user