server-up/app/core/docker.py
2026-05-09 13:55:29 +00:00

264 lines
8.1 KiB
Python

"""Docker CLI wrapper — no SDK needed."""
import json, os, re, subprocess, shutil, threading
from pathlib import Path
COMPOSE_NAMES = ("compose.yml", "compose.yaml", "docker-compose.yml", "docker-compose.yaml")
def _bin() -> str:
for p in ("/usr/local/bin/docker", "/usr/bin/docker"):
if os.path.isfile(p):
return p
return shutil.which("docker") or "docker"
def _run(cmd, **kw) -> subprocess.CompletedProcess:
kw.setdefault("capture_output", True)
kw.setdefault("text", True)
kw.setdefault("timeout", 60)
try:
return subprocess.run(cmd, **kw)
except FileNotFoundError:
return subprocess.CompletedProcess(cmd, 127, "", "docker niet gevonden")
except subprocess.TimeoutExpired:
return subprocess.CompletedProcess(cmd, 1, "", "timeout")
def _compose_base() -> list[str]:
# Probeer docker compose (plugin)
r = _run([_bin(), "compose", "version"], timeout=5)
if r.returncode == 0:
return [_bin(), "compose"]
# Probeer standalone docker-compose
dc = shutil.which("docker-compose")
if dc:
return [dc]
# Probeer compose plugin op alternatieve locaties
for plugin_dir in ("/usr/lib/docker/cli-plugins",
"/usr/libexec/docker/cli-plugins",
"/usr/local/lib/docker/cli-plugins",
os.path.expanduser("~/.docker/cli-plugins")):
compose_bin = os.path.join(plugin_dir, "docker-compose")
if os.path.isfile(compose_bin):
return [compose_bin]
# Laatste poging: docker-compose uit PATH
return [_bin(), "compose"]
# Cache compose base command (thread-safe)
_compose_cmd = None
_compose_lock = threading.Lock()
def _get_compose() -> list[str]:
global _compose_cmd
if _compose_cmd is None:
with _compose_lock:
if _compose_cmd is None:
_compose_cmd = _compose_base()
return _compose_cmd
def _stream(cmd, cwd=None, log_fn=None) -> int:
try:
p = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
text=True, cwd=cwd)
for line in p.stdout:
s = line.rstrip()
if s and log_fn:
log_fn(s)
return p.wait()
except Exception as e:
if log_fn:
log_fn(f"Fout: {e}")
return 1
# ── Info ──────────────────────────────────────────────────────────────────────
def info() -> dict:
r = _run([_bin(), "info", "--format", "json"], timeout=10)
if r.returncode == 0:
try:
d = json.loads(r.stdout)
return {"ok": True, "version": d.get("ServerVersion", "?"),
"containers": d.get("Containers", 0),
"running": d.get("ContainersRunning", 0),
"images": d.get("Images", 0)}
except Exception:
pass
# Fallback
ver = _run([_bin(), "version", "--format", "{{.Server.Version}}"], timeout=5)
ps = _run([_bin(), "ps", "-q"], timeout=5)
imgs = _run([_bin(), "images", "-q"], timeout=5)
return {
"ok": ver.returncode == 0,
"version": ver.stdout.strip() if ver.returncode == 0 else "?",
"running": len(ps.stdout.strip().splitlines()) if ps.returncode == 0 else 0,
"containers": 0,
"images": len(imgs.stdout.strip().splitlines()) if imgs.returncode == 0 else 0,
}
# ── Images ────────────────────────────────────────────────────────────────────
def images() -> list[dict]:
fmt = "{{.ID}}\t{{.Repository}}\t{{.Tag}}\t{{.Size}}\t{{.CreatedSince}}"
r = _run([_bin(), "images", "--format", fmt], timeout=15)
if r.returncode != 0:
return []
out = []
for line in r.stdout.strip().splitlines():
p = line.split("\t")
if len(p) >= 4:
out.append({"id": p[0], "repo": p[1], "tag": p[2],
"size": p[3], "age": p[4] if len(p) > 4 else ""})
return out
def rmi(image_id: str, force=False) -> tuple[bool, str]:
cmd = [_bin(), "rmi"]
if force:
cmd.append("-f")
cmd.append(image_id)
r = _run(cmd, timeout=30)
return r.returncode == 0, (r.stderr or r.stdout).strip()
def prune_images() -> tuple[bool, str]:
r = _run([_bin(), "image", "prune", "-af"], timeout=120)
return r.returncode == 0, (r.stdout or r.stderr).strip()
# ── Container management ─────────────────────────────────────────────────────
def restart_container(name: str) -> tuple[bool, str]:
r = _run([_bin(), "restart", name], timeout=30)
return r.returncode == 0, (r.stderr or r.stdout).strip()
# ── Compose helpers ───────────────────────────────────────────────────────────
def find_compose(d: Path) -> Path | None:
for n in COMPOSE_NAMES:
f = d / n
if f.exists():
return f
return None
def has_compose(d: Path) -> bool:
return find_compose(d) is not None
def compose_ps(d: Path, name=None) -> list[dict]:
f = find_compose(d)
if not f:
return []
cmd = _get_compose() + ["-f", str(f)]
if name:
cmd += ["-p", name]
cmd += ["ps", "--format", "json"]
r = _run(cmd, cwd=str(d), timeout=10)
if r.returncode != 0:
return []
out = []
for line in r.stdout.strip().splitlines():
try:
c = json.loads(line)
out.append({
"name": c.get("Name", ""),
"service": c.get("Service", ""),
"state": c.get("State", ""),
"status": c.get("Status", ""),
"ports": c.get("Ports", ""),
"running": c.get("State", "").lower() in ("running", "up"),
})
except Exception:
pass
return out
def compose_up(d: Path, log_fn=None, name=None) -> int:
f = find_compose(d)
if not f:
return 1
cmd = _get_compose() + ["-f", str(f)]
if name:
cmd += ["-p", name]
cmd += ["up", "-d", "--remove-orphans"]
return _stream(cmd, cwd=str(d), log_fn=log_fn)
def compose_down(d: Path, log_fn=None, volumes=False, name=None) -> int:
f = find_compose(d)
if not f:
return 1
cmd = _get_compose() + ["-f", str(f)]
if name:
cmd += ["-p", name]
cmd += ["down"]
if volumes:
cmd.append("-v")
return _stream(cmd, cwd=str(d), log_fn=log_fn)
def compose_pull(d: Path, log_fn=None) -> int:
f = find_compose(d)
if not f:
return 1
cmd = _get_compose() + ["-f", str(f), "pull"]
return _stream(cmd, cwd=str(d), log_fn=log_fn)
def compose_logs(d: Path, tail=80, name=None) -> str:
f = find_compose(d)
if not f:
return ""
cmd = _get_compose() + ["-f", str(f)]
if name:
cmd += ["-p", name]
cmd += ["logs", "--tail", str(tail), "--no-color"]
r = _run(cmd, cwd=str(d), timeout=15)
return r.stdout if r.returncode == 0 else r.stderr
def read_env(d: Path) -> str:
f = d / ".env"
return f.read_text() if f.exists() else ""
def write_env(d: Path, content: str):
f = d / ".env"
f.write_text(content)
def read_compose(d: Path) -> str:
f = find_compose(d)
return f.read_text() if f else ""
def write_compose(d: Path, content: str):
f = find_compose(d)
if f:
f.write_text(content)
def used_ports() -> set[int]:
r = _run([_bin(), "ps", "-a", "--format", "{{.Ports}}"], timeout=10)
ports = set()
if r.returncode == 0:
for m in re.findall(r"(?:0\.0\.0\.0|::):(\d+)->", r.stdout):
try:
ports.add(int(m))
except ValueError:
pass
return ports
def next_free_port(start=8100) -> int:
used = used_ports()
p = start
while p in used:
p += 1
return p