Skip to content

Add a pre-configure hook that can pick the destination and set the environment - #1964

Open
jeremy wants to merge 4 commits into
fix-subcommand-envfrom
pre-configure-v2
Open

jeremy wants to merge 4 commits into
fix-subcommand-envfrom
pre-configure-v2

Conversation

@jeremy

@jeremy jeremy commented Oct 8, 2026

Copy link
Copy Markdown
Member

Supersedes #1782, #1783 and #1785. Stacked on #1963; GitHub retargets this to main once that merges.

A pre-configure hook runs before Kamal loads the configuration. Each NAME=value line it writes to $KAMAL_ENV is set in Kamal's environment for the rest of the run. That's the same contract as GitHub Actions' $GITHUB_ENV. If one of those lines is KAMAL_DESTINATION, Kamal loads that destination instead of the one given with -d.

#!/bin/sh
# Deploy to staging unless a destination is given
if [ -z "$KAMAL_DESTINATION" ]; then
  echo "KAMAL_DESTINATION=staging" >> "$KAMAL_ENV"
fi

The motivating case is a pool of shared review destinations (beta1…beta16, each with its own deploy.betaN.yml). kamal deploy -d beta should lease a free one and deploy there, and kamal app logs -d beta should go to the one you already hold. That takes something that runs before the config is chosen and knows which command is running. It also covers defaulting a destination, and feeding computed values into deploy.yml without ERB that reaches outside the file.

Why one PR instead of four

#1782, #1783, #1784 and #1785 grew around that one need, and each added a mechanism:

  • an output channel for every hook, accumulated across a run
  • a KAMAL_MESSAGE side channel for printing
  • a hook that used the channel to rewrite the destination
  • external commands
  • .kamal/bin prepended to every hook's PATH

Taken back to first principles, only one of those has to live in Kamal: something must run before the configuration exists and be able to change which configuration loads. Everything else either already exists or belongs to the app. So this PR is that one thing, done carefully: one hook, an env-file for its output, the destination rewrite, and enough context about the command for the hook to decide what to do.

