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,476 @@
|
||||
"""FTP / FTPS production deploy (D8).
|
||||
|
||||
FTP is the weakest transport the platform supports, and the honest thing to do
|
||||
is say so in the code rather than pretend otherwise. Three limits shape the
|
||||
design:
|
||||
|
||||
**There is no atomic switch.** FTP has no symlinks, so a deploy overwrites files
|
||||
in place. A failure halfway through therefore leaves a *half-updated* site, and
|
||||
the only defence is a backup taken first.
|
||||
|
||||
**So a backup is mandatory, not optional.** Before anything is overwritten, the
|
||||
files that are about to change are downloaded into
|
||||
``ARTIFACTS_ROOT/<org>/<project>/ftp-backup-<ts>.tar.gz``. Rollback re-uploads
|
||||
that archive. Without it a bad FTP deploy is unrecoverable.
|
||||
|
||||
**There is no host key to pin.** FTPS (explicit TLS) encrypts the session but
|
||||
says nothing about *which* server you reached, so the TOFU guarantee SSH gives
|
||||
does not exist here. The operator is expected to have verified the host out of
|
||||
band; the code cannot do it for them.
|
||||
|
||||
The transport is injectable, so the deploy logic is testable without a server.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import io
|
||||
import logging
|
||||
import os
|
||||
import tarfile
|
||||
import time
|
||||
from collections.abc import Awaitable, Callable
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Protocol, runtime_checkable
|
||||
|
||||
from app.application.ports.deploy_target import DeployCheck, DeployTargetSpec
|
||||
from app.core.redact import redact_text
|
||||
from app.domain.platform.rules import assert_remote_host_allowed
|
||||
|
||||
logger = logging.getLogger("agy.deploy.ftp")
|
||||
|
||||
CONNECT_TIMEOUT_SECONDS = 15.0
|
||||
TRANSFER_TIMEOUT_SECONDS = 600.0
|
||||
|
||||
#: The directory names a build output commonly lands in, tried in order when the
|
||||
#: app profile does not name one. Matches the framework catalog defaults.
|
||||
DEFAULT_BUILD_DIRS: tuple[str, ...] = ("dist", "build", "public", "out")
|
||||
|
||||
#: A probe file written and deleted by `test_connection`.
|
||||
PROBE_FILENAME = ".smart-org-probe"
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class FtpEntry:
|
||||
path: str
|
||||
size: int = 0
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class FtpTransport(Protocol):
|
||||
"""The remote operations an FTP deploy needs."""
|
||||
|
||||
async def connect(
|
||||
self, *, host: str, port: int, username: str, password: str, tls: bool
|
||||
) -> None: ...
|
||||
|
||||
async def cwd(self, path: str) -> None: ...
|
||||
|
||||
async def download(self, remote_path: str) -> bytes: ...
|
||||
|
||||
async def upload(self, remote_path: str, data: bytes) -> None: ...
|
||||
|
||||
async def remove(self, remote_path: str) -> None: ...
|
||||
|
||||
async def list_files(self) -> list[FtpEntry]: ...
|
||||
|
||||
async def close(self) -> None: ...
|
||||
|
||||
|
||||
TransportFactory = Callable[[], FtpTransport]
|
||||
|
||||
|
||||
class AsyncFtpTransport:
|
||||
"""The real transport: one aioftp session per operation."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
# aioftp.Client is only imported inside connect(); the attribute is
|
||||
# typed loosely so the module imports without the optional dependency
|
||||
# present at type-check time.
|
||||
self._client: Any = None
|
||||
self._root = ""
|
||||
|
||||
async def connect(
|
||||
self, *, host: str, port: int, username: str, password: str, tls: bool
|
||||
) -> None:
|
||||
import aioftp
|
||||
|
||||
if tls:
|
||||
# Explicit FTPS: connect in the clear, then upgrade. `aioftp` exposes
|
||||
# this through `ssl` on the client; the certificate is not verified
|
||||
# because a shared host's FTP certificate is rarely matchable.
|
||||
import ssl
|
||||
|
||||
context = ssl.create_default_context()
|
||||
context.check_hostname = False
|
||||
context.verify_mode = ssl.CERT_NONE
|
||||
self._client = aioftp.Client(ssl=context)
|
||||
else:
|
||||
self._client = aioftp.Client()
|
||||
await self._client.connect(host, port, timeout=CONNECT_TIMEOUT_SECONDS)
|
||||
await self._client.login(username, password)
|
||||
|
||||
async def cwd(self, path: str) -> None:
|
||||
assert self._client is not None, "connect() first"
|
||||
self._root = path.rstrip("/") or "/"
|
||||
await self._client.change_directory(self._root)
|
||||
|
||||
async def download(self, remote_path: str) -> bytes:
|
||||
assert self._client is not None
|
||||
buffer = io.BytesIO()
|
||||
async with self._client.download_stream(remote_path) as stream:
|
||||
async for block in stream.iter_by_block():
|
||||
buffer.write(block)
|
||||
return buffer.getvalue()
|
||||
|
||||
async def upload(self, remote_path: str, data: bytes) -> None:
|
||||
assert self._client is not None
|
||||
async with self._client.upload_stream(remote_path) as stream:
|
||||
await stream.write(data)
|
||||
|
||||
async def remove(self, remote_path: str) -> None:
|
||||
assert self._client is not None
|
||||
with contextlib.suppress(Exception):
|
||||
await self._client.remove_file(remote_path)
|
||||
|
||||
async def list_files(self) -> list[FtpEntry]:
|
||||
assert self._client is not None
|
||||
entries: list[FtpEntry] = []
|
||||
async for path, info in self._client.list(recursive=False):
|
||||
entries.append(FtpEntry(path=str(path), size=int(info.get("size", 0) or 0)))
|
||||
return entries
|
||||
|
||||
async def close(self) -> None:
|
||||
if self._client is not None:
|
||||
with contextlib.suppress(Exception):
|
||||
await self._client.quit()
|
||||
self._client = None
|
||||
|
||||
|
||||
class FtpDeployer:
|
||||
"""``DeployTargetTester`` and the production deploy itself, over FTP/FTPS."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
transport_factory: TransportFactory = AsyncFtpTransport,
|
||||
artifacts_root: str | None = None,
|
||||
allow_private_hosts: bool = False,
|
||||
clock: Callable[[], float] = time.time,
|
||||
) -> None:
|
||||
from app.core.config import settings
|
||||
|
||||
self._factory = transport_factory
|
||||
self.artifacts_root = artifacts_root or settings.artifacts_root
|
||||
self.allow_private_hosts = allow_private_hosts
|
||||
self._clock = clock
|
||||
|
||||
# -- testing -------------------------------------------------------------
|
||||
|
||||
async def test_connection(self, *, target: DeployTargetSpec) -> DeployCheck:
|
||||
"""Log in, ``cwd`` the target path, and write/delete a probe file."""
|
||||
try:
|
||||
assert_remote_host_allowed(
|
||||
f"ftp://{target.host}:{target.port}", allow_private=self.allow_private_hosts
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return DeployCheck(ok=False, message=str(exc))
|
||||
|
||||
transport = self._factory()
|
||||
try:
|
||||
await transport.connect(
|
||||
host=target.host,
|
||||
port=target.port,
|
||||
username=target.username,
|
||||
password=target.secret or "",
|
||||
tls=target.kind in ("ftps",),
|
||||
)
|
||||
await transport.cwd(target.remote_path)
|
||||
await transport.upload(PROBE_FILENAME, b"smart-org")
|
||||
await transport.remove(PROBE_FILENAME)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return DeployCheck(ok=False, message=_ftp_error_message(exc))
|
||||
finally:
|
||||
with contextlib.suppress(Exception):
|
||||
await transport.close()
|
||||
|
||||
return DeployCheck(
|
||||
ok=True,
|
||||
message=(
|
||||
"اتصال و مسیر مقصد تأیید شد. توجه: FTP امکان تأیید کلید میزبان را "
|
||||
"ندارد و تغییر فایلها اتمی نیست."
|
||||
),
|
||||
)
|
||||
|
||||
# -- deploy --------------------------------------------------------------
|
||||
|
||||
async def deploy(
|
||||
self,
|
||||
*,
|
||||
target: DeployTargetSpec,
|
||||
artifact_path: str,
|
||||
sha: str,
|
||||
organization_id: str = "",
|
||||
project_id: str = "",
|
||||
build_dir: str | None = None,
|
||||
on_output: Callable[[str], Awaitable[None]] | None = None,
|
||||
) -> tuple[bool, str, str]:
|
||||
"""Back up what will be overwritten, then upload the new build.
|
||||
|
||||
Returns ``(ok, backup_path, message)``. The backup path is returned even
|
||||
on success: it is what a later rollback needs, and it is recorded in the
|
||||
deploy row.
|
||||
"""
|
||||
if not os.path.isfile(artifact_path):
|
||||
return False, "", "فایل بستهٔ انتشار پیدا نشد."
|
||||
|
||||
# The artifact is a tar.gz of the whole release; the deployable build is
|
||||
# a directory inside it. Unpack first, then choose.
|
||||
extracted = _extract(artifact_path, self.artifacts_root, organization_id, project_id)
|
||||
if extracted is None:
|
||||
return False, "", "استخراج بستهٔ انتشار ناموفق بود."
|
||||
|
||||
source_dir = _pick_build_dir(extracted, build_dir)
|
||||
if source_dir is None:
|
||||
return False, "", "پوشهٔ خروجی ساخت در بستهٔ انتشار پیدا نشد."
|
||||
|
||||
transport = self._factory()
|
||||
try:
|
||||
await transport.connect(
|
||||
host=target.host,
|
||||
port=target.port,
|
||||
username=target.username,
|
||||
password=target.secret or "",
|
||||
tls=target.kind == "ftps",
|
||||
)
|
||||
await transport.cwd(target.remote_path)
|
||||
|
||||
backup = await self._backup(
|
||||
transport,
|
||||
target=target,
|
||||
organization_id=organization_id,
|
||||
project_id=project_id,
|
||||
sha=sha,
|
||||
)
|
||||
if on_output is not None and backup:
|
||||
await on_output(f"[deploy] پشتیبان در {backup} ذخیره شد.\n")
|
||||
|
||||
uploaded = 0
|
||||
for relative, data in _walk(source_dir):
|
||||
remote = relative
|
||||
await transport.upload(remote, data)
|
||||
uploaded += 1
|
||||
if on_output is not None:
|
||||
await on_output(f"[deploy] {uploaded} فایل بارگذاری شد.\n")
|
||||
|
||||
return True, backup, "استقرار در production انجام شد (FTP)."
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.exception("ftp deploy failed")
|
||||
return False, "", _ftp_error_message(exc)
|
||||
finally:
|
||||
with contextlib.suppress(Exception):
|
||||
await transport.close()
|
||||
|
||||
async def _backup(
|
||||
self,
|
||||
transport: FtpTransport,
|
||||
*,
|
||||
target: DeployTargetSpec,
|
||||
organization_id: str,
|
||||
project_id: str,
|
||||
sha: str,
|
||||
) -> str:
|
||||
"""Download every file we are about to overwrite, into one archive."""
|
||||
try:
|
||||
entries = await transport.list_files()
|
||||
except Exception: # noqa: BLE001 - an empty directory has nothing to back up
|
||||
return ""
|
||||
|
||||
buffer = io.BytesIO()
|
||||
captured = 0
|
||||
with tarfile.open(fileobj=buffer, mode="w:gz") as tar:
|
||||
for entry in entries:
|
||||
name = os.path.basename(entry.path.rstrip("/"))
|
||||
if not name or name == PROBE_FILENAME:
|
||||
continue
|
||||
try:
|
||||
data = await transport.download(entry.path)
|
||||
except Exception: # noqa: BLE001 - a directory entry, or unreadable
|
||||
continue
|
||||
info = tarfile.TarInfo(name=name)
|
||||
info.size = len(data)
|
||||
info.mtime = int(self._clock())
|
||||
tar.addfile(info, io.BytesIO(data))
|
||||
captured += 1
|
||||
|
||||
if not captured:
|
||||
return ""
|
||||
|
||||
stamp = time.strftime("%Y%m%dT%H%M%S", time.gmtime(self._clock()))
|
||||
directory = os.path.join(
|
||||
self.artifacts_root, organization_id or "unknown", project_id or "unknown"
|
||||
)
|
||||
os.makedirs(directory, exist_ok=True)
|
||||
path = os.path.join(directory, f"ftp-backup-{stamp}-{(sha or '')[:7]}.tar.gz")
|
||||
with open(path, "wb") as handle:
|
||||
handle.write(buffer.getvalue())
|
||||
return path
|
||||
|
||||
# -- rollback ------------------------------------------------------------
|
||||
|
||||
async def rollback(
|
||||
self,
|
||||
*,
|
||||
target: DeployTargetSpec,
|
||||
backup_path: str,
|
||||
on_output: Callable[[str], Awaitable[None]] | None = None,
|
||||
) -> tuple[bool, str]:
|
||||
"""Re-upload a backup archive over the live directory."""
|
||||
if not backup_path or not os.path.isfile(backup_path):
|
||||
return False, "پشتیبان بازگردانی پیدا نشد."
|
||||
|
||||
transport = self._factory()
|
||||
try:
|
||||
await transport.connect(
|
||||
host=target.host,
|
||||
port=target.port,
|
||||
username=target.username,
|
||||
password=target.secret or "",
|
||||
tls=target.kind == "ftps",
|
||||
)
|
||||
await transport.cwd(target.remote_path)
|
||||
restored = 0
|
||||
with tarfile.open(backup_path, "r:gz") as tar:
|
||||
for member in tar.getmembers():
|
||||
if not member.isfile():
|
||||
continue
|
||||
extracted = tar.extractfile(member)
|
||||
if extracted is None:
|
||||
continue
|
||||
await transport.upload(member.name, extracted.read())
|
||||
restored += 1
|
||||
if on_output is not None:
|
||||
await on_output(f"[rollback] {restored} فایل بازگردانی شد.\n")
|
||||
return True, f"بازگردانی {restored} فایل انجام شد."
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.exception("ftp rollback failed")
|
||||
return False, _ftp_error_message(exc)
|
||||
finally:
|
||||
with contextlib.suppress(Exception):
|
||||
await transport.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _extract(
|
||||
artifact_path: str, artifacts_root: str, organization_id: str, project_id: str
|
||||
) -> str | None:
|
||||
"""Unpack a release archive into a scratch directory and return its path."""
|
||||
import shutil
|
||||
|
||||
stamp = time.strftime("%Y%m%dT%H%M%S", time.gmtime())
|
||||
directory = os.path.join(
|
||||
artifacts_root, organization_id or "unknown", project_id or "unknown", f"extract-{stamp}"
|
||||
)
|
||||
shutil.rmtree(directory, ignore_errors=True)
|
||||
os.makedirs(directory, exist_ok=True)
|
||||
try:
|
||||
with tarfile.open(artifact_path, "r:gz") as tar:
|
||||
tar.extractall(directory, filter=_safe_member)
|
||||
except (tarfile.TarError, OSError):
|
||||
return None
|
||||
return directory
|
||||
|
||||
|
||||
def _safe_member(member: tarfile.TarInfo, dest: str) -> tarfile.TarInfo | None:
|
||||
"""Sanitise an archive member, or skip it.
|
||||
|
||||
An uploaded artifact is not trusted. Two things are refused outright:
|
||||
|
||||
* an absolute name or one containing ``..`` — the classic way a tarball
|
||||
writes outside the directory it is extracted into; and
|
||||
* the archive's own root entry (``.``), which would ``mkdir`` the extraction
|
||||
directory itself. That is harmless on a normal filesystem but raises here,
|
||||
and skipping it is correct regardless: the root already exists.
|
||||
"""
|
||||
name = (member.name or "").replace("\\", "/")
|
||||
name = name.lstrip("/")
|
||||
while name.startswith("./"):
|
||||
name = name[2:]
|
||||
if name in ("", "."):
|
||||
return None
|
||||
parts = [part for part in name.split("/") if part not in ("", ".")]
|
||||
if not parts or ".." in parts:
|
||||
return None
|
||||
if member.issym() or member.islnk():
|
||||
# A link target is another way out of the extraction directory.
|
||||
target = (member.linkname or "").lstrip("/")
|
||||
if os.path.isabs(target) or ".." in target.split("/"):
|
||||
return None
|
||||
member.name = "/".join(parts)
|
||||
return member
|
||||
|
||||
|
||||
def _pick_build_dir(artifact_path: str, explicit: str | None) -> str | None:
|
||||
"""The directory inside the artifact that holds the deployable build.
|
||||
|
||||
A build output is a *subdirectory* of the release archive (`dist/`, `build/`,
|
||||
`public/`, `out/`), not the archive itself. Uploading the whole repository
|
||||
over a static site is how a deploy publishes source code by accident.
|
||||
"""
|
||||
if not os.path.isdir(artifact_path):
|
||||
return None
|
||||
if explicit:
|
||||
candidate = os.path.join(artifact_path, explicit)
|
||||
return candidate if os.path.isdir(candidate) else None
|
||||
for name in DEFAULT_BUILD_DIRS:
|
||||
candidate = os.path.join(artifact_path, name)
|
||||
if os.path.isdir(candidate):
|
||||
return candidate
|
||||
# A static site has no build step: its root *is* the output.
|
||||
return artifact_path
|
||||
|
||||
|
||||
def _walk(root: str):
|
||||
"""Yield ``(relative_path, bytes)`` for every file under ``root``."""
|
||||
for directory, _dirs, files in os.walk(root):
|
||||
for name in files:
|
||||
absolute = os.path.join(directory, name)
|
||||
relative = os.path.relpath(absolute, root).replace(os.sep, "/")
|
||||
try:
|
||||
with open(absolute, "rb") as handle:
|
||||
yield relative, handle.read()
|
||||
except OSError:
|
||||
continue
|
||||
|
||||
|
||||
def _ftp_error_message(exc: BaseException) -> str:
|
||||
"""A plain Persian message. Never the raw exception."""
|
||||
text = str(exc).lower()
|
||||
if "530" in text or "login" in text or "auth" in text:
|
||||
return "ورود به سرور مقصد ناموفق بود."
|
||||
if "550" in text or "not found" in text or "no such" in text:
|
||||
return "مسیر مقصد روی سرور پیدا نشد."
|
||||
if "timeout" in text or "timed out" in text:
|
||||
return "اتصال به سرور مقصد در زمان مجاز برقرار نشد."
|
||||
if "refused" in text or "unreachable" in text:
|
||||
return "اتصال به سرور مقصد برقرار نشد."
|
||||
if "certificate" in text or "ssl" in text or "tls" in text:
|
||||
return "برقراری اتصال امن (FTPS) ناموفق بود."
|
||||
return redact_text("استقرار روی سرور مقصد ناموفق بود.")
|
||||
|
||||
|
||||
__all__ = [
|
||||
"CONNECT_TIMEOUT_SECONDS",
|
||||
"DEFAULT_BUILD_DIRS",
|
||||
"PROBE_FILENAME",
|
||||
"TRANSFER_TIMEOUT_SECONDS",
|
||||
"AsyncFtpTransport",
|
||||
"FtpDeployer",
|
||||
"FtpEntry",
|
||||
"FtpTransport",
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user