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",
|
||||
]
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user