Skip to content
Open
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
68 changes: 62 additions & 6 deletions lib/kamal/cli/base.rb
Original file line number Diff line number Diff line change
Expand Up @@ -52,17 +52,71 @@ def initialize_commander
commander.verbosity = VERBOSITY[:quiet]
end

config_file = Pathname.new(File.expand_path(options[:config_file]))
destination = options[:destination]
version = options[:version]

commander.configure \
config_file: Pathname.new(File.expand_path(options[:config_file])),
destination: options[:destination],
version: options[:version]
config_file: config_file,
destination: destination,
version: version

if config_file.exist? && !options[:skip_hooks]
cli = self
commander.before_config do
if (new_dest = cli.send(:run_pre_configure_hook, config_file, destination).presence)
commander.configure config_file: config_file, destination: new_dest, version: version
end
end
end

commander.specific_hosts = options[:hosts]&.split(",")
commander.specific_roles = options[:roles]&.split(",")
commander.specific_primary! if options[:primary]
end
end

# Fires before config is created — can inject or rewrite the destination.
# Runs as a lightweight hook (no config-derived env) because config
# may be invalid without a destination (require_destination: true).
def run_pre_configure_hook(config_file, destination)
env = { "KAMAL_DESTINATION" => destination }

hook_file = File.join(pre_configure_hooks_path(config_file, env), "pre-configure")
return unless File.exist?(hook_file)

hook_output = Kamal::HookOutput.new

begin
with_env(env.merge("KAMAL_OUTPUT" => hook_output.path)) do
run_locally { execute hook_file }
end

output = hook_output.parse
KAMAL.merge_hook_output(output)

if (message = output["KAMAL_MESSAGE"])
say message
end

output["KAMAL_DESTINATION"]
rescue SSHKit::Command::Failed => e
raise HookError.new("Hook `pre-configure` failed:\n#{e.message}")
ensure
hook_output.cleanup
end
end

def pre_configure_hooks_path(config_file, env)
with_env(env) do
load_method = YAML.respond_to?(:unsafe_load) ? :unsafe_load : :load
raw = YAML.send(load_method, ERB.new(File.read(config_file)).result)
Comment thread
jeremy marked this conversation as resolved.
raw&.dig("hooks_path") || ".kamal/hooks"
end
rescue Psych::SyntaxError, SyntaxError, KeyError
".kamal/hooks"
end

def print_runtime
started_at = Time.now
yield
Expand Down Expand Up @@ -196,18 +250,20 @@ def pre_connect_if_required
def command
@kamal_command ||= begin
invocation_class, invocation_commands = *first_invocation
if invocation_class == Kamal::Cli::Main
if invocation_class.nil?
nil
elsif invocation_class == Kamal::Cli::Main
invocation_commands[0]
else
Kamal::Cli::Main.subcommand_classes.find { |command, clazz| clazz == invocation_class }[0]
Kamal::Cli::Main.subcommand_classes.find { |command, clazz| clazz == invocation_class }&.first
end
end
end

def subcommand
@kamal_subcommand ||= begin
invocation_class, invocation_commands = *first_invocation
invocation_commands[0] if invocation_class != Kamal::Cli::Main
invocation_commands&.at(0) if invocation_class && invocation_class != Kamal::Cli::Main
end
end

Expand Down
22 changes: 22 additions & 0 deletions lib/kamal/cli/templates/sample_hooks/pre-configure.sample
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
#!/bin/sh

# A sample pre-configure hook
#
# Runs before configuration is created. Can inject or rewrite the deployment
# destination by writing KAMAL_DESTINATION to $KAMAL_OUTPUT. Useful for
# dynamic destination assignment (e.g., claiming an available beta slot).
#
# Because config has not been created yet, only these environment variables
# are available:
# KAMAL_DESTINATION (if set via -d flag)
# KAMAL_OUTPUT
#
# Write KEY=VALUE lines to $KAMAL_OUTPUT to pass data back to Kamal:
# echo "KAMAL_DESTINATION=beta2" >> "$KAMAL_OUTPUT"
# echo "KAMAL_MESSAGE=Deploying to beta2" >> "$KAMAL_OUTPUT"
#
# KAMAL_DESTINATION — sets or overwrites the destination for this deploy
# KAMAL_MESSAGE — printed to the user after the hook runs
# Any other keys are accumulated and available to subsequent hooks

