Skip to content

Commit df62f46

Browse files
bpamiriPeter Amiri
andauthored
feat(cli): deliver env.secret to containers via remote env file (#2957) (#3167)
* feat(cli): deliver env.secret to containers via remote env file (#2957) Implements the env.secret delivery feature #3008 deliberately deferred (its EnvSecretUnsupported fail-fast pointed users at #2957). Kamal model: - Values resolve through the SecretResolver the ConfigLoader already builds (new secretResolver() accessor); env-file content renders once per verb. - The remote file is created and chmod'd 600 BEFORE content lands (mkdir + touch + chmod 600), then the content travels over SFTP via uploadString — values never enter argv, dry-run output, or exception command summaries. - docker run references the file via --env-file (.kamal/apps/<service[-destination]>/env/roles/<role>.env; accessories use .../env/accessories/<name>.env). - Wired into deploy(), app boot, and accessory boot/reboot. rollback/start reuse the env baked into the existing container. - A declared name with no resolvable value throws Wheels.Deploy.EnvSecretMissing (missing names only, values never read) before the lock or any remote call. Base.$rejectEnvSecrets is removed. - env.clear stays as escaped -e pairs; per-role env merge remains out of scope (#3088 note unchanged). Specs: FakeSshPool ordering (ensure -> upload -> run), 600 perms in the command, secret values absent from every command summary, dry-run redaction, fail-fast with zero pool calls. CLI suite (lucee7 docker harness): 1004 pass / 0 fail / 2 tolerated docker-env artifacts. Real SSH delivery is unverifiable in-harness; FakeSshPool + --dry-run flows are the bar. Guides updated: deployment/secrets, config-reference, accessories, first-deploy, migrating-from-kamal now describe delivery on 4.0.4+ builds vs silent drop on released 4.0.3. Refs #2957 Signed-off-by: Peter Amiri <peter@alurium.com> * fix(cli): keep deploy env files at 600 perms through the SFTP upload sshj's SFTPFileTransfer defaults preserveAttributes=true and FileSystemFile.getPermissions() hardcodes 0644 for regular files, so every SFTPClient.put() chmod'ed the remote file to 0644 right after the ensure command had locked it to 600 — with the secret content inside (verified against the bundled sshj-0.39.0 bytecode). - SshClient.upload(): setPreserveAttributes(false) so an upload never touches remote permissions. FakeSshPool cannot regression-test this (it records calls without SFTP attribute semantics) — documented at the call site. - $deliverEnvFile (all three mirrors): dispatch a relock command (chmod 600) after the upload as belt-and-braces; this leg IS pinned by the FakeSshPool specs (ensure -> upload -> relock -> docker run). - New AppCommands.relock_env_file() / AccessoryCommands .relock_env_file() builders over Base.$relockEnvFileCmd(). - Docs/changelog updated to state the file is re-locked after upload. CLI suite: 1006 pass / 0 fail / 2 errors (known docker-not-found artifacts in SshClientSpec/SshPoolSpec inside the harness container). Real-SSH SFTP behavior is unverifiable in-harness; the fake-pool specs plus the dispatched relock are the testable bar. Signed-off-by: Peter Amiri <peter@alurium.com> --------- Signed-off-by: Peter Amiri <peter@alurium.com> Signed-off-by: Peter Amiri <petera@pai.com> Co-authored-by: Peter Amiri <petera@pai.com>
1 parent b05d13a commit df62f46

21 files changed

Lines changed: 839 additions & 55 deletions
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
- `wheels deploy` now delivers `env.secret` values to app and accessory containers via a remote env file (Kamal model): the file is created with 600 permissions before any content lands and re-locked to 600 right after the upload (the SFTP layer is also told not to carry local file attributes onto the remote), values travel over SFTP only (never argv, dry-run output, or exception summaries), and `docker run` references it with `--env-file`. A declared secret with no resolvable `.kamal/secrets` value fails fast with `Wheels.Deploy.EnvSecretMissing` (names only) before any remote call; the `Wheels.Deploy.EnvSecretUnsupported` fail-fast from #3008 is retired (#2957)

cli/lucli/services/deploy/cli/DeployAccessoryCli.cfc

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,23 @@ component {
6363
var names = [];
6464

6565
for (var acc in targets) {
66+
// env.secret delivery (#2957): verbs that (re)create the container
67+
// get the accessory env file written — 600 perms first, content
68+
// over SFTP — before docker run references it via --env-file.
69+
if (listFindNoCase("run,reboot", arguments.method)) {
70+
var accSecrets = acc.env().secret();
71+
if (arrayLen(accSecrets)) {
72+
$deliverEnvFile(
73+
acc.hosts(),
74+
accCmds.ensure_env_file(acc),
75+
accCmds.relock_env_file(acc),
76+
accCmds.env_file_content(accSecrets, $resolvedSecrets()),
77+
accCmds.env_file_path(acc),
78+
accSecrets,
79+
dryRun
80+
);
81+
}
82+
}
6683
var cmd = structIsEmpty(arguments.methodOpts)
6784
? invoke(accCmds, arguments.method, [acc])
6885
: invoke(accCmds, arguments.method, [acc, arguments.methodOpts]);
@@ -94,4 +111,56 @@ component {
94111
var doRaise = !arguments.allowFail;
95112
variables.sshPool.onEach(arguments.hosts, function(ssh, host) { ssh.run(c, {raise: doRaise}); });
96113
}
114+
115+
/**
116+
* Resolved key→value map from the SecretResolver the loader built for
117+
* the most recent load(). Empty struct when no resolver exists.
118+
*/
119+
private struct function $resolvedSecrets() {
120+
var resolver = variables.loader.secretResolver();
121+
return isObject(resolver) ? resolver.all() : {};
122+
}
123+
124+
/**
125+
* Deliver env.secret content to `remotePath` on each host (#2957):
126+
* ensure-cmd (mkdir + touch + chmod 600) first so the file is
127+
* permission-locked before content lands, then SFTP via uploadString —
128+
* values never enter argv or dry-run output — then relock-cmd
129+
* (chmod 600) AFTER the upload, belt-and-braces against the SFTP layer
130+
* resetting perms to 0644 (sshj's preserve-attributes default;
131+
* SshClient disables it, but FakeSshPool can't verify that, so the
132+
* re-lock is the testable guarantee). Mirrors
133+
* DeployMainCli.$deliverEnvFile (each Cli keeps its own dispatch
134+
* plumbing by design).
135+
*/
136+
private void function $deliverEnvFile(
137+
required array hosts,
138+
required string ensureCmd,
139+
required string relockCmd,
140+
required string content,
141+
required string remotePath,
142+
required array secretNames,
143+
required boolean dryRun
144+
) {
145+
$dispatch(arguments.hosts, arguments.ensureCmd, arguments.dryRun);
146+
if (arguments.dryRun) {
147+
for (var h in arguments.hosts) {
148+
arrayAppend(
149+
variables.dryRunBuffer,
150+
"[" & h & "] upload env file " & arguments.remotePath
151+
& " (" & arrayLen(arguments.secretNames) & " secret(s): "
152+
& arrayToList(arguments.secretNames, ", ") & " — values not shown)"
153+
);
154+
}
155+
$dispatch(arguments.hosts, arguments.relockCmd, arguments.dryRun);
156+
return;
157+
}
158+
var c = arguments.content;
159+
var p = arguments.remotePath;
160+
var relock = arguments.relockCmd;
161+
variables.sshPool.onEach(arguments.hosts, function(ssh, host) {
162+
ssh.uploadString(c, p);
163+
ssh.run(relock, {raise: true});
164+
});
165+
}
97166
}

cli/lucli/services/deploy/cli/DeployAppCli.cfc

Lines changed: 75 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,9 +25,11 @@ component {
2525
public array function dryRunOutput() { return variables.dryRunBuffer; }
2626

2727
public string function boot(required struct opts) {
28+
// boot (re)creates the container, so env.secret values must be
29+
// delivered to the role env file first (#2957).
2830
var n = $forEachHost(arguments.opts, function(cmds, role, version) {
2931
return cmds.run(role, version);
30-
});
32+
}, {deliverEnvFile: true});
3133
return $renderResult(arguments.opts, "Booted app on " & n & " host(s)");
3234
}
3335

@@ -122,9 +124,29 @@ component {
122124
var roleFilter = arguments.opts.role ?: "";
123125
var hostCount = 0;
124126

127+
// env.secret delivery (#2957): container-(re)creating verbs opt in
128+
// via flags.deliverEnvFile. Content renders once — an unresolvable
129+
// secret fails fast locally before any remote call.
130+
var secretNames = (arguments.flags.deliverEnvFile ?: false) ? cfg.env().secret() : [];
131+
var envFileContent = "";
132+
if (arrayLen(secretNames)) {
133+
envFileContent = appCmds.env_file_content(secretNames, $resolvedSecrets());
134+
}
135+
125136
for (var role in cfg.roles()) {
126137
if (len(roleFilter) && role.name() != roleFilter) continue;
127138
for (var host in role.hosts()) {
139+
if (arrayLen(secretNames)) {
140+
$deliverEnvFile(
141+
[host],
142+
appCmds.ensure_env_file(role),
143+
appCmds.relock_env_file(role),
144+
envFileContent,
145+
appCmds.env_file_path(role),
146+
secretNames,
147+
dryRun
148+
);
149+
}
128150
var cmd = arguments.cmdFn(appCmds, role, version);
129151
if (collect) {
130152
for (var l in $dispatchCollect([host], cmd, dryRun)) {
@@ -161,6 +183,58 @@ component {
161183
variables.sshPool.onEach(arguments.hosts, function(ssh, host) { ssh.run(c, {raise: doRaise}); });
162184
}
163185

186+
/**
187+
* Resolved key→value map from the SecretResolver the loader built for
188+
* the most recent load(). Empty struct when no resolver exists.
189+
*/
190+
private struct function $resolvedSecrets() {
191+
var resolver = variables.loader.secretResolver();
192+
return isObject(resolver) ? resolver.all() : {};
193+
}
194+
195+
/**
196+
* Deliver env.secret content to `remotePath` on each host (#2957):
197+
* ensure-cmd (mkdir + touch + chmod 600) first so the file is
198+
* permission-locked before content lands, then SFTP via uploadString —
199+
* values never enter argv or dry-run output — then relock-cmd
200+
* (chmod 600) AFTER the upload, belt-and-braces against the SFTP layer
201+
* resetting perms to 0644 (sshj's preserve-attributes default;
202+
* SshClient disables it, but FakeSshPool can't verify that, so the
203+
* re-lock is the testable guarantee). Mirrors
204+
* DeployMainCli.$deliverEnvFile (each Cli keeps its own dispatch
205+
* plumbing by design).
206+
*/
207+
private void function $deliverEnvFile(
208+
required array hosts,
209+
required string ensureCmd,
210+
required string relockCmd,
211+
required string content,
212+
required string remotePath,
213+
required array secretNames,
214+
required boolean dryRun
215+
) {
216+
$dispatch(arguments.hosts, arguments.ensureCmd, arguments.dryRun);
217+
if (arguments.dryRun) {
218+
for (var h in arguments.hosts) {
219+
arrayAppend(
220+
variables.dryRunBuffer,
221+
"[" & h & "] upload env file " & arguments.remotePath
222+
& " (" & arrayLen(arguments.secretNames) & " secret(s): "
223+
& arrayToList(arguments.secretNames, ", ") & " — values not shown)"
224+
);
225+
}
226+
$dispatch(arguments.hosts, arguments.relockCmd, arguments.dryRun);
227+
return;
228+
}
229+
var c = arguments.content;
230+
var p = arguments.remotePath;
231+
var relock = arguments.relockCmd;
232+
variables.sshPool.onEach(arguments.hosts, function(ssh, host) {
233+
ssh.uploadString(c, p);
234+
ssh.run(relock, {raise: true});
235+
});
236+
}
237+
164238
/**
165239
* Dispatch + collect host-prefixed remote output (`[host] line`) for
166240
* read verbs (#2957 DEP-6a). Sequential so output order is stable and

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

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,18 @@ component {
9393
// --target. Resolved from proxy.app_port (default 80); see #3089.
9494
var appPort = cfg.proxy().appPort();
9595

96+
// env.secret delivery (#2957): render the env-file content ONCE,
97+
// before the lock or any remote call, so an unresolvable secret
98+
// fails fast locally (Wheels.Deploy.EnvSecretMissing) without
99+
// acquiring — and then stranding — the deploy lock. Values come
100+
// from the SecretResolver the loader already built; they reach the
101+
// host only via SFTP (uploadString), never argv.
102+
var secretNames = cfg.env().secret();
103+
var envFileContent = "";
104+
if (arrayLen(secretNames)) {
105+
envFileContent = app.env_file_content(secretNames, $resolvedSecrets());
106+
}
107+
96108
$fireHook(hooks, "pre-deploy", hookEnv, dryRun);
97109

98110
try {
@@ -124,6 +136,17 @@ component {
124136

125137
for (var role in cfg.roles()) {
126138
for (var host in role.hosts()) {
139+
if (arrayLen(secretNames)) {
140+
$deliverEnvFile(
141+
[host],
142+
app.ensure_env_file(role),
143+
app.relock_env_file(role),
144+
envFileContent,
145+
app.env_file_path(role),
146+
secretNames,
147+
dryRun
148+
);
149+
}
127150
$dispatch([host], app.run(role, ver), dryRun);
128151
// Only proxy-fronted roles register with kamal-proxy —
129152
// job/worker roles serve no traffic (#2957).
@@ -522,6 +545,59 @@ component {
522545
});
523546
}
524547

548+
/**
549+
* Resolved key→value map from the SecretResolver the loader built for
550+
* the most recent load(). Empty struct when no resolver exists (e.g. a
551+
* loader injected with one in tests that never loaded).
552+
*/
553+
private struct function $resolvedSecrets() {
554+
var resolver = variables.loader.secretResolver();
555+
return isObject(resolver) ? resolver.all() : {};
556+
}
557+
558+
/**
559+
* Deliver env.secret content to `remotePath` on each host (#2957):
560+
* 1. dispatch ensure-cmd (mkdir + touch + chmod 600) so the file is
561+
* permission-locked BEFORE any content lands,
562+
* 2. SFTP the content via uploadString — values never enter argv,
563+
* dry-run output, or exception command summaries,
564+
* 3. dispatch relock-cmd (chmod 600) AFTER the upload — belt-and-braces
565+
* against the SFTP layer resetting perms to 0644 (sshj's
566+
* preserve-attributes default; SshClient disables it, but FakeSshPool
567+
* can't verify that, so this re-lock is the testable guarantee).
568+
* Under dryRun, records the upload by path and secret NAMES only.
569+
*/
570+
private void function $deliverEnvFile(
571+
required array hosts,
572+
required string ensureCmd,
573+
required string relockCmd,
574+
required string content,
575+
required string remotePath,
576+
required array secretNames,
577+
required boolean dryRun
578+
) {
579+
$dispatch(arguments.hosts, arguments.ensureCmd, arguments.dryRun);
580+
if (arguments.dryRun) {
581+
for (var h in arguments.hosts) {
582+
arrayAppend(
583+
variables.dryRunBuffer,
584+
"[" & h & "] upload env file " & arguments.remotePath
585+
& " (" & arrayLen(arguments.secretNames) & " secret(s): "
586+
& arrayToList(arguments.secretNames, ", ") & " — values not shown)"
587+
);
588+
}
589+
$dispatch(arguments.hosts, arguments.relockCmd, arguments.dryRun);
590+
return;
591+
}
592+
var c = arguments.content;
593+
var p = arguments.remotePath;
594+
var relock = arguments.relockCmd;
595+
variables.sshPool.onEach(arguments.hosts, function(ssh, host) {
596+
ssh.uploadString(c, p);
597+
ssh.run(relock, {raise: true});
598+
});
599+
}
600+
525601
/**
526602
* Dispatch a command and collect the remote output, host-prefixed
527603
* (`[host] line`), in host order. Used by read verbs (`audit`,

cli/lucli/services/deploy/cli/docs/env.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,12 @@ and `secret` (resolved from `.kamal/secrets` at deploy time).
2626
## `.kamal/secrets` — plain-text file, out of git
2727

2828
`.kamal/secrets` is a simple KEY=value file. Deploy reads it locally, then
29-
ships values into containers via `--env`. Never check it in.
29+
ships `env.secret` values to each host as an env file
30+
(`.kamal/apps/<service>/env/...`, locked to 600 permissions both before
31+
and after the content upload) that
32+
`docker run` references via `--env-file` — secret values never appear on a
33+
command line. `env.clear` values ride as escaped `-e` pairs. Never check
34+
`.kamal/secrets` in.
3035

3136
# .kamal/secrets
3237
DATABASE_URL=postgres://user:pass@db/app

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

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,15 +87,58 @@ component extends="Base" {
8787
return parts;
8888
}
8989

90+
/**
91+
* env.clear values ride as escaped -e pairs; env.secret values NEVER
92+
* enter argv — run() references the remote env file (written with 600
93+
* perms by the orchestration layer before this command is dispatched)
94+
* via --env-file instead (##2957).
95+
*/
9096
private array function $envArgs(required any accessory) {
9197
var env = arguments.accessory.env();
92-
$rejectEnvSecrets(env);
9398
var parts = [];
9499
var clear = env.clear();
95100
for (var k in clear) {
96101
arrayAppend(parts, "-e");
97102
arrayAppend(parts, shellEscape(k & "=" & clear[k]));
98103
}
104+
if (arrayLen(env.secret())) {
105+
arrayAppend(parts, "--env-file");
106+
arrayAppend(parts, env_file_path(arguments.accessory));
107+
}
99108
return parts;
100109
}
110+
111+
/**
112+
* Remote env-file path for an accessory, relative to the SSH user's
113+
* home. Namespaced by service and destination, mirroring Kamal's
114+
* .kamal/apps/<service[-destination]>/env/accessories/<name>.env layout.
115+
*/
116+
public string function env_file_path(required any accessory) {
117+
return $envAccessoriesDir() & "/" & arguments.accessory.name() & ".env";
118+
}
119+
120+
/**
121+
* Preparation command for the accessory env file: mkdir + touch +
122+
* chmod 600 BEFORE the secret content is uploaded over SFTP (##2957).
123+
*/
124+
public string function ensure_env_file(required any accessory) {
125+
return $ensureEnvFileCmd($envAccessoriesDir(), env_file_path(arguments.accessory));
126+
}
127+
128+
/**
129+
* Re-lock command for the accessory env file: chmod 600 AFTER the
130+
* content upload, guarding against the SFTP layer resetting perms
131+
* (##2957).
132+
*/
133+
public string function relock_env_file(required any accessory) {
134+
return $relockEnvFileCmd(env_file_path(arguments.accessory));
135+
}
136+
137+
private string function $envAccessoriesDir() {
138+
var ns = variables.config.service();
139+
if (len(variables.config.destination())) {
140+
ns &= "-" & variables.config.destination();
141+
}
142+
return ".kamal/apps/" & ns & "/env/accessories";
143+
}
101144
}

0 commit comments

Comments
 (0)