"""Repositories for CI/CD runs, their steps, releases and the demo runtime.""" from __future__ import annotations from sqlalchemy import func, select, update from sqlalchemy.ext.asyncio import AsyncSession from app.domain.base import utcnow from app.domain.delivery.models import ( AppRuntime, DeployStatus, PipelineRun, PipelineStep, ProductionDeploy, Release, RunStatus, StepStatus, ) from app.infrastructure.db.models_delivery import ( AppRuntimeRow, PipelineRunRow, PipelineStepRow, ProductionDeployRow, ReleaseRow, ) #: Statuses that count as "in flight" for the one-active-run-per-project rule. ACTIVE_STATUSES = (RunStatus.QUEUED.value, RunStatus.RUNNING.value) def _run(row: PipelineRunRow) -> PipelineRun: return PipelineRun( id=row.id, organization_id=row.organization_id, project_id=row.project_id, branch=row.branch, sha=row.sha, trigger=row.trigger, status=row.status, error_summary=row.error_summary, started_at=row.started_at, finished_at=row.finished_at, created_at=row.created_at, ) def _step(row: PipelineStepRow) -> PipelineStep: return PipelineStep( id=row.id, run_id=row.run_id, index=row.index, name=row.name, status=row.status, exit_code=row.exit_code, log_tail=row.log_tail, started_at=row.started_at, finished_at=row.finished_at, ) def _release(row: ReleaseRow) -> Release: return Release( id=row.id, organization_id=row.organization_id, project_id=row.project_id, run_id=row.run_id, sha=row.sha, artifact_path=row.artifact_path, is_demo_live=row.is_demo_live, created_at=row.created_at, ) def _runtime(row: AppRuntimeRow) -> AppRuntime: return AppRuntime( project_id=row.project_id, organization_id=row.organization_id, demo_container_id=row.demo_container_id, host_port=row.host_port, demo_host=row.demo_host, status=row.status, current_release_id=row.current_release_id, updated_at=row.updated_at, ) class PipelineRunRepository: def __init__(self, session: AsyncSession) -> None: self.session = session async def get(self, run_id: str) -> PipelineRun | None: row = await self.session.get(PipelineRunRow, run_id) return _run(row) if row else None async def get_for_org(self, organization_id: str, run_id: str) -> PipelineRun | None: """Tenant-scoped lookup: a foreign run id is indistinguishable from a missing one.""" row = await self.session.scalar( select(PipelineRunRow).where( PipelineRunRow.id == run_id, PipelineRunRow.organization_id == organization_id, ) ) return _run(row) if row else None async def list_for_project( self, *, organization_id: str, project_id: str, limit: int = 50, offset: int = 0 ) -> list[PipelineRun]: stmt = ( select(PipelineRunRow) .where( PipelineRunRow.organization_id == organization_id, PipelineRunRow.project_id == project_id, ) .order_by(PipelineRunRow.created_at.desc()) .limit(min(max(limit, 1), 200)) .offset(max(offset, 0)) ) return [_run(r) for r in (await self.session.execute(stmt)).scalars()] async def active_for_project(self, project_id: str) -> PipelineRun | None: row = await self.session.scalar( select(PipelineRunRow) .where( PipelineRunRow.project_id == project_id, PipelineRunRow.status.in_(ACTIVE_STATUSES), ) .order_by(PipelineRunRow.created_at.desc()) .limit(1) ) return _run(row) if row else None async def last_succeeded_sha(self, project_id: str) -> str | None: return await self.session.scalar( select(PipelineRunRow.sha) .where( PipelineRunRow.project_id == project_id, PipelineRunRow.status == RunStatus.SUCCEEDED.value, ) .order_by(PipelineRunRow.finished_at.desc()) .limit(1) ) async def queue(self, run: PipelineRun) -> PipelineRun: row = PipelineRunRow( id=run.id, organization_id=run.organization_id, project_id=run.project_id, trigger=run.trigger, branch=run.branch, sha=run.sha, status=run.status, error_summary=run.error_summary, started_at=run.started_at, finished_at=run.finished_at, created_at=run.created_at, ) self.session.add(row) await self.session.flush() return _run(row) async def list_queued(self, *, limit: int = 5) -> list[PipelineRun]: """Oldest queued run first — the worker's hot query.""" stmt = ( select(PipelineRunRow) .where(PipelineRunRow.status == RunStatus.QUEUED.value) .order_by(PipelineRunRow.created_at.asc()) .limit(min(max(limit, 1), 50)) ) return [_run(r) for r in (await self.session.execute(stmt)).scalars()] async def list_running(self, *, limit: int = 50) -> list[PipelineRun]: stmt = ( select(PipelineRunRow) .where(PipelineRunRow.status == RunStatus.RUNNING.value) .order_by(PipelineRunRow.started_at.asc()) .limit(min(max(limit, 1), 200)) ) return [_run(r) for r in (await self.session.execute(stmt)).scalars()] async def supersede_queued(self, *, project_id: str, keep_id: str) -> int: """Mark every *queued* run of a project except ``keep_id`` as superseded. Only queued ones: a run that has already started is doing real work and cancelling it mid-flight would leave a half-built container behind. """ result = await self.session.execute( update(PipelineRunRow) .where( PipelineRunRow.project_id == project_id, PipelineRunRow.status == RunStatus.QUEUED.value, PipelineRunRow.id != keep_id, ) .values(status=RunStatus.SUPERSEDED.value, finished_at=utcnow()) ) await self.session.flush() # `session.execute(update(...))` returns a CursorResult at runtime; the # typed signature is the generic Result, which has no `rowcount`. return int(getattr(result, "rowcount", 0) or 0) async def claim(self, run_id: str) -> bool: """Atomically move a *queued* run to running. True when we won the race. A conditional UPDATE rather than a read-then-write: two worker processes polling the same queue would otherwise both see `queued` and both start a build, publishing two containers to the same demo port. """ result = await self.session.execute( update(PipelineRunRow) .where( PipelineRunRow.id == run_id, PipelineRunRow.status == RunStatus.QUEUED.value, ) .values(status=RunStatus.RUNNING.value, started_at=utcnow()) ) await self.session.flush() return int(getattr(result, "rowcount", 0) or 0) == 1 async def set_status( self, run_id: str, status: str, *, error_summary: str | None = None, started: bool = False, finished: bool = False, ) -> None: row = await self.session.get(PipelineRunRow, run_id) if row is None: return row.status = status if error_summary is not None: row.error_summary = error_summary if started and row.started_at is None: row.started_at = utcnow() if finished: row.finished_at = utcnow() await self.session.flush() async def count_by_status(self, *, organization_id: str) -> dict[str, int]: stmt = ( select(PipelineRunRow.status, func.count()) .where(PipelineRunRow.organization_id == organization_id) .group_by(PipelineRunRow.status) ) return dict((await self.session.execute(stmt)).all()) class PipelineStepRepository: def __init__(self, session: AsyncSession) -> None: self.session = session async def list_for_run(self, run_id: str) -> list[PipelineStep]: stmt = ( select(PipelineStepRow) .where(PipelineStepRow.run_id == run_id) .order_by(PipelineStepRow.index.asc()) ) return [_step(r) for r in (await self.session.execute(stmt)).scalars()] async def create_many(self, steps: list[PipelineStep]) -> list[PipelineStep]: rows = [ PipelineStepRow( id=step.id, run_id=step.run_id, index=step.index, name=step.name, status=step.status, exit_code=step.exit_code, log_tail=step.log_tail, started_at=step.started_at, finished_at=step.finished_at, ) for step in steps ] self.session.add_all(rows) await self.session.flush() return [_step(r) for r in rows] async def mark( self, step_id: str, status: str, *, exit_code: int | None = None, log_tail: str | None = None, started: bool = False, finished: bool = False, ) -> None: row = await self.session.get(PipelineStepRow, step_id) if row is None: return row.status = status if exit_code is not None: row.exit_code = exit_code if log_tail is not None: row.log_tail = log_tail if started and row.started_at is None: row.started_at = utcnow() if finished: row.finished_at = utcnow() await self.session.flush() class ReleaseRepository: def __init__(self, session: AsyncSession) -> None: self.session = session async def get(self, release_id: str) -> Release | None: row = await self.session.get(ReleaseRow, release_id) return _release(row) if row else None async def get_for_org(self, organization_id: str, release_id: str) -> Release | None: row = await self.session.scalar( select(ReleaseRow).where( ReleaseRow.id == release_id, ReleaseRow.organization_id == organization_id, ) ) return _release(row) if row else None async def live_for_project(self, project_id: str) -> Release | None: row = await self.session.scalar( select(ReleaseRow).where( ReleaseRow.project_id == project_id, ReleaseRow.is_demo_live.is_(True) ) ) return _release(row) if row else None async def list_for_project( self, *, project_id: str, limit: int = 20 ) -> list[Release]: stmt = ( select(ReleaseRow) .where(ReleaseRow.project_id == project_id) .order_by(ReleaseRow.created_at.desc()) .limit(min(max(limit, 1), 100)) ) return [_release(r) for r in (await self.session.execute(stmt)).scalars()] async def create(self, release: Release) -> Release: row = ReleaseRow( id=release.id, organization_id=release.organization_id, project_id=release.project_id, run_id=release.run_id, sha=release.sha, artifact_path=release.artifact_path, is_demo_live=release.is_demo_live, created_at=release.created_at, ) self.session.add(row) await self.session.flush() return _release(row) async def mark_live(self, *, project_id: str, release_id: str) -> None: """Exactly one live release per project, switched in one transaction.""" await self.session.execute( update(ReleaseRow) .where(ReleaseRow.project_id == project_id, ReleaseRow.id != release_id) .values(is_demo_live=False) ) await self.session.execute( update(ReleaseRow) .where(ReleaseRow.id == release_id) .values(is_demo_live=True) ) await self.session.flush() class AppRuntimeRepository: def __init__(self, session: AsyncSession) -> None: self.session = session async def get(self, project_id: str) -> AppRuntime | None: row = await self.session.get(AppRuntimeRow, project_id) return _runtime(row) if row else None async def get_for_org(self, organization_id: str, project_id: str) -> AppRuntime | None: row = await self.session.scalar( select(AppRuntimeRow).where( AppRuntimeRow.project_id == project_id, AppRuntimeRow.organization_id == organization_id, ) ) return _runtime(row) if row else None async def list_for_org(self, organization_id: str) -> list[AppRuntime]: stmt = select(AppRuntimeRow).where( AppRuntimeRow.organization_id == organization_id ) return [_runtime(r) for r in (await self.session.execute(stmt)).scalars()] async def list_all(self, *, limit: int = 500) -> list[AppRuntime]: """Every app runtime, for the delivery worker's remote poll. Deliberately not tenant-scoped: the poll is platform housekeeping and is never reachable from a tenant-scoped route. """ stmt = select(AppRuntimeRow).limit(min(max(limit, 1), 2_000)) return [_runtime(r) for r in (await self.session.execute(stmt)).scalars()] async def upsert(self, runtime: AppRuntime) -> AppRuntime: row = await self.session.get(AppRuntimeRow, runtime.project_id) if row is None: row = AppRuntimeRow( project_id=runtime.project_id, organization_id=runtime.organization_id ) self.session.add(row) row.demo_container_id = runtime.demo_container_id row.host_port = runtime.host_port row.demo_host = runtime.demo_host row.status = runtime.status row.current_release_id = runtime.current_release_id row.updated_at = utcnow() await self.session.flush() return _runtime(row) async def set_status(self, project_id: str, status: str) -> None: row = await self.session.get(AppRuntimeRow, project_id) if row is not None: row.status = status row.updated_at = utcnow() await self.session.flush() async def set_live( self, project_id: str, *, container_id: str | None, host_port: int | None, release_id: str | None, status: str = "live", ) -> None: """Commit the blue/green switch: container, port and release together.""" row = await self.session.get(AppRuntimeRow, project_id) if row is None: return row.demo_container_id = container_id row.host_port = host_port row.current_release_id = release_id row.status = status row.updated_at = utcnow() await self.session.flush() def _deploy(row: ProductionDeployRow) -> ProductionDeploy: return ProductionDeploy( id=row.id, organization_id=row.organization_id, project_id=row.project_id, kind=row.kind, host=row.host, release_id=row.release_id, target_id=row.target_id, status=row.status, message=row.message, log_tail=row.log_tail, rolled_back_from=row.rolled_back_from, backup_path=row.backup_path, started_at=row.started_at, finished_at=row.finished_at, created_at=row.created_at, ) class ProductionDeployRepository: """The production deploy ledger.""" def __init__(self, session: AsyncSession) -> None: self.session = session async def get(self, deploy_id: str) -> ProductionDeploy | None: row = await self.session.get(ProductionDeployRow, deploy_id) return _deploy(row) if row else None async def get_for_org(self, organization_id: str, deploy_id: str) -> ProductionDeploy | None: row = await self.session.scalar( select(ProductionDeployRow).where( ProductionDeployRow.id == deploy_id, ProductionDeployRow.organization_id == organization_id, ) ) return _deploy(row) if row else None async def list_for_project( self, *, project_id: str, limit: int = 20 ) -> list[ProductionDeploy]: stmt = ( select(ProductionDeployRow) .where(ProductionDeployRow.project_id == project_id) .order_by(ProductionDeployRow.created_at.desc()) .limit(min(max(limit, 1), 100)) ) return [_deploy(r) for r in (await self.session.execute(stmt)).scalars()] async def active_for_project(self, project_id: str) -> ProductionDeploy | None: """One production deploy per project at a time. Two concurrent deploys to the same target would interleave their writes on a shared host, which is exactly the failure FTP has no way to undo. """ row = await self.session.scalar( select(ProductionDeployRow) .where( ProductionDeployRow.project_id == project_id, ProductionDeployRow.status.in_( [DeployStatus.QUEUED.value, DeployStatus.RUNNING.value] ), ) .limit(1) ) return _deploy(row) if row else None async def create(self, deploy: ProductionDeploy) -> ProductionDeploy: row = ProductionDeployRow( id=deploy.id, organization_id=deploy.organization_id, project_id=deploy.project_id, release_id=deploy.release_id, target_id=deploy.target_id, kind=deploy.kind, host=deploy.host, status=deploy.status, message=deploy.message, log_tail=deploy.log_tail, rolled_back_from=deploy.rolled_back_from, backup_path=deploy.backup_path, started_at=deploy.started_at, finished_at=deploy.finished_at, created_at=deploy.created_at, ) self.session.add(row) await self.session.flush() return _deploy(row) async def finish( self, deploy_id: str, *, status: str, message: str = "", log_tail: str = "", backup_path: str = "", ) -> None: row = await self.session.get(ProductionDeployRow, deploy_id) if row is None: return row.status = status row.message = message[:2_000] row.log_tail = log_tail[-65_536:] if backup_path: row.backup_path = backup_path row.finished_at = utcnow() await self.session.flush() async def mark_running(self, deploy_id: str) -> None: row = await self.session.get(ProductionDeployRow, deploy_id) if row is None: return row.status = DeployStatus.RUNNING.value row.started_at = utcnow() await self.session.flush() __all__ = [ "ACTIVE_STATUSES", "AppRuntimeRepository", "PipelineRunRepository", "PipelineStepRepository", "ProductionDeployRepository", "ReleaseRepository", "StepStatus", ]