Files
smart-org/apps/api/app/application/container_delivery.py
T
mjalmoudy94 72408a0a4f 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
2026-10-14 01:16:23 +03:30

214 lines
8.3 KiB
Python

"""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
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 (
LocalGitWorkspaceAdapter,
ProjectGitSource,
)
from app.infrastructure.adapters.registry import get_domain_router, get_sandbox_provider
from app.infrastructure.db.models import ProjectRow
from app.infrastructure.repositories.app_profile import AppProfileRepository
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,
ProjectGitRepoRepository,
ProjectRepository,
)
logger = logging.getLogger("agy.container.delivery")
_fake_workspace = FakeRepoWorkspace()
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:
"""The real CI/CD service — it *is* the hook implementation.
The org wizard calls `on_app_created` after its own transaction commits, and
the engine calls `on_branch_pushed` after a successful merge; both get the
same object the delivery router and worker use, so there is exactly one
implementation of "a push becomes a build".
"""
return build_delivery_service(session)
def build_delivery_service(session: AsyncSession) -> DeliveryService:
"""CI/CD: pipeline runs, the demo runtime and the deploy hooks."""
return DeliveryService(
runs=PipelineRunRepository(session),
steps=PipelineStepRepository(session),
releases=ReleaseRepository(session),
runtimes=AppRuntimeRepository(session),
projects=ProjectRepository(session),
profiles=AppProfileRepository(session),
organizations=OrganizationRepository(session),
ports=PreviewDomainRepository(session),
workspace=build_repo_workspace(session),
domains=get_domain_router(),
containers=_container_runtime(),
)
def _container_runtime():
"""The adapter that can start build and demo containers.
Falls back to a runtime that refuses everything when the configured sandbox
provider cannot run containers (the mock, or a remote provider): a pipeline
run then fails cleanly with a plain message instead of pretending to deploy.
"""
provider = get_sandbox_provider()
if hasattr(provider, "run_step") and hasattr(provider, "remove_container"):
return provider
return _UnavailableContainerRuntime()
class _UnavailableContainerRuntime:
"""A container runtime that cannot run anything, and says so."""
async def run_step(self, **_kwargs): # noqa: ANN003
from app.application.ports import CommandResult
return CommandResult(
exit_code=127,
stdout="",
stderr="SANDBOX_PROVIDER cannot run containers; set SANDBOX_PROVIDER=docker.",
)
async def remove_container(self, container_id: str) -> bool: # noqa: ARG002
return False
def build_deploy_target_tester(session: AsyncSession) -> DeployTargetTester:
"""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(),
)