# 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/-/`, 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///ftp-backup--.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.