feat(delivery): production deploy over SSH/SFTP/FTP/FTPS (v2 phase A3-5)
The one path that touches a customer's server, and the one the platform cannot undo. It is human-triggered, confirmed and audited; there is deliberately no code path from a pipeline run into it (D13). - deploy_ssh.py: SshDeployer. Release lands in releases/<ts>-<sha7>/, the post-deploy command runs with cwd there, and only then does `current` move — atomically (relative symlink + mv -T). A failed hook or extraction never moves it, so the previous release keeps serving. Host key is pinned on first success (TOFU); a mismatch is a hard failure. Prunes to the newest five. - deploy_ftp.py: FtpDeployer. FTP has no atomic switch and no rollback except a backup, so a backup is mandatory: every file about to be overwritten is archived under ARTIFACTS_ROOT before anything is uploaded. The artifact is extracted, then the build dir (dist/build/public/out) is chosen — a static deploy must not publish the repository. Archive members are sanitised: absolute paths, `..` and escaping links are refused. - deploy_target.py: the tester that dispatches on `kind`. - deploy_service.py + production_deploys (migration a3b4c5d6e7f4) + 4 routes. One deploy per project at a time; the org slug must be typed back; the target summary reports has_secret, never the secret. Tests: 499 API (+26) with fake SSH/FTP transports; ruff, mypy, alembic clean. Report: docs/plan/reports/agent-3-phase-5.md
This commit is contained in:
@@ -0,0 +1,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.
|
||||
Reference in New Issue
Block a user