""" 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()