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
+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(),
)