feat(delivery): production deploy over SSH/SFTP/FTP/FTPS (v2 phase A3-5)

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
This commit is contained in:
mjalmoudy94
2026-10-14 01:16:23 +03:30
parent 82cea1e690
commit 72408a0a4f
15 changed files with 2872 additions and 3 deletions
+2
View File
@@ -18,6 +18,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.application.container_delivery import (
build_delivery_hooks,
build_delivery_service,
build_deploy_service,
build_deploy_target_tester,
build_repo_workspace,
)
@@ -168,6 +169,7 @@ __all__ = [
"build_billing_service",
"build_delivery_hooks",
"build_delivery_service",
"build_deploy_service",
"build_deploy_target_tester",
"build_memory",
"build_model_grants",
+27 -3
View File
@@ -19,9 +19,10 @@ import logging
from sqlalchemy.ext.asyncio import AsyncSession
from app.application.ports.delivery_hooks import DeliveryHooks
from app.application.ports.deploy_target import DeployTargetTester, FakeDeployTargetTester
from app.application.ports.deploy_target import DeployTargetTester
from app.application.ports.repo_workspace import FakeRepoWorkspace, RepoWorkspacePort
from app.application.services.delivery_service import DeliveryService
from app.application.services.deploy_service import DeployService
from app.core.config import settings
from app.core.crypto import decrypt
from app.infrastructure.adapters.git_workspace import (
@@ -35,10 +36,12 @@ from app.infrastructure.repositories.delivery import (
AppRuntimeRepository,
PipelineRunRepository,
PipelineStepRepository,
ProductionDeployRepository,
ReleaseRepository,
)
from app.infrastructure.repositories.identity import OrganizationRepository
from app.infrastructure.repositories.projects import (
DeployTargetRepository,
PreviewDomainRepository,
ProjectExternalGitRepository,
ProjectGitCredentialRepository,
@@ -49,7 +52,6 @@ from app.infrastructure.repositories.projects import (
logger = logging.getLogger("agy.container.delivery")
_fake_workspace = FakeRepoWorkspace()
_fake_deploy_tester = FakeDeployTargetTester()
def build_repo_workspace(session: AsyncSession) -> RepoWorkspacePort:
@@ -186,4 +188,26 @@ class _UnavailableContainerRuntime:
def build_deploy_target_tester(session: AsyncSession) -> DeployTargetTester:
return _fake_deploy_tester
"""The real tester: SSH/SFTP over asyncssh, FTP/FTPS over aioftp.
The wizard calls this to validate a user-supplied production target, and it
runs the same SSRF check the git adapter does before a byte leaves the host.
"""
from app.infrastructure.adapters.deploy_target import DispatchingDeployTargetTester
return DispatchingDeployTargetTester()
def build_deploy_service(session: AsyncSession) -> DeployService:
"""Production deploys: human-triggered, confirmed and audited."""
from app.infrastructure.adapters.deploy_target import DispatchingDeployTargetTester
return DeployService(
projects=ProjectRepository(session),
organizations=OrganizationRepository(session),
targets=DeployTargetRepository(session),
deploys=ProductionDeployRepository(session),
releases=ReleaseRepository(session),
runtimes=AppRuntimeRepository(session),
tester=DispatchingDeployTargetTester(),
)
@@ -0,0 +1,366 @@
"""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"]