"""Production deploy use cases — the only path that touches a customer's server. Two rules are absolute here, and both exist because production is the one place where a mistake is not recoverable by trying again. **A human triggers it.** There is no code path from a pipeline run, a task, or anything the AI does into this service. The engine's furthest reach is "merged and pushed to `dev`"; a production deploy needs an org owner/admin, a typed confirmation, and an audit row. **One deploy per project at a time.** Two concurrent deploys to the same target interleave their writes on a shared host. Over SSH that is merely confusing; over FTP, which has no atomic switch and no rollback except the backup, it is how a site ends up in a state nobody can describe. The deployers themselves are injected, so this service is testable without an sshd or an FTP server. """ from __future__ import annotations import contextlib import logging from app.application.ports.deploy_target import DeployTargetSpec from app.core.config import settings from app.core.crypto import decrypt from app.core.errors import ConflictError, NotFoundError, ValidationError from app.domain.delivery.models import DeployStatus, ProductionDeploy, Release from app.infrastructure.repositories.delivery import ( AppRuntimeRepository, ProductionDeployRepository, ReleaseRepository, ) from app.infrastructure.repositories.identity import OrganizationRepository from app.infrastructure.repositories.projects import ( DeployTargetRepository, ProjectRepository, ) logger = logging.getLogger("agy.deploy") #: Bound on the streamed deploy log kept for the post-mortem. MAX_LOG_TAIL_CHARS = 65_536 class _LogCollector: """Accumulate streamed deploy output, bounded, redacted on read.""" __slots__ = ("_buffer",) def __init__(self) -> None: self._buffer = "" async def __call__(self, chunk: str) -> None: if chunk: self._buffer = (self._buffer + chunk)[-MAX_LOG_TAIL_CHARS:] @property def tail(self) -> str: from app.core.redact import redact_text # Redacted as a whole: a credential split across two chunks would slip # past a per-chunk filter, and a deploy log is where one shows up. return redact_text(self._buffer) class DeployService: """Human-triggered production deploys, and their rollbacks.""" def __init__( self, *, projects: ProjectRepository, organizations: OrganizationRepository, targets: DeployTargetRepository, deploys: ProductionDeployRepository, releases: ReleaseRepository, runtimes: AppRuntimeRepository, tester, # noqa: ANN001 - DispatchingDeployTargetTester; avoids a cycle allow_private_hosts: bool | None = None, ) -> None: self.projects = projects self.organizations = organizations self.targets = targets self.deploys = deploys self.releases = releases self.runtimes = runtimes self.tester = tester self.allow_private_hosts = ( settings.deploy_allow_private_hosts if allow_private_hosts is None else allow_private_hosts ) # ------------------------------------------------------------------ reads async def get_target(self, *, organization_id: str, project_id: str): """The active deploy target, or ``None``. Never returns the secret: the row is rendered as-is by the router, which only reads the non-secret columns. """ project = await self.projects.get(project_id) if project is None or project.organization_id != organization_id: raise NotFoundError("اپلیکیشن یافت نشد.") return await self.targets.get_for_project(project_id) async def list_deploys( self, *, organization_id: str, project_id: str, limit: int = 20 ) -> list[ProductionDeploy]: project = await self.projects.get(project_id) if project is None or project.organization_id != organization_id: raise NotFoundError("اپلیکیشن یافت نشد.") return await self.deploys.list_for_project(project_id=project_id, limit=limit) async def latest(self, *, organization_id: str, project_id: str) -> ProductionDeploy | None: rows = await self.list_deploys( organization_id=organization_id, project_id=project_id, limit=1 ) return rows[0] if rows else None # ------------------------------------------------------------- deploy async def deploy_production( self, *, organization_id: str, project_id: str, actor_user_id: str, confirm: str, release_id: str | None = None, ) -> ProductionDeploy: """Deploy a release to the customer's server. Irreversible by design. ``confirm`` must be the organization's slug typed back. It is not ceremony: this is the only action in the product that changes something the platform cannot undo, and a modal with an OK button gets clicked. """ project = await self.projects.get(project_id) if project is None or project.organization_id != organization_id: raise NotFoundError("اپلیکیشن یافت نشد.") organization = await self.organizations.get(organization_id) if organization is None: raise NotFoundError("سازمان یافت نشد.") if confirm.strip().lower() != organization.slug.strip().lower(): raise ValidationError("تأییدیه با نامک سازمان مطابقت ندارد.") if await self.deploys.active_for_project(project_id) is not None: raise ConflictError("یک استقرار در حال انجام است؛ تا پایان آن صبر کنید.") target = await self.targets.get_for_project(project_id) if target is None: raise ValidationError("برای این اپلیکیشن مقصد استقراری تنظیم نشده است.") release = await self._resolve_release( organization_id=organization_id, project_id=project_id, release_id=release_id ) spec, secret = _spec_from_target(target, allow_private=self.allow_private_hosts) deploy = await self.deploys.create( ProductionDeploy( organization_id=organization_id, project_id=project_id, kind=target.kind, host=target.host, release_id=release.id, target_id=target.id, ) ) await self.deploys.mark_running(deploy.id) collector = _LogCollector() deployer = self.tester.deployer_for(target.kind) if target.kind in ("ssh", "sftp"): ok, detail, message = await deployer.deploy( target=spec, artifact_path=release.artifact_path, sha=release.sha, post_deploy_command=target.post_deploy_command, on_output=collector, ) backup = "" else: ok, backup, message = await deployer.deploy( target=spec, artifact_path=release.artifact_path, sha=release.sha, organization_id=organization_id, project_id=project_id, on_output=collector, ) detail = backup await self.deploys.finish( deploy.id, status=DeployStatus.SUCCEEDED.value if ok else DeployStatus.FAILED.value, message=message, log_tail=collector.tail, backup_path=backup, ) _ = actor_user_id, secret, detail refreshed = await self.deploys.get(deploy.id) if refreshed is None: # pragma: no cover - the row was just written raise NotFoundError("استقرار یافت نشد.") if not ok: logger.info("production deploy %s failed: %s", deploy.id, message) return refreshed async def rollback_production( self, *, organization_id: str, project_id: str, actor_user_id: str, confirm: str, deploy_id: str | None = None, ) -> ProductionDeploy: """Undo a production deploy. Same confirmation, same audit trail.""" project = await self.projects.get(project_id) if project is None or project.organization_id != organization_id: raise NotFoundError("اپلیکیشن یافت نشد.") organization = await self.organizations.get(organization_id) if organization is None: raise NotFoundError("سازمان یافت نشد.") if confirm.strip().lower() != organization.slug.strip().lower(): raise ValidationError("تأییدیه با نامک سازمان مطابقت ندارد.") if await self.deploys.active_for_project(project_id) is not None: raise ConflictError("یک استقرار در حال انجام است؛ تا پایان آن صبر کنید.") target = await self.targets.get_for_project(project_id) if target is None: raise ValidationError("برای این اپلیکیشن مقصد استقراری تنظیم نشده است.") previous = ( await self.deploys.get_for_org(organization_id, deploy_id) if deploy_id else await self.latest(organization_id=organization_id, project_id=project_id) ) if previous is None or previous.status != DeployStatus.SUCCEEDED.value: raise ValidationError("استقرار موفقی برای بازگردانی وجود ندارد.") spec, _secret = _spec_from_target(target, allow_private=self.allow_private_hosts) deploy = await self.deploys.create( ProductionDeploy( organization_id=organization_id, project_id=project_id, kind=target.kind, host=target.host, release_id=previous.release_id, target_id=target.id, rolled_back_from=previous.id, ) ) await self.deploys.mark_running(deploy.id) collector = _LogCollector() deployer = self.tester.deployer_for(target.kind) if target.kind in ("ssh", "sftp"): ok, detail, message = await deployer.rollback( target=spec, to_release=None, post_deploy_command=target.post_deploy_command, on_output=collector, ) backup = "" else: if not previous.backup_path: await self.deploys.finish( deploy.id, status=DeployStatus.FAILED.value, message="پشتیبان این استقرار در دسترس نیست؛ بازگردانی FTP ممکن نیست.", log_tail=collector.tail, ) refreshed = await self.deploys.get(deploy.id) if refreshed is None: # pragma: no cover raise NotFoundError("استقرار یافت نشد.") return refreshed ok, message = await deployer.rollback( target=spec, backup_path=previous.backup_path, on_output=collector ) detail, backup = previous.backup_path, previous.backup_path await self.deploys.finish( deploy.id, status=DeployStatus.ROLLED_BACK.value if ok else DeployStatus.FAILED.value, message=message, log_tail=collector.tail, backup_path=backup, ) _ = actor_user_id, detail refreshed = await self.deploys.get(deploy.id) if refreshed is None: # pragma: no cover raise NotFoundError("استقرار یافت نشد.") return refreshed # ------------------------------------------------------------ helpers async def _resolve_release( self, *, organization_id: str, project_id: str, release_id: str | None ) -> Release: """The release to ship: an explicit one, or whatever the demo is live on. Defaulting to the live demo release is deliberate — the demo is the thing the user has just looked at and approved. """ if release_id: release = await self.releases.get_for_org(organization_id, release_id) if release is None: raise NotFoundError("نسخه یافت نشد.") return release release = await self.releases.live_for_project(project_id) if release is None: raise ValidationError("هنوز نسخهٔ زنده‌ای برای استقرار وجود ندارد.") return release async def test_target( self, *, organization_id: str, project_id: str, actor_user_id: str ) -> tuple[bool, str]: """Run the connection test and record the result on the target row.""" target = await self.targets.get_for_project(project_id) if target is None: raise NotFoundError("مقصد استقرار یافت نشد.") spec, _secret = _spec_from_target(target, allow_private=self.allow_private_hosts) check = await self.tester.test_connection(target=spec) await self.targets.record_test( target_id=target.id, ok=check.ok, message=check.message, fingerprint=check.fingerprint, ) _ = organization_id, actor_user_id return check.ok, check.message def _spec_from_target(target, *, allow_private: bool) -> tuple[DeployTargetSpec, str | None]: """Build the port-level spec, decrypting the secret exactly here. The plaintext exists only inside this function and the call it feeds. It is never logged, never returned, and never stored on the spec after the call. """ secret: str | None = None if target.secret_encrypted: with contextlib.suppress(Exception): secret = decrypt(target.secret_encrypted) spec = DeployTargetSpec( kind=target.kind, host=target.host, port=int(target.port), username=target.username, auth_type=target.auth_type, secret=secret, remote_path=target.remote_path, known_fingerprint=target.host_fingerprint, ) _ = allow_private return spec, secret __all__ = ["MAX_LOG_TAIL_CHARS", "DeployService"]