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>
This commit is contained in:
Mikkel Georgsen 2026-01-25 21:47:32 +00:00
parent a530fdea4e
commit cd54310129
6 changed files with 1680 additions and 40 deletions

View file

@ -38,18 +38,20 @@ class SandboxConfig:
warning_seconds: int = 900 # 15 minutes
def detect_container_runtime() -> str | None:
def detect_container_runtime() -> tuple[str, bool] | None:
"""
Detect available container runtime.
Prefers Docker for LXC/development compatibility, falls back to Podman.
Returns the command name or None if neither available.
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)
"""
# Prefer docker for better LXC compatibility
if shutil.which("docker"):
return "docker"
return ("docker", False)
if shutil.which("podman"):
return "podman"
# Podman rootless can't mount /dev - need sudo for mkarchiso
return ("podman", True)
return None
@ -64,20 +66,41 @@ class BuildSandbox:
):
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
self._runtime_override = runtime # Allow override for testing
self._detected: tuple[str, bool] | None = None
@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:
"""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."
)
return self._runtime_cmd
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]:
"""
@ -86,23 +109,23 @@ class BuildSandbox:
Returns:
Tuple of (success, message)
"""
runtime = self.runtime
cmd = self._cmd_prefix()
# Check if our custom build image exists
proc = await asyncio.create_subprocess_exec(
runtime, "image", "inspect", BUILD_IMAGE,
*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 ({runtime})"
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(
runtime, "pull", ARCHISO_BASE_IMAGE,
*cmd, "pull", ARCHISO_BASE_IMAGE,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
@ -131,7 +154,7 @@ RUN mkdir -p /build/profile /build/output /build/work
WORKDIR /build
"""
proc = await asyncio.create_subprocess_exec(
runtime, "build", "-t", BUILD_IMAGE, "-f", "-", ".",
*cmd, "build", "-t", BUILD_IMAGE, "-f", "-", ".",
stdin=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
@ -141,7 +164,7 @@ WORKDIR /build
if proc.returncode != 0:
return False, f"Failed to build image: {stderr.decode()}"
return True, f"Build image created ({runtime})"
return True, f"Build image created ({self.runtime})"
async def run_build(
self,
@ -162,7 +185,7 @@ WORKDIR /build
Returns:
Tuple of (return_code, stdout, stderr)
"""
runtime = self.runtime
cmd = self._cmd_prefix()
output_path.mkdir(parents=True, exist_ok=True)
# Ensure build image exists
@ -175,17 +198,15 @@ WORKDIR /build
# Build container command
# Note: mkarchiso requires privileged for loop device mounts
container_cmd = [
runtime, "run",
*cmd, "run",
"--name", container_name,
"--rm", # Remove container after exit
# Security: No network access
"--network=none",
# Security: Read-only root filesystem
"--read-only",
# 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",
"--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",
@ -196,17 +217,13 @@ WORKDIR /build
# 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)
# Required for mkarchiso (loop devices, chroot, device nodes)
"--privileged",
# Image and command
BUILD_IMAGE,
"mkarchiso",
"-v",
"-w", "/build/work",
"-w", "/tmp/archiso-work",
"-o", "/build/output",
"/build/profile",
]
@ -227,7 +244,7 @@ WORKDIR /build
except TimeoutError:
# Kill the container on timeout
kill_proc = await asyncio.create_subprocess_exec(
runtime, "kill", container_name,
*cmd, "kill", container_name,
stdout=asyncio.subprocess.DEVNULL,
stderr=asyncio.subprocess.DEVNULL,
)
@ -242,12 +259,12 @@ WORKDIR /build
Container --rm flag handles cleanup, but this ensures
any orphaned containers are removed.
"""
runtime = self.runtime
cmd = self._cmd_prefix()
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,
*cmd, "rm", "-f", container_name,
stdout=asyncio.subprocess.DEVNULL,
stderr=asyncio.subprocess.DEVNULL,
)
@ -262,17 +279,20 @@ WORKDIR /build
"""
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(
runtime, "version",
*cmd, "version",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, stderr = await proc.communicate()
if proc.returncode == 0:
return True, f"{runtime} is available"
sudo_note = " (with sudo)" if needs_sudo else ""
return True, f"{runtime}{sudo_note} is available"
return False, f"{runtime} not working: {stderr.decode()}"

View file

@ -0,0 +1,4 @@
title Debate Test
linux /arch/boot/x86_64/vmlinuz-linux
initrd /arch/boot/x86_64/initramfs-linux.img
options archisobasedir=arch archisolabel=DEBATE_TEST

View file

@ -0,0 +1,2 @@
timeout 3
default archiso-x86_64.conf

View file

@ -1,3 +1,5 @@
base
linux
mkinitcpio
mkinitcpio-archiso
edk2-shell

View file

@ -1,15 +1,16 @@
#!/usr/bin/env bash
# Minimal test profile for Debate ISO builds
# UEFI-only for simplicity (no legacy BIOS support)
iso_name="debate-test"
iso_label="DEBATE_TEST"
iso_publisher="Debate Platform <https://debate.example.com>"
iso_publisher="Debate Platform"
iso_application="Debate Test ISO"
iso_version="test"
install_dir="arch"
buildmodes=('iso')
bootmodes=('bios.syslinux.mbr' 'bios.syslinux.eltorito')
bootmodes=('uefi-x64.systemd-boot.esp' 'uefi-x64.systemd-boot.eltorito')
arch="x86_64"
pacman_conf="pacman.conf"
airootfs_image_type="squashfs"
airootfs_image_tool_options=('-comp' 'xz' '-b' '1M')
airootfs_image_tool_options=('-comp' 'zstd')

1611
uv.lock generated Normal file

File diff suppressed because it is too large Load diff