feat(cli): deliver env.secret to containers via remote env file (#2957)#3167
Merged
Conversation
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>
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>
…nv-file-delivery Signed-off-by: Peter Amiri <petera@pai.com>
bpamiri
added a commit
that referenced
this pull request
Jun 13, 2026
) #3008 added a $rejectEnvSecrets guard that hard-failed any non-empty env.secret with Wheels.Deploy.EnvSecretUnsupported, which made the env.secret block scaffolded by `wheels deploy init` (WHEELS_RELOAD_PASSWORD) un-deployable without manual editing. #3167 then retired that guard and implemented env.secret delivery via a remote --env-file (600 perms, SFTP), so the scaffolded block is now correct and deploys end-to-end. This pins that contract with two DeployMainCliSpec regression tests: - the init scaffold round-trips through config() and deploy --dry-run with no EnvSecretUnsupported/EnvSecretMissing, and the dry-run routes the secret through the --env-file path; - a deploy of the scaffold delivers WHEELS_RELOAD_PASSWORD to the role env file over SFTP (FakeSshPool uploadString), never in argv. Both tests fail if the scaffold drops its env.secret block (verified by temporarily removing it), so they guard the scaffold and the deploy engine against drifting apart again. No template or engine change is needed: the scaffold is correct as shipped now that env.secret is a delivered feature. CLI suite (lucee7 docker harness): 1071 pass / 0 fail / 2 tolerated docker-env artifacts (SshClientSpec/SshPoolSpec require docker-in-docker). Refs #3008, #3167 Fixes #3158 Signed-off-by: Peter Amiri <petera@pai.com> Co-authored-by: Peter Amiri <petera@pai.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
Wave 2b of the #2957 deploy-orchestration campaign:
env.secretenv-file delivery — the feature #3008 deliberately deferred (itsWheels.Deploy.EnvSecretUnsupportedmessage pointed users at #2957).Ticks this Wave 2 checklist item from the 2026-06-12 re-scope on #2957:
How (Kamal model)
SecretResolvertheConfigLoaderalready builds at load time (newConfigLoader.secretResolver()accessor). No new I/O paths.Base.env_file_content(secretNames, resolved)rendersKEY=valuelines with Kamal-style escaping (backslash doubled, CR/LF/CRLF collapsed to literal\n). A declared name with no resolvable value throwsWheels.Deploy.EnvSecretMissinglisting the missing names only — values are never read into a message. Indeploy()this fires before the lock or any remote call, so an unresolvable secret can't strand a deploy lock.mkdir -p '<dir>' && touch '<path>' && chmod 600 '<path>') locks the file to 600 perms before any content lands; the content then travels over SFTP viaSshClient.uploadString— secret values never enter argv, dry-run output, or$raiseRemoteFailurecommand summaries.AppCommands.run/AccessoryCommands.runreference the file via--env-file .kamal/apps/<service[-destination]>/env/roles/<role>.env(accessories:.../env/accessories/<name>.env).DeployMainCli.deploy(),DeployAppCli.boot,DeployAccessoryCli.boot/reboot(the container-(re)creating verbs).rollback/startreuse the env baked into the existing container — nothing to deliver.Base.$rejectEnvSecretsand theEnvSecretUnsupportederror path (commands/Base.cfc:55-67); both former call sites (AppCommands.cfc:98,AccessoryCommands.cfc:92) now emit--env-fileinstead.env.clearstays as escaped-epairs; per-role env merge remains out of scope (parsed-but-ignored, flagged in config-reference.mdx under deploy: Validator allowlists ~20 deploy.yml keys/sub-keys the runtime never reads — accepted-but-ignored config (boot, logging, retain_containers, minimum_version, hooks.path, builder.*, ssh.*, proxy.*, role options/labels/env, accessory files) #3088) — kept narrow per the wave brief.Coordination with #3158 (init template scaffolds env.secret)
#3158's interim fix (strip the
env.secretblock fromdeploy.yml.mustache) has not landed — the bot held it for human review. With this PR the scaffolded block becomes functional instead of fatal:wheels deploy init→wheels deploy setupno longer hard-fails onEnvSecretUnsupported, and the scaffolded.kamal/secretsalready declaresWHEELS_RELOAD_PASSWORD=, so it resolves (to empty until populated). The template needs no change here, and #3158's symptom is gone once this merges — recommend closing it as superseded after merge.Not implemented here (tracked separately): #3159 secret-value redaction in
$raiseRemoteFailuresummaries — this design composes with it (values already stay out of command strings on every new path).Tests / evidence
Real SSH/docker-remote is unverifiable in-harness — FakeSshPool specs +
--dry-runflows are the bar, per campaign convention:DeployMainCliSpec: ordering (ensure →uploadString→docker run --env-file), upload content/remote path asserted, 600 perms in the command, secret value absent from every command summary; dry-run prints path + names, never values, zero pool calls;EnvSecretMissingfail-fast with zero pool calls (no lock).DeployAppCliSpec/DeployAccessoryCliSpec: same ordering + redaction contract forapp bootandaccessory boot(accessory file lands on the accessory's pinned host).AppCommandsSpec/AccessoryCommandsSpec:--env-fileemission (and omission without secrets),ensure_env_fileshape, destination namespacing,env_file_contentrendering/escaping/missing-name throw (replaces the two retiredEnvSecretUnsupportedrejection specs).Full CLI suite in the lucee7 docker harness (
/wheels/cli/tests?format=json): 1004 pass / 0 fail / 2 errors, both the tolerated docker-env artifacts (SshClientSpec/SshPoolSpecdocker-not-found) — beats the ~918 baseline.Docs:
cli/.../docs/env.mdplus the five deployment guides (secrets,config-reference,accessories,first-deploy,migrating-from-kamal) now describe delivery on 4.0.4+ builds vs the silent drop on released 4.0.3. Changelog fragment:changelog.d/deploy-env-secret-env-file-delivery.added.md.Refs #2957
🤖 Generated with Claude Code