What was dropped, and why:

  • KAMAL_OUTPUT for every hook, accumulated across hooks (Let hooks talk back to Kamal via KAMAL_OUTPUT #1782). No use for it turned up beyond picking the destination. Anything the pre-configure hook exports is already in the environment of every later hook, the config's ERB and .kamal/secrets.
  • KAMAL_MESSAGE. It was a second output path that ignored hooks_output: quiet and passed terminal control characters straight through. Kamal now announces a changed destination itself. A hook that wants to talk to the user can already do so with hooks_output: { post-deploy: verbose } (Allow configurable verbosity for hooks output #1758).
  • Dotenv parsing. It quietly mis-read export FOO=bar and ignored malformed lines. The format is now strictly NAME=value per line, with blank lines and # comments allowed. Values are taken as written, quotes included, and are never evaluated. Anything else, including a NUL, fails the hook before any variable is set.
  • Prepending .kamal/bin to every hook's PATH (Prepend .kamal/bin to PATH during hook execution #1785). That changed what every existing hook runs. An executable .kamal/bin/ruby would take over #!/usr/bin/env ruby, and .kamal/bin/git would take over git. A hook can call .kamal/bin/claims by its path.
  • External commands (kamal-*: run external commands from .kamal/bin and the PATH, after aliases #1784) continue separately, as an independent feature. Nothing here depends on them.

Behaviour

  • When it runs. The first time a command needs the configuration, and only once for it (an alias is two commands; see below). kamal version, kamal init, kamal docs, kamal secrets fetch and kamal secrets extract never load it, so they never run the hook. --skip-hooks skips it. It is always .kamal/hooks/pre-configure, since hooks_path lives in the configuration that hasn't loaded yet.
  • What it gets.
    • KAMAL_DESTINATION is set only from -d. A value inherited from the caller's environment is cleared, so a nested kamal run from another hook can't pass its destination along by accident.
    • KAMAL_COMMAND and KAMAL_SUBCOMMAND name the command, e.g. app / boot. This holds even when --hosts, --roles or --primary load the configuration while the CLI is still initializing, before Thor has recorded anything, and an abbreviation is named as Thor will run it (kamal app --hosts x bo is boot).
    • KAMAL_ENV is the path of a private tempfile.
  • Output. The hook runs quietly unless -v, because any command can trigger it, including kamal server exec --raw, whose stdout has to stay byte-clean. When it fails, its output is in the HookError. A changed destination is announced on stderr: Using destination beta3 from the pre-configure hook.
  • Aliases. An alias is found by reading the raw configuration, so the hook runs before that lookup, which has to see the destination and variables the hook provides. It then runs again for the command the alias expands to. The first run has KAMAL_COMMAND set to the alias's name, so a hook with side effects, like taking a lease, can save them for the commands it recognises. The sample hook says so.
  • Failure. A failed hook raises HookError and nothing it wrote is applied. If a caller rescues that and asks for the configuration again, the hook runs again rather than Kamal falling back to the destination it refused. A hook that succeeded is never re-run, even if the configuration then fails to load.

Depends on

#1963 (fix-subcommand-env): on main, KAMAL_SUBCOMMAND is never set for a subcommand run from the command line, and a hook that should treat kamal app boot differently from kamal app logs can't tell them apart without it. This PR adds the same naming for the case where the hook runs while the CLI is still initializing.

Tests

  • test/cli/pre_configure_test.rb covers:
    • rewriting and setting the destination
    • variables reaching the config's ERB
    • the environment the hook gets, including through Main with selectors before and after the subcommand, and an abbreviated subcommand
    • running once
    • running before alias lookup and again for the expansion, with the alias's name first
    • invalid lines, a NUL and non-UTF-8 values
    • failure, --skip-hooks, commands that never load the config, and no hook at all
  • test/commander_test.rb covers when the destination resolver runs, and what happens after it fails.
  • Unit suite: 880 runs. The only failures are the 7 that also fail on main without a Docker builder (CliBuildTest ×4, CommandsBuilderTest ×2, ConfigurationBuilderTest ×1). RuboCop is clean. The Docker integration suite wasn't run.
  • Also checked by hand against a real lease hook: kamal app boot -d beta leases beta1 and deploys there, a later kamal config -d beta loads beta1 without leasing, and kamal server exec --raw -d beta keeps the announcement off stdout.

jeremy added 4 commits October 5, 2026 23:19
The hook runs the first time a command needs the configuration, before it
loads. Each NAME=value line it writes to $KAMAL_ENV is set in the
environment for the rest of the run, where the config's ERB, the secrets
and later hooks can read it, and KAMAL_DESTINATION picks the destination
to load. That's enough to pick a free review slot, default the
destination, or feed computed values into deploy.yml without ERB that
reaches outside the file.

It gets KAMAL_DESTINATION, KAMAL_COMMAND, KAMAL_SUBCOMMAND and
KAMAL_ENV. A destination left in the caller's environment is cleared,
since only -d should reach the hook. There's no configuration yet, so the
hook is always read from .kamal/hooks and its output follows -v and -q
rather than hooks_output.

--hosts, --roles and --primary load the configuration while the CLI is
still initializing, before Thor records the invocation, so fall back to
the command being started to name it.
Aliases are found by reading the raw config before the command they
expand to is configured, so that read has to see the hook's destination
and variables too: `kamal console -d beta` would otherwise look for a
deploy.beta.yml the hook was about to replace, and config ERB reading a
hook variable would fail. The expanded command configures afresh, so the
hook runs again for it.

The first run has KAMAL_COMMAND set to the alias's name, so a hook that
leases a destination can leave that to the second, for the command it
knows, rather than leasing one slot to look the alias up and another to
deploy.
Any command can run the hook, --raw ones included, and the CLI object it
runs from doesn't know about a subcommand's --raw. So run it quietly
unless -v, and say when it changes the destination on stderr, keeping
`kamal server exec --raw -d beta 'cat backup.tar'` byte-clean.

Reject a NUL in a value as an invalid line, before anything is set,
rather than letting ENV.update fail halfway through, and read the file
as bytes so a value that isn't UTF-8 is set as written instead of
raising out of the line match. And only drop the destination resolver
once it has succeeded, so a caller that rescues a failed hook and asks
for the config again runs it again instead of loading the destination
it refused.
--hosts, --roles and --primary load the configuration while Main is
still initializing, before Thor has recorded either Main's `app` or
App's `boot`, so the hook would get KAMAL_SUBCOMMAND=nil for
`kamal app boot --hosts 1.1.1.1`. Take the first argument Main has left
once it has parsed its options instead, wherever they were given, and
name it as Thor will, so `kamal app --hosts 1.1.1.1 bo` is `boot` too.

A pre-configure hook that leases a destination needs this to tell
`kamal app boot` from `kamal app logs`.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-08T21:01:26.218816Z 8507882 PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@jeremy
jeremy added this pull request to stack #1965 October 8, 2026 21:01
@jeremy
jeremy requested a review from djmb October 8, 2026 21:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants