"""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, ) )