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).
This commit is contained in:
Donal McBreen
2024-07-30 16:49:22 +01:00
parent a8837d453c
commit cbb4c87035
12 changed files with 62 additions and 39 deletions

View File

@@ -22,9 +22,21 @@ module Kamal::Cli
class_option :skip_hooks, aliases: "-H", type: :boolean, default: false, desc: "Don't run hooks" 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(*) def initialize(*)
super super
@original_env = ENV.to_h.dup @original_env = ENV.to_h.dup
run_pre_init_hook
load_env load_env
initialize_commander(options_with_subcommand_class_options) initialize_commander(options_with_subcommand_class_options)
end end
@@ -176,8 +188,23 @@ module Kamal::Cli
end end
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) 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 } details = { hosts: KAMAL.hosts.join(","), command: command, subcommand: subcommand }
say "Running the #{hook} hook...", :magenta say "Running the #{hook} hook...", :magenta
@@ -189,6 +216,10 @@ module Kamal::Cli
end end
end end
def run_hook?(hook)
!options[:skip_hooks] && Kamal::Hooks.exists?(hook)
end
def on(*args, &block) def on(*args, &block)
if !KAMAL.connected? if !KAMAL.connected?
run_hook "pre-connect" run_hook "pre-connect"

View File

@@ -1,14 +1,5 @@
class Kamal::Commands::Hook < Kamal::Commands::Base class Kamal::Commands::Hook < Kamal::Commands::Base
def run(hook, **details) def run(hook, **details)
[ hook_file(hook), env: tags(**details).env ] [ Kamal::Hooks.file(hook), env: tags(**details).env ]
end 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 end

View File

@@ -7,7 +7,7 @@ require "erb"
require "net/ssh/proxy/jump" require "net/ssh/proxy/jump"
class Kamal::Configuration 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 delegate :argumentize, :optionize, to: Kamal::Utils
attr_reader :destination, :raw_config attr_reader :destination, :raw_config
@@ -208,10 +208,6 @@ class Kamal::Configuration
end end
end end
def hooks_path
raw_config.hooks_path || ".kamal/hooks"
end
def asset_path def asset_path
raw_config.asset_path raw_config.asset_path
end end

View File

@@ -74,10 +74,6 @@ env:
# To configure this, set the path to the assets: # To configure this, set the path to the assets:
asset_path: /path/to/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 # Require destinations
# #
# Whether deployments require a destination to be specified, defaults to `false` # Whether deployments require a destination to be specified, defaults to `false`

View File

@@ -10,7 +10,7 @@ class CliBuildTest < CliTestCase
test "push" do test "push" do
with_build_directory do |build_directory| 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" } 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) SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info)
@@ -70,7 +70,7 @@ class CliBuildTest < CliTestCase
end end
test "push without clone" do 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" } 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| run_command("push", "--verbose", fixture: :without_clone).tap do |output|

View File

@@ -18,7 +18,7 @@ class CliTestCase < ActiveSupport::TestCase
private private
def fail_hook(hook) def fail_hook(hook)
@executions = [] @executions = []
Kamal::Commands::Hook.any_instance.stubs(:hook_exists?).returns(true) Kamal::Hooks.stubs(:exists?).returns(true)
SSHKit::Backend::Abstract.any_instance.stubs(:execute) SSHKit::Backend::Abstract.any_instance.stubs(:execute)
.with { |*args| @executions << args; args != [ ".kamal/hooks/#{hook}" ] } .with { |*args| @executions << args; args != [ ".kamal/hooks/#{hook}" ] }

View File

