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:
parent
683a1efcf5
commit
c01b4cbf54
2 changed files with 148 additions and 1 deletions
|
|
@ -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"]
|
||||
|
|
|
|||
145
backend/app/services/build.py
Normal file
145
backend/app/services/build.py
Normal 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()
|
||||
Loading…
Add table
Reference in a new issue