201 lines
5.4 KiB
Python
201 lines
5.4 KiB
Python
import atexit
|
|
import hashlib
|
|
import os
|
|
import signal
|
|
import subprocess
|
|
import threading
|
|
from dataclasses import dataclass, field
|
|
from pathlib import Path
|
|
|
|
from src import log
|
|
|
|
|
|
@dataclass
|
|
class Mount:
|
|
src: Path
|
|
dst: str
|
|
readonly: bool = False
|
|
|
|
def as_arg(self) -> str:
|
|
flag = ":ro" if self.readonly else ""
|
|
return f"{self.src}:{self.dst}{flag}"
|
|
|
|
|
|
_active: set["Container"] = set()
|
|
_lock = threading.Lock()
|
|
_handlers_installed = False
|
|
|
|
|
|
def _install_handlers() -> None:
|
|
global _handlers_installed
|
|
if _handlers_installed:
|
|
return
|
|
_handlers_installed = True
|
|
|
|
def cleanup(*_):
|
|
_shutdown_all()
|
|
|
|
atexit.register(_shutdown_all)
|
|
for s in (signal.SIGINT, signal.SIGTERM):
|
|
try:
|
|
signal.signal(s, lambda *_: (_shutdown_all(), os._exit(130)))
|
|
except (ValueError, OSError):
|
|
pass
|
|
|
|
|
|
def _shutdown_all() -> None:
|
|
with _lock:
|
|
cs = list(_active)
|
|
_active.clear()
|
|
for c in cs:
|
|
try:
|
|
subprocess.run(
|
|
["podman", "rm", "-f", c.name],
|
|
stdout=subprocess.DEVNULL,
|
|
stderr=subprocess.DEVNULL,
|
|
check=False,
|
|
)
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
@dataclass
|
|
class Container:
|
|
name: str
|
|
image: str
|
|
mounts: list[Mount] = field(default_factory=list)
|
|
tmpfs: list[str] = field(default_factory=list)
|
|
network: bool = False
|
|
extra_env: dict[str, str] = field(default_factory=dict)
|
|
started: bool = False
|
|
|
|
def __hash__(self) -> int:
|
|
return id(self)
|
|
|
|
def __eq__(self, other) -> bool:
|
|
return self is other
|
|
|
|
def start(self) -> None:
|
|
if self.started:
|
|
return
|
|
_install_handlers()
|
|
# Ensure no stale container.
|
|
subprocess.run(
|
|
["podman", "rm", "-f", self.name],
|
|
stdout=subprocess.DEVNULL,
|
|
stderr=subprocess.DEVNULL,
|
|
check=False,
|
|
)
|
|
argv = [
|
|
"podman",
|
|
"run",
|
|
"-d",
|
|
"--rm",
|
|
"--name",
|
|
self.name,
|
|
]
|
|
for t in ("/tmp", "/dest", "/var/cache", *self.tmpfs):
|
|
argv += ["--tmpfs", t]
|
|
argv += ["--network=none"] if not self.network else []
|
|
for m in self.mounts:
|
|
m.src.mkdir(parents=True, exist_ok=True) if not m.src.exists() else None
|
|
argv += ["-v", m.as_arg()]
|
|
argv += [self.image, "sleep", "infinity"]
|
|
log.debug(f"container start: {' '.join(argv)}")
|
|
r = subprocess.run(argv, capture_output=True, text=True)
|
|
if r.returncode != 0:
|
|
raise RuntimeError(f"podman run failed: {r.stderr.strip()}")
|
|
with _lock:
|
|
_active.add(self)
|
|
self.started = True
|
|
|
|
def stop(self) -> None:
|
|
if not self.started:
|
|
return
|
|
subprocess.run(
|
|
["podman", "rm", "-f", self.name],
|
|
stdout=subprocess.DEVNULL,
|
|
stderr=subprocess.DEVNULL,
|
|
check=False,
|
|
)
|
|
with _lock:
|
|
_active.discard(self)
|
|
self.started = False
|
|
|
|
def exec(
|
|
self,
|
|
argv: list[str],
|
|
*,
|
|
env: dict[str, str] | None = None,
|
|
cwd: str | None = None,
|
|
check: bool = True,
|
|
user: str | None = None,
|
|
) -> int:
|
|
if not self.started:
|
|
raise RuntimeError("container not started")
|
|
cmd = ["podman", "exec"]
|
|
if cwd:
|
|
cmd += ["-w", cwd]
|
|
if user:
|
|
cmd += ["-u", user]
|
|
merged: dict[str, str] = {}
|
|
merged.update(self.extra_env)
|
|
if env:
|
|
merged.update(env)
|
|
for k, v in merged.items():
|
|
cmd += ["-e", f"{k}={v}"]
|
|
cmd += [self.name, *argv]
|
|
log.debug(f"exec: {' '.join(argv)}")
|
|
rc = subprocess.call(cmd)
|
|
if check and rc != 0:
|
|
raise RuntimeError(f"command failed (exit {rc}): {' '.join(argv)}")
|
|
return rc
|
|
|
|
def exec_shell(
|
|
self,
|
|
script: str,
|
|
*,
|
|
env: dict[str, str] | None = None,
|
|
cwd: str | None = None,
|
|
check: bool = True,
|
|
) -> int:
|
|
return self.exec(["sh", "-ec", script], env=env, cwd=cwd, check=check)
|
|
|
|
def cp_out(self, src_in: str, dst_host: Path) -> None:
|
|
dst_host.parent.mkdir(parents=True, exist_ok=True)
|
|
r = subprocess.run(
|
|
["podman", "cp", f"{self.name}:{src_in}", str(dst_host)],
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
if r.returncode != 0:
|
|
raise RuntimeError(f"podman cp failed: {r.stderr.strip()}")
|
|
|
|
|
|
def hash_dockerfile(p: Path) -> str:
|
|
return hashlib.sha256(p.read_bytes()).hexdigest()
|
|
|
|
|
|
def image_exists(tag: str) -> bool:
|
|
r = subprocess.run(["podman", "image", "exists", tag])
|
|
return r.returncode == 0
|
|
|
|
|
|
def build_image(dockerfile: Path, tag: str) -> None:
|
|
log.info(f"building container image {tag}")
|
|
r = subprocess.run(
|
|
["podman", "build", "-t", tag, "-f", str(dockerfile), str(dockerfile.parent)]
|
|
)
|
|
if r.returncode != 0:
|
|
raise RuntimeError("podman build failed")
|
|
|
|
|
|
def ensure_image(dockerfile: Path, tag: str, hash_file: Path) -> None:
|
|
cur = hash_dockerfile(dockerfile) + "\n" + tag
|
|
if hash_file.exists() and hash_file.read_text() == cur and image_exists(tag):
|
|
log.debug(f"image {tag} up-to-date")
|
|
return
|
|
build_image(dockerfile, tag)
|
|
hash_file.parent.mkdir(parents=True, exist_ok=True)
|
|
hash_file.write_text(cur)
|