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
214 lines
8.3 KiB
Python
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(),
|
|
)
|