Files
smart-org/docs/plan/reports/agent-3-phase-5.md
T
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

147 lines
7.9 KiB
Markdown

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