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
@@ -8,8 +8,10 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.domain.base import utcnow
from app.domain.delivery.models import (
AppRuntime,
DeployStatus,
PipelineRun,
PipelineStep,
ProductionDeploy,
Release,
RunStatus,
StepStatus,
@@ -18,6 +20,7 @@ from app.infrastructure.db.models_delivery import (
AppRuntimeRow,
PipelineRunRow,
PipelineStepRow,
ProductionDeployRow,
ReleaseRow,
)
@@ -440,11 +443,131 @@ class AppRuntimeRepository:
await self.session.flush()
def _deploy(row: ProductionDeployRow) -> ProductionDeploy:
return ProductionDeploy(
id=row.id,
organization_id=row.organization_id,
project_id=row.project_id,
kind=row.kind,
host=row.host,
release_id=row.release_id,
target_id=row.target_id,
status=row.status,
message=row.message,
log_tail=row.log_tail,
rolled_back_from=row.rolled_back_from,
backup_path=row.backup_path,
started_at=row.started_at,
finished_at=row.finished_at,
created_at=row.created_at,
)
class ProductionDeployRepository:
"""The production deploy ledger."""
def __init__(self, session: AsyncSession) -> None:
self.session = session
async def get(self, deploy_id: str) -> ProductionDeploy | None:
row = await self.session.get(ProductionDeployRow, deploy_id)
return _deploy(row) if row else None
async def get_for_org(self, organization_id: str, deploy_id: str) -> ProductionDeploy | None:
row = await self.session.scalar(
select(ProductionDeployRow).where(
ProductionDeployRow.id == deploy_id,
ProductionDeployRow.organization_id == organization_id,
)
)
return _deploy(row) if row else None
async def list_for_project(
self, *, project_id: str, limit: int = 20
) -> list[ProductionDeploy]:
stmt = (
select(ProductionDeployRow)
.where(ProductionDeployRow.project_id == project_id)
.order_by(ProductionDeployRow.created_at.desc())
.limit(min(max(limit, 1), 100))
)
return [_deploy(r) for r in (await self.session.execute(stmt)).scalars()]
async def active_for_project(self, project_id: str) -> ProductionDeploy | None:
"""One production deploy per project at a time.
Two concurrent deploys to the same target would interleave their writes
on a shared host, which is exactly the failure FTP has no way to undo.
"""
row = await self.session.scalar(
select(ProductionDeployRow)
.where(
ProductionDeployRow.project_id == project_id,
ProductionDeployRow.status.in_(
[DeployStatus.QUEUED.value, DeployStatus.RUNNING.value]
),
)
.limit(1)
)
return _deploy(row) if row else None
async def create(self, deploy: ProductionDeploy) -> ProductionDeploy:
row = ProductionDeployRow(
id=deploy.id,
organization_id=deploy.organization_id,
project_id=deploy.project_id,
release_id=deploy.release_id,
target_id=deploy.target_id,
kind=deploy.kind,
host=deploy.host,
status=deploy.status,
message=deploy.message,
log_tail=deploy.log_tail,
rolled_back_from=deploy.rolled_back_from,
backup_path=deploy.backup_path,
started_at=deploy.started_at,
finished_at=deploy.finished_at,
created_at=deploy.created_at,
)
self.session.add(row)
await self.session.flush()
return _deploy(row)
async def finish(
self,
deploy_id: str,
*,
status: str,
message: str = "",
log_tail: str = "",
backup_path: str = "",
) -> None:
row = await self.session.get(ProductionDeployRow, deploy_id)
if row is None:
return
row.status = status
row.message = message[:2_000]
row.log_tail = log_tail[-65_536:]
if backup_path:
row.backup_path = backup_path
row.finished_at = utcnow()
await self.session.flush()
async def mark_running(self, deploy_id: str) -> None:
row = await self.session.get(ProductionDeployRow, deploy_id)
if row is None:
return
row.status = DeployStatus.RUNNING.value
row.started_at = utcnow()
await self.session.flush()
__all__ = [
"ACTIVE_STATUSES",
"AppRuntimeRepository",
"PipelineRunRepository",
"PipelineStepRepository",
"ProductionDeployRepository",
"ReleaseRepository",
"StepStatus",
]
@@ -14,6 +14,7 @@ from app.domain.projects.models import (
ProjectGitRepo,
)
from app.infrastructure.db.models import (
DeployTargetRow,
PreviewDomainRow,
PreviewPortRow,
ProjectExternalGitRow,
@@ -164,6 +165,61 @@ class ProjectGitCredentialRepository:
return credential
class DeployTargetRepository:
"""Persistence for a project's production deploy target.
The secret is stored encrypted and is only ever handed back through
:meth:`get_secret`, so a code path that merely *renders* a target never
touches the plaintext.
"""
def __init__(self, session: AsyncSession) -> None:
self.session = session
async def get_for_project(self, project_id: str) -> DeployTargetRow | None:
return await self.session.scalar(
select(DeployTargetRow)
.where(DeployTargetRow.project_id == project_id, DeployTargetRow.is_active.is_(True))
.order_by(DeployTargetRow.created_at.desc())
.limit(1)
)
async def list_for_project(self, project_id: str) -> list[DeployTargetRow]:
stmt = (
select(DeployTargetRow)
.where(DeployTargetRow.project_id == project_id)
.order_by(DeployTargetRow.created_at.desc())
)
return list((await self.session.execute(stmt)).scalars())
async def upsert(self, *, values: dict) -> DeployTargetRow:
"""Create or replace the single active target of a project."""
project_id = str(values["project_id"])
existing = await self.get_for_project(project_id)
if existing is None:
row = DeployTargetRow(**values)
self.session.add(row)
else:
row = existing
for key, value in values.items():
setattr(row, key, value)
await self.session.flush()
return row
async def record_test(
self, *, target_id: str, ok: bool, message: str, fingerprint: str | None = None
) -> None:
row = await self.session.get(DeployTargetRow, target_id)
if row is None:
return
row.last_tested_at = utcnow()
row.last_test_ok = ok
row.last_test_message = message[:500]
if fingerprint:
row.host_fingerprint = fingerprint
await self.session.flush()
class ProjectRepository:
def __init__(self, session: AsyncSession) -> None:
self.session = session