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
57 lines
2.2 KiB
Python
57 lines
2.2 KiB
Python
"""The deploy-target tester that dispatches on ``kind``.
|
|
|
|
``DeployTargetTester`` is one contract with two very different implementations,
|
|
and the wizard does not care which one it is talking to: it hands over a
|
|
:class:`DeployTargetSpec` and wants a yes/no and a plain-language reason.
|
|
|
|
The dispatcher exists so the wizard never has to know that `ssh` and `sftp` are
|
|
the same protocol while `ftp` is a different world with no host key, no atomic
|
|
switch and a mandatory backup. The *differences* are documented on the deployers
|
|
themselves; the caller sees one shape.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from app.application.ports.deploy_target import (
|
|
DEPLOY_KINDS,
|
|
DeployCheck,
|
|
DeployTargetSpec,
|
|
)
|
|
from app.core.config import settings
|
|
from app.core.errors import ValidationError
|
|
from app.infrastructure.adapters.deploy_ftp import FtpDeployer
|
|
from app.infrastructure.adapters.deploy_ssh import SshDeployer
|
|
|
|
|
|
class DispatchingDeployTargetTester:
|
|
"""``test_connection`` routed to the right transport for ``spec.kind``."""
|
|
|
|
def __init__(
|
|
self,
|
|
*,
|
|
ssh: SshDeployer | None = None,
|
|
ftp: FtpDeployer | None = None,
|
|
) -> None:
|
|
allow_private = settings.deploy_allow_private_hosts
|
|
self.ssh = ssh or SshDeployer(allow_private_hosts=allow_private)
|
|
self.ftp = ftp or FtpDeployer(allow_private_hosts=allow_private)
|
|
|
|
async def test_connection(self, *, target: DeployTargetSpec) -> DeployCheck:
|
|
if target.kind not in DEPLOY_KINDS:
|
|
raise ValidationError(
|
|
"نوع مقصد استقرار پشتیبانی نمیشود؛ مقادیر مجاز: "
|
|
+ "، ".join(DEPLOY_KINDS)
|
|
)
|
|
if not target.remote_path.strip():
|
|
raise ValidationError("مسیر مقصد روی سرور مشخص نشده است.")
|
|
if target.kind in ("ssh", "sftp"):
|
|
return await self.ssh.test_connection(target=target)
|
|
return await self.ftp.test_connection(target=target)
|
|
|
|
def deployer_for(self, kind: str):
|
|
"""The object that can actually deploy to this kind of target."""
|
|
return self.ssh if kind in ("ssh", "sftp") else self.ftp
|
|
|
|
|
|
__all__ = ["DispatchingDeployTargetTester"]
|