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:
@@ -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",
|
||||
|
||||
@@ -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"]
|
||||
|
||||
Reference in New Issue
Block a user