echo "pre-configure hook called for destination: ${KAMAL_DESTINATION:-<none>}"
19 changes: 16 additions & 3 deletions lib/kamal/commander.rb
Original file line number Diff line number Diff line change
Expand Up @@ -17,21 +17,34 @@ def reset
self.connected = false
@specifics = @specific_roles = @specific_hosts = nil
@config = @config_kwargs = nil
@before_config = nil
@hook_outputs = {}
@commands = {}
end

def config
@config ||= Kamal::Configuration.create_from(**@config_kwargs.to_h).tap do |config|
@config_kwargs = nil
configure_sshkit_with(config)
@config ||= begin
if @before_config
hook = @before_config
@before_config = nil
hook.call
end

Kamal::Configuration.create_from(**@config_kwargs.to_h).tap do |config|
@config_kwargs = nil
configure_sshkit_with(config)
end
end
end

def configure(**kwargs)
@config, @config_kwargs = nil, kwargs
end

def before_config(&block)
@before_config = block
end

def merge_hook_output(output)
@hook_outputs.merge!(output)
end
Expand Down
2 changes: 1 addition & 1 deletion test/cli/main_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -488,7 +488,7 @@ class CliMainTest < CliTestCase
end

test "init with bundle option and existing binstub" do
Pathname.any_instance.expects(:exist?).returns(true).times(4)
Pathname.any_instance.stubs(:exist?).returns(true)
Pathname.any_instance.stubs(:mkpath)
FileUtils.stubs(:mkdir_p)
FileUtils.stubs(:cp_r)
Expand Down
216 changes: 216 additions & 0 deletions test/cli/pre_configure_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,216 @@
require_relative "cli_test_case"

class CliPreConfigureTest < CliTestCase
test "pre-configure hook can rewrite destination" do
with_pre_configure_hook({ "KAMAL_DESTINATION" => "world" }) do
# Pass -d beta, but the hook rewrites to "world"
run_command("exec", "date", "-c", config_file_path("deploy_for_required_dest"), "-d", "beta")

assert_equal "world", KAMAL.config.destination
end
end

test "pre-configure hook can inject destination when require_destination is set" do
with_pre_configure_hook({ "KAMAL_DESTINATION" => "world" }) do
# No -d flag, but the hook injects one — should not raise despite require_destination: true
run_command("exec", "date", "-c", config_file_path("deploy_for_required_dest"))

assert_equal "world", KAMAL.config.destination
end
end

test "pre-configure hook prints KAMAL_MESSAGE" do
with_pre_configure_hook({ "KAMAL_MESSAGE" => "Deploying to beta2" }) do
output = run_command("exec", "date", "-c", config_file_path("deploy_with_accessories"))

assert_match "Deploying to beta2", output
end
end

test "pre-configure hook accumulates output for subsequent hooks" do
with_pre_configure_hook({ "DEPLOY_SLOT" => "slot3" }) do
run_command("exec", "date", "-c", config_file_path("deploy_with_accessories"))

assert_equal "slot3", KAMAL.hook_outputs["DEPLOY_SLOT"]
end
end

test "pre-configure hook failure raises HookError" do
with_pre_configure_hook_that_fails do
assert_raises(Kamal::Cli::HookError) do
run_command("exec", "date", "-c", config_file_path("deploy_with_accessories"))
end
end
end

test "pre-configure hook tempfile is cleaned up on failure" do
tempfile_paths = []
original_new = Kamal::HookOutput.method(:new)

Kamal::HookOutput.stubs(:new).returns(original_new.call.tap { |ho| tempfile_paths << ho.path })

with_pre_configure_hook_that_fails do
assert_raises(Kamal::Cli::HookError) do
run_command("exec", "date", "-c", config_file_path("deploy_with_accessories"))
end
end

tempfile_paths.each { |path| refute File.exist?(path), "Tempfile #{path} should have been cleaned up" }
end

test "pre-configure hook is skipped with --skip-hooks" do
with_pre_configure_hook({ "KAMAL_DESTINATION" => "world" }) do
run_command("exec", "date", "-c", config_file_path("deploy_with_accessories"), "-H")

assert_nil KAMAL.config.destination
end
end

test "pre-configure hook does not fire for commands that skip config" do
with_pre_configure_hook_that_fails do
# version never accesses KAMAL.config, so the hook should not fire
output = stdouted { Kamal::Cli::Main.start([ "version", "-c", config_file_path("deploy_with_accessories") ]) }
assert_equal Kamal::VERSION, output
end
end

