fester/backend/integrations/podman.py

204 lines
6.9 KiB
Python
Executable File

"""Podman integration — run build actions inside rootless Podman containers.
This mirrors the LXC integration but targets the Podman CLI, which provides
OCI-compatible container management without a daemon. Used by the executor
runtime router when an action specifies ``runtime: podman`` or when a node
is configured with ``runtime: podman``.
Podman advantages for Fester:
- Rootless execution (no CAP_SYS_ADMIN required on the host)
- OCI-compatible (same image format as Docker, buildah, etc.)
- Systemd integration via podman-generate-systemd
--security-opt seccomp can layer with sorcery-go's eBPF warding
Config (config.yaml)::
nodes:
- name: arm-builder
host: 192.168.1.20
max_jobs: 8
runtime: podman
container: smgl-arm64
"""
import json
import shutil
import subprocess
from typing import Any, Dict, List, Optional
class PodmanManager:
"""Manages Podman containers for build execution."""
def __init__(self, podman_bin: str = "podman"):
self.podman_bin = podman_bin
self.available = shutil.which(podman_bin) is not None
def _run(self, args: List[str], check: bool = False) -> subprocess.CompletedProcess:
cmd = [self.podman_bin] + args
return subprocess.run(cmd, capture_output=True, text=True, timeout=300)
def execute(self, container: str, command: str,
cwd: Optional[str] = None, env: Optional[Dict[str, str]] = None) -> int:
"""Execute a command inside a Podman container.
Args:
container: Container name or ID.
command: Shell command to run.
cwd: Working directory inside the container.
env: Additional environment variables.
Returns:
Process exit code (0 = success).
"""
if not self.available:
return 127 # command not found
args = ["exec"]
if cwd:
args.extend(["--workdir", cwd])
if env:
for k, v in env.items():
args.extend(["--env", f"{k}={v}"])
args.extend([container, "bash", "-c", command])
result = self._run(args)
return result.returncode
def create(self, name: str, image: str,
network: Optional[str] = None,
volumes: Optional[List[str]] = None,
env: Optional[Dict[str, str]] = None,
rootfs: Optional[str] = None) -> bool:
"""Create (but don't start) a Podman container.
Args:
name: Container name.
image: Image to use (or ``none`` if rootfs is set).
network: Network mode (e.g., ``bridge``, ``host``, ``none``).
volumes: Host path bind mounts (``/host:/cont:rw``).
env: Environment variables.
rootfs: Path to a rootfs directory for ``--rootfs`` mode.
Returns:
True if the container was created successfully.
"""
if not self.available:
return False
args = ["create", "--name", name]
if rootfs:
args.extend(["--rootfs", rootfs])
else:
args.extend(["--image", image])
if network:
args.extend(["--network", network])
# Security: layer with sorcery-go eBPF cgroup filters if available
# The cgroup path will be /sys/fs/cgroup/podman/<name>/
# sorcery-go's EBPFEnforcer.AttachCgroup() handles attachment.
if volumes:
for v in volumes:
args.extend(["--volume", v])
if env:
for k, v in env.items():
args.extend(["--env", f"{k}={v}"])
# Detach-friendly defaults
args.extend(["--detach", "--init"])
result = self._run(args)
return result.returncode == 0
def start(self, name: str) -> bool:
"""Start a stopped container."""
if not self.available:
return False
result = self._run(["start", name])
return result.returncode == 0
def stop(self, name: str, timeout: int = 10) -> bool:
"""Stop a running container gracefully, then kill if needed."""
if not self.available:
return False
result = self._run(["stop", "-t", str(timeout), name])
return result.returncode == 0
def rm(self, name: str, force: bool = False) -> bool:
"""Remove a container."""
if not self.available:
return False
args = ["rm"]
if force:
args.append("--force")
args.append(name)
result = self._run(args)
return result.returncode == 0
def inspect(self, name: str) -> Optional[Dict[str, Any]]:
"""Inspect a container and return its JSON config."""
if not self.available:
return None
result = self._run(["inspect", name])
if result.returncode != 0:
return None
try:
data = json.loads(result.stdout)
return data[0] if isinstance(data, list) else data
except (json.JSONDecodeError, IndexError):
return None
def cgroup_path(self, name: str) -> Optional[str]:
"""Return the cgroup path for a container (for eBPF attachment).
Podman uses cgroup v2 by default. The path is typically:
/sys/fs/cgroup/podman/<name>/
or via the container's cgroup from inspect.
"""
info = self.inspect(name)
if info is None:
return None
# Try to get the cgroup path from the container's state
try:
# Podman inspect doesn't directly expose cgroup path,
# but we can derive it from the systemd slice or the
# container ID.
cid = info.get("Id", "")
short_id = cid[:12] if len(cid) >= 12 else cid
# Podman cgroupv2: /sys/fs/cgroup/machines.slice/podman-<id>.scope
return f"/sys/fs/cgroup/machines.slice/podman-{short_id}.scope"
except Exception:
return None
def list_containers(self, all: bool = False) -> List[Dict[str, Any]]:
"""List containers. Returns parsed JSON."""
if not self.available:
return []
args = ["ps", "-a", "--format", "json"] if all else ["ps", "--format", "json"]
result = self._run(args)
if result.returncode != 0:
return []
try:
return json.loads(result.stdout)
except json.JSONDecodeError:
return []
def pull(self, image: str) -> bool:
"""Pull an image from a registry."""
if not self.available:
return False
result = self._run(["pull", image])
return result.returncode == 0
def status(self) -> Dict[str, Any]:
"""Return Podman availability and version info."""
if not self.available:
return {"available": False}
result = self._run(["--version"])
version = result.stdout.strip() if result.returncode == 0 else "unknown"
return {"available": True, "version": version}