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:
@@ -0,0 +1,159 @@
|
||||
"""Delivery domain: CI/CD runs, releases and the demo runtime.
|
||||
|
||||
Framework-free, like every other domain package. The interesting rules live in
|
||||
the *state* enums rather than in behaviour, because the behaviour is all about
|
||||
ordering: a run is `queued → running → succeeded|failed|cancelled`, a step is
|
||||
`pending → running → succeeded|failed|skipped`, and a release only becomes the
|
||||
live demo one when the healthcheck has passed and the route has been switched.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from enum import StrEnum
|
||||
|
||||
from app.domain.base import new_id, utcnow
|
||||
|
||||
|
||||
class RunTrigger(StrEnum):
|
||||
"""Why a pipeline run exists.
|
||||
|
||||
``engine`` and ``poll`` are the two automatic sources; ``manual`` is an
|
||||
org admin pressing re-run. The distinction matters in the audit trail: "who
|
||||
started this build?" is the first question asked when a deploy goes wrong.
|
||||
"""
|
||||
|
||||
ENGINE = "engine"
|
||||
POLL = "poll"
|
||||
MANUAL = "manual"
|
||||
|
||||
|
||||
class RunStatus(StrEnum):
|
||||
QUEUED = "queued"
|
||||
RUNNING = "running"
|
||||
SUCCEEDED = "succeeded"
|
||||
FAILED = "failed"
|
||||
CANCELLED = "cancelled"
|
||||
#: A newer run for the same project replaced this one before it started.
|
||||
SUPERSEDED = "superseded"
|
||||
|
||||
|
||||
class StepStatus(StrEnum):
|
||||
PENDING = "pending"
|
||||
RUNNING = "running"
|
||||
SUCCEEDED = "succeeded"
|
||||
FAILED = "failed"
|
||||
#: The step had nothing to do (an empty build_command, say). Not a failure.
|
||||
SKIPPED = "skipped"
|
||||
|
||||
|
||||
class RuntimeStatus(StrEnum):
|
||||
PROVISIONING = "provisioning"
|
||||
LIVE = "live"
|
||||
DEGRADED = "degraded"
|
||||
STOPPED = "stopped"
|
||||
|
||||
|
||||
#: The eight steps of a run, in order. The names are the product contract: the
|
||||
#: UI renders them verbatim and the tests assert on the exact sequence.
|
||||
PIPELINE_STEPS: tuple[str, ...] = (
|
||||
"checkout",
|
||||
"setup",
|
||||
"build",
|
||||
"test",
|
||||
"package",
|
||||
"deploy_demo",
|
||||
"healthcheck",
|
||||
"route",
|
||||
)
|
||||
|
||||
#: Steps that are skipped (not failed) when the app profile leaves the command
|
||||
#: empty. `checkout`, `deploy_demo`, `healthcheck` and `route` always run.
|
||||
OPTIONAL_STEPS: frozenset[str] = frozenset({"setup", "build", "test"})
|
||||
|
||||
#: Statuses that mean "this run is no longer interesting".
|
||||
TERMINAL_RUN_STATUSES: frozenset[str] = frozenset(
|
||||
{
|
||||
RunStatus.SUCCEEDED.value,
|
||||
RunStatus.FAILED.value,
|
||||
RunStatus.CANCELLED.value,
|
||||
RunStatus.SUPERSEDED.value,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@dataclass(slots=True, kw_only=True)
|
||||
class PipelineRun:
|
||||
organization_id: str
|
||||
project_id: str
|
||||
branch: str = "dev"
|
||||
sha: str = ""
|
||||
trigger: str = RunTrigger.ENGINE.value
|
||||
status: str = RunStatus.QUEUED.value
|
||||
error_summary: str = ""
|
||||
id: str = field(default_factory=new_id)
|
||||
started_at: datetime | None = None
|
||||
finished_at: datetime | None = None
|
||||
created_at: datetime = field(default_factory=utcnow)
|
||||
|
||||
@property
|
||||
def is_terminal(self) -> bool:
|
||||
return self.status in TERMINAL_RUN_STATUSES
|
||||
|
||||
@property
|
||||
def is_active(self) -> bool:
|
||||
return self.status in {RunStatus.QUEUED.value, RunStatus.RUNNING.value}
|
||||
|
||||
|
||||
@dataclass(slots=True, kw_only=True)
|
||||
class PipelineStep:
|
||||
run_id: str
|
||||
index: int
|
||||
name: str
|
||||
status: str = StepStatus.PENDING.value
|
||||
exit_code: int | None = None
|
||||
log_tail: str = ""
|
||||
id: str = field(default_factory=new_id)
|
||||
started_at: datetime | None = None
|
||||
finished_at: datetime | None = None
|
||||
|
||||
@property
|
||||
def duration_ms(self) -> int:
|
||||
if not self.started_at or not self.finished_at:
|
||||
return 0
|
||||
return max(0, int((self.finished_at - self.started_at).total_seconds() * 1000))
|
||||
|
||||
|
||||
@dataclass(slots=True, kw_only=True)
|
||||
class Release:
|
||||
organization_id: str
|
||||
project_id: str
|
||||
run_id: str
|
||||
sha: str
|
||||
artifact_path: str = ""
|
||||
is_demo_live: bool = False
|
||||
id: str = field(default_factory=new_id)
|
||||
created_at: datetime = field(default_factory=utcnow)
|
||||
|
||||
|
||||
@dataclass(slots=True, kw_only=True)
|
||||
class AppRuntime:
|
||||
"""The one demo container a project has.
|
||||
|
||||
``host_port`` is the *live* port; a blue/green switch starts the replacement
|
||||
on a second port and only rewrites this row once the healthcheck passes.
|
||||
"""
|
||||
|
||||
project_id: str
|
||||
organization_id: str
|
||||
demo_host: str = ""
|
||||
host_port: int | None = None
|
||||
demo_container_id: str | None = None
|
||||
status: str = RuntimeStatus.PROVISIONING.value
|
||||
current_release_id: str | None = None
|
||||
updated_at: datetime = field(default_factory=utcnow)
|
||||
|
||||
@property
|
||||
def demo_url(self) -> str:
|
||||
return f"https://{self.demo_host}" if self.demo_host else ""
|
||||
Reference in New Issue
Block a user