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:
parent
fd1d931fac
commit
77a5aaa0f5
3 changed files with 301 additions and 122 deletions
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()}"
|
||||
|
|
|
|||
|
|
@ -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 <vmid> -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."
|
||||
# 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 "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
|
||||
|
||||
if ! command -v systemd-nspawn &> /dev/null; then
|
||||
log "ERROR: systemd-nspawn not found. Install systemd-container package."
|
||||
exit 1
|
||||
# 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
|
||||
|
||||
# Create sandbox directories
|
||||
log "Creating sandbox directories..."
|
||||
mkdir -p "$SANDBOX_ROOT"/{base,builds,cache}
|
||||
log "Building Debate ISO builder image..."
|
||||
log "This will pull Arch Linux and install archiso (~500MB download)"
|
||||
|
||||
# Bootstrap base Arch environment
|
||||
if [ ! -d "$SANDBOX_BASE/usr" ]; then
|
||||
log "Bootstrapping base Arch Linux environment..."
|
||||
pacstrap -c -G -M "$SANDBOX_BASE" base archiso
|
||||
# Pull base image
|
||||
log "Pulling base Arch Linux image..."
|
||||
$RUNTIME pull "$BASE_IMAGE"
|
||||
|
||||
# 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
|
||||
# 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
|
||||
echo "en_US.UTF-8 UTF-8" > "$SANDBOX_BASE/etc/locale.gen"
|
||||
systemd-nspawn -D "$SANDBOX_BASE" locale-gen
|
||||
RUN echo "en_US.UTF-8 UTF-8" > /etc/locale.gen && locale-gen
|
||||
ENV LC_ALL=C
|
||||
ENV TZ=UTC
|
||||
|
||||
log "Base environment created at $SANDBOX_BASE"
|
||||
else
|
||||
log "Base environment already exists at $SANDBOX_BASE"
|
||||
fi
|
||||
# Create build directories
|
||||
RUN mkdir -p /build/profile /build/output /build/work
|
||||
|
||||
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"
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue