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