diff --git a/changelog.d/deploy-w3-observability.fixed.md b/changelog.d/deploy-w3-observability.fixed.md new file mode 100644 index 000000000..70199ced2 --- /dev/null +++ b/changelog.d/deploy-w3-observability.fixed.md @@ -0,0 +1,3 @@ +- Deploy read verbs (`wheels deploy audit`, `details`, `app logs`/`details`/`containers`/`images`, `lock status`) now surface the remote output host-prefixed (`[host] line`) instead of returning only a host-count summary — the dispatch layer previously discarded every `ssh.run()` result ([#2957](https://github.com/wheels-dev/wheels/issues/2957) DEP-6a). +- `wheels deploy` / `rollback` / `setup` now write the on-server audit trail (`/tmp/kamal-audit.log`): deploys are bracketed by `started deploy of version X` / `completed deploy of version X` records, rollbacks and setup's accessory boots are recorded, and audit writes are tolerated-on-failure so observability can never fail a deploy. Previously `AuditorCommands.record()` had zero call sites and `wheels deploy audit` tailed a file the tool never created ([#2957](https://github.com/wheels-dev/wheels/issues/2957) DEP-6b). +- The flat `wheels deploy bootstrap` / `wheels deploy exec` aliases — the only CLI-reachable form of these verbs ([#2677](https://github.com/wheels-dev/wheels/issues/2677)) — now seed their SSH pool from `deploy.yml`'s `ssh:` block like every other deploy verb; previously they constructed a bare pool that ignored `ssh.user`, `ssh.port`, and `keys:` and always connected as `root@host:22` ([#2957](https://github.com/wheels-dev/wheels/issues/2957) DEP-7). diff --git a/cli/lucli/Module.cfc b/cli/lucli/Module.cfc index 38b366b57..c717b64b4 100644 --- a/cli/lucli/Module.cfc +++ b/cli/lucli/Module.cfc @@ -2416,8 +2416,11 @@ component extends="modules.BaseModule" { // branch below is retained for Kamal parity and direct callers // (MCP, internal tests) that don't go through LuCLI's picocli root. case "bootstrap": + // #2957 DEP-7: build the pool from deploy.yml's ssh: block like + // every other verb — a bare `new SshPool()` here meant the only + // CLI-reachable bootstrap form ignored ssh.user/port/keys. var bootstrapCli = new modules.wheels.services.deploy.cli.DeployServerCli( - new modules.wheels.services.deploy.lib.SshPool() + $deployBuildSshPool(opts.configPath) ); return bootstrapCli.bootstrap(opts); case "exec": @@ -2430,8 +2433,9 @@ component extends="modules.BaseModule" { arrayAppend(execCmdParts, positional[ei]); } opts.cmd = arrayToList(execCmdParts, " "); + // #2957 DEP-7: same ssh-config seeding as the nested `server` branch. var execCli = new modules.wheels.services.deploy.cli.DeployServerCli( - new modules.wheels.services.deploy.lib.SshPool() + $deployBuildSshPool(opts.configPath) ); return execCli.exec(opts); case "server": diff --git a/cli/lucli/services/deploy/cli/DeployAppCli.cfc b/cli/lucli/services/deploy/cli/DeployAppCli.cfc index a1ba2aea9..957eb646a 100644 --- a/cli/lucli/services/deploy/cli/DeployAppCli.cfc +++ b/cli/lucli/services/deploy/cli/DeployAppCli.cfc @@ -17,6 +17,8 @@ component { variables.sshPool = arguments.sshPool; variables.loader = new modules.wheels.services.deploy.config.ConfigLoader(); variables.dryRunBuffer = []; + // Host-prefixed remote stdout collected by read verbs (#2957 DEP-6a). + variables.liveOutput = []; return this; } @@ -46,21 +48,21 @@ component { public string function details(required struct opts) { var n = $forEachHost(arguments.opts, function(cmds, role, version) { return cmds.status(role, version); - }); + }, {collect: true}); return $renderResult(arguments.opts, "Collected app details on " & n & " host(s)"); } public string function containers(required struct opts) { var n = $forEachHost(arguments.opts, function(cmds, role, version) { return cmds.containers(); - }, {versionOptional: true}); + }, {versionOptional: true, collect: true}); return $renderResult(arguments.opts, "Listed app containers on " & n & " host(s)"); } public string function images(required struct opts) { var n = $forEachHost(arguments.opts, function(cmds, role, version) { return cmds.images(); - }, {versionOptional: true}); + }, {versionOptional: true, collect: true}); return $renderResult(arguments.opts, "Listed app images on " & n & " host(s)"); } @@ -72,7 +74,7 @@ component { }; var n = $forEachHost(arguments.opts, function(cmds, role, version) { return cmds.logs(logOpts); - }, {versionOptional: true}); + }, {versionOptional: true, collect: true}); return $renderResult(arguments.opts, "Tailed app logs on " & n & " host(s)"); } @@ -101,6 +103,7 @@ component { private numeric function $forEachHost(required struct opts, required any cmdFn, struct flags = {}) { arrayClear(variables.dryRunBuffer); + arrayClear(variables.liveOutput); var cfg = variables.loader.load( arguments.opts.configPath, {destination: arguments.opts.destination ?: ""} @@ -112,6 +115,9 @@ component { message="This verb requires --version (e.g. --version=v1.2.3). On older wrappers that pre-date the picocli rewrite, pass --release instead."); } var dryRun = arguments.opts.dryRun ?: false; + // collect=true marks a read verb: remote stdout is gathered into + // liveOutput and appended to the live-mode summary (#2957 DEP-6a). + var collect = arguments.flags.collect ?: false; var appCmds = new modules.wheels.services.deploy.commands.AppCommands(cfg); var roleFilter = arguments.opts.role ?: ""; var hostCount = 0; @@ -120,7 +126,13 @@ component { if (len(roleFilter) && role.name() != roleFilter) continue; for (var host in role.hosts()) { var cmd = arguments.cmdFn(appCmds, role, version); - $dispatch([host], cmd, dryRun); + if (collect) { + for (var l in $dispatchCollect([host], cmd, dryRun)) { + arrayAppend(variables.liveOutput, l); + } + } else { + $dispatch([host], cmd, dryRun); + } hostCount++; } } @@ -131,6 +143,9 @@ component { if (arguments.opts.dryRun ?: false) { return arrayToList(variables.dryRunBuffer, chr(10)); } + if (arrayLen(variables.liveOutput)) { + return arguments.summary & chr(10) & arrayToList(variables.liveOutput, chr(10)); + } return arguments.summary; } @@ -145,4 +160,31 @@ component { var doRaise = !arguments.allowFail; variables.sshPool.onEach(arguments.hosts, function(ssh, host) { ssh.run(c, {raise: doRaise}); }); } + + /** + * Dispatch + collect host-prefixed remote output (`[host] line`) for + * read verbs (#2957 DEP-6a). Sequential so output order is stable and + * the collection stays single-threaded. Mirrors + * DeployMainCli.$dispatchCollect — keep the two in lockstep. + */ + private array function $dispatchCollect(required array hosts, required string cmd, required boolean dryRun, boolean allowFail = false) { + if (arguments.dryRun) { + for (var h in arguments.hosts) arrayAppend(variables.dryRunBuffer, "[" & h & "] " & arguments.cmd); + return []; + } + var c = arguments.cmd; + var doRaise = !arguments.allowFail; + var ctx = {lines: []}; + variables.sshPool.sequential(arguments.hosts, function(ssh, host) { + var res = ssh.run(c, {raise: doRaise}); + var text = trim(res.stdout ?: ""); + if (!len(text)) text = trim(res.stderr ?: ""); + if (!len(text)) return; + text = replace(text, chr(13), "", "all"); + for (var line in listToArray(text, chr(10))) { + arrayAppend(ctx.lines, "[" & host & "] " & line); + } + }); + return ctx.lines; + } } diff --git a/cli/lucli/services/deploy/cli/DeployLockCli.cfc b/cli/lucli/services/deploy/cli/DeployLockCli.cfc index c8949266b..61a8a0b76 100644 --- a/cli/lucli/services/deploy/cli/DeployLockCli.cfc +++ b/cli/lucli/services/deploy/cli/DeployLockCli.cfc @@ -49,8 +49,12 @@ component { // readlink exits nonzero when the lock file is missing — which is // exactly what the operator wants to learn from `status`. Treat that // as advisory output, not a thrown error. #2696. - $dispatchAny($allHosts(cfg), lock.status(), dryRun, true); - return $renderResult(arguments.opts, "Checked deploy lock status for " & cfg.service()); + // #2957 DEP-6a: surface readlink's output (the lock holder on stdout, + // or the "No such file" diagnostic on stderr) instead of dropping it. + var lines = $dispatchAnyCollect($allHosts(cfg), lock.status(), dryRun, true); + var summary = "Checked deploy lock status for " & cfg.service(); + if (arrayLen(lines)) summary &= chr(10) & arrayToList(lines, chr(10)); + return $renderResult(arguments.opts, summary); } private string function $renderResult(required struct opts, required string summary) { @@ -87,6 +91,35 @@ component { variables.sshPool.onAny(arguments.hosts, function(ssh, host) { ssh.run(c, {raise: doRaise}); }); } + /** + * Like $dispatchAny, but returns the remote output host-prefixed + * (`[host] line`). Stdout wins; stderr is the fallback so tolerated + * failures (allowFail) still surface their diagnostic. #2957 DEP-6a. + */ + private array function $dispatchAnyCollect(required array hosts, required string cmd, required boolean dryRun, boolean allowFail = false) { + if (arguments.dryRun) { + if (arrayLen(arguments.hosts)) { + arrayAppend(variables.dryRunBuffer, "[" & arguments.hosts[1] & "] " & arguments.cmd); + } + return []; + } + var c = arguments.cmd; + var doRaise = !arguments.allowFail; + // Closures can't write outer locals reliably — collect via a shared struct. + var ctx = {lines: []}; + variables.sshPool.onAny(arguments.hosts, function(ssh, host) { + var res = ssh.run(c, {raise: doRaise}); + var text = trim(res.stdout ?: ""); + if (!len(text)) text = trim(res.stderr ?: ""); + if (!len(text)) return; + text = replace(text, chr(13), "", "all"); + for (var line in listToArray(text, chr(10))) { + arrayAppend(ctx.lines, "[" & host & "] " & line); + } + }); + return ctx.lines; + } + private string function $currentUser() { var sys = createObject("java", "java.lang.System"); var user = sys.getenv("USER"); diff --git a/cli/lucli/services/deploy/cli/DeployMainCli.cfc b/cli/lucli/services/deploy/cli/DeployMainCli.cfc index 7d2d1f135..482524dee 100644 --- a/cli/lucli/services/deploy/cli/DeployMainCli.cfc +++ b/cli/lucli/services/deploy/cli/DeployMainCli.cfc @@ -77,6 +77,7 @@ component { var builder = new modules.wheels.services.deploy.commands.BuilderCommands(cfg); var dockerCmds = new modules.wheels.services.deploy.commands.DockerCommands(cfg); var lock = new modules.wheels.services.deploy.commands.LockCommands(cfg); + var auditor = new modules.wheels.services.deploy.commands.AuditorCommands(cfg); var hooks = new modules.wheels.services.deploy.commands.HookCommands( cfg, {projectRoot: variables.projectRoot} @@ -102,6 +103,11 @@ component { ); try { + // #2957 DEP-6b: write the on-server audit trail that the + // `audit` verb tails. Records bracket the work (started / + // completed) and are dispatched allowFail — observability + // must never fail a deploy. + $dispatch(hosts, auditor.record("started deploy of version " & ver), dryRun, true); $dispatch(hosts, builder.pull(ver), dryRun); // Fresh-host bootstrap (#2957 DEP-5c): every `docker run` // below joins --network kamal, so the network must exist @@ -130,6 +136,7 @@ component { } } } + $dispatch(hosts, auditor.record("completed deploy of version " & ver), dryRun, true); } finally { // Tolerate release failures so they can never shadow the // original deploy exception inside this finally block. rm -f @@ -196,6 +203,10 @@ component { arrayAppend(hostList, host); } } + // #2957 DEP-6b: rollbacks are exactly what an operator greps the audit + // log for later. allowFail — never let the log line fail the rollback. + var auditor = new modules.wheels.services.deploy.commands.AuditorCommands(cfg); + $dispatch(hostList, auditor.record("rolled back to version " & arguments.opts.version), dryRun, true); return $renderResult( arguments.opts, @@ -222,12 +233,15 @@ component { if (arrayLen(cfg.accessories())) { var dockerCmds = new modules.wheels.services.deploy.commands.DockerCommands(cfg); var accCmds = new modules.wheels.services.deploy.commands.AccessoryCommands(cfg); + var auditor = new modules.wheels.services.deploy.commands.AuditorCommands(cfg); for (var acc in cfg.accessories()) { // Accessories join --network kamal too, and may live on // hosts outside the app roles — ensure the network there // before the accessory container runs (#2957 DEP-5c). $dispatch(acc.hosts(), dockerCmds.ensure_network("kamal"), dryRun); $dispatch(acc.hosts(), accCmds.run(acc), dryRun); + // #2957 DEP-6b: audit trail for setup's accessory phase. + $dispatch(acc.hosts(), auditor.record("booted accessory " & acc.name()), dryRun, true); } } @@ -248,11 +262,15 @@ component { var cmd = "tail -n " & tail & " /tmp/kamal-audit.log"; var hosts = $allHosts(cfg); var dryRun = arguments.opts.dryRun ?: false; - $dispatch(hosts, cmd, dryRun); + // #2957 DEP-6a: surface the actual log content, not just a summary. + var lines = $dispatchCollect(hosts, cmd, dryRun); return $renderResult( arguments.opts, - "Tailed audit log (last " & tail & " lines) on " - & arrayLen(hosts) & " host(s): " & arrayToList(hosts, ", ") + $withRemoteOutput( + "Tailed audit log (last " & tail & " lines) on " + & arrayLen(hosts) & " host(s): " & arrayToList(hosts, ", "), + lines + ) ); } @@ -295,21 +313,26 @@ component { var proxyCmds = new modules.wheels.services.deploy.commands.ProxyCommands(cfg); var hosts = $allHosts(cfg); + // #2957 DEP-6a: read verbs surface the remote stdout, host-prefixed. + var lines = []; // app details: docker ps filtered by service label - $dispatch(hosts, appCmds.containers(), dryRun); + for (var l in $dispatchCollect(hosts, appCmds.containers(), dryRun)) arrayAppend(lines, l); // proxy details: docker ps filtered by kamal-proxy name - $dispatch(hosts, proxyCmds.details(), dryRun); + for (var l in $dispatchCollect(hosts, proxyCmds.details(), dryRun)) arrayAppend(lines, l); // accessory details (if any) if (arrayLen(cfg.accessories())) { var accCmds = new modules.wheels.services.deploy.commands.AccessoryCommands(cfg); for (var acc in cfg.accessories()) { - $dispatch(acc.hosts(), accCmds.details(acc), dryRun); + for (var l in $dispatchCollect(acc.hosts(), accCmds.details(acc), dryRun)) arrayAppend(lines, l); } } return $renderResult( arguments.opts, - "Collected app + proxy + accessory details from " - & arrayLen(hosts) & " host(s): " & arrayToList(hosts, ", ") + $withRemoteOutput( + "Collected app + proxy + accessory details from " + & arrayLen(hosts) & " host(s): " & arrayToList(hosts, ", "), + lines + ) ); } @@ -499,6 +522,51 @@ component { }); } + /** + * Dispatch a command and collect the remote output, host-prefixed + * (`[host] line`), in host order. Used by read verbs (`audit`, + * `details`) so live mode surfaces what the remote actually said + * instead of just a host-count summary (#2957 DEP-6a). Runs hosts + * sequentially — read output should arrive in a predictable order, + * and serial execution keeps the collection single-threaded. + * Falls back to stderr when a tolerated (allowFail) command produced + * no stdout, so e.g. "No such file" diagnostics still reach the user. + */ + private array function $dispatchCollect( + required array hosts, + required string cmd, + required boolean dryRun, + boolean allowFail = false + ) { + if (arguments.dryRun) { + for (var h in arguments.hosts) { + arrayAppend(variables.dryRunBuffer, "[" & h & "] " & arguments.cmd); + } + return []; + } + var c = arguments.cmd; + var doRaise = !arguments.allowFail; + // Closures can't write outer locals reliably — collect via a shared struct. + var ctx = {lines: []}; + variables.sshPool.sequential(arguments.hosts, function(ssh, host) { + var res = ssh.run(c, {raise: doRaise}); + var text = trim(res.stdout ?: ""); + if (!len(text)) text = trim(res.stderr ?: ""); + if (!len(text)) return; + text = replace(text, chr(13), "", "all"); + for (var line in listToArray(text, chr(10))) { + arrayAppend(ctx.lines, "[" & host & "] " & line); + } + }); + return ctx.lines; + } + + /** Append the remote output block to a live-mode summary (no-op when empty). */ + private string function $withRemoteOutput(required string summary, required array lines) { + if (!arrayLen(arguments.lines)) return arguments.summary; + return arguments.summary & chr(10) & arrayToList(arguments.lines, chr(10)); + } + /** * Dispatch a single command to "any one" host — used for operations * that only need to happen once across the fleet (lock acquire/release). diff --git a/cli/lucli/tests/specs/commands/DeployAliasSshPoolSpec.cfc b/cli/lucli/tests/specs/commands/DeployAliasSshPoolSpec.cfc new file mode 100644 index 000000000..f5f2d00b8 --- /dev/null +++ b/cli/lucli/tests/specs/commands/DeployAliasSshPoolSpec.cfc @@ -0,0 +1,57 @@ +/** + * Regression guard for #2957 DEP-7 — the flat `wheels deploy bootstrap` / + * `wheels deploy exec` aliases constructed a bare `new SshPool()` instead of + * routing through `$deployBuildSshPool(opts.configPath)` like every other + * deploy verb. A bare pool ignores the deploy.yml `ssh:` block entirely, so + * any config with a non-root `ssh.user`, custom `ssh.port`, or a `keys:` + * path got `root@host:22` + ssh-agent instead. The flat aliases are the only + * CLI-reachable form (LuCLI's picocli root absorbs the nested `server` verb + * before module dispatch — #2677), so for real CLI users the broken pool was + * the only pool. + * + * Like every other Module.cfc spec, source-level inspection — Module extends + * `modules.BaseModule`, which is only resolvable at LuCLI runtime, and the + * constructed pool is buried inside DeployServerCli with no accessor. The + * config-seeding behavior of `$deployBuildSshPool` itself is pinned by + * SshPoolFactorySpec; this spec pins that the aliases actually call it. + */ +component extends="wheels.wheelstest.system.BaseSpec" { + + function beforeAll() { + variables.moduleSource = fileRead(expandPath("/cli/lucli/Module.cfc")); + } + + function run() { + describe("deploy flat aliases honor ssh: config (##2957 DEP-7)", () => { + + it("no deploy CLI is ever constructed with a bare config-less SshPool", () => { + // Every pool the deploy dispatcher hands to a Deploy*Cli must be + // seeded from deploy.yml via $deployBuildSshPool. A literal + // `new modules.wheels.services.deploy.lib.SshPool(` anywhere in + // Module.cfc means some verb is skipping the ssh: config again. + expect(find("new modules.wheels.services.deploy.lib.SshPool(", variables.moduleSource)) + .toBe(0); + }); + + it("the flat bootstrap alias builds its pool from the deploy config", () => { + var body = $caseBody('case "bootstrap":', 'case "exec":'); + expect(body).toInclude("$deployBuildSshPool(opts.configPath)"); + }); + + it("the flat exec alias builds its pool from the deploy config", () => { + var body = $caseBody('case "exec":', 'case "server":'); + expect(body).toInclude("$deployBuildSshPool(opts.configPath)"); + }); + + }); + } + + /** Slice of Module.cfc between two case labels (asserts both exist). */ + private string function $caseBody(required string fromLabel, required string toLabel) { + var startPos = find(arguments.fromLabel, variables.moduleSource); + var endPos = find(arguments.toLabel, variables.moduleSource, startPos + 1); + expect(startPos).toBeGT(0, "case label not found: " & arguments.fromLabel); + expect(endPos).toBeGT(startPos, "case label not found after start: " & arguments.toLabel); + return mid(variables.moduleSource, startPos, endPos - startPos); + } +} diff --git a/cli/lucli/tests/specs/deploy/cli/DeployAppCliSpec.cfc b/cli/lucli/tests/specs/deploy/cli/DeployAppCliSpec.cfc index d056559d2..1383f4b99 100644 --- a/cli/lucli/tests/specs/deploy/cli/DeployAppCliSpec.cfc +++ b/cli/lucli/tests/specs/deploy/cli/DeployAppCliSpec.cfc @@ -97,6 +97,38 @@ component extends="wheels.wheelstest.system.BaseSpec" { expect(out).toInclude("docker stop"); }); + // Regression suite for #2957 (Wave 3, DEP-6a) — read verbs dropped + // every ssh.run() result, so `app logs` / `app details` returned + // only a host-count summary in live mode. + + it("logs (real mode) surfaces the remote log output host-prefixed (##2957 DEP-6a)", () => { + var fake = new cli.lucli.services.deploy.lib.FakeSshPool(); + var cfg = new cli.lucli.services.deploy.config.ConfigLoader().load(variables.fixture); + var appCmds = new cli.lucli.services.deploy.commands.AppCommands(cfg); + var logsCmd = appCmds.logs({tail: 100, follow: false, container: ""}); + fake.expect("1.2.3.4", logsCmd, { + exitCode: 0, + stdout: "line one" & chr(10) & "line two", + stderr: "", durationMs: 0 + }); + var cli = new cli.lucli.services.deploy.cli.DeployAppCli(fake); + var out = cli.logs({configPath: variables.fixture}); + expect(out).toInclude("[1.2.3.4] line one"); + expect(out).toInclude("[1.2.3.4] line two"); + }); + + it("containers (real mode) surfaces the remote docker ps output (##2957 DEP-6a)", () => { + var fake = new cli.lucli.services.deploy.lib.FakeSshPool(); + var cfg = new cli.lucli.services.deploy.config.ConfigLoader().load(variables.fixture); + var appCmds = new cli.lucli.services.deploy.commands.AppCommands(cfg); + fake.expect("1.2.3.4", appCmds.containers(), { + exitCode: 0, stdout: "abc123 acme/demo:v1 Up 2 hours", stderr: "", durationMs: 0 + }); + var cli = new cli.lucli.services.deploy.cli.DeployAppCli(fake); + var out = cli.containers({configPath: variables.fixture}); + expect(out).toInclude("[1.2.3.4] abc123 acme/demo:v1 Up 2 hours"); + }); + // Issue #3111: `--release=1` hung ~76s under --dry-run because the // argv round-trip dropped the numeric value and the parser then // swallowed --dry-run. Pin the contract at this layer too: a diff --git a/cli/lucli/tests/specs/deploy/cli/DeployLockCliSpec.cfc b/cli/lucli/tests/specs/deploy/cli/DeployLockCliSpec.cfc index ee7cf0a40..987ac7cac 100644 --- a/cli/lucli/tests/specs/deploy/cli/DeployLockCliSpec.cfc +++ b/cli/lucli/tests/specs/deploy/cli/DeployLockCliSpec.cfc @@ -39,6 +39,37 @@ component extends="wheels.wheelstest.system.BaseSpec" { expect(fake.calls()[1].cmd).toInclude("readlink /tmp/kamal_deploy_lock_demo"); }); + // Regression suite for #2957 (Wave 3, DEP-6a) — `lock status` + // dropped the ssh.run() result, so the operator never saw who + // holds the lock (readlink's stdout) in live mode. + + it("status (real mode) surfaces the lock holder from readlink stdout (##2957 DEP-6a)", () => { + var fake = new cli.lucli.services.deploy.lib.FakeSshPool(); + var cfg = new cli.lucli.services.deploy.config.ConfigLoader().load(variables.fixture); + var lockCmds = new cli.lucli.services.deploy.commands.LockCommands(cfg); + fake.expect("1.2.3.4", lockCmds.status(), { + exitCode: 0, stdout: "deploy@ci-runner/2026-06-12T10:00:00/deploy v1", + stderr: "", durationMs: 0 + }); + var cli = new cli.lucli.services.deploy.cli.DeployLockCli(fake); + var out = cli.status({configPath: variables.fixture}); + expect(out).toInclude("[1.2.3.4] deploy@ci-runner/2026-06-12T10:00:00/deploy v1"); + }); + + it("status (real mode) falls back to stderr when readlink fails (no lock held) (##2957 DEP-6a)", () => { + var fake = new cli.lucli.services.deploy.lib.FakeSshPool(); + var cfg = new cli.lucli.services.deploy.config.ConfigLoader().load(variables.fixture); + var lockCmds = new cli.lucli.services.deploy.commands.LockCommands(cfg); + fake.expect("1.2.3.4", lockCmds.status(), { + exitCode: 1, stdout: "", + stderr: "readlink: /tmp/kamal_deploy_lock_demo: No such file or directory", + durationMs: 0 + }); + var cli = new cli.lucli.services.deploy.cli.DeployLockCli(fake); + var out = cli.status({configPath: variables.fixture}); + expect(out).toInclude("[1.2.3.4] readlink: /tmp/kamal_deploy_lock_demo: No such file or directory"); + }); + it("dry-run buffers output instead of dispatching", () => { var fake = new cli.lucli.services.deploy.lib.FakeSshPool(); var cli = new cli.lucli.services.deploy.cli.DeployLockCli(fake); diff --git a/cli/lucli/tests/specs/deploy/cli/DeployMainCliSpec.cfc b/cli/lucli/tests/specs/deploy/cli/DeployMainCliSpec.cfc index 2a3d63400..0e62c679b 100644 --- a/cli/lucli/tests/specs/deploy/cli/DeployMainCliSpec.cfc +++ b/cli/lucli/tests/specs/deploy/cli/DeployMainCliSpec.cfc @@ -809,6 +809,104 @@ component extends="wheels.wheelstest.system.BaseSpec" { expect(out).toInclude("--name demo-web-v1"); }); + // Regression suite for #2957 (Wave 3 — observability). (DEP-6a) the + // dispatch closures dropped every ssh.run() result, so read verbs + // (`audit`, `details`) returned only host-count summaries in live + // mode — the operator never saw the remote output. (DEP-6b) + // AuditorCommands.record() had zero call sites, so `audit` tailed + // /tmp/kamal-audit.log — a file this tool never wrote. + + it("audit (real mode) surfaces the remote log content host-prefixed (##2957 DEP-6a)", () => { + var fake = new cli.lucli.services.deploy.lib.FakeSshPool(); + fake.expect("1.2.3.4", "tail -n 100 /tmp/kamal-audit.log", { + exitCode: 0, + stdout: "2026-06-12T10:00:00 demo started deploy of version v1" & chr(10) + & "2026-06-12T10:01:00 demo completed deploy of version v1", + stderr: "", durationMs: 0 + }); + var dc = new cli.lucli.services.deploy.cli.DeployMainCli(fake); + var out = dc.audit({configPath: variables.fixture}); + expect(out).toInclude("[1.2.3.4] 2026-06-12T10:00:00 demo started deploy of version v1"); + expect(out).toInclude("[1.2.3.4] 2026-06-12T10:01:00 demo completed deploy of version v1"); + }); + + it("details (real mode) surfaces the remote docker ps output host-prefixed (##2957 DEP-6a)", () => { + var fake = new cli.lucli.services.deploy.lib.FakeSshPool(); + var cfg = new cli.lucli.services.deploy.config.ConfigLoader().load(variables.fixture); + var appCmds = new cli.lucli.services.deploy.commands.AppCommands(cfg); + var proxyCmds = new cli.lucli.services.deploy.commands.ProxyCommands(cfg); + fake.expect("1.2.3.4", appCmds.containers(), { + exitCode: 0, stdout: "abc123 acme/demo:v1 Up 2 hours", stderr: "", durationMs: 0 + }); + fake.expect("1.2.3.4", proxyCmds.details(), { + exitCode: 0, stdout: "def456 basecamp/kamal-proxy Up 3 days", stderr: "", durationMs: 0 + }); + var dc = new cli.lucli.services.deploy.cli.DeployMainCli(fake); + var out = dc.details({configPath: variables.fixture}); + expect(out).toInclude("[1.2.3.4] abc123 acme/demo:v1 Up 2 hours"); + expect(out).toInclude("[1.2.3.4] def456 basecamp/kamal-proxy Up 3 days"); + }); + + it("deploy brackets the work with started/completed audit records (##2957 DEP-6b)", () => { + var fake = new cli.lucli.services.deploy.lib.FakeSshPool(); + var dc = new cli.lucli.services.deploy.cli.DeployMainCli(fake); + dc.deploy({configPath: variables.fixture, version: "v1"}); + var cfg = new cli.lucli.services.deploy.config.ConfigLoader().load(variables.fixture); + var auditor = new cli.lucli.services.deploy.commands.AuditorCommands(cfg); + var startedCmd = auditor.record("started deploy of version v1"); + var completedCmd = auditor.record("completed deploy of version v1"); + var cmds = $cmds(fake); + var acquireIdx = 0; var startedIdx = 0; var pullIdx = 0; + var runIdx = 0; var completedIdx = 0; var releaseIdx = 0; + for (var i = 1; i <= arrayLen(cmds); i++) { + if (!acquireIdx && findNoCase("ln -s ", cmds[i])) acquireIdx = i; + if (!startedIdx && cmds[i] == startedCmd) startedIdx = i; + if (!pullIdx && findNoCase("docker pull", cmds[i])) pullIdx = i; + if (!runIdx && findNoCase("docker run --detach", cmds[i])) runIdx = i; + if (!completedIdx && cmds[i] == completedCmd) completedIdx = i; + if (!releaseIdx && findNoCase("rm -f ", cmds[i]) && findNoCase("kamal_deploy_lock", cmds[i])) releaseIdx = i; + } + // started: after the lock, before any work; completed: after the + // app run, before the lock release. + expect(startedIdx).toBeGT(acquireIdx); + expect(pullIdx).toBeGT(startedIdx); + expect(completedIdx).toBeGT(runIdx); + expect(releaseIdx).toBeGT(completedIdx); + }); + + it("rollback records a rolled-back audit line (##2957 DEP-6b)", () => { + var fake = new cli.lucli.services.deploy.lib.FakeSshPool(); + var dc = new cli.lucli.services.deploy.cli.DeployMainCli(fake); + dc.rollback({configPath: variables.fixture, version: "v-old"}); + var cfg = new cli.lucli.services.deploy.config.ConfigLoader().load(variables.fixture); + var auditor = new cli.lucli.services.deploy.commands.AuditorCommands(cfg); + expect($cmds(fake)).toInclude(auditor.record("rolled back to version v-old")); + }); + + it("setup records booted-accessory audit lines on the accessory hosts (##2957 DEP-6b)", () => { + var fake = new cli.lucli.services.deploy.lib.FakeSshPool(); + var dc = new cli.lucli.services.deploy.cli.DeployMainCli(fake); + dc.setup({configPath: variables.accessoriesFixture, version: "v1"}); + var cfg = new cli.lucli.services.deploy.config.ConfigLoader().load(variables.accessoriesFixture); + var auditor = new cli.lucli.services.deploy.commands.AuditorCommands(cfg); + var dbRecord = auditor.record("booted accessory db"); + expect($cmds(fake)).toInclude(dbRecord); + expect($cmds(fake)).toInclude(auditor.record("booted accessory redis")); + expect($hostsFor(fake, dbRecord)).toInclude("1.2.3.5"); + }); + + it("a failing audit record never fails the deploy (##2957 DEP-6b)", () => { + var fake = new cli.lucli.services.deploy.lib.FakeSshPool(); + var cfg = new cli.lucli.services.deploy.config.ConfigLoader().load(variables.fixture); + var auditor = new cli.lucli.services.deploy.commands.AuditorCommands(cfg); + fake.expect("1.2.3.4", auditor.record("started deploy of version v1"), { + exitCode: 1, stdout: "", stderr: "sh: /tmp/kamal-audit.log: Read-only file system" + }); + var dc = new cli.lucli.services.deploy.cli.DeployMainCli(fake); + var out = dc.deploy({configPath: variables.fixture, version: "v1"}); + expect(out).toInclude("Deployed"); + }); + // Regression for #2671 — git's stderr ("fatal: not a git repository...") used to leak through as the version string. it("$gitShortSha() returns 'unknown' when run outside a git repo", () => { var nonGitDir = getTempDirectory() & "/wheels-2671-main-" & createUUID();