@@ -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:app:boot", [], invoke_options)
Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:prune:all", [], 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" } 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| 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_hook_ran "pre-connect", output, **hook_variables
assert_match /Log into image registry/, output assert_match /Log into image registry/, output
assert_match /Build and push app image/, 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:stale_containers", [], invoke_options.merge(stop: true))
Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:app:boot", [], invoke_options) 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" } 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}}'") .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 .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" } 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| run_command("rollback", "--verbose", "123", config_file: "deploy_with_accessories").tap do |output|
@@ -396,7 +397,7 @@ class CliMainTest < CliTestCase
end end
test "init" do 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) Pathname.any_instance.stubs(:mkpath)
FileUtils.stubs(:mkdir_p) FileUtils.stubs(:mkdir_p)
FileUtils.stubs(:cp_r) FileUtils.stubs(:cp_r)
@@ -409,7 +410,7 @@ class CliMainTest < CliTestCase
end end
test "init with existing config" do 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| run_command("init").tap do |output|
assert_match /Config file already exists in config\/deploy.yml \(remove first to create a new one\)/, 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 end
test "init with bundle option" do 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) Pathname.any_instance.stubs(:mkpath)
FileUtils.stubs(:mkdir_p) FileUtils.stubs(:mkdir_p)
FileUtils.stubs(:cp_r) FileUtils.stubs(:cp_r)
@@ -434,7 +435,7 @@ class CliMainTest < CliTestCase
end end
test "init with bundle option and existing binstub" do 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) Pathname.any_instance.stubs(:mkpath)
FileUtils.stubs(:mkdir_p) FileUtils.stubs(:mkdir_p)
FileUtils.stubs(:cp_r) FileUtils.stubs(:cp_r)
@@ -475,7 +476,7 @@ class CliMainTest < CliTestCase
end end
test "envify with skip_push" do 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(:read).with(".kamal/.env.erb").returns("HELLO=<%= 'world' %>")
File.expects(:write).with(".kamal/.env", "HELLO=world", perm: 0600) File.expects(:write).with(".kamal/.env", "HELLO=world", perm: 0600)

View File

@@ -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('[ "${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(: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 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/pre-connect", anything).at_least_once
SSHKit::Backend::Abstract.any_instance.expects(:execute).with(".kamal/hooks/docker-setup", anything).at_least_once SSHKit::Backend::Abstract.any_instance.expects(:execute).with(".kamal/hooks/docker-setup", anything).at_least_once

View File

@@ -28,6 +28,7 @@ class CommandsHookTest < ActiveSupport::TestCase
end end
test "run with custom hooks_path" do test "run with custom hooks_path" do
ENV["KAMAL_HOOKS_PATH"] = "custom/hooks/path"
assert_equal [ assert_equal [
"custom/hooks/path/foo", "custom/hooks/path/foo",
{ env: { { env: {
@@ -36,7 +37,9 @@ class CommandsHookTest < ActiveSupport::TestCase
"KAMAL_VERSION" => "123", "KAMAL_VERSION" => "123",
"KAMAL_SERVICE_VERSION" => "app@123", "KAMAL_SERVICE_VERSION" => "app@123",
"KAMAL_SERVICE" => "app" } } "KAMAL_SERVICE" => "app" } }
], new_command(hooks_path: "custom/hooks/path").run("foo") ], new_command.run("foo")
ensure
ENV.delete("KAMAL_HOOKS_PATH")
end end
private private

View File

@@ -6,7 +6,7 @@ class ConfigurationValidationTest < ActiveSupport::TestCase
end end
test "wrong root types" do 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 => [] } assert_error "#{key}: should be a string", **{ key => [] }
end end

View File

@@ -12,19 +12,19 @@ class MainTest < IntegrationTest
kamal :deploy kamal :deploy
assert_app_is_up version: first_version 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 assert_envs version: first_version
second_version = update_app_rev second_version = update_app_rev
kamal :redeploy kamal :redeploy
assert_app_is_up version: second_version 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 assert_accumulated_assets first_version, second_version
kamal :rollback, first_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 assert_app_is_up version: first_version
details = kamal :details, capture: true details = kamal :details, capture: true
@@ -54,7 +54,7 @@ class MainTest < IntegrationTest
kamal :deploy kamal :deploy
assert_app_is_up version: version 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}" assert_container_running host: :vm3, name: "app-workers-#{version}"
second_version = update_app_rev second_version = update_app_rev
@@ -65,7 +65,7 @@ class MainTest < IntegrationTest
end end
test "config" do test "config" do
config = YAML.load(kamal(:config, capture: true)) config = YAML.load(kamal(:config, "-q", capture: true))
version = latest_app_version version = latest_app_version
assert_equal [ "web" ], config[:roles] assert_equal [ "web" ], config[:roles]

View File

@@ -26,6 +26,10 @@ end
class ActiveSupport::TestCase class ActiveSupport::TestCase
include ActiveSupport::Testing::Stream include ActiveSupport::Testing::Stream
setup do
Kamal::Cli::Base.ran_pre_init_hook = false
end
private private
def stdouted def stdouted
capture(:stdout) { yield }.strip capture(:stdout) { yield }.strip