Skip to content

review remediation: deploy CLI orchestration correctness — multi-host lock bypass, fresh-host bootstrap, discarded stdout/audit log, flat-alias ssh config, roll-up defects #2957

Description

@bpamiri

Problem

The wheels deploy orchestration layer has correctness defects spanning the lock, fresh-host bootstrap, output handling, ssh-config resolution, and several roll-up bugs. They share the command-construction layer with the deploy-security issue and should be fixed after that architecture decision so the same files are not churned twice. Paths relative to cli/lucli/services/deploy/; verified against origin/develop @ 8971094.

1. Deploy lock is silently bypassed on any multi-host fleet (DEP-1, High)
cli/DeployMainCli.cfc:82-86, 107, 452-455 acquires the lock via $dispatchAnySshPool.onAny (lib/SshPool.cfc:101-109), first-success-wins, catching every per-host exception. The lock works by ln -s failing when it exists (commands/LockCommands.cfc:29-39), so on a 2+ host fleet, contention on host 1 raises, onAny swallows it, acquires a fresh lock on host 2, and the concurrent deploy proceeds — mutual exclusion holds only for single-host configs. Release is also $dispatchAny, so the released host may differ from the acquired host, stranding stale locks. Fix: acquire/release on a deterministic primary host via onEach/sequential dispatch with raise=true, mirroring Kamal's primary-host semantics.

2. Fresh-host deploy/setup cannot work (DEP-5, High)
(a) deploy() boots the proxy with proxy.details() & " || " & proxy.boot() (cli/DeployMainCli.cfc:90, 437-461); details() is docker ps --filter name=kamal-proxy, which exits 0 whether or not anything matches — so boot() is unreachable and kamal-proxy is never started. (b) The check is dispatched via $dispatchAny to ONE host, but every traffic-serving host needs its own proxy. (c) docker network create kamal exists (commands/DockerCommands.cfc:14-20) but has zero call sites, while --network kamal is required by AppCommands.run, AccessoryCommands.run, and ProxyCommands.boot — first run on a bootstrapped host fails with "network kamal not found". Fix: use docker start kamal-proxy || docker run ..., dispatch proxy boot to every traffic-serving host (onEach), and wire create_network into bootstrap/setup.

3. Every read verb discards remote stdout; the audit log is never written (DEP-6, Medium)
All dispatch closures call ssh.run(c, {raise: doRaise}) and drop the result; the only writeOutput is the hook-output line (cli/DeployMainCli.cfc:500). So audit (:171-187), details (:217-244), app logs (cli/DeployAppCli.cfc:67-77), lock status (cli/DeployLockCli.cfc:44-54) return only a summary, never the actual content in live mode. Compounding it, AuditorCommands.record() (commands/AuditorCommands.cfc:15-19) has zero call sites, so /tmp/kamal-audit.log is never written — audit tails a file this tool never creates. Fix: surface remote stdout from read verbs; wire AuditorCommands.record() into the deploy flows.

4. Flat aliases wheels deploy bootstrap / wheels deploy exec ignore ssh: config (DEP-7, Medium)
cli/lucli/Module.cfc:2150 and :2164 construct a bare new SshPool() instead of $deployBuildSshPool(opts.configPath) as the nested server branch does (:2183-2185). The flat aliases exist BECAUSE picocli absorbs the nested server form before module dispatch (#2677), so for real CLI users the broken form is the only reachable one. Any deploy.yml with non-root ssh.user, custom port, or a keys: path gets root@host:22/ssh-agent instead. Fix: build the pool via $deployBuildSshPool(opts.configPath) in the flat aliases.

5. Lock metadata never expands (DEP-10, Low)
commands/LockCommands.cfc:33-37: acquire wraps the entire symlink target in single quotes while the comment claims $(hostname)/$(date) are "resolved by the remote shell". Single quotes suppress command substitution, so the lock metadata is the literal user@$(hostname)/$(date ...)/message. Contrast AuditorCommands.record (:18), which correctly uses double quotes. Fix: use double quotes for the symlink target.

6. Roll-up: same-version redeploy, extract key match, proxy volume path (DEP-11, Low)
(a) deploy() issues docker run --name <service>-<role>-<version> (commands/AppCommands.cfc:18-30) without removing an existing same-name container; redeploy with an unchanged git SHA hits a guaranteed name conflict, and old-version containers are never stopped after cutover. (b) CFML == at cli/DeploySecretsCli.cfc:68 makes extract path match PATH — diverges from Kamal's exact match. (c) ProxyCommands.boot mounts /home/#ssh.user()#/.config/kamal-proxy (commands/ProxyCommands.cfc:30); for the default root user the home is /root, so the mount path is wrong on the default config. Fix: remove/replace same-name containers and stop old-version containers after cutover; exact (case-sensitive) key matching in extract; resolve the remote home directory.

Impact

Concurrent deploys on multi-host fleets are not mutually excluded; fresh-host deploy/setup fails outright; operators see only summaries from read verbs while the audit log is never written; the only CLI-reachable bootstrap/exec aliases ignore ssh config; lock metadata is unusable; and same-version redeploys always fail.

Suggested approach

Fix after the deploy-security architecture decision (DEP-2/3/4) lands, since these touch the same DeployMainCli/AppCommands/LockCommands/Module dispatch and command-construction layer. Apply the per-finding fixes above, mirroring Kamal's primary-host and network-bootstrap semantics.

Acceptance criteria

  • Deploy lock is mutually exclusive across a 2+ host fleet; release targets the acquired host.
  • Fresh-host deploy/setup creates the kamal network and boots the proxy on every traffic-serving host.
  • Read verbs (audit, details, app logs, lock status) surface remote stdout; the audit log is actually written.
  • Flat bootstrap/exec aliases honor ssh: config.
  • Lock metadata expands $(hostname)/$(date).
  • Same-version redeploy succeeds; extract key match is case-sensitive; proxy volume resolves the real remote home.

Source

Internal multi-agent framework review 2026-06-09, wave 2 (issues phase). Findings: followups DEP-1, DEP-5, DEP-6, DEP-7, DEP-10, DEP-11.


Re-scope 2026-06-12 (verified against develop @ 271931f)

Rationale: zero DEP items have shipped, but the epic's stated blocker is gone —
the body says "fix after the deploy-security architecture decision (DEP-2/3/4) lands";
#3008 landed it (stdin secrets, Base.shellEscape, validate-not-quote). The items
cluster naturally into four small waves that each touch a disjoint-enough file set,
matching the proven point-fix campaign mechanics from #3142#3146 (TDD red-first on
FakeSshPool/dry-run, full CLI suite in the lucee7 docker harness).

