diff --git a/backend/app/services/__init__.py b/backend/app/services/__init__.py index 0ed1342..636e78c 100644 --- a/backend/app/services/__init__.py +++ b/backend/app/services/__init__.py @@ -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"] diff --git a/backend/app/services/build.py b/backend/app/services/build.py new file mode 100644 index 0000000..de1fe03 --- /dev/null +++ b/backend/app/services/build.py @@ -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()