Files
mjalmoudy94 72408a0a4f 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
2026-10-14 01:16:23 +03:30

7.9 KiB

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.quoted.

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.