feat(delivery): production deploy over SSH/SFTP/FTP/FTPS (v2 phase A3-5)
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
This commit is contained in:
@@ -121,8 +121,46 @@ class AppRuntimeRow(Base):
|
||||
)
|
||||
|
||||
|
||||
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",
|
||||
|
||||
Reference in New Issue
Block a user