Compare commits

...

5 Commits

Author SHA1 Message Date
Donal McBreen
90212129d5 Fix typo 2024-07-31 08:36:45 +01:00
Donal McBreen
a13adbf0dd Remove redundant env replacement 2024-07-31 08:35:50 +01:00
Donal McBreen
d2f57b1889 Remove the envify command
Instead of using `kamal envify` to generate the .env file, we now assume
that it will be in place for us.

Options in place of `kamal envify`:
1. Pre-generate the .env file
2. Create the env file in the `.pre-init` hook
3. Log into a secret store/check you are logged in in the pre-init hook
   Then use .dotenv command and variable substitution to interpolate the
   secrets.
2024-07-30 17:26:45 +01:00
Donal McBreen
cbb4c87035 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).
2024-07-30 16:49:22 +01:00
Donal McBreen
a8837d453c Read from .kamal/.env
To avoid conflicts with other tools that use .env files, read the files
from .kamal/ instead.

If there are no matching env files in .kamal/, we'll read from the
project root for now and emit a warning.
2024-07-30 12:29:44 +01:00
21 changed files with 90 additions and 163 deletions

View File

@@ -22,50 +22,48 @@ 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
private
def reload_env
reset_env
load_env
end
def load_env
if destination = options[:destination]
Dotenv.load(".env.#{destination}", ".env")
if File.exist?(".kamal/.env.#{destination}") || File.exist?(".kamal/.env")
Dotenv.load(".kamal/.env.#{destination}", ".kamal/.env")
else
loading_files = [ (".env" if File.exist?(".env")), (".env.#{destination}" if File.exist?(".env.#{destination}")) ].compact
if loading_files.any?
warn "Loading #{loading_files.join(" and ")} from the project root, in future they will be loaded from .kamal/"
Dotenv.load(".env.#{destination}", ".env")
end
end
else
Dotenv.load(".env")
if File.exist?(".kamal/.env")
Dotenv.load(".kamal/.env")
elsif File.exist?(".env")
$stderr.puts caller
warn "Loading .env from the project root, in future it will be loaded then from .kamal/.env"
Dotenv.load(".env")
end
end
end
def reset_env
replace_env @original_env
end
def replace_env(env)
ENV.clear
ENV.update(env)
end
def with_original_env
keeping_current_env do
reset_env
yield
end
end
def keeping_current_env
current_env = ENV.to_h.dup
yield
ensure
replace_env(current_env)
end
def options_with_subcommand_class_options
options.merge(@_initializer.last[:class_options] || {})
end
@@ -162,8 +160,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
@@ -175,6 +188,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

@@ -10,7 +10,6 @@ class Kamal::Cli::Main < Kamal::Cli::Base
invoke "kamal:cli:server:bootstrap", [], invoke_options
say "Evaluate and push env files...", :magenta
invoke "kamal:cli:main:envify", [], invoke_options
invoke "kamal:cli:env:push", [], invoke_options
invoke "kamal:cli:accessory:boot", [ "all" ], invoke_options
@@ -179,31 +178,6 @@ class Kamal::Cli::Main < Kamal::Cli::Base
end
end
desc "envify", "Create .env by evaluating .env.erb (or .env.staging.erb -> .env.staging when using -d staging)"
option :skip_push, aliases: "-P", type: :boolean, default: false, desc: "Skip .env file push"
def envify
if destination = options[:destination]
env_template_path = ".env.#{destination}.erb"
env_path = ".env.#{destination}"
else
env_template_path = ".env.erb"
env_path = ".env"
end
if Pathname.new(File.expand_path(env_template_path)).exist?
# Ensure existing env doesn't pollute template evaluation
content = with_original_env { ERB.new(File.read(env_template_path), trim_mode: "-").result }
File.write(env_path, content, perm: 0600)
unless options[:skip_push]
reload_env
invoke "kamal:cli:env:push", options
end
else
puts "Skipping envify (no #{env_template_path} exist)"
end
end
desc "remove", "Remove Traefik, app, accessories, and registry session from servers"
option :confirmed, aliases: "-y", type: :boolean, default: false, desc: "Proceed without confirmation question"
def remove

View File

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

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

@@ -24,14 +24,14 @@ env:
# KAMAL_REGISTRY_PASSWORD=pw
# DB_PASSWORD=secret123
# ```
# See https://kamal-deploy.org/docs/commands/envify/ for how to use generated .env files.
# See https://kamal-deploy.org/docs/commands/env/ for how to use generated .env files.
#
# To pass the secrets you should list them under the `secret` key. When you do this the
# other variables need to be moved under the `clear` key.
#
# Unlike clear valies, secrets are not passed directly to the container,
# Unlike clear values, secrets are not passed directly to the container,
# but are stored in an env file on the host
# The file is not updated when deploying, only when running `kamal envify` or `kamal env push`.
# The file is not updated when deploying, only when running `kamal env push`.
env:
clear:
DB_USER: app

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

