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:
@@ -5,6 +5,126 @@ Use ``from app.core.database import Base`` and the helpers in
|
||||
``app.infrastructure.db.models`` here: that module imports this one (import cycle).
|
||||
Foreign keys to existing tables are written as plain strings, e.g.
|
||||
``ForeignKey("organizations.id", ondelete="CASCADE")``. See docs/plan/PLAN-v2.md §6.
|
||||
|
||||
Four tables carry the whole CI/CD story:
|
||||
|
||||
* ``pipeline_runs`` — one row per build. ``(project_id, status)`` is indexed
|
||||
because the worker's hot query is "the queued runs, oldest first".
|
||||
* ``pipeline_steps`` — the eight steps of a run, with a bounded log tail. The
|
||||
full output is streamed live and never stored; a build log is unbounded and a
|
||||
database is the wrong place for it.
|
||||
* ``releases`` — every sha that passed, so a demo rollback is a pointer move
|
||||
rather than a rebuild.
|
||||
* ``app_runtimes`` — the demo container a project currently has. One row per
|
||||
project (it is the primary key), because D5 gives each app exactly one demo
|
||||
container; blue/green is a temporary second container, not a second row.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, ForeignKey, Index, Integer, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.core.database import Base
|
||||
from app.domain.base import utcnow
|
||||
from app.infrastructure.db.columns import created, pk
|
||||
|
||||
#: 64 KB, matching the plan's cap on a stored step log tail.
|
||||
MAX_LOG_TAIL_CHARS = 65_536
|
||||
|
||||
|
||||
class PipelineRunRow(Base):
|
||||
__tablename__ = "pipeline_runs"
|
||||
__table_args__ = (
|
||||
Index("ix_pipeline_run_project_status", "project_id", "status"),
|
||||
Index("ix_pipeline_run_org_created", "organization_id", "created_at"),
|
||||
)
|
||||
|
||||
id: Mapped[str] = pk()
|
||||
organization_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("organizations.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
project_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("projects.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
trigger: Mapped[str] = mapped_column(String(16), default="engine", nullable=False)
|
||||
branch: Mapped[str] = mapped_column(String(120), default="dev", nullable=False)
|
||||
sha: Mapped[str] = mapped_column(String(64), default="", nullable=False)
|
||||
status: Mapped[str] = mapped_column(String(16), default="queued", nullable=False)
|
||||
error_summary: Mapped[str] = mapped_column(Text, default="", nullable=False)
|
||||
started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
created_at: Mapped[datetime] = created()
|
||||
|
||||
|
||||
class PipelineStepRow(Base):
|
||||
__tablename__ = "pipeline_steps"
|
||||
__table_args__ = (Index("ix_pipeline_step_run_index", "run_id", "index"),)
|
||||
|
||||
id: Mapped[str] = pk()
|
||||
run_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("pipeline_runs.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
index: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
name: Mapped[str] = mapped_column(String(48), nullable=False)
|
||||
status: Mapped[str] = mapped_column(String(16), default="pending", nullable=False)
|
||||
exit_code: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
log_tail: Mapped[str] = mapped_column(Text, default="", nullable=False)
|
||||
started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
|
||||
class ReleaseRow(Base):
|
||||
__tablename__ = "releases"
|
||||
__table_args__ = (Index("ix_release_project_created", "project_id", "created_at"),)
|
||||
|
||||
id: Mapped[str] = pk()
|
||||
organization_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("organizations.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
project_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("projects.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
run_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("pipeline_runs.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
sha: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
artifact_path: Mapped[str] = mapped_column(String(512), default="", nullable=False)
|
||||
#: Exactly one release per project is live; the switch is a transaction.
|
||||
is_demo_live: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
created_at: Mapped[datetime] = created()
|
||||
|
||||
|
||||
class AppRuntimeRow(Base):
|
||||
"""The demo container for one project (one row per project, by design)."""
|
||||
|
||||
__tablename__ = "app_runtimes"
|
||||
__table_args__ = (Index("ix_app_runtime_org", "organization_id"),)
|
||||
|
||||
project_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("projects.id", ondelete="CASCADE"), primary_key=True
|
||||
)
|
||||
organization_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("organizations.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
demo_container_id: Mapped[str | None] = mapped_column(String(160), nullable=True)
|
||||
host_port: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
demo_host: Mapped[str] = mapped_column(String(255), default="", nullable=False)
|
||||
status: Mapped[str] = mapped_column(String(16), default="provisioning", nullable=False)
|
||||
current_release_id: Mapped[str | None] = mapped_column(
|
||||
ForeignKey("releases.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=utcnow, onupdate=utcnow, nullable=False
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"AppRuntimeRow",
|
||||
"MAX_LOG_TAIL_CHARS",
|
||||
"PipelineRunRow",
|
||||
"PipelineStepRow",
|
||||
"ReleaseRow",
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user