Skip to content

feat(cli): deliver env.secret to containers via remote env file (#2957)#3167

Merged
bpamiri merged 3 commits into
developfrom
peter/deploy-w2b-env-file-delivery
Jun 13, 2026
Merged

feat(cli): deliver env.secret to containers via remote env file (#2957)#3167
bpamiri merged 3 commits into
developfrom
peter/deploy-w2b-env-file-delivery

Conversation

@bpamiri

@bpamiri bpamiri commented Jun 12, 2026

Copy link
Copy Markdown
Collaborator

What

Wave 2b of the #2957 deploy-orchestration campaign: env.secret env-file delivery — the feature #3008 deliberately deferred (its Wheels.Deploy.EnvSecretUnsupported message pointed users at #2957).

Ticks this Wave 2 checklist item from the 2026-06-12 re-scope on #2957:

How (Kamal model)

  • Resolution: values come from the SecretResolver the ConfigLoader already builds at load time (new ConfigLoader.secretResolver() accessor). No new I/O paths.
  • Content: Base.env_file_content(secretNames, resolved) renders KEY=value lines with Kamal-style escaping (backslash doubled, CR/LF/CRLF collapsed to literal \n). A declared name with no resolvable value throws Wheels.Deploy.EnvSecretMissing listing the missing names only — values are never read into a message. In deploy() this fires before the lock or any remote call, so an unresolvable secret can't strand a deploy lock.
  • Write: per host, an ensure command (mkdir -p '<dir>' && touch '<path>' && chmod 600 '<path>') locks the file to 600 perms before any content lands; the content then travels over SFTP via SshClient.uploadString — secret values never enter argv, dry-run output, or $raiseRemoteFailure command summaries.
  • Run: AppCommands.run / AccessoryCommands.run reference the file via --env-file .kamal/apps/<service[-destination]>/env/roles/<role>.env (accessories: .../env/accessories/<name>.env).
  • Wiring: DeployMainCli.deploy(), DeployAppCli.boot, DeployAccessoryCli.boot/reboot (the container-(re)creating verbs). rollback/start reuse the env baked into the existing container — nothing to deliver.
  • Removed: Base.$rejectEnvSecrets and the EnvSecretUnsupported error path (commands/Base.cfc:55-67); both former call sites (AppCommands.cfc:98, AccessoryCommands.cfc:92) now emit --env-file instead.
  • env.clear stays as escaped -e pairs; 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.secret block from deploy.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 initwheels deploy setup no longer hard-fails on EnvSecretUnsupported, and the scaffolded .kamal/secrets already declares WHEELS_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 $raiseRemoteFailure summaries — 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-run flows are the bar, per campaign convention:

  • DeployMainCliSpec: ordering (ensure → uploadStringdocker 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; EnvSecretMissing fail-fast with zero pool calls (no lock).
  • DeployAppCliSpec / DeployAccessoryCliSpec: same ordering + redaction contract for app boot and accessory boot (accessory file lands on the accessory's pinned host).
  • AppCommandsSpec / AccessoryCommandsSpec: --env-file emission (and omission without secrets), ensure_env_file shape, destination namespacing, env_file_content rendering/escaping/missing-name throw (replaces the two retired EnvSecretUnsupported rejection 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/SshPoolSpec docker-not-found) — beats the ~918 baseline.

Docs: cli/.../docs/env.md plus 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

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>
@bpamiri bpamiri enabled auto-merge (squash) June 12, 2026 20:12
@github-actions github-actions Bot added the docs label Jun 12, 2026
bpamiri and others added 2 commits June 12, 2026 13:31
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 bpamiri merged commit df62f46 into develop Jun 13, 2026
18 of 19 checks passed
@bpamiri bpamiri deleted the peter/deploy-w2b-env-file-delivery branch June 13, 2026 04:55
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant