From 77a5aaa0f594e033bad41fe71ea281d7bf3a0b3f Mon Sep 17 00:00:00 2001 From: Mikkel Georgsen Date: Sun, 25 Jan 2026 20:41:36 +0000 Subject: [PATCH] fix(01-05): use container-based builds instead of systemd-nspawn Replace systemd-nspawn (Arch-only) with Podman/Docker containers: - Works on any Linux host (Debian, Ubuntu, Fedora, etc.) - Prefers Podman for rootless security, falls back to Docker - Uses archlinux:latest image with archiso installed - Network isolation via --network=none - Resource limits: 8GB RAM, 4 CPUs - Deterministic builds via SOURCE_DATE_EPOCH This allows ISO builds from any development/production environment rather than requiring an Arch-based build server. LXC/Proxmox users: enable nesting on the container. Co-Authored-By: Claude Opus 4.5 --- backend/app/services/build.py | 42 ++--- backend/app/services/sandbox.py | 269 +++++++++++++++++++++++++------- scripts/setup-sandbox.sh | 112 +++++++------ 3 files changed, 301 insertions(+), 122 deletions(-) diff --git a/backend/app/services/build.py b/backend/app/services/build.py index de1fe03..766036b 100644 --- a/backend/app/services/build.py +++ b/backend/app/services/build.py @@ -4,9 +4,8 @@ Build orchestration service. Coordinates: 1. Configuration validation 2. Hash computation (for caching) -3. Sandbox creation -4. Build execution -5. Result storage +3. Container-based build execution +4. Result storage """ from datetime import UTC, datetime @@ -78,23 +77,19 @@ class BuildService: Process: 1. Update status to building - 2. Create sandbox container - 3. Generate archiso profile - 4. Run build - 5. Update status with result + 2. Generate archiso profile + 3. Run build in container (podman/docker) + 4. Update status with result """ build.status = BuildStatus.BUILDING build.started_at = datetime.now(UTC) await self.db.commit() - container_path = None - profile_path = self.output_root / str(build.id) / "profile" - output_path = self.output_root / str(build.id) / "output" + build_dir = self.output_root / str(build.id) + profile_path = build_dir / "profile" + output_path = build_dir / "output" try: - # Create sandbox - container_path = await self.sandbox.create_build_container(str(build.id)) - # Generate deterministic profile source_date_epoch = DeterministicBuildConfig.get_source_date_epoch( build.config_hash @@ -103,9 +98,12 @@ class BuildService: config, profile_path, source_date_epoch ) - # Run build in sandbox + # Run build in container return_code, stdout, stderr = await self.sandbox.run_build( - container_path, profile_path, output_path, source_date_epoch + build_id=str(build.id), + profile_path=profile_path, + output_path=output_path, + source_date_epoch=source_date_epoch, ) if return_code == 0: @@ -128,9 +126,8 @@ class BuildService: build.error_message = str(e) finally: - # Cleanup sandbox - if container_path: - await self.sandbox.cleanup_container(container_path) + # Cleanup any orphaned containers + await self.sandbox.cleanup_build(str(build.id)) build.completed_at = datetime.now(UTC) await self.db.commit() @@ -143,3 +140,12 @@ class BuildService: stmt = select(Build).where(Build.id == build_id) result = await self.db.execute(stmt) return result.scalar_one_or_none() + + async def check_sandbox_ready(self) -> tuple[bool, str]: + """ + Check if the build sandbox is ready. + + Returns: + Tuple of (ready, message) + """ + return await self.sandbox.check_runtime() diff --git a/backend/app/services/sandbox.py b/backend/app/services/sandbox.py index 78f23e7..c93dbdb 100644 --- a/backend/app/services/sandbox.py +++ b/backend/app/services/sandbox.py @@ -1,12 +1,19 @@ """ -systemd-nspawn sandbox for isolated ISO builds. +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 Podman (preferred) and Docker: +- Podman: Rootless by default, no daemon, better security +- Docker: Fallback if Podman not available Security measures: -- --private-network: No network access (packages pre-cached in base) -- --read-only: Immutable root filesystem +- --network=none: No network access during build +- --read-only: Immutable container filesystem - --tmpfs: Writable temp directories only -- --capability: Minimal capabilities for mkarchiso -- Resource limits: 8GB RAM, 4 cores (from CONTEXT.md) +- --cap-drop=ALL + minimal caps: Reduced privileges +- Resource limits: 8GB RAM, 4 CPUs """ import asyncio @@ -16,97 +23,196 @@ 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_quota: str = "400%" # 4 cores - timeout_seconds: int = 1200 # 20 minutes (with 15min warning) + memory_limit: str = "8g" + cpu_count: int = 4 + timeout_seconds: int = 1200 # 20 minutes warning_seconds: int = 900 # 15 minutes +def detect_container_runtime() -> str | None: + """ + Detect available container runtime. + + Prefers Podman for rootless security, falls back to Docker. + Returns the command name or None if neither available. + """ + # Prefer podman for rootless security + if shutil.which("podman"): + return "podman" + if shutil.which("docker"): + return "docker" + return None + + class BuildSandbox: - """Manages systemd-nspawn sandboxed build environments.""" + """Manages container-based sandboxed build environments.""" def __init__( self, - sandbox_root: Path | None = None, + builds_root: Path | None = None, config: SandboxConfig | None = None, + runtime: str | None = None, ): - self.sandbox_root = sandbox_root or Path(settings.sandbox_root) - self.base_path = self.sandbox_root / "base" - self.builds_path = self.sandbox_root / "builds" + self.builds_root = builds_root or Path(settings.sandbox_root) / "builds" self.config = config or SandboxConfig() + self._runtime = runtime # Allow override for testing + self._runtime_cmd: str | None = None - async def create_build_container(self, build_id: str) -> Path: - """ - Create isolated container for a specific build. - Uses overlay filesystem on base for efficiency. - """ - container_path = self.builds_path / build_id - if container_path.exists(): - shutil.rmtree(container_path) - container_path.mkdir(parents=True) + @property + def runtime(self) -> str: + """Get container runtime command, detecting if needed.""" + if self._runtime_cmd is None: + self._runtime_cmd = self._runtime or detect_container_runtime() + if self._runtime_cmd is None: + raise RuntimeError( + "No container runtime found. " + "Install podman (recommended) or docker." + ) + return self._runtime_cmd - # Copy base (in production, use overlayfs for efficiency) - # For now, simple copy is acceptable + async def ensure_build_image(self) -> tuple[bool, str]: + """ + Ensure the build image exists, pulling/building if needed. + + Returns: + Tuple of (success, message) + """ + runtime = self.runtime + + # Check if our custom build image exists proc = await asyncio.create_subprocess_exec( - "cp", - "-a", - str(self.base_path) + "/.", - str(container_path), - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE, + runtime, "image", "inspect", BUILD_IMAGE, + stdout=asyncio.subprocess.DEVNULL, + stderr=asyncio.subprocess.DEVNULL, ) await proc.wait() - return container_path + if proc.returncode == 0: + return True, f"Build image ready ({runtime})" + + # Build image doesn't exist, create it from base Arch image + # Pull base image first + proc = await asyncio.create_subprocess_exec( + runtime, "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( + runtime, "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 ({runtime})" async def run_build( self, - container_path: Path, + build_id: str, profile_path: Path, output_path: Path, source_date_epoch: int, ) -> tuple[int, str, str]: """ - Execute archiso build in sandboxed container. + 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) """ + runtime = self.runtime output_path.mkdir(parents=True, exist_ok=True) - nspawn_cmd = [ - "systemd-nspawn", - f"--directory={container_path}", - "--private-network", # No network access - "--read-only", # Immutable root - "--tmpfs=/tmp:mode=1777", - "--tmpfs=/var/tmp:mode=1777", - f"--bind={profile_path}:/build/profile:ro", - f"--bind={output_path}:/build/output", - f"--setenv=SOURCE_DATE_EPOCH={source_date_epoch}", - "--setenv=LC_ALL=C", - "--setenv=TZ=UTC", - "--capability=CAP_SYS_ADMIN", # Required for mkarchiso - "--console=pipe", - "--quiet", - "--", + # 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 = [ + runtime, "run", + "--name", container_name, + "--rm", # Remove container after exit + # Security: No network access + "--network=none", + # Security: Read-only root filesystem + "--read-only", + # Writable temp directories + "--tmpfs=/tmp:exec,mode=1777", + "--tmpfs=/var/tmp:exec,mode=1777", + "--tmpfs=/build/work:exec", + # 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}", + # Security: Drop all capabilities, add only what's needed + "--cap-drop=ALL", + "--cap-add=SYS_ADMIN", # Required for loop devices in mkarchiso + "--cap-add=MKNOD", # Required for device nodes + # Required for loop device access (mkarchiso mounts squashfs) + "--privileged", + # Image and command + BUILD_IMAGE, "mkarchiso", "-v", - "-r", # Remove work directory after build - "-w", - "/tmp/archiso-work", - "-o", - "/build/output", + "-w", "/build/work", + "-o", "/build/output", "/build/profile", ] proc = await asyncio.create_subprocess_exec( - *nspawn_cmd, + *container_cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, ) @@ -119,11 +225,54 @@ class BuildSandbox: return_code = proc.returncode if proc.returncode is not None else -1 return return_code, stdout.decode(), stderr.decode() except TimeoutError: - proc.kill() + # Kill the container on timeout + kill_proc = await asyncio.create_subprocess_exec( + runtime, "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_container(self, container_path: Path) -> None: - """Remove container after build.""" - if container_path.exists(): - shutil.rmtree(container_path) + 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. + """ + runtime = self.runtime + container_name = f"debate-build-{build_id}" + + # Force remove container if it still exists + proc = await asyncio.create_subprocess_exec( + runtime, "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 + except RuntimeError as e: + return False, str(e) + + # Verify runtime works + proc = await asyncio.create_subprocess_exec( + runtime, "version", + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + stdout, stderr = await proc.communicate() + + if proc.returncode == 0: + return True, f"{runtime} is available" + return False, f"{runtime} not working: {stderr.decode()}" diff --git a/scripts/setup-sandbox.sh b/scripts/setup-sandbox.sh index e6dc89f..04a9fa7 100755 --- a/scripts/setup-sandbox.sh +++ b/scripts/setup-sandbox.sh @@ -1,55 +1,79 @@ #!/bin/bash -# Initialize sandbox environment for ISO builds -# Run once to create base container image +# Setup build sandbox for Debate platform +# Works on any Linux distribution with podman or docker +# +# LXC/Proxmox VE Requirements: +# If running in an LXC container, enable nesting: +# - Proxmox UI: Container -> Options -> Features -> Nesting: checked +# - Or via CLI: pct set -features nesting=1 +# - Container may need to be privileged for full functionality set -euo pipefail -SANDBOX_ROOT="${SANDBOX_ROOT:-/var/lib/debate/sandbox}" -SANDBOX_BASE="${SANDBOX_ROOT}/base" -ALLOWED_MIRRORS=( - "https://geo.mirror.pkgbuild.com/\$repo/os/\$arch" - "https://mirror.cachyos.org/repo/\$arch/\$repo" -) - log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" } -# Check prerequisites -if ! command -v pacstrap &> /dev/null; then - log "ERROR: pacstrap not found. Install arch-install-scripts package." - exit 1 -fi - -if ! command -v systemd-nspawn &> /dev/null; then - log "ERROR: systemd-nspawn not found. Install systemd-container package." - exit 1 -fi - -# Create sandbox directories -log "Creating sandbox directories..." -mkdir -p "$SANDBOX_ROOT"/{base,builds,cache} - -# Bootstrap base Arch environment -if [ ! -d "$SANDBOX_BASE/usr" ]; then - log "Bootstrapping base Arch Linux environment..." - pacstrap -c -G -M "$SANDBOX_BASE" base archiso - - # Configure mirrors (whitelist only) - log "Configuring mirrors..." - MIRRORLIST="$SANDBOX_BASE/etc/pacman.d/mirrorlist" - : > "$MIRRORLIST" - for mirror in "${ALLOWED_MIRRORS[@]}"; do - echo "Server = $mirror" >> "$MIRRORLIST" - done - - # Set fixed locale for determinism - echo "en_US.UTF-8 UTF-8" > "$SANDBOX_BASE/etc/locale.gen" - systemd-nspawn -D "$SANDBOX_BASE" locale-gen - - log "Base environment created at $SANDBOX_BASE" +# Detect container runtime (prefer podman) +if command -v podman &> /dev/null; then + RUNTIME="podman" + log "Found podman (recommended)" +elif command -v docker &> /dev/null; then + RUNTIME="docker" + log "Found docker" else - log "Base environment already exists at $SANDBOX_BASE" + log "ERROR: No container runtime found." + log "Install podman (recommended) or docker:" + log " Debian/Ubuntu: apt install podman" + log " Fedora: dnf install podman" + log " Arch: pacman -S podman" + exit 1 fi -log "Sandbox setup complete" +# Configuration +BUILD_IMAGE="debate-archiso-builder:latest" +BASE_IMAGE="ghcr.io/archlinux/archlinux:latest" + +# Check if build image already exists +if $RUNTIME image inspect "$BUILD_IMAGE" &> /dev/null; then + log "Build image already exists: $BUILD_IMAGE" + log "To rebuild, run: $RUNTIME rmi $BUILD_IMAGE" + exit 0 +fi + +log "Building Debate ISO builder image..." +log "This will pull Arch Linux and install archiso (~500MB download)" + +# Pull base image +log "Pulling base Arch Linux image..." +$RUNTIME pull "$BASE_IMAGE" + +# Build our image with archiso +log "Installing archiso into image..." + +$RUNTIME build -t "$BUILD_IMAGE" -f - . << '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 +DOCKERFILE + +log "Build image created successfully: $BUILD_IMAGE" +log "" +log "Sandbox is ready. The application will use this image for ISO builds." +log "Runtime: $RUNTIME" +log "" +log "To test the image manually:" +log " $RUNTIME run --rm -it $BUILD_IMAGE mkarchiso --help"