204 lines
6.9 KiB
Python
Executable File
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} |