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:
mjalmoudy94
2026-10-14 01:16:23 +03:30
parent 82cea1e690
commit 72408a0a4f
15 changed files with 2872 additions and 3 deletions
@@ -0,0 +1,73 @@
"""production deploys: the human-triggered deploy ledger
Revision ID: a3b4c5d6e7f4
Revises: a3b4c5d6e7f3
Create Date: 2026-10-14 01:10:00.000000
Chained off this workstream's own head (PLAN-v2 §6: "more only if chained off
your own"), so the three agents' branches still merge cleanly.
"""
from __future__ import annotations
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "a3b4c5d6e7f4"
down_revision: str | None = "a3b4c5d6e7f3"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.create_table(
"production_deploys",
sa.Column("id", sa.String(length=32), primary_key=True),
sa.Column(
"organization_id",
sa.String(length=32),
sa.ForeignKey("organizations.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column(
"project_id",
sa.String(length=32),
sa.ForeignKey("projects.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column(
"release_id",
sa.String(length=32),
sa.ForeignKey("releases.id", ondelete="SET NULL"),
nullable=True,
),
sa.Column(
"target_id",
sa.String(length=32),
sa.ForeignKey("deploy_targets.id", ondelete="SET NULL"),
nullable=True,
),
sa.Column("kind", sa.String(length=8), nullable=False),
sa.Column("host", sa.String(length=255), nullable=False, server_default=""),
sa.Column("status", sa.String(length=16), nullable=False, server_default="queued"),
sa.Column("message", sa.Text(), nullable=False, server_default=""),
sa.Column("log_tail", sa.Text(), nullable=False, server_default=""),
sa.Column("rolled_back_from", sa.String(length=32), nullable=True),
sa.Column("backup_path", sa.String(length=512), nullable=False, server_default=""),
sa.Column("started_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("finished_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
)
op.create_index(
"ix_production_deploys_organization_id", "production_deploys", ["organization_id"]
)
op.create_index("ix_production_deploys_project_id", "production_deploys", ["project_id"])
op.create_index(
"ix_prod_deploy_project_created", "production_deploys", ["project_id", "created_at"]
)
def downgrade() -> None:
op.drop_table("production_deploys")