"""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// # 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// 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-.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}