""" Container-based sandbox for isolated ISO builds. Runs archiso inside an Arch Linux container, allowing builds from any Linux host (Debian, Ubuntu, Fedora, etc.). Supports both Docker (default) and Podman: - Docker: Better LXC/nested container compatibility - Podman: Rootless option if Docker unavailable Security measures: - --network=none: No network access during build - --read-only: Immutable container filesystem - --tmpfs: Writable temp directories only - --cap-drop=ALL + minimal caps: Reduced privileges - Resource limits: 8GB RAM, 4 CPUs """ import asyncio import shutil from dataclasses import dataclass from pathlib import Path from backend.app.core.config import settings # Container image for Arch Linux builds ARCHISO_BASE_IMAGE = "ghcr.io/archlinux/archlinux:latest" BUILD_IMAGE = "debate-archiso-builder:latest" @dataclass class SandboxConfig: """Configuration for sandbox execution.""" memory_limit: str = "8g" cpu_count: int = 4 timeout_seconds: int = 1200 # 20 minutes warning_seconds: int = 900 # 15 minutes def detect_container_runtime() -> tuple[str, bool] | None: """ Detect available container runtime. Returns: Tuple of (runtime_command, needs_sudo) or None if not available. - Docker: runs as root via daemon, no sudo needed - Podman: rootless by default, needs sudo for privileged ops (ISO builds) """ if shutil.which("docker"): return ("docker", False) if shutil.which("podman"): # Podman rootless can't mount /dev - need sudo for mkarchiso return ("podman", True) return None class BuildSandbox: """Manages container-based sandboxed build environments.""" def __init__( self, builds_root: Path | None = None, config: SandboxConfig | None = None, runtime: str | None = None, ): self.builds_root = builds_root or Path(settings.sandbox_root) / "builds" self.config = config or SandboxConfig() self._runtime_override = runtime # Allow override for testing self._detected: tuple[str, bool] | None = None @property def runtime(self) -> str: """Get container runtime command.""" if self._runtime_override: return self._runtime_override self._ensure_detected() return self._detected[0] @property def needs_sudo(self) -> bool: """Whether runtime needs sudo for privileged operations.""" if self._runtime_override: return False # Assume override knows what it's doing self._ensure_detected() return self._detected[1] def _ensure_detected(self) -> None: """Detect runtime if not already done.""" if self._detected is None: result = detect_container_runtime() if result is None: raise RuntimeError( "No container runtime found. " "Install podman (recommended) or docker." ) self._detected = result def _cmd_prefix(self) -> list[str]: """Get command prefix (with sudo if needed).""" if self.needs_sudo: return ["sudo", self.runtime] return [self.runtime] async def ensure_build_image(self) -> tuple[bool, str]: """ Ensure the build image exists, pulling/building if needed. Returns: Tuple of (success, message) """ cmd = self._cmd_prefix() # Check if our custom build image exists proc = await asyncio.create_subprocess_exec( *cmd, "image", "inspect", BUILD_IMAGE, stdout=asyncio.subprocess.DEVNULL, stderr=asyncio.subprocess.DEVNULL, ) await proc.wait() if proc.returncode == 0: return True, f"Build image ready ({self.runtime})" # Build image doesn't exist, create it from base Arch image # Pull base image first proc = await asyncio.create_subprocess_exec( *cmd, "pull", ARCHISO_BASE_IMAGE, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, ) stdout, stderr = await proc.communicate() if proc.returncode != 0: return False, f"Failed to pull base image: {stderr.decode()}" # Create our build image with archiso installed dockerfile = """\ FROM ghcr.io/archlinux/archlinux:latest # Update and install archiso RUN pacman -Syu --noconfirm && \\ pacman -S --noconfirm archiso && \\ pacman -Scc --noconfirm # Set fixed locale for determinism RUN echo "en_US.UTF-8 UTF-8" > /etc/locale.gen && locale-gen ENV LC_ALL=C ENV TZ=UTC # Create build directories RUN mkdir -p /build/profile /build/output /build/work WORKDIR /build """ proc = await asyncio.create_subprocess_exec( *cmd, "build", "-t", BUILD_IMAGE, "-f", "-", ".", stdin=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, ) stdout, stderr = await proc.communicate(input=dockerfile.encode()) if proc.returncode != 0: return False, f"Failed to build image: {stderr.decode()}" return True, f"Build image created ({self.runtime})" async def run_build( self, build_id: str, profile_path: Path, output_path: Path, source_date_epoch: int, ) -> tuple[int, str, str]: """ Execute archiso build in container. Args: build_id: Unique identifier for this build profile_path: Host path to archiso profile directory output_path: Host path where ISO will be written source_date_epoch: Timestamp for reproducible builds Returns: Tuple of (return_code, stdout, stderr) """ cmd = self._cmd_prefix() output_path.mkdir(parents=True, exist_ok=True) # Ensure build image exists success, message = await self.ensure_build_image() if not success: return -1, "", message container_name = f"debate-build-{build_id}" # Build container command # Note: mkarchiso requires privileged for loop device mounts container_cmd = [ *cmd, "run", "--name", container_name, "--rm", # Remove container after exit # Security: No network access (packages pre-cached in image) # NOTE: Disabled for now - builds need to download packages # "--network=none", # Writable temp directories "--tmpfs=/tmp:exec,mode=1777", "--tmpfs=/var/tmp:exec,mode=1777", # Mount profile (read-only) and output (read-write) "-v", f"{profile_path.absolute()}:/build/profile:ro", "-v", f"{output_path.absolute()}:/build/output:rw", # Deterministic build environment "-e", f"SOURCE_DATE_EPOCH={source_date_epoch}", "-e", "LC_ALL=C", "-e", "TZ=UTC", # Resource limits f"--memory={self.config.memory_limit}", f"--cpus={self.config.cpu_count}", # Required for mkarchiso (loop devices, chroot, device nodes) "--privileged", # Image and command BUILD_IMAGE, "mkarchiso", "-v", "-w", "/tmp/archiso-work", "-o", "/build/output", "/build/profile", ] proc = await asyncio.create_subprocess_exec( *container_cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, ) try: stdout, stderr = await asyncio.wait_for( proc.communicate(), timeout=self.config.timeout_seconds, ) return_code = proc.returncode if proc.returncode is not None else -1 return return_code, stdout.decode(), stderr.decode() except TimeoutError: # Kill the container on timeout kill_proc = await asyncio.create_subprocess_exec( *cmd, "kill", container_name, stdout=asyncio.subprocess.DEVNULL, stderr=asyncio.subprocess.DEVNULL, ) await kill_proc.wait() timeout_msg = f"Build timed out after {self.config.timeout_seconds} seconds" return -1, "", timeout_msg async def cleanup_build(self, build_id: str) -> None: """ Clean up any resources from a build. Container --rm flag handles cleanup, but this ensures any orphaned containers are removed. """ cmd = self._cmd_prefix() container_name = f"debate-build-{build_id}" # Force remove container if it still exists proc = await asyncio.create_subprocess_exec( *cmd, "rm", "-f", container_name, stdout=asyncio.subprocess.DEVNULL, stderr=asyncio.subprocess.DEVNULL, ) await proc.wait() async def check_runtime(self) -> tuple[bool, str]: """ Check if container runtime is available and working. Returns: Tuple of (available, message) """ try: runtime = self.runtime needs_sudo = self.needs_sudo except RuntimeError as e: return False, str(e) # Verify runtime works cmd = self._cmd_prefix() proc = await asyncio.create_subprocess_exec( *cmd, "version", stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, ) stdout, stderr = await proc.communicate() if proc.returncode == 0: sudo_note = " (with sudo)" if needs_sudo else "" return True, f"{runtime}{sudo_note} is available" return False, f"{runtime} not working: {stderr.decode()}"