Skip to content

Commit 242e9ae

Browse files
authored
fix(cli): deploy fresh-host bootstrap — proxy start_or_run per host, kamal network, real setup() (#3162)
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>
1 parent 274952d commit 242e9ae

11 files changed

Lines changed: 327 additions & 11 deletions

File tree

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
- `wheels deploy` now works on fresh hosts: the kamal-proxy boot guard `details() || boot()` never reached `boot()` (`docker ps` exits 0 whether or not the proxy exists) and was dispatched to only one host — the proxy is now booted via Kamal's `docker start kamal-proxy || docker run ...` (`ProxyCommands.start_or_run()`) on every proxy-fronted host; the `kamal` docker network, previously never created (zero `create_network` call sites while every app/proxy/accessory `docker run` joins `--network kamal`), is now idempotently ensured on every host before the first consumer; `wheels deploy setup` is a real setup phase (network create + accessory boot on accessory hosts, then deploy) instead of a literal `deploy()` alias; and `kamal-proxy deploy` registration is gated to proxy-fronted roles (role-level `proxy:` boolean, defaulting to the `web` role) instead of firing for every job/worker role (#2957)

cli/lucli/Module.cfc

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2244,7 +2244,7 @@ component extends="modules.BaseModule" {
22442244
* wheels deploy rollback v1 - roll back to version v1
22452245
* wheels deploy config - print resolved config as YAML
22462246
* wheels deploy init - create config stub
2247-
* wheels deploy setup - full setup (Phase 2 adds accessories)
2247+
* wheels deploy setup - one-time bootstrap (network + accessories) + deploy
22482248
* wheels deploy bootstrap - install Docker on every host
22492249
* wheels deploy exec "uname -a" - run a command on every host
22502250
* wheels deploy version - show version pinning

cli/lucli/services/deploy/cli/DeployMainCli.cfc

Lines changed: 76 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,15 @@ component {
5656

5757
public string function deploy(required struct opts) {
5858
arrayClear(variables.dryRunBuffer);
59+
return $deploy(arguments.opts);
60+
}
61+
62+
/**
63+
* Deploy body, shared by deploy() and setup(). Does NOT clear the
64+
* dry-run buffer — the public verbs do, so setup()'s pre-deploy
65+
* bootstrap commands survive into dryRunOutput().
66+
*/
67+
private string function $deploy(required struct opts) {
5968
var cfg = variables.loader.load(
6069
arguments.opts.configPath,
6170
{destination: arguments.opts.destination ?: ""}
@@ -66,6 +75,7 @@ component {
6675
var app = new modules.wheels.services.deploy.commands.AppCommands(cfg);
6776
var proxy = new modules.wheels.services.deploy.commands.ProxyCommands(cfg);
6877
var builder = new modules.wheels.services.deploy.commands.BuilderCommands(cfg);
78+
var dockerCmds = new modules.wheels.services.deploy.commands.DockerCommands(cfg);
6979
var lock = new modules.wheels.services.deploy.commands.LockCommands(cfg);
7080
var hooks = new modules.wheels.services.deploy.commands.HookCommands(
7181
cfg,
@@ -93,16 +103,31 @@ component {
93103

94104
try {
95105
$dispatch(hosts, builder.pull(ver), dryRun);
96-
$dispatchAny(hosts, proxy.details() & " || " & proxy.boot(), dryRun);
106+
// Fresh-host bootstrap (#2957 DEP-5c): every `docker run`
107+
// below joins --network kamal, so the network must exist
108+
// before the first consumer. ensure_network is idempotent.
109+
$dispatch(hosts, dockerCmds.ensure_network("kamal"), dryRun);
110+
// Boot (or start) kamal-proxy on EVERY proxy-fronted host
111+
// (#2957 DEP-5a/5b). The old `details() || boot()` guard
112+
// never booted anything — `docker ps` exits 0 regardless —
113+
// and was dispatched to only one host via $dispatchAny.
114+
var proxyHosts = $proxyHosts(cfg);
115+
if (arrayLen(proxyHosts)) {
116+
$dispatch(proxyHosts, proxy.start_or_run(), dryRun);
117+
}
97118

98119
for (var role in cfg.roles()) {
99120
for (var host in role.hosts()) {
100121
$dispatch([host], app.run(role, ver), dryRun);
101-
$dispatch(
102-
[host],
103-
proxy.deploy(role, app.container_name(role, ver) & ":" & appPort),
104-
dryRun
105-
);
122+
// Only proxy-fronted roles register with kamal-proxy —
123+
// job/worker roles serve no traffic (#2957).
124+
if (role.runningProxy()) {
125+
$dispatch(
126+
[host],
127+
proxy.deploy(role, app.container_name(role, ver) & ":" & appPort),
128+
dryRun
129+
);
130+
}
106131
}
107132
}
108133
} finally {
@@ -179,9 +204,34 @@ component {
179204
);
180205
}
181206

207+
/**
208+
* One-time server bootstrap + first deploy (Kamal `setup` semantics):
209+
* create the kamal docker network on every accessory host, boot each
210+
* accessory on its hosts, then run a full deploy (which bootstraps the
211+
* network + proxy on the app hosts). Previously a literal alias for
212+
* deploy(), so fresh hosts never got their accessories (#2957).
213+
*/
182214
public string function setup(required struct opts) {
183-
// Phase 2 will add accessory boot; for Phase 1 this equals deploy.
184-
return deploy(arguments.opts);
215+
arrayClear(variables.dryRunBuffer);
216+
var cfg = variables.loader.load(
217+
arguments.opts.configPath,
218+
{destination: arguments.opts.destination ?: ""}
219+
);
220+
var dryRun = arguments.opts.dryRun ?: false;
221+
222+
if (arrayLen(cfg.accessories())) {
223+
var dockerCmds = new modules.wheels.services.deploy.commands.DockerCommands(cfg);
224+
var accCmds = new modules.wheels.services.deploy.commands.AccessoryCommands(cfg);
225+
for (var acc in cfg.accessories()) {
226+
// Accessories join --network kamal too, and may live on
227+
// hosts outside the app roles — ensure the network there
228+
// before the accessory container runs (#2957 DEP-5c).
229+
$dispatch(acc.hosts(), dockerCmds.ensure_network("kamal"), dryRun);
230+
$dispatch(acc.hosts(), accCmds.run(acc), dryRun);
231+
}
232+
}
233+
234+
return $deploy(arguments.opts);
185235
}
186236

187237
/**
@@ -451,8 +501,8 @@ component {
451501

452502
/**
453503
* Dispatch a single command to "any one" host — used for operations
454-
* that only need to happen once across the fleet (lock acquire/release,
455-
* proxy boot check). FakeSshPool.onAny records exactly one call.
504+
* that only need to happen once across the fleet (lock acquire/release).
505+
* FakeSshPool.onAny records exactly one call.
456506
*/
457507
private void function $dispatchAny(
458508
required array hosts,
@@ -544,6 +594,22 @@ component {
544594
return out;
545595
}
546596

597+
/**
598+
* Distinct hosts of every proxy-fronted role (Role.runningProxy()),
599+
* in declaration order. Each of these needs its own kamal-proxy
600+
* container (#2957 DEP-5b).
601+
*/
602+
private array function $proxyHosts(required any cfg) {
603+
var out = [];
604+
for (var role in arguments.cfg.roles()) {
605+
if (!role.runningProxy()) continue;
606+
for (var h in role.hosts()) {
607+
if (!arrayContains(out, h)) arrayAppend(out, h);
608+
}
609+
}
610+
return out;
611+
}
612+
547613
private struct function $roleHosts(required any cfg) {
548614
var out = {};
549615
for (var role in arguments.cfg.roles()) {

cli/lucli/services/deploy/commands/DockerCommands.cfc

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,4 +18,16 @@ component extends="Base" {
1818
public string function create_network(required string name) {
1919
return docker("network", "create", arguments.name);
2020
}
21+
22+
/**
23+
* Idempotent network create — `docker network create` exits nonzero
24+
* when the network already exists, so deploy/setup guard it with an
25+
* inspect probe (exit 0 only when the network is present). Ruby Kamal
26+
* rescues the "already exists" error instead; a shell guard is the
27+
* commands-are-strings equivalent (#2957 DEP-5c).
28+
*/
29+
public string function ensure_network(required string name) {
30+
return "docker network inspect #arguments.name# >/dev/null 2>&1 || "
31+
& create_network(arguments.name);
32+
}
2133
}

cli/lucli/services/deploy/commands/ProxyCommands.cfc

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,19 @@ component extends="Base" {
7171
return docker("start", variables.PROXY_CONTAINER_NAME);
7272
}
7373

74+
/**
75+
* Fresh-host-safe boot, mirroring Kamal's Proxy#start_or_run
76+
* (`combine start, run, by: "||"`): `docker start` succeeds when the
77+
* container already exists (running start is a no-op, stopped start
78+
* resumes it), and the full `docker run` fires only on a truly fresh
79+
* host. The previous guard — `details() || boot()` — never reached
80+
* boot() because `docker ps --filter` exits 0 whether or not anything
81+
* matches (#2957 DEP-5a).
82+
*/
83+
public string function start_or_run() {
84+
return start() & " || " & boot();
85+
}
86+
7487
public string function stop() {
7588
return docker("stop", variables.PROXY_CONTAINER_NAME);
7689
}

cli/lucli/services/deploy/config/Config.cfc

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,7 @@ component {
9393
var roleRaw = {name: roleName, hosts: hosts};
9494
if (structKeyExists(entry, "env")) roleRaw.env = entry.env;
9595
if (structKeyExists(entry, "cmd")) roleRaw.cmd = entry.cmd;
96+
if (structKeyExists(entry, "proxy")) roleRaw.proxy = entry.proxy;
9697
arrayAppend(out, new Role(roleRaw));
9798
}
9899
}

cli/lucli/services/deploy/config/Role.cfc

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,4 +33,21 @@ component {
3333
return variables.raw.cmd ?: "";
3434
}
3535

36+
/**
37+
* Whether this role's containers are fronted by kamal-proxy.
38+
*
39+
* Mirrors Kamal's Role#running_proxy? (lib/kamal/configuration/role.rb):
40+
* an explicit role-level `proxy:` boolean wins; a `proxy:` hash (proxy
41+
* options) opts the role in; otherwise only the default "web" role runs
42+
* behind the proxy. Job/worker roles must not receive proxy boot or
43+
* `kamal-proxy deploy` commands (#2957).
44+
*/
45+
public boolean function runningProxy() {
46+
if (structKeyExists(variables.raw, "proxy")) {
47+
if (isBoolean(variables.raw.proxy)) return variables.raw.proxy;
48+
if (isStruct(variables.raw.proxy)) return true;
49+
}
50+
return lCase(name()) == "web";
51+
}
52+
3653
}

cli/lucli/tests/specs/deploy/cli/DeployMainCliSpec.cfc

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ component extends="wheels.wheelstest.system.BaseSpec" {
33
function beforeAll() {
44
variables.fixture = expandPath("/cli/lucli/tests/_fixtures/deploy/configs/minimal.yml");
55
variables.proxyFixture = expandPath("/cli/lucli/tests/_fixtures/deploy/configs/with-proxy.yml");
6+
variables.multiRoleFixture = expandPath("/cli/lucli/tests/_fixtures/deploy/configs/full.yml");
7+
variables.accessoriesFixture = expandPath("/cli/lucli/tests/_fixtures/deploy/configs/with-accessories.yml");
68
}
79

810
function run() {
@@ -709,6 +711,104 @@ component extends="wheels.wheelstest.system.BaseSpec" {
709711
expect(out).notToInclude(":3000");
710712
});
711713

714+
// Regression suite for #2957 (Wave 2a) — fresh-host bootstrap.
715+
// (DEP-5a) the proxy boot guard was `details() || boot()`; details()
716+
// is `docker ps --filter ...` which exits 0 whether or not the proxy
717+
// exists, so boot() was unreachable and kamal-proxy never started on
718+
// a fresh host. (DEP-5b) the guard was dispatched to ONE host via
719+
// $dispatchAny while every proxy-fronted host needs its own proxy.
720+
// (DEP-5c) `docker network create kamal` had zero call sites while
721+
// app/proxy/accessory runs all require `--network kamal`. setup()
722+
// was literally `return deploy(opts)` — no accessory boot. And
723+
// proxy.deploy fired for EVERY role, including non-fronted job roles.
724+
725+
it("deploy boots kamal-proxy via docker start || docker run, not the dead docker ps guard (##2957 DEP-5a)", () => {
726+
var fake = new cli.lucli.services.deploy.lib.FakeSshPool();
727+
var dc = new cli.lucli.services.deploy.cli.DeployMainCli(fake);
728+
dc.deploy({configPath: variables.fixture, version: "v1"});
729+
var cmds = $cmds(fake);
730+
expect($anyInclude(cmds, "docker start kamal-proxy || docker run")).toBeTrue();
731+
expect($anyInclude(cmds, "docker ps --filter name=kamal-proxy || ")).toBeFalse();
732+
});
733+
734+
it("deploy creates the kamal network before any --network kamal consumer (##2957 DEP-5c)", () => {
735+
var fake = new cli.lucli.services.deploy.lib.FakeSshPool();
736+
var dc = new cli.lucli.services.deploy.cli.DeployMainCli(fake);
737+
dc.deploy({configPath: variables.fixture, version: "v1"});
738+
var cmds = $cmds(fake);
739+
var networkIdx = 0; var consumerIdx = 0;
740+
for (var i = 1; i <= arrayLen(cmds); i++) {
741+
if (!networkIdx && findNoCase("docker network create kamal", cmds[i])) networkIdx = i;
742+
if (!consumerIdx && findNoCase("--network kamal", cmds[i])) consumerIdx = i;
743+
}
744+
expect(networkIdx).toBeGT(0);
745+
expect(consumerIdx).toBeGT(networkIdx);
746+
});
747+
748+
it("deploy boots the proxy on EVERY proxy-fronted host, not just one (##2957 DEP-5b)", () => {
749+
var fake = new cli.lucli.services.deploy.lib.FakeSshPool();
750+
var dc = new cli.lucli.services.deploy.cli.DeployMainCli(fake);
751+
dc.deploy({configPath: variables.multiRoleFixture, version: "v1"});
752+
var bootHosts = $hostsFor(fake, "docker start kamal-proxy || docker run");
753+
// full.yml: web role = 1.1.1.1 + 1.1.1.2; workers = 1.1.1.3 + 1.1.1.4.
754+
expect(bootHosts).toInclude("1.1.1.1");
755+
expect(bootHosts).toInclude("1.1.1.2");
756+
expect(bootHosts).notToInclude("1.1.1.3");
757+
expect(bootHosts).notToInclude("1.1.1.4");
758+
});
759+
760+
it("deploy gates kamal-proxy deploy to proxy-fronted roles only (##2957)", () => {
761+
var fake = new cli.lucli.services.deploy.lib.FakeSshPool();
762+
var dc = new cli.lucli.services.deploy.cli.DeployMainCli(fake);
763+
dc.deploy({configPath: variables.multiRoleFixture, version: "v1"});
764+
var proxyDeployHosts = $hostsFor(fake, "kamal-proxy deploy");
765+
expect(proxyDeployHosts).toInclude("1.1.1.1");
766+
expect(proxyDeployHosts).toInclude("1.1.1.2");
767+
expect(proxyDeployHosts).notToInclude("1.1.1.3");
768+
expect(proxyDeployHosts).notToInclude("1.1.1.4");
769+
// ...while the app containers still run on every role's hosts.
770+
var runHosts = $hostsFor(fake, "docker run --detach --restart unless-stopped --name app-");
771+
expect(runHosts).toInclude("1.1.1.3");
772+
expect(runHosts).toInclude("1.1.1.4");
773+
});
774+
775+
it("setup boots accessories before the app deploy; plain deploy does not (##2957 setup!=deploy)", () => {
776+
// setup: accessory containers run, before the app container.
777+
var fake = new cli.lucli.services.deploy.lib.FakeSshPool();
778+
var dc = new cli.lucli.services.deploy.cli.DeployMainCli(fake);
779+
dc.setup({configPath: variables.accessoriesFixture, version: "v1"});
780+
var cmds = $cmds(fake);
781+
var accIdx = 0; var appIdx = 0;
782+
for (var i = 1; i <= arrayLen(cmds); i++) {
783+
if (!accIdx && findNoCase("--name demo-db", cmds[i])) accIdx = i;
784+
if (!appIdx && findNoCase("--name demo-web-v1", cmds[i])) appIdx = i;
785+
}
786+
expect(accIdx).toBeGT(0);
787+
expect($anyInclude(cmds, "--name demo-redis")).toBeTrue();
788+
expect(appIdx).toBeGT(accIdx);
789+
// The accessory host (1.2.3.5) gets the network created too.
790+
expect($hostsFor(fake, "docker network create kamal")).toInclude("1.2.3.5");
791+
792+
// plain deploy: no accessory boot.
793+
var fake2 = new cli.lucli.services.deploy.lib.FakeSshPool();
794+
var dc2 = new cli.lucli.services.deploy.cli.DeployMainCli(fake2);
795+
dc2.deploy({configPath: variables.accessoriesFixture, version: "v1"});
796+
var cmds2 = $cmds(fake2);
797+
expect($anyInclude(cmds2, "--name demo-db")).toBeFalse();
798+
expect($anyInclude(cmds2, "--name demo-redis")).toBeFalse();
799+
});
800+
801+
it("setup --dry-run buffers network create + accessory boot + proxy boot + app run (##2957)", () => {
802+
var fake = new cli.lucli.services.deploy.lib.FakeSshPool();
803+
var dc = new cli.lucli.services.deploy.cli.DeployMainCli(fake);
804+
var out = dc.setup({configPath: variables.accessoriesFixture, version: "v1", dryRun: true});
805+
expect(arrayLen(fake.calls())).toBe(0);
806+
expect(out).toInclude("docker network create kamal");
807+
expect(out).toInclude("--name demo-db");
808+
expect(out).toInclude("docker start kamal-proxy || docker run");
809+
expect(out).toInclude("--name demo-web-v1");
810+
});
811+
712812
// Regression for #2671 — git's stderr ("fatal: not a git repository...") used to leak through as the version string.
713813
it("$gitShortSha() returns 'unknown' when run outside a git repo", () => {
714814
var nonGitDir = getTempDirectory() & "/wheels-2671-main-" & createUUID();
@@ -733,4 +833,21 @@ component extends="wheels.wheelstest.system.BaseSpec" {
733833
for (var s in arguments.arr) if (findNoCase(arguments.needle, s)) return true;
734834
return false;
735835
}
836+
837+
private array function $cmds(required any fake) {
838+
var out = [];
839+
for (var c in arguments.fake.calls()) arrayAppend(out, c.cmd ?: "");
840+
return out;
841+
}
842+
843+
/** Distinct hosts that received a command containing needle, in call order. */
844+
private array function $hostsFor(required any fake, required string needle) {
845+
var out = [];
846+
for (var c in arguments.fake.calls()) {
847+
if (findNoCase(arguments.needle, c.cmd ?: "") && !arrayContains(out, c.host ?: "")) {
848+
arrayAppend(out, c.host ?: "");
849+
}
850+
}
851+
return out;
852+
}
736853
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
component extends="wheels.wheelstest.system.BaseSpec" {
2+
3+
function beforeAll() {
4+
variables.cfg = new cli.lucli.services.deploy.config.ConfigLoader()
5+
.load(expandPath("/cli/lucli/tests/_fixtures/deploy/configs/minimal.yml"));
6+
}
7+
8+
function run() {
9+
describe("DockerCommands", () => {
10+
11+
it("create_network() emits docker network create", () => {
12+
var cmd = new cli.lucli.services.deploy.commands.DockerCommands(variables.cfg)
13+
.create_network("kamal");
14+
expect(cmd).toBe("docker network create kamal");
15+
});
16+
17+
// #2957 DEP-5c — `docker network create` exits nonzero when the
18+
// network already exists, so the deploy/setup flows need an
19+
// idempotent guard (inspect probe || create) to be re-runnable.
20+
it("ensure_network() guards create with an inspect probe so reruns are idempotent (##2957)", () => {
21+
var cmd = new cli.lucli.services.deploy.commands.DockerCommands(variables.cfg)
22+
.ensure_network("kamal");
23+
expect(cmd).toInclude("docker network inspect kamal");
24+
expect(cmd).toInclude(" || docker network create kamal");
25+
});
26+
});
27+
}
28+
}

0 commit comments

Comments
 (0)