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:
@@ -236,6 +236,42 @@ class PreviewDomainRepository:
|
||||
)
|
||||
await self.session.flush()
|
||||
|
||||
async def set_port(
|
||||
self, *, project_id: str, service_name: str, port: int
|
||||
) -> None:
|
||||
"""Re-point an existing service to a new port.
|
||||
|
||||
The blue/green switch uses this: the *service name* (`demo`) always means
|
||||
"the live port", so a deploy rewrites it rather than accumulating one
|
||||
ledger row per release.
|
||||
"""
|
||||
row = (
|
||||
await self.session.execute(
|
||||
select(PreviewPortRow).where(
|
||||
PreviewPortRow.project_id == project_id,
|
||||
PreviewPortRow.service_name == service_name,
|
||||
)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if row is None:
|
||||
return
|
||||
row.port = port
|
||||
await self.session.flush()
|
||||
|
||||
async def delete_port(self, *, project_id: str, service_name: str) -> None:
|
||||
"""Release a transient port reservation (a staging port, say)."""
|
||||
row = (
|
||||
await self.session.execute(
|
||||
select(PreviewPortRow).where(
|
||||
PreviewPortRow.project_id == project_id,
|
||||
PreviewPortRow.service_name == service_name,
|
||||
)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if row is not None:
|
||||
await self.session.delete(row)
|
||||
await self.session.flush()
|
||||
|
||||
# -- domains -------------------------------------------------------------
|
||||
|
||||
async def get_by_host(self, host: str) -> dict | None:
|
||||
|
||||
Reference in New Issue
Block a user