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
+9
View File
@@ -204,3 +204,12 @@ async def get_delivery_service(
from app.application.container import build_delivery_service
return build_delivery_service(session)
async def get_deploy_service(
session: AsyncSession = Depends(get_session),
):
"""Build the production deploy service with the configured transports."""
from app.application.container import build_deploy_service
return build_deploy_service(session)
@@ -21,12 +21,14 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.application.services.audit_service import record_audit
from app.application.services.delivery_service import DeliveryService
from app.application.services.deploy_service import DeployService
from app.core.database import get_session
from app.domain.identity.models import CAN_MANAGE_PROJECTS
from app.interfaces.http.deps import (
OrgContext,
client_meta,
get_delivery_service,
get_deploy_service,
get_org_context,
)
@@ -248,3 +250,210 @@ async def rollback_demo(
ip=ip, user_agent=ua,
)
return _release_out(release)
# ---------------------------------------------------------------------------
# Production (always human-triggered, always confirmed)
# ---------------------------------------------------------------------------
class ProductionDeployOut(BaseModel):
id: str
project_id: str
kind: str
host: str
status: str
message: str
log_tail: str
release_id: str | None
rolled_back_from: str | None
started_at: datetime | None
finished_at: datetime | None
created_at: datetime
class ProductionTargetOut(BaseModel):
"""The target as a *user* sees it: never a secret, only a hint that one exists."""
kind: str
host: str
port: int
username: str
auth_type: str
remote_path: str
has_secret: bool
host_fingerprint: str | None
last_tested_at: datetime | None
last_test_ok: bool | None
last_test_message: str | None
class ProductionOverviewOut(BaseModel):
target: ProductionTargetOut | None
deploys: list[ProductionDeployOut]
class DeployConfirmRequest(BaseModel):
project_id: str = Field(min_length=1, max_length=32)
#: The organization's slug, typed back. Not ceremony: this is the only
#: action in the product the platform cannot undo.
confirm: str = Field(min_length=1, max_length=120)
release_id: str | None = Field(default=None, max_length=32)
class RollbackConfirmRequest(BaseModel):
project_id: str = Field(min_length=1, max_length=32)
confirm: str = Field(min_length=1, max_length=120)
deploy_id: str | None = Field(default=None, max_length=32)
class TargetTestOut(BaseModel):
ok: bool
message: str
def _deploy_out(deploy) -> ProductionDeployOut: # noqa: ANN001 - a domain dataclass
return ProductionDeployOut(
id=deploy.id, project_id=deploy.project_id, kind=deploy.kind, host=deploy.host,
status=deploy.status, message=deploy.message, log_tail=deploy.log_tail,
release_id=deploy.release_id, rolled_back_from=deploy.rolled_back_from,
started_at=deploy.started_at, finished_at=deploy.finished_at,
created_at=deploy.created_at,
)
def _target_out(target) -> ProductionTargetOut: # noqa: ANN001
return ProductionTargetOut(
kind=target.kind, host=target.host, port=target.port, username=target.username,
auth_type=target.auth_type, remote_path=target.remote_path,
# The secret itself is never returned — only that one is configured.
has_secret=bool(target.secret_encrypted),
host_fingerprint=target.host_fingerprint,
last_tested_at=target.last_tested_at, last_test_ok=target.last_test_ok,
last_test_message=target.last_test_message,
)
@router.get("/production", response_model=ProductionOverviewOut)
async def production_overview(
project_id: str = Query(min_length=1),
context: OrgContext = Depends(get_org_context),
deploys: DeployService = Depends(get_deploy_service),
) -> ProductionOverviewOut:
"""The target summary and the deploy history (admin+ — it names a host)."""
context.require(CAN_MANAGE_PROJECTS)
target = await deploys.get_target(
organization_id=context.org_id, project_id=project_id
)
history = await deploys.list_deploys(
organization_id=context.org_id, project_id=project_id
)
return ProductionOverviewOut(
target=_target_out(target) if target else None,
deploys=[_deploy_out(d) for d in history],
)
@router.post("/production/test", response_model=TargetTestOut)
async def test_production_target(
payload: ManualRunRequest,
context: OrgContext = Depends(get_org_context),
deploys: DeployService = Depends(get_deploy_service),
) -> TargetTestOut:
"""Test the configured target and record the result (admin+)."""
context.require(CAN_MANAGE_PROJECTS)
ok, message = await deploys.test_target(
organization_id=context.org_id,
project_id=payload.project_id,
actor_user_id=context.user.id,
)
return TargetTestOut(ok=ok, message=message)
@router.post("/production/deploy", response_model=ProductionDeployOut)
async def deploy_to_production(
payload: DeployConfirmRequest,
request: Request,
context: OrgContext = Depends(get_org_context),
deploys: DeployService = Depends(get_deploy_service),
session: AsyncSession = Depends(get_session),
) -> ProductionDeployOut:
"""Ship a release to the customer's server (org owner/admin, confirmed)."""
context.require(CAN_MANAGE_PROJECTS)
deploy = await deploys.deploy_production(
organization_id=context.org_id,
project_id=payload.project_id,
actor_user_id=context.user.id,
confirm=payload.confirm,
release_id=payload.release_id,
)
ip, ua = client_meta(request)
await record_audit(
session, action="deploy.production", organization_id=context.org_id,
actor_user_id=context.user.id, target_type="production_deploy", target_id=deploy.id,
metadata={
"project_id": deploy.project_id,
"kind": deploy.kind,
"status": deploy.status,
"release_id": deploy.release_id,
},
ip=ip, user_agent=ua,
)
await _emit_deploy_status(context.org_id, deploy)
return _deploy_out(deploy)
@router.post("/production/rollback", response_model=ProductionDeployOut)
async def rollback_production(
payload: RollbackConfirmRequest,
request: Request,
context: OrgContext = Depends(get_org_context),
deploys: DeployService = Depends(get_deploy_service),
session: AsyncSession = Depends(get_session),
) -> ProductionDeployOut:
"""Undo a production deploy (org owner/admin, confirmed, audited)."""
context.require(CAN_MANAGE_PROJECTS)
deploy = await deploys.rollback_production(
organization_id=context.org_id,
project_id=payload.project_id,
actor_user_id=context.user.id,
confirm=payload.confirm,
deploy_id=payload.deploy_id,
)
ip, ua = client_meta(request)
await record_audit(
session, action="deploy.production_rollback", organization_id=context.org_id,
actor_user_id=context.user.id, target_type="production_deploy", target_id=deploy.id,
metadata={
"project_id": deploy.project_id,
"kind": deploy.kind,
"status": deploy.status,
"rolled_back_from": deploy.rolled_back_from,
},
ip=ip, user_agent=ua,
)
await _emit_deploy_status(context.org_id, deploy)
return _deploy_out(deploy)
async def _emit_deploy_status(organization_id: str, deploy) -> None: # noqa: ANN001
"""Push the outcome on the tenant channel. Never raises."""
import contextlib
from app.application.ports.realtime import RealtimeEvent, get_publisher, org_channel
with contextlib.suppress(Exception):
await get_publisher().publish(
RealtimeEvent(
type="deploy.status",
channels=(org_channel(organization_id),),
data={
"project_id": deploy.project_id,
"deploy_id": deploy.id,
"kind": deploy.kind,
"status": deploy.status,
"message": deploy.message,
},
organization_id=organization_id,
)
)