Draft replacement body section (paste into #2957)

Status 2026-06-12 — re-scoped to campaign waves (scoping pass against develop @ 271931f)

The sequencing blocker is resolved: the #2956 command-builder architecture landed in #3008 (Base.shellEscape, SSH-stdin secret delivery, $rejectEnvSecrets fail-fast, name format validation). Every DEP item below was re-verified still reproducing in source at develop HEAD. The 2026-06-12 audit-confirmation comment adds live-harness evidence per item. None of this week's point fixes (#3142#3146) touched these items.

Wave 1 — lock correctness (DeployMainCli.cfc, LockCommands.cfc, SshPool.cfc)

  • DEP-1a Acquire the deploy lock on a deterministic primary host (Kamal semantics: first host of the primary role) via onEach/sequential with raise=true — not $dispatchAny (DeployMainCli.cfc:88-92; SshPool.onAny swallows per-host failures, SshPool.cfc:101-108).
  • DEP-1b Release on the same host that acquired (DeployMainCli.cfc:113).
  • DEP-10 Double-quote the lock symlink target so $(hostname)/$(date) expand (LockCommands.cfc:39; keep user/message escaping from fix(cli): deploy remote-exec security — stdin registry login, shell escaping, env.secret fail-fast #3008, fix the stale comment at :32-36).

Wave 2 — fresh-host bootstrap + setup (DeployMainCli.cfc, ProxyCommands.cfc, DockerCommands.cfc, AppCommands.cfc)

  • DEP-5a Replace proxy.details() || proxy.boot() (DeployMainCli.cfc:96) with docker start kamal-proxy || docker run ...details() exits 0 regardless.
  • DEP-5b Boot the proxy on every traffic-serving (web-role) host via onEach, not $dispatchAny.
  • DEP-5c Wire DockerCommands.create_network("kamal") (zero call sites today) into setup/bootstrap before any --network kamal consumer.
  • setup() real implementationsetup() is return deploy(opts) (DeployMainCli.cfc:182-185): add accessory boot + network create + proxy boot (audit claims fd-07/acc-07).
  • Proxy gating — only web/proxy-fronted roles get proxy.deploy (DeployMainCli.cfc:98-106 currently loops all roles; audit claim cfg-04).
  • DEP-11c Resolve the real remote home for the proxy config volume (ProxyCommands.cfc:30 hardcodes /home/<user>; root's home is /root).
  • env.secret env-file delivery (review remediation: deploy CLI remote-execution security — secret delivery, shell escaping, registry password redaction #2956 residue, pointed here by Base.cfc:63 and the EnvSecretUnsupported message): build env file from env.secret + SecretResolver, SshClient.uploadString (SshClient.cfc:235), --env-file on docker run; then lift the fail-fast and restore the init template's secret block. Optionally fold per-role env merge (Role.cfc:25-30 parsed, AppCommands.$envArgs ignores it) into the same design.

Wave 3 — observability + ssh-config (DeployMainCli.cfc, Deploy*Cli.cfc, Module.cfc, AuditorCommands.cfc)

  • DEP-6a Surface remote stdout from read verbs (audit, details, app logs/details, lock status) — dispatch closures currently drop ssh.run() results (DeployMainCli.cfc:447-449); live mode returns only host-count summaries.
  • DEP-6b Wire AuditorCommands.record() (zero call sites) into deploy/rollback/setup flows so /tmp/kamal-audit.log is actually written before audit tails it.
  • DEP-7 Flat bootstrap/exec aliases must build the pool via $deployBuildSshPool(opts.configPath) (Module.cfc:2419-2421, :2433-2435 use bare new SshPool()), matching the nested server branch — these aliases are the only CLI-reachable form (wheels deploy server bootstrap is interpreted as LuCLI server command instead of deploy subcommand #2677).

Wave 4 — roll-up point fixes (independent, any order)

Acceptance criteria unchanged from the original body, plus: setup on a pristine docker-sshd fixture host succeeds end-to-end (E2EDeploySpec extension), and wheels deploy rollback --destination=X --dry-run targets the overlay hosts.


Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions