Skip to content

fix(cli): deploy fresh-host bootstrap — proxy start_or_run per host, kamal network, real setup() - #3162

Merged
bpamiri merged 1 commit into
developfrom
peter/deploy-w2a-fresh-host-bootstrap
Jun 12, 2026
Merged

fix(cli): deploy fresh-host bootstrap — proxy start_or_run per host, kamal network, real setup()#3162
bpamiri merged 1 commit into
developfrom
peter/deploy-w2a-fresh-host-bootstrap

Conversation

@bpamiri

@bpamiri bpamiri commented Jun 12, 2026

Copy link
Copy Markdown
Collaborator

Summary

Wave 2a of the #2957 deploy-orchestration campaign: fresh-host bootstrap + a real setup phase. All work under cli/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):

  • DEP-5a — "Replace proxy.details() || proxy.boot() (DeployMainCli.cfc:96) with docker start kamal-proxy || docker run ...details() exits 0 regardless." New ProxyCommands.start_or_run() mirrors Kamal's Proxy#start_or_run (combine start, run, by: "||"): docker start succeeds when the container exists (running or stopped), the full docker run fires only on a truly fresh host.
  • DEP-5b — "Boot the proxy on every traffic-serving (web-role) host via onEach, not $dispatchAny." The boot now dispatches via $dispatch (onEach) to $proxyHosts(cfg) — the distinct hosts of every proxy-fronted role.
  • DEP-5c — "Wire DockerCommands.create_network("kamal") (zero call sites today) into setup/bootstrap before any --network kamal consumer." New idempotent DockerCommands.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 in deploy() after the image pull and before any --network kamal consumer, and to accessory hosts in setup().
  • setup() real implementation — "setup() is return 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 (Kamal setup semantics: 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 into dryRunOutput().
  • Proxy gating — "only web/proxy-fronted roles get proxy.deploy (DeployMainCli.cfc:98-106 currently loops all roles; audit claim cfg-04)." New Role.runningProxy() mirrors Kamal's Role#running_proxy?: explicit role-level proxy: boolean (or hash) wins; otherwise only the default web role fronts the proxy. Config.roles() forwards the role-level proxy key for struct-shaped servers:. kamal-proxy deploy and 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 kamal strictly before the first --network kamal consumer; docker start kamal-proxy || docker run present, dead docker ps ... || guard absent), multi-host proxy iteration on full.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 boots demo-db/demo-redis on 1.2.3.5 before the app container; plain deploy emits no accessory commands), and setup --dry-run buffering (network + accessory + proxy boot + app run, zero pool calls).
  • RoleSpec (new) — runningProxy() semantics incl. Config.roles() pass-through.
  • DockerCommandsSpec (new) — create_network / ensure_network strings.
  • ProxyCommandsSpecstart_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 found from tools/deploy-sshd-up.sh inside the container). Beats the ~918 baseline.

Real SSH/docker-remote behavior is unverifiable in this harness — FakeSshPool specs + --dry-run flows are the verification bar here; the env-gated E2EDeploySpec (DEPLOY_E2E=1) remains the live-wire check.

Refs #2957

🤖 Generated with Claude Code

…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>
@bpamiri
bpamiri enabled auto-merge (squash) June 12, 2026 19:52
@github-actions github-actions Bot added the docs label Jun 12, 2026
@bpamiri
bpamiri merged commit 242e9ae into develop Jun 12, 2026
7 checks passed
@bpamiri
bpamiri deleted the peter/deploy-w2a-fresh-host-bootstrap branch June 12, 2026 19:55

@wheels-bot wheels-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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/5bProxyCommands.start_or_run() (cli/lucli/services/deploy/commands/ProxyCommands.cfc:83-85) is exactly start() & " || " & boot(), matching Kamal's Proxy#start_or_run, and is dispatched via $dispatch (onEach) to $proxyHosts(cfg) (DeployMainCli.cfc:114-117). The claim that docker ps --filter exits 0 regardless of matches is accurate — the old guard's boot() was indeed unreachable.
  • DEP-5censure_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 after builder.pull and before the proxy boot / app runs — the only --network kamal emitters (AppCommands.cfc:24, ProxyCommands.boot(), AccessoryCommands.run()), and the ordering spec in DeployMainCliSpec.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 clear variables.dryRunBuffer, while the public verbs do, DeployMainCli.cfc:57-67) is the right shape: setup's pre-deploy accessory commands survive into dryRunOutput(), and the spec asserts arrayLen(fake.calls()) == 0 under --dry-run. redeploy() and rollback() 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 for web. (Kamal additionally keys the default off a configurable primary_role; since this port's trimmed allowlist doesn't support primary_role, hardcoding "web" is the correct approximation.) Config.roles() forwards the role-level proxy key for struct-shaped servers: (Config.cfc:96), and the new RoleSpec covers all four branches plus the pass-through.
  • Tests — all six DeployMainCliSpec additions assert against FakeSshPool.calls() whose entries carry host/cmd keys (FakeSshPool.cfc:84), matching the new $cmds/$hostsFor helpers. 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 in it() titles is correct.

Correctness (non-blocking follow-ups — adjacent pre-existing code, not regressions in this diff)

  1. rollback() still dispatches proxy.deploy to 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 with raise=true after 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 the role.runningProxy() gate in rollback() as a follow-up item on #2957.

  2. setup() silently skips accessories declared with the Kamal roles: shape. Accessory.hosts() (cli/lucli/services/deploy/config/Accessory.cfc:30-40) only reads host/hosts and returns [] otherwise — roles appears nowhere in the file. The repo's own full.yml fixture 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 resolve roles: → role hosts in Accessory.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 per Config.cfc's contract), but passing the loaded cfg into $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, valid fixed type, no direct CHANGELOG.md edit). .ai/wheels/deploy.md already documents setup as "one-time server bootstrap + first deploy", which the code now actually matches; Module.cfc help 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant