debate/backend/app/services/build.py
Mikkel Georgsen 77a5aaa0f5 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>
2026-01-25 20:41:36 +00:00

151 lines
4.5 KiB
Python

"""
Build orchestration service.
Coordinates:
1. Configuration validation
2. Hash computation (for caching)
3. Container-based build execution
4. Result storage
"""
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
from uuid import uuid4
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from backend.app.core.config import settings
from backend.app.db.models.build import Build, BuildStatus
from backend.app.services.deterministic import DeterministicBuildConfig
from backend.app.services.sandbox import BuildSandbox
class BuildService:
"""Orchestrates ISO build process."""
def __init__(self, db: AsyncSession):
self.db = db
self.sandbox = BuildSandbox()
self.output_root = Path(settings.iso_output_root)
async def get_or_create_build(
self,
config: dict[str, Any],
) -> tuple[Build, bool]:
"""
Get existing build from cache or create new one.
Returns:
Tuple of (Build, is_cached)
"""
# Compute deterministic hash
config_hash = DeterministicBuildConfig.compute_config_hash(config)
# Check cache
stmt = select(Build).where(
Build.config_hash == config_hash,
Build.status == BuildStatus.COMPLETED,
)
result = await self.db.execute(stmt)
cached_build = result.scalar_one_or_none()
if cached_build:
# Return cached build
return cached_build, True
# Create new build
build = Build(
id=uuid4(),
config_hash=config_hash,
status=BuildStatus.PENDING,
)
self.db.add(build)
await self.db.commit()
await self.db.refresh(build)
return build, False
async def execute_build(
self,
build: Build,
config: dict[str, Any],
) -> Build:
"""
Execute the actual ISO build.
Process:
1. Update status to building
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()
build_dir = self.output_root / str(build.id)
profile_path = build_dir / "profile"
output_path = build_dir / "output"
try:
# Generate deterministic profile
source_date_epoch = DeterministicBuildConfig.get_source_date_epoch(
build.config_hash
)
DeterministicBuildConfig.create_archiso_profile(
config, profile_path, source_date_epoch
)
# Run build in container
return_code, stdout, stderr = await self.sandbox.run_build(
build_id=str(build.id),
profile_path=profile_path,
output_path=output_path,
source_date_epoch=source_date_epoch,
)
if return_code == 0:
# Find generated ISO
iso_files = list(output_path.glob("*.iso"))
if iso_files:
build.iso_path = str(iso_files[0])
build.status = BuildStatus.COMPLETED
else:
build.status = BuildStatus.FAILED
build.error_message = "Build completed but no ISO found"
else:
build.status = BuildStatus.FAILED
build.error_message = stderr or f"Build failed with code {return_code}"
build.build_log = stdout + "\n" + stderr
except Exception as e:
build.status = BuildStatus.FAILED
build.error_message = str(e)
finally:
# Cleanup any orphaned containers
await self.sandbox.cleanup_build(str(build.id))
build.completed_at = datetime.now(UTC)
await self.db.commit()
await self.db.refresh(build)
return build
async def get_build_status(self, build_id: str) -> Build | None:
"""Get build by ID."""
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()