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
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 thatremote_pathexists 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 toremote_path/releases/<timestamp>-<sha7>/, extract, runpost_deploy_commandwithcwdthere (600 s, streamed), then switch thecurrentsymlink atomically (ln -sfnto a relative target +mv -Ton the same filesystem), then prune to the newest 5.rollback— re-pointcurrentat the previous release (ls -1tis 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 forftps),cwdthe target path, write and delete a probe file.deploy— backs up first, always: every file about to be overwritten is downloaded intoARTIFACTS_ROOT/<org>/<project>/ftp-backup-<ts>-<sha7>.tar.gzbefore 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
post_deploy_commandis a deploy parameter, not part ofDeployTargetSpec.ports/deploy_target.pyis a frozen contract and the spec has no such field; the service that owns thedeploy_targetsrow passes it explicitly instead. No contract change.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".a3b4c5d6e7f4is a second migration for this workstream. The plan allows it ("more only if chained off your own"); chaining offa3b4c5d6e7f3keeps the merge with Agent 1 and Agent 2 trivial.- The
artifactfor 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 ordist/,build/,public/" describes the choice, not the container.
Open questions / CHANGE REQUESTs
- CHANGE REQUEST (for integration):
DeployTargetSpecwould benefit frompost_deploy_commandandorganization_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_testernow returns the realDispatchingDeployTargetTester, so Agent 1's wizard will attempt a real connection. Its tests must inject a fake tester, astests/test_deploy.pydoes.
Known limitations
AsyncSshTransportpassesknown_hosts=Noneand 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-seededknown_hostswould 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_itintegration 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.