"""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 "" class DeployStatus(StrEnum): QUEUED = "queued" RUNNING = "running" SUCCEEDED = "succeeded" FAILED = "failed" #: A deploy that undid an earlier one. Kept as its own row so "what was #: live when?" is answerable from the table alone. ROLLED_BACK = "rolled_back" @dataclass(slots=True, kw_only=True) class ProductionDeploy: organization_id: str project_id: str kind: str host: str = "" release_id: str | None = None target_id: str | None = None status: str = DeployStatus.QUEUED.value message: str = "" log_tail: str = "" rolled_back_from: str | None = None backup_path: 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)