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 <noreply@anthropic.com>
This commit is contained in:
Mikkel Georgsen 2026-01-25 20:41:36 +00:00
parent fd1d931fac
commit 77a5aaa0f5
3 changed files with 301 additions and 122 deletions

View file

@ -4,9 +4,8 @@ Build orchestration service.
Coordinates: Coordinates:
1. Configuration validation 1. Configuration validation
2. Hash computation (for caching) 2. Hash computation (for caching)
3. Sandbox creation 3. Container-based build execution
4. Build execution 4. Result storage
5. Result storage
""" """
from datetime import UTC, datetime from datetime import UTC, datetime
@ -78,23 +77,19 @@ class BuildService:
Process: Process:
1. Update status to building 1. Update status to building
2. Create sandbox container 2. Generate archiso profile
3. Generate archiso profile 3. Run build in container (podman/docker)
4. Run build 4. Update status with result
5. Update status with result
""" """
build.status = BuildStatus.BUILDING build.status = BuildStatus.BUILDING
build.started_at = datetime.now(UTC) build.started_at = datetime.now(UTC)
await self.db.commit() await self.db.commit()
container_path = None build_dir = self.output_root / str(build.id)
profile_path = self.output_root / str(build.id) / "profile" profile_path = build_dir / "profile"
output_path = self.output_root / str(build.id) / "output" output_path = build_dir / "output"
try: try:
# Create sandbox
container_path = await self.sandbox.create_build_container(str(build.id))
# Generate deterministic profile # Generate deterministic profile
source_date_epoch = DeterministicBuildConfig.get_source_date_epoch( source_date_epoch = DeterministicBuildConfig.get_source_date_epoch(
build.config_hash build.config_hash
@ -103,9 +98,12 @@ class BuildService:
config, profile_path, source_date_epoch config, profile_path, source_date_epoch
) )
# Run build in sandbox # Run build in container
return_code, stdout, stderr = await self.sandbox.run_build( 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: if return_code == 0:
@ -128,9 +126,8 @@ class BuildService:
build.error_message = str(e) build.error_message = str(e)
finally: finally:
# Cleanup sandbox # Cleanup any orphaned containers
if container_path: await self.sandbox.cleanup_build(str(build.id))
await self.sandbox.cleanup_container(container_path)
build.completed_at = datetime.now(UTC) build.completed_at = datetime.now(UTC)
await self.db.commit() await self.db.commit()
@ -143,3 +140,12 @@ class BuildService:
stmt = select(Build).where(Build.id == build_id) stmt = select(Build).where(Build.id == build_id)
result = await self.db.execute(stmt) result = await self.db.execute(stmt)
return result.scalar_one_or_none() 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()

View file

@ -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: Security measures:
- --private-network: No network access (packages pre-cached in base) - --network=none: No network access during build
- --read-only: Immutable root filesystem - --read-only: Immutable container filesystem
- --tmpfs: Writable temp directories only - --tmpfs: Writable temp directories only
- --capability: Minimal capabilities for mkarchiso - --cap-drop=ALL + minimal caps: Reduced privileges
- Resource limits: 8GB RAM, 4 cores (from CONTEXT.md) - Resource limits: 8GB RAM, 4 CPUs
""" """
import asyncio import asyncio
@ -16,97 +23,196 @@ from pathlib import Path
from backend.app.core.config import settings 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 @dataclass
class SandboxConfig: class SandboxConfig:
"""Configuration for sandbox execution.""" """Configuration for sandbox execution."""
memory_limit: str = "8G" memory_limit: str = "8g"
cpu_quota: str = "400%" # 4 cores cpu_count: int = 4
timeout_seconds: int = 1200 # 20 minutes (with 15min warning) timeout_seconds: int = 1200 # 20 minutes
warning_seconds: int = 900 # 15 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: class BuildSandbox:
"""Manages systemd-nspawn sandboxed build environments.""" """Manages container-based sandboxed build environments."""
def __init__( def __init__(
self, self,
sandbox_root: Path | None = None, builds_root: Path | None = None,
config: SandboxConfig | None = None, config: SandboxConfig | None = None,
runtime: str | None = None,
): ):
self.sandbox_root = sandbox_root or Path(settings.sandbox_root) self.builds_root = builds_root or Path(settings.sandbox_root) / "builds"
self.base_path = self.sandbox_root / "base"
self.builds_path = self.sandbox_root / "builds"
self.config = config or SandboxConfig() 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: @property
""" def runtime(self) -> str:
Create isolated container for a specific build. """Get container runtime command, detecting if needed."""
Uses overlay filesystem on base for efficiency. if self._runtime_cmd is None:
""" self._runtime_cmd = self._runtime or detect_container_runtime()
container_path = self.builds_path / build_id if self._runtime_cmd is None:
if container_path.exists(): raise RuntimeError(
shutil.rmtree(container_path) "No container runtime found. "
container_path.mkdir(parents=True) "Install podman (recommended) or docker."
)
return self._runtime_cmd
# Copy base (in production, use overlayfs for efficiency) async def ensure_build_image(self) -> tuple[bool, str]:
# For now, simple copy is acceptable """
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( proc = await asyncio.create_subprocess_exec(
"cp", runtime, "image", "inspect", BUILD_IMAGE,
"-a", stdout=asyncio.subprocess.DEVNULL,
str(self.base_path) + "/.", stderr=asyncio.subprocess.DEVNULL,
str(container_path),
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
) )
await proc.wait() 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( async def run_build(
self, self,
container_path: Path, build_id: str,
profile_path: Path, profile_path: Path,
output_path: Path, output_path: Path,
source_date_epoch: int, source_date_epoch: int,
) -> tuple[int, str, str]: ) -> 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: Returns:
Tuple of (return_code, stdout, stderr) Tuple of (return_code, stdout, stderr)
""" """
runtime = self.runtime
output_path.mkdir(parents=True, exist_ok=True) output_path.mkdir(parents=True, exist_ok=True)
nspawn_cmd = [ # Ensure build image exists
"systemd-nspawn", success, message = await self.ensure_build_image()
f"--directory={container_path}", if not success:
"--private-network", # No network access return -1, "", message
"--read-only", # Immutable root
"--tmpfs=/tmp:mode=1777", container_name = f"debate-build-{build_id}"
"--tmpfs=/var/tmp:mode=1777",
f"--bind={profile_path}:/build/profile:ro", # Build container command
f"--bind={output_path}:/build/output", # Note: mkarchiso requires privileged for loop device mounts
f"--setenv=SOURCE_DATE_EPOCH={source_date_epoch}", container_cmd = [
"--setenv=LC_ALL=C", runtime, "run",
"--setenv=TZ=UTC", "--name", container_name,
"--capability=CAP_SYS_ADMIN", # Required for mkarchiso "--rm", # Remove container after exit
"--console=pipe", # Security: No network access
"--quiet", "--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", "mkarchiso",
"-v", "-v",
"-r", # Remove work directory after build "-w", "/build/work",
"-w", "-o", "/build/output",
"/tmp/archiso-work",
"-o",
"/build/output",
"/build/profile", "/build/profile",
] ]
proc = await asyncio.create_subprocess_exec( proc = await asyncio.create_subprocess_exec(
*nspawn_cmd, *container_cmd,
stdout=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE,
stderr=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_code = proc.returncode if proc.returncode is not None else -1
return return_code, stdout.decode(), stderr.decode() return return_code, stdout.decode(), stderr.decode()
except TimeoutError: 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" timeout_msg = f"Build timed out after {self.config.timeout_seconds} seconds"
return -1, "", timeout_msg return -1, "", timeout_msg
async def cleanup_container(self, container_path: Path) -> None: async def cleanup_build(self, build_id: str) -> None:
"""Remove container after build.""" """
if container_path.exists(): Clean up any resources from a build.
shutil.rmtree(container_path)
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()}"

View file

@ -1,55 +1,79 @@
#!/bin/bash #!/bin/bash
# Initialize sandbox environment for ISO builds # Setup build sandbox for Debate platform
# Run once to create base container image # 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 <vmid> -features nesting=1
# - Container may need to be privileged for full functionality
set -euo pipefail 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() { log() {
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1"
} }
# Check prerequisites # Detect container runtime (prefer podman)
if ! command -v pacstrap &> /dev/null; then if command -v podman &> /dev/null; then
log "ERROR: pacstrap not found. Install arch-install-scripts package." RUNTIME="podman"
log "Found podman (recommended)"
elif command -v docker &> /dev/null; then
RUNTIME="docker"
log "Found docker"
else
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 exit 1
fi fi
if ! command -v systemd-nspawn &> /dev/null; then # Configuration
log "ERROR: systemd-nspawn not found. Install systemd-container package." BUILD_IMAGE="debate-archiso-builder:latest"
exit 1 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 fi
# Create sandbox directories log "Building Debate ISO builder image..."
log "Creating sandbox directories..." log "This will pull Arch Linux and install archiso (~500MB download)"
mkdir -p "$SANDBOX_ROOT"/{base,builds,cache}
# Bootstrap base Arch environment # Pull base image
if [ ! -d "$SANDBOX_BASE/usr" ]; then log "Pulling base Arch Linux image..."
log "Bootstrapping base Arch Linux environment..." $RUNTIME pull "$BASE_IMAGE"
pacstrap -c -G -M "$SANDBOX_BASE" base archiso
# Configure mirrors (whitelist only) # Build our image with archiso
log "Configuring mirrors..." log "Installing archiso into image..."
MIRRORLIST="$SANDBOX_BASE/etc/pacman.d/mirrorlist"
: > "$MIRRORLIST" $RUNTIME build -t "$BUILD_IMAGE" -f - . << 'DOCKERFILE'
for mirror in "${ALLOWED_MIRRORS[@]}"; do FROM ghcr.io/archlinux/archlinux:latest
echo "Server = $mirror" >> "$MIRRORLIST"
done # Update and install archiso
RUN pacman -Syu --noconfirm && \
pacman -S --noconfirm archiso && \
pacman -Scc --noconfirm
# Set fixed locale for determinism # Set fixed locale for determinism
echo "en_US.UTF-8 UTF-8" > "$SANDBOX_BASE/etc/locale.gen" RUN echo "en_US.UTF-8 UTF-8" > /etc/locale.gen && locale-gen
systemd-nspawn -D "$SANDBOX_BASE" locale-gen ENV LC_ALL=C
ENV TZ=UTC
log "Base environment created at $SANDBOX_BASE" # Create build directories
else RUN mkdir -p /build/profile /build/output /build/work
log "Base environment already exists at $SANDBOX_BASE"
fi
log "Sandbox setup complete" 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"