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
@@ -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",
]
@@ -0,0 +1,475 @@
"""SSH / SFTP production deploy (D8).
Production is the one path in the product that a human must trigger, and the one
that can destroy a running site. Three rules follow from that and shape
everything here.
**Nothing is uploaded into the live directory.** A release lands in
``<remote_path>/releases/<timestamp>-<sha7>/`` and is switched in by moving a
``current`` symlink. That makes the switch atomic (``ln -sfn`` + ``mv`` on the
same filesystem) and makes rollback a pointer move rather than a re-upload.
**The host key is pinned on first success (TOFU).** The first ``test_connection``
that succeeds records the fingerprint; every later connection must match it. A
changed key is a hard failure, not a warning — it is either a rebuilt server (in
which case the operator re-tests deliberately) or an interception.
**Credentials never touch the target's disk.** The password or key is held in
memory for the connection only; the deploy scripts that run on the server are
built from a fixed template and never interpolate a secret.
The transport is injectable so the deploy logic — release numbering, pruning,
the symlink dance, rollback — is testable without a live sshd.
"""
from __future__ import annotations
import contextlib
import logging
import os
import shlex
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.ssh")
CONNECT_TIMEOUT_SECONDS = 15.0
COMMAND_TIMEOUT_SECONDS = 600.0
PROBE_TIMEOUT_SECONDS = 20.0
#: How many releases to keep on the target. Five is enough to roll back past a
#: bad deploy and a bad deploy of its fix, without filling the operator's disk.
KEEP_RELEASES = 5
#: Name of the pointer the web server is configured to serve from.
CURRENT_LINK = "current"
@dataclass(slots=True)
class CommandOutcome:
exit_code: int
output: str = ""
timed_out: bool = False
@property
def ok(self) -> bool:
return self.exit_code == 0 and not self.timed_out
@runtime_checkable
class SshTransport(Protocol):
"""The remote operations a deploy needs. Implemented over asyncssh."""
async def connect(
self,
*,
host: str,
port: int,
username: str,
password: str | None,
key: str | None,
known_hosts: str | None,
) -> None: ...
def host_key_fingerprint(self) -> str: ...
async def run(
self, command: str, *, timeout: float = COMMAND_TIMEOUT_SECONDS
) -> CommandOutcome: ...
async def upload(self, *, local_path: str, remote_path: str) -> None: ...
async def close(self) -> None: ...
TransportFactory = Callable[[], SshTransport]
class AsyncSshTransport:
"""The real transport: one asyncssh connection per deploy."""
def __init__(self) -> None:
# asyncssh is imported lazily inside connect(); the attribute is typed
# loosely so this module imports without the dependency at hand.
self._conn: Any = None
self._fingerprint = ""
async def connect(
self,
*,
host: str,
port: int,
username: str,
password: str | None,
key: str | None,
known_hosts: str | None,
) -> None:
import asyncssh
# `known_hosts=None` disables verification for this connection; the
# fingerprint is captured and compared by the caller, which is where the
# TOFU policy lives. Passing an explicit `known_hosts` string enforces
# the pinned key inside asyncssh as well.
kwargs: dict = {
"host": host,
"port": port,
"username": username,
"connect_timeout": CONNECT_TIMEOUT_SECONDS,
"known_hosts": known_hosts if known_hosts else None,
}
if key:
kwargs["client_keys"] = [asyncssh.import_private_key(key)]
kwargs["password"] = None
else:
kwargs["password"] = password or ""
self._conn = await asyncssh.connect(**kwargs)
self._fingerprint = self._read_fingerprint()
def _read_fingerprint(self) -> str:
"""Best-effort host key digest of the connection we just made."""
try:
key = self._conn.get_server_host_key()
if key is None:
return ""
digest = key.get_fingerprint("sha256")
return f"SHA256:{digest}" if not str(digest).startswith("SHA256:") else str(digest)
except Exception: # noqa: BLE001 - a missing fingerprint must not block a deploy
return ""
def host_key_fingerprint(self) -> str:
return self._fingerprint
async def run(
self, command: str, *, timeout: float = COMMAND_TIMEOUT_SECONDS
) -> CommandOutcome:
import asyncssh
assert self._conn is not None, "connect() first"
try:
result = await self._conn.run(command, check=False, timeout=timeout)
except (asyncssh.Error, TimeoutError) as exc:
return CommandOutcome(exit_code=1, output=redact_text(str(exc))[:2000], timed_out=True)
output = f"{result.stdout or ''}{result.stderr or ''}"
return CommandOutcome(exit_code=int(result.exit_status or 0), output=output)
async def upload(self, *, local_path: str, remote_path: str) -> None:
assert self._conn is not None, "connect() first"
async with self._conn.start_sftp_client() as sftp:
await sftp.put(local_path, remote_path)
async def close(self) -> None:
if self._conn is not None:
self._conn.close()
with contextlib.suppress(Exception):
await self._conn.wait_closed()
self._conn = None
class SshDeployer:
"""``DeployTargetTester`` and the production deploy itself, over SSH."""
def __init__(
self,
*,
transport_factory: TransportFactory = AsyncSshTransport,
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:
"""Connect, pin the host key, and confirm the path is writable."""
try:
assert_remote_host_allowed(
f"ssh://{target.host}:{target.port}", allow_private=self.allow_private_hosts
)
except Exception as exc: # noqa: BLE001 - translated to a plain message
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 if target.auth_type == "password" else None,
key=target.secret if target.auth_type == "key" else None,
known_hosts=None,
)
except Exception as exc: # noqa: BLE001 - never surface the raw error
return DeployCheck(ok=False, message=_ssh_error_message(exc))
finally:
with contextlib.suppress(Exception):
await transport.close()
fingerprint = transport.host_key_fingerprint()
if target.known_fingerprint and fingerprint and fingerprint != target.known_fingerprint:
# A changed host key is a hard failure: it is either a rebuilt server
# or an interception, and only a human can tell the two apart.
return DeployCheck(
ok=False,
message="کلید میزبان تغییر کرده است؛ برای ادامه باید دوباره تأیید کنید.",
fingerprint=fingerprint,
)
probe = await _probe_writable(transport, target.remote_path)
if not probe.ok:
return DeployCheck(
ok=False,
message=f"مسیر «{target.remote_path}» قابل نوشتن نیست.",
fingerprint=fingerprint,
)
return DeployCheck(
ok=True, message="اتصال و مسیر مقصد تأیید شد.", fingerprint=fingerprint
)
# -- deploy --------------------------------------------------------------
async def deploy(
self,
*,
target: DeployTargetSpec,
artifact_path: str,
sha: str,
post_deploy_command: str | None = None,
on_output: Callable[[str], Awaitable[None]] | None = None,
) -> tuple[bool, str, str]:
"""Upload, extract, run the post-deploy command, switch ``current``.
Returns ``(ok, release_dir, message)``. On failure the ``current``
symlink is untouched, so a failed deploy leaves the previous release
serving — the same property the demo pipeline has.
"""
if not os.path.isfile(artifact_path):
return False, "", "فایل بستهٔ انتشار پیدا نشد."
transport = self._factory()
try:
await transport.connect(
host=target.host,
port=target.port,
username=target.username,
password=target.secret if target.auth_type == "password" else None,
key=target.secret if target.auth_type == "key" else None,
known_hosts=None,
)
except Exception as exc: # noqa: BLE001
return False, "", _ssh_error_message(exc)
try:
fingerprint = transport.host_key_fingerprint()
if target.known_fingerprint and fingerprint and fingerprint != target.known_fingerprint:
return False, "", "کلید میزبان تغییر کرده است."
release = _release_name(self._clock(), sha)
release_dir = f"{target.remote_path.rstrip('/')}/releases/{release}"
setup = await transport.run(
f"mkdir -p {shlex.quote(release_dir)} && "
f"mkdir -p {shlex.quote(target.remote_path.rstrip('/') + '/releases')}",
timeout=PROBE_TIMEOUT_SECONDS,
)
if not setup.ok:
return False, "", "ساخت پوشهٔ انتشار در سرور مقصد ناموفق بود."
archive = f"{release_dir}/release.tar.gz"
await transport.upload(local_path=artifact_path, remote_path=archive)
await _stream(on_output, f"[deploy] بسته در {archive} بارگذاری شد.\n")
extract = await transport.run(
f"cd {shlex.quote(release_dir)} && tar -xzf release.tar.gz && rm -f release.tar.gz",
timeout=COMMAND_TIMEOUT_SECONDS,
)
if on_output is not None:
await on_output(redact_text(extract.output))
if not extract.ok:
return False, release_dir, "استخراج بسته در سرور مقصد ناموفق بود."
if post_deploy_command:
post = await transport.run(
f"cd {shlex.quote(release_dir)} && {post_deploy_command}",
timeout=COMMAND_TIMEOUT_SECONDS,
)
if on_output is not None:
await on_output(redact_text(post.output))
if not post.ok:
# The previous release keeps serving: `current` never moved.
return False, release_dir, "اجرای دستور پس از استقرار ناموفق بود."
switch = await transport.run(
_switch_script(target.remote_path, release),
timeout=PROBE_TIMEOUT_SECONDS,
)
if not switch.ok:
return False, release_dir, "تغییر نسخهٔ فعال ناموفق بود."
prune = await transport.run(
_prune_script(target.remote_path, KEEP_RELEASES),
timeout=PROBE_TIMEOUT_SECONDS,
)
if not prune.ok:
# Pruning is housekeeping; a full disk is not a failed deploy.
logger.warning("ssh deploy: pruning old releases failed: %s", prune.output[:200])
return True, release_dir, "استقرار در production انجام شد."
except Exception as exc: # noqa: BLE001 - never leak the raw error
logger.exception("ssh deploy failed")
return False, "", _ssh_error_message(exc)
finally:
with contextlib.suppress(Exception):
await transport.close()
async def rollback(
self,
*,
target: DeployTargetSpec,
to_release: str | None = None,
post_deploy_command: str | None = None,
on_output: Callable[[str], Awaitable[None]] | None = None,
) -> tuple[bool, str, str]:
"""Re-point ``current`` at the previous release and re-run the hook."""
transport = self._factory()
try:
await transport.connect(
host=target.host,
port=target.port,
username=target.username,
password=target.secret if target.auth_type == "password" else None,
key=target.secret if target.auth_type == "key" else None,
known_hosts=None,
)
except Exception as exc: # noqa: BLE001
return False, "", _ssh_error_message(exc)
try:
releases = await transport.run(
_list_releases_script(target.remote_path), timeout=PROBE_TIMEOUT_SECONDS
)
if not releases.ok:
return False, "", "خواندن فهرست نسخه‌ها ناموفق بود."
names = [line.strip() for line in releases.output.splitlines() if line.strip()]
if len(names) < 2:
return False, "", "نسخهٔ قبلی‌ای برای بازگردانی وجود ندارد."
# `ls -1t` is newest first, so the previous release is the second.
current = names[0]
target_name = to_release or names[1]
if target_name == current:
target_name = names[1]
switch = await transport.run(
_switch_script(target.remote_path, target_name), timeout=PROBE_TIMEOUT_SECONDS
)
if not switch.ok:
return False, "", "بازگردانی ناموفق بود."
if post_deploy_command:
post = await transport.run(
f"cd {shlex.quote(target.remote_path.rstrip('/') + '/releases/' + target_name)}"
f" && {post_deploy_command}",
timeout=COMMAND_TIMEOUT_SECONDS,
)
if on_output is not None:
await on_output(redact_text(post.output))
if not post.ok:
return False, target_name, "دستور پس از بازگردانی ناموفق بود."
return True, target_name, f"بازگردانی به نسخهٔ {target_name} انجام شد."
except Exception as exc: # noqa: BLE001
return False, "", _ssh_error_message(exc)
finally:
with contextlib.suppress(Exception):
await transport.close()
# ---------------------------------------------------------------------------
# remote scripts — built from constants, never from user input
# ---------------------------------------------------------------------------
def _release_name(now: float, sha: str) -> str:
stamp = time.strftime("%Y%m%dT%H%M%S", time.gmtime(now))
return f"{stamp}-{(sha or 'unknown')[:7]}"
def _switch_script(remote_path: str, release: str) -> str:
"""Move the ``current`` symlink atomically.
A relative symlink into ``releases/`` plus ``mv -T`` on the same filesystem
is the only switch that is atomic *and* survives the directory being moved.
"""
base = shlex.quote(remote_path.rstrip("/"))
rel = shlex.quote(f"releases/{release}")
link = shlex.quote(f"{remote_path.rstrip('/')}/{CURRENT_LINK}")
tmp = shlex.quote(f"{remote_path.rstrip('/')}/.{CURRENT_LINK}.new")
return f"ln -sfn {rel} {tmp} && mv -T {tmp} {link} && cd {base} && ls -l {link}"
def _prune_script(remote_path: str, keep: int) -> str:
"""Delete all but the newest ``keep`` release directories."""
releases = shlex.quote(f"{remote_path.rstrip('/')}/releases")
return (
f"cd {releases} && ls -1t | tail -n +{keep + 1} | "
f"while read -r old; do rm -rf -- \"$old\"; done"
)
def _list_releases_script(remote_path: str) -> str:
releases = shlex.quote(f"{remote_path.rstrip('/')}/releases")
return f"cd {releases} && ls -1t"
async def _probe_writable(transport: SshTransport, remote_path: str) -> CommandOutcome:
base = shlex.quote(remote_path.rstrip("/") or "/")
return await transport.run(
f"mkdir -p {base} && test -w {base} && "
f"probe={base}/.smart-org-probe && touch $probe && rm -f $probe",
timeout=PROBE_TIMEOUT_SECONDS,
)
async def _stream(callback: Callable[[str], Awaitable[None]] | None, text: str) -> None:
if callback is not None:
await callback(text)
def _ssh_error_message(exc: BaseException) -> str:
"""A plain Persian message. Never the raw exception, which can carry the host."""
text = str(exc).lower()
if "permission denied" in text or "auth" in text:
return "احراز هویت روی سرور مقصد ناموفق بود."
if "timed out" in text or "timeout" in text:
return "اتصال به سرور مقصد در زمان مجاز برقرار نشد."
if "host key" in text:
return "کلید میزبان تأیید نشد."
if "no such file" in text or "not found" in text:
return "مسیر یا فایل مقصد پیدا نشد."
if "connection refused" in text or "unreachable" in text:
return "اتصال به سرور مقصد برقرار نشد."
return "استقرار در سرور مقصد ناموفق بود."
__all__ = [
"COMMAND_TIMEOUT_SECONDS",
"CONNECT_TIMEOUT_SECONDS",
"CURRENT_LINK",
"KEEP_RELEASES",
"AsyncSshTransport",
"CommandOutcome",
"SshDeployer",
"SshTransport",
]
@@ -0,0 +1,56 @@
"""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"]
@@ -121,8 +121,46 @@ class AppRuntimeRow(Base):
)
class ProductionDeployRow(Base):
"""One human-triggered production deploy (and its rollbacks).
``log_tail`` is bounded exactly like a pipeline step's: the full output is
streamed, the tail is kept for the post-mortem. ``rolled_back_from`` makes a
rollback its own row rather than an edit of the deploy it undid — "what was
live when?" must be answerable from the table alone.
"""
__tablename__ = "production_deploys"
__table_args__ = (Index("ix_prod_deploy_project_created", "project_id", "created_at"),)
id: Mapped[str] = pk()
organization_id: Mapped[str] = mapped_column(
ForeignKey("organizations.id", ondelete="CASCADE"), nullable=False, index=True
)
project_id: Mapped[str] = mapped_column(
ForeignKey("projects.id", ondelete="CASCADE"), nullable=False, index=True
)
release_id: Mapped[str | None] = mapped_column(
ForeignKey("releases.id", ondelete="SET NULL"), nullable=True
)
target_id: Mapped[str | None] = mapped_column(
ForeignKey("deploy_targets.id", ondelete="SET NULL"), nullable=True
)
kind: Mapped[str] = mapped_column(String(8), nullable=False)
host: Mapped[str] = mapped_column(String(255), default="", nullable=False)
status: Mapped[str] = mapped_column(String(16), default="queued", nullable=False)
message: Mapped[str] = mapped_column(Text, default="", nullable=False)
log_tail: Mapped[str] = mapped_column(Text, default="", nullable=False)
rolled_back_from: Mapped[str | None] = mapped_column(String(32), nullable=True)
backup_path: Mapped[str] = mapped_column(String(512), default="", nullable=False)
started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
created_at: Mapped[datetime] = created()
__all__ = [
"AppRuntimeRow",
"ProductionDeployRow",
"MAX_LOG_TAIL_CHARS",
"PipelineRunRow",
"PipelineStepRow",
@@ -8,8 +8,10 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.domain.base import utcnow
from app.domain.delivery.models import (
AppRuntime,
DeployStatus,
PipelineRun,
PipelineStep,
ProductionDeploy,
Release,
RunStatus,
StepStatus,
@@ -18,6 +20,7 @@ from app.infrastructure.db.models_delivery import (
AppRuntimeRow,
PipelineRunRow,
PipelineStepRow,
ProductionDeployRow,
ReleaseRow,
)
@@ -440,11 +443,131 @@ class AppRuntimeRepository:
await self.session.flush()
def _deploy(row: ProductionDeployRow) -> ProductionDeploy:
return ProductionDeploy(
id=row.id,
organization_id=row.organization_id,
project_id=row.project_id,
kind=row.kind,
host=row.host,
release_id=row.release_id,
target_id=row.target_id,
status=row.status,
message=row.message,
log_tail=row.log_tail,
rolled_back_from=row.rolled_back_from,
backup_path=row.backup_path,
started_at=row.started_at,
finished_at=row.finished_at,
created_at=row.created_at,
)
class ProductionDeployRepository:
"""The production deploy ledger."""
def __init__(self, session: AsyncSession) -> None:
self.session = session
async def get(self, deploy_id: str) -> ProductionDeploy | None:
row = await self.session.get(ProductionDeployRow, deploy_id)
return _deploy(row) if row else None
async def get_for_org(self, organization_id: str, deploy_id: str) -> ProductionDeploy | None:
row = await self.session.scalar(
select(ProductionDeployRow).where(
ProductionDeployRow.id == deploy_id,
ProductionDeployRow.organization_id == organization_id,
)
)
return _deploy(row) if row else None
async def list_for_project(
self, *, project_id: str, limit: int = 20
) -> list[ProductionDeploy]:
stmt = (
select(ProductionDeployRow)
.where(ProductionDeployRow.project_id == project_id)
.order_by(ProductionDeployRow.created_at.desc())
.limit(min(max(limit, 1), 100))
)
return [_deploy(r) for r in (await self.session.execute(stmt)).scalars()]
async def active_for_project(self, project_id: str) -> ProductionDeploy | None:
"""One production deploy per project at a time.
Two concurrent deploys to the same target would interleave their writes
on a shared host, which is exactly the failure FTP has no way to undo.
"""
row = await self.session.scalar(
select(ProductionDeployRow)
.where(
ProductionDeployRow.project_id == project_id,
ProductionDeployRow.status.in_(
[DeployStatus.QUEUED.value, DeployStatus.RUNNING.value]
),
)
.limit(1)
)
return _deploy(row) if row else None
async def create(self, deploy: ProductionDeploy) -> ProductionDeploy:
row = ProductionDeployRow(
id=deploy.id,
organization_id=deploy.organization_id,
project_id=deploy.project_id,
release_id=deploy.release_id,
target_id=deploy.target_id,
kind=deploy.kind,
host=deploy.host,
status=deploy.status,
message=deploy.message,
log_tail=deploy.log_tail,
rolled_back_from=deploy.rolled_back_from,
backup_path=deploy.backup_path,
started_at=deploy.started_at,
finished_at=deploy.finished_at,
created_at=deploy.created_at,
)
self.session.add(row)
await self.session.flush()
return _deploy(row)
async def finish(
self,
deploy_id: str,
*,
status: str,
message: str = "",
log_tail: str = "",
backup_path: str = "",
) -> None:
row = await self.session.get(ProductionDeployRow, deploy_id)
if row is None:
return
row.status = status
row.message = message[:2_000]
row.log_tail = log_tail[-65_536:]
if backup_path:
row.backup_path = backup_path
row.finished_at = utcnow()
await self.session.flush()
async def mark_running(self, deploy_id: str) -> None:
row = await self.session.get(ProductionDeployRow, deploy_id)
if row is None:
return
row.status = DeployStatus.RUNNING.value
row.started_at = utcnow()
await self.session.flush()
__all__ = [
"ACTIVE_STATUSES",
"AppRuntimeRepository",
"PipelineRunRepository",
"PipelineStepRepository",
"ProductionDeployRepository",
"ReleaseRepository",
"StepStatus",
]
@@ -14,6 +14,7 @@ from app.domain.projects.models import (
ProjectGitRepo,
)
from app.infrastructure.db.models import (
DeployTargetRow,
PreviewDomainRow,
PreviewPortRow,
ProjectExternalGitRow,
@@ -164,6 +165,61 @@ class ProjectGitCredentialRepository:
return credential
class DeployTargetRepository:
"""Persistence for a project's production deploy target.
The secret is stored encrypted and is only ever handed back through
:meth:`get_secret`, so a code path that merely *renders* a target never
touches the plaintext.
"""
def __init__(self, session: AsyncSession) -> None:
self.session = session
async def get_for_project(self, project_id: str) -> DeployTargetRow | None:
return await self.session.scalar(
select(DeployTargetRow)
.where(DeployTargetRow.project_id == project_id, DeployTargetRow.is_active.is_(True))
.order_by(DeployTargetRow.created_at.desc())
.limit(1)
)
async def list_for_project(self, project_id: str) -> list[DeployTargetRow]:
stmt = (
select(DeployTargetRow)
.where(DeployTargetRow.project_id == project_id)
.order_by(DeployTargetRow.created_at.desc())
)
return list((await self.session.execute(stmt)).scalars())
async def upsert(self, *, values: dict) -> DeployTargetRow:
"""Create or replace the single active target of a project."""
project_id = str(values["project_id"])
existing = await self.get_for_project(project_id)
if existing is None:
row = DeployTargetRow(**values)
self.session.add(row)
else:
row = existing
for key, value in values.items():
setattr(row, key, value)
await self.session.flush()
return row
async def record_test(
self, *, target_id: str, ok: bool, message: str, fingerprint: str | None = None
) -> None:
row = await self.session.get(DeployTargetRow, target_id)
if row is None:
return
row.last_tested_at = utcnow()
row.last_test_ok = ok
row.last_test_message = message[:500]
if fingerprint:
row.host_fingerprint = fingerprint
await self.session.flush()
class ProjectRepository:
def __init__(self, session: AsyncSession) -> None:
self.session = session