"""Repositories for Projects, their Git repositories, credentials and preview domains.""" from __future__ import annotations from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.domain.base import utcnow from app.domain.projects.models import ( Project, ProjectExternalGit, ProjectGitCredential, ProjectGitRepo, ) from app.infrastructure.db.models import ( DeployTargetRow, PreviewDomainRow, PreviewPortRow, ProjectExternalGitRow, ProjectGitCredentialRow, ProjectRepositoryRow, ProjectRow, ) def _project_to_domain(row: ProjectRow) -> Project: return Project( id=row.id, organization_id=row.organization_id, name=row.name, slug=row.slug, description=row.description, created_by_user_id=row.created_by_user_id, default_branch=row.default_branch, created_at=row.created_at, ) def _repo_to_domain(row: ProjectRepositoryRow) -> ProjectGitRepo: return ProjectGitRepo( id=row.id, project_id=row.project_id, provider=row.provider, owner=row.owner, repo_name=row.repo_name, clone_url=row.clone_url, web_url=row.web_url, default_branch=row.default_branch, created_at=row.created_at, ) class ProjectExternalGitRepository: """Persistence for a user-supplied remote's credentials. The secret is stored encrypted and is *never* returned by this repository: callers that need it (the git workspace adapter) ask for it explicitly through :meth:`get_secret`, which keeps the plaintext out of every code path that only needs to render a status. """ def __init__(self, session: AsyncSession) -> None: self.session = session async def get_for_project(self, project_id: str) -> ProjectExternalGit | None: row = await self.session.get(ProjectExternalGitRow, project_id) return _external_git_to_domain(row) if row else None async def upsert(self, record: ProjectExternalGit) -> ProjectExternalGit: row = await self.session.get(ProjectExternalGitRow, record.project_id) if row is None: row = ProjectExternalGitRow(project_id=record.project_id) self.session.add(row) row.auth_type = record.auth_type row.username = record.username row.secret_encrypted = record.secret_encrypted row.known_hosts = record.known_hosts row.updated_at = utcnow() await self.session.flush() return _external_git_to_domain(row) async def delete_for_project(self, project_id: str) -> None: row = await self.session.get(ProjectExternalGitRow, project_id) if row is not None: await self.session.delete(row) await self.session.flush() def _external_git_to_domain(row: ProjectExternalGitRow) -> ProjectExternalGit: return ProjectExternalGit( project_id=row.project_id, auth_type=row.auth_type, username=row.username, secret_encrypted=row.secret_encrypted, known_hosts=row.known_hosts, updated_at=row.updated_at, ) class ProjectGitRepoRepository: """Persistence for the Git repository attached to a project.""" def __init__(self, session: AsyncSession) -> None: self.session = session async def get_for_project(self, project_id: str) -> ProjectGitRepo | None: stmt = select(ProjectRepositoryRow).where( ProjectRepositoryRow.project_id == project_id ) row = (await self.session.execute(stmt)).scalar_one_or_none() return _repo_to_domain(row) if row else None async def upsert(self, repo: ProjectGitRepo) -> ProjectGitRepo: existing = await self.get_for_project(repo.project_id) if existing: row = await self.session.get(ProjectRepositoryRow, existing.id) assert row is not None row.provider = repo.provider row.owner = repo.owner row.repo_name = repo.repo_name row.clone_url = repo.clone_url row.web_url = repo.web_url row.default_branch = repo.default_branch await self.session.flush() return _repo_to_domain(row) row = ProjectRepositoryRow( id=repo.id, project_id=repo.project_id, provider=repo.provider, owner=repo.owner, repo_name=repo.repo_name, clone_url=repo.clone_url, web_url=repo.web_url, default_branch=repo.default_branch, created_at=repo.created_at, ) self.session.add(row) await self.session.flush() return _repo_to_domain(row) class ProjectGitCredentialRepository: def __init__(self, session: AsyncSession) -> None: self.session = session async def get_for_project(self, project_id: str) -> ProjectGitCredential | None: stmt = select(ProjectGitCredentialRow).where( ProjectGitCredentialRow.project_id == project_id ) row = (await self.session.execute(stmt)).scalar_one_or_none() if row is None: return None return ProjectGitCredential( id=row.id, project_id=row.project_id, username=row.username, encrypted_password=row.encrypted_password, created_at=row.created_at, ) async def upsert(self, credential: ProjectGitCredential) -> ProjectGitCredential: existing = await self.get_for_project(credential.project_id) if existing: row = await self.session.get(ProjectGitCredentialRow, existing.id) assert row is not None row.username = credential.username row.encrypted_password = credential.encrypted_password await self.session.flush() return ProjectGitCredential( id=row.id, project_id=row.project_id, username=row.username, encrypted_password=row.encrypted_password, created_at=row.created_at, ) row = ProjectGitCredentialRow( id=credential.id, project_id=credential.project_id, username=credential.username, encrypted_password=credential.encrypted_password, created_at=credential.created_at, ) self.session.add(row) await self.session.flush() return credential class DeployTargetRepository: """Persistence for a project's production deploy target. The secret is stored encrypted and is only ever handed back through :meth:`get_secret`, so a code path that merely *renders* a target never touches the plaintext. """ def __init__(self, session: AsyncSession) -> None: self.session = session async def get_for_project(self, project_id: str) -> DeployTargetRow | None: return await self.session.scalar( select(DeployTargetRow) .where(DeployTargetRow.project_id == project_id, DeployTargetRow.is_active.is_(True)) .order_by(DeployTargetRow.created_at.desc()) .limit(1) ) async def list_for_project(self, project_id: str) -> list[DeployTargetRow]: stmt = ( select(DeployTargetRow) .where(DeployTargetRow.project_id == project_id) .order_by(DeployTargetRow.created_at.desc()) ) return list((await self.session.execute(stmt)).scalars()) async def upsert(self, *, values: dict) -> DeployTargetRow: """Create or replace the single active target of a project.""" project_id = str(values["project_id"]) existing = await self.get_for_project(project_id) if existing is None: row = DeployTargetRow(**values) self.session.add(row) else: row = existing for key, value in values.items(): setattr(row, key, value) await self.session.flush() return row async def record_test( self, *, target_id: str, ok: bool, message: str, fingerprint: str | None = None ) -> None: row = await self.session.get(DeployTargetRow, target_id) if row is None: return row.last_tested_at = utcnow() row.last_test_ok = ok row.last_test_message = message[:500] if fingerprint: row.host_fingerprint = fingerprint await self.session.flush() class ProjectRepository: def __init__(self, session: AsyncSession) -> None: self.session = session async def get(self, project_id: str) -> Project | None: row = await self.session.get(ProjectRow, project_id) return _project_to_domain(row) if row else None async def list_for_org( self, organization_id: str, *, limit: int = 100, offset: int = 0 ) -> list[Project]: stmt = ( select(ProjectRow) .where(ProjectRow.organization_id == organization_id) .order_by(ProjectRow.created_at.desc()) .limit(min(limit, 500)) .offset(offset) ) return [_project_to_domain(r) for r in (await self.session.execute(stmt)).scalars()] async def list_slugs(self, organization_id: str) -> set[str]: stmt = select(ProjectRow.slug).where(ProjectRow.organization_id == organization_id) return set((await self.session.execute(stmt)).scalars().all()) async def create(self, project: Project) -> Project: row = ProjectRow( id=project.id, organization_id=project.organization_id, name=project.name, slug=project.slug, description=project.description, created_by_user_id=project.created_by_user_id, default_branch=project.default_branch, created_at=project.created_at, ) self.session.add(row) await self.session.flush() return _project_to_domain(row) async def delete(self, project_id: str) -> None: row = await self.session.get(ProjectRow, project_id) if row: await self.session.delete(row) await self.session.flush() class PreviewDomainRepository: def __init__(self, session: AsyncSession) -> None: self.session = session # -- ports --------------------------------------------------------------- async def get_port(self, project_id: str, service_name: str) -> int | None: stmt = select(PreviewPortRow.port).where( PreviewPortRow.project_id == project_id, PreviewPortRow.service_name == service_name, ) return (await self.session.execute(stmt)).scalar_one_or_none() async def list_used_ports(self, *, start: int, end: int) -> set[int]: stmt = select(PreviewPortRow.port).where( PreviewPortRow.port.between(start, end) ) return set((await self.session.execute(stmt)).scalars().all()) async def add_port( self, *, organization_id: str, project_id: str, service_name: str, port: int ) -> None: self.session.add( PreviewPortRow( organization_id=organization_id, project_id=project_id, service_name=service_name, port=port, status="active", ) ) await self.session.flush() async def set_port( self, *, project_id: str, service_name: str, port: int ) -> None: """Re-point an existing service to a new port. The blue/green switch uses this: the *service name* (`demo`) always means "the live port", so a deploy rewrites it rather than accumulating one ledger row per release. """ row = ( await self.session.execute( select(PreviewPortRow).where( PreviewPortRow.project_id == project_id, PreviewPortRow.service_name == service_name, ) ) ).scalar_one_or_none() if row is None: return row.port = port await self.session.flush() async def delete_port(self, *, project_id: str, service_name: str) -> None: """Release a transient port reservation (a staging port, say).""" row = ( await self.session.execute( select(PreviewPortRow).where( PreviewPortRow.project_id == project_id, PreviewPortRow.service_name == service_name, ) ) ).scalar_one_or_none() if row is not None: await self.session.delete(row) await self.session.flush() # -- domains ------------------------------------------------------------- async def get_by_host(self, host: str) -> dict | None: stmt = select(PreviewDomainRow).where(PreviewDomainRow.host == host) row = (await self.session.execute(stmt)).scalar_one_or_none() return self._row_to_dict(row) if row else None async def get(self, domain_id: str) -> dict | None: row = await self.session.get(PreviewDomainRow, domain_id) return self._row_to_dict(row) if row else None async def list_for_project(self, project_id: str) -> list[dict]: stmt = ( select(PreviewDomainRow) .where(PreviewDomainRow.project_id == project_id) .order_by(PreviewDomainRow.created_at) ) return [self._row_to_dict(r) for r in (await self.session.execute(stmt)).scalars()] async def count_for_project(self, project_id: str) -> int: return len(await self.list_for_project(project_id)) async def list_all(self, *, limit: int = 2_000) -> list[dict]: """Every preview domain on the platform. Deliberately **not tenant-scoped**: this is the owner console's view of the whole routing table, and it is reachable only from an ``require_platform_owner`` route. Never use it for authorization. """ stmt = ( select(PreviewDomainRow) .order_by(PreviewDomainRow.host.asc()) .limit(min(max(limit, 1), 5_000)) ) return [self._row_to_dict(r) for r in (await self.session.execute(stmt)).scalars()] async def create( self, *, organization_id: str, project_id: str, host: str, port: int, created_by_user_id: str | None, ) -> dict: row = PreviewDomainRow( organization_id=organization_id, project_id=project_id, host=host, port=port, status="active", protected=False, created_by_user_id=created_by_user_id, ) self.session.add(row) await self.session.flush() return self._row_to_dict(row) async def set_status(self, domain_id: str, status: str) -> None: row = await self.session.get(PreviewDomainRow, domain_id) if row: row.status = status await self.session.flush() async def delete(self, domain_id: str) -> None: row = await self.session.get(PreviewDomainRow, domain_id) if row: await self.session.delete(row) await self.session.flush() @staticmethod def _row_to_dict(row: PreviewDomainRow) -> dict: return { "id": row.id, "organization_id": row.organization_id, "project_id": row.project_id, "host": row.host, "port": row.port, "status": row.status, "protected": row.protected, "created_by_user_id": row.created_by_user_id, "created_at": row.created_at, # Derived, not stored: the scheme is always https and the host is the # identity. Kept here so every caller renders the same URL. "url": f"https://{row.host}", }