Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/deploy-env-secret-env-file-delivery.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +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)
69 changes: 69 additions & 0 deletions cli/lucli/services/deploy/cli/DeployAccessoryCli.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,23 @@ component {
var names = [];

for (var acc in targets) {
// env.secret delivery (#2957): verbs that (re)create the container
// get the accessory env file written — 600 perms first, content
// over SFTP — before docker run references it via --env-file.
if (listFindNoCase("run,reboot", arguments.method)) {
var accSecrets = acc.env().secret();
if (arrayLen(accSecrets)) {
$deliverEnvFile(
acc.hosts(),
accCmds.ensure_env_file(acc),
accCmds.relock_env_file(acc),
accCmds.env_file_content(accSecrets, $resolvedSecrets()),
accCmds.env_file_path(acc),
accSecrets,
dryRun
);
}
}
var cmd = structIsEmpty(arguments.methodOpts)
? invoke(accCmds, arguments.method, [acc])
: invoke(accCmds, arguments.method, [acc, arguments.methodOpts]);
Expand Down Expand Up @@ -94,4 +111,56 @@ component {
var doRaise = !arguments.allowFail;
variables.sshPool.onEach(arguments.hosts, function(ssh, host) { ssh.run(c, {raise: doRaise}); });
}

/**
* Resolved key→value map from the SecretResolver the loader built for
* the most recent load(). Empty struct when no resolver exists.
*/
private struct function $resolvedSecrets() {
var resolver = variables.loader.secretResolver();
return isObject(resolver) ? resolver.all() : {};
}

/**
* Deliver env.secret content to `remotePath` on each host (#2957):
* ensure-cmd (mkdir + touch + chmod 600) first so the file is
* permission-locked before content lands, then SFTP via uploadString —
* values never enter argv or dry-run output — then relock-cmd
* (chmod 600) AFTER the upload, belt-and-braces against the SFTP layer
* resetting perms to 0644 (sshj's preserve-attributes default;
* SshClient disables it, but FakeSshPool can't verify that, so the
* re-lock is the testable guarantee). Mirrors
* DeployMainCli.$deliverEnvFile (each Cli keeps its own dispatch
* plumbing by design).
*/
private void function $deliverEnvFile(
required array hosts,
required string ensureCmd,
required string relockCmd,
required string content,
required string remotePath,
required array secretNames,
required boolean dryRun
) {
$dispatch(arguments.hosts, arguments.ensureCmd, arguments.dryRun);
if (arguments.dryRun) {
for (var h in arguments.hosts) {
arrayAppend(
variables.dryRunBuffer,
"[" & h & "] upload env file " & arguments.remotePath
& " (" & arrayLen(arguments.secretNames) & " secret(s): "
& arrayToList(arguments.secretNames, ", ") & " — values not shown)"
);
}
$dispatch(arguments.hosts, arguments.relockCmd, arguments.dryRun);
return;
}
var c = arguments.content;
var p = arguments.remotePath;
var relock = arguments.relockCmd;
variables.sshPool.onEach(arguments.hosts, function(ssh, host) {
ssh.uploadString(c, p);
ssh.run(relock, {raise: true});
});
}
}
76 changes: 75 additions & 1 deletion cli/lucli/services/deploy/cli/DeployAppCli.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,11 @@ component {
public array function dryRunOutput() { return variables.dryRunBuffer; }

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

Expand Down Expand Up @@ -122,9 +124,29 @@ component {
var roleFilter = arguments.opts.role ?: "";
var hostCount = 0;

// env.secret delivery (#2957): container-(re)creating verbs opt in
// via flags.deliverEnvFile. Content renders once — an unresolvable
// secret fails fast locally before any remote call.
var secretNames = (arguments.flags.deliverEnvFile ?: false) ? cfg.env().secret() : [];
var envFileContent = "";
if (arrayLen(secretNames)) {
envFileContent = appCmds.env_file_content(secretNames, $resolvedSecrets());
}

for (var role in cfg.roles()) {
if (len(roleFilter) && role.name() != roleFilter) continue;
for (var host in role.hosts()) {
if (arrayLen(secretNames)) {
$deliverEnvFile(
[host],
appCmds.ensure_env_file(role),
appCmds.relock_env_file(role),
envFileContent,
appCmds.env_file_path(role),
secretNames,
dryRun
);
}
var cmd = arguments.cmdFn(appCmds, role, version);
if (collect) {
for (var l in $dispatchCollect([host], cmd, dryRun)) {
Expand Down Expand Up @@ -161,6 +183,58 @@ component {
variables.sshPool.onEach(arguments.hosts, function(ssh, host) { ssh.run(c, {raise: doRaise}); });
}

/**
* Resolved key→value map from the SecretResolver the loader built for
* the most recent load(). Empty struct when no resolver exists.
*/
private struct function $resolvedSecrets() {
var resolver = variables.loader.secretResolver();
return isObject(resolver) ? resolver.all() : {};
}

/**
* Deliver env.secret content to `remotePath` on each host (#2957):
* ensure-cmd (mkdir + touch + chmod 600) first so the file is
* permission-locked before content lands, then SFTP via uploadString —
* values never enter argv or dry-run output — then relock-cmd
* (chmod 600) AFTER the upload, belt-and-braces against the SFTP layer
* resetting perms to 0644 (sshj's preserve-attributes default;
* SshClient disables it, but FakeSshPool can't verify that, so the
* re-lock is the testable guarantee). Mirrors
* DeployMainCli.$deliverEnvFile (each Cli keeps its own dispatch
* plumbing by design).
*/
private void function $deliverEnvFile(
required array hosts,
required string ensureCmd,
required string relockCmd,
required string content,
required string remotePath,
required array secretNames,
required boolean dryRun
) {
$dispatch(arguments.hosts, arguments.ensureCmd, arguments.dryRun);
if (arguments.dryRun) {
for (var h in arguments.hosts) {
arrayAppend(
variables.dryRunBuffer,
"[" & h & "] upload env file " & arguments.remotePath
& " (" & arrayLen(arguments.secretNames) & " secret(s): "
& arrayToList(arguments.secretNames, ", ") & " — values not shown)"
);
}
$dispatch(arguments.hosts, arguments.relockCmd, arguments.dryRun);
return;
}
var c = arguments.content;
var p = arguments.remotePath;
var relock = arguments.relockCmd;
variables.sshPool.onEach(arguments.hosts, function(ssh, host) {
ssh.uploadString(c, p);
ssh.run(relock, {raise: true});
});
}

/**
* Dispatch + collect host-prefixed remote output (`[host] line`) for
* read verbs (#2957 DEP-6a). Sequential so output order is stable and
Expand Down
76 changes: 76 additions & 0 deletions cli/lucli/services/deploy/cli/DeployMainCli.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,18 @@ component {
// --target. Resolved from proxy.app_port (default 80); see #3089.
var appPort = cfg.proxy().appPort();

// env.secret delivery (#2957): render the env-file content ONCE,
// before the lock or any remote call, so an unresolvable secret
// fails fast locally (Wheels.Deploy.EnvSecretMissing) without
// acquiring — and then stranding — the deploy lock. Values come
// from the SecretResolver the loader already built; they reach the
// host only via SFTP (uploadString), never argv.
var secretNames = cfg.env().secret();
var envFileContent = "";
if (arrayLen(secretNames)) {
envFileContent = app.env_file_content(secretNames, $resolvedSecrets());
}

$fireHook(hooks, "pre-deploy", hookEnv, dryRun);

try {
Expand Down Expand Up @@ -124,6 +136,17 @@ component {

for (var role in cfg.roles()) {
for (var host in role.hosts()) {
if (arrayLen(secretNames)) {
$deliverEnvFile(
[host],
app.ensure_env_file(role),
app.relock_env_file(role),
envFileContent,
app.env_file_path(role),
secretNames,
dryRun
);
}
$dispatch([host], app.run(role, ver), dryRun);
// Only proxy-fronted roles register with kamal-proxy —
// job/worker roles serve no traffic (#2957).
Expand Down Expand Up @@ -522,6 +545,59 @@ component {
});
}

/**
* Resolved key→value map from the SecretResolver the loader built for
* the most recent load(). Empty struct when no resolver exists (e.g. a
* loader injected with one in tests that never loaded).
*/
private struct function $resolvedSecrets() {
var resolver = variables.loader.secretResolver();
return isObject(resolver) ? resolver.all() : {};
}

/**
* Deliver env.secret content to `remotePath` on each host (#2957):
* 1. dispatch ensure-cmd (mkdir + touch + chmod 600) so the file is
* permission-locked BEFORE any content lands,
* 2. SFTP the content via uploadString — values never enter argv,
* dry-run output, or exception command summaries,
* 3. dispatch relock-cmd (chmod 600) AFTER the upload — belt-and-braces
* against the SFTP layer resetting perms to 0644 (sshj's
* preserve-attributes default; SshClient disables it, but FakeSshPool
* can't verify that, so this re-lock is the testable guarantee).
* Under dryRun, records the upload by path and secret NAMES only.
*/
private void function $deliverEnvFile(
required array hosts,
required string ensureCmd,
required string relockCmd,
required string content,
required string remotePath,
required array secretNames,
required boolean dryRun
) {
$dispatch(arguments.hosts, arguments.ensureCmd, arguments.dryRun);
if (arguments.dryRun) {
for (var h in arguments.hosts) {
arrayAppend(
variables.dryRunBuffer,
"[" & h & "] upload env file " & arguments.remotePath
& " (" & arrayLen(arguments.secretNames) & " secret(s): "
& arrayToList(arguments.secretNames, ", ") & " — values not shown)"
);
}
$dispatch(arguments.hosts, arguments.relockCmd, arguments.dryRun);
return;
}
var c = arguments.content;
var p = arguments.remotePath;
var relock = arguments.relockCmd;
variables.sshPool.onEach(arguments.hosts, function(ssh, host) {
ssh.uploadString(c, p);
ssh.run(relock, {raise: true});
});
}

/**
* Dispatch a command and collect the remote output, host-prefixed
* (`[host] line`), in host order. Used by read verbs (`audit`,
Expand Down
7 changes: 6 additions & 1 deletion cli/lucli/services/deploy/cli/docs/env.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,12 @@ and `secret` (resolved from `.kamal/secrets` at deploy time).
## `.kamal/secrets` — plain-text file, out of git

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

# .kamal/secrets
DATABASE_URL=postgres://user:pass@db/app
Expand Down
45 changes: 44 additions & 1 deletion cli/lucli/services/deploy/commands/AccessoryCommands.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -87,15 +87,58 @@ component extends="Base" {
return parts;
}

/**
* env.clear values ride as escaped -e pairs; env.secret values NEVER
* enter argv — run() references the remote env file (written with 600
* perms by the orchestration layer before this command is dispatched)
* via --env-file instead (##2957).
*/
private array function $envArgs(required any accessory) {
var env = arguments.accessory.env();
$rejectEnvSecrets(env);
var parts = [];
var clear = env.clear();
for (var k in clear) {
arrayAppend(parts, "-e");
arrayAppend(parts, shellEscape(k & "=" & clear[k]));
}
if (arrayLen(env.secret())) {
arrayAppend(parts, "--env-file");
arrayAppend(parts, env_file_path(arguments.accessory));
}
return parts;
}

/**
* Remote env-file path for an accessory, relative to the SSH user's
* home. Namespaced by service and destination, mirroring Kamal's
* .kamal/apps/<service[-destination]>/env/accessories/<name>.env layout.
*/
public string function env_file_path(required any accessory) {
return $envAccessoriesDir() & "/" & arguments.accessory.name() & ".env";
}

/**
* Preparation command for the accessory env file: mkdir + touch +
* chmod 600 BEFORE the secret content is uploaded over SFTP (##2957).
*/
public string function ensure_env_file(required any accessory) {
return $ensureEnvFileCmd($envAccessoriesDir(), env_file_path(arguments.accessory));
}

/**
* Re-lock command for the accessory env file: chmod 600 AFTER the
* content upload, guarding against the SFTP layer resetting perms
* (##2957).
*/
public string function relock_env_file(required any accessory) {
return $relockEnvFileCmd(env_file_path(arguments.accessory));
}

private string function $envAccessoriesDir() {
var ns = variables.config.service();
if (len(variables.config.destination())) {
ns &= "-" & variables.config.destination();
}
return ".kamal/apps/" & ns & "/env/accessories";
}
}
Loading
Loading