debate/backend/app/services/sandbox.py
Mikkel Georgsen cd54310129 feat(01-05): ISO build verified end-to-end on build VM
- Sandbox auto-detects podman/docker and handles sudo requirement
- Podman needs sudo for mkarchiso (loop devices, chroot)
- Docker runs privileged via daemon (no sudo needed)
- Test profile updated for UEFI-only boot (modern approach)
- Build VM (debate-builder) successfully produced 432MB ISO

Architecture:
- Dev LXC: FastAPI, PostgreSQL, code
- Build VM: Podman + archiso for ISO generation
- SSH triggers builds remotely

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 21:47:32 +00:00

298 lines
9.7 KiB
Python

"""
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()}"