feat(delivery): live CI/CD pipeline + demo runtime (v2 phase A3-3)
A push to `dev` now becomes a live demo, and a failed build never takes the demo down. - Four tables (pipeline_runs, pipeline_steps, releases, app_runtimes) and their migration; alembic upgrade/downgrade/check all clean. - DeliveryService implements DeliveryHooks: on_app_created provisions the demo port + host + first build (idempotently); on_branch_pushed queues a run for `dev` and supersedes older *queued* runs, leaving a running one alone. - The 8-step pipeline. Blue/green is real: the replacement starts on a staging port, is healthchecked, and only then does the route move — then the previous container is stopped. A failure removes the replacement, releases its port and leaves the old container serving. - The demo port ledger is the DATABASE, not the router adapters' in-memory maps: the API and the delivery worker are separate processes. - Runs are claimed with a conditional UPDATE, so two workers never build the same run. - Live logs are batched (1 event / 500 ms, 200 lines) and redacted as a whole tail — per-chunk redaction misses a secret split across pipe reads, and the *stored* tail leaked until this was fixed. - delivery worker: drain the queue, then poll remotes for pushes made outside the engine. - 7 routes; reads need membership, actions need projects.manage and are audited. Tests: 415 API (+21, 1 docker-marked skipped); ruff, mypy, alembic check clean. Report: docs/plan/reports/agent-3-phase-3.md
This commit is contained in:
@@ -195,3 +195,12 @@ async def get_sandbox_service(
|
||||
from app.application.container import build_sandbox_service
|
||||
|
||||
return build_sandbox_service(session)
|
||||
|
||||
|
||||
async def get_delivery_service(
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Build the CI/CD service with the configured workspace, router and runtime."""
|
||||
from app.application.container import build_delivery_service
|
||||
|
||||
return build_delivery_service(session)
|
||||
|
||||
@@ -2,10 +2,249 @@
|
||||
|
||||
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 fastapi import APIRouter
|
||||
from datetime import datetime
|
||||
|
||||
router = APIRouter(tags=["delivery"])
|
||||
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.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_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)
|
||||
|
||||
Reference in New Issue
Block a user