# TODO — Agent 3: Delivery & Infra (docker sandbox, git workspaces, live CI/CD, demo server, Caddy, SSH/FTP) - **Worktree:** `/home/mamad/work/smart-org-wt/agent3` · **Branch:** `agent3/delivery-infra` (already created, already installed) - **Read first:** `docs/plan/PLAN-v2.md` (all of it, especially D5–D9). Then read fully: `apps/api/app/application/ports/sandbox_provider.py`, `git_provider.py`, `domain_router.py`, `realtime.py`, `app/infrastructure/adapters/registry.py`, `mock.py`, `caddy.py`, `gitea.py`, `sandbox_remote.py`, `app/application/services/sandbox_service.py`, `project_service.py`, `app/domain/platform/rules.py`, `docs/current-infra-notes.md`, `tests/test_project_repo.py`, `tests/test_domain_isolation.py`. - **Reference (concepts only, NEVER import, never edit):** `/projects/agybot/caddy_manager.py`, `port_registry.py`, `ssh_manager.py`, `ftp_manager.py`, `git_manager.py`. - **Your files:** only the "Agent 3" column of PLAN-v2 §6 (including ALL of `app/infrastructure/adapters/`), your block in `config.py` / `.env.example`, and the migration `alembic/versions/a3b4c5d6e7f3_delivery_infra.py` (`down_revision = "a0f0e1d2c3b4"`). New tables go in `app/infrastructure/db/models_delivery.py`. **`db/models.py` is locked**, so extra data about existing sandbox tables goes into new side tables. - **Never touch:** the locked files and the other agents' files. Need something there? → `CHANGE REQUEST` in your report. - **Do NOT commit, push, merge or rebase.** Never `cd` outside the worktree, never write to `/tmp`: tests use pytest's `tmp_path` (allowed) and the worktree's `./_scratch/`. - **After each phase:** run all checks (PLAN-v2 §7.4), write `docs/plan/reports/agent-3-phase-.md`, then STOP and wait. You implement: `RepoWorkspacePort`, `DeliveryHooks`, `DeployTargetTester`, plus a docker `SandboxProviderPort` adapter. Wire them in `container_delivery.py` and `registry.py` behind settings (mock remains the default for tests). **Security rules specific to you** - Nothing runs on the host except: `git` (via the workspace adapter, hardened per D6), the docker/podman CLI, and Caddy reload (existing adapter). - Every subprocess: `asyncio.create_subprocess_exec` (never `shell=True`), `start_new_session=True`, kill the process group on timeout, drain stdout and stderr concurrently (avoid the 64 KB pipe deadlock), and pass explicit `env` (never inherit secrets). - Credentials never appear in argv, URLs, logs, exceptions, realtime events or DB columns other than `*_encrypted`. Use `GIT_ASKPASS` with a 0700 helper script that reads a 0600 temp file in the worktree's `./_scratch`-like private dir under `SANDBOX_WORKSPACES_ROOT`, deleted in `finally`. - Ports and hosts must pass `app/domain/platform/rules.py` (protected ports, single-label hosts, localhost targets). --- ## Phase 1 — Docker sandbox adapter - [ ] 1.1 `adapters/docker_sandbox.py`, `DockerSandboxAdapter` implementing `SandboxProviderPort` with the CLI in `SANDBOX_DOCKER_BIN` (docker or podman, the same flags): - `create_workspace`: creates a host dir `SANDBOX_WORKSPACES_ROOT///work` (0700, owned by the platform user). - `start_session`: `docker run -d` the **workspace container**: - image from the app profile language: `node:20-bookworm`, `python:3.13-bookworm`, `php:8.3-cli`, `golang:1.23`, fallback `debian:bookworm`; make the mapping a dict in your config block / domain - `--user 1000:1000 --cap-drop ALL --security-opt no-new-privileges --pids-limit 512 --cpus $SANDBOX_DEFAULT_CPUS --memory ${MB}m` - `--read-only` with `--tmpfs /tmp` and `--tmpfs /home/app` - bind-mount **only the work dir** (never the git dir) to `/workspace` - label `smart-org.org=`, `smart-org.project=`, `smart-org.role=workspace` - network: the default bridge (package installs need egress), with no published ports - `run_command`: `docker exec -w sh -lc `, with timeout, output cap, exit code, and an optional async `on_output(chunk)` callback for streaming (add it as an adapter-level extra method; the port stays unchanged). - `read_file` / `write_file`: through the host bind mount with path safety (`resolve()` stays inside the work dir; reject symlinks that escape). - `pause` / `resume` / `destroy`: stop/start/rm. `snapshot`: `docker commit` (tagged per org) or raise a clear "not supported" error, your choice, documented. - `get_preview_url`: delegated to the domain layer (Phase 4). - [ ] 1.2 `registry.py`: `SANDBOX_PROVIDER=docker` → this adapter. The mock stays the default. - [ ] 1.3 The `sandbox_gateway` worker: also reap containers with the `smart-org.*` label that have no live session row (orphans), audited. - [ ] 1.4 Hide infra from non-owners: - `sandbox.py` command/file endpoints become **owner-only** (or admin+ of the org with a setting off by default; prefer owner-only). - Keep the read-only status endpoints for org members. - Update `components/sandbox-panel.tsx` to show only status to non-owners. - Update existing tests that assumed member access, and explain each change in your report. - [ ] 1.5 Tests `tests/test_docker_sandbox.py`: unit tests with a **fake process runner** (inject the subprocess function) that assert the exact argv (hardening flags present, no secrets, mount of the work dir only), timeout kill, output cap, path escape refused. Integration tests marked `@pytest.mark.docker`, skipped unless `SANDBOX_DOCKER_IT=1`: real start → exec `echo` → write/read file → destroy. --- ## Phase 2 — Git workspaces (`RepoWorkspacePort`, the platform does git, D6) - [ ] 2.1 `adapters/git_workspace.py`, `LocalGitWorkspaceAdapter`. Layout per project under `SANDBOX_WORKSPACES_ROOT///`: - `git/`: a bare-ish gitdir, **never mounted** - `work/`: the main working tree for the dev branch, mounted at `/workspace` - `work/.worktrees//`: task worktrees, visible in the container at `/workspace/.worktrees/` - `WorktreeRef.sandbox_path` = that in-container path Note: the `.worktrees` dir must be excluded via `info/exclude` in the gitdir, not the user's `.gitignore`. - [ ] 2.2 Every git call is `git --git-dir= --work-tree= -c core.hooksPath=/dev/null -c core.fsmonitor=false -c core.quotePath=false -c protocol.file.allow=never -c safe.directory=*`, with env `GIT_CONFIG_NOSYSTEM=1`, `HOME=`, `GIT_TERMINAL_PROMPT=0`. Never trust the `.git` *file* inside a worktree; always pass explicit dirs. - Status: `status --porcelain=v1 -uall -z`. - One `asyncio.Lock` per project id held for every mutating operation (agybot M14). Remove a stale `index.lock` only if older than 30s. - [ ] 2.3 Remotes: - **platform** git: the URL and credentials come from the existing Gitea adapter / `project_git_credentials` (decrypt via the existing crypto helper). - **external** git: `project_external_git` (token → `GIT_ASKPASS`; ssh_key → temp key file 0600 + `GIT_SSH_COMMAND="ssh -i -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes -o UserKnownHostsFile="`; store `known_hosts` on the first `test_remote` = TOFU, in the existing column). - `test_remote`: `git ls-remote --heads` with a 20s timeout; returns ok, default branch, branch list, and a friendly error (auth failed / not found / unreachable) **without echoing the URL credentials or stderr secrets**. - SSRF check on external hosts (resolve DNS, reject private, loopback and link-local unless `DEPLOY_ALLOW_PRIVATE_HOSTS`); put the check in `domain/platform/rules.py` (yours). It runs inside `test_remote` and before every deploy connection; Agent 1 only does format validation and relies on your check. - [ ] 2.4 Implement every `RepoWorkspacePort` method per PLAN-v2 §5. - `ensure_workspace` clones on first use, fetches afterwards. It creates `dev` from the default branch if missing; for platform repos also push `dev`. - `merge_into` does a `--no-ff` merge in the main work tree under the lock and returns the conflict file list on failure, after `merge --abort`. Never leave a half-merge (agybot H3). - `push` returns ok/rejected (non-fast-forward → fetch + report, never force). - [ ] 2.5 Wire it in `container_delivery.py` (setting `GIT_WORKSPACE=local|fake`, fake default in tests). - [ ] 2.6 Tests `tests/test_git_workspace.py` with **real git** and a local bare repo in `tmp_path` as "remote" (no network): - the full flow ensure → worktree → write → diff (new dir appears thanks to `-uall`) → commit → sync → merge → push → head - a conflict path leaves a clean tree - hooks are NOT executed (plant a `post-commit` hook that writes a file and assert it is absent) - a `.git` file tampered to point elsewhere is ignored - credentials are never in argv (capture the runner calls) - concurrent merges are serialised --- ## Phase 3 — Live CI/CD + demo server - [ ] 3.1 Tables (`models_delivery.py`): - `pipeline_runs` (id, org, project, trigger `engine|poll|manual`, branch, sha, status `queued|running|succeeded|failed|cancelled|superseded`, started/finished, error_summary) - `pipeline_steps` (id, run_id, index, name, status, started/finished, exit_code, log_tail ≤ 64 KB) - `releases` (id, org, project, run_id, sha, artifact_path, created_at, is_demo_live) - `app_runtimes` (project_id PK, demo_container_id, host_port, demo_host, status, current_release_id, updated_at) - [ ] 3.2 `delivery_service.py` implementing `DeliveryHooks`: - `on_app_created`: allocate a demo port in `DEMO_PORT_START..END` (reuse the existing preview port rules), claim the demo host `.` through the existing `DomainRouterPort`, create `app_runtimes`, `ensure_workspace`, and queue a first pipeline run if the repo has a `dev` head. - `on_branch_pushed`: queue a run (one active run per project; a newer queued run supersedes an older queued one). - [ ] 3.3 `workers/delivery.py`: loop (`--loop`) that executes queued runs and polls remotes every `DELIVERY_POLL_SECONDS` (`head` vs the last run sha) for pushes made outside the engine (users pushing to their own git). - Steps, each in a **fresh build container** (same image mapping and hardening, work dir = a clean checkout of the sha in a per-run dir): 1. `checkout` 2. `setup` (setup_command) 3. `build` (build_command, skipped if empty) 4. `test` (test_command, skipped if empty) 5. `package` (tar.gz of the run dir excluding `.git` and `node_modules` → `ARTIFACTS_ROOT///.tar.gz`) 6. `deploy_demo`: start the new demo container with `start_command`, port mapping `127.0.0.1::`, the same hardening, label `role=demo` 7. `healthcheck`: HTTP GET `http://127.0.0.1:` with 20 retries × 3s; any 2xx/3xx = ok 8. `route`: `configure_reverse_proxy` for the demo host → `127.0.0.1:` - **Blue/green:** start the new container on a temporary second port, healthcheck it, then switch the Caddy route, then stop the old one. If any step fails, the old demo keeps running untouched and the run is `failed` with a Persian `error_summary`. - [ ] 3.4 Live logs: every step streams output via the `on_output` callback → batched `pipeline.log` events on `org:` (≤ 1 event / 500 ms, ≤ 200 lines), plus `pipeline.step` and `pipeline.status`; `release.live` when switched. Store the step `log_tail` (last 64 KB). Pass all output through `app/core/redact.py` before publishing or storing. - [ ] 3.5 Router `delivery.py` (org member read, admin+ actions): list runs, run detail with steps and tails, `POST /delivery/runs` (manual re-run of the dev head, admin+), cancel the queued/running run, list releases, rollback the demo to a previous release (admin+, confirm, audit), and the runtime status (demo URL, live release, health). - [ ] 3.6 Tests `tests/test_delivery.py` with the fake runner + `FakeRepoWorkspace` + mock domain router: - the happy path step sequence and events - a failed build keeps the old demo - healthcheck failure → no route switch - supersede logic - poll detects an external push - logs redacted (plant a secret in the build output) - tenant isolation on every endpoint - an optional `@pytest.mark.docker` end-to-end with a tiny static app (`python -m http.server`) --- ## Phase 4 — Domains (Caddy) owned by the platform owner - [ ] 4.1 `admin_domains.py` (owner only): - list all routes (host → org/app → target port → status) - attach an extra host to an org's app: a single label under the root via the existing rules, OR a full custom FQDN when `ALLOW_CUSTOM_DOMAINS=true` (strict FQDN regex, no wildcard, no protected hosts such as `git`, `mcp`, `panel`, `api`, root) - detach (confirm + audit) - re-sync all routes from the DB to Caddy (idempotent) Users cannot add domains (the existing member-facing preview-domain endpoints in `projects.py` become owner-only, and the tests are updated with reasons). - [ ] 4.2 The Caddy adapter keeps validate → revert → reload. Add a `routes_fingerprint` check so re-sync does nothing when nothing changed. - [ ] 4.3 The demo host is created automatically in `on_app_created` (Phase 3). Record the MCP gateway host suggestion (`mcp.` → `MCP_GATEWAY_PORT`) as a protected system route that the owner can enable from this page. - [ ] 4.4 Tests `tests/test_admin_domains.py`: non-owner forbidden; protected hosts refused; invalid FQDNs refused; detach audited; re-sync idempotent (mock router call count). --- ## Phase 5 — Production deploy over SSH / SFTP / FTP / FTPS (always human-triggered) - [ ] 5.1 `adapters/deploy_ssh.py` (asyncssh), `DeployTargetTester` + `SshDeployer`: - Test: connect (15s timeout) → host-key TOFU (store the fingerprint on the first success; afterwards a mismatch = hard fail "کلید میزبان تغییر کرده"), check `remote_path` exists and is writable. - Deploy: upload the release artifact → `remote_path/releases/-/` → extract → run `post_deploy_command` with `cwd` there (timeout 600s, streamed output) → atomically switch the `remote_path/current` symlink → keep the last 5 releases. - Rollback = re-point `current` to the previous release + rerun post_deploy. - [ ] 5.2 `adapters/deploy_ftp.py` (aioftp; FTPS via explicit TLS), `DeployTargetTester` + `FtpDeployer`: - Test: login, cwd `remote_path`, write and delete a probe file. - Deploy: upload the build output (the build dir is set by the framework catalog default or `dist/`, `build/`, `public/`, else the whole artifact) into `remote_path` after downloading a **backup listing** of the files it overwrites into `ARTIFACTS_ROOT/.../ftp-backup-.tar.gz`. - Rollback = re-upload that backup. Document the limits of FTP (no atomic switch). - [ ] 5.3 SSRF check for every target host (the Phase 2.3 helper). A combined `DeployTargetTester` dispatching on `kind`, wired in `container_delivery.py`. - [ ] 5.4 `deploy_service.py` + endpoints in `delivery.py`: - `POST /delivery/production/deploy` with `release_id` (default: the live demo release). Org **owner/admin only**; requires a `confirm: ""` field; one production deploy per project at a time. - Status: `deploy.status` events and a `production_deploys` table (id, release, target, status, started/finished, log_tail, rolled_back_from). - `POST /delivery/production/rollback`. - Every action audited. Credentials decrypted only inside the adapter call. - [ ] 5.5 Tests `tests/test_deploy.py`: fake SSH/FTP clients injected. Cover: the release/symlink sequence, keep-5 pruning, rollback, host-key mismatch refused, SSRF refused, a member (non-admin) refused, the confirm slug mismatch refused, no password in logs/events/DB columns other than encrypted ones. Optional integration (`DEPLOY_IT=1`) against a local `sshd`/`pure-ftpd` container. --- ## Phase 6 — UI: the "Application" page (replaces `/projects`) - [ ] 6.1 `(app)/app/page.tsx` (+ components under `(app)/app/_components/`): - **Header:** app name, type/language/framework badges, demo URL (open button), live release sha + time, health dot. - **Pipeline runs:** list with status; clicking one opens the run view: a step list with status and duration, and a **live log console** (subscribe `org:`, filter by run id, ANSI colours stripped, auto-scroll with pause, and a "copy" button). - **Releases:** history; "rollback demo" (admin+, confirm). - **Production:** target summary (kind, host, path, last test result; no secrets), the "استقرار در production" button (admin+, modal requiring the org slug), a deploy live log, production history + rollback. - **Git:** repo link (platform: Gitea URL; external: the URL without credentials), `dev` head sha, last poll time. No branch/PR/port controls for users. - [ ] 6.2 Remove `(app)/projects` (redirect `/projects*` → `/app`) and drop the member-facing port/branch/PR/command controls. Owner-only debug tools move to `/admin/sandbox`. - [ ] 6.3 `/admin/sandbox` (owner): running containers per org (workspace/demo/build), CPU/memory limits per org (existing `sandbox_resource_limits`), stop/restart a container (confirm + audit), and an image mapping view. `/admin/domains` (owner): the Phase 4 UI. - [ ] 6.4 `lib/api-delivery.ts`: typed client for all of the above. - [ ] 6.5 Web tests `src/test/delivery-*.test.tsx`: the log console batching and auto-scroll, the confirm-slug modal, no secret fields rendered, non-admin sees no deploy button. --- ## Final report (after Phase 6) `docs/plan/reports/agent-3-final.md`: summary, host requirements (docker/podman, Caddy, dirs and permissions, the sudo/systemd needs of the Caddy reload), every setting, every endpoint added or changed (method + path + role), tables, CHANGE REQUESTs still open, manual QA script (wizard app → push → watch live pipeline → open demo URL → deploy to a local SSH test server → rollback), known limitations.