test "pre-configure hook clears KAMAL_DESTINATION when no -d flag is passed" do
ENV["KAMAL_DESTINATION"] = "leaked"

with_pre_configure_hook({}) do
run_command("exec", "date", "-c", config_file_path("deploy_with_accessories"))

assert_nil KAMAL.config.destination
end
ensure
ENV.delete("KAMAL_DESTINATION")
end

test "pre-configure hook ignores empty KAMAL_DESTINATION from hook" do
with_pre_configure_hook({ "KAMAL_DESTINATION" => "" }) do
run_command("exec", "date", "-c", config_file_path("deploy_with_accessories"))

assert_nil KAMAL.config.destination
end
end

test "pre-configure hook falls back to default hooks_path on config ERB error" do
Tempfile.create([ "deploy", ".yml" ]) do |config_file|
config_file.write(<<~YAML)
service: app
image: dhh/app
hooks_path: '<%= ENV.fetch("NONEXISTENT_VAR_FOR_TEST") %>'
YAML
config_file.flush

cli = Kamal::Cli::Base.allocate
result = cli.send(:pre_configure_hooks_path, config_file.path, {})
assert_equal ".kamal/hooks", result
end
end

test "pre-configure hook honors destination-aware hooks_path" do
hooks_path = '.kamal/hooks-<%= ENV["KAMAL_DESTINATION"] %>'
with_pre_configure_hook({ "KAMAL_DESTINATION" => "world" }, hooks_path: hooks_path, destination: "beta") do
run_command("exec", "date", "-c", "config/deploy.yml", "-d", "beta")

assert_equal "world", KAMAL.config.destination
end
end

private
def run_command(*command)
SSHKit::Backend::Abstract.any_instance.stubs(:capture)
.with("date", verbosity: 1)
.returns("Today")

stdouted { Kamal::Cli::Server.start(command) }
end

def config_file_path(fixture_name)
"test/fixtures/#{fixture_name}.yml"
end

def with_pre_configure_hook(output, hooks_path: nil, destination: nil)
Dir.mktmpdir do |tmpdir|
original_pwd = Dir.pwd
old_dest = ENV["KAMAL_DESTINATION"]
begin
copy_fixtures(tmpdir)

# Resolve ERB hooks_path to determine the actual directory on disk
resolved_hooks_path = if hooks_path && destination
ENV["KAMAL_DESTINATION"] = destination
ERB.new(hooks_path).result
else
hooks_path || ".kamal/hooks"
end

hook_dir = File.join(tmpdir, resolved_hooks_path)
FileUtils.mkdir_p(hook_dir)
File.write(File.join(hook_dir, "pre-configure"), "#!/bin/bash\n")

# If custom hooks_path, write a config that uses it (with raw ERB intact),
# plus a destination overlay so config loads successfully after rewrite
if hooks_path
config_dir = File.join(tmpdir, "config")
FileUtils.mkdir_p(config_dir)
File.write(File.join(config_dir, "deploy.yml"), <<~YAML)
service: app
image: dhh/app
registry:
username: dhh
password: secret
servers:
- 1.1.1.1
builder:
arch: amd64
hooks_path: '#{hooks_path}'
YAML

# Create destination overlay for the rewritten destination
rewritten_dest = output["KAMAL_DESTINATION"]
if rewritten_dest
File.write(File.join(config_dir, "deploy.#{rewritten_dest}.yml"), <<~YAML)
servers:
- 1.1.1.1
YAML
end
end

Dir.chdir(tmpdir)

# Stub parse to return desired output since Printer backend won't run the hook
Kamal::HookOutput.any_instance.stubs(:parse).returns(output)

yield
ensure
ENV["KAMAL_DESTINATION"] = old_dest
Dir.chdir(original_pwd)
end
end
end

def with_pre_configure_hook_that_fails
Dir.mktmpdir do |tmpdir|
original_pwd = Dir.pwd
begin
copy_fixtures(tmpdir)

hook_dir = File.join(tmpdir, ".kamal", "hooks")
FileUtils.mkdir_p(hook_dir)
File.write(File.join(hook_dir, "pre-configure"), "#!/bin/bash\nexit 1")

Dir.chdir(tmpdir)

SSHKit::Backend::Abstract.any_instance.stubs(:execute)
.with { |*args| args.first.to_s.include?("pre-configure") }
.raises(SSHKit::Command::Failed.new("hook failed"))

yield
ensure
Dir.chdir(original_pwd)
end
end
end
end