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