"""Wiring for the delivery workstream (repo workspaces, delivery hooks, deploy tester). The real adapters are selected by settings; the fakes remain the defaults so a test that does not opt in never touches a git binary, a container or a network. ``build_repo_workspace`` is the interesting one: the git adapter needs to know *where* a project's repository lives and *how* to authenticate to it, and that knowledge lives in three places — ``project_repositories`` + ``project_git_credentials`` for the platform forge, ``project_external_git`` for a user-supplied remote, and ``projects`` for which of the two applies. The resolver below is the only code that joins them, and it hands the plaintext secret straight to the adapter for the duration of a single call. """ from __future__ import annotations import logging from sqlalchemy.ext.asyncio import AsyncSession from app.application.ports.delivery_hooks import DeliveryHooks, NullDeliveryHooks from app.application.ports.deploy_target import DeployTargetTester, FakeDeployTargetTester from app.application.ports.repo_workspace import FakeRepoWorkspace, RepoWorkspacePort from app.core.config import settings from app.core.crypto import decrypt from app.infrastructure.adapters.git_workspace import ( LocalGitWorkspaceAdapter, ProjectGitSource, ) from app.infrastructure.db.models import ProjectRow from app.infrastructure.repositories.projects import ( ProjectExternalGitRepository, ProjectGitCredentialRepository, ProjectGitRepoRepository, ProjectRepository, ) logger = logging.getLogger("agy.container.delivery") _fake_workspace = FakeRepoWorkspace() _null_hooks = NullDeliveryHooks() _fake_deploy_tester = FakeDeployTargetTester() def build_repo_workspace(session: AsyncSession) -> RepoWorkspacePort: """The platform's git adapter, or the in-memory fake when ``GIT_WORKSPACE=fake``.""" if settings.git_workspace.strip().lower() != "local": return _fake_workspace projects = ProjectRepository(session) repos = ProjectGitRepoRepository(session) credentials = ProjectGitCredentialRepository(session) external = ProjectExternalGitRepository(session) async def resolve(project_id: str) -> ProjectGitSource | None: project = await projects.get(project_id) if project is None: return None row = await session.get(ProjectRow, project_id) if row is None: return None git_source = (row.git_source or "platform").lower() if git_source == "external": url = (row.external_repo_url or "").strip() if not url: return None record = await external.get_for_project(project_id) return ProjectGitSource( project_id=project_id, organization_id=project.organization_id, clone_url=url, default_branch=project.default_branch, auth_type=(record.auth_type if record else "none") or "none", username=record.username if record else None, secret=_decrypt_or_none( record.secret_encrypted if record else None, project_id ), known_hosts=record.known_hosts if record else None, is_platform=False, ) repo = await repos.get_for_project(project_id) if repo is None or not repo.clone_url: return None credential = await credentials.get_for_project(project_id) secret = _decrypt_or_none( credential.encrypted_password if credential else None, project_id ) return ProjectGitSource( project_id=project_id, organization_id=project.organization_id, clone_url=repo.clone_url, default_branch=repo.default_branch or project.default_branch, auth_type="token" if secret else "none", username=credential.username if credential else None, secret=secret, is_platform=True, ) return LocalGitWorkspaceAdapter(resolver=resolve) def _decrypt_or_none(ciphertext: str | None, project_id: str) -> str | None: """Decrypt a stored secret, tolerating a rotated ``ENCRYPTION_KEY``. A rotated key must surface as "no credential" (git then fails with an auth error the user can act on) rather than as an exception that breaks an unrelated request. The failure is logged, never raised with the ciphertext. """ if not ciphertext: return None try: return decrypt(ciphertext) except Exception: # noqa: BLE001 - see docstring logger.warning("git credential for project %s could not be decrypted", project_id) return None def build_delivery_hooks(session: AsyncSession) -> DeliveryHooks: return _null_hooks def build_deploy_target_tester(session: AsyncSession) -> DeployTargetTester: return _fake_deploy_tester