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>
151 lines
4.5 KiB
Python
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()
|