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"
@@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"

View File

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

View File

@@ -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

View File

@@ -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`

View File

@@ -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|

View File

@@ -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}" ] }

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: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)

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(: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

View File

@@ -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

View File

@@ -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

View File

@@ -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]

View File

@@ -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