diff --git a/lib/kamal/cli/base.rb b/lib/kamal/cli/base.rb index 8468dc78a..2065b8afb 100644 --- a/lib/kamal/cli/base.rb +++ b/lib/kamal/cli/base.rb @@ -52,10 +52,23 @@ 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(",") @@ -63,6 +76,47 @@ def initialize_commander 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) + raw&.dig("hooks_path") || ".kamal/hooks" + end + rescue Psych::SyntaxError, SyntaxError, KeyError + ".kamal/hooks" + end + def print_runtime started_at = Time.now yield @@ -196,10 +250,12 @@ 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 @@ -207,7 +263,7 @@ def command 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 diff --git a/lib/kamal/cli/templates/sample_hooks/pre-configure.sample b/lib/kamal/cli/templates/sample_hooks/pre-configure.sample new file mode 100755 index 000000000..40e0406f9 --- /dev/null +++ b/lib/kamal/cli/templates/sample_hooks/pre-configure.sample @@ -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:-}" diff --git a/lib/kamal/commander.rb b/lib/kamal/commander.rb index 3852b4474..09e278ed8 100644 --- a/lib/kamal/commander.rb +++ b/lib/kamal/commander.rb @@ -17,14 +17,23 @@ 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 @@ -32,6 +41,10 @@ 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 diff --git a/test/cli/main_test.rb b/test/cli/main_test.rb index 69e2cbf02..3856c8814 100644 --- a/test/cli/main_test.rb +++ b/test/cli/main_test.rb @@ -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) diff --git a/test/cli/pre_configure_test.rb b/test/cli/pre_configure_test.rb new file mode 100644 index 000000000..e986007b5 --- /dev/null +++ b/test/cli/pre_configure_test.rb @@ -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