Repository navigation
Add a restricted deploy gate and one deploy script for every droplet - #612
Conversation
This comment has been minimized.
This comment has been minimized.
CI deploys by logging in to each droplet as root with a key held in a repository secret, and sends the whole deploy script inline, in three copies that have already drifted. A stolen key is a root shell. deploy/gate.sh is the one command a CI key will be authorised to run, as a forced command beside restrict. It accepts four verbs from SSH_ORIGINAL_COMMAND and refuses anything else, character by character: deploy, rollback, probe-towers and status. deploy fetches refs/heads/main from GitHub itself and runs deploy/deploy.sh as it stands in that commit, with a clean environment, so nothing it executes comes from the caller. The ref is spelt out because a bare name also matches a tag. On test it takes a branch, fetched as refs/heads/<branch> and nothing else, so a pull request's ref is never fetched; that key can therefore run any pushed branch as root on test. probe-towers asks the contract in main's tip, which is what the next deploy ships. Deploys and rollbacks queue on a lock on the box, and git is kept from detaching maintenance that would inherit it. Their output reaches the caller through a relay that also logs it on the box and outlives the caller, so a dropped connection cannot kill a deploy or rollback mid-step; a caller that gave up while its call waited for the lock stops it before it acts. The gate's state is kept out of the Docker build context, since its log is written during the build. The rollback decision now lives in one place, and the caller says which of its jobs failed. `rollback <run> deploy` acts on a marker naming the run, as the inline rollbacks do, and never on a finished deploy, so a re-run whose deploy refuses in a pre-flight cannot undo the healthy one before it. When that finished deploy is still what runs, it refuses loudly rather than report nothing to do: the caller may have gone away while the deploy carried on, and then its checks never ran. `rollback <run> checks` acts on the last deploy record, written once a deploy is healthy, when it names the run, is still what is checked out and finished within 90 minutes; past that it refuses loudly rather than leave a broken deploy up behind a green step. Any other run's marker, or an empty one, is refused, and a deploy some later run has replaced is left to that run's checks, so a late rollback can no longer undo a later run's deploy. A rollback marks the box while it runs and a failed one leaves the mark, so the next deploy refuses rather than snapshot a half-restored box; the same run can resume it. deploy/deploy.sh takes the three inline copies' shared sequence, including their wait on deploy/wait-for-health.sh, and their per-environment differences: production's JWT_SECRET check, the radar-key check on production and staging, the origin-pull CA on both, staging's fleet-container cleanup, and test's retina-edge network and injected failures. The record of the last deploy is dropped before pre-deploy.sh retakes the rollback point, and a new one is written before the marker goes, so a deploy that dies between the two is still rolled back. Housekeeping after a healthy deploy, including refreshing the gate from main, only warns, so it cannot fail a good deploy into a rollback. tower-contract.sh gains the retina-edge probe the staging job runs inline, pinned by digest, bounded on connect as the public probes are, and free of pipes that pipefail would misread. setup-server.sh installs main's gate, whatever the tree on the box holds, and only for /opt/retina-server, the one directory it serves. The workflows do not use any of this yet. Each droplet switches in its own change, once its gate and key are in place. A shellcheck hook now covers every shell script at warning level, and ONBOARDING's list of what the hook runs says so. test_deploy_scripts.py runs the gate, the deploy and setup-server.sh's install of the gate for real against a scratch clone, with stand-ins for hostname, docker, df, openssl, flock and sleep, and for the scripts with tests of their own. Breaking the scripts in each of 45 ways, one at a time, fails the tests that pin them. ClickUp 123zgec4n1y. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
0e5c03d to
3279e17
Compare
|
Rebased onto main at 14c63f8 and reviewed again. The description is left as it was, so here is what has changed since it was written:
|
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
ONBOARDING's list of pre-commit hooks gained shellcheck here and the node API contract check on main; keep both. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Claude finished @jehanazad's task in 14m 40s —— View job Code reviewTodo list
SummaryThis is the third review pass on this PR (two earlier passes found no blocking issues). I re-traced A few narrow, non-blocking gaps worth a look:
Everything else the parallel agents raised turned out to be either already called out explicitly in the PR's own "Known limits" (the 90-minute rollback window and 15-minute lock-wait replacing the old workflows' unbounded waits; the self-installing gate's trust model), the already-acknowledged temporary duplication with the still-live inline Sandbox limitation: |
ClickUp: 1.6 Deploy through a restricted SSH gate with one on-box deploy.sh
Why
CI deploys by logging in to each droplet as root with a key from a repository secret, and it sends the whole deploy script inline. There are three copies of that script (
ci.yml,staging-deploy-verify.ymlanddeploy-test.yml), and they have already drifted. A stolen key is a root shell.This PR adds the on-box half. The workflows are unchanged. Each droplet switches to
ssh <alias> <verb>in its own later PR, once its gate is installed and its new key is authorised: test first, then staging, then production.What changes
deploy/gate.sh, installed as/usr/local/sbin/retina-deploy-gateThis is the only command a CI key will be authorised to run, as
restrict,command="/usr/local/sbin/retina-deploy-gate retina-server".SSH_ORIGINAL_COMMANDand acceptsdeploy,rollback,probe-towersandstatus. Anything else is refused.._/@+-and spaces is refused before parsing. The locale is pinned, so a client'sLC_*cannot widen that set.deploy <run-id>fetchesrefs/heads/mainitself and runsdeploy/deploy.shas it stands in that commit, with a clean environment. Nothing it runs comes from the caller. The ref is spelt out because a baremainalso matches a tag.deploy <run-id> <branch> <fail-at>is the form on test. It fetchesrefs/heads/<branch>and nothing else, so a pull request's ref is never fetched. The test key can therefore run any pushed branch as root on test, and the header says so. A branch deploy never replaces the gate.probe-towersasks the contract in main's tip, which is what the next deploy ships, not the contract in the tree the box is running. It fetches into a ref of its own..deploy-gate.logon the box and outlives the caller. A dropped connection therefore cannot kill a deploy or rollback mid-step.One rollback decision
The caller now says which of its jobs failed.
rollback <run> deploy(the deploy failed or was cancelled) acts only on a marker naming the run, as the inline rollbacks do. It never acts on a finished deploy, so a re-run whose deploy refuses in a pre-flight cannot undo the healthy deploy before it.rollback <run> checks(smoke tests or E2E failed) acts on the last deploy record.deploy.shwrites that record once a deploy is healthy. It rolls back when the record names the run, still matches what is checked out, and finished within 90 minutes. Past 90 minutes it refuses loudly, so a broken deploy is not left up behind a green step. A deploy that some later run has replaced is left to that run's own checks.deploy/deploy.shretina-edgenetwork and injected failures.pre-deploy.shretakes the rollback point.Supporting changes
tower-contract.shgainsassert_tower_contract_over_edge, the probetower-service-contractcurrently runs inline on staging. Its curl image is pinned by digest.setup-server.shinstalls main's copy of the gate, whatever tree the box holds. Only a failure there is a warning; it doesn't stop provisioning..gitignoreand.dockerignoregain the gate's state on the box.Known limits
deployships main's tip at fetch time, as the inline deploys do today, not the commit the calling run tested.deploy.shruns the checked-out tree'spre-deploy.shbefore it moves the tree, as today.ci.ymlkeeps its inline copies of the deploy and of the edge probe, alongside the new scripts.Verification
backend/tests/test_deploy_scripts.py(88 tests) runs the real gate and deploy against a scratch clone. Stand-in commands replacehostname,docker,df,openssl,flockandsleep, and the tests pass under bash 3.2 and 5. They cover:test_rollback_script.pynow also holdsdeploy.sh's health wait torollback.sh's.deploy-test.ymlwith eachfail_at; on staging and production, the next real merge.🤖 Generated with Claude Code