The one path that touches a customer's server, and the one the platform cannot undo. It is human-triggered, confirmed and audited; there is deliberately no code path from a pipeline run into it (D13). - deploy_ssh.py: SshDeployer. Release lands in releases/<ts>-<sha7>/, the post-deploy command runs with cwd there, and only then does `current` move — atomically (relative symlink + mv -T). A failed hook or extraction never moves it, so the previous release keeps serving. Host key is pinned on first success (TOFU); a mismatch is a hard failure. Prunes to the newest five. - deploy_ftp.py: FtpDeployer. FTP has no atomic switch and no rollback except a backup, so a backup is mandatory: every file about to be overwritten is archived under ARTIFACTS_ROOT before anything is uploaded. The artifact is extracted, then the build dir (dist/build/public/out) is chosen — a static deploy must not publish the repository. Archive members are sanitised: absolute paths, `..` and escaping links are refused. - deploy_target.py: the tester that dispatches on `kind`. - deploy_service.py + production_deploys (migration a3b4c5d6e7f4) + 4 routes. One deploy per project at a time; the org slug must be typed back; the target summary reports has_secret, never the secret. Tests: 499 API (+26) with fake SSH/FTP transports; ruff, mypy, alembic clean. Report: docs/plan/reports/agent-3-phase-5.md
367 lines
14 KiB
Python
367 lines
14 KiB
Python
"""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"]
|
|
|