"""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///ftp-backup-.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", ]