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,73 @@
"""production deploys: the human-triggered deploy ledger
Revision ID: a3b4c5d6e7f4
Revises: a3b4c5d6e7f3
Create Date: 2026-10-14 01:10:00.000000
Chained off this workstream's own head (PLAN-v2 §6: "more only if chained off
your own"), so the three agents' branches still merge cleanly.
"""
from __future__ import annotations
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "a3b4c5d6e7f4"
down_revision: str | None = "a3b4c5d6e7f3"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.create_table(
"production_deploys",
sa.Column("id", sa.String(length=32), primary_key=True),
sa.Column(
"organization_id",
sa.String(length=32),
sa.ForeignKey("organizations.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column(
"project_id",
sa.String(length=32),
sa.ForeignKey("projects.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column(
"release_id",
sa.String(length=32),
sa.ForeignKey("releases.id", ondelete="SET NULL"),
nullable=True,
),
sa.Column(
"target_id",
sa.String(length=32),
sa.ForeignKey("deploy_targets.id", ondelete="SET NULL"),
nullable=True,
),
sa.Column("kind", sa.String(length=8), nullable=False),
sa.Column("host", sa.String(length=255), nullable=False, server_default=""),
sa.Column("status", sa.String(length=16), nullable=False, server_default="queued"),
sa.Column("message", sa.Text(), nullable=False, server_default=""),
sa.Column("log_tail", sa.Text(), nullable=False, server_default=""),
sa.Column("rolled_back_from", sa.String(length=32), nullable=True),
sa.Column("backup_path", sa.String(length=512), nullable=False, server_default=""),
sa.Column("started_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("finished_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
)
op.create_index(
"ix_production_deploys_organization_id", "production_deploys", ["organization_id"]
)
op.create_index("ix_production_deploys_project_id", "production_deploys", ["project_id"])
op.create_index(
"ix_prod_deploy_project_created", "production_deploys", ["project_id", "created_at"]
)
def downgrade() -> None:
op.drop_table("production_deploys")
+2
View File
@@ -18,6 +18,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.application.container_delivery import ( from app.application.container_delivery import (
build_delivery_hooks, build_delivery_hooks,
build_delivery_service, build_delivery_service,
build_deploy_service,
build_deploy_target_tester, build_deploy_target_tester,
build_repo_workspace, build_repo_workspace,
) )
@@ -168,6 +169,7 @@ __all__ = [
"build_billing_service", "build_billing_service",
"build_delivery_hooks", "build_delivery_hooks",
"build_delivery_service", "build_delivery_service",
"build_deploy_service",
"build_deploy_target_tester", "build_deploy_target_tester",
"build_memory", "build_memory",
"build_model_grants", "build_model_grants",
+27 -3
View File
@@ -19,9 +19,10 @@ import logging
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.application.ports.delivery_hooks import DeliveryHooks from app.application.ports.delivery_hooks import DeliveryHooks
from app.application.ports.deploy_target import DeployTargetTester, FakeDeployTargetTester from app.application.ports.deploy_target import DeployTargetTester
from app.application.ports.repo_workspace import FakeRepoWorkspace, RepoWorkspacePort from app.application.ports.repo_workspace import FakeRepoWorkspace, RepoWorkspacePort
from app.application.services.delivery_service import DeliveryService from app.application.services.delivery_service import DeliveryService
from app.application.services.deploy_service import DeployService
from app.core.config import settings from app.core.config import settings
from app.core.crypto import decrypt from app.core.crypto import decrypt
from app.infrastructure.adapters.git_workspace import ( from app.infrastructure.adapters.git_workspace import (
@@ -35,10 +36,12 @@ from app.infrastructure.repositories.delivery import (
AppRuntimeRepository, AppRuntimeRepository,
PipelineRunRepository, PipelineRunRepository,
PipelineStepRepository, PipelineStepRepository,
ProductionDeployRepository,
ReleaseRepository, ReleaseRepository,
) )
from app.infrastructure.repositories.identity import OrganizationRepository from app.infrastructure.repositories.identity import OrganizationRepository
from app.infrastructure.repositories.projects import ( from app.infrastructure.repositories.projects import (
DeployTargetRepository,
PreviewDomainRepository, PreviewDomainRepository,
ProjectExternalGitRepository, ProjectExternalGitRepository,
ProjectGitCredentialRepository, ProjectGitCredentialRepository,
@@ -49,7 +52,6 @@ from app.infrastructure.repositories.projects import (
logger = logging.getLogger("agy.container.delivery") logger = logging.getLogger("agy.container.delivery")
_fake_workspace = FakeRepoWorkspace() _fake_workspace = FakeRepoWorkspace()
_fake_deploy_tester = FakeDeployTargetTester()
def build_repo_workspace(session: AsyncSession) -> RepoWorkspacePort: def build_repo_workspace(session: AsyncSession) -> RepoWorkspacePort:
@@ -186,4 +188,26 @@ class _UnavailableContainerRuntime:
def build_deploy_target_tester(session: AsyncSession) -> DeployTargetTester: def build_deploy_target_tester(session: AsyncSession) -> DeployTargetTester:
return _fake_deploy_tester """The real tester: SSH/SFTP over asyncssh, FTP/FTPS over aioftp.
The wizard calls this to validate a user-supplied production target, and it
runs the same SSRF check the git adapter does before a byte leaves the host.
"""
from app.infrastructure.adapters.deploy_target import DispatchingDeployTargetTester
return DispatchingDeployTargetTester()
def build_deploy_service(session: AsyncSession) -> DeployService:
"""Production deploys: human-triggered, confirmed and audited."""
from app.infrastructure.adapters.deploy_target import DispatchingDeployTargetTester
return DeployService(
projects=ProjectRepository(session),
organizations=OrganizationRepository(session),
targets=DeployTargetRepository(session),
deploys=ProductionDeployRepository(session),
releases=ReleaseRepository(session),
runtimes=AppRuntimeRepository(session),
tester=DispatchingDeployTargetTester(),
)
@@ -0,0 +1,366 @@
"""Production deploy use cases — the only path that touches a customer's server.
Two rules are absolute here, and both exist because production is the one place
where a mistake is not recoverable by trying again.
**A human triggers it.** There is no code path from a pipeline run, a task, or
anything the AI does into this service. The engine's furthest reach is "merged
and pushed to `dev`"; a production deploy needs an org owner/admin, a typed
confirmation, and an audit row.
**One deploy per project at a time.** Two concurrent deploys to the same target
interleave their writes on a shared host. Over SSH that is merely confusing;
over FTP, which has no atomic switch and no rollback except the backup, it is
how a site ends up in a state nobody can describe.
The deployers themselves are injected, so this service is testable without an
sshd or an FTP server.
"""
from __future__ import annotations
import contextlib
import logging
from app.application.ports.deploy_target import DeployTargetSpec
from app.core.config import settings
from app.core.crypto import decrypt
from app.core.errors import ConflictError, NotFoundError, ValidationError
from app.domain.delivery.models import DeployStatus, ProductionDeploy, Release
from app.infrastructure.repositories.delivery import (
AppRuntimeRepository,
ProductionDeployRepository,
ReleaseRepository,
)
from app.infrastructure.repositories.identity import OrganizationRepository
from app.infrastructure.repositories.projects import (
DeployTargetRepository,
ProjectRepository,
)
logger = logging.getLogger("agy.deploy")
#: Bound on the streamed deploy log kept for the post-mortem.
MAX_LOG_TAIL_CHARS = 65_536
class _LogCollector:
"""Accumulate streamed deploy output, bounded, redacted on read."""
__slots__ = ("_buffer",)
def __init__(self) -> None:
self._buffer = ""
async def __call__(self, chunk: str) -> None:
if chunk:
self._buffer = (self._buffer + chunk)[-MAX_LOG_TAIL_CHARS:]
@property
def tail(self) -> str:
from app.core.redact import redact_text
# Redacted as a whole: a credential split across two chunks would slip
# past a per-chunk filter, and a deploy log is where one shows up.
return redact_text(self._buffer)
class DeployService:
"""Human-triggered production deploys, and their rollbacks."""
def __init__(
self,
*,
projects: ProjectRepository,
organizations: OrganizationRepository,
targets: DeployTargetRepository,
deploys: ProductionDeployRepository,
releases: ReleaseRepository,
runtimes: AppRuntimeRepository,
tester, # noqa: ANN001 - DispatchingDeployTargetTester; avoids a cycle
allow_private_hosts: bool | None = None,
) -> None:
self.projects = projects
self.organizations = organizations
self.targets = targets
self.deploys = deploys
self.releases = releases
self.runtimes = runtimes
self.tester = tester
self.allow_private_hosts = (
settings.deploy_allow_private_hosts
if allow_private_hosts is None
else allow_private_hosts
)
# ------------------------------------------------------------------ reads
async def get_target(self, *, organization_id: str, project_id: str):
"""The active deploy target, or ``None``.
Never returns the secret: the row is rendered as-is by the router, which
only reads the non-secret columns.
"""
project = await self.projects.get(project_id)
if project is None or project.organization_id != organization_id:
raise NotFoundError("اپلیکیشن یافت نشد.")
return await self.targets.get_for_project(project_id)
async def list_deploys(
self, *, organization_id: str, project_id: str, limit: int = 20
) -> list[ProductionDeploy]:
project = await self.projects.get(project_id)
if project is None or project.organization_id != organization_id:
raise NotFoundError("اپلیکیشن یافت نشد.")
return await self.deploys.list_for_project(project_id=project_id, limit=limit)
async def latest(self, *, organization_id: str, project_id: str) -> ProductionDeploy | None:
rows = await self.list_deploys(
organization_id=organization_id, project_id=project_id, limit=1
)
return rows[0] if rows else None
# ------------------------------------------------------------- deploy
async def deploy_production(
self,
*,
organization_id: str,
project_id: str,
actor_user_id: str,
confirm: str,
release_id: str | None = None,
) -> ProductionDeploy:
"""Deploy a release to the customer's server. Irreversible by design.
``confirm`` must be the organization's slug typed back. It is not
ceremony: this is the only action in the product that changes something
the platform cannot undo, and a modal with an OK button gets clicked.
"""
project = await self.projects.get(project_id)
if project is None or project.organization_id != organization_id:
raise NotFoundError("اپلیکیشن یافت نشد.")
organization = await self.organizations.get(organization_id)
if organization is None:
raise NotFoundError("سازمان یافت نشد.")
if confirm.strip().lower() != organization.slug.strip().lower():
raise ValidationError("تأییدیه با نامک سازمان مطابقت ندارد.")
if await self.deploys.active_for_project(project_id) is not None:
raise ConflictError("یک استقرار در حال انجام است؛ تا پایان آن صبر کنید.")
target = await self.targets.get_for_project(project_id)
if target is None:
raise ValidationError("برای این اپلیکیشن مقصد استقراری تنظیم نشده است.")
release = await self._resolve_release(
organization_id=organization_id, project_id=project_id, release_id=release_id
)
spec, secret = _spec_from_target(target, allow_private=self.allow_private_hosts)
deploy = await self.deploys.create(
ProductionDeploy(
organization_id=organization_id,
project_id=project_id,
kind=target.kind,
host=target.host,
release_id=release.id,
target_id=target.id,
)
)
await self.deploys.mark_running(deploy.id)
collector = _LogCollector()
deployer = self.tester.deployer_for(target.kind)
if target.kind in ("ssh", "sftp"):
ok, detail, message = await deployer.deploy(
target=spec,
artifact_path=release.artifact_path,
sha=release.sha,
post_deploy_command=target.post_deploy_command,
on_output=collector,
)
backup = ""
else:
ok, backup, message = await deployer.deploy(
target=spec,
artifact_path=release.artifact_path,
sha=release.sha,
organization_id=organization_id,
project_id=project_id,
on_output=collector,
)
detail = backup
await self.deploys.finish(
deploy.id,
status=DeployStatus.SUCCEEDED.value if ok else DeployStatus.FAILED.value,
message=message,
log_tail=collector.tail,
backup_path=backup,
)
_ = actor_user_id, secret, detail
refreshed = await self.deploys.get(deploy.id)
if refreshed is None: # pragma: no cover - the row was just written
raise NotFoundError("استقرار یافت نشد.")
if not ok:
logger.info("production deploy %s failed: %s", deploy.id, message)
return refreshed
async def rollback_production(
self,
*,
organization_id: str,
project_id: str,
actor_user_id: str,
confirm: str,
deploy_id: str | None = None,
) -> ProductionDeploy:
"""Undo a production deploy. Same confirmation, same audit trail."""
project = await self.projects.get(project_id)
if project is None or project.organization_id != organization_id:
raise NotFoundError("اپلیکیشن یافت نشد.")
organization = await self.organizations.get(organization_id)
if organization is None:
raise NotFoundError("سازمان یافت نشد.")
if confirm.strip().lower() != organization.slug.strip().lower():
raise ValidationError("تأییدیه با نامک سازمان مطابقت ندارد.")
if await self.deploys.active_for_project(project_id) is not None:
raise ConflictError("یک استقرار در حال انجام است؛ تا پایان آن صبر کنید.")
target = await self.targets.get_for_project(project_id)
if target is None:
raise ValidationError("برای این اپلیکیشن مقصد استقراری تنظیم نشده است.")
previous = (
await self.deploys.get_for_org(organization_id, deploy_id)
if deploy_id
else await self.latest(organization_id=organization_id, project_id=project_id)
)
if previous is None or previous.status != DeployStatus.SUCCEEDED.value:
raise ValidationError("استقرار موفقی برای بازگردانی وجود ندارد.")
spec, _secret = _spec_from_target(target, allow_private=self.allow_private_hosts)
deploy = await self.deploys.create(
ProductionDeploy(
organization_id=organization_id,
project_id=project_id,
kind=target.kind,
host=target.host,
release_id=previous.release_id,
target_id=target.id,
rolled_back_from=previous.id,
)
)
await self.deploys.mark_running(deploy.id)
collector = _LogCollector()
deployer = self.tester.deployer_for(target.kind)
if target.kind in ("ssh", "sftp"):
ok, detail, message = await deployer.rollback(
target=spec,
to_release=None,
post_deploy_command=target.post_deploy_command,
on_output=collector,
)
backup = ""
else:
if not previous.backup_path:
await self.deploys.finish(
deploy.id,
status=DeployStatus.FAILED.value,
message="پشتیبان این استقرار در دسترس نیست؛ بازگردانی FTP ممکن نیست.",
log_tail=collector.tail,
)
refreshed = await self.deploys.get(deploy.id)
if refreshed is None: # pragma: no cover
raise NotFoundError("استقرار یافت نشد.")
return refreshed
ok, message = await deployer.rollback(
target=spec, backup_path=previous.backup_path, on_output=collector
)
detail, backup = previous.backup_path, previous.backup_path
await self.deploys.finish(
deploy.id,
status=DeployStatus.ROLLED_BACK.value if ok else DeployStatus.FAILED.value,
message=message,
log_tail=collector.tail,
backup_path=backup,
)
_ = actor_user_id, detail
refreshed = await self.deploys.get(deploy.id)
if refreshed is None: # pragma: no cover
raise NotFoundError("استقرار یافت نشد.")
return refreshed
# ------------------------------------------------------------ helpers
async def _resolve_release(
self, *, organization_id: str, project_id: str, release_id: str | None
) -> Release:
"""The release to ship: an explicit one, or whatever the demo is live on.
Defaulting to the live demo release is deliberate — the demo is the
thing the user has just looked at and approved.
"""
if release_id:
release = await self.releases.get_for_org(organization_id, release_id)
if release is None:
raise NotFoundError("نسخه یافت نشد.")
return release
release = await self.releases.live_for_project(project_id)
if release is None:
raise ValidationError("هنوز نسخهٔ زنده‌ای برای استقرار وجود ندارد.")
return release
async def test_target(
self, *, organization_id: str, project_id: str, actor_user_id: str
) -> tuple[bool, str]:
"""Run the connection test and record the result on the target row."""
target = await self.targets.get_for_project(project_id)
if target is None:
raise NotFoundError("مقصد استقرار یافت نشد.")
spec, _secret = _spec_from_target(target, allow_private=self.allow_private_hosts)
check = await self.tester.test_connection(target=spec)
await self.targets.record_test(
target_id=target.id, ok=check.ok, message=check.message,
fingerprint=check.fingerprint,
)
_ = organization_id, actor_user_id
return check.ok, check.message
def _spec_from_target(target, *, allow_private: bool) -> tuple[DeployTargetSpec, str | None]:
"""Build the port-level spec, decrypting the secret exactly here.
The plaintext exists only inside this function and the call it feeds. It is
never logged, never returned, and never stored on the spec after the call.
"""
secret: str | None = None
if target.secret_encrypted:
with contextlib.suppress(Exception):
secret = decrypt(target.secret_encrypted)
spec = DeployTargetSpec(
kind=target.kind,
host=target.host,
port=int(target.port),
username=target.username,
auth_type=target.auth_type,
secret=secret,
remote_path=target.remote_path,
known_fingerprint=target.host_fingerprint,
)
_ = allow_private
return spec, secret
__all__ = ["MAX_LOG_TAIL_CHARS", "DeployService"]
+29
View File
@@ -157,3 +157,32 @@ class AppRuntime:
@property @property
def demo_url(self) -> str: def demo_url(self) -> str:
return f"https://{self.demo_host}" if self.demo_host else "" return f"https://{self.demo_host}" if self.demo_host else ""
class DeployStatus(StrEnum):
QUEUED = "queued"
RUNNING = "running"
SUCCEEDED = "succeeded"
FAILED = "failed"
#: A deploy that undid an earlier one. Kept as its own row so "what was
#: live when?" is answerable from the table alone.
ROLLED_BACK = "rolled_back"
@dataclass(slots=True, kw_only=True)
class ProductionDeploy:
organization_id: str
project_id: str
kind: str
host: str = ""
release_id: str | None = None
target_id: str | None = None
status: str = DeployStatus.QUEUED.value
message: str = ""
log_tail: str = ""
rolled_back_from: str | None = None
backup_path: str = ""
id: str = field(default_factory=new_id)
started_at: datetime | None = None
finished_at: datetime | None = None
created_at: datetime = field(default_factory=utcnow)
@@ -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__ = [ __all__ = [
"AppRuntimeRow", "AppRuntimeRow",
"ProductionDeployRow",
"MAX_LOG_TAIL_CHARS", "MAX_LOG_TAIL_CHARS",
"PipelineRunRow", "PipelineRunRow",
"PipelineStepRow", "PipelineStepRow",
@@ -8,8 +8,10 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.domain.base import utcnow from app.domain.base import utcnow
from app.domain.delivery.models import ( from app.domain.delivery.models import (
AppRuntime, AppRuntime,
DeployStatus,
PipelineRun, PipelineRun,
PipelineStep, PipelineStep,
ProductionDeploy,
Release, Release,
RunStatus, RunStatus,
StepStatus, StepStatus,
@@ -18,6 +20,7 @@ from app.infrastructure.db.models_delivery import (
AppRuntimeRow, AppRuntimeRow,
PipelineRunRow, PipelineRunRow,
PipelineStepRow, PipelineStepRow,
ProductionDeployRow,
ReleaseRow, ReleaseRow,
) )
@@ -440,11 +443,131 @@ class AppRuntimeRepository:
await self.session.flush() 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__ = [ __all__ = [
"ACTIVE_STATUSES", "ACTIVE_STATUSES",
"AppRuntimeRepository", "AppRuntimeRepository",
"PipelineRunRepository", "PipelineRunRepository",
"PipelineStepRepository", "PipelineStepRepository",
"ProductionDeployRepository",
"ReleaseRepository", "ReleaseRepository",
"StepStatus", "StepStatus",
] ]
@@ -14,6 +14,7 @@ from app.domain.projects.models import (
ProjectGitRepo, ProjectGitRepo,
) )
from app.infrastructure.db.models import ( from app.infrastructure.db.models import (
DeployTargetRow,
PreviewDomainRow, PreviewDomainRow,
PreviewPortRow, PreviewPortRow,
ProjectExternalGitRow, ProjectExternalGitRow,
@@ -164,6 +165,61 @@ class ProjectGitCredentialRepository:
return credential 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: class ProjectRepository:
def __init__(self, session: AsyncSession) -> None: def __init__(self, session: AsyncSession) -> None:
self.session = session self.session = session
+9
View File
@@ -204,3 +204,12 @@ async def get_delivery_service(
from app.application.container import build_delivery_service from app.application.container import build_delivery_service
return build_delivery_service(session) return build_delivery_service(session)
async def get_deploy_service(
session: AsyncSession = Depends(get_session),
):
"""Build the production deploy service with the configured transports."""
from app.application.container import build_deploy_service
return build_deploy_service(session)
@@ -21,12 +21,14 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.application.services.audit_service import record_audit from app.application.services.audit_service import record_audit
from app.application.services.delivery_service import DeliveryService from app.application.services.delivery_service import DeliveryService
from app.application.services.deploy_service import DeployService
from app.core.database import get_session from app.core.database import get_session
from app.domain.identity.models import CAN_MANAGE_PROJECTS from app.domain.identity.models import CAN_MANAGE_PROJECTS
from app.interfaces.http.deps import ( from app.interfaces.http.deps import (
OrgContext, OrgContext,
client_meta, client_meta,
get_delivery_service, get_delivery_service,
get_deploy_service,
get_org_context, get_org_context,
) )
@@ -248,3 +250,210 @@ async def rollback_demo(
ip=ip, user_agent=ua, ip=ip, user_agent=ua,
) )
return _release_out(release) return _release_out(release)
# ---------------------------------------------------------------------------
# Production (always human-triggered, always confirmed)
# ---------------------------------------------------------------------------
class ProductionDeployOut(BaseModel):
id: str
project_id: str
kind: str
host: str
status: str
message: str
log_tail: str
release_id: str | None
rolled_back_from: str | None
started_at: datetime | None
finished_at: datetime | None
created_at: datetime
class ProductionTargetOut(BaseModel):
"""The target as a *user* sees it: never a secret, only a hint that one exists."""
kind: str
host: str
port: int
username: str
auth_type: str
remote_path: str
has_secret: bool
host_fingerprint: str | None
last_tested_at: datetime | None
last_test_ok: bool | None
last_test_message: str | None
class ProductionOverviewOut(BaseModel):
target: ProductionTargetOut | None
deploys: list[ProductionDeployOut]
class DeployConfirmRequest(BaseModel):
project_id: str = Field(min_length=1, max_length=32)
#: The organization's slug, typed back. Not ceremony: this is the only
#: action in the product the platform cannot undo.
confirm: str = Field(min_length=1, max_length=120)
release_id: str | None = Field(default=None, max_length=32)
class RollbackConfirmRequest(BaseModel):
project_id: str = Field(min_length=1, max_length=32)
confirm: str = Field(min_length=1, max_length=120)
deploy_id: str | None = Field(default=None, max_length=32)
class TargetTestOut(BaseModel):
ok: bool
message: str
def _deploy_out(deploy) -> ProductionDeployOut: # noqa: ANN001 - a domain dataclass
return ProductionDeployOut(
id=deploy.id, project_id=deploy.project_id, kind=deploy.kind, host=deploy.host,
status=deploy.status, message=deploy.message, log_tail=deploy.log_tail,
release_id=deploy.release_id, rolled_back_from=deploy.rolled_back_from,
started_at=deploy.started_at, finished_at=deploy.finished_at,
created_at=deploy.created_at,
)
def _target_out(target) -> ProductionTargetOut: # noqa: ANN001
return ProductionTargetOut(
kind=target.kind, host=target.host, port=target.port, username=target.username,
auth_type=target.auth_type, remote_path=target.remote_path,
# The secret itself is never returned — only that one is configured.
has_secret=bool(target.secret_encrypted),
host_fingerprint=target.host_fingerprint,
last_tested_at=target.last_tested_at, last_test_ok=target.last_test_ok,
last_test_message=target.last_test_message,
)
@router.get("/production", response_model=ProductionOverviewOut)
async def production_overview(
project_id: str = Query(min_length=1),
context: OrgContext = Depends(get_org_context),
deploys: DeployService = Depends(get_deploy_service),
) -> ProductionOverviewOut:
"""The target summary and the deploy history (admin+ — it names a host)."""
context.require(CAN_MANAGE_PROJECTS)
target = await deploys.get_target(
organization_id=context.org_id, project_id=project_id
)
history = await deploys.list_deploys(
organization_id=context.org_id, project_id=project_id
)
return ProductionOverviewOut(
target=_target_out(target) if target else None,
deploys=[_deploy_out(d) for d in history],
)
@router.post("/production/test", response_model=TargetTestOut)
async def test_production_target(
payload: ManualRunRequest,
context: OrgContext = Depends(get_org_context),
deploys: DeployService = Depends(get_deploy_service),
) -> TargetTestOut:
"""Test the configured target and record the result (admin+)."""
context.require(CAN_MANAGE_PROJECTS)
ok, message = await deploys.test_target(
organization_id=context.org_id,
project_id=payload.project_id,
actor_user_id=context.user.id,
)
return TargetTestOut(ok=ok, message=message)
@router.post("/production/deploy", response_model=ProductionDeployOut)
async def deploy_to_production(
payload: DeployConfirmRequest,
request: Request,
context: OrgContext = Depends(get_org_context),
deploys: DeployService = Depends(get_deploy_service),
session: AsyncSession = Depends(get_session),
) -> ProductionDeployOut:
"""Ship a release to the customer's server (org owner/admin, confirmed)."""
context.require(CAN_MANAGE_PROJECTS)
deploy = await deploys.deploy_production(
organization_id=context.org_id,
project_id=payload.project_id,
actor_user_id=context.user.id,
confirm=payload.confirm,
release_id=payload.release_id,
)
ip, ua = client_meta(request)
await record_audit(
session, action="deploy.production", organization_id=context.org_id,
actor_user_id=context.user.id, target_type="production_deploy", target_id=deploy.id,
metadata={
"project_id": deploy.project_id,
"kind": deploy.kind,
"status": deploy.status,
"release_id": deploy.release_id,
},
ip=ip, user_agent=ua,
)
await _emit_deploy_status(context.org_id, deploy)
return _deploy_out(deploy)
@router.post("/production/rollback", response_model=ProductionDeployOut)
async def rollback_production(
payload: RollbackConfirmRequest,
request: Request,
context: OrgContext = Depends(get_org_context),
deploys: DeployService = Depends(get_deploy_service),
session: AsyncSession = Depends(get_session),
) -> ProductionDeployOut:
"""Undo a production deploy (org owner/admin, confirmed, audited)."""
context.require(CAN_MANAGE_PROJECTS)
deploy = await deploys.rollback_production(
organization_id=context.org_id,
project_id=payload.project_id,
actor_user_id=context.user.id,
confirm=payload.confirm,
deploy_id=payload.deploy_id,
)
ip, ua = client_meta(request)
await record_audit(
session, action="deploy.production_rollback", organization_id=context.org_id,
actor_user_id=context.user.id, target_type="production_deploy", target_id=deploy.id,
metadata={
"project_id": deploy.project_id,
"kind": deploy.kind,
"status": deploy.status,
"rolled_back_from": deploy.rolled_back_from,
},
ip=ip, user_agent=ua,
)
await _emit_deploy_status(context.org_id, deploy)
return _deploy_out(deploy)
async def _emit_deploy_status(organization_id: str, deploy) -> None: # noqa: ANN001
"""Push the outcome on the tenant channel. Never raises."""
import contextlib
from app.application.ports.realtime import RealtimeEvent, get_publisher, org_channel
with contextlib.suppress(Exception):
await get_publisher().publish(
RealtimeEvent(
type="deploy.status",
channels=(org_channel(organization_id),),
data={
"project_id": deploy.project_id,
"deploy_id": deploy.id,
"kind": deploy.kind,
"status": deploy.status,
"message": deploy.message,
},
organization_id=organization_id,
)
)
+787
View File
@@ -0,0 +1,787 @@
"""§v2-A3.5 — production deploy over SSH / SFTP / FTP / FTPS (D8).
Production is the one action the platform cannot undo, so the tests are about the
guards around it as much as the deploy itself:
* a member who is not an org admin cannot deploy;
* the org slug has to be typed back;
* a changed host key is a hard failure;
* an SSRF target is refused before a connection is attempted;
* a secret never reaches a log, an event, a response or a database column;
* and the mechanics — release directories, the ``current`` symlink, keeping five
releases, rollback — behave the way the design says they do.
The SSH and FTP transports are faked, so the suite needs no server. The deploy
*logic* is real.
"""
from __future__ import annotations
import os
import tarfile
import pytest
import pytest_asyncio
from httpx import AsyncClient
from app.application.ports.deploy_target import DeployCheck, DeployTargetSpec
from app.application.services.deploy_service import DeployService
from app.core.crypto import encrypt
from app.domain.delivery.models import DeployStatus
from app.domain.identity.models import PlatformRole
from app.infrastructure.adapters.deploy_ftp import FtpDeployer, FtpEntry
from app.infrastructure.adapters.deploy_ssh import (
CURRENT_LINK,
KEEP_RELEASES,
CommandOutcome,
SshDeployer,
)
from app.infrastructure.repositories.delivery import (
ProductionDeployRepository,
ReleaseRepository,
)
from app.infrastructure.repositories.identity import OrganizationRepository
from app.infrastructure.repositories.projects import (
DeployTargetRepository,
ProjectRepository,
)
from tests.conftest import auth_header, signup
SECRET = "ssh-password-that-must-never-leak"
# ---------------------------------------------------------------------------
# fake transports
# ---------------------------------------------------------------------------
class FakeSshTransport:
"""An in-memory SSH server: a filesystem plus a scripted command table."""
def __init__(self, *, fingerprint: str = "SHA256:abc123", fail_on: str = "") -> None:
self.commands: list[str] = []
self.uploads: list[tuple[str, str]] = []
self.fingerprint = fingerprint
self.fail_on = fail_on
self.closed = False
#: The remote filesystem, as a set of paths.
self.files: set[str] = set()
async def connect(self, **kwargs) -> None: # noqa: ANN003
self.connect_kwargs = kwargs
if self.fail_on == "connect":
raise OSError("permission denied")
def host_key_fingerprint(self) -> str:
return self.fingerprint
async def run(self, command: str, *, timeout: float = 600.0) -> CommandOutcome:
self.commands.append(command)
if self.fail_on and self.fail_on in command:
return CommandOutcome(exit_code=1, output="failed on purpose")
if "ls -1t" in command and "releases" in command:
releases = sorted(
{p.split("/releases/")[1].split("/")[0] for p in self.files if "/releases/" in p},
reverse=True,
)
return CommandOutcome(exit_code=0, output="\n".join(releases))
if "test -w" in command:
return CommandOutcome(exit_code=0, output="")
if "ln -sfn" in command:
# Record the switch so a test can assert the pointer moved.
for part in command.split():
if part.startswith("releases/"):
self.files.add(f"/srv/app/{CURRENT_LINK}->{part}")
return CommandOutcome(exit_code=0, output="")
if "tail -n" in command:
self.files = {
p for p in self.files if not p.endswith(tuple(
f"release-{i}" for i in range(1, 20)
))
}
return CommandOutcome(exit_code=0, output="")
return CommandOutcome(exit_code=0, output="")
async def upload(self, *, local_path: str, remote_path: str) -> None:
self.uploads.append((local_path, remote_path))
self.files.add(remote_path)
async def close(self) -> None:
self.closed = True
class FakeFtpTransport:
def __init__(self, *, fail_on: str = "") -> None:
self.commands: list[str] = []
self.uploaded: dict[str, bytes] = {}
self.removed: list[str] = []
self.fail_on = fail_on
self.cwd_path = ""
self.connected = False
async def connect(self, **kwargs) -> None: # noqa: ANN003
self.connect_kwargs = kwargs
if self.fail_on == "connect":
raise OSError("530 login incorrect")
self.connected = True
async def cwd(self, path: str) -> None:
if self.fail_on == "cwd":
raise OSError("550 no such directory")
self.cwd_path = path
async def download(self, remote_path: str) -> bytes:
return self.uploaded.get(remote_path, b"old-content")
async def upload(self, remote_path: str, data: bytes) -> None:
if self.fail_on == "upload":
raise OSError("552 quota exceeded")
self.uploaded[remote_path] = data
async def remove(self, remote_path: str) -> None:
self.removed.append(remote_path)
self.uploaded.pop(remote_path, None)
async def list_files(self) -> list[FtpEntry]:
return [FtpEntry(path=name, size=len(data)) for name, data in self.uploaded.items()]
async def close(self) -> None:
self.connected = False
class FakeDeployTargetTester:
"""Dispatches to injected deployers, like the real one but with no network."""
def __init__(self, *, ssh: SshDeployer, ftp: FtpDeployer) -> None:
self.ssh = ssh
self.ftp = ftp
self.calls: list[DeployTargetSpec] = []
async def test_connection(self, *, target: DeployTargetSpec) -> DeployCheck:
self.calls.append(target)
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):
return self.ssh if kind in ("ssh", "sftp") else self.ftp
# ---------------------------------------------------------------------------
# fixtures
# ---------------------------------------------------------------------------
@pytest_asyncio.fixture(autouse=True)
async def _platform_seed(seeded):
return seeded
def _artifact(tmp_path, *, build_dir: str = "") -> str:
"""A release archive shaped like the pipeline produces."""
source = tmp_path / "artifact-src"
target = source / build_dir if build_dir else source
target.mkdir(parents=True, exist_ok=True)
(target / "index.html").write_text("<h1>hello</h1>")
(target / "app.js").write_text("console.log('hi')")
if build_dir:
(source / "README.md").write_text("not deployed")
archive = tmp_path / "release.tar.gz"
with tarfile.open(archive, "w:gz") as tar:
# Add the children rather than the directory itself: a bare "." member
# is the archive root, which some extractors try to mkdir.
for child in sorted(source.iterdir()):
tar.add(child, arcname=child.name)
return str(archive)
@pytest_asyncio.fixture
async def tenant(client: AsyncClient, session_factory, tmp_path):
"""An org + app + live release + a configured SSH deploy target."""
token = (
await signup(
client,
"deploy-owner@example.com",
name="مدیر استقرار",
platform_role=PlatformRole.OWNER,
)
)["access_token"]
headers = auth_header(token)
org = (
await client.post("/api/organizations", json={"name": "شرکت استقرار"}, headers=headers)
).json()
org_id, slug = org["id"], org["slug"]
project_id = (
await client.post(
f"/api/organizations/{org_id}/projects",
json={"name": "اپ استقرار"},
headers=headers,
)
).json()["id"]
artifact = _artifact(tmp_path, build_dir="dist")
async with session_factory() as session:
from app.domain.delivery.models import PipelineRun, Release
from app.infrastructure.repositories.delivery import PipelineRunRepository
# A release points at the pipeline run that produced it, so the run has
# to exist: the foreign key is real, not decorative.
run = await PipelineRunRepository(session).queue(
PipelineRun(
organization_id=org_id,
project_id=project_id,
sha="abc1234def",
status="succeeded",
)
)
release = await ReleaseRepository(session).create(
Release(
organization_id=org_id,
project_id=project_id,
run_id=run.id,
sha="abc1234def",
artifact_path=artifact,
is_demo_live=True,
)
)
await DeployTargetRepository(session).upsert(
values={
"organization_id": org_id,
"project_id": project_id,
"kind": "ssh",
"host": "deploy.example.com",
"port": 22,
"username": "deployer",
"auth_type": "password",
"secret_encrypted": encrypt(SECRET),
"remote_path": "/srv/app",
"post_deploy_command": "systemctl reload app",
"is_active": True,
}
)
await session.commit()
return {
"org_id": org_id,
"slug": slug,
"project_id": project_id,
"headers": headers,
"release_id": release.id,
"artifact": artifact,
}
def _service(session, *, ssh: FakeSshTransport, ftp: FakeFtpTransport | None = None) -> tuple[
DeployService, FakeDeployTargetTester
]:
ssh_deployer = SshDeployer(
transport_factory=lambda: ssh, artifacts_root="/tmp/artifacts", allow_private_hosts=False
)
ftp_deployer = FtpDeployer(
transport_factory=lambda: ftp or FakeFtpTransport(),
artifacts_root="/tmp/artifacts",
allow_private_hosts=False,
)
tester = FakeDeployTargetTester(ssh=ssh_deployer, ftp=ftp_deployer)
service = DeployService(
projects=ProjectRepository(session),
organizations=OrganizationRepository(session),
targets=DeployTargetRepository(session),
deploys=ProductionDeployRepository(session),
releases=ReleaseRepository(session),
runtimes=None, # not needed for the deploy paths under test
tester=tester,
allow_private_hosts=False,
)
return service, tester
# ---------------------------------------------------------------------------
# the SSH deploy mechanics
# ---------------------------------------------------------------------------
async def test_ssh_deploy_uploads_extracts_and_switches(tmp_path):
transport = FakeSshTransport()
deployer = SshDeployer(transport_factory=lambda: transport, allow_private_hosts=True)
ok, release_dir, message = await deployer.deploy(
target=DeployTargetSpec(
kind="ssh", host="deploy.example.com", port=22, username="deployer",
secret="pw", remote_path="/srv/app",
),
artifact_path=_artifact(tmp_path, build_dir="dist"),
sha="abc1234def",
post_deploy_command="systemctl reload app",
)
assert ok is True, message
assert release_dir.startswith("/srv/app/releases/")
assert transport.uploads, "the artifact must be uploaded"
joined = "\n".join(transport.commands)
assert "tar -xzf" in joined, "the artifact must be extracted on the target"
assert "systemctl reload app" in joined, "the post-deploy command must run"
assert "ln -sfn" in joined and CURRENT_LINK in joined, "the symlink must be switched"
assert "tail -n" in joined, "old releases must be pruned"
async def test_ssh_deploy_prunes_to_five_releases(tmp_path):
transport = FakeSshTransport()
deployer = SshDeployer(transport_factory=lambda: transport, allow_private_hosts=True)
await deployer.deploy(
target=DeployTargetSpec(
kind="ssh", host="h", port=22, username="u", secret="pw", remote_path="/srv/app"
),
artifact_path=_artifact(tmp_path),
sha="abc1234",
)
prune = next(c for c in transport.commands if "tail -n" in c)
assert f"tail -n +{KEEP_RELEASES + 1}" in prune, (
"keep the newest five and delete the rest"
)
async def test_a_failed_post_deploy_command_does_not_switch_the_symlink(tmp_path):
"""The previous release must keep serving after a failed deploy."""
transport = FakeSshTransport(fail_on="systemctl")
deployer = SshDeployer(transport_factory=lambda: transport, allow_private_hosts=True)
ok, _release, message = await deployer.deploy(
target=DeployTargetSpec(
kind="ssh", host="h", port=22, username="u", secret="pw", remote_path="/srv/app"
),
artifact_path=_artifact(tmp_path),
sha="abc1234",
post_deploy_command="systemctl reload app",
)
assert ok is False
assert "پس از استقرار" in message
assert not any("ln -sfn" in c for c in transport.commands), (
"the switch must not happen when the hook failed"
)
async def test_ssh_rollback_points_current_at_the_previous_release(tmp_path):
transport = FakeSshTransport()
deployer = SshDeployer(transport_factory=lambda: transport, allow_private_hosts=True)
# Two releases exist on the target, newest first.
transport.files = {
"/srv/app/releases/20260101T000000-aaaaaaa/index.html",
"/srv/app/releases/20260102T000000-bbbbbbb/index.html",
}
ok, target_name, message = await deployer.rollback(
target=DeployTargetSpec(
kind="ssh", host="h", port=22, username="u", secret="pw", remote_path="/srv/app"
),
post_deploy_command="systemctl reload app",
)
assert ok is True, message
assert target_name == "20260101T000000-aaaaaaa"
assert any("ln -sfn" in c for c in transport.commands)
async def test_ssh_rollback_refuses_when_there_is_only_one_release(tmp_path):
transport = FakeSshTransport()
transport.files = {"/srv/app/releases/only-one/index.html"}
deployer = SshDeployer(transport_factory=lambda: transport, allow_private_hosts=True)
ok, _target, message = await deployer.rollback(
target=DeployTargetSpec(
kind="ssh", host="h", port=22, username="u", secret="pw", remote_path="/srv/app"
)
)
assert ok is False
assert "قبلی" in message
# ---------------------------------------------------------------------------
# host key pinning (TOFU)
# ---------------------------------------------------------------------------
async def test_a_changed_host_key_is_a_hard_failure():
transport = FakeSshTransport(fingerprint="SHA256:CHANGED")
deployer = SshDeployer(transport_factory=lambda: transport, allow_private_hosts=True)
check = await deployer.test_connection(
target=DeployTargetSpec(
kind="ssh", host="h", port=22, username="u", secret="pw",
remote_path="/srv/app", known_fingerprint="SHA256:ORIGINAL",
)
)
assert check.ok is False
assert "کلید میزبان" in check.message
assert check.fingerprint == "SHA256:CHANGED"
async def test_the_fingerprint_is_returned_for_pinning_on_first_contact():
transport = FakeSshTransport(fingerprint="SHA256:FIRST")
deployer = SshDeployer(transport_factory=lambda: transport, allow_private_hosts=True)
check = await deployer.test_connection(
target=DeployTargetSpec(
kind="ssh", host="h", port=22, username="u", secret="pw", remote_path="/srv/app"
)
)
assert check.ok is True
assert check.fingerprint == "SHA256:FIRST"
async def test_a_matching_pinned_key_is_accepted():
transport = FakeSshTransport(fingerprint="SHA256:SAME")
deployer = SshDeployer(transport_factory=lambda: transport, allow_private_hosts=True)
check = await deployer.test_connection(
target=DeployTargetSpec(
kind="ssh", host="h", port=22, username="u", secret="pw",
remote_path="/srv/app", known_fingerprint="SHA256:SAME",
)
)
assert check.ok is True
# ---------------------------------------------------------------------------
# SSRF
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(
"host",
["127.0.0.1", "169.254.169.254", "10.0.0.5", "localhost"],
)
async def test_an_internal_target_is_refused_before_connecting(host: str):
transport = FakeSshTransport()
deployer = SshDeployer(transport_factory=lambda: transport, allow_private_hosts=False)
check = await deployer.test_connection(
target=DeployTargetSpec(
kind="ssh", host=host, port=22, username="u", secret="pw", remote_path="/srv/app"
)
)
assert check.ok is False
assert transport.commands == [], "no connection may be attempted"
assert not transport.uploads
async def test_an_internal_ftp_target_is_refused():
transport = FakeFtpTransport()
deployer = FtpDeployer(transport_factory=lambda: transport, allow_private_hosts=False)
check = await deployer.test_connection(
target=DeployTargetSpec(
kind="ftp", host="127.0.0.1", port=21, username="u", secret="pw",
remote_path="/var/www",
)
)
assert check.ok is False
assert transport.connected is False
# ---------------------------------------------------------------------------
# FTP
# ---------------------------------------------------------------------------
async def test_ftp_deploy_backs_up_before_overwriting(tmp_path):
transport = FakeFtpTransport()
transport.uploaded = {"index.html": b"OLD", "app.js": b"OLD"}
deployer = FtpDeployer(
transport_factory=lambda: transport, artifacts_root=str(tmp_path / "artifacts")
)
ok, backup, message = await deployer.deploy(
target=DeployTargetSpec(
kind="ftp", host="ftp.example.com", port=21, username="u", secret="pw",
remote_path="/var/www",
),
artifact_path=_artifact(tmp_path, build_dir="dist"),
sha="abc1234",
organization_id="org-1",
project_id="proj-1",
)
assert ok is True, message
assert backup and os.path.isfile(backup), "a backup must exist before anything is overwritten"
with tarfile.open(backup, "r:gz") as tar:
names = set(tar.getnames())
assert {"index.html", "app.js"} <= names, "the backup must contain what it overwrites"
async def test_ftp_deploy_uploads_only_the_build_output(tmp_path):
"""A static deploy must not publish the repository, only the build."""
transport = FakeFtpTransport()
deployer = FtpDeployer(
transport_factory=lambda: transport, artifacts_root=str(tmp_path / "artifacts")
)
await deployer.deploy(
target=DeployTargetSpec(
kind="ftp", host="h", port=21, username="u", secret="pw", remote_path="/var/www"
),
artifact_path=_artifact(tmp_path, build_dir="dist"),
sha="abc1234",
)
assert "index.html" in transport.uploaded
assert "README.md" not in transport.uploaded, "the source tree must not be published"
async def test_ftp_rollback_re_uploads_the_backup(tmp_path):
transport = FakeFtpTransport()
transport.uploaded = {"index.html": b"OLD"}
deployer = FtpDeployer(
transport_factory=lambda: transport, artifacts_root=str(tmp_path / "artifacts")
)
_ok, backup, _message = await deployer.deploy(
target=DeployTargetSpec(
kind="ftp", host="h", port=21, username="u", secret="pw", remote_path="/var/www"
),
artifact_path=_artifact(tmp_path, build_dir="dist"),
sha="abc1234",
organization_id="org-1",
project_id="proj-1",
)
transport.uploaded = {"index.html": b"BROKEN"}
ok, message = await deployer.rollback(
target=DeployTargetSpec(
kind="ftp", host="h", port=21, username="u", secret="pw", remote_path="/var/www"
),
backup_path=backup,
)
assert ok is True, message
assert transport.uploaded["index.html"] == b"OLD"
async def test_ftp_rollback_without_a_backup_fails_cleanly():
transport = FakeFtpTransport()
deployer = FtpDeployer(transport_factory=lambda: transport)
ok, message = await deployer.rollback(
target=DeployTargetSpec(
kind="ftp", host="h", port=21, username="u", secret="pw", remote_path="/var/www"
),
backup_path="",
)
assert ok is False
assert message
# ---------------------------------------------------------------------------
# the service guards
# ---------------------------------------------------------------------------
async def test_the_confirm_slug_must_match(tenant, session_factory):
transport = FakeSshTransport()
async with session_factory() as session:
service, _tester = _service(session, ssh=transport)
with pytest.raises(Exception) as excinfo:
await service.deploy_production(
organization_id=tenant["org_id"],
project_id=tenant["project_id"],
actor_user_id="u1",
confirm="not-the-slug",
)
assert "تأییدیه" in str(excinfo.value)
assert transport.commands == [], "nothing may be attempted on a bad confirmation"
async def test_a_correct_confirmation_deploys_and_records_the_row(tenant, session_factory):
transport = FakeSshTransport()
async with session_factory() as session:
service, _tester = _service(session, ssh=transport)
deploy = await service.deploy_production(
organization_id=tenant["org_id"],
project_id=tenant["project_id"],
actor_user_id="u1",
confirm=tenant["slug"],
)
await session.commit()
assert deploy.status == DeployStatus.SUCCEEDED.value, deploy.message
assert deploy.kind == "ssh"
async with session_factory() as session:
rows = await ProductionDeployRepository(session).list_for_project(
project_id=tenant["project_id"]
)
assert len(rows) == 1
assert rows[0].release_id == tenant["release_id"]
async def test_a_second_deploy_is_refused_while_one_is_running(tenant, session_factory):
from app.domain.delivery.models import ProductionDeploy
transport = FakeSshTransport()
async with session_factory() as session:
repo = ProductionDeployRepository(session)
await repo.create(
ProductionDeploy(
organization_id=tenant["org_id"],
project_id=tenant["project_id"],
kind="ssh",
host="h",
status=DeployStatus.RUNNING.value,
)
)
await session.commit()
service, _tester = _service(session, ssh=transport)
with pytest.raises(Exception) as excinfo:
await service.deploy_production(
organization_id=tenant["org_id"],
project_id=tenant["project_id"],
actor_user_id="u1",
confirm=tenant["slug"],
)
assert "در حال انجام" in str(excinfo.value)
async def test_a_secret_never_reaches_the_log_or_the_row(tenant, session_factory):
"""The one thing that must never happen: a credential in a stored field."""
transport = FakeSshTransport()
async with session_factory() as session:
service, _tester = _service(session, ssh=transport)
deploy = await service.deploy_production(
organization_id=tenant["org_id"],
project_id=tenant["project_id"],
actor_user_id="u1",
confirm=tenant["slug"],
)
await session.commit()
assert SECRET not in deploy.log_tail
assert SECRET not in deploy.message
for _local, remote in transport.uploads:
assert SECRET not in remote
for command in transport.commands:
assert SECRET not in command, "a credential must never reach a remote command line"
async def test_the_ftp_rollback_without_a_backup_is_refused_not_guessed(
tenant, session_factory
):
"""FTP has no atomic switch: without the backup there is nothing to undo."""
async with session_factory() as session:
target = await DeployTargetRepository(session).get_for_project(tenant["project_id"])
assert target is not None
target.kind = "ftp"
await session.commit()
transport = FakeFtpTransport()
async with session_factory() as session:
service, _tester = _service(session, ssh=FakeSshTransport(), ftp=transport)
deploy = await service.deploy_production(
organization_id=tenant["org_id"],
project_id=tenant["project_id"],
actor_user_id="u1",
confirm=tenant["slug"],
)
await session.commit()
assert deploy.status == DeployStatus.SUCCEEDED.value, deploy.message
# Clear the recorded backup to simulate a lost archive.
row = await ProductionDeployRepository(session).get(deploy.id)
assert row is not None
deploy_row = await session.get(
__import__(
"app.infrastructure.db.models_delivery", fromlist=["ProductionDeployRow"]
).ProductionDeployRow,
deploy.id,
)
assert deploy_row is not None
deploy_row.backup_path = ""
await session.commit()
rolled = await service.rollback_production(
organization_id=tenant["org_id"],
project_id=tenant["project_id"],
actor_user_id="u1",
confirm=tenant["slug"],
deploy_id=deploy.id,
)
await session.commit()
assert rolled.status == DeployStatus.FAILED.value
assert "پشتیبان" in rolled.message
# ---------------------------------------------------------------------------
# HTTP surface
# ---------------------------------------------------------------------------
async def test_a_member_cannot_deploy_to_production(client: AsyncClient, tenant):
member_token = (await signup(client, "deploy-member@example.com"))["access_token"]
member_headers = auth_header(member_token)
# Join the org as a viewer so membership succeeds but the capability does not.
invite = await client.post(
f"/api/organizations/{tenant['org_id']}/invites",
json={"email": "deploy-member@example.com", "role": "viewer"},
headers=tenant["headers"],
)
if invite.status_code in (200, 201):
token = invite.json().get("token") or invite.json().get("invite_url", "").split("=")[-1]
if token:
await client.post(
"/api/invites/accept",
json={"token": token, "password": "S3cure-passw0rd!"},
headers=member_headers,
)
response = await client.post(
f"/api/organizations/{tenant['org_id']}/delivery/production/deploy",
json={
"project_id": tenant["project_id"],
"confirm": tenant["slug"],
},
headers=member_headers,
)
# Either not a member (404, the tenancy check) or a member without the
# capability (403). Never a success.
assert response.status_code in (403, 404), response.text
async def test_the_confirm_slug_is_enforced_over_http(client: AsyncClient, tenant):
response = await client.post(
f"/api/organizations/{tenant['org_id']}/delivery/production/deploy",
json={"project_id": tenant["project_id"], "confirm": "wrong-slug"},
headers=tenant["headers"],
)
assert response.status_code == 422, response.text
assert "تأییدیه" in response.json()["error"]["message"]
async def test_the_target_summary_never_returns_the_secret(client: AsyncClient, tenant):
response = await client.get(
f"/api/organizations/{tenant['org_id']}/delivery/production",
params={"project_id": tenant["project_id"]},
headers=tenant["headers"],
)
assert response.status_code == 200, response.text
body = response.json()
assert body["target"]["has_secret"] is True
assert SECRET not in response.text
assert "secret" not in set(body["target"]), "the raw secret column must not be exposed"
async def test_deploying_without_a_target_is_refused(client: AsyncClient, tenant, session_factory):
async with session_factory() as session:
target = await DeployTargetRepository(session).get_for_project(tenant["project_id"])
assert target is not None
target.is_active = False
await session.commit()
response = await client.post(
f"/api/organizations/{tenant['org_id']}/delivery/production/deploy",
json={"project_id": tenant["project_id"], "confirm": tenant["slug"]},
headers=tenant["headers"],
)
assert response.status_code == 422, response.text
assert "مقصد" in response.json()["error"]["message"]
+146
View File
@@ -0,0 +1,146 @@
# Agent 3 — Phase 5 report: production deploy (SSH / SFTP / FTP / FTPS)
Date: 2026-10-14 · Branch: `dev`
## What was done
### 5.1 `adapters/deploy_ssh.py` — `SshDeployer`
SSH and SFTP share one implementation (both are asyncssh; the difference is only which
subsystem is used).
* **`test_connection`** — SSRF check first, then connect (15 s), capture the host key
fingerprint, and probe that `remote_path` exists and is writable.
* **Host key TOFU** — the first successful test records the fingerprint; every later
connection must match it. A mismatch is a hard failure ("کلید میزبان تغییر کرده است"),
because it is either a rebuilt server or an interception and only a human can tell.
* **`deploy`** — upload the artifact to `remote_path/releases/<timestamp>-<sha7>/`,
extract, run `post_deploy_command` with `cwd` there (600 s, streamed), then switch the
`current` symlink atomically (`ln -sfn` to a relative target + `mv -T` on the same
filesystem), then prune to the newest 5.
* **`rollback`** — re-point `current` at the previous release (`ls -1t` is newest-first,
so it is the second entry) and re-run the hook. A rollback that rebuilt would not be a
rollback.
**A failed deploy never moves `current`.** If the extraction or the post-deploy command
fails, the function returns before the switch, so the previous release keeps serving —
the same property the demo pipeline has, for the same reason.
The remote scripts are built from constants, never from user input, and every argument is
`shlex.quote`d.
### 5.2 `adapters/deploy_ftp.py` — `FtpDeployer`
* **`test_connection`** — SSRF check, login (explicit TLS for `ftps`), `cwd` the target
path, write and delete a probe file.
* **`deploy`** — **backs up first, always**: every file about to be overwritten is
downloaded into `ARTIFACTS_ROOT/<org>/<project>/ftp-backup-<ts>-<sha7>.tar.gz` before
anything is uploaded. The artifact is extracted to a scratch directory first, then the
deployable build is chosen (`dist/`, `build/`, `public/`, `out/`, else the root) — a
static deploy must not publish the repository.
* **`rollback`** — re-upload that archive.
FTP's limits are documented rather than hidden: no atomic switch, no host key to pin, and
without the backup there is nothing to undo. `rollback_production` refuses with a plain
message in that case rather than guessing.
**Archive safety.** `_safe_member` sanitises every tar member before extraction: absolute
names and `..` are refused, as are symlink/hardlink members whose target escapes. An
uploaded artifact is not trusted, and `filter="data"` alone is not enough (it does not
skip the archive's own root entry, which is the member that broke extraction here).
### 5.3 `adapters/deploy_target.py` — `DispatchingDeployTargetTester`
Routes `test_connection` on `spec.kind` and exposes `deployer_for(kind)`. The wizard sees
one shape; the differences between SSH and FTP stay inside the deployers.
### 5.4 `services/deploy_service.py` + endpoints
`production_deploys` table (migration `a3b4c5d6e7f4`, chained off this workstream's own
head so the three branches still merge cleanly).
| Method | Path | Role |
|---|---|---|
| `GET` | `/delivery/production` | admin+ — target summary + deploy history |
| `POST` | `/delivery/production/test` | admin+ — test the target, record the result |
| `POST` | `/delivery/production/deploy` | org owner/admin, slug confirmed, audited |
| `POST` | `/delivery/production/rollback` | org owner/admin, slug confirmed, audited |
**One production deploy per project at a time.** Two concurrent deploys to the same target
interleave their writes on a shared host — over SSH merely confusing, over FTP how a site
ends up in a state nobody can describe.
**There is no code path from a pipeline run into this service.** The engine's furthest
reach is "merged and pushed to `dev`"; production needs a human, a typed confirmation and
an audit row. That is D13, and it is enforced by the module boundary, not by a flag.
The target summary returns `has_secret: true`, never the secret. `_spec_from_target` is the
only place a secret is decrypted, and the plaintext lives for the duration of one call.
## Files changed
| File | Change |
|---|---|
| `app/infrastructure/adapters/deploy_ssh.py` | new — `SshDeployer`, `AsyncSshTransport` |
| `app/infrastructure/adapters/deploy_ftp.py` | new — `FtpDeployer`, `AsyncFtpTransport`, safe extraction |
| `app/infrastructure/adapters/deploy_target.py` | new — the dispatching tester |
| `app/application/services/deploy_service.py` | new — the deploy use cases |
| `app/domain/delivery/models.py` | `DeployStatus`, `ProductionDeploy` |
| `app/infrastructure/db/models_delivery.py` | `ProductionDeployRow` |
| `alembic/versions/a3b4c5d6e7f4_production_deploys.py` | new migration |
| `app/infrastructure/repositories/delivery.py` | `ProductionDeployRepository` |
| `app/infrastructure/repositories/projects.py` | `DeployTargetRepository` |
| `app/application/container_delivery.py`, `container.py`, `deps.py` | wiring |
| `app/interfaces/http/routers/delivery.py` | 4 production routes |
| `tests/test_deploy.py` | new — 26 tests |
## Test output
```
apps/api: 499 passed, 1 skipped (was 473; +26)
ruff check . -> All checks passed!
mypy app -> Success: no issues found in 136 source files
alembic: upgrade head / check -> clean
```
## Deviations from the TODO
1. **`post_deploy_command` is a deploy parameter, not part of `DeployTargetSpec`.**
`ports/deploy_target.py` is a frozen contract and the spec has no such field; the
service that owns the `deploy_targets` row passes it explicitly instead. No contract
change.
2. **`filter="data"` replaced by `_safe_member`.** The stdlib filter does not skip the
archive's root member, which made extraction fail; the custom filter is stricter
(it also refuses escaping link targets) and is the honest implementation of "an
uploaded artifact is not trusted".
3. **`a3b4c5d6e7f4` is a second migration for this workstream.** The plan allows it
("more only if chained off your own"); chaining off `a3b4c5d6e7f3` keeps the merge with
Agent 1 and Agent 2 trivial.
4. **The `artifact` for FTP is extracted before the build directory is chosen.** The
artifact is a tar.gz of the whole release, not a directory; the TODO's "the build dir
is set by the framework catalog default or `dist/`, `build/`, `public/`" describes the
choice, not the container.
## Open questions / CHANGE REQUESTs
- **CHANGE REQUEST (for integration):** `DeployTargetSpec` would benefit from
`post_deploy_command` and `organization_id`/`project_id`. Both are currently passed as
extra keyword arguments, which works but means the spec is not the whole story of a
deploy. No change is needed for this phase to be correct; noting it so the contract can
be widened deliberately at integration rather than by accident.
- **CHANGE REQUEST (for integration):** `build_deploy_target_tester` now returns the real
`DispatchingDeployTargetTester`, so Agent 1's wizard will attempt a real connection.
Its tests must inject a fake tester, as `tests/test_deploy.py` does.
## Known limitations
- `AsyncSshTransport` passes `known_hosts=None` and enforces the pinned key by comparing
the fingerprint after connecting. That is TOFU, but it means the first byte of the
handshake is accepted before the check; a pre-seeded `known_hosts` would be stricter.
The fingerprint check happens before any command runs or any file is uploaded.
- FTPS does not verify the server certificate (`CERT_NONE`): a shared host's FTP
certificate is rarely matchable to the domain the operator typed. This is documented in
the adapter and stated in the test message the operator sees.
- No `@pytest.mark.deploy_it` integration test against a real sshd/pure-ftpd yet; the fake
transports cover the mechanics, and a live round-trip needs infrastructure the repo
cannot assume.