@@ -8,7 +8,6 @@ class CliMainTest < CliTestCase
invoke_options = { "config_file" => "test/fixtures/deploy_simple.yml", "version" => "999", "skip_hooks" => false }
Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:server:bootstrap", [], invoke_options)
Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:main:envify", [], invoke_options)
Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:env:push", [], invoke_options)
Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:accessory:boot", [ "all" ], invoke_options)
Kamal::Cli::Main.any_instance.expects(:deploy)
@@ -24,7 +23,6 @@ class CliMainTest < CliTestCase
Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:server:bootstrap", [], invoke_options)
Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:env:push", [], invoke_options)
Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:main:envify", [], invoke_options)
Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:accessory:boot", [ "all" ], invoke_options)
# deploy
Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:registry:login", [], invoke_options.merge(skip_local: true))
@@ -58,10 +56,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 the pre-init hook...", 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 +236,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 +297,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 +395,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 +408,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 +416,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 +433,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)
@@ -446,50 +445,6 @@ class CliMainTest < CliTestCase
end
end
test "envify" do
with_test_dotenv(".env.erb": "HELLO=<%= 'world' %>") do
run_command("envify")
assert_equal("HELLO=world", File.read(".env"))
end
end
test "envify with blank line trimming" do
file = <<~EOF
HELLO=<%= 'world' %>
<% if true -%>
KEY=value
<% end -%>
EOF
with_test_dotenv(".env.erb": file) do
run_command("envify")
assert_equal("HELLO=world\nKEY=value\n", File.read(".env"))
end
end
test "envify with destination" do
with_test_dotenv(".env.world.erb": "HELLO=<%= 'world' %>") do
run_command("envify", "-d", "world", config_file: "deploy_for_dest")
assert_equal "HELLO=world", File.read(".env.world")
end
end
test "envify with skip_push" do
Pathname.any_instance.expects(:exist?).returns(true).times(1)
File.expects(:read).with(".env.erb").returns("HELLO=<%= 'world' %>")
File.expects(:write).with(".env", "HELLO=world", perm: 0600)
Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:env:push").never
run_command("envify", "--skip-push")
end
test "envify with clean env" do
with_test_dotenv(".env": "HELLO=already", ".env.erb": "HELLO=<%= ENV.fetch 'HELLO', 'never' %>") do
run_command("envify", "--skip-push")
assert_equal "HELLO=never", File.read(".env")
end
end
test "remove with confirmation" do
run_command("remove", "-y", config_file: "deploy_with_accessories").tap do |output|
assert_match /docker container stop traefik/, output
@@ -549,8 +504,11 @@ class CliMainTest < CliTestCase
FileUtils.cp_r("test/fixtures/", fixtures_dup)
Dir.chdir(dir) do
files.each do |filename, contents|
File.binwrite(filename.to_s, contents)
FileUtils.mkdir_p(".kamal")
Dir.chdir(".kamal") do
files.each do |filename, contents|
File.binwrite(filename.to_s, contents)
end
end
yield
end

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

@@ -2,8 +2,6 @@ require_relative "integration_test"
class AccessoryTest < IntegrationTest
test "boot, stop, start, restart, logs, remove" do
kamal :envify
kamal :accessory, :boot, :busybox
assert_accessory_running :busybox

View File

@@ -2,8 +2,6 @@ require_relative "integration_test"
class AppTest < IntegrationTest
test "stop, start, boot, logs, images, containers, exec, remove" do
kamal :envify
kamal :deploy
assert_app_is_up

View File

@@ -4,8 +4,6 @@ class BrokenDeployTest < IntegrationTest
test "deploying a bad image" do
@app = "app_with_roles"
kamal :envify
first_version = latest_app_version
kamal :deploy

View File

@@ -1,2 +0,0 @@
SECRET_TOKEN='1234 with "中文"'
SECRET_TAG='TAGME'

View File

@@ -1 +0,0 @@
SECRET_TOKEN='1234 with "中文"'

View File

@@ -2,8 +2,6 @@ require_relative "integration_test"
class LockTest < IntegrationTest
test "acquire, release, status" do
kamal :envify
kamal :lock, :acquire, "-m 'Integration Tests'"
status = kamal :lock, :status, capture: true

View File

@@ -1,8 +1,8 @@
require_relative "integration_test"
class MainTest < IntegrationTest
test "envify, deploy, redeploy, rollback, details and audit" do
kamal :envify
test "env push, deploy, redeploy, rollback, details and audit" do
kamal :env, :push
assert_env_files
remove_local_env_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
@@ -45,7 +45,7 @@ class MainTest < IntegrationTest
test "app with roles" do
@app = "app_with_roles"
kamal :envify
kamal :env, :push
version = latest_app_version
@@ -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
@@ -87,7 +87,7 @@ class MainTest < IntegrationTest
kamal :remove, "-y"
assert_no_images_or_containers
kamal :envify
kamal :env, :push
kamal :setup
assert_images_and_containers
@@ -97,7 +97,7 @@ class MainTest < IntegrationTest
private
def assert_local_env_file(contents)
assert_equal contents, deployer_exec("cat .env", capture: true)
assert_equal contents, deployer_exec("cat .kamal/.env", capture: true)
end
def assert_envs(version:)
@@ -127,7 +127,7 @@ class MainTest < IntegrationTest
end
def remove_local_env_file
deployer_exec("rm .env")
deployer_exec("rm .kamal/.env")
end
def assert_remote_env_file(contents, vm:)

View File

@@ -2,8 +2,6 @@ require_relative "integration_test"
class TraefikTest < IntegrationTest
test "boot, reboot, stop, start, restart, logs, remove" do
kamal :envify
kamal :traefik, :boot
assert_traefik_running

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