The one path that touches a customer's server, and the one the platform cannot undo. It is human-triggered, confirmed and audited; there is deliberately no code path from a pipeline run into it (D13). - deploy_ssh.py: SshDeployer. Release lands in releases/<ts>-<sha7>/, the post-deploy command runs with cwd there, and only then does `current` move — atomically (relative symlink + mv -T). A failed hook or extraction never moves it, so the previous release keeps serving. Host key is pinned on first success (TOFU); a mismatch is a hard failure. Prunes to the newest five. - deploy_ftp.py: FtpDeployer. FTP has no atomic switch and no rollback except a backup, so a backup is mandatory: every file about to be overwritten is archived under ARTIFACTS_ROOT before anything is uploaded. The artifact is extracted, then the build dir (dist/build/public/out) is chosen — a static deploy must not publish the repository. Archive members are sanitised: absolute paths, `..` and escaping links are refused. - deploy_target.py: the tester that dispatches on `kind`. - deploy_service.py + production_deploys (migration a3b4c5d6e7f4) + 4 routes. One deploy per project at a time; the org slug must be typed back; the target summary reports has_secret, never the secret. Tests: 499 API (+26) with fake SSH/FTP transports; ruff, mypy, alembic clean. Report: docs/plan/reports/agent-3-phase-5.md
169 lines
7.6 KiB
Python
169 lines
7.6 KiB
Python
"""ORM tables owned by Agent 3 (delivery & infra).
|
|
|
|
Use ``from app.core.database import Base`` and the helpers in
|
|
``app.infrastructure.db.columns`` (``pk()``, ``created()``). Do NOT import from
|
|
``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
|
|
)
|
|
|
|
|
|
class ProductionDeployRow(Base):
|
|
"""One human-triggered production deploy (and its rollbacks).
|
|
|
|
``log_tail`` is bounded exactly like a pipeline step's: the full output is
|
|
streamed, the tail is kept for the post-mortem. ``rolled_back_from`` makes a
|
|
rollback its own row rather than an edit of the deploy it undid — "what was
|
|
live when?" must be answerable from the table alone.
|
|
"""
|
|
|
|
__tablename__ = "production_deploys"
|
|
__table_args__ = (Index("ix_prod_deploy_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
|
|
)
|
|
release_id: Mapped[str | None] = mapped_column(
|
|
ForeignKey("releases.id", ondelete="SET NULL"), nullable=True
|
|
)
|
|
target_id: Mapped[str | None] = mapped_column(
|
|
ForeignKey("deploy_targets.id", ondelete="SET NULL"), nullable=True
|
|
)
|
|
kind: Mapped[str] = mapped_column(String(8), nullable=False)
|
|
host: Mapped[str] = mapped_column(String(255), default="", nullable=False)
|
|
status: Mapped[str] = mapped_column(String(16), default="queued", nullable=False)
|
|
message: Mapped[str] = mapped_column(Text, default="", nullable=False)
|
|
log_tail: Mapped[str] = mapped_column(Text, default="", nullable=False)
|
|
rolled_back_from: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
|
backup_path: Mapped[str] = mapped_column(String(512), 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()
|
|
|
|
|
|
__all__ = [
|
|
"AppRuntimeRow",
|
|
"ProductionDeployRow",
|
|
"MAX_LOG_TAIL_CHARS",
|
|
"PipelineRunRow",
|
|
"PipelineStepRow",
|
|
"ReleaseRow",
|
|
]
|