From cbb4c8703587d75b8ae7a7a60e9adc231be50afe Mon Sep 17 00:00:00 2001 From: Donal McBreen Date: Tue, 30 Jul 2024 16:49:22 +0100 Subject: [PATCH] Add a pre-init hook The hook is run before the environment is loaded or the config is parsed. This makes it a bit of a special case - it doesn't have the usual KAMAL_XYZ environment variables, as we haven't parsed the config. The use case for this is to do auth checking or setup. So for example we can confirm you are logged in to a secret manager, and then you can directly call it to load your secrets in the .kamal/.env file using .dotenv's [command substitution](https://github.com/bkeepers/dotenv?tab=readme-ov-file#command-substitution). --- lib/kamal/cli/base.rb | 33 ++++++++++++++++++- lib/kamal/commands/hook.rb | 11 +------ lib/kamal/configuration.rb | 6 +--- .../configuration/docs/configuration.yml | 4 --- test/cli/build_test.rb | 4 +-- test/cli/cli_test_case.rb | 2 +- test/cli/main_test.rb | 17 +++++----- test/cli/server_test.rb | 3 +- test/commands/hook_test.rb | 5 ++- test/configuration/validation_test.rb | 2 +- test/integration/main_test.rb | 10 +++--- test/test_helper.rb | 4 +++ 12 files changed, 62 insertions(+), 39 deletions(-) diff --git a/lib/kamal/cli/base.rb b/lib/kamal/cli/base.rb index 2463f585..3129eaab 100644 --- a/lib/kamal/cli/base.rb +++ b/lib/kamal/cli/base.rb @@ -22,9 +22,21 @@ module Kamal::Cli class_option :skip_hooks, aliases: "-H", type: :boolean, default: false, desc: "Don't run hooks" + @@ran_pre_init_hook = false + class << self + def ran_pre_init_hook + @@ran_pre_init_hook + end + + def ran_pre_init_hook=(value) + @@ran_pre_init_hook = value + end + end + def initialize(*) super @original_env = ENV.to_h.dup + run_pre_init_hook load_env initialize_commander(options_with_subcommand_class_options) end @@ -176,8 +188,23 @@ module Kamal::Cli end end + def run_pre_init_hook + unless self.class.ran_pre_init_hook + hook = "pre-init" + if run_hook?(hook) + say "Running the #{hook} hook...", :magenta + run_locally do + execute *Kamal::Hooks.file(hook), verbosity: :debug + rescue SSHKit::Command::Failed => e + raise HookError.new("Hook `#{hook}` failed:\n#{e.message}") + end + end + self.class.ran_pre_init_hook = true + end + end + def run_hook(hook, **extra_details) - if !options[:skip_hooks] && KAMAL.hook.hook_exists?(hook) + if run_hook?(hook) details = { hosts: KAMAL.hosts.join(","), command: command, subcommand: subcommand } say "Running the #{hook} hook...", :magenta @@ -189,6 +216,10 @@ module Kamal::Cli end end + def run_hook?(hook) + !options[:skip_hooks] && Kamal::Hooks.exists?(hook) + end + def on(*args, &block) if !KAMAL.connected? run_hook "pre-connect" diff --git a/lib/kamal/commands/hook.rb b/lib/kamal/commands/hook.rb index 66fe8b8c..50cb6d35 100644 --- a/lib/kamal/commands/hook.rb +++ b/lib/kamal/commands/hook.rb @@ -1,14 +1,5 @@ class Kamal::Commands::Hook < Kamal::Commands::Base def run(hook, **details) - [ hook_file(hook), env: tags(**details).env ] + [ Kamal::Hooks.file(hook), env: tags(**details).env ] end - - def hook_exists?(hook) - Pathname.new(hook_file(hook)).exist? - end - - private - def hook_file(hook) - File.join(config.hooks_path, hook) - end end diff --git a/lib/kamal/configuration.rb b/lib/kamal/configuration.rb index 8d989464..48bf536c 100644 --- a/lib/kamal/configuration.rb +++ b/lib/kamal/configuration.rb @@ -7,7 +7,7 @@ require "erb" require "net/ssh/proxy/jump" class Kamal::Configuration - delegate :service, :image, :labels, :stop_wait_time, :hooks_path, to: :raw_config, allow_nil: true + delegate :service, :image, :labels, :stop_wait_time, to: :raw_config, allow_nil: true delegate :argumentize, :optionize, to: Kamal::Utils attr_reader :destination, :raw_config @@ -208,10 +208,6 @@ class Kamal::Configuration end end - def hooks_path - raw_config.hooks_path || ".kamal/hooks" - end - def asset_path raw_config.asset_path end diff --git a/lib/kamal/configuration/docs/configuration.yml b/lib/kamal/configuration/docs/configuration.yml index fc9245c5..4522bb94 100644 --- a/lib/kamal/configuration/docs/configuration.yml +++ b/lib/kamal/configuration/docs/configuration.yml @@ -74,10 +74,6 @@ env: # To configure this, set the path to the assets: asset_path: /path/to/assets -# Path to hooks, defaults to `.kamal/hooks` -# See https://kamal-deploy.org/docs/hooks for more information -hooks_path: /user_home/kamal/hooks - # Require destinations # # Whether deployments require a destination to be specified, defaults to `false` diff --git a/test/cli/build_test.rb b/test/cli/build_test.rb index 18ff254b..be0e7e10 100644 --- a/test/cli/build_test.rb +++ b/test/cli/build_test.rb @@ -10,7 +10,7 @@ class CliBuildTest < CliTestCase test "push" do with_build_directory do |build_directory| - Kamal::Commands::Hook.any_instance.stubs(:hook_exists?).returns(true) + Kamal::Hooks.stubs(:exists?).returns(true) hook_variables = { version: 999, service_version: "app@999", hosts: "1.1.1.1,1.1.1.2,1.1.1.3,1.1.1.4", command: "build", subcommand: "push" } SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info) @@ -70,7 +70,7 @@ class CliBuildTest < CliTestCase end test "push without clone" do - Kamal::Commands::Hook.any_instance.stubs(:hook_exists?).returns(true) + Kamal::Hooks.stubs(:exists?).returns(true) hook_variables = { version: 999, service_version: "app@999", hosts: "1.1.1.1,1.1.1.2,1.1.1.3,1.1.1.4", command: "build", subcommand: "push" } run_command("push", "--verbose", fixture: :without_clone).tap do |output| diff --git a/test/cli/cli_test_case.rb b/test/cli/cli_test_case.rb index 4c6b491d..8b32e1c7 100644 --- a/test/cli/cli_test_case.rb +++ b/test/cli/cli_test_case.rb @@ -18,7 +18,7 @@ class CliTestCase < ActiveSupport::TestCase private def fail_hook(hook) @executions = [] - Kamal::Commands::Hook.any_instance.stubs(:hook_exists?).returns(true) + Kamal::Hooks.stubs(:exists?).returns(true) SSHKit::Backend::Abstract.any_instance.stubs(:execute) .with { |*args| @executions << args; args != [ ".kamal/hooks/#{hook}" ] } diff --git a/test/cli/main_test.rb b/test/cli/main_test.rb index 82d4e2ba..61f2e7d2 100644 --- a/test/cli/main_test.rb +++ b/test/cli/main_test.rb @@ -58,10 +58,11 @@ class CliMainTest < CliTestCase Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:app:boot", [], invoke_options) Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:prune:all", [], invoke_options) - Kamal::Commands::Hook.any_instance.stubs(:hook_exists?).returns(true) + Kamal::Hooks.stubs(:exists?).returns(true) hook_variables = { version: 999, service_version: "app@999", hosts: "1.1.1.1,1.1.1.2", command: "deploy" } run_command("deploy", "--verbose").tap do |output| + assert_match "Running /usr/bin/env .kamal/hooks/pre-init", output assert_hook_ran "pre-connect", output, **hook_variables assert_match /Log into image registry/, output assert_match /Build and push app image/, output @@ -237,7 +238,7 @@ class CliMainTest < CliTestCase Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:app:stale_containers", [], invoke_options.merge(stop: true)) Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:app:boot", [], invoke_options) - Kamal::Commands::Hook.any_instance.stubs(:hook_exists?).returns(true) + Kamal::Hooks.stubs(:exists?).returns(true) hook_variables = { version: 999, service_version: "app@999", hosts: "1.1.1.1,1.1.1.2", command: "redeploy" } @@ -298,7 +299,7 @@ class CliMainTest < CliTestCase .with(:docker, :container, :ls, "--all", "--filter", "name=^app-web-version-to-rollback$", "--quiet", "|", :xargs, :docker, :inspect, "--format", "'{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}'") .returns("unhealthy").at_least_once # health check - Kamal::Commands::Hook.any_instance.stubs(:hook_exists?).returns(true) + Kamal::Hooks.stubs(:exists?).returns(true) hook_variables = { version: 123, service_version: "app@123", hosts: "1.1.1.1,1.1.1.2,1.1.1.3,1.1.1.4", command: "rollback" } run_command("rollback", "--verbose", "123", config_file: "deploy_with_accessories").tap do |output| @@ -396,7 +397,7 @@ class CliMainTest < CliTestCase end test "init" do - Pathname.any_instance.expects(:exist?).returns(false).times(3) + Pathname.any_instance.expects(:exist?).returns(false).times(4) Pathname.any_instance.stubs(:mkpath) FileUtils.stubs(:mkdir_p) FileUtils.stubs(:cp_r) @@ -409,7 +410,7 @@ class CliMainTest < CliTestCase end test "init with existing config" do - Pathname.any_instance.expects(:exist?).returns(true).times(3) + Pathname.any_instance.expects(:exist?).returns(true).times(4) run_command("init").tap do |output| assert_match /Config file already exists in config\/deploy.yml \(remove first to create a new one\)/, output @@ -417,7 +418,7 @@ class CliMainTest < CliTestCase end test "init with bundle option" do - Pathname.any_instance.expects(:exist?).returns(false).times(4) + Pathname.any_instance.expects(:exist?).returns(false).times(5) Pathname.any_instance.stubs(:mkpath) FileUtils.stubs(:mkdir_p) FileUtils.stubs(:cp_r) @@ -434,7 +435,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.expects(:exist?).returns(true).times(5) Pathname.any_instance.stubs(:mkpath) FileUtils.stubs(:mkdir_p) FileUtils.stubs(:cp_r) @@ -475,7 +476,7 @@ class CliMainTest < CliTestCase end test "envify with skip_push" do - Pathname.any_instance.expects(:exist?).returns(true).times(1) + Pathname.any_instance.expects(:exist?).returns(true).times(2) File.expects(:read).with(".kamal/.env.erb").returns("HELLO=<%= 'world' %>") File.expects(:write).with(".kamal/.env", "HELLO=world", perm: 0600) diff --git a/test/cli/server_test.rb b/test/cli/server_test.rb index 5d9fec4d..f4ecf650 100644 --- a/test/cli/server_test.rb +++ b/test/cli/server_test.rb @@ -40,7 +40,8 @@ class CliServerTest < CliTestCase SSHKit::Backend::Abstract.any_instance.expects(:execute).with('[ "${EUID:-$(id -u)}" -eq 0 ] || command -v sudo >/dev/null || command -v su >/dev/null', raise_on_non_zero_exit: false).returns(true).at_least_once SSHKit::Backend::Abstract.any_instance.expects(:execute).with(:sh, "-c", "'curl -fsSL https://get.docker.com || wget -O - https://get.docker.com || echo \"exit 1\"'", "|", :sh).at_least_once SSHKit::Backend::Abstract.any_instance.expects(:execute).with(:mkdir, "-p", ".kamal").returns("").at_least_once - Kamal::Commands::Hook.any_instance.stubs(:hook_exists?).returns(true) + Kamal::Hooks.stubs(:exists?).returns(true) + SSHKit::Backend::Abstract.any_instance.expects(:execute).with(".kamal/hooks/pre-init", anything).at_least_once SSHKit::Backend::Abstract.any_instance.expects(:execute).with(".kamal/hooks/pre-connect", anything).at_least_once SSHKit::Backend::Abstract.any_instance.expects(:execute).with(".kamal/hooks/docker-setup", anything).at_least_once diff --git a/test/commands/hook_test.rb b/test/commands/hook_test.rb index c0e6e98f..ec4ae583 100644 --- a/test/commands/hook_test.rb +++ b/test/commands/hook_test.rb @@ -28,6 +28,7 @@ class CommandsHookTest < ActiveSupport::TestCase end test "run with custom hooks_path" do + ENV["KAMAL_HOOKS_PATH"] = "custom/hooks/path" assert_equal [ "custom/hooks/path/foo", { env: { @@ -36,7 +37,9 @@ class CommandsHookTest < ActiveSupport::TestCase "KAMAL_VERSION" => "123", "KAMAL_SERVICE_VERSION" => "app@123", "KAMAL_SERVICE" => "app" } } - ], new_command(hooks_path: "custom/hooks/path").run("foo") + ], new_command.run("foo") + ensure + ENV.delete("KAMAL_HOOKS_PATH") end private diff --git a/test/configuration/validation_test.rb b/test/configuration/validation_test.rb index b7bd6b6a..dd2c0a29 100644 --- a/test/configuration/validation_test.rb +++ b/test/configuration/validation_test.rb @@ -6,7 +6,7 @@ class ConfigurationValidationTest < ActiveSupport::TestCase end test "wrong root types" do - [ :service, :image, :asset_path, :hooks_path, :primary_role, :minimum_version, :run_directory ].each do |key| + [ :service, :image, :asset_path, :primary_role, :minimum_version, :run_directory ].each do |key| assert_error "#{key}: should be a string", **{ key => [] } end diff --git a/test/integration/main_test.rb b/test/integration/main_test.rb index 4d4513ad..854e4396 100644 --- a/test/integration/main_test.rb +++ b/test/integration/main_test.rb @@ -12,19 +12,19 @@ class MainTest < IntegrationTest kamal :deploy assert_app_is_up version: first_version - assert_hooks_ran "pre-connect", "pre-build", "pre-deploy", "post-deploy" + assert_hooks_ran "pre-init", "pre-connect", "pre-build", "pre-deploy", "post-deploy" assert_envs version: first_version second_version = update_app_rev kamal :redeploy assert_app_is_up version: second_version - assert_hooks_ran "pre-connect", "pre-build", "pre-deploy", "post-deploy" + assert_hooks_ran "pre-init", "pre-connect", "pre-build", "pre-deploy", "post-deploy" assert_accumulated_assets first_version, second_version kamal :rollback, first_version - assert_hooks_ran "pre-connect", "pre-deploy", "post-deploy" + assert_hooks_ran "pre-init", "pre-connect", "pre-deploy", "post-deploy" assert_app_is_up version: first_version details = kamal :details, capture: true @@ -54,7 +54,7 @@ class MainTest < IntegrationTest kamal :deploy assert_app_is_up version: version - assert_hooks_ran "pre-connect", "pre-build", "pre-deploy", "post-deploy" + assert_hooks_ran "pre-init", "pre-connect", "pre-build", "pre-deploy", "post-deploy" assert_container_running host: :vm3, name: "app-workers-#{version}" second_version = update_app_rev @@ -65,7 +65,7 @@ class MainTest < IntegrationTest end test "config" do - config = YAML.load(kamal(:config, capture: true)) + config = YAML.load(kamal(:config, "-q", capture: true)) version = latest_app_version assert_equal [ "web" ], config[:roles] diff --git a/test/test_helper.rb b/test/test_helper.rb index 5f7a25c4..5647c382 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -26,6 +26,10 @@ end class ActiveSupport::TestCase include ActiveSupport::Testing::Stream + setup do + Kamal::Cli::Base.ran_pre_init_hook = false + end + private def stdouted capture(:stdout) { yield }.strip