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
460 lines
16 KiB
Python
460 lines
16 KiB
Python
"""CI/CD runs, releases, production deploy: /organizations/{org}/delivery/*.
|
|
|
|
Owned by Agent 3 (see docs/plan/PLAN-v2.md §6). Registered in ``rpc_app.py`` already; give each
|
|
route its full path (no prefix is applied here besides ``/api``).
|
|
|
|
**Who may call what.** Reading a build — its steps, its logs, the demo URL — is
|
|
an ordinary thing for a member of the organization to do, so the GET routes need
|
|
only membership. Everything that *causes* work (re-run, cancel, rollback) or
|
|
touches production requires `projects.manage`, which is org admin and above. The
|
|
production deploy endpoint (Phase 5) additionally demands the org slug typed back
|
|
as a confirmation, because it is the one irreversible action in the product.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import datetime
|
|
|
|
from fastapi import APIRouter, Depends, Query, Request, status
|
|
from pydantic import BaseModel, Field
|
|
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,
|
|
)
|
|
|
|
router = APIRouter(prefix="/organizations/{org_id}/delivery", tags=["delivery"])
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Schemas
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class PipelineStepOut(BaseModel):
|
|
id: str
|
|
index: int
|
|
name: str
|
|
status: str
|
|
exit_code: int | None
|
|
log_tail: str
|
|
duration_ms: int
|
|
|
|
|
|
class PipelineRunOut(BaseModel):
|
|
id: str
|
|
project_id: str
|
|
branch: str
|
|
sha: str
|
|
trigger: str
|
|
status: str
|
|
error_summary: str
|
|
started_at: datetime | None
|
|
finished_at: datetime | None
|
|
created_at: datetime
|
|
|
|
|
|
class PipelineRunDetailOut(PipelineRunOut):
|
|
steps: list[PipelineStepOut]
|
|
|
|
|
|
class ReleaseOut(BaseModel):
|
|
id: str
|
|
project_id: str
|
|
run_id: str
|
|
sha: str
|
|
is_demo_live: bool
|
|
created_at: datetime
|
|
|
|
|
|
class RuntimeOut(BaseModel):
|
|
project_id: str
|
|
demo_host: str
|
|
demo_url: str
|
|
host_port: int | None
|
|
status: str
|
|
current_release_id: str | None
|
|
updated_at: datetime
|
|
|
|
|
|
class ManualRunRequest(BaseModel):
|
|
project_id: str = Field(min_length=1, max_length=32)
|
|
|
|
|
|
class RollbackRequest(BaseModel):
|
|
project_id: str = Field(min_length=1, max_length=32)
|
|
release_id: str | None = Field(default=None, max_length=32)
|
|
|
|
|
|
def _run_out(run) -> PipelineRunOut: # noqa: ANN001 - a domain dataclass
|
|
return PipelineRunOut(
|
|
id=run.id, project_id=run.project_id, branch=run.branch, sha=run.sha,
|
|
trigger=run.trigger, status=run.status, error_summary=run.error_summary,
|
|
started_at=run.started_at, finished_at=run.finished_at, created_at=run.created_at,
|
|
)
|
|
|
|
|
|
def _release_out(release) -> ReleaseOut: # noqa: ANN001
|
|
return ReleaseOut(
|
|
id=release.id, project_id=release.project_id, run_id=release.run_id,
|
|
sha=release.sha, is_demo_live=release.is_demo_live, created_at=release.created_at,
|
|
)
|
|
|
|
|
|
def _runtime_out(runtime) -> RuntimeOut: # noqa: ANN001
|
|
return RuntimeOut(
|
|
project_id=runtime.project_id, demo_host=runtime.demo_host,
|
|
demo_url=runtime.demo_url, host_port=runtime.host_port, status=runtime.status,
|
|
current_release_id=runtime.current_release_id, updated_at=runtime.updated_at,
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Runs
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@router.get("/runs", response_model=list[PipelineRunOut])
|
|
async def list_runs(
|
|
project_id: str = Query(min_length=1),
|
|
context: OrgContext = Depends(get_org_context),
|
|
service: DeliveryService = Depends(get_delivery_service),
|
|
limit: int = Query(default=50, ge=1, le=200),
|
|
offset: int = Query(default=0, ge=0),
|
|
) -> list[PipelineRunOut]:
|
|
runs = await service.list_runs(
|
|
organization_id=context.org_id, project_id=project_id, limit=limit, offset=offset
|
|
)
|
|
return [_run_out(r) for r in runs]
|
|
|
|
|
|
@router.get("/runs/{run_id}", response_model=PipelineRunDetailOut)
|
|
async def get_run(
|
|
run_id: str,
|
|
context: OrgContext = Depends(get_org_context),
|
|
service: DeliveryService = Depends(get_delivery_service),
|
|
) -> PipelineRunDetailOut:
|
|
run, steps = await service.get_run_detail(organization_id=context.org_id, run_id=run_id)
|
|
return PipelineRunDetailOut(
|
|
**_run_out(run).model_dump(),
|
|
steps=[
|
|
PipelineStepOut(
|
|
id=s.id, index=s.index, name=s.name, status=s.status,
|
|
exit_code=s.exit_code, log_tail=s.log_tail, duration_ms=s.duration_ms,
|
|
)
|
|
for s in steps
|
|
],
|
|
)
|
|
|
|
|
|
@router.post("/runs", response_model=PipelineRunOut, status_code=status.HTTP_201_CREATED)
|
|
async def start_run(
|
|
payload: ManualRunRequest,
|
|
request: Request,
|
|
context: OrgContext = Depends(get_org_context),
|
|
service: DeliveryService = Depends(get_delivery_service),
|
|
session: AsyncSession = Depends(get_session),
|
|
) -> PipelineRunOut:
|
|
"""Re-run the current `dev` head. Never a production deploy."""
|
|
context.require(CAN_MANAGE_PROJECTS)
|
|
run = await service.request_manual_run(
|
|
organization_id=context.org_id, project_id=payload.project_id
|
|
)
|
|
ip, ua = client_meta(request)
|
|
await record_audit(
|
|
session, action="delivery.run_started", organization_id=context.org_id,
|
|
actor_user_id=context.user.id, target_type="pipeline_run", target_id=run.id,
|
|
metadata={"project_id": run.project_id, "sha": run.sha, "trigger": run.trigger},
|
|
ip=ip, user_agent=ua,
|
|
)
|
|
return _run_out(run)
|
|
|
|
|
|
@router.post("/runs/{run_id}/cancel", response_model=PipelineRunOut)
|
|
async def cancel_run(
|
|
run_id: str,
|
|
request: Request,
|
|
context: OrgContext = Depends(get_org_context),
|
|
service: DeliveryService = Depends(get_delivery_service),
|
|
session: AsyncSession = Depends(get_session),
|
|
) -> PipelineRunOut:
|
|
context.require(CAN_MANAGE_PROJECTS)
|
|
run = await service.cancel_run(organization_id=context.org_id, run_id=run_id)
|
|
ip, ua = client_meta(request)
|
|
await record_audit(
|
|
session, action="delivery.run_cancelled", organization_id=context.org_id,
|
|
actor_user_id=context.user.id, target_type="pipeline_run", target_id=run.id,
|
|
ip=ip, user_agent=ua,
|
|
)
|
|
return _run_out(run)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Releases & runtime
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@router.get("/releases", response_model=list[ReleaseOut])
|
|
async def list_releases(
|
|
project_id: str = Query(min_length=1),
|
|
context: OrgContext = Depends(get_org_context),
|
|
service: DeliveryService = Depends(get_delivery_service),
|
|
) -> list[ReleaseOut]:
|
|
releases = await service.list_releases(
|
|
organization_id=context.org_id, project_id=project_id
|
|
)
|
|
return [_release_out(r) for r in releases]
|
|
|
|
|
|
@router.get("/runtime", response_model=RuntimeOut)
|
|
async def get_runtime(
|
|
project_id: str = Query(min_length=1),
|
|
context: OrgContext = Depends(get_org_context),
|
|
service: DeliveryService = Depends(get_delivery_service),
|
|
) -> RuntimeOut:
|
|
runtime = await service.runtime_status(
|
|
organization_id=context.org_id, project_id=project_id
|
|
)
|
|
return _runtime_out(runtime)
|
|
|
|
|
|
@router.post("/releases/rollback", response_model=ReleaseOut)
|
|
async def rollback_demo(
|
|
payload: RollbackRequest,
|
|
request: Request,
|
|
context: OrgContext = Depends(get_org_context),
|
|
service: DeliveryService = Depends(get_delivery_service),
|
|
session: AsyncSession = Depends(get_session),
|
|
) -> ReleaseOut:
|
|
"""Point the demo host back at an earlier release (admin+, audited)."""
|
|
context.require(CAN_MANAGE_PROJECTS)
|
|
release = await service.rollback_demo(
|
|
organization_id=context.org_id,
|
|
project_id=payload.project_id,
|
|
release_id=payload.release_id,
|
|
)
|
|
ip, ua = client_meta(request)
|
|
await record_audit(
|
|
session, action="delivery.demo_rollback", organization_id=context.org_id,
|
|
actor_user_id=context.user.id, target_type="release", target_id=release.id,
|
|
metadata={"project_id": release.project_id, "sha": release.sha},
|
|
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,
|
|
)
|
|
)
|