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
+29
View File
@@ -157,3 +157,32 @@ class AppRuntime:
@property
def demo_url(self) -> str:
return f"https://{self.demo_host}" if self.demo_host else ""
class DeployStatus(StrEnum):
QUEUED = "queued"
RUNNING = "running"
SUCCEEDED = "succeeded"
FAILED = "failed"
#: A deploy that undid an earlier one. Kept as its own row so "what was
#: live when?" is answerable from the table alone.
ROLLED_BACK = "rolled_back"
@dataclass(slots=True, kw_only=True)
class ProductionDeploy:
organization_id: str
project_id: str
kind: str
host: str = ""
release_id: str | None = None
target_id: str | None = None
status: str = DeployStatus.QUEUED.value
message: str = ""
log_tail: str = ""
rolled_back_from: str | None = None
backup_path: str = ""
id: str = field(default_factory=new_id)
started_at: datetime | None = None
finished_at: datetime | None = None
created_at: datetime = field(default_factory=utcnow)