Engine honours org policy and monthly budget, legacy runner skips engine tasks, sandbox session provisioned on demand, project fallback for threads, reviewer fallback, tenant checks on project ids, memory reflection respects grants, expiry worker, MCP pipeline_status, login redirect, sharing settings, e2e smoke. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
19 KiB
19 KiB
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 inconfig.py/.env.example, and the migrationalembic/versions/a3b4c5d6e7f3_delivery_infra.py(down_revision = "a0f0e1d2c3b4"). New tables go inapp/infrastructure/db/models_delivery.py.db/models.pyis 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 REQUESTin your report. - Do NOT commit, push, merge or rebase. Never
cdoutside the worktree, never write to/tmp: tests use pytest'stmp_path(allowed) and the worktree's./_scratch/. - After each phase: run all checks (PLAN-v2 §7.4), write
docs/plan/reports/agent-3-phase-<K>.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(nevershell=True),start_new_session=True, kill the process group on timeout, drain stdout and stderr concurrently (avoid the 64 KB pipe deadlock), and pass explicitenv(never inherit secrets). - Credentials never appear in argv, URLs, logs, exceptions, realtime events or DB columns other than
*_encrypted. UseGIT_ASKPASSwith a 0700 helper script that reads a 0600 temp file in the worktree's./_scratch-like private dir underSANDBOX_WORKSPACES_ROOT, deleted infinally. - 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,DockerSandboxAdapterimplementingSandboxProviderPortwith the CLI inSANDBOX_DOCKER_BIN(docker or podman, the same flags):create_workspace: creates a host dirSANDBOX_WORKSPACES_ROOT/<org_id>/<project_id>/work(0700, owned by the platform user).start_session:docker run -dthe workspace container:- image from the app profile language:
node:20-bookworm,python:3.13-bookworm,php:8.3-cli,golang:1.23, fallbackdebian: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-onlywith--tmpfs /tmpand--tmpfs /home/app- bind-mount only the work dir (never the git dir) to
/workspace - label
smart-org.org=<id>,smart-org.project=<id>,smart-org.role=workspace - network: the default bridge (package installs need egress), with no published ports
- image from the app profile language:
run_command:docker exec -w <cwd> <ctr> sh -lc <cmd>, with timeout, output cap, exit code, and an optional asyncon_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_gatewayworker: also reap containers with thesmart-org.*label that have no live session row (orphans), audited. - 1.4 Hide infra from non-owners:
sandbox.pycommand/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.tsxto 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 unlessSANDBOX_DOCKER_IT=1: real start → exececho→ write/read file → destroy. — open: no@pytest.mark.dockersandbox integration test (real start, exec, write/read, destroy) exists in tests/test_docker_sandbox.py; the only docker-marked test is test_delivery.py::test_docker_end_to_end_static_app.
Phase 2 — Git workspaces (RepoWorkspacePort, the platform does git, D6)
- 2.1
adapters/git_workspace.py,LocalGitWorkspaceAdapter. Layout per project underSANDBOX_WORKSPACES_ROOT/<org>/<project>/:git/: a bare-ish gitdir, never mountedwork/: the main working tree for the dev branch, mounted at/workspacework/.worktrees/<task_id>/: task worktrees, visible in the container at/workspace/.worktrees/<task_id>WorktreeRef.sandbox_path= that in-container path Note: the.worktreesdir must be excluded viainfo/excludein the gitdir, not the user's.gitignore.
- 2.2 Every git call is
git --git-dir=<gitdir> --work-tree=<path> -c core.hooksPath=/dev/null -c core.fsmonitor=false -c core.quotePath=false -c protocol.file.allow=never -c safe.directory=*, with envGIT_CONFIG_NOSYSTEM=1,HOME=<private dir>,GIT_TERMINAL_PROMPT=0. Never trust the.gitfile inside a worktree; always pass explicit dirs.- Status:
status --porcelain=v1 -uall -z. - One
asyncio.Lockper project id held for every mutating operation (agybot M14). Remove a staleindex.lockonly if older than 30s.
- Status:
- 2.3 Remotes: — open: the ssh_key first contact uses StrictHostKeyChecking=accept-new and the key is never stored. test_remote returns no host key (RemoteCheck has no field for it) and nothing writes project_external_git.known_hosts.
- 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 <key> -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes -o UserKnownHostsFile=<per-project known_hosts>"; storeknown_hostson the firsttest_remote= TOFU, in the existing column). test_remote:git ls-remote --headswith 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 indomain/platform/rules.py(yours). It runs insidetest_remoteand before every deploy connection; Agent 1 only does format validation and relies on your check.
- platform git: the URL and credentials come from the existing Gitea adapter /
- 2.4 Implement every
RepoWorkspacePortmethod per PLAN-v2 §5.ensure_workspaceclones on first use, fetches afterwards. It createsdevfrom the default branch if missing; for platform repos also pushdev.merge_intodoes a--no-ffmerge in the main work tree under the lock and returns the conflict file list on failure, aftermerge --abort. Never leave a half-merge (agybot H3).pushreturns ok/rejected (non-fast-forward → fetch + report, never force).
- 2.5 Wire it in
container_delivery.py(settingGIT_WORKSPACE=local|fake, fake default in tests). - 2.6 Tests
tests/test_git_workspace.pywith real git and a local bare repo intmp_pathas "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-commithook that writes a file and assert it is absent) - a
.gitfile tampered to point elsewhere is ignored - credentials are never in argv (capture the runner calls)
- concurrent merges are serialised
- the full flow ensure → worktree → write → diff (new dir appears thanks to
Phase 3 — Live CI/CD + demo server
- 3.1 Tables (
models_delivery.py):pipeline_runs(id, org, project, triggerengine|poll|manual, branch, sha, statusqueued|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.pyimplementingDeliveryHooks:on_app_created: allocate a demo port inDEMO_PORT_START..END(reuse the existing preview port rules), claim the demo host<org-slug>.<PREVIEW_ROOT_DOMAIN>through the existingDomainRouterPort, createapp_runtimes,ensure_workspace, and queue a first pipeline run if the repo has adevhead.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 everyDELIVERY_POLL_SECONDS(headvs 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):
checkoutsetup(setup_command)build(build_command, skipped if empty)test(test_command, skipped if empty)package(tar.gz of the run dir excluding.gitandnode_modules→ARTIFACTS_ROOT/<org>/<project>/<sha>.tar.gz)deploy_demo: start the new demo container withstart_command, port mapping127.0.0.1:<host_port>:<app_port>, the same hardening, labelrole=demohealthcheck: HTTP GEThttp://127.0.0.1:<host_port><healthcheck_path>with 20 retries × 3s; any 2xx/3xx = okroute:configure_reverse_proxyfor the demo host →127.0.0.1:<host_port>
- 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
failedwith a Persianerror_summary.
- 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):
- 3.4 Live logs: every step streams output via the
on_outputcallback → batchedpipeline.logevents onorg:<id>(≤ 1 event / 500 ms, ≤ 200 lines), pluspipeline.stepandpipeline.status;release.livewhen switched. Store the steplog_tail(last 64 KB). Pass all output throughapp/core/redact.pybefore 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.pywith 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.dockerend-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 asgit,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.pybecome owner-only, and the tests are updated with reasons).
- 4.2 The Caddy adapter keeps validate → revert → reload. Add a
routes_fingerprintcheck 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.<root>→MCP_GATEWAY_PORT) as a protected system route that the owner can enable from this page. — open: the MCP route is only a read-only suggestion (GET /api/admin/domains/suggested, shown as enabled or disabled on the page). There is no enable action, and validate_extra_host refuses the protected label mcp, so the owner cannot enable it 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_pathexists and is writable. - Deploy: upload the release artifact →
remote_path/releases/<ts>-<sha7>/→ extract → runpost_deploy_commandwithcwdthere (timeout 600s, streamed output) → atomically switch theremote_path/currentsymlink → keep the last 5 releases. - Rollback = re-point
currentto the previous release + rerun post_deploy.
- Test: connect (15s timeout) → host-key TOFU (store the fingerprint on the first success; afterwards a mismatch = hard fail "کلید میزبان تغییر کرده"), check
- 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) intoremote_pathafter downloading a backup listing of the files it overwrites intoARTIFACTS_ROOT/.../ftp-backup-<ts>.tar.gz. - Rollback = re-upload that backup. Document the limits of FTP (no atomic switch).
- Test: login, cwd
- 5.3 SSRF check for every target host (the Phase 2.3 helper). A combined
DeployTargetTesterdispatching onkind, wired incontainer_delivery.py. - 5.4
deploy_service.py+ endpoints indelivery.py:POST /delivery/production/deploywithrelease_id(default: the live demo release). Org owner/admin only; requires aconfirm: "<org-slug>"field; one production deploy per project at a time.- Status:
deploy.statusevents and aproduction_deploystable (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 localsshd/pure-ftpdcontainer.
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:<id>, 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),
devhead 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 (existingsandbox_resource_limits), stop/restart a container (confirm + audit), and an image mapping view./admin/domains(owner): the Phase 4 UI. — open: no restart action. The backend and the page offer list and stop only (POST /api/admin/sandbox/containers/{id}/stop). - 6.4
lib/api-delivery.ts: typed client for all of the above. — open: /admin/sandbox calls apiRequest directly, and lib/api-delivery.ts has deliveryApi and domainsApi only, with no sandbox client. - 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. — open: no test for log batching or auto-scroll. The non-admin deploy-button case is covered byhides the deploy actions from an org member who is not an admin(apps/web/src/test/delivery-app.test.tsx, added in T14).
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.