feat(01-05): add build orchestration service

- Implement BuildService for coordinating ISO build lifecycle
- Integrate sandbox and deterministic config for reproducible builds
- Add cache lookup before build execution (same hash = return cached)
- Handle build status transitions: pending -> building -> completed/failed
This commit is contained in:
Mikkel Georgsen 2026-01-25 20:20:57 +00:00
parent 683a1efcf5
commit c01b4cbf54
2 changed files with 148 additions and 1 deletions

View file

@ -1,5 +1,7 @@
"""Services package for business logic."""
from backend.app.services.build import BuildService
from backend.app.services.deterministic import DeterministicBuildConfig
from backend.app.services.sandbox import BuildSandbox, SandboxConfig
__all__ = ["BuildSandbox", "SandboxConfig"]
__all__ = ["BuildSandbox", "SandboxConfig", "DeterministicBuildConfig", "BuildService"]

View file

@ -0,0 +1,145 @@
"""
Build orchestration service.
Coordinates:
1. Configuration validation
2. Hash computation (for caching)
3. Sandbox creation
4. Build execution
5. 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. Create sandbox container
3. Generate archiso profile
4. Run build
5. 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"
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
)
DeterministicBuildConfig.create_archiso_profile(
config, profile_path, source_date_epoch
)
# Run build in sandbox
return_code, stdout, stderr = await self.sandbox.run_build(
container_path, profile_path, output_path, 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 sandbox
if container_path:
await self.sandbox.cleanup_container(container_path)
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()