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:
@@ -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")
|
||||
Reference in New Issue
Block a user