Files
podman-mvp/control/app_containers.py
T

389 lines
13 KiB
Python

import os
import asyncio
import json
from fastapi import APIRouter
from fastapi.responses import StreamingResponse
_SESSION = None
_PODMAN_API_BASE = None
# --- STATS CACHE (contract-neutral; in-memory) ---
# Poll Podman stats centrally and expose as optional dashboard fields.
_STATS_CACHE_BY_NAME = {} # name -> {"cpu": float|None, "mem_usage": float|None, "mem_perc": float|None}
_STATS_CACHE_TS = None
_STATS_POLLER_TASK = None
def _norm_container_name(name) -> str:
try:
return str(name or "").lstrip("/")
except Exception:
return ""
def _parse_stats_interval_seconds() -> float:
raw = os.getenv("STATS_INTERVAL_SECONDS", "1.0")
try:
v = float(raw)
except Exception:
v = 1.0
if v <= 0:
v = 1.0
if v < 0.5:
v = 0.5
if v > 30:
v = 30
return v
async def _stats_poller_loop():
global _STATS_CACHE_BY_NAME, _STATS_CACHE_TS
interval = _parse_stats_interval_seconds()
stats_url = f"{_PODMAN_API_BASE}/libpod/containers/stats?all=true&stream=false"
def _to_float(x):
try:
return float(x)
except Exception:
return None
while True:
try:
data = _SESSION.get(stats_url, timeout=5).json()
stats_list = data.get("Stats") if isinstance(data, dict) else None
if not isinstance(stats_list, list):
stats_list = []
new_cache = {}
for st in stats_list:
if not isinstance(st, dict):
continue
key = _norm_container_name(st.get("Name"))
if not key:
continue
# CPUPerc returned by Podman is already percentage (0.10 == 0.10%)
cpu_val = st.get("CPUPerc")
if cpu_val is None:
cpu_val = st.get("CPU")
if cpu_val is None:
cpu_val = st.get("AvgCPU")
new_cache[key] = {
"cpu": _to_float(cpu_val),
"mem_usage": _to_float(st.get("MemUsage")),
"mem_perc": _to_float(st.get("MemPerc")),
}
_STATS_CACHE_BY_NAME = new_cache
_STATS_CACHE_TS = int(__import__("time").time())
except Exception:
# Keep last good cache; try again next tick.
pass
await asyncio.sleep(interval)
async def start_stats_poller():
global _STATS_POLLER_TASK
if _STATS_POLLER_TASK and not _STATS_POLLER_TASK.done():
return
loop = asyncio.get_running_loop()
_STATS_POLLER_TASK = loop.create_task(_stats_poller_loop())
def init_containers_router(
session,
podman_api_base: str,
workloads_dir: str,
podman_get_json,
podman_get_text,
podman_post,
podman_action_post,
podman_delete,
systemctl_func,
systemd_then_podman,
map_pod_to_unit,
build_pod_to_containers_map,
) -> APIRouter:
router = APIRouter(tags=["containers"])
global _SESSION, _PODMAN_API_BASE
_SESSION = session
_PODMAN_API_BASE = podman_api_base
def find_defined_containers():
defined = {}
for root, _, files in os.walk(os.path.join(workloads_dir, "systemd")):
for f in files:
if f.endswith(".container"):
name = os.path.splitext(f)[0]
full = os.path.join(root, f)
rel = os.path.relpath(full, workloads_dir)
defined[name] = rel
return defined
def _ensure_container_status_field(container: dict):
# keep exact existing defaulting behavior
if "Status" not in container:
container["Status"] = container.get("State", "")
def _make_defined_container_dashboard_row(name: str, relpath: str):
# keep exact key set and default values as before
return {
"Names": [name],
"Image": "",
"State": "",
"Status": "",
"Ports": [],
"PodName": "",
"_dashboard_source": "systemd",
"_dashboard_unit": f"{name}.service",
"_dashboard_def_path": relpath,
"_dashboard_cpu": None,
"_dashboard_mem_usage": None,
"_dashboard_mem_perc": None,
}
def _legacy_dashboard_item_from_container(c: dict):
# Keep exact keys & defaults as before
return {
"name": (c.get("Names") or ["?"])[0],
"status": c.get("Status") or c.get("State") or "",
"path": "",
"ip": "",
"containers": [],
}
def _extract_published_ports(container: dict) -> list[str]:
"""
Normalize Podman API Ports into a stable display list:
- "127.0.0.1:8080:8000/tcp"
- "8080:8000/tcp" (if no host ip)
"""
out: list[str] = []
for p in (container.get("Ports") or []):
host_ip = p.get("host_ip") or p.get("HostIp") or ""
host_port = p.get("host_port") or p.get("HostPort")
cont_port = p.get("container_port") or p.get("ContainerPort")
proto = p.get("protocol") or p.get("Protocol") or ""
if host_port is None or cont_port is None:
continue
s = ""
if host_ip:
s += f"{host_ip}:"
s += f"{host_port}:{cont_port}"
if proto:
s += f"/{proto}"
out.append(s)
return out
@router.get("/containers-dashboard")
def containers_dashboard():
dashboard = []
defined = find_defined_containers()
# Cache zodat we niet voor elke container opnieuw systemctl doen
unit_active_cache = {}
stats_by_name = _STATS_CACHE_BY_NAME
def _unit_is_active(unit):
if not unit:
return False
if unit in unit_active_cache:
return unit_active_cache[unit]
code, out = systemctl_func(["systemctl", "--user", "is-active", unit])
ok = (code == 0) or ((out or "").strip() == "active")
unit_active_cache[unit] = ok
return ok
# A) echte containers (runtime)
real = podman_get_json(f"{podman_api_base}/libpod/containers/json?all=true")
for c in real:
_ensure_container_status_field(c)
# Published ports: behoud jouw hotfix
c["_dashboard_published_ports"] = _extract_published_ports(c)
# Normaliseer naam: Podman kan "/name" geven
rname = ((c.get("Names") or ["?"])[0] or "").lstrip("/")
# Optional live stats (always present; null on miss)
c["_dashboard_cpu"] = None
c["_dashboard_mem_usage"] = None
c["_dashboard_mem_perc"] = None
st = stats_by_name.get(rname)
if isinstance(st, dict):
c["_dashboard_cpu"] = st.get("cpu")
c["_dashboard_mem_usage"] = st.get("mem_usage")
c["_dashboard_mem_perc"] = st.get("mem_perc")
# 1) Managed: systemd als er een .container definitie bestaat
if rname in defined:
c["_dashboard_source"] = "systemd"
c["_dashboard_unit"] = f"{rname}.service"
c["_dashboard_def_path"] = defined[rname]
else:
# 2) Extra: zit container in een pod die via systemd (kube/quadlet) draait?
podname = (c.get("PodName") or "").strip()
pod_unit = map_pod_to_unit(podname) if podname else None
if pod_unit and _unit_is_active(pod_unit):
c["_dashboard_source"] = "systemd"
c["_dashboard_unit"] = pod_unit
# geen _dashboard_def_path, want dit is geen .container definitie
else:
c["_dashboard_source"] = "podman"
dashboard.append(c)
# B) Dedup set: ook genormaliseerd (voorkomt /name vs name doublures)
runtime_names = set((((c.get("Names") or ["?"])[0] or "").lstrip("/")) for c in real)
# C) defined containers from systemd/*.container (skip duplicates)
for name, relpath in defined.items():
if name in runtime_names:
continue
row = _make_defined_container_dashboard_row(name, relpath)
code, out = systemctl_func(["systemctl", "--user", "is-active", f"{name}.service"])
row["Status"] = (out or "").strip()
dashboard.append(row)
return dashboard
@router.get("/containers")
def list_containers():
# Ook hier ?all=true voor gestopte containers
url = f"{podman_api_base}/libpod/containers/json?all=true"
return podman_get_json(url)
@router.get("/containers/inspect/{name}")
def inspect_container(name: str):
return podman_get_json(f"{podman_api_base}/libpod/containers/{name}/json")
@router.get("/containers/logs/{name}")
def get_container_logs(name: str):
# We vragen de laatste 100 regels op (tail=100)
txt = podman_get_text(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": txt}
@router.get("/containers/stats/stream")
async def containers_stats_stream(interval: float = 2.0):
"""
SSE stream met periodieke container stats.
Contract-neutraal: nieuw endpoint, geen bestaande outputs aangepast.
"""
# Guardrails tegen misbruik
if interval < 0.5:
interval = 0.5
if interval > 30:
interval = 30
stats_url = f"{podman_api_base}/libpod/containers/stats?all=true&stream=false"
async def event_gen():
try:
while True:
# timeout zodat een haperende podman socket niet je stream “bevriest”
try:
data = session.get(stats_url, timeout=5).json()
except Exception as e:
data = {"Error": str(e), "Stats": []}
payload = {
"ts": int(__import__("time").time()),
"data": data,
}
yield "event: stats\n"
yield f"data: {json.dumps(payload, separators=(',',':'))}\n\n"
await asyncio.sleep(interval)
except asyncio.CancelledError:
return
headers = {
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no", # helpt bij proxies
}
return StreamingResponse(event_gen(), media_type="text/event-stream", headers=headers)
@router.get("/debug/defined-containers")
def debug_defined_containers():
return find_defined_containers()
@router.get("/dashboard")
def get_dashboard():
# Legacy dashboard view (keep shape)
try:
api_containers = podman_get_json(f"{podman_api_base}/libpod/containers/json?all=true")
except:
api_containers = []
items = []
for c in api_containers:
items.append(_legacy_dashboard_item_from_container(c))
return items
@router.post("/containers/{action}/{name}")
def container_action(action: str, name: str):
if action not in ("start", "stop", "restart"):
return {"error": "Invalid action"}, 400
defined = find_defined_containers()
_sys = {"code": None, "out": None}
def _systemd_call():
if name in defined:
code, out = systemctl_func(["systemctl", "--user", action, name])
_sys["code"] = code
_sys["out"] = out
if code == 0:
return {
"method": "systemd",
"name": name,
"unit": f"{name}.service",
"definition": defined[name],
"cmd": f"systemctl --user {action} {name}",
"exit": code,
"output": out,
}
return {"exit": code, "output": out}
return None
def _podman_call(systemd_res):
res = podman_action_post("containers", name, action)
if res.status_code in (200, 204):
return {"method": "podman", "name": name, "cmd": f"podman {action} {name}", "status_code": res.status_code}
if res.status_code >= 400:
return {
"method": "podman",
"name": name,
"cmd": f"podman {action} {name}",
"status_code": res.status_code,
"error": getattr(res, "text", "") or "",
}, res.status_code
if name in defined:
return {
"method": "systemd",
"name": name,
"unit": f"{name}.service",
"definition": defined[name],
"cmd": f"systemctl --user {action} {name}",
"exit": _sys["code"],
"output": _sys["out"],
}
return {"method": "podman", "name": name, "cmd": f"podman {action} {name}", "status_code": res.status_code}
return systemd_then_podman(_systemd_call, _podman_call)
return router