fix(cli): deploy fresh-host bootstrap — proxy start_or_run per host, kamal network, real setup() - #3162
Conversation
…kamal network, real setup() Wave 2a of the #2957 deploy-orchestration campaign: - DEP-5a: the proxy boot guard was details() || boot(), but details() (docker ps --filter) exits 0 whether or not the proxy exists, so boot() was unreachable and kamal-proxy never started on a fresh host. Replaced with Kamal's Proxy##start_or_run shape: docker start kamal-proxy || docker run ... (ProxyCommands.start_or_run). - DEP-5b: the guard was dispatched to ONE host via $dispatchAny; the proxy now boots on every proxy-fronted host via $dispatch/onEach. - DEP-5c: DockerCommands.create_network had zero call sites while every app/proxy/accessory docker run joins --network kamal. New ensure_network() (inspect probe || create, idempotent) is dispatched to all app hosts during deploy and to accessory hosts during setup. - setup() was literally 'return deploy(opts)'. It now runs a real setup phase: kamal network create + accessory boot on each accessory's hosts, then the full deploy (Kamal setup semantics). Deploy body extracted to $deploy() so setup's bootstrap commands survive into dryRunOutput(). - Proxy gating: kamal-proxy deploy fired for EVERY role; it is now gated to proxy-fronted roles via Role.runningProxy() (explicit role-level proxy: boolean/hash wins, default true only for web), matching Kamal's Role##running_proxy?. Verified with FakeSshPool specs (fresh-host sequence ordering, multi-host proxy iteration, setup!=deploy, proxy gating) and dry-run flows; real SSH/docker-remote is unverifiable in-harness. Full CLI suite in the lucee7 docker harness: 1005 pass / 0 fail / 2 tolerated docker-env artifacts (SshClientSpec/SshPoolSpec docker-not-found). Refs #2957 Signed-off-by: Peter Amiri <peter@alurium.com>
There was a problem hiding this comment.
Wheels Bot — Reviewer
TL;DR: This PR delivers Wave 2a of #2957: it replaces the dead details() || boot() proxy guard with Kamal's docker start || docker run shape dispatched per proxy-fronted host, idempotently ensures the kamal docker network before its first consumer, turns setup() from a literal deploy() alias into a real bootstrap phase (network + accessory boot, then deploy), and gates kamal-proxy deploy to proxy-fronted roles via Role.runningProxy(). The diff is correct, the red-first FakeSshPool specs pin every claimed behavior (ordering, multi-host iteration, gating, setup≠deploy, dry-run buffering), and the commit/changelog hygiene is clean. Verdict: comment — no blocking findings; two non-blocking follow-up gaps in adjacent pre-existing code worth tracking on #2957.
Verified
- DEP-5a/5b —
ProxyCommands.start_or_run()(cli/lucli/services/deploy/commands/ProxyCommands.cfc:83-85) is exactlystart() & " || " & boot(), matching Kamal'sProxy#start_or_run, and is dispatched via$dispatch(onEach) to$proxyHosts(cfg)(DeployMainCli.cfc:114-117). The claim thatdocker ps --filterexits 0 regardless of matches is accurate — the old guard'sboot()was indeed unreachable. - DEP-5c —
ensure_network()(DockerCommands.cfc:28-31) uses an inspect-probe guard, the commands-are-strings equivalent of Kamal's rescue-"already exists". In$deploy()it lands afterbuilder.pulland before the proxy boot / app runs — the only--network kamalemitters (AppCommands.cfc:24,ProxyCommands.boot(),AccessoryCommands.run()), and the ordering spec inDeployMainCliSpec.cfc("creates the kamal network before any --network kamal consumer") pins it. setup()/ dry-run contract — extracting the body to private$deploy()(which does NOT clearvariables.dryRunBuffer, while the public verbs do,DeployMainCli.cfc:57-67) is the right shape: setup's pre-deploy accessory commands survive intodryRunOutput(), and the spec assertsarrayLen(fake.calls()) == 0under--dry-run.redeploy()androllback()still clear correctly via their public entry points.Role.runningProxy()(Role.cfc:45-51) matches Kamal's semantics for the supported config surface: explicit boolean wins, hash opts in, default true only forweb. (Kamal additionally keys the default off a configurableprimary_role; since this port's trimmed allowlist doesn't supportprimary_role, hardcoding"web"is the correct approximation.)Config.roles()forwards the role-levelproxykey for struct-shapedservers:(Config.cfc:96), and the newRoleSpeccovers all four branches plus the pass-through.- Tests — all six DeployMainCliSpec additions assert against
FakeSshPool.calls()whose entries carryhost/cmdkeys (FakeSshPool.cfc:84), matching the new$cmds/$hostsForhelpers. Fixture hosts line up (full.yml: web = 1.1.1.1/1.1.1.2, workers = 1.1.1.3/1.1.1.4;with-accessories.yml: accessories on 1.2.3.5). The setup spec does not depend on accessory struct iteration order.##escaping init()titles is correct.
Correctness (non-blocking follow-ups — adjacent pre-existing code, not regressions in this diff)
-
rollback()still dispatchesproxy.deployto every role's hosts, ungated.cli/lucli/services/deploy/cli/DeployMainCli.cfc:191-195:$dispatch( [host], proxy.deploy(role, app.container_name(role, arguments.opts.version) & ":" & appPort), dryRun );Now that deploy correctly boots kamal-proxy only on proxy-fronted hosts, a rollback against a multi-role config will
docker exec kamal-proxy …on worker hosts where the container deliberately doesn't exist, and abort withraise=trueafter starting old containers on only some hosts. This was equally broken before the PR (the proxy never booted anywhere, so multi-role fleets never reached a deployed state), so it's not a regression — but the new gating makes the asymmetry load-bearing. Suggest mirroring therole.runningProxy()gate inrollback()as a follow-up item on #2957. -
setup()silently skips accessories declared with the Kamalroles:shape.Accessory.hosts()(cli/lucli/services/deploy/config/Accessory.cfc:30-40) only readshost/hostsand returns[]otherwise —rolesappears nowhere in the file. The repo's ownfull.ymlfixture declares exactly this shape (redis: { roles: [web] }), and it passes config validation, so a roles-shaped accessory yields zero dispatches in the new setup loop: setup reports success while the accessory never boots — the same silent-no-op class this PR exists to fix (and at odds with the #3088 "unimplemented keys fail loudly" stance, since the validator accepts the key but nothing reads it). Pre-existing (details()/remove()share the gap), so non-blocking here — but worth a tracked follow-up: either resolveroles:→ role hosts inAccessory.hosts(), or reject the key until implemented.
Conventions
- Nit:
setup()loads + validates the config (DeployMainCli.cfc:216-219) and then$deploy()immediately reloads it (DeployMainCli.cfc:68-71). Harmless (loads are pure perConfig.cfc's contract), but passing the loadedcfginto$deploy()would avoid the double YAML parse/interpolation. Fine to leave for a later cleanup.
Docs & Commits
- Changelog fragment present (
changelog.d/deploy-fresh-host-bootstrap.fixed.md, validfixedtype, no directCHANGELOG.mdedit)..ai/wheels/deploy.mdalready documentssetupas "one-time server bootstrap + first deploy", which the code now actually matches;Module.cfchelp text updated accordingly. - Single commit
fix(cli): …conforms to commitlint (valid type, header ≈96 chars, body lines well under 100), carries a DCO sign-off, and explains the why.
Summary
Wave 2a of the #2957 deploy-orchestration campaign: fresh-host bootstrap + a real
setupphase. All work undercli/lucli/services/deploy/+cli/lucli/Module.cfc(help text only).Closes the following items from the #2957 re-scope checklist (2026-06-12, verified @
271931fde):proxy.details() || proxy.boot()(DeployMainCli.cfc:96) withdocker start kamal-proxy || docker run ...—details()exits 0 regardless." NewProxyCommands.start_or_run()mirrors Kamal'sProxy#start_or_run(combine start, run, by: "||"):docker startsucceeds when the container exists (running or stopped), the fulldocker runfires only on a truly fresh host.onEach, not$dispatchAny." The boot now dispatches via$dispatch(onEach) to$proxyHosts(cfg)— the distinct hosts of every proxy-fronted role.DockerCommands.create_network("kamal")(zero call sites today) into setup/bootstrap before any--network kamalconsumer." New idempotentDockerCommands.ensure_network()(docker network inspect <name> >/dev/null 2>&1 || docker network create <name>; the shell-guard equivalent of Kamal's rescue-"already exists") is dispatched to all app hosts indeploy()after the image pull and before any--network kamalconsumer, and to accessory hosts insetup().setup()isreturn deploy(opts)(DeployMainCli.cfc:182-185): add accessory boot + network create + proxy boot (audit claims fd-07/acc-07)."setup()now ensures the network and boots each accessory on its hosts (Kamalsetupsemantics: accessory boot, then deploy), then runs the deploy body — which itself bootstraps network + proxy on app hosts. The deploy body is extracted to a private$deploy()so setup's bootstrap commands survive intodryRunOutput().proxy.deploy(DeployMainCli.cfc:98-106currently loops all roles; audit claim cfg-04)." NewRole.runningProxy()mirrors Kamal'sRole#running_proxy?: explicit role-levelproxy:boolean (or hash) wins; otherwise only the defaultwebrole fronts the proxy.Config.roles()forwards the role-levelproxykey for struct-shapedservers:.kamal-proxy deployand proxy boot are both gated on it; app containers still run on every role's hosts.Not in this wave (tracked on #2957): DEP-11c (proxy volume
/home/<user>), env.secret env-file delivery, and the Wave 1/3/4 items.Evidence
Red-first FakeSshPool specs (all six failed against the old code, exact failures observed before implementing):
DeployMainCliSpec— fresh-host sequence ordering (docker network create kamalstrictly before the first--network kamalconsumer;docker start kamal-proxy || docker runpresent, deaddocker ps ... ||guard absent), multi-host proxy iteration onfull.yml(proxy boots on web hosts 1.1.1.1 + 1.1.1.2, not on workers 1.1.1.3/1.1.1.4), proxy-deploy gating (web hosts only; app run still on all four),setup != deploy(setup bootsdemo-db/demo-redison 1.2.3.5 before the app container; plain deploy emits no accessory commands), and setup--dry-runbuffering (network + accessory + proxy boot + app run, zero pool calls).RoleSpec(new) —runningProxy()semantics incl.Config.roles()pass-through.DockerCommandsSpec(new) —create_network/ensure_networkstrings.ProxyCommandsSpec—start_or_run()shape.Full CLI suite in the lucee7 docker harness (
/wheels/cli/tests?format=json): 1005 pass / 0 fail / 2 errors, both the tolerated docker-env artifacts (SshClientSpec/SshPoolSpec:docker: command not foundfromtools/deploy-sshd-up.shinside the container). Beats the ~918 baseline.Real SSH/docker-remote behavior is unverifiable in this harness — FakeSshPool specs +
--dry-runflows are the verification bar here; the env-gatedE2EDeploySpec(DEPLOY_E2E=1) remains the live-wire check.Refs #2957
🤖 Generated with Claude Code