From e6d436f6468795b48c18a5820e2246d1a08a2471 Mon Sep 17 00:00:00 2001 From: Donal McBreen Date: Fri, 5 Apr 2024 12:46:51 +0100 Subject: [PATCH 01/71] Output the host when running accessory details We already do this for app and Traefik hosts. --- lib/kamal/cli/accessory.rb | 3 ++- test/cli/accessory_test.rb | 7 ++++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/lib/kamal/cli/accessory.rb b/lib/kamal/cli/accessory.rb index 64cd97cdd..4c695a28f 100644 --- a/lib/kamal/cli/accessory.rb +++ b/lib/kamal/cli/accessory.rb @@ -107,8 +107,9 @@ def details(name) if name == "all" KAMAL.accessory_names.each { |accessory_name| details(accessory_name) } else + type = "Accessory #{name}" with_accessory(name) do |accessory, hosts| - on(hosts) { puts capture_with_info(*accessory.info) } + on(hosts) { puts_by_host host, capture_with_info(*accessory.info), type: type } end end end diff --git a/test/cli/accessory_test.rb b/test/cli/accessory_test.rb index a7913e0f2..cb52ee2e1 100644 --- a/test/cli/accessory_test.rb +++ b/test/cli/accessory_test.rb @@ -76,7 +76,10 @@ class CliAccessoryTest < CliTestCase end test "details" do - assert_match "docker ps --filter label=service=app-mysql", run_command("details", "mysql") + run_command("details", "mysql").tap do |output| + assert_match "docker ps --filter label=service=app-mysql", output + assert_match "Accessory mysql Host: 1.1.1.3", output + end end test "details with non-existent accessory" do @@ -85,6 +88,8 @@ class CliAccessoryTest < CliTestCase test "details with all" do run_command("details", "all").tap do |output| + assert_match "Accessory mysql Host: 1.1.1.3", output + assert_match "Accessory redis Host: 1.1.1.2", output assert_match "docker ps --filter label=service=app-mysql", output assert_match "docker ps --filter label=service=app-redis", output end From 69f90387a881332468686df2d1d1ea9afa664c42 Mon Sep 17 00:00:00 2001 From: Tim Tilberg Date: Mon, 15 Apr 2024 09:09:58 -0500 Subject: [PATCH 02/71] Allow capital letters to match valid service name, such as in MyApp --- lib/kamal/configuration.rb | 2 +- test/configuration_test.rb | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/kamal/configuration.rb b/lib/kamal/configuration.rb index df95f170c..0a9c35763 100644 --- a/lib/kamal/configuration.rb +++ b/lib/kamal/configuration.rb @@ -301,7 +301,7 @@ def ensure_required_keys_present end def ensure_valid_service_name - raise ArgumentError, "Service name can only include alphanumeric characters, hyphens, and underscores" unless raw_config[:service] =~ /^[a-z0-9_-]+$/ + raise ArgumentError, "Service name can only include alphanumeric characters, hyphens, and underscores" unless raw_config[:service] =~ /^[a-z0-9_-]+$/i true end diff --git a/test/configuration_test.rb b/test/configuration_test.rb index 6f89d669c..36a9f3055 100644 --- a/test/configuration_test.rb +++ b/test/configuration_test.rb @@ -44,6 +44,7 @@ class ConfigurationTest < ActiveSupport::TestCase test "service name valid" do assert Kamal::Configuration.new(@deploy.tap { _1[:service] = "hey-app1_primary" }).valid? + assert Kamal::Configuration.new(@deploy.tap { _1[:service] = "MyApp" }).valid? end test "service name invalid" do From 12c518097f94bdf755c403a0ea53d5c856d2f98c Mon Sep 17 00:00:00 2001 From: Alexandr Borisov Date: Wed, 17 Apr 2024 11:45:33 +0300 Subject: [PATCH 03/71] Take accessory hosts into account --- lib/kamal/configuration.rb | 2 +- test/configuration/accessory_test.rb | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/lib/kamal/configuration.rb b/lib/kamal/configuration.rb index df95f170c..53ed52cfe 100644 --- a/lib/kamal/configuration.rb +++ b/lib/kamal/configuration.rb @@ -88,7 +88,7 @@ def accessory(name) def all_hosts - roles.flat_map(&:hosts).uniq + (roles + accessories).flat_map(&:hosts).uniq end def primary_host diff --git a/test/configuration/accessory_test.rb b/test/configuration/accessory_test.rb index d53ac9ba7..51c98c6e0 100644 --- a/test/configuration/accessory_test.rb +++ b/test/configuration/accessory_test.rb @@ -107,6 +107,10 @@ class ConfigurationAccessoryTest < ActiveSupport::TestCase assert_equal "Specify one of `host`, `hosts` or `roles` for accessory `mysql`", exception.message end + test "all hosts" do + assert_equal [ "1.1.1.1", "1.1.1.2", "1.1.1.3", "1.1.1.4", "1.1.1.5", "1.1.1.6", "1.1.1.7" ], @config.all_hosts + end + test "label args" do assert_equal [ "--label", "service=\"app-mysql\"" ], @config.accessory(:mysql).label_args assert_equal [ "--label", "service=\"app-redis\"", "--label", "cache=\"true\"" ], @config.accessory(:redis).label_args From 9a9a0914cdbcc045552a98bad7829fbf415ab268 Mon Sep 17 00:00:00 2001 From: xiaohui Date: Wed, 17 Apr 2024 17:42:06 +0800 Subject: [PATCH 04/71] don't escape non-ascii characters in docker env file --- lib/kamal/env_file.rb | 7 +++++ test/env_file_test.rb | 27 +++++++++++++++++++ test/integration/docker/deployer/app/.env.erb | 2 +- .../docker/deployer/app_with_roles/.env.erb | 2 +- test/integration/main_test.rb | 6 ++--- 5 files changed, 39 insertions(+), 5 deletions(-) diff --git a/lib/kamal/env_file.rb b/lib/kamal/env_file.rb index 0c9f23414..2228be098 100644 --- a/lib/kamal/env_file.rb +++ b/lib/kamal/env_file.rb @@ -24,6 +24,13 @@ def docker_env_file_line(key, value) # Escape a value to make it safe to dump in a docker file. def escape_docker_env_file_value(value) + # keep non-ascii(UTF-8) characters as it is + value.to_s.scan(/[\x00-\x7F]+|[^\x00-\x7F]+/).map do |part| + part.ascii_only? ? escape_docker_env_file_ascii_value(part) : part + end.join + end + + def escape_docker_env_file_ascii_value(value) # Doublequotes are treated literally in docker env files # so remove leading and trailing ones and unescape any others value.to_s.dump[1..-2].gsub(/\\"/, "\"") diff --git a/test/env_file_test.rb b/test/env_file_test.rb index 6fcef6e39..c6b9e66ec 100644 --- a/test/env_file_test.rb +++ b/test/env_file_test.rb @@ -11,6 +11,33 @@ class EnvFileTest < ActiveSupport::TestCase Kamal::EnvFile.new(env).to_s end + test "to_str won't escape chinese characters" do + env = { + "foo" => '你好 means hello, "欢迎" means welcome, that\'s simple! 😃 {smile}' + } + + assert_equal "foo=你好 means hello, \"欢迎\" means welcome, that's simple! 😃 {smile}\n", + Kamal::EnvFile.new(env).to_s + end + + test "to_s won't escape japanese characters" do + env = { + "foo" => 'こんにちは means hello, "ようこそ" means welcome, that\'s simple! 😃 {smile}' + } + + assert_equal "foo=こんにちは means hello, \"ようこそ\" means welcome, that's simple! 😃 {smile}\n", \ + Kamal::EnvFile.new(env).to_s + end + + test "to_s won't escape korean characters" do + env = { + "foo" => '안녕하세요 means hello, "어서 오십시오" means welcome, that\'s simple! 😃 {smile}' + } + + assert_equal "foo=안녕하세요 means hello, \"어서 오십시오\" means welcome, that's simple! 😃 {smile}\n", \ + Kamal::EnvFile.new(env).to_s + end + test "to_s empty" do assert_equal "\n", Kamal::EnvFile.new({}).to_s end diff --git a/test/integration/docker/deployer/app/.env.erb b/test/integration/docker/deployer/app/.env.erb index dcd2fcf5c..cb2988d6b 100644 --- a/test/integration/docker/deployer/app/.env.erb +++ b/test/integration/docker/deployer/app/.env.erb @@ -1 +1 @@ -SECRET_TOKEN=1234 +SECRET_TOKEN='1234 with "中文"' diff --git a/test/integration/docker/deployer/app_with_roles/.env.erb b/test/integration/docker/deployer/app_with_roles/.env.erb index dcd2fcf5c..cb2988d6b 100644 --- a/test/integration/docker/deployer/app_with_roles/.env.erb +++ b/test/integration/docker/deployer/app_with_roles/.env.erb @@ -1 +1 @@ -SECRET_TOKEN=1234 +SECRET_TOKEN='1234 with "中文"' diff --git a/test/integration/main_test.rb b/test/integration/main_test.rb index f8f69b0e0..ac549b972 100644 --- a/test/integration/main_test.rb +++ b/test/integration/main_test.rb @@ -3,8 +3,8 @@ class MainTest < IntegrationTest test "envify, deploy, redeploy, rollback, details and audit" do kamal :envify - assert_local_env_file "SECRET_TOKEN=1234" - assert_remote_env_file "SECRET_TOKEN=1234" + assert_local_env_file "SECRET_TOKEN='1234 with \"中文\"'" + assert_remote_env_file "SECRET_TOKEN=1234 with \"中文\"" remove_local_env_file first_version = latest_app_version @@ -16,7 +16,7 @@ class MainTest < IntegrationTest assert_hooks_ran "pre-connect", "pre-build", "pre-deploy", "post-deploy" assert_env :CLEAR_TOKEN, "4321", version: first_version assert_env :HOST_TOKEN, "abcd", version: first_version - assert_env :SECRET_TOKEN, "1234", version: first_version + assert_env :SECRET_TOKEN, "1234 with \"中文\"", version: first_version second_version = update_app_rev From 2f912367ac643c37a0db9ac21b239c1580f55f1f Mon Sep 17 00:00:00 2001 From: Maciej Litwiniuk Date: Tue, 26 Dec 2023 17:24:47 +0100 Subject: [PATCH 05/71] Allow custom user and port for builder host When ssh options are set, they overwrite username and password passed as ssh builder uri. Passing part of uri for ssh-kit is fine, as it then properly extracts username and password and forwards it as host.ssh_options (in which case it's no longer empty) --- lib/kamal/cli/build.rb | 7 ++- lib/kamal/sshkit_with_ext.rb | 3 +- test/cli/build_test.rb | 8 ++++ ...y_with_remote_builder_and_custom_ports.yml | 45 +++++++++++++++++++ 4 files changed, 60 insertions(+), 3 deletions(-) create mode 100644 test/fixtures/deploy_with_remote_builder_and_custom_ports.yml diff --git a/lib/kamal/cli/build.rb b/lib/kamal/cli/build.rb index 8eae4dd70..558afe6c5 100644 --- a/lib/kamal/cli/build.rb +++ b/lib/kamal/cli/build.rb @@ -114,8 +114,11 @@ def verify_local_dependencies def connect_to_remote_host(remote_host) remote_uri = URI.parse(remote_host) if remote_uri.scheme == "ssh" - options = { user: remote_uri.user, port: remote_uri.port }.compact - on(remote_uri.host, options) do + host = SSHKit::Host.new( + hostname: remote_uri.host, + ssh_options: { user: remote_uri.user, port: remote_uri.port }.compact + ) + on(host, options) do execute "true" end end diff --git a/lib/kamal/sshkit_with_ext.rb b/lib/kamal/sshkit_with_ext.rb index e0c62c3ad..f0cdf8c82 100644 --- a/lib/kamal/sshkit_with_ext.rb +++ b/lib/kamal/sshkit_with_ext.rb @@ -80,7 +80,8 @@ class << self module LimitConcurrentStartsInstance private def with_ssh(&block) - host.ssh_options = self.class.config.ssh_options.merge(host.ssh_options || {}) + host.ssh_options = (host.ssh_options || {}).merge({ port: host.port, user: host.user }.compact) + host.ssh_options = self.class.config.ssh_options.merge(host.ssh_options) self.class.pool.with( method(:start_with_concurrency_limit), String(host.hostname), diff --git a/test/cli/build_test.rb b/test/cli/build_test.rb index 5c3e3680e..46770cf40 100644 --- a/test/cli/build_test.rb +++ b/test/cli/build_test.rb @@ -79,6 +79,14 @@ class CliBuildTest < CliTestCase end end + test "create remote with custom ports" do + run_command("create", fixture: :with_remote_builder_and_custom_ports).tap do |output| + assert_match "Running /usr/bin/env true on 1.1.1.5", output + assert_match "docker context create kamal-app-native-remote-amd64 --description 'kamal-app-native-remote amd64 native host' --docker 'host=ssh://app@1.1.1.5:2122'", output + assert_match "docker buildx create --name kamal-app-native-remote kamal-app-native-remote-amd64 --platform linux/amd64", output + end + end + test "create with error" do stub_setup SSHKit::Backend::Abstract.any_instance.stubs(:execute) diff --git a/test/fixtures/deploy_with_remote_builder_and_custom_ports.yml b/test/fixtures/deploy_with_remote_builder_and_custom_ports.yml new file mode 100644 index 000000000..d1e81836b --- /dev/null +++ b/test/fixtures/deploy_with_remote_builder_and_custom_ports.yml @@ -0,0 +1,45 @@ +service: app +image: dhh/app +servers: + web: + - "1.1.1.1" + - "1.1.1.2" + workers: + - "1.1.1.3" + - "1.1.1.4" +registry: + username: user + password: pw + +accessories: + mysql: + image: mysql:5.7 + host: 1.1.1.3 + port: 3306 + env: + clear: + MYSQL_ROOT_HOST: '%' + secret: + - MYSQL_ROOT_PASSWORD + files: + - test/fixtures/files/my.cnf:/etc/mysql/my.cnf + directories: + - data:/var/lib/mysql + redis: + image: redis:latest + roles: + - web + port: 6379 + directories: + - data:/data + +readiness_delay: 0 + +ssh: + user: root + port: 22 + +builder: + remote: + arch: amd64 + host: ssh://app@1.1.1.5:2122 From d475e88dbee72dd97bd6217578976339b8402d58 Mon Sep 17 00:00:00 2001 From: Donal McBreen Date: Thu, 25 Apr 2024 13:39:06 +0100 Subject: [PATCH 06/71] Bump version for 1.5.0 --- Gemfile.lock | 2 +- lib/kamal/version.rb | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index 3aed5e983..e0e6b627b 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,7 +1,7 @@ PATH remote: . specs: - kamal (1.4.0) + kamal (1.5.0) activesupport (>= 7.0) base64 (~> 0.2) bcrypt_pbkdf (~> 1.0) diff --git a/lib/kamal/version.rb b/lib/kamal/version.rb index c67efefc5..0b10ac361 100644 --- a/lib/kamal/version.rb +++ b/lib/kamal/version.rb @@ -1,3 +1,3 @@ module Kamal - VERSION = "1.4.0" + VERSION = "1.5.0" end From f785451cc75a3c083993cc33d95c3d2b4c4b64e8 Mon Sep 17 00:00:00 2001 From: Donal McBreen Date: Fri, 26 Apr 2024 13:32:24 +0100 Subject: [PATCH 07/71] Allow glob matches for roles and hosts This lets you do things like: ``` kamal details -h '1.1.1.[1-9]' kamal details -r 'w{eb,orkers}' ``` --- lib/kamal/utils.rb | 5 ++--- test/commander_test.rb | 6 ++++++ 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/lib/kamal/utils.rb b/lib/kamal/utils.rb index af35edea1..e6b28e43a 100644 --- a/lib/kamal/utils.rb +++ b/lib/kamal/utils.rb @@ -66,13 +66,12 @@ def filter_specific_items(filters, items) Array(filters).select do |filter| matches += Array(items).select do |item| # Only allow * for a wildcard - pattern = Regexp.escape(filter).gsub('\*', ".*") # items are roles or hosts - (item.respond_to?(:name) ? item.name : item).match(/^#{pattern}$/) + File.fnmatch(filter, item.to_s, File::FNM_EXTGLOB) end end - matches + matches.uniq end def stable_sort!(elements, &block) diff --git a/test/commander_test.rb b/test/commander_test.rb index 9eda17544..c8dd0517c 100644 --- a/test/commander_test.rb +++ b/test/commander_test.rb @@ -24,6 +24,9 @@ class CommanderTest < ActiveSupport::TestCase @kamal.specific_hosts = [ "*" ] assert_equal [ "1.1.1.1", "1.1.1.2", "1.1.1.3", "1.1.1.4" ], @kamal.hosts + @kamal.specific_hosts = [ "1.1.1.[12]" ] + assert_equal [ "1.1.1.1", "1.1.1.2" ], @kamal.hosts + exception = assert_raises(ArgumentError) do @kamal.specific_hosts = [ "*miss" ] end @@ -57,6 +60,9 @@ class CommanderTest < ActiveSupport::TestCase @kamal.specific_roles = [ "*" ] assert_equal [ "web", "workers" ], @kamal.roles.map(&:name) + @kamal.specific_roles = [ "w{eb,orkers}" ] + assert_equal [ "web", "workers" ], @kamal.roles.map(&:name) + exception = assert_raises(ArgumentError) do @kamal.specific_roles = [ "*miss" ] end From 1f5b936fa2133db174bc8bdd9c9e1b56c79c55ab Mon Sep 17 00:00:00 2001 From: Donal McBreen Date: Fri, 26 Apr 2024 14:16:19 +0100 Subject: [PATCH 08/71] Escape single quotes to fix log following Fixes: https://github.com/basecamp/kamal/issues/777 --- lib/kamal/commands/base.rb | 2 +- test/cli/app_test.rb | 4 ++-- test/commands/app_test.rb | 8 ++++---- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/lib/kamal/commands/base.rb b/lib/kamal/commands/base.rb index 8c289ae0b..824047839 100644 --- a/lib/kamal/commands/base.rb +++ b/lib/kamal/commands/base.rb @@ -18,7 +18,7 @@ def run_over_ssh(*command, host:) elsif config.ssh.proxy && config.ssh.proxy.is_a?(Net::SSH::Proxy::Command) cmd << " -o ProxyCommand='#{config.ssh.proxy.command_line_template}'" end - cmd << " -t #{config.ssh.user}@#{host} -p #{config.ssh.port} '#{command.join(" ")}'" + cmd << " -t #{config.ssh.user}@#{host} -p #{config.ssh.port} '#{command.join(" ").gsub("'", "'\\\\''")}'" end end diff --git a/test/cli/app_test.rb b/test/cli/app_test.rb index ab0eff51f..dabd2ccda 100644 --- a/test/cli/app_test.rb +++ b/test/cli/app_test.rb @@ -218,9 +218,9 @@ class CliAppTest < CliTestCase test "logs with follow" do SSHKit::Backend::Abstract.any_instance.stubs(:exec) - .with("ssh -t root@1.1.1.1 -p 22 'sh -c 'docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''{{.ID}}'\\'') ; docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting' | head -1 | xargs docker logs --timestamps --tail 10 --follow 2>&1'") + .with("ssh -t root@1.1.1.1 -p 22 'sh -c '\\''docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''\\'\\'''\\''{{.ID}}'\\''\\'\\'''\\'') ; docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting'\\'' | head -1 | xargs docker logs --timestamps --tail 10 --follow 2>&1'") - assert_match "sh -c 'docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''{{.ID}}'\\'') ; docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting' | head -1 | xargs docker logs --timestamps --tail 10 --follow 2>&1", run_command("logs", "--follow") + assert_match "sh -c '\\''docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''\\'\\'''\\''{{.ID}}'\\''\\'\\'''\\'') ; docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting'\\'' | head -1 | xargs docker logs --timestamps --tail 10 --follow 2>&1", run_command("logs", "--follow") end test "version" do diff --git a/test/commands/app_test.rb b/test/commands/app_test.rb index 2d6163351..3fe97d97c 100644 --- a/test/commands/app_test.rb +++ b/test/commands/app_test.rb @@ -154,19 +154,19 @@ class CommandsAppTest < ActiveSupport::TestCase test "follow logs" do assert_equal \ - "ssh -t root@app-1 -p 22 'sh -c 'docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''{{.ID}}'\\'') ; docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting' | head -1 | xargs docker logs --timestamps --follow 2>&1'", + "ssh -t root@app-1 -p 22 'sh -c '\\''docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''\\'\\'''\\''{{.ID}}'\\''\\'\\'''\\'') ; docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting'\\'' | head -1 | xargs docker logs --timestamps --follow 2>&1'", new_command.follow_logs(host: "app-1") assert_equal \ - "ssh -t root@app-1 -p 22 'sh -c 'docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''{{.ID}}'\\'') ; docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting' | head -1 | xargs docker logs --timestamps --follow 2>&1 | grep \"Completed\"'", + "ssh -t root@app-1 -p 22 'sh -c '\\''docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''\\'\\'''\\''{{.ID}}'\\''\\'\\'''\\'') ; docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting'\\'' | head -1 | xargs docker logs --timestamps --follow 2>&1 | grep \"Completed\"'", new_command.follow_logs(host: "app-1", grep: "Completed") assert_equal \ - "ssh -t root@app-1 -p 22 'sh -c 'docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''{{.ID}}'\\'') ; docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting' | head -1 | xargs docker logs --timestamps --tail 123 --follow 2>&1'", + "ssh -t root@app-1 -p 22 'sh -c '\\''docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''\\'\\'''\\''{{.ID}}'\\''\\'\\'''\\'') ; docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting'\\'' | head -1 | xargs docker logs --timestamps --tail 123 --follow 2>&1'", new_command.follow_logs(host: "app-1", lines: 123) assert_equal \ - "ssh -t root@app-1 -p 22 'sh -c 'docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''{{.ID}}'\\'') ; docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting' | head -1 | xargs docker logs --timestamps --tail 123 --follow 2>&1 | grep \"Completed\"'", + "ssh -t root@app-1 -p 22 'sh -c '\\''docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''\\'\\'''\\''{{.ID}}'\\''\\'\\'''\\'') ; docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting'\\'' | head -1 | xargs docker logs --timestamps --tail 123 --follow 2>&1 | grep \"Completed\"'", new_command.follow_logs(host: "app-1", lines: 123, grep: "Completed") end From f48f528043c1f5a428dd94582d0d7c4b341491a1 Mon Sep 17 00:00:00 2001 From: Donal McBreen Date: Fri, 26 Apr 2024 14:26:02 +0100 Subject: [PATCH 09/71] Bump version for 1.5.1 --- Gemfile.lock | 2 +- lib/kamal/version.rb | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index e0e6b627b..d4e23c415 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,7 +1,7 @@ PATH remote: . specs: - kamal (1.5.0) + kamal (1.5.1) activesupport (>= 7.0) base64 (~> 0.2) bcrypt_pbkdf (~> 1.0) diff --git a/lib/kamal/version.rb b/lib/kamal/version.rb index 0b10ac361..28d0ddf08 100644 --- a/lib/kamal/version.rb +++ b/lib/kamal/version.rb @@ -1,3 +1,3 @@ module Kamal - VERSION = "1.5.0" + VERSION = "1.5.1" end From b8aaddb4c975b224a25a23913a660ac6d0234a5f Mon Sep 17 00:00:00 2001 From: Matthew Kent Date: Fri, 26 Apr 2024 17:07:06 -0700 Subject: [PATCH 10/71] Apply --hosts and --roles filters to traefik hosts as well. --- lib/kamal/commander/specifics.rb | 2 +- test/commander_test.rb | 14 ++++++++++++++ test/configuration_test.rb | 9 +++++++++ 3 files changed, 24 insertions(+), 1 deletion(-) diff --git a/lib/kamal/commander/specifics.rb b/lib/kamal/commander/specifics.rb index 88d89a72c..127bd40e5 100644 --- a/lib/kamal/commander/specifics.rb +++ b/lib/kamal/commander/specifics.rb @@ -19,7 +19,7 @@ def roles_on(host) end def traefik_hosts - specific_hosts || config.traefik_hosts + config.traefik_hosts & specified_hosts end def accessory_hosts diff --git a/test/commander_test.rb b/test/commander_test.rb index c8dd0517c..2bbdb4790 100644 --- a/test/commander_test.rb +++ b/test/commander_test.rb @@ -131,6 +131,20 @@ class CommanderTest < ActiveSupport::TestCase assert_equal [ "1.1.1.3", "1.1.1.4", "1.1.1.1", "1.1.1.2" ], @kamal.hosts end + test "traefik hosts should observe filtered roles" do + configure_with(:deploy_with_aliases) + + @kamal.specific_roles = [ "web_tokyo" ] + assert_equal [ "1.1.1.3", "1.1.1.4" ], @kamal.traefik_hosts + end + + test "traefik hosts should observe filtered hosts" do + configure_with(:deploy_with_aliases) + + @kamal.specific_hosts = [ "1.1.1.4" ] + assert_equal [ "1.1.1.4" ], @kamal.traefik_hosts + end + private def configure_with(variant) @kamal = Kamal::Commander.new.tap do |kamal| diff --git a/test/configuration_test.rb b/test/configuration_test.rb index 36a9f3055..6a86b2314 100644 --- a/test/configuration_test.rb +++ b/test/configuration_test.rb @@ -83,6 +83,15 @@ class ConfigurationTest < ActiveSupport::TestCase assert_equal [ "1.1.1.1", "1.1.1.2", "1.1.1.3" ], config.traefik_hosts end + test "filtered traefik hosts" do + assert_equal [ "1.1.1.1", "1.1.1.2" ], @config_with_roles.traefik_hosts + + @deploy_with_roles[:servers]["workers"]["traefik"] = true + config = Kamal::Configuration.new(@deploy_with_roles) + + assert_equal [ "1.1.1.1", "1.1.1.2", "1.1.1.3" ], config.traefik_hosts + end + test "version no git repo" do ENV.delete("VERSION") From 947be0877f3ef7277297bdd20804dfb9eb3fbfab Mon Sep 17 00:00:00 2001 From: Jason Nochlin Date: Sat, 27 Apr 2024 10:24:34 -0600 Subject: [PATCH 11/71] add --target option for builder configuration --- lib/kamal/commands/builder/base.rb | 8 ++++++-- lib/kamal/configuration/builder.rb | 4 ++++ test/commands/builder_test.rb | 7 +++++++ 3 files changed, 17 insertions(+), 2 deletions(-) diff --git a/lib/kamal/commands/builder/base.rb b/lib/kamal/commands/builder/base.rb index 95b079e15..5021de1ca 100644 --- a/lib/kamal/commands/builder/base.rb +++ b/lib/kamal/commands/builder/base.rb @@ -3,7 +3,7 @@ class Kamal::Commands::Builder::Base < Kamal::Commands::Base class BuilderError < StandardError; end delegate :argumentize, to: Kamal::Utils - delegate :args, :secrets, :dockerfile, :local_arch, :local_host, :remote_arch, :remote_host, :cache_from, :cache_to, :ssh, :git_archive?, to: :builder_config + delegate :args, :secrets, :dockerfile, :target, :local_arch, :local_host, :remote_arch, :remote_host, :cache_from, :cache_to, :ssh, :git_archive?, to: :builder_config def clean docker :image, :rm, "--force", config.absolute_image @@ -24,7 +24,7 @@ def push end def build_options - [ *build_tags, *build_cache, *build_labels, *build_args, *build_secrets, *build_dockerfile, *build_ssh ] + [ *build_tags, *build_cache, *build_labels, *build_args, *build_secrets, *build_dockerfile, *build_target, *build_ssh ] end def build_context @@ -73,6 +73,10 @@ def build_dockerfile end end + def build_target + argumentize "--target", target if target.present? + end + def build_ssh argumentize "--ssh", ssh if ssh.present? end diff --git a/lib/kamal/configuration/builder.rb b/lib/kamal/configuration/builder.rb index ea4e71d94..cf69939b5 100644 --- a/lib/kamal/configuration/builder.rb +++ b/lib/kamal/configuration/builder.rb @@ -38,6 +38,10 @@ def secrets def dockerfile @options["dockerfile"] || "Dockerfile" end + + def target + @options["target"] + end def context @options["context"] || (git_archive? ? "-" : ".") diff --git a/test/commands/builder_test.rb b/test/commands/builder_test.rb index b03b0c83d..cc7cea0b7 100644 --- a/test/commands/builder_test.rb +++ b/test/commands/builder_test.rb @@ -83,6 +83,13 @@ class CommandsBuilderTest < ActiveSupport::TestCase end end + test "build target" do + builder = new_builder_command(builder: { "target" => "prod" }) + assert_equal \ + "-t dhh/app:123 -t dhh/app:latest --label service=\"app\" --file Dockerfile --target prod", + builder.target.build_options.join(" ") + end + test "build context" do builder = new_builder_command(builder: { "context" => ".." }) assert_equal \ From b67f40bdf7448718bd401e8f9217ad60375b2f47 Mon Sep 17 00:00:00 2001 From: Donal McBreen Date: Thu, 2 May 2024 12:38:20 +0100 Subject: [PATCH 12/71] Warn on missing builder We are going to try to create a builder if one is missing, so let's warn rather than report it as an error. --- lib/kamal/cli/build.rb | 2 +- test/cli/build_test.rb | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/kamal/cli/build.rb b/lib/kamal/cli/build.rb index 8eae4dd70..a43cee2e4 100644 --- a/lib/kamal/cli/build.rb +++ b/lib/kamal/cli/build.rb @@ -30,7 +30,7 @@ def push end rescue SSHKit::Command::Failed => e if e.message =~ /(no builder)|(no such file or directory)/ - error "Missing compatible builder, so creating a new one first" + warn "Missing compatible builder, so creating a new one first" if cli.create KAMAL.with_verbosity(:debug) { execute *KAMAL.builder.push } diff --git a/test/cli/build_test.rb b/test/cli/build_test.rb index 5c3e3680e..ca11dc1aa 100644 --- a/test/cli/build_test.rb +++ b/test/cli/build_test.rb @@ -34,7 +34,7 @@ class CliBuildTest < CliTestCase .returns(true) run_command("push").tap do |output| - assert_match /Missing compatible builder, so creating a new one first/, output + assert_match /WARN Missing compatible builder, so creating a new one first/, output end end From 3c8428504db6539de789613614ef280578a04f3e Mon Sep 17 00:00:00 2001 From: Donal McBreen Date: Tue, 7 May 2024 09:44:11 +0100 Subject: [PATCH 13/71] Bump version for 1.5.2 --- Gemfile.lock | 2 +- lib/kamal/version.rb | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index d4e23c415..dbb216606 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,7 +1,7 @@ PATH remote: . specs: - kamal (1.5.1) + kamal (1.5.2) activesupport (>= 7.0) base64 (~> 0.2) bcrypt_pbkdf (~> 1.0) diff --git a/lib/kamal/version.rb b/lib/kamal/version.rb index 28d0ddf08..f5d10d62c 100644 --- a/lib/kamal/version.rb +++ b/lib/kamal/version.rb @@ -1,3 +1,3 @@ module Kamal - VERSION = "1.5.1" + VERSION = "1.5.2" end From 63c47eca4c3b7592fe4c17417b0c377b97fb2621 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Falk?= Date: Sun, 7 Apr 2024 18:59:51 +0200 Subject: [PATCH 14/71] Trim long hostnames Hostnames longer than 64 characters are not supported by docker --- lib/kamal/cli/app/boot.rb | 3 ++- test/cli/app_test.rb | 20 +++++++++++++++++++ .../deploy_with_uncommon_hostnames.yml | 8 ++++++++ 3 files changed, 30 insertions(+), 1 deletion(-) create mode 100644 test/fixtures/deploy_with_uncommon_hostnames.yml diff --git a/lib/kamal/cli/app/boot.rb b/lib/kamal/cli/app/boot.rb index ed7e2ed6e..1179c2edf 100644 --- a/lib/kamal/cli/app/boot.rb +++ b/lib/kamal/cli/app/boot.rb @@ -47,7 +47,8 @@ def old_version_renamed_if_clashing def start_new_version audit "Booted app version #{version}" execute *app.tie_cord(role.cord_host_file) if uses_cord? - execute *app.run(hostname: "#{host}-#{SecureRandom.hex(6)}") + hostname = "#{host.to_s[0...51].gsub(/\.+$/, '')}-#{SecureRandom.hex(6)}" + execute *app.run(hostname: hostname) Kamal::Cli::Healthcheck::Poller.wait_for_healthy(pause_after_ready: true) { capture_with_info(*app.status(version: version)) } end diff --git a/test/cli/app_test.rb b/test/cli/app_test.rb index ab0eff51f..9ed9587e5 100644 --- a/test/cli/app_test.rb +++ b/test/cli/app_test.rb @@ -236,6 +236,26 @@ class CliAppTest < CliTestCase end end + test "long hostname" do + stub_running + + hostname = "this-hostname-is-really-unacceptably-long-to-be-honest.example.com" + + stdouted { Kamal::Cli::App.start([ "boot", "-c", "test/fixtures/deploy_with_uncommon_hostnames.yml", "--hosts", hostname ]) }.tap do |output| + assert_match /docker run --detach --restart unless-stopped --name app-web-latest --hostname this-hostname-is-really-unacceptably-long-to-be-hon-[0-9a-f]{12} /, output + end + end + + test "hostname is trimmed if will end with a period" do + stub_running + + hostname = "this-hostname-with-random-part-is-too-long.example.com" + + stdouted { Kamal::Cli::App.start([ "boot", "-c", "test/fixtures/deploy_with_uncommon_hostnames.yml", "--hosts", hostname ]) }.tap do |output| + assert_match /docker run --detach --restart unless-stopped --name app-web-latest --hostname this-hostname-with-random-part-is-too-long.example-[0-9a-f]{12} /, output + end + end + private def run_command(*command, config: :with_accessories) stdouted { Kamal::Cli::App.start([ *command, "-c", "test/fixtures/deploy_#{config}.yml", "--hosts", "1.1.1.1" ]) } diff --git a/test/fixtures/deploy_with_uncommon_hostnames.yml b/test/fixtures/deploy_with_uncommon_hostnames.yml new file mode 100644 index 000000000..71e7a6018 --- /dev/null +++ b/test/fixtures/deploy_with_uncommon_hostnames.yml @@ -0,0 +1,8 @@ +service: app +image: dhh/app +servers: + - "this-hostname-with-random-part-is-too-long.example.com" + - "this-hostname-is-really-unacceptably-long-to-be-honest.example.com" +registry: + username: user + password: pw From 1e44cc2597684797641168c6c85bc7afe71acc36 Mon Sep 17 00:00:00 2001 From: Jason Nochlin Date: Wed, 8 May 2024 19:22:25 -0600 Subject: [PATCH 15/71] fix rubocop violation --- lib/kamal/configuration/builder.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/kamal/configuration/builder.rb b/lib/kamal/configuration/builder.rb index cf69939b5..dcc9c516b 100644 --- a/lib/kamal/configuration/builder.rb +++ b/lib/kamal/configuration/builder.rb @@ -38,7 +38,7 @@ def secrets def dockerfile @options["dockerfile"] || "Dockerfile" end - + def target @options["target"] end From 6d062ce27124dca0feb61d78688b6dad4f47fc2b Mon Sep 17 00:00:00 2001 From: Donal McBreen Date: Thu, 2 May 2024 10:36:15 +0100 Subject: [PATCH 16/71] Host specific env with tags Allow hosts to be tagged so we can have host specific env variables. We might want host specific env variables for things like datacenter specific tags or testing GC settings on a specific host. Right now you either need to set up a separate role, or have the app be host aware. Now you can define tag env variables and assign those to hosts. For example: ``` servers: - 1.1.1.1 - 1.1.1.2: tag1 - 1.1.1.2: tag2 - 1.1.1.3: [ tag1, tag2 ] env_tags: tag1: ENV1: value1 tag2: ENV2: value2 ``` The tag env supports the full env format, allowing you to set secret and clear values. --- lib/kamal/cli/app.rb | 36 ++++--- lib/kamal/cli/app/boot.rb | 2 +- lib/kamal/cli/app/prepare_assets.rb | 2 +- lib/kamal/cli/env.rb | 6 +- lib/kamal/cli/main.rb | 2 +- lib/kamal/commander.rb | 4 +- lib/kamal/commands/app.rb | 11 +- lib/kamal/commands/app/execution.rb | 6 +- lib/kamal/commands/healthcheck.rb | 2 +- lib/kamal/configuration.rb | 8 ++ lib/kamal/configuration/env/tag.rb | 12 +++ lib/kamal/configuration/role.rb | 35 ++++-- test/cli/app_test.rb | 26 +++++ test/commands/app_test.rb | 38 +++++-- test/commands/healthcheck_test.rb | 9 ++ test/configuration/env/tags_test.rb | 102 ++++++++++++++++++ test/configuration/role_test.rb | 26 ++--- test/fixtures/deploy_with_env_tags.yml | 29 +++++ test/integration/docker/deployer/app/.env.erb | 1 + .../docker/deployer/app/config/deploy.yml | 9 +- test/integration/main_test.rb | 37 +++++-- 21 files changed, 334 insertions(+), 69 deletions(-) create mode 100644 lib/kamal/configuration/env/tag.rb create mode 100644 test/configuration/env/tags_test.rb create mode 100644 test/fixtures/deploy_with_env_tags.yml diff --git a/lib/kamal/cli/app.rb b/lib/kamal/cli/app.rb index ec7c019a2..188d5e9b8 100644 --- a/lib/kamal/cli/app.rb +++ b/lib/kamal/cli/app.rb @@ -37,7 +37,7 @@ def start roles.each do |role| execute *KAMAL.auditor.record("Started app version #{KAMAL.config.version}"), verbosity: :debug - execute *KAMAL.app(role: role).start, raise_on_non_zero_exit: false + execute *KAMAL.app(role: role, host: host).start, raise_on_non_zero_exit: false end end end @@ -51,7 +51,7 @@ def stop roles.each do |role| execute *KAMAL.auditor.record("Stopped app", role: role), verbosity: :debug - execute *KAMAL.app(role: role).stop, raise_on_non_zero_exit: false + execute *KAMAL.app(role: role, host: host).stop, raise_on_non_zero_exit: false end end end @@ -64,7 +64,7 @@ def details roles = KAMAL.roles_on(host) roles.each do |role| - puts_by_host host, capture_with_info(*KAMAL.app(role: role).info) + puts_by_host host, capture_with_info(*KAMAL.app(role: role, host: host).info) end end end @@ -80,7 +80,7 @@ def exec(cmd) say "Get current version of running container...", :magenta unless options[:version] using_version(options[:version] || current_running_version) do |version| say "Launching interactive command with version #{version} via SSH from existing container on #{KAMAL.primary_host}...", :magenta - run_locally { exec KAMAL.app(role: KAMAL.primary_role).execute_in_existing_container_over_ssh(cmd, host: KAMAL.primary_host, env: env) } + run_locally { exec KAMAL.app(role: KAMAL.primary_role, host: KAMAL.primary_host).execute_in_existing_container_over_ssh(cmd, env: env) } end when options[:interactive] @@ -88,7 +88,7 @@ def exec(cmd) using_version(version_or_latest) do |version| say "Launching interactive command with version #{version} via SSH from new container on #{KAMAL.primary_host}...", :magenta run_locally do - exec KAMAL.app(role: KAMAL.primary_role).execute_in_new_container_over_ssh(cmd, host: KAMAL.primary_host, env: env) + exec KAMAL.app(role: KAMAL.primary_role, host: KAMAL.primary_host).execute_in_new_container_over_ssh(cmd, env: env) end end @@ -102,7 +102,7 @@ def exec(cmd) roles.each do |role| execute *KAMAL.auditor.record("Executed cmd '#{cmd}' on app version #{version}", role: role), verbosity: :debug - puts_by_host host, capture_with_info(*KAMAL.app(role: role).execute_in_existing_container(cmd, env: env)) + puts_by_host host, capture_with_info(*KAMAL.app(role: role, host: host).execute_in_existing_container(cmd, env: env)) end end end @@ -116,7 +116,7 @@ def exec(cmd) roles.each do |role| execute *KAMAL.auditor.record("Executed cmd '#{cmd}' on app version #{version}"), verbosity: :debug - puts_by_host host, capture_with_info(*KAMAL.app(role: role).execute_in_new_container(cmd, env: env)) + puts_by_host host, capture_with_info(*KAMAL.app(role: role, host: host).execute_in_new_container(cmd, env: env)) end end end @@ -140,13 +140,14 @@ def stale_containers roles = KAMAL.roles_on(host) roles.each do |role| - versions = capture_with_info(*KAMAL.app(role: role).list_versions, raise_on_non_zero_exit: false).split("\n") - versions -= [ capture_with_info(*KAMAL.app(role: role).current_running_version, raise_on_non_zero_exit: false).strip ] + app = KAMAL.app(role: role, host: host) + versions = capture_with_info(*app.list_versions, raise_on_non_zero_exit: false).split("\n") + versions -= [ capture_with_info(*app.current_running_version, raise_on_non_zero_exit: false).strip ] versions.each do |version| if stop puts_by_host host, "Stopping stale container for role #{role} with version #{version}" - execute *KAMAL.app(role: role).stop(version: version), raise_on_non_zero_exit: false + execute *app.stop(version: version), raise_on_non_zero_exit: false else puts_by_host host, "Detected stale container for role #{role} with version #{version} (use `kamal app stale_containers --stop` to stop)" end @@ -180,8 +181,9 @@ def logs KAMAL.specific_roles ||= [ "web" ] role = KAMAL.roles_on(KAMAL.primary_host).first - info KAMAL.app(role: role).follow_logs(host: KAMAL.primary_host, lines: lines, grep: grep) - exec KAMAL.app(role: role).follow_logs(host: KAMAL.primary_host, lines: lines, grep: grep) + app = KAMAL.app(role: role, host: host) + info app.follow_logs(host: KAMAL.primary_host, lines: lines, grep: grep) + exec app.follow_logs(host: KAMAL.primary_host, lines: lines, grep: grep) end else lines = options[:lines].presence || ((since || grep) ? nil : 100) # Default to 100 lines if since or grep isn't set @@ -191,7 +193,7 @@ def logs roles.each do |role| begin - puts_by_host host, capture_with_info(*KAMAL.app(role: role).logs(since: since, lines: lines, grep: grep)) + puts_by_host host, capture_with_info(*KAMAL.app(role: role, host: host).logs(since: since, lines: lines, grep: grep)) rescue SSHKit::Command::Failed puts_by_host host, "Nothing found" end @@ -217,7 +219,7 @@ def remove_container(version) roles.each do |role| execute *KAMAL.auditor.record("Removed app container with version #{version}", role: role), verbosity: :debug - execute *KAMAL.app(role: role).remove_container(version: version) + execute *KAMAL.app(role: role, host: host).remove_container(version: version) end end end @@ -231,7 +233,7 @@ def remove_containers roles.each do |role| execute *KAMAL.auditor.record("Removed all app containers", role: role), verbosity: :debug - execute *KAMAL.app(role: role).remove_containers + execute *KAMAL.app(role: role, host: host).remove_containers end end end @@ -251,7 +253,7 @@ def remove_images def version on(KAMAL.hosts) do |host| role = KAMAL.roles_on(host).first - puts_by_host host, capture_with_info(*KAMAL.app(role: role).current_running_version).strip + puts_by_host host, capture_with_info(*KAMAL.app(role: role, host: host).current_running_version).strip end end @@ -274,7 +276,7 @@ def current_running_version(host: KAMAL.primary_host) version = nil on(host) do role = KAMAL.roles_on(host).first - version = capture_with_info(*KAMAL.app(role: role).current_running_version).strip + version = capture_with_info(*KAMAL.app(role: role, host: host).current_running_version).strip end version.presence end diff --git a/lib/kamal/cli/app/boot.rb b/lib/kamal/cli/app/boot.rb index ed7e2ed6e..6098e654a 100644 --- a/lib/kamal/cli/app/boot.rb +++ b/lib/kamal/cli/app/boot.rb @@ -22,7 +22,7 @@ def run private def app - @app ||= KAMAL.app(role: role) + @app ||= KAMAL.app(role: role, host: host) end def auditor diff --git a/lib/kamal/cli/app/prepare_assets.rb b/lib/kamal/cli/app/prepare_assets.rb index f7f442691..dd28fa41e 100644 --- a/lib/kamal/cli/app/prepare_assets.rb +++ b/lib/kamal/cli/app/prepare_assets.rb @@ -19,6 +19,6 @@ def run private def app - @app ||= KAMAL.app(role: role) + @app ||= KAMAL.app(role: role, host: host) end end diff --git a/lib/kamal/cli/env.rb b/lib/kamal/cli/env.rb index 3cfb17410..56ba505a5 100644 --- a/lib/kamal/cli/env.rb +++ b/lib/kamal/cli/env.rb @@ -8,8 +8,8 @@ def push execute *KAMAL.auditor.record("Pushed env files"), verbosity: :debug KAMAL.roles_on(host).each do |role| - execute *KAMAL.app(role: role).make_env_directory - upload! role.env.secrets_io, role.env.secrets_file, mode: 400 + execute *KAMAL.app(role: role, host: host).make_env_directory + upload! role.env(host).secrets_io, role.env(host).secrets_file, mode: 400 end end @@ -35,7 +35,7 @@ def delete execute *KAMAL.auditor.record("Deleted env files"), verbosity: :debug KAMAL.roles_on(host).each do |role| - execute *KAMAL.app(role: role).remove_env_file + execute *KAMAL.app(role: role, host: host).remove_env_file end end diff --git a/lib/kamal/cli/main.rb b/lib/kamal/cli/main.rb index 00ffbfe3e..d972da6b0 100644 --- a/lib/kamal/cli/main.rb +++ b/lib/kamal/cli/main.rb @@ -246,7 +246,7 @@ def container_available?(version) begin on(KAMAL.hosts) do KAMAL.roles_on(host).each do |role| - container_id = capture_with_info(*KAMAL.app(role: role).container_id_for_version(version)) + container_id = capture_with_info(*KAMAL.app(role: role, host: host).container_id_for_version(version)) raise "Container not found" unless container_id.present? end end diff --git a/lib/kamal/commander.rb b/lib/kamal/commander.rb index 042e84293..e7c5d21f0 100644 --- a/lib/kamal/commander.rb +++ b/lib/kamal/commander.rb @@ -65,8 +65,8 @@ def accessories_on(host) end - def app(role: nil) - Kamal::Commands::App.new(config, role: role) + def app(role: nil, host: nil) + Kamal::Commands::App.new(config, role: role, host: host) end def accessory(name) diff --git a/lib/kamal/commands/app.rb b/lib/kamal/commands/app.rb index 15992640b..37fa86ab6 100644 --- a/lib/kamal/commands/app.rb +++ b/lib/kamal/commands/app.rb @@ -3,11 +3,12 @@ class Kamal::Commands::App < Kamal::Commands::Base ACTIVE_DOCKER_STATUSES = [ :running, :restarting ] - attr_reader :role, :role + attr_reader :role, :host - def initialize(config, role: nil) + def initialize(config, role: nil, host: nil) super(config) @role = role + @host = host end def run(hostname: nil) @@ -18,7 +19,7 @@ def run(hostname: nil) *([ "--hostname", hostname ] if hostname), "-e", "KAMAL_CONTAINER_NAME=\"#{container_name}\"", "-e", "KAMAL_VERSION=\"#{config.version}\"", - *role.env_args, + *role.env_args(host), *role.health_check_args, *role.logging_args, *config.volume_args, @@ -70,11 +71,11 @@ def list_versions(*docker_args, statuses: nil) def make_env_directory - make_directory role.env.secrets_directory + make_directory role.env(host).secrets_directory end def remove_env_file - [ :rm, "-f", role.env.secrets_file ] + [ :rm, "-f", role.env(host).secrets_file ] end diff --git a/lib/kamal/commands/app/execution.rb b/lib/kamal/commands/app/execution.rb index 6d32b0c8b..215821dcc 100644 --- a/lib/kamal/commands/app/execution.rb +++ b/lib/kamal/commands/app/execution.rb @@ -11,7 +11,7 @@ def execute_in_new_container(*command, interactive: false, env:) docker :run, ("-it" if interactive), "--rm", - *role&.env_args, + *role&.env_args(host), *argumentize("--env", env), *config.volume_args, *role&.option_args, @@ -19,11 +19,11 @@ def execute_in_new_container(*command, interactive: false, env:) *command end - def execute_in_existing_container_over_ssh(*command, host:, env:) + def execute_in_existing_container_over_ssh(*command, env:) run_over_ssh execute_in_existing_container(*command, interactive: true, env: env), host: host end - def execute_in_new_container_over_ssh(*command, host:, env:) + def execute_in_new_container_over_ssh(*command, env:) run_over_ssh execute_in_new_container(*command, interactive: true, env: env), host: host end end diff --git a/lib/kamal/commands/healthcheck.rb b/lib/kamal/commands/healthcheck.rb index 708681010..2517a5e27 100644 --- a/lib/kamal/commands/healthcheck.rb +++ b/lib/kamal/commands/healthcheck.rb @@ -8,7 +8,7 @@ def run "--publish", "#{exposed_port}:#{config.healthcheck["port"]}", "--label", "service=#{config.healthcheck_service}", "-e", "KAMAL_CONTAINER_NAME=\"#{config.healthcheck_service}\"", - *primary.env_args, + *primary.env_args(config.primary_host), *primary.health_check_args(cord: false), *config.volume_args, *primary.option_args, diff --git a/lib/kamal/configuration.rb b/lib/kamal/configuration.rb index ec2c3f66f..dd7970cd6 100644 --- a/lib/kamal/configuration.rb +++ b/lib/kamal/configuration.rb @@ -233,6 +233,14 @@ def env raw_config.env || {} end + def env_tags + raw_config.env_tags.collect { |name, config| Kamal::Configuration::Env::Tag.new(name, config: config) } + end + + def env_tag(name) + env_tags.detect { |t| t.name == name.to_s } + end + def valid? ensure_destination_if_required && ensure_required_keys_present && ensure_valid_kamal_version && ensure_retain_containers_valid && ensure_valid_service_name diff --git a/lib/kamal/configuration/env/tag.rb b/lib/kamal/configuration/env/tag.rb new file mode 100644 index 000000000..2a6a13060 --- /dev/null +++ b/lib/kamal/configuration/env/tag.rb @@ -0,0 +1,12 @@ +class Kamal::Configuration::Env::Tag + attr_reader :name, :config + + def initialize(name, config:) + @name = name + @config = config + end + + def env + Kamal::Configuration::Env.from_config(config: config) + end +end diff --git a/lib/kamal/configuration/role.rb b/lib/kamal/configuration/role.rb index c726c7b80..f0df59244 100644 --- a/lib/kamal/configuration/role.rb +++ b/lib/kamal/configuration/role.rb @@ -7,6 +7,7 @@ class Kamal::Configuration::Role def initialize(name, config:) @name, @config = name.inquiry, config + @tagged_hosts ||= extract_tagged_hosts_from_config end def primary_host @@ -14,7 +15,11 @@ def primary_host end def hosts - @hosts ||= extract_hosts_from_config + tagged_hosts.keys + end + + def env_tags(host) + tagged_hosts.fetch(host).collect { |tag| config.env_tag(tag) } end def cmd @@ -50,12 +55,13 @@ def logging_args end - def env - @env ||= base_env.merge(specialized_env) + def env(host) + @envs ||= {} + @envs[host] ||= [ base_env, specialized_env, *env_tags(host).map(&:env) ].reduce(:merge) end - def env_args - env.args + def env_args(host) + env(host).args end def asset_volume_args @@ -164,7 +170,24 @@ def asset_volume_path(version = nil) end private - attr_accessor :config + attr_accessor :config, :tagged_hosts + + def extract_tagged_hosts_from_config + {}.tap do |tagged_hosts| + extract_hosts_from_config.map do |host_config| + if host_config.is_a?(Hash) + raise ArgumentError, "Multiple hosts found: #{host_config.inspect}" unless host_config.size == 1 + + host, tags = host_config.first + tagged_hosts[host] = Array(tags) + elsif host_config.is_a?(String) || host_config.is_a?(Symbol) + tagged_hosts[host_config] = [] + else + raise ArgumentError, "Invalid host config: #{host_config.inspect}" + end + end + end + end def extract_hosts_from_config if config.servers.is_a?(Array) diff --git a/test/cli/app_test.rb b/test/cli/app_test.rb index dabd2ccda..2b44ddf66 100644 --- a/test/cli/app_test.rb +++ b/test/cli/app_test.rb @@ -92,6 +92,32 @@ class CliAppTest < CliTestCase end end + test "boot with host tags" do + Object.any_instance.stubs(:sleep) + + SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info) + .with(:docker, :container, :ls, "--all", "--filter", "name=^app-web-latest$", "--quiet", raise_on_non_zero_exit: false) + .returns("12345678") # running version + + SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info) + .with(:docker, :container, :ls, "--all", "--filter", "name=^app-web-latest$", "--quiet", "|", :xargs, :docker, :inspect, "--format", "'{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}'") + .returns("running") # health check + + SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info) + .with(:sh, "-c", "'docker ps --latest --format '\\''{{.Names}}'\\'' --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''{{.ID}}'\\'') ; docker ps --latest --format '\\''{{.Names}}'\\'' --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting'", "|", :head, "-1", "|", "while read line; do echo ${line#app-web-}; done", raise_on_non_zero_exit: false) + .returns("123") # old version + + SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info) + .with(:docker, :inspect, "-f '{{ range .Mounts }}{{printf \"%s %s\\n\" .Source .Destination}}{{ end }}'", "app-web-123", "|", :awk, "'$2 == \"/tmp/kamal-cord\" {print $1}'", raise_on_non_zero_exit: false) + .returns("") # old version + + run_command("boot", config: :with_env_tags).tap do |output| + assert_match "docker tag dhh/app:latest dhh/app:latest", output + assert_match %r{docker run --detach --restart unless-stopped --name app-web-latest --hostname 1.1.1.1-[0-9a-f]{12} -e KAMAL_CONTAINER_NAME="app-web-latest" -e KAMAL_VERSION="latest" --env-file .kamal/env/roles/app-web.env --env TEST="root" --env EXPERIMENT="disabled" --env SITE="site1"}, output + assert_match "docker container ls --all --filter name=^app-web-123$ --quiet | xargs docker stop", output + end + end + test "start" do run_command("start").tap do |output| assert_match "docker start app-web-999", output diff --git a/test/commands/app_test.rb b/test/commands/app_test.rb index 3fe97d97c..af7c62515 100644 --- a/test/commands/app_test.rb +++ b/test/commands/app_test.rb @@ -60,7 +60,7 @@ class CommandsAppTest < ActiveSupport::TestCase @config[:servers] = { "web" => [ "1.1.1.1" ], "jobs" => { "hosts" => [ "1.1.1.2" ], "cmd" => "bin/jobs", "options" => { "mount" => "somewhere", "cap-add" => true } } } assert_equal \ "docker run --detach --restart unless-stopped --name app-jobs-999 -e KAMAL_CONTAINER_NAME=\"app-jobs-999\" -e KAMAL_VERSION=\"999\" --env-file .kamal/env/roles/app-jobs.env --log-opt max-size=\"10m\" --label service=\"app\" --label role=\"jobs\" --label destination --mount \"somewhere\" --cap-add dhh/app:999 bin/jobs", - new_command(role: "jobs").run.join(" ") + new_command(role: "jobs", host: "1.1.1.2").run.join(" ") end test "run with logging config" do @@ -80,6 +80,15 @@ class CommandsAppTest < ActiveSupport::TestCase new_command.run.join(" ") end + test "run with tags" do + @config[:servers] = [ { "1.1.1.1" => "tag1" } ] + @config[:env_tags] = { "tag1" => { "ENV1" => "value1" } } + + assert_equal \ + "docker run --detach --restart unless-stopped --name app-web-999 -e KAMAL_CONTAINER_NAME=\"app-web-999\" -e KAMAL_VERSION=\"999\" --env-file .kamal/env/roles/app-web.env --env ENV1=\"value1\" --health-cmd \"(curl -f http://localhost:3000/up || exit 1) && (stat /tmp/kamal-cord/cord > /dev/null || exit 1)\" --health-interval \"1s\" --volume $(pwd)/.kamal/cords/app-web-12345678901234567890123456789012:/tmp/kamal-cord --log-opt max-size=\"10m\" --label service=\"app\" --label role=\"web\" --label destination --label traefik.http.services.app-web.loadbalancer.server.scheme=\"http\" --label traefik.http.routers.app-web.rule=\"PathPrefix(\\`/\\`)\" --label traefik.http.routers.app-web.priority=\"2\" --label traefik.http.middlewares.app-web-retry.retry.attempts=\"5\" --label traefik.http.middlewares.app-web-retry.retry.initialinterval=\"500ms\" --label traefik.http.routers.app-web.middlewares=\"app-web-retry@docker\" dhh/app:999", + new_command.run.join(" ") + end + test "start" do assert_equal \ "docker start app-web-999", @@ -183,6 +192,15 @@ class CommandsAppTest < ActiveSupport::TestCase new_command.execute_in_new_container("bin/rails", "db:setup", env: { "foo" => "bar" }).join(" ") end + test "execute in new container with tags" do + @config[:servers] = [ { "1.1.1.1" => "tag1" } ] + @config[:env_tags] = { "tag1" => { "ENV1" => "value1" } } + + assert_equal \ + "docker run --rm --env-file .kamal/env/roles/app-web.env --env ENV1=\"value1\" dhh/app:999 bin/rails db:setup", + new_command.execute_in_new_container("bin/rails", "db:setup", env: {}).join(" ") + end + test "execute in new container with custom options" do @config[:servers] = { "web" => { "hosts" => [ "1.1.1.1" ], "options" => { "mount" => "somewhere", "cap-add" => true } } } assert_equal \ @@ -204,18 +222,26 @@ class CommandsAppTest < ActiveSupport::TestCase test "execute in new container over ssh" do assert_match %r{docker run -it --rm --env-file .kamal/env/roles/app-web.env dhh/app:999 bin/rails c}, - new_command.execute_in_new_container_over_ssh("bin/rails", "c", host: "app-1", env: {}) + new_command.execute_in_new_container_over_ssh("bin/rails", "c", env: {}) + end + + test "execute in new container over ssh with tags" do + @config[:servers] = [ { "1.1.1.1" => "tag1" } ] + @config[:env_tags] = { "tag1" => { "ENV1" => "value1" } } + + assert_equal "ssh -t root@1.1.1.1 -p 22 'docker run -it --rm --env-file .kamal/env/roles/app-web.env --env ENV1=\"value1\" dhh/app:999 bin/rails c'", + new_command.execute_in_new_container_over_ssh("bin/rails", "c", env: {}) end test "execute in new container with custom options over ssh" do @config[:servers] = { "web" => { "hosts" => [ "1.1.1.1" ], "options" => { "mount" => "somewhere", "cap-add" => true } } } assert_match %r{docker run -it --rm --env-file .kamal/env/roles/app-web.env --mount \"somewhere\" --cap-add dhh/app:999 bin/rails c}, - new_command.execute_in_new_container_over_ssh("bin/rails", "c", host: "app-1", env: {}) + new_command.execute_in_new_container_over_ssh("bin/rails", "c", env: {}) end test "execute in existing container over ssh" do assert_match %r{docker exec -it app-web-999 bin/rails c}, - new_command.execute_in_existing_container_over_ssh("bin/rails", "c", host: "app-1", env: {}) + new_command.execute_in_existing_container_over_ssh("bin/rails", "c", env: {}) end test "run over ssh" do @@ -418,8 +444,8 @@ class CommandsAppTest < ActiveSupport::TestCase end private - def new_command(role: "web", **additional_config) + def new_command(role: "web", host: "1.1.1.1", **additional_config) config = Kamal::Configuration.new(@config.merge(additional_config), destination: @destination, version: "999") - Kamal::Commands::App.new(config, role: config.role(role)) + Kamal::Commands::App.new(config, role: config.role(role), host: host) end end diff --git a/test/commands/healthcheck_test.rb b/test/commands/healthcheck_test.rb index 66cdda6b4..5c6051757 100644 --- a/test/commands/healthcheck_test.rb +++ b/test/commands/healthcheck_test.rb @@ -46,6 +46,15 @@ class CommandsHealthcheckTest < ActiveSupport::TestCase new_command.run.join(" ") end + test "run with tags" do + @config[:servers] = [ { "1.1.1.1" => "tag1" } ] + @config[:env_tags] = { "tag1" => { "ENV1" => "value1" } } + + assert_equal \ + "docker run --detach --name healthcheck-app-123 --publish 3999:3000 --label service=healthcheck-app -e KAMAL_CONTAINER_NAME=\"healthcheck-app\" --env-file .kamal/env/roles/app-web.env --env ENV1=\"value1\" --health-cmd \"curl -f http://localhost:3000/up || exit 1\" --health-interval \"1s\" dhh/app:123", + new_command.run.join(" ") + end + test "status" do assert_equal \ "docker container ls --all --filter name=^healthcheck-app-123$ --quiet | xargs docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}'", diff --git a/test/configuration/env/tags_test.rb b/test/configuration/env/tags_test.rb new file mode 100644 index 000000000..380cb5cda --- /dev/null +++ b/test/configuration/env/tags_test.rb @@ -0,0 +1,102 @@ +require "test_helper" + +class ConfigurationEnvTagsTest < ActiveSupport::TestCase + setup do + @deploy = { + service: "app", image: "dhh/app", registry: { "username" => "dhh", "password" => "secret" }, + servers: [ { "1.1.1.1" => "odd" }, { "1.1.1.2" => "even" }, { "1.1.1.3" => [ "odd", "three" ] } ], + env: { "REDIS_URL" => "redis://x/y", "THREE" => "false" }, + env_tags: { + "odd" => { "TYPE" => "odd" }, + "even" => { "TYPE" => "even" }, + "three" => { "THREE" => "true" } + } + } + + @config = Kamal::Configuration.new(@deploy) + + @deploy_with_roles = @deploy.dup.merge({ + servers: { + "web" => [ { "1.1.1.1" => "odd" }, "1.1.1.2" ], + "workers" => { + "hosts" => [ { "1.1.1.3" => [ "odd", "oddjob" ] }, "1.1.1.4" ], + "cmd" => "bin/jobs", + "env" => { + "REDIS_URL" => "redis://a/b", + "WEB_CONCURRENCY" => 4 + } + } + }, + env_tags: { + "odd" => { "TYPE" => "odd" }, + "oddjob" => { "TYPE" => "oddjob" } + } + }) + + @config_with_roles = Kamal::Configuration.new(@deploy_with_roles) + end + + test "tags" do + assert_equal 3, @config.env_tags.size + assert_equal %w[ odd even three ], @config.env_tags.map(&:name) + assert_equal({ "TYPE" => "odd" }, @config.env_tag("odd").env.clear) + assert_equal({ "TYPE" => "even" }, @config.env_tag("even").env.clear) + assert_equal({ "THREE" => "true" }, @config.env_tag("three").env.clear) + end + + test "tags with roles" do + assert_equal 2, @config_with_roles.env_tags.size + assert_equal %w[ odd oddjob ], @config_with_roles.env_tags.map(&:name) + assert_equal({ "TYPE" => "odd" }, @config_with_roles.env_tag("odd").env.clear) + assert_equal({ "TYPE" => "oddjob" }, @config_with_roles.env_tag("oddjob").env.clear) + end + + test "tag overrides env" do + assert_equal "false", @config.role("web").env("1.1.1.1").clear["THREE"] + assert_equal "true", @config.role("web").env("1.1.1.3").clear["THREE"] + end + + test "later tag wins" do + deploy = { + service: "app", image: "dhh/app", registry: { "username" => "dhh", "password" => "secret" }, + servers: [ { "1.1.1.1" => [ "first", "second" ] } ], + env_tags: { + "first" => { "TYPE" => "first" }, + "second" => { "TYPE" => "second" } + } + } + + config = Kamal::Configuration.new(deploy) + assert_equal "second", config.role("web").env("1.1.1.1").clear["TYPE"] + end + + test "tag secret env" do + ENV["PASSWORD"] = "hello" + + deploy = { + service: "app", image: "dhh/app", registry: { "username" => "dhh", "password" => "secret" }, + servers: [ { "1.1.1.1" => "secrets" } ], + env_tags: { + "secrets" => { "secret" => [ "PASSWORD" ] } + } + } + + config = Kamal::Configuration.new(deploy) + assert_equal "hello", config.role("web").env("1.1.1.1").secrets["PASSWORD"] + ensure + ENV.delete "PASSWORD" + end + + test "tag clear env" do + deploy = { + service: "app", image: "dhh/app", registry: { "username" => "dhh", "password" => "secret" }, + servers: [ { "1.1.1.1" => "clearly" } ], + env_tags: { + "clearly" => { "clear" => { "FOO" => "bar" } } + } + } + + config = Kamal::Configuration.new(deploy) + assert_equal "bar", config.role("web").env("1.1.1.1").clear["FOO"] + end +end diff --git a/test/configuration/role_test.rb b/test/configuration/role_test.rb index 0952f1e88..84fdfe6be 100644 --- a/test/configuration/role_test.rb +++ b/test/configuration/role_test.rb @@ -70,10 +70,10 @@ class ConfigurationRoleTest < ActiveSupport::TestCase end test "env overwritten by role" do - assert_equal "redis://a/b", @config_with_roles.role(:workers).env.clear["REDIS_URL"] + assert_equal "redis://a/b", @config_with_roles.role(:workers).env("1.1.1.3").clear["REDIS_URL"] - assert_equal "\n", @config_with_roles.role(:workers).env.secrets_io.string - assert_equal [ "--env-file", ".kamal/env/roles/app-workers.env", "--env", "REDIS_URL=\"redis://a/b\"", "--env", "WEB_CONCURRENCY=\"4\"" ], @config_with_roles.role(:workers).env_args + assert_equal "\n", @config_with_roles.role(:workers).env("1.1.1.3").secrets_io.string + assert_equal [ "--env-file", ".kamal/env/roles/app-workers.env", "--env", "REDIS_URL=\"redis://a/b\"", "--env", "WEB_CONCURRENCY=\"4\"" ], @config_with_roles.role(:workers).env_args("1.1.1.3") end test "container name" do @@ -86,7 +86,7 @@ class ConfigurationRoleTest < ActiveSupport::TestCase end test "env args" do - assert_equal [ "--env-file", ".kamal/env/roles/app-workers.env", "--env", "REDIS_URL=\"redis://a/b\"", "--env", "WEB_CONCURRENCY=\"4\"" ], @config_with_roles.role(:workers).env_args + assert_equal [ "--env-file", ".kamal/env/roles/app-workers.env", "--env", "REDIS_URL=\"redis://a/b\"", "--env", "WEB_CONCURRENCY=\"4\"" ], @config_with_roles.role(:workers).env_args("1.1.1.3") end test "env secret overwritten by role" do @@ -117,8 +117,8 @@ class ConfigurationRoleTest < ActiveSupport::TestCase DB_PASSWORD=secret&\"123 ENV - assert_equal expected_secrets_file, @config_with_roles.role(:workers).env.secrets_io.string - assert_equal [ "--env-file", ".kamal/env/roles/app-workers.env", "--env", "REDIS_URL=\"redis://a/b\"", "--env", "WEB_CONCURRENCY=\"4\"" ], @config_with_roles.role(:workers).env_args + assert_equal expected_secrets_file, @config_with_roles.role(:workers).env("1.1.1.3").secrets_io.string + assert_equal [ "--env-file", ".kamal/env/roles/app-workers.env", "--env", "REDIS_URL=\"redis://a/b\"", "--env", "WEB_CONCURRENCY=\"4\"" ], @config_with_roles.role(:workers).env_args("1.1.1.3") ensure ENV["REDIS_PASSWORD"] = nil ENV["DB_PASSWORD"] = nil @@ -141,8 +141,8 @@ class ConfigurationRoleTest < ActiveSupport::TestCase DB_PASSWORD=secret123 ENV - assert_equal expected_secrets_file, @config_with_roles.role(:workers).env.secrets_io.string - assert_equal [ "--env-file", ".kamal/env/roles/app-workers.env", "--env", "REDIS_URL=\"redis://a/b\"", "--env", "WEB_CONCURRENCY=\"4\"" ], @config_with_roles.role(:workers).env_args + assert_equal expected_secrets_file, @config_with_roles.role(:workers).env("1.1.1.3").secrets_io.string + assert_equal [ "--env-file", ".kamal/env/roles/app-workers.env", "--env", "REDIS_URL=\"redis://a/b\"", "--env", "WEB_CONCURRENCY=\"4\"" ], @config_with_roles.role(:workers).env_args("1.1.1.3") ensure ENV["DB_PASSWORD"] = nil end @@ -163,8 +163,8 @@ class ConfigurationRoleTest < ActiveSupport::TestCase REDIS_PASSWORD=secret456 ENV - assert_equal expected_secrets_file, @config_with_roles.role(:workers).env.secrets_io.string - assert_equal [ "--env-file", ".kamal/env/roles/app-workers.env", "--env", "REDIS_URL=\"redis://a/b\"", "--env", "WEB_CONCURRENCY=\"4\"" ], @config_with_roles.role(:workers).env_args + assert_equal expected_secrets_file, @config_with_roles.role(:workers).env("1.1.1.3").secrets_io.string + assert_equal [ "--env-file", ".kamal/env/roles/app-workers.env", "--env", "REDIS_URL=\"redis://a/b\"", "--env", "WEB_CONCURRENCY=\"4\"" ], @config_with_roles.role(:workers).env_args("1.1.1.3") ensure ENV["REDIS_PASSWORD"] = nil end @@ -191,14 +191,14 @@ class ConfigurationRoleTest < ActiveSupport::TestCase REDIS_PASSWORD=secret456 ENV - assert_equal expected_secrets_file, @config_with_roles.role(:workers).env.secrets_io.string - assert_equal [ "--env-file", ".kamal/env/roles/app-workers.env", "--env", "REDIS_URL=\"redis://c/d\"" ], @config_with_roles.role(:workers).env_args + assert_equal expected_secrets_file, @config_with_roles.role(:workers).env("1.1.1.3").secrets_io.string + assert_equal [ "--env-file", ".kamal/env/roles/app-workers.env", "--env", "REDIS_URL=\"redis://c/d\"" ], @config_with_roles.role(:workers).env_args("1.1.1.3") ensure ENV["REDIS_PASSWORD"] = nil end test "env secrets_file" do - assert_equal ".kamal/env/roles/app-workers.env", @config_with_roles.role(:workers).env.secrets_file + assert_equal ".kamal/env/roles/app-workers.env", @config_with_roles.role(:workers).env("1.1.1.3").secrets_file end test "uses cord" do diff --git a/test/fixtures/deploy_with_env_tags.yml b/test/fixtures/deploy_with_env_tags.yml new file mode 100644 index 000000000..4acc839c2 --- /dev/null +++ b/test/fixtures/deploy_with_env_tags.yml @@ -0,0 +1,29 @@ +service: app +image: dhh/app +servers: + web: + - 1.1.1.1: site1 + - 1.1.1.2: [ site1 experimental ] + - 1.2.1.1: site2 + - 1.2.1.2: site2 + workers: + - 1.1.1.3: site1 + - 1.1.1.4: site1 + - 1.2.1.3: site2 + - 1.2.1.4: [ site2 experimental ] +env: + clear: + TEST: "root" + EXPERIMENT: "disabled" +env_tags: + site1: + SITE: site1 + site2: + SITE: site2 + experimental: + env: + EXPERIMENT: "enabled" + +registry: + username: user + password: pw diff --git a/test/integration/docker/deployer/app/.env.erb b/test/integration/docker/deployer/app/.env.erb index cb2988d6b..ea15ab06e 100644 --- a/test/integration/docker/deployer/app/.env.erb +++ b/test/integration/docker/deployer/app/.env.erb @@ -1 +1,2 @@ SECRET_TOKEN='1234 with "中文"' +SECRET_TAG='TAGME' diff --git a/test/integration/docker/deployer/app/config/deploy.yml b/test/integration/docker/deployer/app/config/deploy.yml index 11e2cf049..aed676cc3 100644 --- a/test/integration/docker/deployer/app/config/deploy.yml +++ b/test/integration/docker/deployer/app/config/deploy.yml @@ -2,13 +2,20 @@ service: app image: app servers: - vm1 - - vm2 + - vm2: [ tag1, tag2 ] env: clear: CLEAR_TOKEN: 4321 + CLEAR_TAG: "" HOST_TOKEN: "${HOST_TOKEN}" secret: - SECRET_TOKEN +env_tags: + tag1: + CLEAR_TAG: tagged + tag2: + secret: + - SECRET_TAG asset_path: /usr/share/nginx/html/versions registry: diff --git a/test/integration/main_test.rb b/test/integration/main_test.rb index ac549b972..a2dc8a0ca 100644 --- a/test/integration/main_test.rb +++ b/test/integration/main_test.rb @@ -3,8 +3,7 @@ class MainTest < IntegrationTest test "envify, deploy, redeploy, rollback, details and audit" do kamal :envify - assert_local_env_file "SECRET_TOKEN='1234 with \"中文\"'" - assert_remote_env_file "SECRET_TOKEN=1234 with \"中文\"" + assert_env_files remove_local_env_file first_version = latest_app_version @@ -14,9 +13,7 @@ class MainTest < IntegrationTest kamal :deploy assert_app_is_up version: first_version assert_hooks_ran "pre-connect", "pre-build", "pre-deploy", "post-deploy" - assert_env :CLEAR_TOKEN, "4321", version: first_version - assert_env :HOST_TOKEN, "abcd", version: first_version - assert_env :SECRET_TOKEN, "1234 with \"中文\"", version: first_version + assert_envs version: first_version second_version = update_app_rev @@ -97,16 +94,38 @@ def assert_local_env_file(contents) assert_equal contents, deployer_exec("cat .env", capture: true) end - def assert_env(key, value, version:) - assert_equal "#{key}=#{value}", docker_compose("exec vm1 docker exec app-web-#{version} env | grep #{key}", capture: true) + def assert_envs(version:) + assert_env :CLEAR_TOKEN, "4321", version: version, vm: :vm1 + assert_env :HOST_TOKEN, "abcd", version: version, vm: :vm1 + assert_env :SECRET_TOKEN, "1234 with \"中文\"", version: version, vm: :vm1 + assert_no_env :CLEAR_TAG, version: version, vm: :vm1 + assert_no_env :SECRET_TAG, version: version, vm: :vm11 + assert_env :CLEAR_TAG, "tagged", version: version, vm: :vm2 + assert_env :SECRET_TAG, "TAGME", version: version, vm: :vm2 + end + + def assert_env(key, value, vm:, version:) + assert_equal "#{key}=#{value}", docker_compose("exec #{vm} docker exec app-web-#{version} env | grep #{key}", capture: true) + end + + def assert_no_env(key, vm:, version:) + assert_raises(RuntimeError, /exit 1/) do + docker_compose("exec #{vm} docker exec app-web-#{version} env | grep #{key}", capture: true) + end + end + + def assert_env_files + assert_local_env_file "SECRET_TOKEN='1234 with \"中文\"'\nSECRET_TAG='TAGME'" + assert_remote_env_file "SECRET_TOKEN=1234 with \"中文\"", vm: :vm1 + assert_remote_env_file "SECRET_TOKEN=1234 with \"中文\"\nSECRET_TAG=TAGME", vm: :vm2 end def remove_local_env_file deployer_exec("rm .env") end - def assert_remote_env_file(contents) - assert_equal contents, docker_compose("exec vm1 cat /root/.kamal/env/roles/app-web.env", capture: true) + def assert_remote_env_file(contents, vm:) + assert_equal contents, docker_compose("exec #{vm} cat /root/.kamal/env/roles/app-web.env", capture: true) end def assert_no_remote_env_file From fb58fc0ba6e6aeb27d3c426a8041cf0d26878bfe Mon Sep 17 00:00:00 2001 From: Nick Hammond Date: Thu, 11 Jan 2024 23:01:38 -0700 Subject: [PATCH 17/71] Add in a server exec command for running ad-hoc commands directly on the server --- lib/kamal/cli/app.rb | 2 +- lib/kamal/cli/server.rb | 22 ++++++++++++++++++++++ test/cli/server_test.rb | 14 ++++++++++++++ 3 files changed, 37 insertions(+), 1 deletion(-) diff --git a/lib/kamal/cli/app.rb b/lib/kamal/cli/app.rb index fae607fcf..6dbfba264 100644 --- a/lib/kamal/cli/app.rb +++ b/lib/kamal/cli/app.rb @@ -106,7 +106,7 @@ def details end end - desc "exec [CMD]", "Execute a custom command on servers (use --help to show options)" + desc "exec [CMD]", "Execute a custom command on servers within the app container (use --help to show options)" option :interactive, aliases: "-i", type: :boolean, default: false, desc: "Execute command over ssh for an interactive shell (use for console/bash)" option :reuse, type: :boolean, default: false, desc: "Reuse currently running container instead of starting a new one" def exec(cmd) diff --git a/lib/kamal/cli/server.rb b/lib/kamal/cli/server.rb index 6dcc6b02d..58eed4af3 100644 --- a/lib/kamal/cli/server.rb +++ b/lib/kamal/cli/server.rb @@ -1,4 +1,26 @@ class Kamal::Cli::Server < Kamal::Cli::Base + desc "exec", "Run a custom command on the server(use --help to show options)" + option :interactive, type: :boolean, aliases: "-i", default: false, desc: "Run the command interactively(use for console/bash)" + def exec(cmd) + hosts = KAMAL.hosts | KAMAL.accessory_hosts + + case + when options[:interactive] + host = hosts.first + + say "Running '#{cmd}' on #{host} interactively...", :magenta + + run_locally { exec KAMAL.server.run_over_ssh(cmd, host: host) } + else + say "Running '#{cmd}' on #{hosts.join(', ')}...", :magenta + + on(hosts) do |host| + execute *KAMAL.auditor.record("Executed cmd '#{cmd}' on #{host}"), verbosity: :debug + puts_by_host host, capture_with_info(cmd) + end + end + end + desc "bootstrap", "Set up Docker to run Kamal apps" def bootstrap missing = [] diff --git a/test/cli/server_test.rb b/test/cli/server_test.rb index 1c8a2607d..bd34c726f 100644 --- a/test/cli/server_test.rb +++ b/test/cli/server_test.rb @@ -1,6 +1,20 @@ require_relative "cli_test_case" class CliServerTest < CliTestCase + test "running a command with exec" do + SSHKit::Backend::Abstract.any_instance.stubs(:capture) + .with("date", verbosity: 1) + .returns("Today") + + hosts = "1.1.1.1".."1.1.1.4" + run_command("exec", "date").tap do |output| + hosts.map do |host| + assert_match "Running 'date' on #{hosts.to_a.join(', ')}...", output + assert_match "App Host: #{host}\nToday", output + end + end + end + test "bootstrap already installed" do SSHKit::Backend::Abstract.any_instance.expects(:execute).with(:docker, "-v", raise_on_non_zero_exit: false).returns(true).at_least_once SSHKit::Backend::Abstract.any_instance.expects(:execute).with(:mkdir, "-p", ".kamal").returns("").at_least_once From 7cac7e6fb0e77c946ea1f2407cb9677499f69adb Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Mon, 13 May 2024 15:18:11 -0700 Subject: [PATCH 18/71] Envify during setup --- lib/kamal/cli/main.rb | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/kamal/cli/main.rb b/lib/kamal/cli/main.rb index d972da6b0..d1605cff0 100644 --- a/lib/kamal/cli/main.rb +++ b/lib/kamal/cli/main.rb @@ -9,7 +9,8 @@ def setup say "Ensure Docker is installed...", :magenta invoke "kamal:cli:server:bootstrap", [], invoke_options - say "Push env files...", :magenta + say "Evaluate and push env files...", :magenta + invoke "kamal:envify", [], invoke_options invoke "kamal:cli:env:push", [], invoke_options invoke "kamal:cli:accessory:boot", [ "all" ], invoke_options From 033f2a340137ef926bb8e3cd43273d5205d56caa Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Mon, 13 May 2024 16:59:50 -0700 Subject: [PATCH 19/71] Correct invocation --- lib/kamal/cli/main.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/kamal/cli/main.rb b/lib/kamal/cli/main.rb index d1605cff0..dd14ca3f2 100644 --- a/lib/kamal/cli/main.rb +++ b/lib/kamal/cli/main.rb @@ -10,7 +10,7 @@ def setup invoke "kamal:cli:server:bootstrap", [], invoke_options say "Evaluate and push env files...", :magenta - invoke "kamal:envify", [], invoke_options + invoke "kamal:cli:main:envify", [], invoke_options invoke "kamal:cli:env:push", [], invoke_options invoke "kamal:cli:accessory:boot", [ "all" ], invoke_options From dc1f707a561c67d6b30f2cdc5575c4a3ebd1a2d8 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Mon, 13 May 2024 17:01:50 -0700 Subject: [PATCH 20/71] Fix test --- test/cli/main_test.rb | 2 ++ 1 file changed, 2 insertions(+) diff --git a/test/cli/main_test.rb b/test/cli/main_test.rb index 20df071ec..8f897ca9e 100644 --- a/test/cli/main_test.rb +++ b/test/cli/main_test.rb @@ -5,6 +5,7 @@ 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) @@ -20,6 +21,7 @@ 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) From 938ac375a15771bd153b5d4edf9c85808ba4d0cd Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Mon, 13 May 2024 17:08:53 -0700 Subject: [PATCH 21/71] Only envify if there is a template file available --- lib/kamal/cli/main.rb | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/lib/kamal/cli/main.rb b/lib/kamal/cli/main.rb index dd14ca3f2..6daa1c652 100644 --- a/lib/kamal/cli/main.rb +++ b/lib/kamal/cli/main.rb @@ -186,11 +186,15 @@ def envify env_path = ".env" end - File.write(env_path, ERB.new(File.read(env_template_path), trim_mode: "-").result, perm: 0600) + if File.exist?(env_template_path) + File.write(env_path, ERB.new(File.read(env_template_path), trim_mode: "-").result, perm: 0600) - unless options[:skip_push] - reload_envs - invoke "kamal:cli:env:push", options + unless options[:skip_push] + reload_envs + invoke "kamal:cli:env:push", options + end + else + puts "Skipping envify (no #{env_template_path} exist)" end end From e58d2f67f2fccbec97a0528ec09cbcf72ac74727 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Tue, 14 May 2024 10:07:31 -0700 Subject: [PATCH 22/71] Fix env template path check and tests --- lib/kamal/cli/main.rb | 2 +- test/cli/main_test.rb | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/lib/kamal/cli/main.rb b/lib/kamal/cli/main.rb index 6daa1c652..1cc9d98ea 100644 --- a/lib/kamal/cli/main.rb +++ b/lib/kamal/cli/main.rb @@ -186,7 +186,7 @@ def envify env_path = ".env" end - if File.exist?(env_template_path) + if Pathname.new(File.expand_path(env_template_path)).exist? File.write(env_path, ERB.new(File.read(env_template_path), trim_mode: "-").result, perm: 0600) unless options[:skip_push] diff --git a/test/cli/main_test.rb b/test/cli/main_test.rb index 8f897ca9e..6c7c0f24d 100644 --- a/test/cli/main_test.rb +++ b/test/cli/main_test.rb @@ -12,7 +12,7 @@ class CliMainTest < CliTestCase run_command("setup").tap do |output| assert_match /Ensure Docker is installed.../, output - assert_match /Push env files.../, output + assert_match /Evaluate and push env files.../, output end end @@ -34,7 +34,7 @@ class CliMainTest < CliTestCase run_command("setup", "--skip_push").tap do |output| assert_match /Ensure Docker is installed.../, output - assert_match /Push env files.../, output + assert_match /Evaluate and push env files.../, output # deploy assert_match /Acquiring the deploy lock/, output assert_match /Log into image registry/, output @@ -429,6 +429,7 @@ class CliMainTest < CliTestCase end test "envify" do + Pathname.any_instance.expects(:exist?).returns(true).times(3) File.expects(:read).with(".env.erb").returns("HELLO=<%= 'world' %>") File.expects(:write).with(".env", "HELLO=world", perm: 0600) From 0bc27c10cc723a3dd6c2ca0bd51370ba72025a19 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Tue, 14 May 2024 11:59:42 -0700 Subject: [PATCH 23/71] Fix tests --- test/cli/main_test.rb | 3 +++ 1 file changed, 3 insertions(+) diff --git a/test/cli/main_test.rb b/test/cli/main_test.rb index 6c7c0f24d..6149cf0ba 100644 --- a/test/cli/main_test.rb +++ b/test/cli/main_test.rb @@ -444,6 +444,7 @@ class CliMainTest < CliTestCase <% end -%> EOF + Pathname.any_instance.expects(:exist?).returns(true).times(3) File.expects(:read).with(".env.erb").returns(file.strip) File.expects(:write).with(".env", "HELLO=world\nKEY=value\n", perm: 0600) @@ -451,6 +452,7 @@ class CliMainTest < CliTestCase end test "envify with destination" do + Pathname.any_instance.expects(:exist?).returns(true).times(4) File.expects(:read).with(".env.world.erb").returns("HELLO=<%= 'world' %>") File.expects(:write).with(".env.world", "HELLO=world", perm: 0600) @@ -458,6 +460,7 @@ class CliMainTest < CliTestCase 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) From f48c2277686cd3658e11fedab4274afebd322fea Mon Sep 17 00:00:00 2001 From: Donal McBreen Date: Wed, 15 May 2024 10:19:22 +0100 Subject: [PATCH 24/71] Move env_tags under env key Instead of: ``` env: CLEAR_TAG: untagged env_tags: tag1: CLEAR_TAG: tagged ``` We'll have: ``` env: clear: CLEAR_TAG: untagged tags: tag1: CLEAR_TAG: tagged ``` --- lib/kamal/configuration.rb | 2 +- lib/kamal/configuration/env.rb | 2 +- test/commands/app_test.rb | 6 +-- test/commands/healthcheck_test.rb | 3 +- test/configuration/env/tags_test.rb | 40 ++++++++++++------- test/fixtures/deploy_with_env_tags.yml | 13 +++--- .../docker/deployer/app/config/deploy.yml | 12 +++--- 7 files changed, 44 insertions(+), 34 deletions(-) diff --git a/lib/kamal/configuration.rb b/lib/kamal/configuration.rb index dd7970cd6..5b5878c38 100644 --- a/lib/kamal/configuration.rb +++ b/lib/kamal/configuration.rb @@ -234,7 +234,7 @@ def env end def env_tags - raw_config.env_tags.collect { |name, config| Kamal::Configuration::Env::Tag.new(name, config: config) } + raw_config.env["tags"].collect { |name, config| Kamal::Configuration::Env::Tag.new(name, config: config) } end def env_tag(name) diff --git a/lib/kamal/configuration/env.rb b/lib/kamal/configuration/env.rb index f9b8cd667..a78338493 100644 --- a/lib/kamal/configuration/env.rb +++ b/lib/kamal/configuration/env.rb @@ -4,7 +4,7 @@ class Kamal::Configuration::Env def self.from_config(config:, secrets_file: nil) secrets_keys = config.fetch("secret", []) - clear = config.fetch("clear", config.key?("secret") ? {} : config) + clear = config.fetch("clear", config.key?("secret") || config.key?("tags") ? {} : config) new clear: clear, secrets_keys: secrets_keys, secrets_file: secrets_file end diff --git a/test/commands/app_test.rb b/test/commands/app_test.rb index af7c62515..2f9dc57fd 100644 --- a/test/commands/app_test.rb +++ b/test/commands/app_test.rb @@ -82,7 +82,7 @@ class CommandsAppTest < ActiveSupport::TestCase test "run with tags" do @config[:servers] = [ { "1.1.1.1" => "tag1" } ] - @config[:env_tags] = { "tag1" => { "ENV1" => "value1" } } + @config[:env]["tags"] = { "tag1" => { "ENV1" => "value1" } } assert_equal \ "docker run --detach --restart unless-stopped --name app-web-999 -e KAMAL_CONTAINER_NAME=\"app-web-999\" -e KAMAL_VERSION=\"999\" --env-file .kamal/env/roles/app-web.env --env ENV1=\"value1\" --health-cmd \"(curl -f http://localhost:3000/up || exit 1) && (stat /tmp/kamal-cord/cord > /dev/null || exit 1)\" --health-interval \"1s\" --volume $(pwd)/.kamal/cords/app-web-12345678901234567890123456789012:/tmp/kamal-cord --log-opt max-size=\"10m\" --label service=\"app\" --label role=\"web\" --label destination --label traefik.http.services.app-web.loadbalancer.server.scheme=\"http\" --label traefik.http.routers.app-web.rule=\"PathPrefix(\\`/\\`)\" --label traefik.http.routers.app-web.priority=\"2\" --label traefik.http.middlewares.app-web-retry.retry.attempts=\"5\" --label traefik.http.middlewares.app-web-retry.retry.initialinterval=\"500ms\" --label traefik.http.routers.app-web.middlewares=\"app-web-retry@docker\" dhh/app:999", @@ -194,7 +194,7 @@ class CommandsAppTest < ActiveSupport::TestCase test "execute in new container with tags" do @config[:servers] = [ { "1.1.1.1" => "tag1" } ] - @config[:env_tags] = { "tag1" => { "ENV1" => "value1" } } + @config[:env]["tags"] = { "tag1" => { "ENV1" => "value1" } } assert_equal \ "docker run --rm --env-file .kamal/env/roles/app-web.env --env ENV1=\"value1\" dhh/app:999 bin/rails db:setup", @@ -227,7 +227,7 @@ class CommandsAppTest < ActiveSupport::TestCase test "execute in new container over ssh with tags" do @config[:servers] = [ { "1.1.1.1" => "tag1" } ] - @config[:env_tags] = { "tag1" => { "ENV1" => "value1" } } + @config[:env]["tags"] = { "tag1" => { "ENV1" => "value1" } } assert_equal "ssh -t root@1.1.1.1 -p 22 'docker run -it --rm --env-file .kamal/env/roles/app-web.env --env ENV1=\"value1\" dhh/app:999 bin/rails c'", new_command.execute_in_new_container_over_ssh("bin/rails", "c", env: {}) diff --git a/test/commands/healthcheck_test.rb b/test/commands/healthcheck_test.rb index 5c6051757..829aa0b2f 100644 --- a/test/commands/healthcheck_test.rb +++ b/test/commands/healthcheck_test.rb @@ -48,7 +48,8 @@ class CommandsHealthcheckTest < ActiveSupport::TestCase test "run with tags" do @config[:servers] = [ { "1.1.1.1" => "tag1" } ] - @config[:env_tags] = { "tag1" => { "ENV1" => "value1" } } + @config[:env] = {} + @config[:env]["tags"] = { "tag1" => { "ENV1" => "value1" } } assert_equal \ "docker run --detach --name healthcheck-app-123 --publish 3999:3000 --label service=healthcheck-app -e KAMAL_CONTAINER_NAME=\"healthcheck-app\" --env-file .kamal/env/roles/app-web.env --env ENV1=\"value1\" --health-cmd \"curl -f http://localhost:3000/up || exit 1\" --health-interval \"1s\" dhh/app:123", diff --git a/test/configuration/env/tags_test.rb b/test/configuration/env/tags_test.rb index 380cb5cda..c36b6057b 100644 --- a/test/configuration/env/tags_test.rb +++ b/test/configuration/env/tags_test.rb @@ -5,11 +5,13 @@ class ConfigurationEnvTagsTest < ActiveSupport::TestCase @deploy = { service: "app", image: "dhh/app", registry: { "username" => "dhh", "password" => "secret" }, servers: [ { "1.1.1.1" => "odd" }, { "1.1.1.2" => "even" }, { "1.1.1.3" => [ "odd", "three" ] } ], - env: { "REDIS_URL" => "redis://x/y", "THREE" => "false" }, - env_tags: { - "odd" => { "TYPE" => "odd" }, - "even" => { "TYPE" => "even" }, - "three" => { "THREE" => "true" } + env: { + "clear" => { "REDIS_URL" => "redis://x/y", "THREE" => "false" }, + "tags" => { + "odd" => { "TYPE" => "odd" }, + "even" => { "TYPE" => "even" }, + "three" => { "THREE" => "true" } + } } } @@ -27,9 +29,11 @@ class ConfigurationEnvTagsTest < ActiveSupport::TestCase } } }, - env_tags: { - "odd" => { "TYPE" => "odd" }, - "oddjob" => { "TYPE" => "oddjob" } + env: { + "tags" => { + "odd" => { "TYPE" => "odd" }, + "oddjob" => { "TYPE" => "oddjob" } + } } }) @@ -60,9 +64,11 @@ class ConfigurationEnvTagsTest < ActiveSupport::TestCase deploy = { service: "app", image: "dhh/app", registry: { "username" => "dhh", "password" => "secret" }, servers: [ { "1.1.1.1" => [ "first", "second" ] } ], - env_tags: { - "first" => { "TYPE" => "first" }, - "second" => { "TYPE" => "second" } + env: { + "tags" => { + "first" => { "TYPE" => "first" }, + "second" => { "TYPE" => "second" } + } } } @@ -76,8 +82,10 @@ class ConfigurationEnvTagsTest < ActiveSupport::TestCase deploy = { service: "app", image: "dhh/app", registry: { "username" => "dhh", "password" => "secret" }, servers: [ { "1.1.1.1" => "secrets" } ], - env_tags: { - "secrets" => { "secret" => [ "PASSWORD" ] } + env: { + "tags" => { + "secrets" => { "secret" => [ "PASSWORD" ] } + } } } @@ -91,8 +99,10 @@ class ConfigurationEnvTagsTest < ActiveSupport::TestCase deploy = { service: "app", image: "dhh/app", registry: { "username" => "dhh", "password" => "secret" }, servers: [ { "1.1.1.1" => "clearly" } ], - env_tags: { - "clearly" => { "clear" => { "FOO" => "bar" } } + env: { + "tags" => { + "clearly" => { "clear" => { "FOO" => "bar" } } + } } } diff --git a/test/fixtures/deploy_with_env_tags.yml b/test/fixtures/deploy_with_env_tags.yml index 4acc839c2..f0a247603 100644 --- a/test/fixtures/deploy_with_env_tags.yml +++ b/test/fixtures/deploy_with_env_tags.yml @@ -15,13 +15,12 @@ env: clear: TEST: "root" EXPERIMENT: "disabled" -env_tags: - site1: - SITE: site1 - site2: - SITE: site2 - experimental: - env: + tags: + site1: + SITE: site1 + site2: + SITE: site2 + experimental: EXPERIMENT: "enabled" registry: diff --git a/test/integration/docker/deployer/app/config/deploy.yml b/test/integration/docker/deployer/app/config/deploy.yml index aed676cc3..38113737e 100644 --- a/test/integration/docker/deployer/app/config/deploy.yml +++ b/test/integration/docker/deployer/app/config/deploy.yml @@ -10,12 +10,12 @@ env: HOST_TOKEN: "${HOST_TOKEN}" secret: - SECRET_TOKEN -env_tags: - tag1: - CLEAR_TAG: tagged - tag2: - secret: - - SECRET_TAG + tags: + tag1: + CLEAR_TAG: tagged + tag2: + secret: + - SECRET_TAG asset_path: /usr/share/nginx/html/versions registry: From 307750ff708a24aaecef858d987fbf1ee12be5c9 Mon Sep 17 00:00:00 2001 From: Donal McBreen Date: Fri, 10 May 2024 15:23:43 +0100 Subject: [PATCH 25/71] Build from within a git clone by default Docker does not respect the .dockerignore file when building from a tar. Instead by default we'll make a local clone into a tmp directory and build from there. Subsequent builds will reset the clone to match the checkout. Compared to building directly in the repo, we'll have reproducible builds. Compared to using a git archive: 1. .dockerignore is respected 2. We'll have faster builds - docker can be smarter about caching the build context on subsequent builds from a directory To build from the repo directly, set the build context to "." in the config. If there are uncommitted changes, we'll warn about them either being included or ignored depending on whether we build from the clone. --- lib/kamal/cli/build.rb | 40 ++++++++- lib/kamal/commands/base.rb | 4 +- lib/kamal/commands/builder.rb | 18 ++++ lib/kamal/commands/builder/base.rb | 12 +-- lib/kamal/commands/builder/multiarch.rb | 18 ++-- lib/kamal/commands/builder/native.rb | 13 ++- lib/kamal/commands/builder/native/cached.rb | 13 ++- lib/kamal/commands/builder/native/remote.rb | 18 ++-- lib/kamal/configuration.rb | 2 +- lib/kamal/configuration/builder.rb | 31 ++++++- lib/kamal/git.rb | 4 + test/cli/build_test.rb | 93 +++++++++++++++++---- test/commands/builder_test.rb | 22 +++-- test/configuration/builder_test.rb | 2 +- test/fixtures/deploy_without_clone.yml | 39 +++++++++ 15 files changed, 253 insertions(+), 76 deletions(-) create mode 100644 test/fixtures/deploy_without_clone.yml diff --git a/lib/kamal/cli/build.rb b/lib/kamal/cli/build.rb index a43cee2e4..52fd45e89 100644 --- a/lib/kamal/cli/build.rb +++ b/lib/kamal/cli/build.rb @@ -19,21 +19,34 @@ def push verify_local_dependencies run_hook "pre-build" - if (uncommitted_changes = Kamal::Git.uncommitted_changes).present? - say "The following paths have uncommitted changes:\n #{uncommitted_changes}", :yellow + uncommitted_changes = Kamal::Git.uncommitted_changes + + if KAMAL.config.builder.git_clone? + if uncommitted_changes.present? + say "Building from a local git clone, so ignoring these uncommitted changes:\n #{uncommitted_changes}", :yellow + end + + prepare_clone + elsif uncommitted_changes.present? + say "Building with uncommitted changes:\n #{uncommitted_changes}", :yellow end + # Get the command here to ensure the Dir.chdir doesn't interfere with it + push = KAMAL.builder.push + run_locally do begin KAMAL.with_verbosity(:debug) do - execute *KAMAL.builder.push + Dir.chdir(KAMAL.config.builder.build_directory) { execute *push } end rescue SSHKit::Command::Failed => e if e.message =~ /(no builder)|(no such file or directory)/ warn "Missing compatible builder, so creating a new one first" if cli.create - KAMAL.with_verbosity(:debug) { execute *KAMAL.builder.push } + KAMAL.with_verbosity(:debug) do + Dir.chdir(KAMAL.config.builder.build_directory) { execute *push } + end end else raise @@ -120,4 +133,23 @@ def connect_to_remote_host(remote_host) end end end + + def prepare_clone + run_locally do + begin + info "Cloning repo into build directory `#{KAMAL.config.builder.build_directory}`..." + + execute *KAMAL.builder.create_clone_directory + execute *KAMAL.builder.clone + rescue SSHKit::Command::Failed => e + if e.message =~ /already exists and is not an empty directory/ + info "Resetting local clone as `#{KAMAL.config.builder.build_directory}` already exists..." + + KAMAL.builder.clone_reset_steps.each { |step| execute *step } + else + raise + end + end + end + end end diff --git a/lib/kamal/commands/base.rb b/lib/kamal/commands/base.rb index 824047839..a98d6330f 100644 --- a/lib/kamal/commands/base.rb +++ b/lib/kamal/commands/base.rb @@ -78,8 +78,8 @@ def docker(*args) args.compact.unshift :docker end - def git(*args) - args.compact.unshift :git + def git(*args, path: nil) + [ :git, *([ "-C", path ] if path), *args.compact ] end def tags(**details) diff --git a/lib/kamal/commands/builder.rb b/lib/kamal/commands/builder.rb index bed0581da..d8fa9fcf1 100644 --- a/lib/kamal/commands/builder.rb +++ b/lib/kamal/commands/builder.rb @@ -2,6 +2,7 @@ class Kamal::Commands::Builder < Kamal::Commands::Base delegate :create, :remove, :push, :clean, :pull, :info, :validate_image, to: :target + delegate :clone_directory, :build_directory, to: :"config.builder" def name target.class.to_s.remove("Kamal::Commands::Builder::").underscore.inquiry @@ -53,6 +54,23 @@ def ensure_local_dependencies_installed end end + def create_clone_directory + make_directory clone_directory + end + + def clone + git :clone, Kamal::Git.root, path: clone_directory + end + + def clone_reset_steps + [ + git(:remote, "set-url", :origin, Kamal::Git.root, path: build_directory), + git(:fetch, :origin, path: build_directory), + git(:reset, "--hard", Kamal::Git.revision, path: build_directory), + git(:clean, "-fdx", path: build_directory) + ] + end + private def ensure_local_docker_installed docker "--version" diff --git a/lib/kamal/commands/builder/base.rb b/lib/kamal/commands/builder/base.rb index 5021de1ca..d4d79e162 100644 --- a/lib/kamal/commands/builder/base.rb +++ b/lib/kamal/commands/builder/base.rb @@ -3,7 +3,7 @@ class Kamal::Commands::Builder::Base < Kamal::Commands::Base class BuilderError < StandardError; end delegate :argumentize, to: Kamal::Utils - delegate :args, :secrets, :dockerfile, :target, :local_arch, :local_host, :remote_arch, :remote_host, :cache_from, :cache_to, :ssh, :git_archive?, to: :builder_config + delegate :args, :secrets, :dockerfile, :target, :local_arch, :local_host, :remote_arch, :remote_host, :cache_from, :cache_to, :ssh, to: :builder_config def clean docker :image, :rm, "--force", config.absolute_image @@ -13,16 +13,6 @@ def pull docker :pull, config.absolute_image end - def push - if git_archive? - pipe \ - git(:archive, "--format=tar", :HEAD), - build_and_push - else - build_and_push - end - end - def build_options [ *build_tags, *build_cache, *build_labels, *build_args, *build_secrets, *build_dockerfile, *build_target, *build_ssh ] end diff --git a/lib/kamal/commands/builder/multiarch.rb b/lib/kamal/commands/builder/multiarch.rb index b80200cd4..d232226c7 100644 --- a/lib/kamal/commands/builder/multiarch.rb +++ b/lib/kamal/commands/builder/multiarch.rb @@ -13,6 +13,15 @@ def info docker(:buildx, :ls) end + def push + docker :buildx, :build, + "--push", + "--platform", platform_names, + "--builder", builder_name, + *build_options, + build_context + end + private def builder_name "kamal-#{config.service}-multiarch" @@ -25,13 +34,4 @@ def platform_names "linux/amd64,linux/arm64" end end - - def build_and_push - docker :buildx, :build, - "--push", - "--platform", platform_names, - "--builder", builder_name, - *build_options, - build_context - end end diff --git a/lib/kamal/commands/builder/native.rb b/lib/kamal/commands/builder/native.rb index cc0f03b10..599bdc0f4 100644 --- a/lib/kamal/commands/builder/native.rb +++ b/lib/kamal/commands/builder/native.rb @@ -11,11 +11,10 @@ def info # No-op on native end - private - def build_and_push - combine \ - docker(:build, *build_options, build_context), - docker(:push, config.absolute_image), - docker(:push, config.latest_image) - end + def push + combine \ + docker(:build, *build_options, build_context), + docker(:push, config.absolute_image), + docker(:push, config.latest_image) + end end diff --git a/lib/kamal/commands/builder/native/cached.rb b/lib/kamal/commands/builder/native/cached.rb index b3a3d6356..f72d11923 100644 --- a/lib/kamal/commands/builder/native/cached.rb +++ b/lib/kamal/commands/builder/native/cached.rb @@ -7,11 +7,10 @@ def remove docker :buildx, :rm, builder_name end - private - def build_and_push - docker :buildx, :build, - "--push", - *build_options, - build_context - end + def push + docker :buildx, :build, + "--push", + *build_options, + build_context + end end diff --git a/lib/kamal/commands/builder/native/remote.rb b/lib/kamal/commands/builder/native/remote.rb index d3053d409..a14a776a0 100644 --- a/lib/kamal/commands/builder/native/remote.rb +++ b/lib/kamal/commands/builder/native/remote.rb @@ -17,6 +17,15 @@ def info docker(:buildx, :ls) end + def push + docker :buildx, :build, + "--push", + "--platform", platform, + "--builder", builder_name, + *build_options, + build_context + end + private def builder_name @@ -47,13 +56,4 @@ def create_buildx def remove_buildx docker :buildx, :rm, builder_name end - - def build_and_push - docker :buildx, :build, - "--push", - "--platform", platform, - "--builder", builder_name, - *build_options, - build_context - end end diff --git a/lib/kamal/configuration.rb b/lib/kamal/configuration.rb index 5b5878c38..b27c1ffb2 100644 --- a/lib/kamal/configuration.rb +++ b/lib/kamal/configuration.rb @@ -336,7 +336,7 @@ def role_names def git_version @git_version ||= if Kamal::Git.used? - if Kamal::Git.uncommitted_changes.present? && !builder.git_archive? + if Kamal::Git.uncommitted_changes.present? && !builder.git_clone? uncommitted_suffix = "_uncommitted_#{SecureRandom.hex(8)}" end [ Kamal::Git.revision, uncommitted_suffix ].compact.join diff --git a/lib/kamal/configuration/builder.rb b/lib/kamal/configuration/builder.rb index dcc9c516b..ab9abb02f 100644 --- a/lib/kamal/configuration/builder.rb +++ b/lib/kamal/configuration/builder.rb @@ -3,6 +3,8 @@ def initialize(config:) @options = config.raw_config.builder || {} @image = config.image @server = config.registry["server"] + @service = config.service + @destination = config.destination valid? end @@ -44,7 +46,7 @@ def target end def context - @options["context"] || (git_archive? ? "-" : ".") + @options["context"] || "." end def local_arch @@ -89,10 +91,23 @@ def ssh @options["ssh"] end - def git_archive? + def git_clone? Kamal::Git.used? && @options["context"].nil? end + def clone_directory + @clone_directory ||= File.join Dir.tmpdir, "kamal-clones", [ @service, @destination, pwd_sha ].compact.join("-") + end + + def build_directory + @build_directory ||= + if git_clone? + File.join clone_directory, repo_basename, repo_relative_pwd + else + "." + end + end + private def valid? if @options["cache"] && @options["cache"]["type"] @@ -123,4 +138,16 @@ def cache_to_config_for_gha def cache_to_config_for_registry [ "type=registry", @options["cache"]&.fetch("options", nil), "ref=#{cache_image_ref}" ].compact.join(",") end + + def repo_basename + File.basename(Kamal::Git.root) + end + + def repo_relative_pwd + Dir.pwd.delete_prefix(Kamal::Git.root) + end + + def pwd_sha + Digest::SHA256.hexdigest(Dir.pwd)[0..12] + end end diff --git a/lib/kamal/git.rb b/lib/kamal/git.rb index 8d59827a0..c25b055c6 100644 --- a/lib/kamal/git.rb +++ b/lib/kamal/git.rb @@ -16,4 +16,8 @@ def revision def uncommitted_changes `git status --porcelain`.strip end + + def root + `git rev-parse --show-toplevel`.strip + end end diff --git a/test/cli/build_test.rb b/test/cli/build_test.rb index ca11dc1aa..eac244115 100644 --- a/test/cli/build_test.rb +++ b/test/cli/build_test.rb @@ -9,32 +9,84 @@ class CliBuildTest < CliTestCase end test "push" do + with_build_directory do + Kamal::Commands::Hook.any_instance.stubs(:hook_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").tap do |output| + assert_hook_ran "pre-build", output, **hook_variables + assert_match /Cloning repo into build directory/, output + assert_match /git -C #{Dir.tmpdir}\/kamal-clones\/app-#{pwd_sha} clone #{Dir.pwd}/, output + assert_match /docker --version && docker buildx version/, output + assert_match /docker buildx build --push --platform linux\/amd64,linux\/arm64 --builder kamal-app-multiarch -t dhh\/app:999 -t dhh\/app:latest --label service="app" --file Dockerfile \. as .*@localhost/, output + end + end + end + + test "push reseting clone" do + with_build_directory do + stub_setup + build_dir = "#{Dir.tmpdir}/kamal-clones/app-#{pwd_sha}/kamal/" + + SSHKit::Backend::Abstract.any_instance.stubs(:execute).with(:docker, "--version", "&&", :docker, :buildx, "version") + SSHKit::Backend::Abstract.any_instance.stubs(:execute).with(:docker, :buildx, :create, "--use", "--name", "kamal-app-multiarch") + + SSHKit::Backend::Abstract.any_instance.stubs(:execute).with(:mkdir, "-p", "#{Dir.tmpdir}/kamal-clones/app-#{pwd_sha}") + SSHKit::Backend::Abstract.any_instance.stubs(:execute) + .with(:git, "-C", "#{Dir.tmpdir}/kamal-clones/app-#{pwd_sha}", :clone, Dir.pwd) + .raises(SSHKit::Command::Failed.new("fatal: destination path 'kamal' already exists and is not an empty directory")) + .then + .returns(true) + SSHKit::Backend::Abstract.any_instance.stubs(:execute).with(:git, "-C", build_dir, :remote, "set-url", :origin, Dir.pwd) + SSHKit::Backend::Abstract.any_instance.stubs(:execute).with(:git, "-C", build_dir, :fetch, :origin) + SSHKit::Backend::Abstract.any_instance.stubs(:execute).with(:git, "-C", build_dir, :reset, "--hard", Kamal::Git.revision) + SSHKit::Backend::Abstract.any_instance.stubs(:execute).with(:git, "-C", build_dir, :clean, "-fdx") + + SSHKit::Backend::Abstract.any_instance.stubs(:execute) + .with(:docker, :buildx, :build, "--push", "--platform", "linux/amd64,linux/arm64", "--builder", "kamal-app-multiarch", "-t", "dhh/app:999", "-t", "dhh/app:latest", "--label", "service=\"app\"", "--file", "Dockerfile", ".") + + run_command("push", "--verbose").tap do |output| + assert_match /Cloning repo into build directory/, output + assert_match /Resetting local clone/, output + end + end + end + + test "push without clone" do Kamal::Commands::Hook.any_instance.stubs(:hook_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").tap do |output| + run_command("push", "--verbose", fixture: :without_clone).tap do |output| + assert_no_match /Cloning repo into build directory/, output assert_hook_ran "pre-build", output, **hook_variables assert_match /docker --version && docker buildx version/, output - assert_match /git archive -tar HEAD | docker buildx build --push --platform linux\/amd64,linux\/arm64 --builder kamal-app-multiarch -t dhh\/app:999 -t dhh\/app:latest --label service="app" --file Dockerfile - as .*@localhost/, output + assert_match /docker buildx build --push --platform linux\/amd64,linux\/arm64 --builder kamal-app-multiarch -t dhh\/app:999 -t dhh\/app:latest --label service="app" --file Dockerfile . as .*@localhost/, output end end test "push without builder" do - stub_setup - SSHKit::Backend::Abstract.any_instance.stubs(:execute) - .with(:docker, "--version", "&&", :docker, :buildx, "version") + with_build_directory do + stub_setup - SSHKit::Backend::Abstract.any_instance.stubs(:execute) - .with(:docker, :buildx, :create, "--use", "--name", "kamal-app-multiarch") + SSHKit::Backend::Abstract.any_instance.stubs(:execute) + .with(:docker, "--version", "&&", :docker, :buildx, "version") - SSHKit::Backend::Abstract.any_instance.stubs(:execute) - .with { |*args| p args[0..6]; args[0..6] == [ :git, :archive, "--format=tar", :HEAD, "|", :docker, :buildx ] } - .raises(SSHKit::Command::Failed.new("no builder")) - .then - .returns(true) + SSHKit::Backend::Abstract.any_instance.stubs(:execute) + .with(:docker, :buildx, :create, "--use", "--name", "kamal-app-multiarch") + + SSHKit::Backend::Abstract.any_instance.stubs(:execute) + .with { |*args| args[0..1] == [ :docker, :buildx ] } + .raises(SSHKit::Command::Failed.new("no builder")) + .then + .returns(true) + + SSHKit::Backend::Abstract.any_instance.stubs(:execute).with { |*args| args.first.start_with?("git") } - run_command("push").tap do |output| - assert_match /WARN Missing compatible builder, so creating a new one first/, output + SSHKit::Backend::Abstract.any_instance.stubs(:execute).with(:mkdir, "-p", "#{Dir.tmpdir}/kamal-clones/app-#{pwd_sha}") + + run_command("push").tap do |output| + assert_match /WARN Missing compatible builder, so creating a new one first/, output + end end end @@ -118,4 +170,17 @@ def stub_dependency_checks SSHKit::Backend::Abstract.any_instance.stubs(:execute) .with { |*args| args[0..1] == [ :docker, :buildx ] } end + + def with_build_directory + build_directory = File.join Dir.tmpdir, "kamal-clones", "app-#{pwd_sha}", "kamal" + FileUtils.mkdir_p build_directory + FileUtils.touch File.join build_directory, "Dockerfile" + yield + ensure + FileUtils.rm_rf build_directory + end + + def pwd_sha + Digest::SHA256.hexdigest(Dir.pwd)[0..12] + end end diff --git a/test/commands/builder_test.rb b/test/commands/builder_test.rb index cc7cea0b7..064454a2e 100644 --- a/test/commands/builder_test.rb +++ b/test/commands/builder_test.rb @@ -9,7 +9,7 @@ class CommandsBuilderTest < ActiveSupport::TestCase builder = new_builder_command(builder: { "cache" => { "type" => "gha" } }) assert_equal "multiarch", builder.name assert_equal \ - "git archive --format=tar HEAD | docker buildx build --push --platform linux/amd64,linux/arm64 --builder kamal-app-multiarch -t dhh/app:123 -t dhh/app:latest --cache-to type=gha --cache-from type=gha --label service=\"app\" --file Dockerfile -", + "docker buildx build --push --platform linux/amd64,linux/arm64 --builder kamal-app-multiarch -t dhh/app:123 -t dhh/app:latest --cache-to type=gha --cache-from type=gha --label service=\"app\" --file Dockerfile .", builder.push.join(" ") end @@ -17,7 +17,7 @@ class CommandsBuilderTest < ActiveSupport::TestCase builder = new_builder_command(builder: { "multiarch" => false }) assert_equal "native", builder.name assert_equal \ - "git archive --format=tar HEAD | docker build -t dhh/app:123 -t dhh/app:latest --label service=\"app\" --file Dockerfile - && docker push dhh/app:123 && docker push dhh/app:latest", + "docker build -t dhh/app:123 -t dhh/app:latest --label service=\"app\" --file Dockerfile . && docker push dhh/app:123 && docker push dhh/app:latest", builder.push.join(" ") end @@ -25,7 +25,7 @@ class CommandsBuilderTest < ActiveSupport::TestCase builder = new_builder_command(builder: { "multiarch" => false, "cache" => { "type" => "gha" } }) assert_equal "native/cached", builder.name assert_equal \ - "git archive --format=tar HEAD | docker buildx build --push -t dhh/app:123 -t dhh/app:latest --cache-to type=gha --cache-from type=gha --label service=\"app\" --file Dockerfile -", + "docker buildx build --push -t dhh/app:123 -t dhh/app:latest --cache-to type=gha --cache-from type=gha --label service=\"app\" --file Dockerfile .", builder.push.join(" ") end @@ -33,7 +33,7 @@ class CommandsBuilderTest < ActiveSupport::TestCase builder = new_builder_command(builder: { "local" => {}, "remote" => {}, "cache" => { "type" => "gha" } }) assert_equal "multiarch/remote", builder.name assert_equal \ - "git archive --format=tar HEAD | docker buildx build --push --platform linux/amd64,linux/arm64 --builder kamal-app-multiarch-remote -t dhh/app:123 -t dhh/app:latest --cache-to type=gha --cache-from type=gha --label service=\"app\" --file Dockerfile -", + "docker buildx build --push --platform linux/amd64,linux/arm64 --builder kamal-app-multiarch-remote -t dhh/app:123 -t dhh/app:latest --cache-to type=gha --cache-from type=gha --label service=\"app\" --file Dockerfile .", builder.push.join(" ") end @@ -41,7 +41,7 @@ class CommandsBuilderTest < ActiveSupport::TestCase builder = new_builder_command(builder: { "local" => { "arch" => "amd64" } }) assert_equal "multiarch", builder.name assert_equal \ - "git archive --format=tar HEAD | docker buildx build --push --platform linux/amd64 --builder kamal-app-multiarch -t dhh/app:123 -t dhh/app:latest --label service=\"app\" --file Dockerfile -", + "docker buildx build --push --platform linux/amd64 --builder kamal-app-multiarch -t dhh/app:123 -t dhh/app:latest --label service=\"app\" --file Dockerfile .", builder.push.join(" ") end @@ -49,7 +49,7 @@ class CommandsBuilderTest < ActiveSupport::TestCase builder = new_builder_command(builder: { "remote" => { "arch" => "amd64" }, "cache" => { "type" => "gha" } }) assert_equal "native/remote", builder.name assert_equal \ - "git archive --format=tar HEAD | docker buildx build --push --platform linux/amd64 --builder kamal-app-native-remote -t dhh/app:123 -t dhh/app:latest --cache-to type=gha --cache-from type=gha --label service=\"app\" --file Dockerfile -", + "docker buildx build --push --platform linux/amd64 --builder kamal-app-native-remote -t dhh/app:123 -t dhh/app:latest --cache-to type=gha --cache-from type=gha --label service=\"app\" --file Dockerfile .", builder.push.join(" ") end @@ -100,21 +100,21 @@ class CommandsBuilderTest < ActiveSupport::TestCase test "native push with build args" do builder = new_builder_command(builder: { "multiarch" => false, "args" => { "a" => 1, "b" => 2 } }) assert_equal \ - "git archive --format=tar HEAD | docker build -t dhh/app:123 -t dhh/app:latest --label service=\"app\" --build-arg a=\"1\" --build-arg b=\"2\" --file Dockerfile - && docker push dhh/app:123 && docker push dhh/app:latest", + "docker build -t dhh/app:123 -t dhh/app:latest --label service=\"app\" --build-arg a=\"1\" --build-arg b=\"2\" --file Dockerfile . && docker push dhh/app:123 && docker push dhh/app:latest", builder.push.join(" ") end test "multiarch push with build args" do builder = new_builder_command(builder: { "args" => { "a" => 1, "b" => 2 } }) assert_equal \ - "git archive --format=tar HEAD | docker buildx build --push --platform linux/amd64,linux/arm64 --builder kamal-app-multiarch -t dhh/app:123 -t dhh/app:latest --label service=\"app\" --build-arg a=\"1\" --build-arg b=\"2\" --file Dockerfile -", + "docker buildx build --push --platform linux/amd64,linux/arm64 --builder kamal-app-multiarch -t dhh/app:123 -t dhh/app:latest --label service=\"app\" --build-arg a=\"1\" --build-arg b=\"2\" --file Dockerfile .", builder.push.join(" ") end test "native push with build secrets" do builder = new_builder_command(builder: { "multiarch" => false, "secrets" => [ "a", "b" ] }) assert_equal \ - "git archive --format=tar HEAD | docker build -t dhh/app:123 -t dhh/app:latest --label service=\"app\" --secret id=\"a\" --secret id=\"b\" --file Dockerfile - && docker push dhh/app:123 && docker push dhh/app:latest", + "docker build -t dhh/app:123 -t dhh/app:latest --label service=\"app\" --secret id=\"a\" --secret id=\"b\" --file Dockerfile . && docker push dhh/app:123 && docker push dhh/app:latest", builder.push.join(" ") end @@ -162,4 +162,8 @@ class CommandsBuilderTest < ActiveSupport::TestCase def new_builder_command(additional_config = {}) Kamal::Commands::Builder.new(Kamal::Configuration.new(@config.merge(additional_config), version: "123")) end + + def build_directory + "#{Dir.tmpdir}/kamal-clones/app/kamal/" + end end diff --git a/test/configuration/builder_test.rb b/test/configuration/builder_test.rb index 9ac02ad6a..a519be676 100644 --- a/test/configuration/builder_test.rb +++ b/test/configuration/builder_test.rb @@ -140,7 +140,7 @@ class ConfigurationBuilderTest < ActiveSupport::TestCase end test "context" do - assert_equal "-", @config.builder.context + assert_equal ".", @config.builder.context end test "setting context" do diff --git a/test/fixtures/deploy_without_clone.yml b/test/fixtures/deploy_without_clone.yml new file mode 100644 index 000000000..abb1224a1 --- /dev/null +++ b/test/fixtures/deploy_without_clone.yml @@ -0,0 +1,39 @@ +service: app +image: dhh/app +servers: + web: + - "1.1.1.1" + - "1.1.1.2" + workers: + - "1.1.1.3" + - "1.1.1.4" +registry: + username: user + password: pw + +accessories: + mysql: + image: mysql:5.7 + host: 1.1.1.3 + port: 3306 + env: + clear: + MYSQL_ROOT_HOST: '%' + secret: + - MYSQL_ROOT_PASSWORD + files: + - test/fixtures/files/my.cnf:/etc/mysql/my.cnf + directories: + - data:/var/lib/mysql + redis: + image: redis:latest + roles: + - web + port: 6379 + directories: + - data:/data + +readiness_delay: 0 + +builder: + context: "." From 0ea2a2c5090da941c5c4e455c76604fc7a3056fe Mon Sep 17 00:00:00 2001 From: Donal McBreen Date: Mon, 20 May 2024 09:34:42 +0100 Subject: [PATCH 26/71] Don't include destination in clone directory Reusing the clone directory should allow caching of the build context between deployments to different destinations. --- lib/kamal/configuration/builder.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/kamal/configuration/builder.rb b/lib/kamal/configuration/builder.rb index ab9abb02f..4663f11fd 100644 --- a/lib/kamal/configuration/builder.rb +++ b/lib/kamal/configuration/builder.rb @@ -96,7 +96,7 @@ def git_clone? end def clone_directory - @clone_directory ||= File.join Dir.tmpdir, "kamal-clones", [ @service, @destination, pwd_sha ].compact.join("-") + @clone_directory ||= File.join Dir.tmpdir, "kamal-clones", [ @service, pwd_sha ].compact.join("-") end def build_directory From b6dba57c7d44f0152cdd89804279af74ff9a82fe Mon Sep 17 00:00:00 2001 From: Donal McBreen Date: Mon, 20 May 2024 10:06:53 +0100 Subject: [PATCH 27/71] Set sshkit minimum version to 1.22.2 This includes a fix for a bug in the eviction thread that could cause this error: ``` [ERROR (IOError): Exception while executing on host foo: closed stream] ``` See https://github.com/capistrano/sshkit/pull/534 --- Gemfile.lock | 8 ++++++-- kamal.gemspec | 2 +- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index dbb216606..4dc54b335 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -9,7 +9,7 @@ PATH dotenv (~> 2.8) ed25519 (~> 1.2) net-ssh (~> 7.0) - sshkit (~> 1.21) + sshkit (>= 1.22.2, < 2.0) thor (~> 1.2) zeitwerk (~> 2.5) @@ -75,6 +75,8 @@ GEM mutex_m (0.2.0) net-scp (4.0.0) net-ssh (>= 2.6.5, < 8.0.0) + net-sftp (4.0.0) + net-ssh (>= 5.0.0, < 8.0.0) net-ssh (7.2.1) nokogiri (1.16.0-arm64-darwin) racc (~> 1.4) @@ -151,9 +153,11 @@ GEM rubocop-rails ruby-progressbar (1.13.0) ruby2_keywords (0.0.5) - sshkit (1.21.7) + sshkit (1.22.2) + base64 mutex_m net-scp (>= 1.1.2) + net-sftp (>= 2.1.2) net-ssh (>= 2.8.0) stringio (3.1.0) thor (1.3.0) diff --git a/kamal.gemspec b/kamal.gemspec index 55f18ce9f..4278ebea8 100644 --- a/kamal.gemspec +++ b/kamal.gemspec @@ -12,7 +12,7 @@ Gem::Specification.new do |spec| spec.executables = %w[ kamal ] spec.add_dependency "activesupport", ">= 7.0" - spec.add_dependency "sshkit", "~> 1.21" + spec.add_dependency "sshkit", ">= 1.22.2", "< 2.0" spec.add_dependency "net-ssh", "~> 7.0" spec.add_dependency "thor", "~> 1.2" spec.add_dependency "dotenv", "~> 2.8" From 17dcaccb6acb1e6ca1fe8b6a20eecb2237a8e7c7 Mon Sep 17 00:00:00 2001 From: Donal McBreen Date: Mon, 20 May 2024 10:50:07 +0100 Subject: [PATCH 28/71] Don't blow up if there are no env tags --- lib/kamal/configuration.rb | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/lib/kamal/configuration.rb b/lib/kamal/configuration.rb index 5b5878c38..b82d39659 100644 --- a/lib/kamal/configuration.rb +++ b/lib/kamal/configuration.rb @@ -234,7 +234,11 @@ def env end def env_tags - raw_config.env["tags"].collect { |name, config| Kamal::Configuration::Env::Tag.new(name, config: config) } + @env_tags ||= if (tags = raw_config.env["tags"]) + tags.collect { |name, config| Kamal::Configuration::Env::Tag.new(name, config: config) } + else + [] + end end def env_tag(name) From 0efb5ccfffe4faf5553782768d328a89abb88706 Mon Sep 17 00:00:00 2001 From: Donal McBreen Date: Thu, 21 Mar 2024 11:36:21 +0000 Subject: [PATCH 29/71] Remove the healthcheck step To speed up deployments, we'll remove the healthcheck step. This adds some risk to deployments for non-web roles - if they don't have a Docker healthcheck configured then the only check we do is if the container is running. If there is a bad image we might see the container running before it exits and deploy it. Previously the healthcheck step would have avoided this by ensuring a web container could boot and serve traffic first. To mitigate this, we'll add a deployment barrier. Until one of the primary role containers passes its healthcheck, we'll keep the barrier up and avoid stopping the containers on the non-primary roles. It the primary role container fails its healthcheck, we'll close the barrier and shut down the new containers on the waiting roles. We also have a new integration test to check we correctly handle a a broken image. This highlighted that SSHKit's default runner will stop at the first error it encounters. We'll now have a custom runner that waits for all threads to finish allowing them to clean up. --- lib/kamal/cli/app.rb | 9 +- lib/kamal/cli/app/boot.rb | 56 +++++++- lib/kamal/cli/healthcheck.rb | 21 --- lib/kamal/cli/healthcheck/barrier.rb | 31 +++++ lib/kamal/cli/healthcheck/error.rb | 2 + lib/kamal/cli/healthcheck/poller.rb | 9 +- lib/kamal/cli/main.rb | 11 -- lib/kamal/commander.rb | 1 + lib/kamal/commands/healthcheck.rb | 59 --------- lib/kamal/configuration.rb | 2 +- lib/kamal/sshkit_with_ext.rb | 31 +++++ test/cli/app_test.rb | 64 ++++++++- test/cli/healthcheck_test.rb | 82 ------------ test/cli/main_test.rb | 12 -- test/commander_test.rb | 5 + test/commands/healthcheck_test.rb | 124 ------------------ test/configuration_test.rb | 2 +- .../deploy_with_two_roles_one_host.yml | 15 +++ test/integration/broken_deploy_test.rb | 24 ++++ .../docker/deployer/app/config/deploy.yml | 2 + .../deployer/app_with_roles/config/deploy.yml | 2 + test/integration/docker/deployer/break_app.sh | 3 + test/integration/integration_test.rb | 17 +++ test/integration/main_test.rb | 12 +- 24 files changed, 269 insertions(+), 327 deletions(-) delete mode 100644 lib/kamal/cli/healthcheck.rb create mode 100644 lib/kamal/cli/healthcheck/barrier.rb create mode 100644 lib/kamal/cli/healthcheck/error.rb delete mode 100644 lib/kamal/commands/healthcheck.rb delete mode 100644 test/cli/healthcheck_test.rb delete mode 100644 test/commands/healthcheck_test.rb create mode 100644 test/fixtures/deploy_with_two_roles_one_host.yml create mode 100644 test/integration/broken_deploy_test.rb create mode 100755 test/integration/docker/deployer/break_app.sh diff --git a/lib/kamal/cli/app.rb b/lib/kamal/cli/app.rb index 188d5e9b8..ae84e3680 100644 --- a/lib/kamal/cli/app.rb +++ b/lib/kamal/cli/app.rb @@ -14,9 +14,12 @@ def boot end end + barrier = Kamal::Cli::Healthcheck::Barrier.new if KAMAL.roles.many? + on(KAMAL.hosts, **KAMAL.boot_strategy) do |host| + # Ensure primary role is booted first to allow the web barrier to be opened KAMAL.roles_on(host).each do |role| - Kamal::Cli::App::Boot.new(host, role, version, self).run + Kamal::Cli::App::Boot.new(host, role, self, version, barrier).run end end @@ -284,4 +287,8 @@ def current_running_version(host: KAMAL.primary_host) def version_or_latest options[:version] || KAMAL.config.latest_tag end + + def web_and_non_web_roles? + KAMAL.roles.any?(&:running_traefik?) && !KAMAL.roles.all?(&:running_traefik?) + end end diff --git a/lib/kamal/cli/app/boot.rb b/lib/kamal/cli/app/boot.rb index 786701040..de219a23d 100644 --- a/lib/kamal/cli/app/boot.rb +++ b/lib/kamal/cli/app/boot.rb @@ -1,12 +1,13 @@ class Kamal::Cli::App::Boot - attr_reader :host, :role, :version, :sshkit + attr_reader :host, :role, :version, :barrier, :sshkit delegate :execute, :capture_with_info, :info, to: :sshkit - delegate :uses_cord?, :assets?, to: :role + delegate :uses_cord?, :assets?, :running_traefik?, to: :role - def initialize(host, role, version, sshkit) + def initialize(host, role, sshkit, version, barrier) @host = host @role = role @version = version + @barrier = barrier @sshkit = sshkit end @@ -46,10 +47,18 @@ def old_version_renamed_if_clashing def start_new_version audit "Booted app version #{version}" + execute *app.tie_cord(role.cord_host_file) if uses_cord? hostname = "#{host.to_s[0...51].gsub(/\.+$/, '')}-#{SecureRandom.hex(6)}" execute *app.run(hostname: hostname) Kamal::Cli::Healthcheck::Poller.wait_for_healthy(pause_after_ready: true) { capture_with_info(*app.status(version: version)) } + + reach_barrier + rescue => e + close_barrier if barrier_role? + execute *app.stop(version: version), raise_on_non_zero_exit: false + + raise end def stop_old_version(version) @@ -65,4 +74,45 @@ def stop_old_version(version) execute *app.clean_up_assets if assets? end + + def reach_barrier + if barrier + if barrier_role? + if barrier.open + info "Opened barrier (#{host})" + end + else + wait_for_barrier + end + end + end + + def wait_for_barrier + info "Waiting at web barrier (#{host})..." + barrier.wait + info "Barrier opened (#{host})" + rescue Kamal::Cli::Healthcheck::Error + info "Barrier closed, shutting down new container... (#{host})" + raise + end + + def close_barrier + barrier&.close + end + + def barrier_role? + role == KAMAL.primary_role + end + + def app + @app ||= KAMAL.app(role: role) + end + + def auditor + @auditor = KAMAL.auditor(role: role) + end + + def audit(message) + execute *auditor.record(message), verbosity: :debug + end end diff --git a/lib/kamal/cli/healthcheck.rb b/lib/kamal/cli/healthcheck.rb deleted file mode 100644 index b3dbf6bd1..000000000 --- a/lib/kamal/cli/healthcheck.rb +++ /dev/null @@ -1,21 +0,0 @@ -class Kamal::Cli::Healthcheck < Kamal::Cli::Base - default_command :perform - - desc "perform", "Health check current app version" - def perform - raise "The primary host is not configured to run Traefik" unless KAMAL.config.role(KAMAL.config.primary_role).running_traefik? - on(KAMAL.primary_host) do - begin - execute *KAMAL.healthcheck.run - Poller.wait_for_healthy { capture_with_info(*KAMAL.healthcheck.status) } - rescue Poller::HealthcheckError => e - error capture_with_info(*KAMAL.healthcheck.logs) - error capture_with_pretty_json(*KAMAL.healthcheck.container_health_log) - raise - ensure - execute *KAMAL.healthcheck.stop, raise_on_non_zero_exit: false - execute *KAMAL.healthcheck.remove, raise_on_non_zero_exit: false - end - end - end -end diff --git a/lib/kamal/cli/healthcheck/barrier.rb b/lib/kamal/cli/healthcheck/barrier.rb new file mode 100644 index 000000000..0fbfb511b --- /dev/null +++ b/lib/kamal/cli/healthcheck/barrier.rb @@ -0,0 +1,31 @@ +class Kamal::Cli::Healthcheck::Barrier + def initialize + @ivar = Concurrent::IVar.new + end + + def close + set(false) + end + + def open + set(true) + end + + def wait + unless opened? + raise Kamal::Cli::Healthcheck::Error.new("Halted at barrier") + end + end + + private + def opened? + @ivar.value + end + + def set(value) + @ivar.set(value) + true + rescue Concurrent::MultipleAssignmentError + false + end +end diff --git a/lib/kamal/cli/healthcheck/error.rb b/lib/kamal/cli/healthcheck/error.rb new file mode 100644 index 000000000..8824a72eb --- /dev/null +++ b/lib/kamal/cli/healthcheck/error.rb @@ -0,0 +1,2 @@ +class Kamal::Cli::Healthcheck::Error < StandardError +end diff --git a/lib/kamal/cli/healthcheck/poller.rb b/lib/kamal/cli/healthcheck/poller.rb index 9d91adfce..06898b1cf 100644 --- a/lib/kamal/cli/healthcheck/poller.rb +++ b/lib/kamal/cli/healthcheck/poller.rb @@ -3,7 +3,6 @@ module Kamal::Cli::Healthcheck::Poller TRAEFIK_UPDATE_DELAY = 5 - class HealthcheckError < StandardError; end def wait_for_healthy(pause_after_ready: false, &block) attempt = 1 @@ -16,9 +15,9 @@ def wait_for_healthy(pause_after_ready: false, &block) when "running" # No health check configured sleep KAMAL.config.readiness_delay if pause_after_ready else - raise HealthcheckError, "container not ready (#{status})" + raise Kamal::Cli::Healthcheck::Error, "container not ready (#{status})" end - rescue HealthcheckError => e + rescue Kamal::Cli::Healthcheck::Error => e if attempt <= max_attempts info "#{e.message}, retrying in #{attempt}s (attempt #{attempt}/#{max_attempts})..." sleep attempt @@ -41,9 +40,9 @@ def wait_for_unhealthy(pause_after_ready: false, &block) when "unhealthy" sleep TRAEFIK_UPDATE_DELAY if pause_after_ready else - raise HealthcheckError, "container not unhealthy (#{status})" + raise Kamal::Cli::Healthcheck::Error, "container not unhealthy (#{status})" end - rescue HealthcheckError => e + rescue Kamal::Cli::Healthcheck::Error => e if attempt <= max_attempts info "#{e.message}, retrying in #{attempt}s (attempt #{attempt}/#{max_attempts})..." sleep attempt diff --git a/lib/kamal/cli/main.rb b/lib/kamal/cli/main.rb index 1cc9d98ea..594fb3fd1 100644 --- a/lib/kamal/cli/main.rb +++ b/lib/kamal/cli/main.rb @@ -42,11 +42,6 @@ def deploy say "Ensure Traefik is running...", :magenta invoke "kamal:cli:traefik:boot", [], invoke_options - if KAMAL.config.role(KAMAL.config.primary_role).running_traefik? - say "Ensure app can pass healthcheck...", :magenta - invoke "kamal:cli:healthcheck:perform", [], invoke_options - end - say "Detect stale containers...", :magenta invoke "kamal:cli:app:stale_containers", [], invoke_options.merge(stop: true) @@ -77,9 +72,6 @@ def redeploy run_hook "pre-deploy" - say "Ensure app can pass healthcheck...", :magenta - invoke "kamal:cli:healthcheck:perform", [], invoke_options - say "Detect stale containers...", :magenta invoke "kamal:cli:app:stale_containers", [], invoke_options.merge(stop: true) @@ -228,9 +220,6 @@ def version desc "env", "Manage environment files" subcommand "env", Kamal::Cli::Env - desc "healthcheck", "Healthcheck application" - subcommand "healthcheck", Kamal::Cli::Healthcheck - desc "lock", "Manage the deploy lock" subcommand "lock", Kamal::Cli::Lock diff --git a/lib/kamal/commander.rb b/lib/kamal/commander.rb index e7c5d21f0..f4c54215c 100644 --- a/lib/kamal/commander.rb +++ b/lib/kamal/commander.rb @@ -150,6 +150,7 @@ def configure_sshkit_with(config) sshkit.max_concurrent_starts = config.sshkit.max_concurrent_starts sshkit.ssh_options = config.ssh.options end + SSHKit.config.default_runner = SSHKit::Runner::ParallelCompleteAll SSHKit.config.command_map[:docker] = "docker" # No need to use /usr/bin/env, just clogs up the logs SSHKit.config.output_verbosity = verbosity end diff --git a/lib/kamal/commands/healthcheck.rb b/lib/kamal/commands/healthcheck.rb deleted file mode 100644 index 2517a5e27..000000000 --- a/lib/kamal/commands/healthcheck.rb +++ /dev/null @@ -1,59 +0,0 @@ -class Kamal::Commands::Healthcheck < Kamal::Commands::Base - def run - primary = config.role(config.primary_role) - - docker :run, - "--detach", - "--name", container_name_with_version, - "--publish", "#{exposed_port}:#{config.healthcheck["port"]}", - "--label", "service=#{config.healthcheck_service}", - "-e", "KAMAL_CONTAINER_NAME=\"#{config.healthcheck_service}\"", - *primary.env_args(config.primary_host), - *primary.health_check_args(cord: false), - *config.volume_args, - *primary.option_args, - config.absolute_image, - primary.cmd - end - - def status - pipe container_id, xargs(docker(:inspect, "--format", DOCKER_HEALTH_STATUS_FORMAT)) - end - - def container_health_log - pipe container_id, xargs(docker(:inspect, "--format", DOCKER_HEALTH_LOG_FORMAT)) - end - - def logs - pipe container_id, xargs(docker(:logs, "--tail", log_lines, "2>&1")) - end - - def stop - pipe container_id, xargs(docker(:stop)) - end - - def remove - pipe container_id, xargs(docker(:container, :rm)) - end - - private - def container_name_with_version - "#{config.healthcheck_service}-#{config.version}" - end - - def container_id - container_id_for(container_name: container_name_with_version) - end - - def health_url - "http://localhost:#{exposed_port}#{config.healthcheck["path"]}" - end - - def exposed_port - config.healthcheck["exposed_port"] - end - - def log_lines - config.healthcheck["log_lines"] - end -end diff --git a/lib/kamal/configuration.rb b/lib/kamal/configuration.rb index 4957be179..bed91cf67 100644 --- a/lib/kamal/configuration.rb +++ b/lib/kamal/configuration.rb @@ -188,7 +188,7 @@ def sshkit def healthcheck - { "path" => "/up", "port" => 3000, "max_attempts" => 7, "exposed_port" => 3999, "cord" => "/tmp/kamal-cord", "log_lines" => 50 }.merge(raw_config.healthcheck || {}) + { "path" => "/up", "port" => 3000, "max_attempts" => 7, "cord" => "/tmp/kamal-cord", "log_lines" => 50 }.merge(raw_config.healthcheck || {}) end def healthcheck_service diff --git a/lib/kamal/sshkit_with_ext.rb b/lib/kamal/sshkit_with_ext.rb index e0c62c3ad..c556774b2 100644 --- a/lib/kamal/sshkit_with_ext.rb +++ b/lib/kamal/sshkit_with_ext.rb @@ -103,3 +103,34 @@ def start_with_concurrency_limit(*args) prepend LimitConcurrentStartsInstance end + +require "thread" + +module SSHKit + module Runner + class ParallelCompleteAll < Abstract + def execute + threads = hosts.map do |host| + Thread.new(host) do |h| + begin + backend(h, &block).run + rescue ::StandardError => e + e2 = SSHKit::Runner::ExecuteError.new e + raise e2, "Exception while executing #{host.user ? "as #{host.user}@" : "on host "}#{host}: #{e.message}" + end + end + end + + exception = nil + threads.each do |t| + begin + t.join + rescue SSHKit::Runner::ExecuteError => e + exception ||= e + end + end + raise exception if exception + end + end + end +end diff --git a/test/cli/app_test.rb b/test/cli/app_test.rb index c75a86cc0..ced41317d 100644 --- a/test/cli/app_test.rb +++ b/test/cli/app_test.rb @@ -118,6 +118,62 @@ class CliAppTest < CliTestCase end end + test "boot with web barrier opened" do + Object.any_instance.stubs(:sleep) + + SSHKit::Backend::Abstract.any_instance.stubs(:capture_with_info).returns("123") # old version + + SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info) + .with(:docker, :container, :ls, "--all", "--filter", "name=^app-web-latest$", "--quiet", "|", :xargs, :docker, :inspect, "--format", "'{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}'") + .returns("running").at_least_once # web health check passing + + SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info) + .with(:docker, :container, :ls, "--all", "--filter", "name=^app-web-123$", "--quiet", "|", :xargs, :docker, :inspect, "--format", "'{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}'") + .returns("unhealthy").at_least_once # web health check failing + + SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info) + .with(:docker, :container, :ls, "--all", "--filter", "name=^app-workers-latest$", "--quiet", "|", :xargs, :docker, :inspect, "--format", "'{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}'") + .returns("running").at_least_once # workers health check + + run_command("boot", config: :with_roles, host: nil).tap do |output| + assert_match "Waiting at web barrier (1.1.1.3)...", output + assert_match "Waiting at web barrier (1.1.1.4)...", output + assert_match "Barrier opened (1.1.1.3)", output + assert_match "Barrier opened (1.1.1.4)", output + end + end + + test "boot with web barrier closed" do + Thread.report_on_exception = false + + Object.any_instance.stubs(:sleep) + + SSHKit::Backend::Abstract.any_instance.stubs(:capture_with_info).returns("123") # old version + + SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info) + .with(:docker, :container, :ls, "--all", "--filter", "name=^app-web-latest$", "--quiet", "|", :xargs, :docker, :inspect, "--format", "'{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}'") + .returns("unhealthy").at_least_once # web health check failing + + SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info) + .with(:docker, :container, :ls, "--all", "--filter", "name=^app-workers-latest$", "--quiet", "|", :xargs, :docker, :inspect, "--format", "'{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}'") + .returns("running").at_least_once # workers health check passing + + stderred do + run_command("boot", config: :with_roles, host: nil, allow_execute_error: true).tap do |output| + assert_match "Waiting at web barrier (1.1.1.3)...", output + assert_match "Waiting at web barrier (1.1.1.4)...", output + assert_match "Barrier closed, shutting down new container... (1.1.1.3)", output + assert_match "Barrier closed, shutting down new container... (1.1.1.4)", output + assert_match "Running docker container ls --all --filter name=^app-web-latest$ --quiet | xargs docker stop on 1.1.1.1", output + assert_match "Running docker container ls --all --filter name=^app-web-latest$ --quiet | xargs docker stop on 1.1.1.2", output + assert_match "Running docker container ls --all --filter name=^app-workers-latest$ --quiet | xargs docker stop on 1.1.1.3", output + assert_match "Running docker container ls --all --filter name=^app-workers-latest$ --quiet | xargs docker stop on 1.1.1.4", output + end + end + ensure + Thread.report_on_exception = true + end + test "start" do run_command("start").tap do |output| assert_match "docker start app-web-999", output @@ -283,8 +339,12 @@ class CliAppTest < CliTestCase end private - def run_command(*command, config: :with_accessories) - stdouted { Kamal::Cli::App.start([ *command, "-c", "test/fixtures/deploy_#{config}.yml", "--hosts", "1.1.1.1" ]) } + def run_command(*command, config: :with_accessories, host: "1.1.1.1", allow_execute_error: false) + stdouted do + Kamal::Cli::App.start([ *command, "-c", "test/fixtures/deploy_#{config}.yml", *([ "--hosts", host ] if host) ]) + rescue SSHKit::Runner::ExecuteError => e + raise e unless allow_execute_error + end end def stub_running diff --git a/test/cli/healthcheck_test.rb b/test/cli/healthcheck_test.rb deleted file mode 100644 index e273bf2e5..000000000 --- a/test/cli/healthcheck_test.rb +++ /dev/null @@ -1,82 +0,0 @@ -require_relative "cli_test_case" - -class CliHealthcheckTest < CliTestCase - test "perform" do - # Prevent expected failures from outputting to terminal - Thread.report_on_exception = false - - Kamal::Cli::Healthcheck::Poller.stubs(:sleep) # No sleeping when retrying - Kamal::Configuration.any_instance.stubs(:run_id).returns("12345678901234567890123456789012") - - SSHKit::Backend::Abstract.any_instance.stubs(:execute) - .with(:docker, :container, :ls, "--all", "--filter", "name=^healthcheck-app-999$", "--quiet", "|", :xargs, :docker, :stop, raise_on_non_zero_exit: false) - SSHKit::Backend::Abstract.any_instance.stubs(:execute) - .with(:docker, :run, "--detach", "--name", "healthcheck-app-999", "--publish", "3999:3000", "--label", "service=healthcheck-app", "-e", "KAMAL_CONTAINER_NAME=\"healthcheck-app\"", "--env-file", ".kamal/env/roles/app-web.env", "--health-cmd", "\"curl -f http://localhost:3000/up || exit 1\"", "--health-interval", "\"1s\"", "dhh/app:999") - SSHKit::Backend::Abstract.any_instance.stubs(:execute) - .with(:docker, :container, :ls, "--all", "--filter", "name=^healthcheck-app-999$", "--quiet", "|", :xargs, :docker, :container, :rm, raise_on_non_zero_exit: false) - - # Fail twice to test retry logic - SSHKit::Backend::Abstract.any_instance.stubs(:capture_with_info) - .with(:docker, :container, :ls, "--all", "--filter", "name=^healthcheck-app-999$", "--quiet", "|", :xargs, :docker, :inspect, "--format", "'{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}'") - .returns("starting") - .then - .returns("unhealthy") - .then - .returns("healthy") - - run_command("perform").tap do |output| - assert_match "container not ready (starting), retrying in 1s (attempt 1/7)...", output - assert_match "container not ready (unhealthy), retrying in 2s (attempt 2/7)...", output - assert_match "Container is healthy!", output - end - end - - test "perform failing to become healthy" do - # Prevent expected failures from outputting to terminal - Thread.report_on_exception = false - - Kamal::Cli::Healthcheck::Poller.stubs(:sleep) # No sleeping when retrying - - SSHKit::Backend::Abstract.any_instance.stubs(:execute) - .with(:docker, :container, :ls, "--all", "--filter", "name=^healthcheck-app-999$", "--quiet", "|", :xargs, :docker, :stop, raise_on_non_zero_exit: false) - SSHKit::Backend::Abstract.any_instance.stubs(:execute) - .with(:docker, :run, "--detach", "--name", "healthcheck-app-999", "--publish", "3999:3000", "--label", "service=healthcheck-app", "-e", "KAMAL_CONTAINER_NAME=\"healthcheck-app\"", "--env-file", ".kamal/env/roles/app-web.env", "--health-cmd", "\"curl -f http://localhost:3000/up || exit 1\"", "--health-interval", "\"1s\"", "dhh/app:999") - SSHKit::Backend::Abstract.any_instance.stubs(:execute) - .with(:docker, :container, :ls, "--all", "--filter", "name=^healthcheck-app-999$", "--quiet", "|", :xargs, :docker, :container, :rm, raise_on_non_zero_exit: false) - - # Continually report unhealthy - SSHKit::Backend::Abstract.any_instance.stubs(:capture_with_info) - .with(:docker, :container, :ls, "--all", "--filter", "name=^healthcheck-app-999$", "--quiet", "|", :xargs, :docker, :inspect, "--format", "'{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}'") - .returns("unhealthy") - - # Capture logs when failing - SSHKit::Backend::Abstract.any_instance.stubs(:capture_with_info) - .with(:docker, :container, :ls, "--all", "--filter", "name=^healthcheck-app-999$", "--quiet", "|", :xargs, :docker, :logs, "--tail", 50, "2>&1") - .returns("some log output") - - # Capture container health log when failing - SSHKit::Backend::Abstract.any_instance.stubs(:capture_with_pretty_json) - .with(:docker, :container, :ls, "--all", "--filter", "name=^healthcheck-app-999$", "--quiet", "|", :xargs, :docker, :inspect, "--format", "'{{json .State.Health}}'") - .returns('{"Status":"unhealthy","Log":[{"ExitCode": 1,"Output": "/bin/sh: 1: curl: not found\n"}]}"') - - exception = assert_raises do - run_command("perform") - end - assert_match "container not ready (unhealthy)", exception.message - end - - test "raises an exception if primary does not have traefik" do - SSHKit::Backend::Abstract.any_instance.expects(:execute).never - - exception = assert_raises do - run_command("perform", config_file: "test/fixtures/deploy_workers_only.yml") - end - - assert_equal "The primary host is not configured to run Traefik", exception.message - end - - private - def run_command(*command, config_file: "test/fixtures/deploy_with_accessories.yml") - stdouted { Kamal::Cli::Healthcheck.start([ *command, "-c", config_file ]) } - end -end diff --git a/test/cli/main_test.rb b/test/cli/main_test.rb index 6149cf0ba..3f7c52155 100644 --- a/test/cli/main_test.rb +++ b/test/cli/main_test.rb @@ -27,7 +27,6 @@ class CliMainTest < CliTestCase Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:registry:login", [], invoke_options) Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:build:pull", [], invoke_options) Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:traefik:boot", [], invoke_options) - Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:healthcheck:perform", [], invoke_options) 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:prune:all", [], invoke_options) @@ -40,7 +39,6 @@ class CliMainTest < CliTestCase assert_match /Log into image registry/, output assert_match /Pull app image/, output assert_match /Ensure Traefik is running/, output - assert_match /Ensure app can pass healthcheck/, output assert_match /Detect stale containers/, output assert_match /Prune old containers and images/, output assert_match /Releasing the deploy lock/, output @@ -53,7 +51,6 @@ class CliMainTest < CliTestCase Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:registry:login", [], invoke_options) Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:build:deliver", [], invoke_options) Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:traefik:boot", [], invoke_options) - Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:healthcheck:perform", [], invoke_options) 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:prune:all", [], invoke_options) @@ -67,7 +64,6 @@ class CliMainTest < CliTestCase assert_match /Build and push app image/, output assert_hook_ran "pre-deploy", output, **hook_variables assert_match /Ensure Traefik is running/, output - assert_match /Ensure app can pass healthcheck/, output assert_match /Detect stale containers/, output assert_match /Prune old containers and images/, output assert_hook_ran "post-deploy", output, **hook_variables, runtime: true @@ -80,7 +76,6 @@ class CliMainTest < CliTestCase Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:registry:login", [], invoke_options) Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:build:pull", [], invoke_options) Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:traefik:boot", [], invoke_options) - Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:healthcheck:perform", [], invoke_options) 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:prune:all", [], invoke_options) @@ -90,7 +85,6 @@ class CliMainTest < CliTestCase assert_match /Log into image registry/, output assert_match /Pull app image/, output assert_match /Ensure Traefik is running/, output - assert_match /Ensure app can pass healthcheck/, output assert_match /Detect stale containers/, output assert_match /Prune old containers and images/, output assert_match /Releasing the deploy lock/, output @@ -156,7 +150,6 @@ class CliMainTest < CliTestCase Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:registry:login", [], invoke_options) Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:build:deliver", [], invoke_options) Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:traefik:boot", [], invoke_options) - Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:healthcheck:perform", [], invoke_options) 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:prune:all", [], invoke_options) @@ -187,7 +180,6 @@ class CliMainTest < CliTestCase Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:registry:login", [], invoke_options) Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:build:deliver", [], invoke_options) Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:traefik:boot", [], invoke_options) - Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:healthcheck:perform", [], invoke_options) 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:prune:all", [], invoke_options) @@ -199,7 +191,6 @@ class CliMainTest < CliTestCase invoke_options = { "config_file" => "test/fixtures/deploy_simple.yml", "version" => "999", "skip_hooks" => false, "verbose" => true } Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:build:deliver", [], invoke_options) - Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:healthcheck:perform", [], invoke_options) 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) @@ -212,7 +203,6 @@ class CliMainTest < CliTestCase assert_match /Build and push app image/, output assert_hook_ran "pre-deploy", output, **hook_variables assert_match /Running the pre-deploy hook.../, output - assert_match /Ensure app can pass healthcheck/, output assert_hook_ran "post-deploy", output, **hook_variables, runtime: true end end @@ -221,13 +211,11 @@ 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:build:pull", [], invoke_options) - Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:healthcheck:perform", [], invoke_options) 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) run_command("redeploy", "--skip_push").tap do |output| assert_match /Pull app image/, output - assert_match /Ensure app can pass healthcheck/, output end end diff --git a/test/commander_test.rb b/test/commander_test.rb index 2bbdb4790..6a7ec536e 100644 --- a/test/commander_test.rb +++ b/test/commander_test.rb @@ -99,6 +99,11 @@ class CommanderTest < ActiveSupport::TestCase assert_equal [ "workers" ], @kamal.roles_on("1.1.1.3").map(&:name) end + test "roles_on web comes first" do + configure_with(:deploy_with_two_roles_one_host) + assert_equal [ "web", "workers" ], @kamal.roles_on("1.1.1.1").map(&:name) + end + test "default group strategy" do assert_empty @kamal.boot_strategy end diff --git a/test/commands/healthcheck_test.rb b/test/commands/healthcheck_test.rb deleted file mode 100644 index 829aa0b2f..000000000 --- a/test/commands/healthcheck_test.rb +++ /dev/null @@ -1,124 +0,0 @@ -require "test_helper" - -class CommandsHealthcheckTest < ActiveSupport::TestCase - setup do - @config = { - service: "app", image: "dhh/app", registry: { "username" => "dhh", "password" => "secret" }, servers: [ "1.1.1.1" ], - traefik: { "args" => { "accesslog.format" => "json", "metrics.prometheus.buckets" => "0.1,0.3,1.2,5.0" } } - } - end - - test "run" do - assert_equal \ - "docker run --detach --name healthcheck-app-123 --publish 3999:3000 --label service=healthcheck-app -e KAMAL_CONTAINER_NAME=\"healthcheck-app\" --env-file .kamal/env/roles/app-web.env --health-cmd \"curl -f http://localhost:3000/up || exit 1\" --health-interval \"1s\" dhh/app:123", - new_command.run.join(" ") - end - - test "run with custom port" do - @config[:healthcheck] = { "port" => 3001 } - - assert_equal \ - "docker run --detach --name healthcheck-app-123 --publish 3999:3001 --label service=healthcheck-app -e KAMAL_CONTAINER_NAME=\"healthcheck-app\" --env-file .kamal/env/roles/app-web.env --health-cmd \"curl -f http://localhost:3001/up || exit 1\" --health-interval \"1s\" dhh/app:123", - new_command.run.join(" ") - end - - test "run with destination" do - @destination = "staging" - - assert_equal \ - "docker run --detach --name healthcheck-app-staging-123 --publish 3999:3000 --label service=healthcheck-app-staging -e KAMAL_CONTAINER_NAME=\"healthcheck-app-staging\" --env-file .kamal/env/roles/app-web-staging.env --health-cmd \"curl -f http://localhost:3000/up || exit 1\" --health-interval \"1s\" dhh/app:123", - new_command.run.join(" ") - end - - test "run with custom healthcheck" do - @config[:healthcheck] = { "cmd" => "/bin/up" } - - assert_equal \ - "docker run --detach --name healthcheck-app-123 --publish 3999:3000 --label service=healthcheck-app -e KAMAL_CONTAINER_NAME=\"healthcheck-app\" --env-file .kamal/env/roles/app-web.env --health-cmd \"/bin/up\" --health-interval \"1s\" dhh/app:123", - new_command.run.join(" ") - end - - test "run with custom options" do - @config[:servers] = { "web" => { "hosts" => [ "1.1.1.1" ], "options" => { "mount" => "somewhere" } } } - @config[:healthcheck] = { "exposed_port" => 4999 } - assert_equal \ - "docker run --detach --name healthcheck-app-123 --publish 4999:3000 --label service=healthcheck-app -e KAMAL_CONTAINER_NAME=\"healthcheck-app\" --env-file .kamal/env/roles/app-web.env --health-cmd \"curl -f http://localhost:3000/up || exit 1\" --health-interval \"1s\" --mount \"somewhere\" dhh/app:123", - new_command.run.join(" ") - end - - test "run with tags" do - @config[:servers] = [ { "1.1.1.1" => "tag1" } ] - @config[:env] = {} - @config[:env]["tags"] = { "tag1" => { "ENV1" => "value1" } } - - assert_equal \ - "docker run --detach --name healthcheck-app-123 --publish 3999:3000 --label service=healthcheck-app -e KAMAL_CONTAINER_NAME=\"healthcheck-app\" --env-file .kamal/env/roles/app-web.env --env ENV1=\"value1\" --health-cmd \"curl -f http://localhost:3000/up || exit 1\" --health-interval \"1s\" dhh/app:123", - new_command.run.join(" ") - end - - test "status" do - assert_equal \ - "docker container ls --all --filter name=^healthcheck-app-123$ --quiet | xargs docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}'", - new_command.status.join(" ") - end - - test "container_health_log" do - assert_equal \ - "docker container ls --all --filter name=^healthcheck-app-123$ --quiet | xargs docker inspect --format '{{json .State.Health}}'", - new_command.container_health_log.join(" ") - end - - test "stop" do - assert_equal \ - "docker container ls --all --filter name=^healthcheck-app-123$ --quiet | xargs docker stop", - new_command.stop.join(" ") - end - - test "stop with destination" do - @destination = "staging" - - assert_equal \ - "docker container ls --all --filter name=^healthcheck-app-staging-123$ --quiet | xargs docker stop", - new_command.stop.join(" ") - end - - test "remove" do - assert_equal \ - "docker container ls --all --filter name=^healthcheck-app-123$ --quiet | xargs docker container rm", - new_command.remove.join(" ") - end - - test "remove with destination" do - @destination = "staging" - - assert_equal \ - "docker container ls --all --filter name=^healthcheck-app-staging-123$ --quiet | xargs docker container rm", - new_command.remove.join(" ") - end - - test "logs" do - assert_equal \ - "docker container ls --all --filter name=^healthcheck-app-123$ --quiet | xargs docker logs --tail 50 2>&1", - new_command.logs.join(" ") - end - - test "logs with custom lines number" do - @config[:healthcheck] = { "log_lines" => 150 } - assert_equal \ - "docker container ls --all --filter name=^healthcheck-app-123$ --quiet | xargs docker logs --tail 150 2>&1", - new_command.logs.join(" ") - end - - test "logs with destination" do - @destination = "staging" - - assert_equal \ - "docker container ls --all --filter name=^healthcheck-app-staging-123$ --quiet | xargs docker logs --tail 50 2>&1", - new_command.logs.join(" ") - end - - private - def new_command - Kamal::Commands::Healthcheck.new(Kamal::Configuration.new(@config, destination: @destination, version: "123")) - end -end diff --git a/test/configuration_test.rb b/test/configuration_test.rb index 6a86b2314..e7e7f1511 100644 --- a/test/configuration_test.rb +++ b/test/configuration_test.rb @@ -272,7 +272,7 @@ class ConfigurationTest < ActiveSupport::TestCase volume_args: [ "--volume", "/local/path:/container/path" ], builder: {}, logging: [ "--log-opt", "max-size=\"10m\"" ], - healthcheck: { "path"=>"/up", "port"=>3000, "max_attempts" => 7, "exposed_port" => 3999, "cord" => "/tmp/kamal-cord", "log_lines" => 50 } } + healthcheck: { "path"=>"/up", "port"=>3000, "max_attempts" => 7, "cord" => "/tmp/kamal-cord", "log_lines" => 50 } } assert_equal expected_config, @config.to_h end diff --git a/test/fixtures/deploy_with_two_roles_one_host.yml b/test/fixtures/deploy_with_two_roles_one_host.yml new file mode 100644 index 000000000..cae054698 --- /dev/null +++ b/test/fixtures/deploy_with_two_roles_one_host.yml @@ -0,0 +1,15 @@ +service: app +image: dhh/app +servers: + workers: + hosts: + - 1.1.1.1 + web: + hosts: + - 1.1.1.1 +env: + REDIS_URL: redis://x/y +registry: + server: registry.digitalocean.com + username: user + password: pw diff --git a/test/integration/broken_deploy_test.rb b/test/integration/broken_deploy_test.rb new file mode 100644 index 000000000..06a5e3c6d --- /dev/null +++ b/test/integration/broken_deploy_test.rb @@ -0,0 +1,24 @@ +require_relative "integration_test" + +class BrokenDeployTest < IntegrationTest + test "deploying a bad image" do + @app = "app_with_roles" + + kamal :envify + + first_version = latest_app_version + + kamal :deploy + + assert_app_is_up version: first_version + assert_container_running host: :vm3, name: "app-workers-#{first_version}" + + second_version = break_app + + kamal :deploy, raise_on_error: false + + assert_app_is_up version: first_version + assert_container_running host: :vm3, name: "app-workers-#{first_version}" + assert_container_not_running host: :vm3, name: "app-workers-#{second_version}" + end +end diff --git a/test/integration/docker/deployer/app/config/deploy.yml b/test/integration/docker/deployer/app/config/deploy.yml index 38113737e..397a49ca9 100644 --- a/test/integration/docker/deployer/app/config/deploy.yml +++ b/test/integration/docker/deployer/app/config/deploy.yml @@ -28,6 +28,7 @@ builder: COMMIT_SHA: <%= `git rev-parse HEAD` %> healthcheck: cmd: wget -qO- http://localhost > /dev/null || exit 1 + max_attempts: 3 traefik: args: accesslog: true @@ -41,3 +42,4 @@ accessories: roles: - web stop_wait_time: 1 +readiness_delay: 0 diff --git a/test/integration/docker/deployer/app_with_roles/config/deploy.yml b/test/integration/docker/deployer/app_with_roles/config/deploy.yml index 004ffb250..2cf362c66 100644 --- a/test/integration/docker/deployer/app_with_roles/config/deploy.yml +++ b/test/integration/docker/deployer/app_with_roles/config/deploy.yml @@ -22,6 +22,7 @@ builder: COMMIT_SHA: <%= `git rev-parse HEAD` %> healthcheck: cmd: wget -qO- http://localhost > /dev/null || exit 1 + max_attempts: 3 traefik: args: accesslog: true @@ -35,3 +36,4 @@ accessories: roles: - web stop_wait_time: 1 +readiness_delay: 0 diff --git a/test/integration/docker/deployer/break_app.sh b/test/integration/docker/deployer/break_app.sh new file mode 100755 index 000000000..e8b19044d --- /dev/null +++ b/test/integration/docker/deployer/break_app.sh @@ -0,0 +1,3 @@ +#!/bin/bash + +cd $1 && echo "bad nginx config" > default.conf && git commit -am 'Broken' diff --git a/test/integration/integration_test.rb b/test/integration/integration_test.rb index bd62c4746..f8f54d424 100644 --- a/test/integration/integration_test.rb +++ b/test/integration/integration_test.rb @@ -78,6 +78,11 @@ def update_app_rev latest_app_version end + def break_app + deployer_exec "./break_app.sh #{@app}", workdir: "/" + latest_app_version + end + def latest_app_version deployer_exec("git rev-parse HEAD", capture: true) end @@ -131,4 +136,16 @@ def debug_response_code(app_response, expected_code) puts "Tried to get the response code again and got #{app_response.code}" end end + + def assert_container_running(host:, name:) + assert container_running?(host: host, name: name) + end + + def assert_container_not_running(host:, name:) + assert_not container_running?(host: host, name: name) + end + + def container_running?(host:, name:) + docker_compose("exec #{host} docker ps --filter=name=#{name} | tail -n+2", capture: true).tap { |x| p [ x, x.strip, x.strip.present? ] }.strip.present? + end end diff --git a/test/integration/main_test.rb b/test/integration/main_test.rb index a2dc8a0ca..62857d4ad 100644 --- a/test/integration/main_test.rb +++ b/test/integration/main_test.rb @@ -56,6 +56,12 @@ class MainTest < IntegrationTest assert_app_is_up version: version assert_hooks_ran "pre-connect", "pre-build", "pre-deploy", "post-deploy" assert_container_running host: :vm3, name: "app-workers-#{version}" + + second_version = update_app_rev + + kamal :redeploy + assert_app_is_up version: second_version + assert_container_running host: :vm3, name: "app-workers-#{second_version}" end test "config" do @@ -73,7 +79,7 @@ class MainTest < IntegrationTest assert_equal({ user: "root", port: 22, keepalive: true, keepalive_interval: 30, log_level: :fatal }, config[:ssh_options]) assert_equal({ "multiarch" => false, "args" => { "COMMIT_SHA" => version } }, config[:builder]) assert_equal [ "--log-opt", "max-size=\"10m\"" ], config[:logging] - assert_equal({ "path" => "/up", "port" => 3000, "max_attempts" => 7, "exposed_port" => 3999, "cord"=>"/tmp/kamal-cord", "log_lines" => 50, "cmd"=>"wget -qO- http://localhost > /dev/null || exit 1" }, config[:healthcheck]) + assert_equal({ "path" => "/up", "port" => 3000, "max_attempts" => 3, "cord"=>"/tmp/kamal-cord", "log_lines" => 50, "cmd"=>"wget -qO- http://localhost > /dev/null || exit 1" }, config[:healthcheck]) end test "setup and remove" do @@ -157,8 +163,4 @@ def assert_images_and_containers assert vm1_image_ids.any? assert vm1_container_ids.any? end - - def assert_container_running(host:, name:) - assert docker_compose("exec #{host} docker ps --filter=name=#{name} -q", capture: true).strip.present? - end end From 07c56583961bcd559bde3595ffba831368dfa6fc Mon Sep 17 00:00:00 2001 From: Donal McBreen Date: Wed, 3 Apr 2024 16:15:27 +0100 Subject: [PATCH 30/71] Remove redundant method --- lib/kamal/cli/app.rb | 4 ---- 1 file changed, 4 deletions(-) diff --git a/lib/kamal/cli/app.rb b/lib/kamal/cli/app.rb index ae84e3680..390f6a7df 100644 --- a/lib/kamal/cli/app.rb +++ b/lib/kamal/cli/app.rb @@ -287,8 +287,4 @@ def current_running_version(host: KAMAL.primary_host) def version_or_latest options[:version] || KAMAL.config.latest_tag end - - def web_and_non_web_roles? - KAMAL.roles.any?(&:running_traefik?) && !KAMAL.roles.all?(&:running_traefik?) - end end From 5be6fa3b4e05fb2ba14eb0a991956c417d0aa359 Mon Sep 17 00:00:00 2001 From: Donal McBreen Date: Wed, 3 Apr 2024 16:24:25 +0100 Subject: [PATCH 31/71] Improve comments --- lib/kamal/cli/app.rb | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/kamal/cli/app.rb b/lib/kamal/cli/app.rb index 390f6a7df..14cf54adf 100644 --- a/lib/kamal/cli/app.rb +++ b/lib/kamal/cli/app.rb @@ -14,15 +14,16 @@ def boot end end + #  Primary hosts and roles are returned first, so they can open the barrier barrier = Kamal::Cli::Healthcheck::Barrier.new if KAMAL.roles.many? on(KAMAL.hosts, **KAMAL.boot_strategy) do |host| - # Ensure primary role is booted first to allow the web barrier to be opened KAMAL.roles_on(host).each do |role| Kamal::Cli::App::Boot.new(host, role, self, version, barrier).run end end + #  Tag once the app booted on all hosts on(KAMAL.hosts) do |host| execute *KAMAL.auditor.record("Tagging #{KAMAL.config.absolute_image} as the latest image"), verbosity: :debug execute *KAMAL.app.tag_latest_image From 773ba3a5ab774dd696abf6c7dfe9584a7d4aafb8 Mon Sep 17 00:00:00 2001 From: Donal McBreen Date: Tue, 7 May 2024 17:07:21 +0100 Subject: [PATCH 32/71] Show container logs and healthcheck status on failure --- lib/kamal/cli/app.rb | 2 +- lib/kamal/cli/app/boot.rb | 14 +++++++------- lib/kamal/commands/app/containers.rb | 8 ++++++++ lib/kamal/commands/app/logging.rb | 4 ++-- lib/kamal/commands/base.rb | 1 - test/cli/app_test.rb | 4 ++-- test/integration/broken_deploy_test.rb | 12 +++++++++++- 7 files changed, 31 insertions(+), 14 deletions(-) diff --git a/lib/kamal/cli/app.rb b/lib/kamal/cli/app.rb index 14cf54adf..356b09f89 100644 --- a/lib/kamal/cli/app.rb +++ b/lib/kamal/cli/app.rb @@ -15,7 +15,7 @@ def boot end #  Primary hosts and roles are returned first, so they can open the barrier - barrier = Kamal::Cli::Healthcheck::Barrier.new if KAMAL.roles.many? + barrier = Kamal::Cli::Healthcheck::Barrier.new on(KAMAL.hosts, **KAMAL.boot_strategy) do |host| KAMAL.roles_on(host).each do |role| diff --git a/lib/kamal/cli/app/boot.rb b/lib/kamal/cli/app/boot.rb index de219a23d..e9b167fe0 100644 --- a/lib/kamal/cli/app/boot.rb +++ b/lib/kamal/cli/app/boot.rb @@ -1,6 +1,6 @@ class Kamal::Cli::App::Boot attr_reader :host, :role, :version, :barrier, :sshkit - delegate :execute, :capture_with_info, :info, to: :sshkit + delegate :execute, :capture_with_info, :capture_with_pretty_json, :info, :error, to: :sshkit delegate :uses_cord?, :assets?, :running_traefik?, to: :role def initialize(host, role, sshkit, version, barrier) @@ -55,7 +55,11 @@ def start_new_version reach_barrier rescue => e - close_barrier if barrier_role? + if barrier_role? && barrier.close + info "Deploy failed, so closed barrier (#{host})" + error capture_with_info(*app.logs(version: version)) + error capture_with_info(*app.container_health_log(version: version)) + end execute *app.stop(version: version), raise_on_non_zero_exit: false raise @@ -92,14 +96,10 @@ def wait_for_barrier barrier.wait info "Barrier opened (#{host})" rescue Kamal::Cli::Healthcheck::Error - info "Barrier closed, shutting down new container... (#{host})" + info "Barrier closed, shutting down new container (#{host})..." raise end - def close_barrier - barrier&.close - end - def barrier_role? role == KAMAL.primary_role end diff --git a/lib/kamal/commands/app/containers.rb b/lib/kamal/commands/app/containers.rb index a62d9a35a..0bab388b8 100644 --- a/lib/kamal/commands/app/containers.rb +++ b/lib/kamal/commands/app/containers.rb @@ -1,4 +1,6 @@ module Kamal::Commands::App::Containers + DOCKER_HEALTH_LOG_FORMAT = "'{{json .State.Health}}'" + def list_containers docker :container, :ls, "--all", *filter_args end @@ -20,4 +22,10 @@ def rename_container(version:, new_version:) def remove_containers docker :container, :prune, "--force", *filter_args end + + def container_health_log(version:) + pipe \ + container_id_for(container_name: container_name(version)), + xargs(docker(:inspect, "--format", DOCKER_HEALTH_LOG_FORMAT)) + end end diff --git a/lib/kamal/commands/app/logging.rb b/lib/kamal/commands/app/logging.rb index 7e7605122..8acb49e97 100644 --- a/lib/kamal/commands/app/logging.rb +++ b/lib/kamal/commands/app/logging.rb @@ -1,7 +1,7 @@ module Kamal::Commands::App::Logging - def logs(since: nil, lines: nil, grep: nil) + def logs(version: nil, since: nil, lines: nil, grep: nil) pipe \ - current_running_container_id, + version ? container_id_for_version(version) : current_running_container_id, "xargs docker logs#{" --since #{since}" if since}#{" --tail #{lines}" if lines} 2>&1", ("grep '#{grep}'" if grep) end diff --git a/lib/kamal/commands/base.rb b/lib/kamal/commands/base.rb index a98d6330f..2173d0644 100644 --- a/lib/kamal/commands/base.rb +++ b/lib/kamal/commands/base.rb @@ -3,7 +3,6 @@ class Base delegate :sensitive, :argumentize, to: Kamal::Utils DOCKER_HEALTH_STATUS_FORMAT = "'{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}'" - DOCKER_HEALTH_LOG_FORMAT = "'{{json .State.Health}}'" attr_accessor :config diff --git a/test/cli/app_test.rb b/test/cli/app_test.rb index ced41317d..3d6061b54 100644 --- a/test/cli/app_test.rb +++ b/test/cli/app_test.rb @@ -162,8 +162,8 @@ class CliAppTest < CliTestCase run_command("boot", config: :with_roles, host: nil, allow_execute_error: true).tap do |output| assert_match "Waiting at web barrier (1.1.1.3)...", output assert_match "Waiting at web barrier (1.1.1.4)...", output - assert_match "Barrier closed, shutting down new container... (1.1.1.3)", output - assert_match "Barrier closed, shutting down new container... (1.1.1.4)", output + assert_match "Barrier closed, shutting down new container (1.1.1.3)...", output + assert_match "Barrier closed, shutting down new container (1.1.1.4)...", output assert_match "Running docker container ls --all --filter name=^app-web-latest$ --quiet | xargs docker stop on 1.1.1.1", output assert_match "Running docker container ls --all --filter name=^app-web-latest$ --quiet | xargs docker stop on 1.1.1.2", output assert_match "Running docker container ls --all --filter name=^app-workers-latest$ --quiet | xargs docker stop on 1.1.1.3", output diff --git a/test/integration/broken_deploy_test.rb b/test/integration/broken_deploy_test.rb index 06a5e3c6d..efa90fc90 100644 --- a/test/integration/broken_deploy_test.rb +++ b/test/integration/broken_deploy_test.rb @@ -15,10 +15,20 @@ class BrokenDeployTest < IntegrationTest second_version = break_app - kamal :deploy, raise_on_error: false + output = kamal :deploy, raise_on_error: false, capture: true + assert_failed_deploy output assert_app_is_up version: first_version assert_container_running host: :vm3, name: "app-workers-#{first_version}" assert_container_not_running host: :vm3, name: "app-workers-#{second_version}" end + + private + def assert_failed_deploy(output) + assert_match "Waiting at web barrier (vm3)...", output + assert_match /Deploy failed, so closed barrier \(vm[12]\)/, output + assert_match "Barrier closed, shutting down new container (vm3)...", output + assert_match "nginx: [emerg] unexpected end of file, expecting \";\" or \"}\" in /etc/nginx/conf.d/default.conf:2", output + assert_match 'ERROR {"Status":"unhealthy","FailingStreak":0,"Log":[]}', output + end end From bb2ca81d87e6860e47cd9f7539cb0db2fd083e45 Mon Sep 17 00:00:00 2001 From: Donal McBreen Date: Mon, 20 May 2024 11:26:22 +0100 Subject: [PATCH 33/71] Fix rebase method duplication --- lib/kamal/cli/app/boot.rb | 14 +------------- 1 file changed, 1 insertion(+), 13 deletions(-) diff --git a/lib/kamal/cli/app/boot.rb b/lib/kamal/cli/app/boot.rb index e9b167fe0..a91c6ffaf 100644 --- a/lib/kamal/cli/app/boot.rb +++ b/lib/kamal/cli/app/boot.rb @@ -22,18 +22,6 @@ def run end private - def app - @app ||= KAMAL.app(role: role, host: host) - end - - def auditor - @auditor = KAMAL.auditor(role: role) - end - - def audit(message) - execute *auditor.record(message), verbosity: :debug - end - def old_version_renamed_if_clashing if capture_with_info(*app.container_id_for_version(version), raise_on_non_zero_exit: false).present? renamed_version = "#{version}_replaced_#{SecureRandom.hex(8)}" @@ -105,7 +93,7 @@ def barrier_role? end def app - @app ||= KAMAL.app(role: role) + @app ||= KAMAL.app(role: role, host: host) end def auditor From ee758d951af5c513a9b1b272dc39393a048936cd Mon Sep 17 00:00:00 2001 From: Donal McBreen Date: Mon, 20 May 2024 12:17:01 +0100 Subject: [PATCH 34/71] Only use barrier when needed, more descriptive info --- lib/kamal/cli/app.rb | 2 +- lib/kamal/cli/app/boot.rb | 12 ++++++------ test/cli/app_test.rb | 16 ++++++++-------- test/integration/broken_deploy_test.rb | 6 +++--- 4 files changed, 18 insertions(+), 18 deletions(-) diff --git a/lib/kamal/cli/app.rb b/lib/kamal/cli/app.rb index 356b09f89..14cf54adf 100644 --- a/lib/kamal/cli/app.rb +++ b/lib/kamal/cli/app.rb @@ -15,7 +15,7 @@ def boot end #  Primary hosts and roles are returned first, so they can open the barrier - barrier = Kamal::Cli::Healthcheck::Barrier.new + barrier = Kamal::Cli::Healthcheck::Barrier.new if KAMAL.roles.many? on(KAMAL.hosts, **KAMAL.boot_strategy) do |host| KAMAL.roles_on(host).each do |role| diff --git a/lib/kamal/cli/app/boot.rb b/lib/kamal/cli/app/boot.rb index a91c6ffaf..5100e3ccb 100644 --- a/lib/kamal/cli/app/boot.rb +++ b/lib/kamal/cli/app/boot.rb @@ -43,8 +43,8 @@ def start_new_version reach_barrier rescue => e - if barrier_role? && barrier.close - info "Deploy failed, so closed barrier (#{host})" + if barrier_role? && barrier&.close + info "First #{KAMAL.primary_role} container unhealthy, stopping other roles (#{host})" error capture_with_info(*app.logs(version: version)) error capture_with_info(*app.container_health_log(version: version)) end @@ -71,7 +71,7 @@ def reach_barrier if barrier if barrier_role? if barrier.open - info "Opened barrier (#{host})" + info "First #{KAMAL.primary_role} container healthy, continuing other roles (#{host})" end else wait_for_barrier @@ -80,11 +80,11 @@ def reach_barrier end def wait_for_barrier - info "Waiting at web barrier (#{host})..." + info "Waiting for a healthy #{KAMAL.primary_role} container (#{host})..." barrier.wait - info "Barrier opened (#{host})" + info "First #{KAMAL.primary_role} container is healthy, continuing (#{host})" rescue Kamal::Cli::Healthcheck::Error - info "Barrier closed, shutting down new container (#{host})..." + info "First #{KAMAL.primary_role} container is unhealthy, stopping (#{host})" raise end diff --git a/test/cli/app_test.rb b/test/cli/app_test.rb index 3d6061b54..b5b03e274 100644 --- a/test/cli/app_test.rb +++ b/test/cli/app_test.rb @@ -136,10 +136,10 @@ class CliAppTest < CliTestCase .returns("running").at_least_once # workers health check run_command("boot", config: :with_roles, host: nil).tap do |output| - assert_match "Waiting at web barrier (1.1.1.3)...", output - assert_match "Waiting at web barrier (1.1.1.4)...", output - assert_match "Barrier opened (1.1.1.3)", output - assert_match "Barrier opened (1.1.1.4)", output + assert_match "Waiting for a healthy web container (1.1.1.3)...", output + assert_match "Waiting for a healthy web container (1.1.1.4)...", output + assert_match "First web container is healthy, continuing (1.1.1.3)", output + assert_match "First web container is healthy, continuing (1.1.1.4)", output end end @@ -160,10 +160,10 @@ class CliAppTest < CliTestCase stderred do run_command("boot", config: :with_roles, host: nil, allow_execute_error: true).tap do |output| - assert_match "Waiting at web barrier (1.1.1.3)...", output - assert_match "Waiting at web barrier (1.1.1.4)...", output - assert_match "Barrier closed, shutting down new container (1.1.1.3)...", output - assert_match "Barrier closed, shutting down new container (1.1.1.4)...", output + assert_match "Waiting for a healthy web container (1.1.1.3)...", output + assert_match "Waiting for a healthy web container (1.1.1.4)...", output + assert_match "First web container is unhealthy, stopping (1.1.1.3)", output + assert_match "First web container is unhealthy, stopping (1.1.1.4)", output assert_match "Running docker container ls --all --filter name=^app-web-latest$ --quiet | xargs docker stop on 1.1.1.1", output assert_match "Running docker container ls --all --filter name=^app-web-latest$ --quiet | xargs docker stop on 1.1.1.2", output assert_match "Running docker container ls --all --filter name=^app-workers-latest$ --quiet | xargs docker stop on 1.1.1.3", output diff --git a/test/integration/broken_deploy_test.rb b/test/integration/broken_deploy_test.rb index efa90fc90..6b71844e0 100644 --- a/test/integration/broken_deploy_test.rb +++ b/test/integration/broken_deploy_test.rb @@ -25,9 +25,9 @@ class BrokenDeployTest < IntegrationTest private def assert_failed_deploy(output) - assert_match "Waiting at web barrier (vm3)...", output - assert_match /Deploy failed, so closed barrier \(vm[12]\)/, output - assert_match "Barrier closed, shutting down new container (vm3)...", output + assert_match "Waiting for a healthy web container (vm3)...", output + assert_match /First #{KAMAL.primary_role} container is unhealthy, stopping \(vm[12]\)/, output + assert_match "First #{KAMAL.primary_role} container unhealthy, stopping other roles (vm3)...", output assert_match "nginx: [emerg] unexpected end of file, expecting \";\" or \"}\" in /etc/nginx/conf.d/default.conf:2", output assert_match 'ERROR {"Status":"unhealthy","FailingStreak":0,"Log":[]}', output end From 8a4f7163bb8ac6e29ccc7394138c1d15d4d4d531 Mon Sep 17 00:00:00 2001 From: Nick Hammond Date: Mon, 20 May 2024 11:15:14 -0700 Subject: [PATCH 35/71] Apply suggestions from code review Co-authored-by: Donal McBreen --- lib/kamal/cli/server.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/kamal/cli/server.rb b/lib/kamal/cli/server.rb index 58eed4af3..c89bee614 100644 --- a/lib/kamal/cli/server.rb +++ b/lib/kamal/cli/server.rb @@ -1,12 +1,12 @@ class Kamal::Cli::Server < Kamal::Cli::Base - desc "exec", "Run a custom command on the server(use --help to show options)" + desc "exec", "Run a custom command on the server (use --help to show options)" option :interactive, type: :boolean, aliases: "-i", default: false, desc: "Run the command interactively(use for console/bash)" def exec(cmd) hosts = KAMAL.hosts | KAMAL.accessory_hosts case when options[:interactive] - host = hosts.first + host = KAMAL.primary_host say "Running '#{cmd}' on #{host} interactively...", :magenta From 060e5d2027509876b8b6e712fc3654bf091f0b3b Mon Sep 17 00:00:00 2001 From: Donal McBreen Date: Tue, 21 May 2024 08:22:20 +0100 Subject: [PATCH 36/71] Update lib/kamal/cli/server.rb Co-authored-by: Sijawusz Pur Rahnama --- lib/kamal/cli/server.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/kamal/cli/server.rb b/lib/kamal/cli/server.rb index c89bee614..9b5dbfc13 100644 --- a/lib/kamal/cli/server.rb +++ b/lib/kamal/cli/server.rb @@ -1,6 +1,6 @@ class Kamal::Cli::Server < Kamal::Cli::Base desc "exec", "Run a custom command on the server (use --help to show options)" - option :interactive, type: :boolean, aliases: "-i", default: false, desc: "Run the command interactively(use for console/bash)" + option :interactive, type: :boolean, aliases: "-i", default: false, desc: "Run the command interactively (use for console/bash)" def exec(cmd) hosts = KAMAL.hosts | KAMAL.accessory_hosts From 78c0a0ba4b2093c2062a4b9336facece5da22f2e Mon Sep 17 00:00:00 2001 From: Donal McBreen Date: Tue, 21 May 2024 08:33:49 +0100 Subject: [PATCH 37/71] Don't start other roles we have a healthy container If a primary role container is unhealthy, we might take a while to timeout the health check poller. In the meantime if we have started the other roles, they'll be running tow containers. This could be a problem, especially if they read run jobs as that doubles the worker capacity which could cause exessive load. We'll wait for the first primary role container to boot successfully before starting the other containers from other roles. --- lib/kamal/cli/app/boot.rb | 41 ++++++++++++++++++++++++--------------- test/cli/app_test.rb | 4 ---- 2 files changed, 25 insertions(+), 20 deletions(-) diff --git a/lib/kamal/cli/app/boot.rb b/lib/kamal/cli/app/boot.rb index 5100e3ccb..47f8c21c4 100644 --- a/lib/kamal/cli/app/boot.rb +++ b/lib/kamal/cli/app/boot.rb @@ -34,6 +34,8 @@ def old_version_renamed_if_clashing end def start_new_version + wait_at_barrier if queuer? + audit "Booted app version #{version}" execute *app.tie_cord(role.cord_host_file) if uses_cord? @@ -41,13 +43,10 @@ def start_new_version execute *app.run(hostname: hostname) Kamal::Cli::Healthcheck::Poller.wait_for_healthy(pause_after_ready: true) { capture_with_info(*app.status(version: version)) } - reach_barrier + release_barrier if gatekeeper? rescue => e - if barrier_role? && barrier&.close - info "First #{KAMAL.primary_role} container unhealthy, stopping other roles (#{host})" - error capture_with_info(*app.logs(version: version)) - error capture_with_info(*app.container_health_log(version: version)) - end + close_barrier if gatekeeper? + execute *app.stop(version: version), raise_on_non_zero_exit: false raise @@ -67,19 +66,13 @@ def stop_old_version(version) execute *app.clean_up_assets if assets? end - def reach_barrier - if barrier - if barrier_role? - if barrier.open - info "First #{KAMAL.primary_role} container healthy, continuing other roles (#{host})" - end - else - wait_for_barrier - end + def release_barrier + if barrier.open + info "First #{KAMAL.primary_role} container healthy, continuing other roles (#{host})" end end - def wait_for_barrier + def wait_at_barrier info "Waiting for a healthy #{KAMAL.primary_role} container (#{host})..." barrier.wait info "First #{KAMAL.primary_role} container is healthy, continuing (#{host})" @@ -88,6 +81,14 @@ def wait_for_barrier raise end + def close_barrier + if barrier.close + info "First #{KAMAL.primary_role} container unhealthy, stopping other roles (#{host})" + error capture_with_info(*app.logs(version: version)) + error capture_with_info(*app.container_health_log(version: version)) + end + end + def barrier_role? role == KAMAL.primary_role end @@ -103,4 +104,12 @@ def auditor def audit(message) execute *auditor.record(message), verbosity: :debug end + + def gatekeeper? + barrier && barrier_role? + end + + def queuer? + barrier && !barrier_role? + end end diff --git a/test/cli/app_test.rb b/test/cli/app_test.rb index b5b03e274..f684deb8c 100644 --- a/test/cli/app_test.rb +++ b/test/cli/app_test.rb @@ -154,10 +154,6 @@ class CliAppTest < CliTestCase .with(:docker, :container, :ls, "--all", "--filter", "name=^app-web-latest$", "--quiet", "|", :xargs, :docker, :inspect, "--format", "'{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}'") .returns("unhealthy").at_least_once # web health check failing - SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info) - .with(:docker, :container, :ls, "--all", "--filter", "name=^app-workers-latest$", "--quiet", "|", :xargs, :docker, :inspect, "--format", "'{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}'") - .returns("running").at_least_once # workers health check passing - stderred do run_command("boot", config: :with_roles, host: nil, allow_execute_error: true).tap do |output| assert_match "Waiting for a healthy web container (1.1.1.3)...", output From fa7e941648ce78e2ee46cb4e9b567078072557d6 Mon Sep 17 00:00:00 2001 From: Donal McBreen Date: Tue, 21 May 2024 09:31:08 +0100 Subject: [PATCH 38/71] Make SSHKit::Runner::Parallel fail slow Using a different SSHKit runner doesn't work well, because the group runner uses the Parallel runner internally. So instead we'll patch its behaviour to fail slow. We'll also get it to return all the errors so we can report on all the hosts that failed. --- bin/kamal | 9 ++++++ lib/kamal/commander.rb | 1 - lib/kamal/sshkit_with_ext.rb | 59 ++++++++++++++++++++++-------------- 3 files changed, 45 insertions(+), 24 deletions(-) diff --git a/bin/kamal b/bin/kamal index 96c0dc640..a8e2ac2b9 100755 --- a/bin/kamal +++ b/bin/kamal @@ -7,6 +7,15 @@ require "kamal" begin Kamal::Cli::Main.start(ARGV) +rescue SSHKit::Runner::MultipleExecuteError => e + e.execute_errors.each do |execute_error| + puts " \e[31mERROR (#{execute_error.cause.class}): #{execute_error.message}\e[0m" + end + if ENV["VERBOSE"] + puts "Backtrace for the first error:" + puts e.execute_errors.first.cause.backtrace + end + exit 1 rescue SSHKit::Runner::ExecuteError => e puts " \e[31mERROR (#{e.cause.class}): #{e.message}\e[0m" puts e.cause.backtrace if ENV["VERBOSE"] diff --git a/lib/kamal/commander.rb b/lib/kamal/commander.rb index f4c54215c..e7c5d21f0 100644 --- a/lib/kamal/commander.rb +++ b/lib/kamal/commander.rb @@ -150,7 +150,6 @@ def configure_sshkit_with(config) sshkit.max_concurrent_starts = config.sshkit.max_concurrent_starts sshkit.ssh_options = config.ssh.options end - SSHKit.config.default_runner = SSHKit::Runner::ParallelCompleteAll SSHKit.config.command_map[:docker] = "docker" # No need to use /usr/bin/env, just clogs up the logs SSHKit.config.output_verbosity = verbosity end diff --git a/lib/kamal/sshkit_with_ext.rb b/lib/kamal/sshkit_with_ext.rb index c556774b2..373ae8434 100644 --- a/lib/kamal/sshkit_with_ext.rb +++ b/lib/kamal/sshkit_with_ext.rb @@ -104,33 +104,46 @@ def start_with_concurrency_limit(*args) prepend LimitConcurrentStartsInstance end -require "thread" - -module SSHKit - module Runner - class ParallelCompleteAll < Abstract - def execute - threads = hosts.map do |host| - Thread.new(host) do |h| - begin - backend(h, &block).run - rescue ::StandardError => e - e2 = SSHKit::Runner::ExecuteError.new e - raise e2, "Exception while executing #{host.user ? "as #{host.user}@" : "on host "}#{host}: #{e.message}" - end - end +class SSHKit::Runner::MultipleExecuteError < SSHKit::StandardError + attr_reader :execute_errors + + def initialize(execute_errors) + @execute_errors = execute_errors + end +end + +class SSHKit::Runner::Parallel + # SSHKit joins the threads in sequence and fails on the first error it encounters, which means that we wait threads + # before the first failure to complete but not for ones after. + # + # We'll patch it to wait for them all to complete, and to record all the threads that errored so we can see when a + # problem occurs on multiple hosts. + module CompleteAll + def execute + threads = hosts.map do |host| + Thread.new(host) do |h| + backend(h, &block).run + rescue ::StandardError => e + e2 = SSHKit::Runner::ExecuteError.new e + raise e2, "Exception while executing #{host.user ? "as #{host.user}@" : "on host "}#{host}: #{e.message}" end + end - exception = nil - threads.each do |t| - begin - t.join - rescue SSHKit::Runner::ExecuteError => e - exception ||= e - end + exceptions = [] + threads.each do |t| + begin + t.join + rescue SSHKit::Runner::ExecuteError => e + exceptions << e end - raise exception if exception + end + if exceptions.one? + raise exceptions.first + elsif exceptions.many? + raise SSHKit::Runner::MultipleExecuteError.new(exceptions) end end end + + prepend CompleteAll end From 706b82baa164dec68d1a88c22e9db9d73e394763 Mon Sep 17 00:00:00 2001 From: Donal McBreen Date: Tue, 21 May 2024 10:40:01 +0100 Subject: [PATCH 39/71] Simplify messages and remove multiple execute error --- bin/kamal | 9 -------- lib/kamal/cli/app/boot.rb | 32 +++++++++++++++----------- lib/kamal/cli/main.rb | 2 +- lib/kamal/sshkit_with_ext.rb | 10 +------- test/cli/app_test.rb | 20 ++++++++-------- test/integration/broken_deploy_test.rb | 6 ++--- 6 files changed, 32 insertions(+), 47 deletions(-) diff --git a/bin/kamal b/bin/kamal index a8e2ac2b9..96c0dc640 100755 --- a/bin/kamal +++ b/bin/kamal @@ -7,15 +7,6 @@ require "kamal" begin Kamal::Cli::Main.start(ARGV) -rescue SSHKit::Runner::MultipleExecuteError => e - e.execute_errors.each do |execute_error| - puts " \e[31mERROR (#{execute_error.cause.class}): #{execute_error.message}\e[0m" - end - if ENV["VERBOSE"] - puts "Backtrace for the first error:" - puts e.execute_errors.first.cause.backtrace - end - exit 1 rescue SSHKit::Runner::ExecuteError => e puts " \e[31mERROR (#{e.cause.class}): #{e.message}\e[0m" puts e.cause.backtrace if ENV["VERBOSE"] diff --git a/lib/kamal/cli/app/boot.rb b/lib/kamal/cli/app/boot.rb index 47f8c21c4..c41257719 100644 --- a/lib/kamal/cli/app/boot.rb +++ b/lib/kamal/cli/app/boot.rb @@ -14,7 +14,17 @@ def initialize(host, role, sshkit, version, barrier) def run old_version = old_version_renamed_if_clashing - start_new_version + wait_at_barrier if queuer? + + begin + start_new_version + rescue => e + close_barrier if gatekeeper? + stop_new_version + raise + end + + release_barrier if gatekeeper? if old_version stop_old_version(old_version) @@ -34,22 +44,16 @@ def old_version_renamed_if_clashing end def start_new_version - wait_at_barrier if queuer? - audit "Booted app version #{version}" execute *app.tie_cord(role.cord_host_file) if uses_cord? hostname = "#{host.to_s[0...51].gsub(/\.+$/, '')}-#{SecureRandom.hex(6)}" execute *app.run(hostname: hostname) Kamal::Cli::Healthcheck::Poller.wait_for_healthy(pause_after_ready: true) { capture_with_info(*app.status(version: version)) } + end - release_barrier if gatekeeper? - rescue => e - close_barrier if gatekeeper? - + def stop_new_version execute *app.stop(version: version), raise_on_non_zero_exit: false - - raise end def stop_old_version(version) @@ -68,22 +72,22 @@ def stop_old_version(version) def release_barrier if barrier.open - info "First #{KAMAL.primary_role} container healthy, continuing other roles (#{host})" + info "First #{KAMAL.primary_role} container is healthy on #{host}, booting other roles" end end def wait_at_barrier - info "Waiting for a healthy #{KAMAL.primary_role} container (#{host})..." + info "Waiting for the first healthy #{KAMAL.primary_role} container before booting #{role} on #{host}..." barrier.wait - info "First #{KAMAL.primary_role} container is healthy, continuing (#{host})" + info "First #{KAMAL.primary_role} container is healthy, booting #{role} on #{host}..." rescue Kamal::Cli::Healthcheck::Error - info "First #{KAMAL.primary_role} container is unhealthy, stopping (#{host})" + info "First #{KAMAL.primary_role} container is unhealthy, not booting #{role} on #{host}" raise end def close_barrier if barrier.close - info "First #{KAMAL.primary_role} container unhealthy, stopping other roles (#{host})" + info "First #{KAMAL.primary_role} container is unhealthy on #{host}, not booting other roles" error capture_with_info(*app.logs(version: version)) error capture_with_info(*app.container_health_log(version: version)) end diff --git a/lib/kamal/cli/main.rb b/lib/kamal/cli/main.rb index 594fb3fd1..c05c15039 100644 --- a/lib/kamal/cli/main.rb +++ b/lib/kamal/cli/main.rb @@ -244,7 +244,7 @@ def container_available?(version) raise "Container not found" unless container_id.present? end end - rescue SSHKit::Runner::ExecuteError => e + rescue SSHKit::Runner::ExecuteError, SSHKit::Runner::MultipleExecuteError => e if e.message =~ /Container not found/ say "Error looking for container version #{version}: #{e.message}" return false diff --git a/lib/kamal/sshkit_with_ext.rb b/lib/kamal/sshkit_with_ext.rb index 373ae8434..2d0257a87 100644 --- a/lib/kamal/sshkit_with_ext.rb +++ b/lib/kamal/sshkit_with_ext.rb @@ -104,14 +104,6 @@ def start_with_concurrency_limit(*args) prepend LimitConcurrentStartsInstance end -class SSHKit::Runner::MultipleExecuteError < SSHKit::StandardError - attr_reader :execute_errors - - def initialize(execute_errors) - @execute_errors = execute_errors - end -end - class SSHKit::Runner::Parallel # SSHKit joins the threads in sequence and fails on the first error it encounters, which means that we wait threads # before the first failure to complete but not for ones after. @@ -140,7 +132,7 @@ def execute if exceptions.one? raise exceptions.first elsif exceptions.many? - raise SSHKit::Runner::MultipleExecuteError.new(exceptions) + raise exceptions.first, [ "Exceptions on #{exceptions.count} hosts:", exceptions.map(&:message) ].join("\n") end end end diff --git a/test/cli/app_test.rb b/test/cli/app_test.rb index f684deb8c..7a2a266ef 100644 --- a/test/cli/app_test.rb +++ b/test/cli/app_test.rb @@ -136,11 +136,11 @@ class CliAppTest < CliTestCase .returns("running").at_least_once # workers health check run_command("boot", config: :with_roles, host: nil).tap do |output| - assert_match "Waiting for a healthy web container (1.1.1.3)...", output - assert_match "Waiting for a healthy web container (1.1.1.4)...", output - assert_match "First web container is healthy, continuing (1.1.1.3)", output - assert_match "First web container is healthy, continuing (1.1.1.4)", output - end + assert_match "Waiting for the first healthy web container before booting workers on 1.1.1.3...", output + assert_match "Waiting for the first healthy web container before booting workers on 1.1.1.4...", output + assert_match "First web container is healthy, booting workers on 1.1.1.3", output + assert_match "First web container is healthy, booting workers on 1.1.1.4", output + end end test "boot with web barrier closed" do @@ -156,14 +156,12 @@ class CliAppTest < CliTestCase stderred do run_command("boot", config: :with_roles, host: nil, allow_execute_error: true).tap do |output| - assert_match "Waiting for a healthy web container (1.1.1.3)...", output - assert_match "Waiting for a healthy web container (1.1.1.4)...", output - assert_match "First web container is unhealthy, stopping (1.1.1.3)", output - assert_match "First web container is unhealthy, stopping (1.1.1.4)", output + assert_match "Waiting for the first healthy web container before booting workers on 1.1.1.3...", output + assert_match "Waiting for the first healthy web container before booting workers on 1.1.1.4...", output + assert_match "First web container is unhealthy, not booting workers on 1.1.1.3", output + assert_match "First web container is unhealthy, not booting workers on 1.1.1.4", output assert_match "Running docker container ls --all --filter name=^app-web-latest$ --quiet | xargs docker stop on 1.1.1.1", output assert_match "Running docker container ls --all --filter name=^app-web-latest$ --quiet | xargs docker stop on 1.1.1.2", output - assert_match "Running docker container ls --all --filter name=^app-workers-latest$ --quiet | xargs docker stop on 1.1.1.3", output - assert_match "Running docker container ls --all --filter name=^app-workers-latest$ --quiet | xargs docker stop on 1.1.1.4", output end end ensure diff --git a/test/integration/broken_deploy_test.rb b/test/integration/broken_deploy_test.rb index 6b71844e0..a3f74d45f 100644 --- a/test/integration/broken_deploy_test.rb +++ b/test/integration/broken_deploy_test.rb @@ -25,9 +25,9 @@ class BrokenDeployTest < IntegrationTest private def assert_failed_deploy(output) - assert_match "Waiting for a healthy web container (vm3)...", output - assert_match /First #{KAMAL.primary_role} container is unhealthy, stopping \(vm[12]\)/, output - assert_match "First #{KAMAL.primary_role} container unhealthy, stopping other roles (vm3)...", output + assert_match "Waiting for the first healthy web container before booting workers on vm3...", output + assert_match /First web container is unhealthy on vm[12], not booting other roles/, output + assert_match "First web container is unhealthy, not booting workers on vm3", output assert_match "nginx: [emerg] unexpected end of file, expecting \";\" or \"}\" in /etc/nginx/conf.d/default.conf:2", output assert_match 'ERROR {"Status":"unhealthy","FailingStreak":0,"Log":[]}', output end From 1e296c41402a7fe34ca223448c1204a480bb8720 Mon Sep 17 00:00:00 2001 From: Donal McBreen Date: Tue, 21 May 2024 11:38:30 +0100 Subject: [PATCH 40/71] Update sshkit_with_ext.rb --- lib/kamal/sshkit_with_ext.rb | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/lib/kamal/sshkit_with_ext.rb b/lib/kamal/sshkit_with_ext.rb index f0cdf8c82..e0c62c3ad 100644 --- a/lib/kamal/sshkit_with_ext.rb +++ b/lib/kamal/sshkit_with_ext.rb @@ -80,8 +80,7 @@ class << self module LimitConcurrentStartsInstance private def with_ssh(&block) - host.ssh_options = (host.ssh_options || {}).merge({ port: host.port, user: host.user }.compact) - host.ssh_options = self.class.config.ssh_options.merge(host.ssh_options) + host.ssh_options = self.class.config.ssh_options.merge(host.ssh_options || {}) self.class.pool.with( method(:start_with_concurrency_limit), String(host.hostname), From 7b55f4734e327df42fcbc06df415a1e42f80b1b5 Mon Sep 17 00:00:00 2001 From: Donal McBreen Date: Tue, 21 May 2024 11:47:51 +0100 Subject: [PATCH 41/71] Envify already env pushes `kamal envify` will do `kamal env push` for us, so no need to call it ourselves during setup. --- lib/kamal/cli/main.rb | 1 - test/cli/main_test.rb | 2 -- 2 files changed, 3 deletions(-) diff --git a/lib/kamal/cli/main.rb b/lib/kamal/cli/main.rb index 1cc9d98ea..a09724b62 100644 --- a/lib/kamal/cli/main.rb +++ b/lib/kamal/cli/main.rb @@ -11,7 +11,6 @@ def setup 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 deploy diff --git a/test/cli/main_test.rb b/test/cli/main_test.rb index 6149cf0ba..81d3eb4de 100644 --- a/test/cli/main_test.rb +++ b/test/cli/main_test.rb @@ -6,7 +6,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: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) @@ -20,7 +19,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: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 From 6a7c90cf4d5f7b3532390c4ceec3afd09cce4de6 Mon Sep 17 00:00:00 2001 From: Donal McBreen Date: Wed, 3 Apr 2024 12:38:10 +0100 Subject: [PATCH 42/71] Only stopping containers locks --- lib/kamal/cli/app.rb | 6 ++---- lib/kamal/cli/base.rb | 4 ++-- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/lib/kamal/cli/app.rb b/lib/kamal/cli/app.rb index 523eb4879..d28de3c4f 100644 --- a/lib/kamal/cli/app.rb +++ b/lib/kamal/cli/app.rb @@ -135,11 +135,9 @@ def containers desc "stale_containers", "Detect app stale containers" option :stop, aliases: "-s", type: :boolean, default: false, desc: "Stop the stale containers found" def stale_containers - mutating do - stop = options[:stop] - - cli = self + stop = options[:stop] + mutating(mutates: stop) do on(KAMAL.hosts) do |host| roles = KAMAL.roles_on(host) diff --git a/lib/kamal/cli/base.rb b/lib/kamal/cli/base.rb index 206f63a40..1cee54841 100644 --- a/lib/kamal/cli/base.rb +++ b/lib/kamal/cli/base.rb @@ -79,8 +79,8 @@ def print_runtime puts " Finished all in #{sprintf("%.1f seconds", runtime)}" end - def mutating - return yield if KAMAL.holding_lock? + def mutating(mutates: true) + return yield if KAMAL.holding_lock? || !mutates run_hook "pre-connect" From d2a719998af73993228a8852d508f513429e2d94 Mon Sep 17 00:00:00 2001 From: Donal McBreen Date: Wed, 3 Apr 2024 12:39:40 +0100 Subject: [PATCH 43/71] Building doesn't need a deploy lock --- lib/kamal/cli/build.rb | 112 +++++++++++++++++++---------------------- 1 file changed, 52 insertions(+), 60 deletions(-) diff --git a/lib/kamal/cli/build.rb b/lib/kamal/cli/build.rb index 1a50f8ffa..0e0a2640d 100644 --- a/lib/kamal/cli/build.rb +++ b/lib/kamal/cli/build.rb @@ -5,87 +5,81 @@ class BuildError < StandardError; end desc "deliver", "Build app and push app image to registry then pull image on servers" def deliver - mutating do - push - pull - end + push + pull end desc "push", "Build and push app image to registry" def push - mutating do - cli = self - - verify_local_dependencies - run_hook "pre-build" + cli = self - uncommitted_changes = Kamal::Git.uncommitted_changes + verify_local_dependencies + run_hook "pre-build" - if KAMAL.config.builder.git_clone? - if uncommitted_changes.present? - say "Building from a local git clone, so ignoring these uncommitted changes:\n #{uncommitted_changes}", :yellow - end + uncommitted_changes = Kamal::Git.uncommitted_changes - prepare_clone - elsif uncommitted_changes.present? - say "Building with uncommitted changes:\n #{uncommitted_changes}", :yellow + if KAMAL.config.builder.git_clone? + if uncommitted_changes.present? + say "Building from a local git clone, so ignoring these uncommitted changes:\n #{uncommitted_changes}", :yellow end - # Get the command here to ensure the Dir.chdir doesn't interfere with it - push = KAMAL.builder.push + prepare_clone + elsif uncommitted_changes.present? + say "Building with uncommitted changes:\n #{uncommitted_changes}", :yellow + end - run_locally do - begin - KAMAL.with_verbosity(:debug) do - Dir.chdir(KAMAL.config.builder.build_directory) { execute *push } - end - rescue SSHKit::Command::Failed => e - if e.message =~ /(no builder)|(no such file or directory)/ - warn "Missing compatible builder, so creating a new one first" + # Get the command here to ensure the Dir.chdir doesn't interfere with it + push = KAMAL.builder.push + + run_locally do + begin + KAMAL.with_verbosity(:debug) do + Dir.chdir(KAMAL.config.builder.build_directory) { execute *push } + end + rescue SSHKit::Command::Failed => e + if e.message =~ /(no builder)|(no such file or directory)/ + warn "Missing compatible builder, so creating a new one first" - if cli.create - KAMAL.with_verbosity(:debug) do - Dir.chdir(KAMAL.config.builder.build_directory) { execute *push } - end + if cli.create + KAMAL.with_verbosity(:debug) do + Dir.chdir(KAMAL.config.builder.build_directory) { execute *push } end - else - raise end + else + raise end + else + raise end end end desc "pull", "Pull app image from registry onto servers" def pull - mutating do - on(KAMAL.hosts) do - execute *KAMAL.auditor.record("Pulled image with version #{KAMAL.config.version}"), verbosity: :debug - execute *KAMAL.builder.clean, raise_on_non_zero_exit: false - execute *KAMAL.builder.pull - execute *KAMAL.builder.validate_image - end + on(KAMAL.hosts) do + execute *KAMAL.auditor.record("Pulled image with version #{KAMAL.config.version}"), verbosity: :debug + execute *KAMAL.builder.clean, raise_on_non_zero_exit: false + execute *KAMAL.builder.pull + execute *KAMAL.builder.validate_image end end desc "create", "Create a build setup" def create - mutating do - if (remote_host = KAMAL.config.builder.remote_host) - connect_to_remote_host(remote_host) - end + if (remote_host = KAMAL.config.builder.remote_host) + connect_to_remote_host(remote_host) + end - run_locally do - begin - debug "Using builder: #{KAMAL.builder.name}" - execute *KAMAL.builder.create - rescue SSHKit::Command::Failed => e - if e.message =~ /stderr=(.*)/ - error "Couldn't create remote builder: #{$1}" - false - else - raise - end + run_locally do + begin + debug "Using builder: #{KAMAL.builder.name}" + execute *KAMAL.builder.create + rescue SSHKit::Command::Failed => e + if e.message =~ /stderr=(.*)/ + error "Couldn't create remote builder: #{$1}" + false + else + raise end end end @@ -93,11 +87,9 @@ def create desc "remove", "Remove build setup" def remove - mutating do - run_locally do - debug "Using builder: #{KAMAL.builder.name}" - execute *KAMAL.builder.remove - end + run_locally do + debug "Using builder: #{KAMAL.builder.name}" + execute *KAMAL.builder.remove end end From 64f5955444e7acf9b31b89444228576bced140b9 Mon Sep 17 00:00:00 2001 From: Donal McBreen Date: Wed, 3 Apr 2024 12:45:11 +0100 Subject: [PATCH 44/71] Don't hold lock on error --- lib/kamal/cli/app.rb | 40 +++++++++++++++++++--------------------- lib/kamal/cli/base.rb | 17 +++-------------- lib/kamal/commander.rb | 7 +------ test/cli/app_test.rb | 4 ++-- 4 files changed, 25 insertions(+), 43 deletions(-) diff --git a/lib/kamal/cli/app.rb b/lib/kamal/cli/app.rb index d28de3c4f..ac2b9e40c 100644 --- a/lib/kamal/cli/app.rb +++ b/lib/kamal/cli/app.rb @@ -2,32 +2,30 @@ class Kamal::Cli::App < Kamal::Cli::Base desc "boot", "Boot app on servers (or reboot app if already running)" def boot mutating do - hold_lock_on_error do - say "Get most recent version available as an image...", :magenta unless options[:version] - using_version(version_or_latest) do |version| - say "Start container with version #{version} using a #{KAMAL.config.readiness_delay}s readiness delay (or reboot if already running)...", :magenta - - # Assets are prepared in a separate step to ensure they are on all hosts before booting - on(KAMAL.hosts) do - KAMAL.roles_on(host).each do |role| - Kamal::Cli::App::PrepareAssets.new(host, role, self).run - end + say "Get most recent version available as an image...", :magenta unless options[:version] + using_version(version_or_latest) do |version| + say "Start container with version #{version} using a #{KAMAL.config.readiness_delay}s readiness delay (or reboot if already running)...", :magenta + + # Assets are prepared in a separate step to ensure they are on all hosts before booting + on(KAMAL.hosts) do + KAMAL.roles_on(host).each do |role| + Kamal::Cli::App::PrepareAssets.new(host, role, self).run end + end - #  Primary hosts and roles are returned first, so they can open the barrier - barrier = Kamal::Cli::Healthcheck::Barrier.new if KAMAL.roles.many? + # Primary hosts and roles are returned first, so they can open the barrier + barrier = Kamal::Cli::Healthcheck::Barrier.new if KAMAL.roles.many? - on(KAMAL.hosts, **KAMAL.boot_strategy) do |host| - KAMAL.roles_on(host).each do |role| - Kamal::Cli::App::Boot.new(host, role, self, version, barrier).run - end + on(KAMAL.hosts, **KAMAL.boot_strategy) do |host| + KAMAL.roles_on(host).each do |role| + Kamal::Cli::App::Boot.new(host, role, self, version, barrier).run end + end - #  Tag once the app booted on all hosts - on(KAMAL.hosts) do |host| - execute *KAMAL.auditor.record("Tagging #{KAMAL.config.absolute_image} as the latest image"), verbosity: :debug - execute *KAMAL.app.tag_latest_image - end + # Tag once the app booted on all hosts + on(KAMAL.hosts) do |host| + execute *KAMAL.auditor.record("Tagging #{KAMAL.config.absolute_image} as the latest image"), verbosity: :debug + execute *KAMAL.app.tag_latest_image end end end diff --git a/lib/kamal/cli/base.rb b/lib/kamal/cli/base.rb index 1cee54841..d363a8ee8 100644 --- a/lib/kamal/cli/base.rb +++ b/lib/kamal/cli/base.rb @@ -91,12 +91,11 @@ def mutating(mutates: true) begin yield rescue - if KAMAL.hold_lock_on_error? - error " \e[31mDeploy lock was not released\e[0m" - else + begin release_lock + rescue => e + say "Error releasing the deploy lock: #{e.message}", :red end - raise end @@ -141,16 +140,6 @@ def raise_if_locked end end - def hold_lock_on_error - if KAMAL.hold_lock_on_error? - yield - else - KAMAL.hold_lock_on_error = true - yield - KAMAL.hold_lock_on_error = false - end - end - def run_hook(hook, **extra_details) if !options[:skip_hooks] && KAMAL.hook.hook_exists?(hook) details = { hosts: KAMAL.hosts.join(","), command: command, subcommand: subcommand } diff --git a/lib/kamal/commander.rb b/lib/kamal/commander.rb index e7c5d21f0..9937cb694 100644 --- a/lib/kamal/commander.rb +++ b/lib/kamal/commander.rb @@ -2,13 +2,12 @@ require "active_support/core_ext/module/delegation" class Kamal::Commander - attr_accessor :verbosity, :holding_lock, :hold_lock_on_error + attr_accessor :verbosity, :holding_lock delegate :hosts, :roles, :primary_host, :primary_role, :roles_on, :traefik_hosts, :accessory_hosts, to: :specifics def initialize self.verbosity = :info self.holding_lock = false - self.hold_lock_on_error = false @specifics = nil end @@ -138,10 +137,6 @@ def holding_lock? self.holding_lock end - def hold_lock_on_error? - self.hold_lock_on_error - end - private # Lazy setup of SSHKit def configure_sshkit_with(config) diff --git a/test/cli/app_test.rb b/test/cli/app_test.rb index 7a2a266ef..643f8d858 100644 --- a/test/cli/app_test.rb +++ b/test/cli/app_test.rb @@ -54,14 +54,14 @@ class CliAppTest < CliTestCase run_command("boot", config: :with_boot_strategy) end - test "boot errors leave lock in place" do + test "boot errors don't leave lock in place" do Kamal::Cli::App.any_instance.expects(:using_version).raises(RuntimeError) assert_not KAMAL.holding_lock? assert_raises(RuntimeError) do stderred { run_command("boot") } end - assert KAMAL.holding_lock? + assert_not KAMAL.holding_lock? end test "boot with assets" do From b12654ccd0da55ed6d74b6434e61048cdb33a185 Mon Sep 17 00:00:00 2001 From: Donal McBreen Date: Mon, 20 May 2024 13:01:22 +0100 Subject: [PATCH 45/71] Don't lock until confirmed --- lib/kamal/cli/main.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/kamal/cli/main.rb b/lib/kamal/cli/main.rb index f17fd6cd4..90e63aba7 100644 --- a/lib/kamal/cli/main.rb +++ b/lib/kamal/cli/main.rb @@ -192,8 +192,8 @@ def envify 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 - mutating do - confirming "This will remove all containers and images. Are you sure?" do + confirming "This will remove all containers and images. Are you sure?" do + mutating do invoke "kamal:cli:traefik:remove", [], options.without(:confirmed) invoke "kamal:cli:app:remove", [], options.without(:confirmed) invoke "kamal:cli:accessory:remove", [ "all" ], options From 96ef0fbc4d047c6c5ec27d3bea10b092732b0527 Mon Sep 17 00:00:00 2001 From: Donal McBreen Date: Mon, 20 May 2024 14:12:14 +0100 Subject: [PATCH 46/71] Fix merge error --- lib/kamal/cli/build.rb | 2 -- 1 file changed, 2 deletions(-) diff --git a/lib/kamal/cli/build.rb b/lib/kamal/cli/build.rb index 0e0a2640d..0af76b8c6 100644 --- a/lib/kamal/cli/build.rb +++ b/lib/kamal/cli/build.rb @@ -48,8 +48,6 @@ def push else raise end - else - raise end end end From 83d0078525096af35353e9feb80cc61cf08c13e0 Mon Sep 17 00:00:00 2001 From: Donal McBreen Date: Mon, 20 May 2024 14:12:26 +0100 Subject: [PATCH 47/71] Confirm outside mutating --- lib/kamal/cli/accessory.rb | 26 +++++++++++++++----------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/lib/kamal/cli/accessory.rb b/lib/kamal/cli/accessory.rb index 4c695a28f..8a4f61430 100644 --- a/lib/kamal/cli/accessory.rb +++ b/lib/kamal/cli/accessory.rb @@ -174,17 +174,12 @@ def logs(name) desc "remove [NAME]", "Remove accessory container, image and data directory from host (use NAME=all to remove all accessories)" option :confirmed, aliases: "-y", type: :boolean, default: false, desc: "Proceed without confirmation question" def remove(name) - mutating do - if name == "all" - KAMAL.accessory_names.each { |accessory_name| remove(accessory_name) } - else - confirming "This will remove all containers, images and data directories for #{name}. Are you sure?" do - with_accessory(name) do - stop(name) - remove_container(name) - remove_image(name) - remove_service_directory(name) - end + confirming "This will remove all containers, images and data directories for #{name}. Are you sure?" do + mutating do + if name == "all" + KAMAL.accessory_names.each { |accessory_name| remove_accessory(accessory_name) } + else + remove_accessory(name) end end end @@ -250,4 +245,13 @@ def accessory_hosts(accessory) accessory.hosts end end + + def remove_accessory(name) + with_accessory(name) do + stop(name) + remove_container(name) + remove_image(name) + remove_service_directory(name) + end + end end From 0e73f027436059ee92c0d30223d5f5da3e7dbd76 Mon Sep 17 00:00:00 2001 From: Donal McBreen Date: Mon, 20 May 2024 15:03:38 +0100 Subject: [PATCH 48/71] Split lock and connection setup Allow run the pre-connect hook before the first SSH command is executed, but only run the locking in `with_lock` blocks. --- lib/kamal/cli/accessory.rb | 22 ++++++++--------- lib/kamal/cli/app.rb | 24 ++++++++++++------- lib/kamal/cli/base.rb | 41 +++++++++++++++++++------------- lib/kamal/cli/env.rb | 4 ++-- lib/kamal/cli/main.rb | 48 +++++++++++++++++++------------------- lib/kamal/cli/prune.rb | 6 ++--- lib/kamal/cli/server.rb | 32 +++++++++++++------------ lib/kamal/cli/traefik.rb | 16 ++++++------- lib/kamal/commander.rb | 7 +++++- test/cli/main_test.rb | 6 +++++ test/cli/server_test.rb | 6 ++++- 11 files changed, 123 insertions(+), 89 deletions(-) diff --git a/lib/kamal/cli/accessory.rb b/lib/kamal/cli/accessory.rb index 8a4f61430..e1d31b779 100644 --- a/lib/kamal/cli/accessory.rb +++ b/lib/kamal/cli/accessory.rb @@ -1,7 +1,7 @@ class Kamal::Cli::Accessory < Kamal::Cli::Base desc "boot [NAME]", "Boot new accessory service on host (use NAME=all to boot all accessories)" def boot(name, login: true) - mutating do + with_lock do if name == "all" KAMAL.accessory_names.each { |accessory_name| boot(accessory_name) } else @@ -21,7 +21,7 @@ def boot(name, login: true) desc "upload [NAME]", "Upload accessory files to host", hide: true def upload(name) - mutating do + with_lock do with_accessory(name) do |accessory, hosts| on(hosts) do accessory.files.each do |(local, remote)| @@ -38,7 +38,7 @@ def upload(name) desc "directories [NAME]", "Create accessory directories on host", hide: true def directories(name) - mutating do + with_lock do with_accessory(name) do |accessory, hosts| on(hosts) do accessory.directories.keys.each do |host_path| @@ -51,7 +51,7 @@ def directories(name) desc "reboot [NAME]", "Reboot existing accessory on host (stop container, remove container, start new container; use NAME=all to boot all accessories)" def reboot(name) - mutating do + with_lock do if name == "all" KAMAL.accessory_names.each { |accessory_name| reboot(accessory_name) } else @@ -70,7 +70,7 @@ def reboot(name) desc "start [NAME]", "Start existing accessory container on host" def start(name) - mutating do + with_lock do with_accessory(name) do |accessory, hosts| on(hosts) do execute *KAMAL.auditor.record("Started #{name} accessory"), verbosity: :debug @@ -82,7 +82,7 @@ def start(name) desc "stop [NAME]", "Stop existing accessory container on host" def stop(name) - mutating do + with_lock do with_accessory(name) do |accessory, hosts| on(hosts) do execute *KAMAL.auditor.record("Stopped #{name} accessory"), verbosity: :debug @@ -94,7 +94,7 @@ def stop(name) desc "restart [NAME]", "Restart existing accessory container on host" def restart(name) - mutating do + with_lock do with_accessory(name) do stop(name) start(name) @@ -175,7 +175,7 @@ def logs(name) option :confirmed, aliases: "-y", type: :boolean, default: false, desc: "Proceed without confirmation question" def remove(name) confirming "This will remove all containers, images and data directories for #{name}. Are you sure?" do - mutating do + with_lock do if name == "all" KAMAL.accessory_names.each { |accessory_name| remove_accessory(accessory_name) } else @@ -187,7 +187,7 @@ def remove(name) desc "remove_container [NAME]", "Remove accessory container from host", hide: true def remove_container(name) - mutating do + with_lock do with_accessory(name) do |accessory, hosts| on(hosts) do execute *KAMAL.auditor.record("Remove #{name} accessory container"), verbosity: :debug @@ -199,7 +199,7 @@ def remove_container(name) desc "remove_image [NAME]", "Remove accessory image from host", hide: true def remove_image(name) - mutating do + with_lock do with_accessory(name) do |accessory, hosts| on(hosts) do execute *KAMAL.auditor.record("Removed #{name} accessory image"), verbosity: :debug @@ -211,7 +211,7 @@ def remove_image(name) desc "remove_service_directory [NAME]", "Remove accessory directory used for uploaded files and data directories from host", hide: true def remove_service_directory(name) - mutating do + with_lock do with_accessory(name) do |accessory, hosts| on(hosts) do execute *accessory.remove_service_directory diff --git a/lib/kamal/cli/app.rb b/lib/kamal/cli/app.rb index ac2b9e40c..ee8ab901e 100644 --- a/lib/kamal/cli/app.rb +++ b/lib/kamal/cli/app.rb @@ -1,7 +1,7 @@ class Kamal::Cli::App < Kamal::Cli::Base desc "boot", "Boot app on servers (or reboot app if already running)" def boot - mutating do + with_lock do say "Get most recent version available as an image...", :magenta unless options[:version] using_version(version_or_latest) do |version| say "Start container with version #{version} using a #{KAMAL.config.readiness_delay}s readiness delay (or reboot if already running)...", :magenta @@ -33,7 +33,7 @@ def boot desc "start", "Start existing app container on servers" def start - mutating do + with_lock do on(KAMAL.hosts) do |host| roles = KAMAL.roles_on(host) @@ -47,7 +47,7 @@ def start desc "stop", "Stop app container on servers" def stop - mutating do + with_lock do on(KAMAL.hosts) do |host| roles = KAMAL.roles_on(host) @@ -135,7 +135,7 @@ def containers def stale_containers stop = options[:stop] - mutating(mutates: stop) do + with_lock_if_stopping do on(KAMAL.hosts) do |host| roles = KAMAL.roles_on(host) @@ -204,7 +204,7 @@ def logs desc "remove", "Remove app containers and images from servers" def remove - mutating do + with_lock do stop remove_containers remove_images @@ -213,7 +213,7 @@ def remove desc "remove_container [VERSION]", "Remove app container with given version from servers", hide: true def remove_container(version) - mutating do + with_lock do on(KAMAL.hosts) do |host| roles = KAMAL.roles_on(host) @@ -227,7 +227,7 @@ def remove_container(version) desc "remove_containers", "Remove all app containers from servers", hide: true def remove_containers - mutating do + with_lock do on(KAMAL.hosts) do |host| roles = KAMAL.roles_on(host) @@ -241,7 +241,7 @@ def remove_containers desc "remove_images", "Remove all app images from servers", hide: true def remove_images - mutating do + with_lock do on(KAMAL.hosts) do execute *KAMAL.auditor.record("Removed all app images"), verbosity: :debug execute *KAMAL.app.remove_images @@ -284,4 +284,12 @@ def current_running_version(host: KAMAL.primary_host) def version_or_latest options[:version] || KAMAL.config.latest_tag end + + def with_lock_if_stopping + if options[:stop] + with_lock { yield } + else + yield + end + end end diff --git a/lib/kamal/cli/base.rb b/lib/kamal/cli/base.rb index d363a8ee8..e648281ff 100644 --- a/lib/kamal/cli/base.rb +++ b/lib/kamal/cli/base.rb @@ -79,27 +79,27 @@ def print_runtime puts " Finished all in #{sprintf("%.1f seconds", runtime)}" end - def mutating(mutates: true) - return yield if KAMAL.holding_lock? || !mutates - - run_hook "pre-connect" - - ensure_run_and_locks_directory + def with_lock + if KAMAL.holding_lock? + yield + else + ensure_run_and_locks_directory - acquire_lock + acquire_lock - begin - yield - rescue begin - release_lock - rescue => e - say "Error releasing the deploy lock: #{e.message}", :red + yield + rescue + begin + release_lock + rescue => e + say "Error releasing the deploy lock: #{e.message}", :red + end + raise end - raise - end - release_lock + release_lock + end end def confirming(question) @@ -153,6 +153,15 @@ def run_hook(hook, **extra_details) end end + def on(*args, &block) + if !KAMAL.connected? + run_hook "pre-connect" + KAMAL.connected = true + end + + super + end + def command @kamal_command ||= begin invocation_class, invocation_commands = *first_invocation diff --git a/lib/kamal/cli/env.rb b/lib/kamal/cli/env.rb index 56ba505a5..f12174a7b 100644 --- a/lib/kamal/cli/env.rb +++ b/lib/kamal/cli/env.rb @@ -3,7 +3,7 @@ class Kamal::Cli::Env < Kamal::Cli::Base desc "push", "Push the env file to the remote hosts" def push - mutating do + with_lock do on(KAMAL.hosts) do execute *KAMAL.auditor.record("Pushed env files"), verbosity: :debug @@ -30,7 +30,7 @@ def push desc "delete", "Delete the env file from the remote hosts" def delete - mutating do + with_lock do on(KAMAL.hosts) do execute *KAMAL.auditor.record("Deleted env files"), verbosity: :debug diff --git a/lib/kamal/cli/main.rb b/lib/kamal/cli/main.rb index 90e63aba7..2816ab089 100644 --- a/lib/kamal/cli/main.rb +++ b/lib/kamal/cli/main.rb @@ -3,7 +3,7 @@ class Kamal::Cli::Main < Kamal::Cli::Base option :skip_push, aliases: "-P", type: :boolean, default: false, desc: "Skip image build and push" def setup print_runtime do - mutating do + with_lock do invoke_options = deploy_options say "Ensure Docker is installed...", :magenta @@ -22,22 +22,22 @@ def setup option :skip_push, aliases: "-P", type: :boolean, default: false, desc: "Skip image build and push" def deploy runtime = print_runtime do - mutating do - invoke_options = deploy_options + invoke_options = deploy_options - say "Log into image registry...", :magenta - invoke "kamal:cli:registry:login", [], invoke_options + say "Log into image registry...", :magenta + invoke "kamal:cli:registry:login", [], invoke_options - if options[:skip_push] - say "Pull app image...", :magenta - invoke "kamal:cli:build:pull", [], invoke_options - else - say "Build and push app image...", :magenta - invoke "kamal:cli:build:deliver", [], invoke_options - end + if options[:skip_push] + say "Pull app image...", :magenta + invoke "kamal:cli:build:pull", [], invoke_options + else + say "Build and push app image...", :magenta + invoke "kamal:cli:build:deliver", [], invoke_options + end - run_hook "pre-deploy" + run_hook "pre-deploy" + with_lock do say "Ensure Traefik is running...", :magenta invoke "kamal:cli:traefik:boot", [], invoke_options @@ -58,17 +58,17 @@ def deploy option :skip_push, aliases: "-P", type: :boolean, default: false, desc: "Skip image build and push" def redeploy runtime = print_runtime do - mutating do - invoke_options = deploy_options + invoke_options = deploy_options - if options[:skip_push] - say "Pull app image...", :magenta - invoke "kamal:cli:build:pull", [], invoke_options - else - say "Build and push app image...", :magenta - invoke "kamal:cli:build:deliver", [], invoke_options - end + if options[:skip_push] + say "Pull app image...", :magenta + invoke "kamal:cli:build:pull", [], invoke_options + else + say "Build and push app image...", :magenta + invoke "kamal:cli:build:deliver", [], invoke_options + end + with_lock do run_hook "pre-deploy" say "Detect stale containers...", :magenta @@ -85,7 +85,7 @@ def redeploy def rollback(version) rolled_back = false runtime = print_runtime do - mutating do + with_lock do invoke_options = deploy_options KAMAL.config.version = version @@ -193,7 +193,7 @@ def envify option :confirmed, aliases: "-y", type: :boolean, default: false, desc: "Proceed without confirmation question" def remove confirming "This will remove all containers and images. Are you sure?" do - mutating do + with_lock do invoke "kamal:cli:traefik:remove", [], options.without(:confirmed) invoke "kamal:cli:app:remove", [], options.without(:confirmed) invoke "kamal:cli:accessory:remove", [ "all" ], options diff --git a/lib/kamal/cli/prune.rb b/lib/kamal/cli/prune.rb index 498e4ec41..7635e97da 100644 --- a/lib/kamal/cli/prune.rb +++ b/lib/kamal/cli/prune.rb @@ -1,7 +1,7 @@ class Kamal::Cli::Prune < Kamal::Cli::Base desc "all", "Prune unused images and stopped containers" def all - mutating do + with_lock do containers images end @@ -9,7 +9,7 @@ def all desc "images", "Prune unused images" def images - mutating do + with_lock do on(KAMAL.hosts) do execute *KAMAL.auditor.record("Pruned images"), verbosity: :debug execute *KAMAL.prune.dangling_images @@ -24,7 +24,7 @@ def containers retain = options.fetch(:retain, KAMAL.config.retain_containers) raise "retain must be at least 1" if retain < 1 - mutating do + with_lock do on(KAMAL.hosts) do execute *KAMAL.auditor.record("Pruned containers"), verbosity: :debug execute *KAMAL.prune.app_containers(retain: retain) diff --git a/lib/kamal/cli/server.rb b/lib/kamal/cli/server.rb index fe0ae62fa..b545050f0 100644 --- a/lib/kamal/cli/server.rb +++ b/lib/kamal/cli/server.rb @@ -23,25 +23,27 @@ def exec(cmd) desc "bootstrap", "Set up Docker to run Kamal apps" def bootstrap - missing = [] - - on(KAMAL.hosts | KAMAL.accessory_hosts) do |host| - unless execute(*KAMAL.docker.installed?, raise_on_non_zero_exit: false) - if execute(*KAMAL.docker.superuser?, raise_on_non_zero_exit: false) - info "Missing Docker on #{host}. Installing…" - execute *KAMAL.docker.install - else - missing << host + with_lock do + missing = [] + + on(KAMAL.hosts | KAMAL.accessory_hosts) do |host| + unless execute(*KAMAL.docker.installed?, raise_on_non_zero_exit: false) + if execute(*KAMAL.docker.superuser?, raise_on_non_zero_exit: false) + info "Missing Docker on #{host}. Installing…" + execute *KAMAL.docker.install + else + missing << host + end end + + execute(*KAMAL.server.ensure_run_directory) end - execute(*KAMAL.server.ensure_run_directory) - end + if missing.any? + raise "Docker is not installed on #{missing.join(", ")} and can't be automatically installed without having root access and either `wget` or `curl`. Install Docker manually: https://docs.docker.com/engine/install/" + end - if missing.any? - raise "Docker is not installed on #{missing.join(", ")} and can't be automatically installed without having root access and either `wget` or `curl`. Install Docker manually: https://docs.docker.com/engine/install/" + run_hook "docker-setup" end - - run_hook "docker-setup" end end diff --git a/lib/kamal/cli/traefik.rb b/lib/kamal/cli/traefik.rb index c13e2d803..d192ee1a6 100644 --- a/lib/kamal/cli/traefik.rb +++ b/lib/kamal/cli/traefik.rb @@ -1,7 +1,7 @@ class Kamal::Cli::Traefik < Kamal::Cli::Base desc "boot", "Boot Traefik on servers" def boot - mutating do + with_lock do on(KAMAL.traefik_hosts) do execute *KAMAL.registry.login execute *KAMAL.traefik.start_or_run @@ -14,7 +14,7 @@ def boot option :confirmed, aliases: "-y", type: :boolean, default: false, desc: "Proceed without confirmation question" def reboot confirming "This will cause a brief outage on each host. Are you sure?" do - mutating do + with_lock do host_groups = options[:rolling] ? KAMAL.traefik_hosts : [ KAMAL.traefik_hosts ] host_groups.each do |hosts| host_list = Array(hosts).join(",") @@ -34,7 +34,7 @@ def reboot desc "start", "Start existing Traefik container on servers" def start - mutating do + with_lock do on(KAMAL.traefik_hosts) do execute *KAMAL.auditor.record("Started traefik"), verbosity: :debug execute *KAMAL.traefik.start @@ -44,7 +44,7 @@ def start desc "stop", "Stop existing Traefik container on servers" def stop - mutating do + with_lock do on(KAMAL.traefik_hosts) do execute *KAMAL.auditor.record("Stopped traefik"), verbosity: :debug execute *KAMAL.traefik.stop, raise_on_non_zero_exit: false @@ -54,7 +54,7 @@ def stop desc "restart", "Restart existing Traefik container on servers" def restart - mutating do + with_lock do stop start end @@ -91,7 +91,7 @@ def logs desc "remove", "Remove Traefik container and image from servers" def remove - mutating do + with_lock do stop remove_container remove_image @@ -100,7 +100,7 @@ def remove desc "remove_container", "Remove Traefik container from servers", hide: true def remove_container - mutating do + with_lock do on(KAMAL.traefik_hosts) do execute *KAMAL.auditor.record("Removed traefik container"), verbosity: :debug execute *KAMAL.traefik.remove_container @@ -110,7 +110,7 @@ def remove_container desc "remove_image", "Remove Traefik image from servers", hide: true def remove_image - mutating do + with_lock do on(KAMAL.traefik_hosts) do execute *KAMAL.auditor.record("Removed traefik image"), verbosity: :debug execute *KAMAL.traefik.remove_image diff --git a/lib/kamal/commander.rb b/lib/kamal/commander.rb index 9937cb694..c28fda82b 100644 --- a/lib/kamal/commander.rb +++ b/lib/kamal/commander.rb @@ -2,12 +2,13 @@ require "active_support/core_ext/module/delegation" class Kamal::Commander - attr_accessor :verbosity, :holding_lock + attr_accessor :verbosity, :holding_lock, :connected delegate :hosts, :roles, :primary_host, :primary_role, :roles_on, :traefik_hosts, :accessory_hosts, to: :specifics def initialize self.verbosity = :info self.holding_lock = false + self.connected = false @specifics = nil end @@ -137,6 +138,10 @@ def holding_lock? self.holding_lock end + def connected? + self.connected + end + private # Lazy setup of SSHKit def configure_sshkit_with(config) diff --git a/test/cli/main_test.rb b/test/cli/main_test.rb index 358a73947..f7e479d7d 100644 --- a/test/cli/main_test.rb +++ b/test/cli/main_test.rb @@ -92,6 +92,9 @@ class CliMainTest < CliTestCase test "deploy when locked" do Thread.report_on_exception = false + SSHKit::Backend::Abstract.any_instance.stubs(:execute) + Dir.stubs(:chdir) + SSHKit::Backend::Abstract.any_instance.stubs(:execute) .with { |*args| args == [ :mkdir, "-p", ".kamal" ] } @@ -113,6 +116,9 @@ class CliMainTest < CliTestCase test "deploy error when locking" do Thread.report_on_exception = false + SSHKit::Backend::Abstract.any_instance.stubs(:execute) + Dir.stubs(:chdir) + SSHKit::Backend::Abstract.any_instance.stubs(:execute) .with { |*args| args == [ :mkdir, "-p", ".kamal" ] } diff --git a/test/cli/server_test.rb b/test/cli/server_test.rb index c3dbb0bbf..5d9fec4d9 100644 --- a/test/cli/server_test.rb +++ b/test/cli/server_test.rb @@ -16,13 +16,15 @@ class CliServerTest < CliTestCase end test "bootstrap already installed" do + stub_setup SSHKit::Backend::Abstract.any_instance.expects(:execute).with(:docker, "-v", raise_on_non_zero_exit: false).returns(true).at_least_once SSHKit::Backend::Abstract.any_instance.expects(:execute).with(:mkdir, "-p", ".kamal").returns("").at_least_once - assert_equal "", run_command("bootstrap") + assert_equal "Acquiring the deploy lock...\nReleasing the deploy lock...", run_command("bootstrap") end test "bootstrap install as non-root user" do + stub_setup SSHKit::Backend::Abstract.any_instance.expects(:execute).with(:docker, "-v", raise_on_non_zero_exit: false).returns(false).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(false).at_least_once SSHKit::Backend::Abstract.any_instance.expects(:execute).with(:mkdir, "-p", ".kamal").returns("").at_least_once @@ -33,11 +35,13 @@ class CliServerTest < CliTestCase end test "bootstrap install as root user" do + stub_setup SSHKit::Backend::Abstract.any_instance.expects(:execute).with(:docker, "-v", raise_on_non_zero_exit: false).returns(false).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(:mkdir, "-p", ".kamal").returns("").at_least_once Kamal::Commands::Hook.any_instance.stubs(:hook_exists?).returns(true) + 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 run_command("bootstrap").tap do |output| From 5ff1203c80a3c7c51c4650a5a9131bf13eb15fd1 Mon Sep 17 00:00:00 2001 From: Donal McBreen Date: Mon, 20 May 2024 15:17:27 +0100 Subject: [PATCH 49/71] Always lock before pre-deploy hook --- lib/kamal/cli/main.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/kamal/cli/main.rb b/lib/kamal/cli/main.rb index 2816ab089..c86418482 100644 --- a/lib/kamal/cli/main.rb +++ b/lib/kamal/cli/main.rb @@ -35,9 +35,9 @@ def deploy invoke "kamal:cli:build:deliver", [], invoke_options end - run_hook "pre-deploy" - with_lock do + run_hook "pre-deploy" + say "Ensure Traefik is running...", :magenta invoke "kamal:cli:traefik:boot", [], invoke_options From 187861fa60b4a027dcc6d3fe3e485bb8a678c2db Mon Sep 17 00:00:00 2001 From: Donal McBreen Date: Tue, 21 May 2024 12:20:19 +0100 Subject: [PATCH 50/71] Space not tab --- lib/kamal/cli/app.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/kamal/cli/app.rb b/lib/kamal/cli/app.rb index ee8ab901e..809607a93 100644 --- a/lib/kamal/cli/app.rb +++ b/lib/kamal/cli/app.rb @@ -13,7 +13,7 @@ def boot end end - # Primary hosts and roles are returned first, so they can open the barrier + # Primary hosts and roles are returned first, so they can open the barrier barrier = Kamal::Cli::Healthcheck::Barrier.new if KAMAL.roles.many? on(KAMAL.hosts, **KAMAL.boot_strategy) do |host| From 89994c8b20647d405e174f74a2c1d64d6c27219c Mon Sep 17 00:00:00 2001 From: Nick Hammond Date: Fri, 24 May 2024 08:59:33 -0700 Subject: [PATCH 51/71] Add grep's context option to show lines before and after a match --- lib/kamal/cli/accessory.rb | 8 +++++--- lib/kamal/cli/app.rb | 9 ++++++--- lib/kamal/cli/traefik.rb | 8 +++++--- lib/kamal/commands/accessory.rb | 8 ++++---- lib/kamal/commands/app/logging.rb | 8 ++++---- lib/kamal/commands/traefik.rb | 8 ++++---- test/cli/accessory_test.rb | 29 +++++++++++++++++++++++++++++ test/cli/app_test.rb | 18 ++++++++++++++++++ test/cli/traefik_test.rb | 14 ++++++++++++++ test/commands/accessory_test.rb | 4 ++++ test/commands/app_test.rb | 22 ++++++++++++++++++++++ test/commands/traefik_test.rb | 6 ++++++ 12 files changed, 121 insertions(+), 21 deletions(-) diff --git a/lib/kamal/cli/accessory.rb b/lib/kamal/cli/accessory.rb index e1d31b779..928af9209 100644 --- a/lib/kamal/cli/accessory.rb +++ b/lib/kamal/cli/accessory.rb @@ -150,22 +150,24 @@ def exec(name, cmd) option :lines, type: :numeric, aliases: "-n", desc: "Number of log lines to pull from each server" option :grep, aliases: "-g", desc: "Show lines with grep match only (use this to fetch specific requests by id)" option :follow, aliases: "-f", desc: "Follow logs on primary server (or specific host set by --hosts)" + option :context, aliases: "-C", desc: "Show number of lines leading and trailing a grep match (use with --grep)" def logs(name) with_accessory(name) do |accessory, hosts| grep = options[:grep] + context = options[:context] if options[:follow] run_locally do info "Following logs on #{hosts}..." - info accessory.follow_logs(grep: grep) - exec accessory.follow_logs(grep: grep) + info accessory.follow_logs(grep: grep, context: context) + exec accessory.follow_logs(grep: grep, context: context) end else since = options[:since] lines = options[:lines].presence || ((since || grep) ? nil : 100) # Default to 100 lines if since or grep isn't set on(hosts) do - puts capture_with_info(*accessory.logs(since: since, lines: lines, grep: grep)) + puts capture_with_info(*accessory.logs(since: since, lines: lines, grep: grep, context: context)) end end end diff --git a/lib/kamal/cli/app.rb b/lib/kamal/cli/app.rb index 809607a93..685b9944a 100644 --- a/lib/kamal/cli/app.rb +++ b/lib/kamal/cli/app.rb @@ -167,11 +167,14 @@ def images option :lines, type: :numeric, aliases: "-n", desc: "Number of lines to show from each server" option :grep, aliases: "-g", desc: "Show lines with grep match only (use this to fetch specific requests by id)" option :follow, aliases: "-f", desc: "Follow log on primary server (or specific host set by --hosts)" + option :context, aliases: "-C", desc: "Show number of lines leading and trailing a grep match (use with --grep)" def logs # FIXME: Catch when app containers aren't running grep = options[:grep] + context = options[:context] since = options[:since] + if options[:follow] lines = options[:lines].presence || ((since || grep) ? nil : 10) # Default to 10 lines if since or grep isn't set @@ -182,8 +185,8 @@ def logs role = KAMAL.roles_on(KAMAL.primary_host).first app = KAMAL.app(role: role, host: host) - info app.follow_logs(host: KAMAL.primary_host, lines: lines, grep: grep) - exec app.follow_logs(host: KAMAL.primary_host, lines: lines, grep: grep) + info app.follow_logs(host: KAMAL.primary_host, lines: lines, grep: grep, context: context) + exec app.follow_logs(host: KAMAL.primary_host, lines: lines, grep: grep, context: context) end else lines = options[:lines].presence || ((since || grep) ? nil : 100) # Default to 100 lines if since or grep isn't set @@ -193,7 +196,7 @@ def logs roles.each do |role| begin - puts_by_host host, capture_with_info(*KAMAL.app(role: role, host: host).logs(since: since, lines: lines, grep: grep)) + puts_by_host host, capture_with_info(*KAMAL.app(role: role, host: host).logs(since: since, lines: lines, grep: grep, context: context)) rescue SSHKit::Command::Failed puts_by_host host, "Nothing found" end diff --git a/lib/kamal/cli/traefik.rb b/lib/kamal/cli/traefik.rb index d192ee1a6..0cd4023f3 100644 --- a/lib/kamal/cli/traefik.rb +++ b/lib/kamal/cli/traefik.rb @@ -70,21 +70,23 @@ def details option :lines, type: :numeric, aliases: "-n", desc: "Number of log lines to pull from each server" option :grep, aliases: "-g", desc: "Show lines with grep match only (use this to fetch specific requests by id)" option :follow, aliases: "-f", desc: "Follow logs on primary server (or specific host set by --hosts)" + option :context, aliases: "-C", desc: "Show number of lines leading and trailing a grep match (use with --grep)" def logs grep = options[:grep] + context = options[:context] if options[:follow] run_locally do info "Following logs on #{KAMAL.primary_host}..." - info KAMAL.traefik.follow_logs(host: KAMAL.primary_host, grep: grep) - exec KAMAL.traefik.follow_logs(host: KAMAL.primary_host, grep: grep) + info KAMAL.traefik.follow_logs(host: KAMAL.primary_host, grep: grep, context: context) + exec KAMAL.traefik.follow_logs(host: KAMAL.primary_host, grep: grep, context: context) end else since = options[:since] lines = options[:lines].presence || ((since || grep) ? nil : 100) # Default to 100 lines if since or grep isn't set on(KAMAL.traefik_hosts) do |host| - puts_by_host host, capture(*KAMAL.traefik.logs(since: since, lines: lines, grep: grep)), type: "Traefik" + puts_by_host host, capture(*KAMAL.traefik.logs(since: since, lines: lines, grep: grep, context: context)), type: "Traefik" end end end diff --git a/lib/kamal/commands/accessory.rb b/lib/kamal/commands/accessory.rb index 661ab7eef..b475be6c5 100644 --- a/lib/kamal/commands/accessory.rb +++ b/lib/kamal/commands/accessory.rb @@ -36,17 +36,17 @@ def info end - def logs(since: nil, lines: nil, grep: nil) + def logs(since: nil, lines: nil, grep: nil, context: nil) pipe \ docker(:logs, service_name, (" --since #{since}" if since), (" --tail #{lines}" if lines), "--timestamps", "2>&1"), - ("grep '#{grep}'" if grep) + ("grep '#{grep}'#{" -C #{context}" if context}" if grep) end - def follow_logs(grep: nil) + def follow_logs(grep: nil, context: nil) run_over_ssh \ pipe \ docker(:logs, service_name, "--timestamps", "--tail", "10", "--follow", "2>&1"), - (%(grep "#{grep}") if grep) + (%(grep "#{grep}"#{" -C #{context}" if context}) if grep) end diff --git a/lib/kamal/commands/app/logging.rb b/lib/kamal/commands/app/logging.rb index 8acb49e97..becc88c67 100644 --- a/lib/kamal/commands/app/logging.rb +++ b/lib/kamal/commands/app/logging.rb @@ -1,17 +1,17 @@ module Kamal::Commands::App::Logging - def logs(version: nil, since: nil, lines: nil, grep: nil) + def logs(version: nil, since: nil, lines: nil, grep: nil, context: nil) pipe \ version ? container_id_for_version(version) : current_running_container_id, "xargs docker logs#{" --since #{since}" if since}#{" --tail #{lines}" if lines} 2>&1", - ("grep '#{grep}'" if grep) + ("grep '#{grep}'#{" -C #{context}" if context}" if grep) end - def follow_logs(host:, lines: nil, grep: nil) + def follow_logs(host:, lines: nil, grep: nil, context: nil) run_over_ssh \ pipe( current_running_container_id, "xargs docker logs --timestamps#{" --tail #{lines}" if lines} --follow 2>&1", - (%(grep "#{grep}") if grep) + (%(grep "#{grep}"#{" -C #{context}" if context}) if grep) ), host: host end diff --git a/lib/kamal/commands/traefik.rb b/lib/kamal/commands/traefik.rb index 569c2c2ca..3b0322ba7 100644 --- a/lib/kamal/commands/traefik.rb +++ b/lib/kamal/commands/traefik.rb @@ -46,16 +46,16 @@ def info docker :ps, "--filter", "name=^traefik$" end - def logs(since: nil, lines: nil, grep: nil) + def logs(since: nil, lines: nil, grep: nil, context: nil) pipe \ docker(:logs, "traefik", (" --since #{since}" if since), (" --tail #{lines}" if lines), "--timestamps", "2>&1"), - ("grep '#{grep}'" if grep) + ("grep '#{grep}'#{" -C #{context}" if context}" if grep) end - def follow_logs(host:, grep: nil) + def follow_logs(host:, grep: nil, context: nil) run_over_ssh pipe( docker(:logs, "traefik", "--timestamps", "--tail", "10", "--follow", "2>&1"), - (%(grep "#{grep}") if grep) + (%(grep "#{grep}"#{" -C #{context}" if context}) if grep) ).join(" "), host: host end diff --git a/test/cli/accessory_test.rb b/test/cli/accessory_test.rb index cb52ee2e1..35e558500 100644 --- a/test/cli/accessory_test.rb +++ b/test/cli/accessory_test.rb @@ -114,6 +114,21 @@ class CliAccessoryTest < CliTestCase .with("ssh -t root@1.1.1.3 'docker logs app-mysql --timestamps --tail 10 2>&1'") assert_match "docker logs app-mysql --tail 100 --timestamps 2>&1", run_command("logs", "mysql") + + end + + test "logs with grep" do + SSHKit::Backend::Abstract.any_instance.stubs(:exec) + .with("ssh -t root@1.1.1.3 'docker logs app-mysql --timestamps 2>&1 | grep \'hey\''") + + assert_match "docker logs app-mysql --timestamps 2>&1 | grep 'hey'", run_command("logs", "mysql", "--grep", "hey") + end + + test "logs with grep and context" do + SSHKit::Backend::Abstract.any_instance.stubs(:exec) + .with("ssh -t root@1.1.1.3 'docker logs app-mysql --timestamps 2>&1 | grep \'hey\' -C 2'") + + assert_match "docker logs app-mysql --timestamps 2>&1 | grep 'hey' -C 2", run_command("logs", "mysql", "--grep", "hey", "--context", "2") end test "logs with follow" do @@ -123,6 +138,20 @@ class CliAccessoryTest < CliTestCase assert_match "docker logs app-mysql --timestamps --tail 10 --follow 2>&1", run_command("logs", "mysql", "--follow") end + test "logs with follow and grep" do + SSHKit::Backend::Abstract.any_instance.stubs(:exec) + .with("ssh -t root@1.1.1.3 -p 22 'docker logs app-mysql --timestamps --tail 10 --follow 2>&1 | grep \"hey\"'") + + assert_match "docker logs app-mysql --timestamps --tail 10 --follow 2>&1 | grep \"hey\"", run_command("logs", "mysql", "--follow", "--grep", "hey") + end + + test "logs with follow, grep, and context" do + SSHKit::Backend::Abstract.any_instance.stubs(:exec) + .with("ssh -t root@1.1.1.3 -p 22 'docker logs app-mysql --timestamps --tail 10 --follow 2>&1 | grep \"hey\" -C 2'") + + assert_match "docker logs app-mysql --timestamps --tail 10 --follow 2>&1 | grep \"hey\" -C 2", run_command("logs", "mysql", "--follow", "--grep", "hey", "--context", "2") + end + test "remove with confirmation" do Kamal::Cli::Accessory.any_instance.expects(:stop).with("mysql") Kamal::Cli::Accessory.any_instance.expects(:remove_container).with("mysql") diff --git a/test/cli/app_test.rb b/test/cli/app_test.rb index 643f8d858..044eadc3a 100644 --- a/test/cli/app_test.rb +++ b/test/cli/app_test.rb @@ -290,6 +290,10 @@ class CliAppTest < CliTestCase .with("ssh -t root@1.1.1.1 'sh -c 'docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''{{.ID}}'\\'') ; docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting' | head -1| xargs docker logs --timestamps --tail 10 2>&1'") assert_match "sh -c 'docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''{{.ID}}'\\'') ; docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting' | head -1 | xargs docker logs --tail 100 2>&1", run_command("logs") + + assert_match "sh -c 'docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''{{.ID}}'\\'') ; docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting' | head -1 | xargs docker logs 2>&1 | grep 'hey'", run_command("logs", "--grep", "hey") + + assert_match "sh -c 'docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''{{.ID}}'\\'') ; docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting' | head -1 | xargs docker logs 2>&1 | grep 'hey' -C 2", run_command("logs", "--grep", "hey", "--context", "2") end test "logs with follow" do @@ -299,6 +303,20 @@ class CliAppTest < CliTestCase assert_match "sh -c '\\''docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''\\'\\'''\\''{{.ID}}'\\''\\'\\'''\\'') ; docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting'\\'' | head -1 | xargs docker logs --timestamps --tail 10 --follow 2>&1", run_command("logs", "--follow") end + test "logs with follow and grep" do + SSHKit::Backend::Abstract.any_instance.stubs(:exec) + .with("ssh -t root@1.1.1.1 -p 22 'sh -c '\\''docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''\\'\\'''\\''{{.ID}}'\\''\\'\\'''\\'') ; docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting'\\'' | head -1 | xargs docker logs --timestamps --follow 2>&1 | grep \"hey\"'") + + assert_match "sh -c '\\''docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''\\'\\'''\\''{{.ID}}'\\''\\'\\'''\\'') ; docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting'\\'' | head -1 | xargs docker logs --timestamps --follow 2>&1 | grep \"hey\"", run_command("logs", "--follow", "--grep", "hey") + end + + test "logs with follow, grep and context" do + SSHKit::Backend::Abstract.any_instance.stubs(:exec) + .with("ssh -t root@1.1.1.1 -p 22 'sh -c '\\''docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''\\'\\'''\\''{{.ID}}'\\''\\'\\'''\\'') ; docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting'\\'' | head -1 | xargs docker logs --timestamps --follow 2>&1 | grep \"hey\" -C 2'") + + assert_match "sh -c '\\''docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''\\'\\'''\\''{{.ID}}'\\''\\'\\'''\\'') ; docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting'\\'' | head -1 | xargs docker logs --timestamps --follow 2>&1 | grep \"hey\" -C 2", run_command("logs", "--follow", "--grep", "hey", "--context", "2") + end + test "version" do run_command("version").tap do |output| assert_match "sh -c 'docker ps --latest --format '\\''{{.Names}}'\\'' --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''{{.ID}}'\\'') ; docker ps --latest --format '\\''{{.Names}}'\\'' --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting' | head -1 | while read line; do echo ${line#app-web-}; done", output diff --git a/test/cli/traefik_test.rb b/test/cli/traefik_test.rb index d83529cd4..73e7ad5d8 100644 --- a/test/cli/traefik_test.rb +++ b/test/cli/traefik_test.rb @@ -69,6 +69,20 @@ class CliTraefikTest < CliTestCase assert_match "docker logs traefik --timestamps --tail 10 --follow", run_command("logs", "--follow") end + test "logs with follow and grep" do + SSHKit::Backend::Abstract.any_instance.stubs(:exec) + .with("ssh -t root@1.1.1.1 -p 22 'docker logs traefik --timestamps --tail 10 --follow 2>&1 | grep \"hey\"'") + + assert_match "docker logs traefik --timestamps --tail 10 --follow 2>&1 | grep \"hey\"", run_command("logs", "--follow", "--grep", "hey") + end + + test "logs with follow, grep, and context" do + SSHKit::Backend::Abstract.any_instance.stubs(:exec) + .with("ssh -t root@1.1.1.1 -p 22 'docker logs traefik --timestamps --tail 10 --follow 2>&1 | grep \"hey\" -C 2'") + + assert_match "docker logs traefik --timestamps --tail 10 --follow 2>&1 | grep \"hey\" -C 2", run_command("logs", "--follow", "--grep", "hey", "--context", "2") + end + test "remove" do Kamal::Cli::Traefik.any_instance.expects(:stop) Kamal::Cli::Traefik.any_instance.expects(:remove_container) diff --git a/test/commands/accessory_test.rb b/test/commands/accessory_test.rb index da8c30571..c8b33fa37 100644 --- a/test/commands/accessory_test.rb +++ b/test/commands/accessory_test.rb @@ -125,6 +125,10 @@ class CommandsAccessoryTest < ActiveSupport::TestCase assert_equal \ "docker logs app-mysql --since 5m --tail 100 --timestamps 2>&1 | grep 'thing'", new_command(:mysql).logs(since: "5m", lines: 100, grep: "thing").join(" ") + + assert_equal \ + "docker logs app-mysql --since 5m --tail 100 --timestamps 2>&1 | grep 'thing' -C 2", + new_command(:mysql).logs(since: "5m", lines: 100, grep: "thing", context: 2).join(" ") end test "follow logs" do diff --git a/test/commands/app_test.rb b/test/commands/app_test.rb index 2f9dc57fd..226dd1666 100644 --- a/test/commands/app_test.rb +++ b/test/commands/app_test.rb @@ -139,23 +139,45 @@ class CommandsAppTest < ActiveSupport::TestCase assert_equal \ "sh -c 'docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''{{.ID}}'\\'') ; docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting' | head -1 | xargs docker logs 2>&1", new_command.logs.join(" ") + end + test "logs with since" do assert_equal \ "sh -c 'docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''{{.ID}}'\\'') ; docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting' | head -1 | xargs docker logs --since 5m 2>&1", new_command.logs(since: "5m").join(" ") + end + test "logs with lines" do assert_equal \ "sh -c 'docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''{{.ID}}'\\'') ; docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting' | head -1 | xargs docker logs --tail 100 2>&1", new_command.logs(lines: "100").join(" ") + end + test "logs with since and lines" do assert_equal \ "sh -c 'docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''{{.ID}}'\\'') ; docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting' | head -1 | xargs docker logs --since 5m --tail 100 2>&1", new_command.logs(since: "5m", lines: "100").join(" ") + end + test "logs with grep" do assert_equal \ "sh -c 'docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''{{.ID}}'\\'') ; docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting' | head -1 | xargs docker logs 2>&1 | grep 'my-id'", new_command.logs(grep: "my-id").join(" ") + end + + test "logs with grep and context" do + assert_equal \ + "sh -c 'docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''{{.ID}}'\\'') ; docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting' | head -1 | xargs docker logs 2>&1 | grep 'my-id' -C 2", + new_command.logs(grep: "my-id", context: 2).join(" ") + end + + test "logs with since, grep and context" do + assert_equal \ + "sh -c 'docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''{{.ID}}'\\'') ; docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting' | head -1 | xargs docker logs --since 5m 2>&1 | grep 'my-id' -C 2", + new_command.logs(since: "5m", grep: "my-id", context: 2).join(" ") + end + test "logs with since and grep" do assert_equal \ "sh -c 'docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''{{.ID}}'\\'') ; docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting' | head -1 | xargs docker logs --since 5m 2>&1 | grep 'my-id'", new_command.logs(since: "5m", grep: "my-id").join(" ") diff --git a/test/commands/traefik_test.rb b/test/commands/traefik_test.rb index 157670a33..ececa5fec 100644 --- a/test/commands/traefik_test.rb +++ b/test/commands/traefik_test.rb @@ -153,6 +153,12 @@ class CommandsTraefikTest < ActiveSupport::TestCase new_command.logs(grep: "hello!").join(" ") end + test "traefik logs with grep hello! and context" do + assert_equal \ + "docker logs traefik --timestamps 2>&1 | grep 'hello!' -C 2", + new_command.logs(grep: "hello!", context: 2).join(" ") + end + test "traefik remove container" do assert_equal \ "docker container prune --force --filter label=org.opencontainers.image.title=Traefik", From eb79d93139723069709ca9239e36ade0013b7f4b Mon Sep 17 00:00:00 2001 From: Nick Hammond Date: Fri, 24 May 2024 09:16:14 -0700 Subject: [PATCH 52/71] Run RC --- test/cli/accessory_test.rb | 1 - 1 file changed, 1 deletion(-) diff --git a/test/cli/accessory_test.rb b/test/cli/accessory_test.rb index 35e558500..0ca8e42dd 100644 --- a/test/cli/accessory_test.rb +++ b/test/cli/accessory_test.rb @@ -114,7 +114,6 @@ class CliAccessoryTest < CliTestCase .with("ssh -t root@1.1.1.3 'docker logs app-mysql --timestamps --tail 10 2>&1'") assert_match "docker logs app-mysql --tail 100 --timestamps 2>&1", run_command("logs", "mysql") - end test "logs with grep" do From beac539d8cc2a18861c97161dbf0873d3f370e14 Mon Sep 17 00:00:00 2001 From: fabiosammy Date: Fri, 24 May 2024 13:25:01 -0300 Subject: [PATCH 53/71] Fix the header template of the docker-setup hook --- lib/kamal/cli/templates/sample_hooks/docker-setup.sample | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/kamal/cli/templates/sample_hooks/docker-setup.sample b/lib/kamal/cli/templates/sample_hooks/docker-setup.sample index fe68b9373..ce263fffe 100755 --- a/lib/kamal/cli/templates/sample_hooks/docker-setup.sample +++ b/lib/kamal/cli/templates/sample_hooks/docker-setup.sample @@ -1,4 +1,4 @@ -#!/usr/bin/env ruby +#!/bin/sh # A sample docker-setup hook # From 2c2053558aa5e43425b411b4d5865970f35fc121 Mon Sep 17 00:00:00 2001 From: Donal McBreen Date: Mon, 27 May 2024 11:11:51 +0100 Subject: [PATCH 54/71] Handle corrupt git clones When cloning the git repo: 1. Try to clone 2. If there's already a build directory reset it 3. Check the clone is valid If anything goes wrong during that process: 1. Delete the clone directory 2. Clone it again 3. Check the clone is valid Raise any errors after that --- lib/kamal/cli/build.rb | 23 +------ lib/kamal/cli/build/clone.rb | 61 +++++++++++++++++++ lib/kamal/commands/builder.rb | 20 +------ lib/kamal/commands/builder/clone.rb | 28 +++++++++ test/cli/build_test.rb | 93 ++++++++++++++++++++++------- test/cli/main_test.rb | 16 +++++ 6 files changed, 183 insertions(+), 58 deletions(-) create mode 100644 lib/kamal/cli/build/clone.rb create mode 100644 lib/kamal/commands/builder/clone.rb diff --git a/lib/kamal/cli/build.rb b/lib/kamal/cli/build.rb index 0af76b8c6..45b53eb72 100644 --- a/lib/kamal/cli/build.rb +++ b/lib/kamal/cli/build.rb @@ -23,7 +23,9 @@ def push say "Building from a local git clone, so ignoring these uncommitted changes:\n #{uncommitted_changes}", :yellow end - prepare_clone + run_locally do + Clone.new(self).prepare + end elsif uncommitted_changes.present? say "Building with uncommitted changes:\n #{uncommitted_changes}", :yellow end @@ -126,23 +128,4 @@ def connect_to_remote_host(remote_host) end end end - - def prepare_clone - run_locally do - begin - info "Cloning repo into build directory `#{KAMAL.config.builder.build_directory}`..." - - execute *KAMAL.builder.create_clone_directory - execute *KAMAL.builder.clone - rescue SSHKit::Command::Failed => e - if e.message =~ /already exists and is not an empty directory/ - info "Resetting local clone as `#{KAMAL.config.builder.build_directory}` already exists..." - - KAMAL.builder.clone_reset_steps.each { |step| execute *step } - else - raise - end - end - end - end end diff --git a/lib/kamal/cli/build/clone.rb b/lib/kamal/cli/build/clone.rb new file mode 100644 index 000000000..98a1751a8 --- /dev/null +++ b/lib/kamal/cli/build/clone.rb @@ -0,0 +1,61 @@ +require "uri" + +class Kamal::Cli::Build::Clone + attr_reader :sshkit + delegate :info, :error, :execute, :capture_with_info, to: :sshkit + + def initialize(sshkit) + @sshkit = sshkit + end + + def prepare + begin + clone_repo + rescue SSHKit::Command::Failed => e + if e.message =~ /already exists and is not an empty directory/ + reset + else + raise Kamal::Cli::Build::BuildError, "Failed to clone repo: #{e.message}" + end + end + + validate! + rescue Kamal::Cli::Build::BuildError => e + error "Error preparing clone: #{e.message}, deleting and retrying..." + + FileUtils.rm_rf KAMAL.config.builder.clone_directory + clone_repo + validate! + end + + private + def clone_repo + info "Cloning repo into build directory `#{KAMAL.config.builder.build_directory}`..." + + FileUtils.mkdir_p KAMAL.config.builder.clone_directory + execute *KAMAL.builder.clone + end + + def reset + info "Resetting local clone as `#{KAMAL.config.builder.build_directory}` already exists..." + + KAMAL.builder.clone_reset_steps.each { |step| execute *step } + rescue SSHKit::Command::Failed => e + raise Kamal::Cli::Build::BuildError, "Failed to clone repo: #{e.message}" + end + + def validate! + status = capture_with_info(*KAMAL.builder.clone_status).strip + + unless status.empty? + raise Kamal::Cli::Build::BuildError, "Clone in #{KAMAL.config.builder.build_directory} is dirty, #{status}" + end + + revision = capture_with_info(*KAMAL.builder.clone_revision).strip + if revision != Kamal::Git.revision + raise Kamal::Cli::Build::BuildError, "Clone in #{KAMAL.config.builder.build_directory} is not on the correct revision, expected `#{Kamal::Git.revision}` but got `#{revision}`" + end + rescue SSHKit::Command::Failed => e + raise Kamal::Cli::Build::BuildError, "Failed to validate clone: #{e.message}" + end +end diff --git a/lib/kamal/commands/builder.rb b/lib/kamal/commands/builder.rb index d8fa9fcf1..2e5862a8a 100644 --- a/lib/kamal/commands/builder.rb +++ b/lib/kamal/commands/builder.rb @@ -2,7 +2,8 @@ class Kamal::Commands::Builder < Kamal::Commands::Base delegate :create, :remove, :push, :clean, :pull, :info, :validate_image, to: :target - delegate :clone_directory, :build_directory, to: :"config.builder" + + include Clone def name target.class.to_s.remove("Kamal::Commands::Builder::").underscore.inquiry @@ -54,23 +55,6 @@ def ensure_local_dependencies_installed end end - def create_clone_directory - make_directory clone_directory - end - - def clone - git :clone, Kamal::Git.root, path: clone_directory - end - - def clone_reset_steps - [ - git(:remote, "set-url", :origin, Kamal::Git.root, path: build_directory), - git(:fetch, :origin, path: build_directory), - git(:reset, "--hard", Kamal::Git.revision, path: build_directory), - git(:clean, "-fdx", path: build_directory) - ] - end - private def ensure_local_docker_installed docker "--version" diff --git a/lib/kamal/commands/builder/clone.rb b/lib/kamal/commands/builder/clone.rb new file mode 100644 index 000000000..b40e9e4ef --- /dev/null +++ b/lib/kamal/commands/builder/clone.rb @@ -0,0 +1,28 @@ +module Kamal::Commands::Builder::Clone + extend ActiveSupport::Concern + + included do + delegate :clone_directory, :build_directory, to: :"config.builder" + end + + def clone + git :clone, Kamal::Git.root, path: clone_directory + end + + def clone_reset_steps + [ + git(:remote, "set-url", :origin, Kamal::Git.root, path: build_directory), + git(:fetch, :origin, path: build_directory), + git(:reset, "--hard", Kamal::Git.revision, path: build_directory), + git(:clean, "-fdx", path: build_directory) + ] + end + + def clone_status + git :status, "--porcelain", path: build_directory + end + + def clone_revision + git :"rev-parse", :HEAD, path: build_directory + end +end diff --git a/test/cli/build_test.rb b/test/cli/build_test.rb index 606aaf971..b0bbd83bb 100644 --- a/test/cli/build_test.rb +++ b/test/cli/build_test.rb @@ -9,10 +9,18 @@ class CliBuildTest < CliTestCase end test "push" do - with_build_directory do + with_build_directory do |build_directory| Kamal::Commands::Hook.any_instance.stubs(:hook_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) + .with(:git, "-C", anything, :"rev-parse", :HEAD) + .returns(Kamal::Git.revision) + + SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info) + .with(:git, "-C", anything, :status, "--porcelain") + .returns("") + run_command("push", "--verbose").tap do |output| assert_hook_ran "pre-build", output, **hook_variables assert_match /Cloning repo into build directory/, output @@ -23,28 +31,33 @@ class CliBuildTest < CliTestCase end end - test "push reseting clone" do - with_build_directory do + test "push resetting clone" do + with_build_directory do |build_directory| stub_setup - build_dir = "#{Dir.tmpdir}/kamal-clones/app-#{pwd_sha}/kamal/" - SSHKit::Backend::Abstract.any_instance.stubs(:execute).with(:docker, "--version", "&&", :docker, :buildx, "version") - SSHKit::Backend::Abstract.any_instance.stubs(:execute).with(:docker, :buildx, :create, "--use", "--name", "kamal-app-multiarch") + SSHKit::Backend::Abstract.any_instance.expects(:execute).with(:docker, "--version", "&&", :docker, :buildx, "version") - SSHKit::Backend::Abstract.any_instance.stubs(:execute).with(:mkdir, "-p", "#{Dir.tmpdir}/kamal-clones/app-#{pwd_sha}") - SSHKit::Backend::Abstract.any_instance.stubs(:execute) + SSHKit::Backend::Abstract.any_instance.expects(:execute) .with(:git, "-C", "#{Dir.tmpdir}/kamal-clones/app-#{pwd_sha}", :clone, Dir.pwd) .raises(SSHKit::Command::Failed.new("fatal: destination path 'kamal' already exists and is not an empty directory")) .then .returns(true) - SSHKit::Backend::Abstract.any_instance.stubs(:execute).with(:git, "-C", build_dir, :remote, "set-url", :origin, Dir.pwd) - SSHKit::Backend::Abstract.any_instance.stubs(:execute).with(:git, "-C", build_dir, :fetch, :origin) - SSHKit::Backend::Abstract.any_instance.stubs(:execute).with(:git, "-C", build_dir, :reset, "--hard", Kamal::Git.revision) - SSHKit::Backend::Abstract.any_instance.stubs(:execute).with(:git, "-C", build_dir, :clean, "-fdx") + SSHKit::Backend::Abstract.any_instance.expects(:execute).with(:git, "-C", build_directory, :remote, "set-url", :origin, Dir.pwd) + SSHKit::Backend::Abstract.any_instance.expects(:execute).with(:git, "-C", build_directory, :fetch, :origin) + SSHKit::Backend::Abstract.any_instance.expects(:execute).with(:git, "-C", build_directory, :reset, "--hard", Kamal::Git.revision) + SSHKit::Backend::Abstract.any_instance.expects(:execute).with(:git, "-C", build_directory, :clean, "-fdx") - SSHKit::Backend::Abstract.any_instance.stubs(:execute) + SSHKit::Backend::Abstract.any_instance.expects(:execute) .with(:docker, :buildx, :build, "--push", "--platform", "linux/amd64,linux/arm64", "--builder", "kamal-app-multiarch", "-t", "dhh/app:999", "-t", "dhh/app:latest", "--label", "service=\"app\"", "--file", "Dockerfile", ".") + SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info) + .with(:git, "-C", anything, :"rev-parse", :HEAD) + .returns(Kamal::Git.revision) + + SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info) + .with(:git, "-C", anything, :status, "--porcelain") + .returns("") + run_command("push", "--verbose").tap do |output| assert_match /Cloning repo into build directory/, output assert_match /Resetting local clone/, output @@ -64,25 +77,65 @@ class CliBuildTest < CliTestCase end end + test "push with corrupt clone" do + with_build_directory do |build_directory| + stub_setup + + SSHKit::Backend::Abstract.any_instance.expects(:execute).with(:docker, "--version", "&&", :docker, :buildx, "version") + + SSHKit::Backend::Abstract.any_instance.expects(:execute) + .with(:git, "-C", "#{Dir.tmpdir}/kamal-clones/app-#{pwd_sha}", :clone, Dir.pwd) + .raises(SSHKit::Command::Failed.new("fatal: destination path 'kamal' already exists and is not an empty directory")) + .then + .returns(true) + .twice + + SSHKit::Backend::Abstract.any_instance.expects(:execute).with(:git, "-C", build_directory, :remote, "set-url", :origin, Dir.pwd) + .raises(SSHKit::Command::Failed.new("fatal: not a git repository")) + + SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info) + .with(:git, "-C", anything, :"rev-parse", :HEAD) + .returns(Kamal::Git.revision) + + SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info) + .with(:git, "-C", anything, :status, "--porcelain") + .returns("") + + Dir.stubs(:chdir) + + run_command("push", "--verbose") do |output| + assert_match /Cloning repo into build directory `#{build_directory}`\.\.\..*Cloning repo into build directory `#{build_directory}`\.\.\./, output + assert_match "Resetting local clone as `#{build_directory}` already exists...", output + assert_match "Error preparing clone: Failed to clone repo: fatal: not a git repository, deleting and retrying...", output + end + end + end + test "push without builder" do - with_build_directory do + with_build_directory do |build_directory| stub_setup - SSHKit::Backend::Abstract.any_instance.stubs(:execute) + SSHKit::Backend::Abstract.any_instance.expects(:execute) .with(:docker, "--version", "&&", :docker, :buildx, "version") - SSHKit::Backend::Abstract.any_instance.stubs(:execute) + SSHKit::Backend::Abstract.any_instance.expects(:execute) .with(:docker, :buildx, :create, "--use", "--name", "kamal-app-multiarch") - SSHKit::Backend::Abstract.any_instance.stubs(:execute) + SSHKit::Backend::Abstract.any_instance.expects(:execute) .with { |*args| args[0..1] == [ :docker, :buildx ] } .raises(SSHKit::Command::Failed.new("no builder")) .then .returns(true) - SSHKit::Backend::Abstract.any_instance.stubs(:execute).with { |*args| args.first.start_with?("git") } + SSHKit::Backend::Abstract.any_instance.expects(:execute).with { |*args| args.first.start_with?("git") } + + SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info) + .with(:git, "-C", anything, :"rev-parse", :HEAD) + .returns(Kamal::Git.revision) - SSHKit::Backend::Abstract.any_instance.stubs(:execute).with(:mkdir, "-p", "#{Dir.tmpdir}/kamal-clones/app-#{pwd_sha}") + SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info) + .with(:git, "-C", anything, :status, "--porcelain") + .returns("") run_command("push").tap do |output| assert_match /WARN Missing compatible builder, so creating a new one first/, output @@ -183,7 +236,7 @@ def with_build_directory build_directory = File.join Dir.tmpdir, "kamal-clones", "app-#{pwd_sha}", "kamal" FileUtils.mkdir_p build_directory FileUtils.touch File.join build_directory, "Dockerfile" - yield + yield build_directory + "/" ensure FileUtils.rm_rf build_directory end diff --git a/test/cli/main_test.rb b/test/cli/main_test.rb index f7e479d7d..595556fe0 100644 --- a/test/cli/main_test.rb +++ b/test/cli/main_test.rb @@ -108,6 +108,14 @@ class CliMainTest < CliTestCase SSHKit::Backend::Abstract.any_instance.expects(:capture_with_debug) .with(:stat, ".kamal/locks/app", ">", "/dev/null", "&&", :cat, ".kamal/locks/app/details", "|", :base64, "-d") + SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info) + .with(:git, "-C", anything, :"rev-parse", :HEAD) + .returns(Kamal::Git.revision) + + SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info) + .with(:git, "-C", anything, :status, "--porcelain") + .returns("") + assert_raises(Kamal::Cli::LockError) do run_command("deploy") end @@ -129,6 +137,14 @@ class CliMainTest < CliTestCase .with { |*arg| arg[0..1] == [ :mkdir, ".kamal/locks/app" ] } .raises(SocketError, "getaddrinfo: nodename nor servname provided, or not known") + SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info) + .with(:git, "-C", anything, :"rev-parse", :HEAD) + .returns(Kamal::Git.revision) + + SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info) + .with(:git, "-C", anything, :status, "--porcelain") + .returns("") + assert_raises(SSHKit::Runner::ExecuteError) do run_command("deploy") end From 6e60ab918a801cdd859f7c7796bd0565768f6745 Mon Sep 17 00:00:00 2001 From: Donal McBreen Date: Mon, 3 Jun 2024 08:34:12 +0100 Subject: [PATCH 55/71] Bump version for 1.6.0 --- Gemfile.lock | 2 +- lib/kamal/version.rb | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index 4dc54b335..b275d9e0b 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,7 +1,7 @@ PATH remote: . specs: - kamal (1.5.2) + kamal (1.6.0) activesupport (>= 7.0) base64 (~> 0.2) bcrypt_pbkdf (~> 1.0) diff --git a/lib/kamal/version.rb b/lib/kamal/version.rb index f5d10d62c..39d3008c9 100644 --- a/lib/kamal/version.rb +++ b/lib/kamal/version.rb @@ -1,3 +1,3 @@ module Kamal - VERSION = "1.5.2" + VERSION = "1.6.0" end From 4f317b849996e73bd09731e6b804867b2d4c0eaf Mon Sep 17 00:00:00 2001 From: Donal McBreen Date: Tue, 28 May 2024 09:25:42 +0100 Subject: [PATCH 56/71] Configuration validation Validate the Kamal configuration giving useful warning on errors. Each section of the configuration has its own config class and a YAML file containing documented example configuration. You can run `kamal docs` to see the example configuration, and `kamal docs
` to see the example configuration for a specific section. The validation matches the configuration to the example configuration checking that there are no unknown keys and that the values are of matching types. Where there is more complex validation - e.g for envs and servers, we have custom validators that implement those rules. Additonally the configuration examples are used to generate the configuration documentation in the kamal-site repo. You generate them by running: ``` bundle exec bin/docs ``` --- bin/docs | 134 +++++++++++++++ lib/kamal.rb | 2 + lib/kamal/cli.rb | 2 +- lib/kamal/cli/healthcheck/poller.rb | 4 +- lib/kamal/cli/main.rb | 12 ++ lib/kamal/commands/builder.rb | 25 +-- lib/kamal/commands/registry.rb | 17 +- lib/kamal/commands/traefik.rb | 47 +----- lib/kamal/configuration.rb | 109 +++++------- lib/kamal/configuration/accessory.rb | 71 ++++---- lib/kamal/configuration/boot.rb | 13 +- lib/kamal/configuration/builder.rb | 66 ++++---- lib/kamal/configuration/docs/accessory.yml | 90 ++++++++++ lib/kamal/configuration/docs/boot.yml | 19 +++ lib/kamal/configuration/docs/builder.yml | 107 ++++++++++++ .../configuration/docs/configuration.yml | 157 ++++++++++++++++++ lib/kamal/configuration/docs/env.yml | 72 ++++++++ lib/kamal/configuration/docs/healthcheck.yml | 59 +++++++ lib/kamal/configuration/docs/logging.yml | 21 +++ lib/kamal/configuration/docs/registry.yml | 49 ++++++ lib/kamal/configuration/docs/role.yml | 52 ++++++ lib/kamal/configuration/docs/servers.yml | 27 +++ lib/kamal/configuration/docs/ssh.yml | 46 +++++ lib/kamal/configuration/docs/sshkit.yml | 23 +++ lib/kamal/configuration/docs/traefik.yml | 62 +++++++ lib/kamal/configuration/env.rb | 24 ++- lib/kamal/configuration/env/tag.rb | 2 +- lib/kamal/configuration/healthcheck.rb | 63 +++++++ lib/kamal/configuration/logging.rb | 33 ++++ lib/kamal/configuration/registry.rb | 31 ++++ lib/kamal/configuration/role.rb | 118 ++++++------- lib/kamal/configuration/servers.rb | 18 ++ lib/kamal/configuration/ssh.rb | 19 ++- lib/kamal/configuration/sshkit.rb | 16 +- lib/kamal/configuration/traefik.rb | 60 +++++++ lib/kamal/configuration/validation.rb | 27 +++ lib/kamal/configuration/validator.rb | 140 ++++++++++++++++ .../configuration/validator/accessory.rb | 9 + lib/kamal/configuration/validator/builder.rb | 9 + lib/kamal/configuration/validator/env.rb | 54 ++++++ lib/kamal/configuration/validator/registry.rb | 25 +++ lib/kamal/configuration/validator/role.rb | 11 ++ lib/kamal/configuration/validator/servers.rb | 7 + test/cli/main_test.rb | 71 ++++---- test/cli/traefik_test.rb | 4 +- test/commander_test.rb | 8 +- test/commands/accessory_test.rb | 2 +- test/commands/traefik_test.rb | 2 +- test/configuration/accessory_test.rb | 24 ++- test/configuration/builder_test.rb | 85 +++++----- test/configuration/env_test.rb | 6 +- test/configuration/role_test.rb | 21 +-- test/configuration/validation_test.rb | 116 +++++++++++++ test/configuration_test.rb | 47 +++--- .../deploy_primary_web_role_override.yml | 4 +- test/fixtures/deploy_with_aliases.yml | 36 ---- .../deploy_with_multiple_traefik_roles.yml | 34 ++++ test/integration/integration_test.rb | 2 +- test/integration/main_test.rb | 2 +- 59 files changed, 1939 insertions(+), 477 deletions(-) create mode 100755 bin/docs create mode 100644 lib/kamal/configuration/docs/accessory.yml create mode 100644 lib/kamal/configuration/docs/boot.yml create mode 100644 lib/kamal/configuration/docs/builder.yml create mode 100644 lib/kamal/configuration/docs/configuration.yml create mode 100644 lib/kamal/configuration/docs/env.yml create mode 100644 lib/kamal/configuration/docs/healthcheck.yml create mode 100644 lib/kamal/configuration/docs/logging.yml create mode 100644 lib/kamal/configuration/docs/registry.yml create mode 100644 lib/kamal/configuration/docs/role.yml create mode 100644 lib/kamal/configuration/docs/servers.yml create mode 100644 lib/kamal/configuration/docs/ssh.yml create mode 100644 lib/kamal/configuration/docs/sshkit.yml create mode 100644 lib/kamal/configuration/docs/traefik.yml create mode 100644 lib/kamal/configuration/healthcheck.rb create mode 100644 lib/kamal/configuration/logging.rb create mode 100644 lib/kamal/configuration/registry.rb create mode 100644 lib/kamal/configuration/servers.rb create mode 100644 lib/kamal/configuration/traefik.rb create mode 100644 lib/kamal/configuration/validation.rb create mode 100644 lib/kamal/configuration/validator.rb create mode 100644 lib/kamal/configuration/validator/accessory.rb create mode 100644 lib/kamal/configuration/validator/builder.rb create mode 100644 lib/kamal/configuration/validator/env.rb create mode 100644 lib/kamal/configuration/validator/registry.rb create mode 100644 lib/kamal/configuration/validator/role.rb create mode 100644 lib/kamal/configuration/validator/servers.rb create mode 100644 test/configuration/validation_test.rb delete mode 100644 test/fixtures/deploy_with_aliases.yml create mode 100644 test/fixtures/deploy_with_multiple_traefik_roles.yml diff --git a/bin/docs b/bin/docs new file mode 100755 index 000000000..08b2937a7 --- /dev/null +++ b/bin/docs @@ -0,0 +1,134 @@ +#!/usr/bin/env ruby +require "stringio" + +def usage + puts "Usage: #{$0} " + exit 1 +end + +usage if ARGV.size != 1 + +kamal_site_repo = ARGV[0] + +if !File.directory?(kamal_site_repo) + puts "Error: #{kamal_site_repo} is not a directory" + exit 1 +end + +DOCS = { + "accessory" => "Accessories", + "boot" => "Booting", + "builder" => "Builders", + "configuration" => "Configuration overview", + "env" => "Environment variables", + "healthcheck" => "Healthchecks", + "logging" => "Logging", + "registry" => "Docker Registry", + "role" => "Roles", + "servers" => "Servers", + "ssh" => "SSH", + "sshkit" => "SSHKit", + "traefik" => "Traefik" +} + +class DocWriter + attr_reader :from_file, :to_file, :key, :heading, :body, :output, :in_yaml + + def initialize(from_file, to_dir) + @from_file = from_file + @key = File.basename(from_file, ".yml") + @to_file = File.join(to_dir, "#{linkify(DOCS[key])}.md") + @body = File.readlines(from_file) + @heading = body.shift.chomp("\n") + @output = nil + end + + def write + puts "Writing #{to_file}" + generate_markdown + File.write(to_file, output.string) + end + + private + def generate_markdown + @output = StringIO.new + + generate_header + + place = :in_section + + loop do + line = body.shift&.chomp("\n") + break if line.nil? + + case place + when :new_section, :in_section + if line.empty? + output.puts + place = :new_section + elsif line =~ /^ *#/ + generate_line(line, place: place) + place = :in_section + else + output.puts "```yaml" + output.print line + place = :in_yaml + end + when :in_yaml + if line =~ /^ *#/ + output.puts "```" + generate_line(line, place: :new_section) + place = :in_section + else + output.puts + output.print line + end + end + end + + output.puts "\n```" if place == :in_yaml + end + + def generate_header + output.puts "---" + output.puts "title: #{heading[2..-1]}" + output.puts "---" + output.puts + output.puts heading + output.puts + end + + def generate_line(line, place: :in_section) + line = line.gsub(/^ *#\s?/, "") + + if line =~ /(.*)kamal docs ([a-z]*)(.*)/ + line = "#{$1}[#{DOCS[$2]}](../#{linkify(DOCS[$2])})#{$3}" + end + + if line =~ /(.*)https:\/\/kamal-deploy.org([a-z\/-]*)(.*)/ + line = "#{$1}[#{titlify($2.split("/").last)}](#{$2})#{$3}" + end + + if place == :new_section + output.puts "## [#{line}](##{linkify(line)})" + else + output.puts line + end + end + + def linkify(text) + text.downcase.gsub(" ", "-") + end + + def titlify(text) + text.capitalize.gsub("-", " ") + end +end + +from_dir = File.join(File.dirname(__FILE__), "../lib/kamal/configuration/docs") +to_dir = File.join(kamal_site_repo, "docs/configuration") +Dir.glob("#{from_dir}/*") do |from_file| + key = File.basename(from_file, ".yml") + + DocWriter.new(from_file, to_dir).write +end diff --git a/lib/kamal.rb b/lib/kamal.rb index 6b4800a77..2da2bbf2c 100644 --- a/lib/kamal.rb +++ b/lib/kamal.rb @@ -1,8 +1,10 @@ module Kamal + class ConfigurationError < StandardError; end end require "active_support" require "zeitwerk" +require "yaml" loader = Zeitwerk::Loader.for_gem loader.ignore(File.join(__dir__, "kamal", "sshkit_with_ext.rb")) diff --git a/lib/kamal/cli.rb b/lib/kamal/cli.rb index c1501ddb3..6772556e3 100644 --- a/lib/kamal/cli.rb +++ b/lib/kamal/cli.rb @@ -1,6 +1,6 @@ module Kamal::Cli - class LockError < StandardError; end class HookError < StandardError; end + class LockError < StandardError; end end # SSHKit uses instance eval, so we need a global const for ergonomics diff --git a/lib/kamal/cli/healthcheck/poller.rb b/lib/kamal/cli/healthcheck/poller.rb index 06898b1cf..249a1f6b5 100644 --- a/lib/kamal/cli/healthcheck/poller.rb +++ b/lib/kamal/cli/healthcheck/poller.rb @@ -6,7 +6,7 @@ module Kamal::Cli::Healthcheck::Poller def wait_for_healthy(pause_after_ready: false, &block) attempt = 1 - max_attempts = KAMAL.config.healthcheck["max_attempts"] + max_attempts = KAMAL.config.healthcheck.max_attempts begin case status = block.call @@ -33,7 +33,7 @@ def wait_for_healthy(pause_after_ready: false, &block) def wait_for_unhealthy(pause_after_ready: false, &block) attempt = 1 - max_attempts = KAMAL.config.healthcheck["max_attempts"] + max_attempts = KAMAL.config.healthcheck.max_attempts begin case status = block.call diff --git a/lib/kamal/cli/main.rb b/lib/kamal/cli/main.rb index c86418482..6b3087ae7 100644 --- a/lib/kamal/cli/main.rb +++ b/lib/kamal/cli/main.rb @@ -126,6 +126,18 @@ def config end end + desc "docs", "Show Kamal documentation for configuration setting" + def docs(section = nil) + case section + when NilClass + puts Kamal::Configuration.validation_doc + else + puts Kamal::Configuration.const_get(section.titlecase.to_sym).validation_doc + end + rescue NameError + puts "No documentation found for #{section}" + end + desc "init", "Create config stub in config/deploy.yml and env stub in .env" option :bundle, type: :boolean, default: false, desc: "Add Kamal to the Gemfile and create a bin/kamal binstub" def init diff --git a/lib/kamal/commands/builder.rb b/lib/kamal/commands/builder.rb index 2e5862a8a..21f8e75f4 100644 --- a/lib/kamal/commands/builder.rb +++ b/lib/kamal/commands/builder.rb @@ -10,17 +10,22 @@ def name end def target - case - when !config.builder.multiarch? && !config.builder.cached? - native - when !config.builder.multiarch? && config.builder.cached? - native_cached - when config.builder.local? && config.builder.remote? - multiarch_remote - when config.builder.remote? - native_remote + if config.builder.multiarch? + if config.builder.remote? + if config.builder.local? + multiarch_remote + else + native_remote + end + else + multiarch + end else - multiarch + if config.builder.cached? + native_cached + else + native + end end end diff --git a/lib/kamal/commands/registry.rb b/lib/kamal/commands/registry.rb index e13c90acf..69f953608 100644 --- a/lib/kamal/commands/registry.rb +++ b/lib/kamal/commands/registry.rb @@ -3,21 +3,12 @@ class Kamal::Commands::Registry < Kamal::Commands::Base def login docker :login, - registry["server"], - "-u", sensitive(Kamal::Utils.escape_shell_value(lookup("username"))), - "-p", sensitive(Kamal::Utils.escape_shell_value(lookup("password"))) + registry.server, + "-u", sensitive(Kamal::Utils.escape_shell_value(registry.username)), + "-p", sensitive(Kamal::Utils.escape_shell_value(registry.password)) end def logout - docker :logout, registry["server"] + docker :logout, registry.server end - - private - def lookup(key) - if registry[key].is_a?(Array) - ENV.fetch(registry[key].first).dup - else - registry[key] - end - end end diff --git a/lib/kamal/commands/traefik.rb b/lib/kamal/commands/traefik.rb index 569c2c2ca..fc1c32e85 100644 --- a/lib/kamal/commands/traefik.rb +++ b/lib/kamal/commands/traefik.rb @@ -1,19 +1,6 @@ class Kamal::Commands::Traefik < Kamal::Commands::Base delegate :argumentize, :optionize, to: Kamal::Utils - - DEFAULT_IMAGE = "traefik:v2.10" - CONTAINER_PORT = 80 - DEFAULT_ARGS = { - "log.level" => "DEBUG" - } - DEFAULT_LABELS = { - # These ensure we serve a 502 rather than a 404 if no containers are available - "traefik.http.routers.catchall.entryPoints" => "http", - "traefik.http.routers.catchall.rule" => "PathPrefix(`/`)", - "traefik.http.routers.catchall.service" => "unavailable", - "traefik.http.routers.catchall.priority" => 1, - "traefik.http.services.unavailable.loadbalancer.server.port" => "0" - } + delegate :port, :publish?, :labels, :env, :image, :options, :args, to: :"config.traefik" def run docker :run, "--name traefik", @@ -67,16 +54,6 @@ def remove_image docker :image, :prune, "--all", "--force", "--filter", "label=org.opencontainers.image.title=Traefik" end - def port - "#{host_port}:#{CONTAINER_PORT}" - end - - def env - Kamal::Configuration::Env.from_config \ - config: config.traefik.fetch("env", {}), - secrets_file: File.join(config.host_env_directory, "traefik", "traefik.env") - end - def make_env_directory make_directory(env.secrets_directory) end @@ -87,7 +64,7 @@ def remove_env_file private def publish_args - argumentize "--publish", port unless config.traefik["publish"] == false + argumentize "--publish", port if publish? end def label_args @@ -98,27 +75,11 @@ def env_args env.args end - def labels - DEFAULT_LABELS.merge(config.traefik["labels"] || {}) - end - - def image - config.traefik.fetch("image") { DEFAULT_IMAGE } - end - def docker_options_args - optionize(config.traefik["options"] || {}) + optionize(options) end def cmd_option_args - if args = config.traefik["args"] - optionize DEFAULT_ARGS.merge(args), with: "=" - else - optionize DEFAULT_ARGS, with: "=" - end - end - - def host_port - config.traefik["host_port"] || CONTAINER_PORT + optionize args, with: "=" end end diff --git a/lib/kamal/configuration.rb b/lib/kamal/configuration.rb index bed91cf67..a20a572b9 100644 --- a/lib/kamal/configuration.rb +++ b/lib/kamal/configuration.rb @@ -1,15 +1,19 @@ require "active_support/ordered_options" require "active_support/core_ext/string/inquiry" require "active_support/core_ext/module/delegation" +require "active_support/core_ext/hash/keys" require "pathname" require "erb" require "net/ssh/proxy/jump" class Kamal::Configuration - delegate :service, :image, :servers, :labels, :registry, :stop_wait_time, :hooks_path, :logging, to: :raw_config, allow_nil: true + delegate :service, :image, :labels, :stop_wait_time, :hooks_path, to: :raw_config, allow_nil: true delegate :argumentize, :optionize, to: Kamal::Utils attr_reader :destination, :raw_config + attr_reader :accessories, :boot, :builder, :env, :healthcheck, :logging, :traefik, :servers, :ssh, :sshkit, :registry + + include Validation class << self def create_from(config_file:, destination: nil, version: nil) @@ -25,9 +29,7 @@ def load_config_files(*files) def load_config_file(file) if file.exist? - # Newer Psych doesn't load aliases by default - load_method = YAML.respond_to?(:unsafe_load) ? :unsafe_load : :load - YAML.send(load_method, ERB.new(IO.read(file)).result).symbolize_keys + YAML.load(ERB.new(IO.read(file)).result).symbolize_keys else raise "Configuration file not found in #{file}" end @@ -42,7 +44,29 @@ def initialize(raw_config, destination: nil, version: nil, validate: true) @raw_config = ActiveSupport::InheritableOptions.new(raw_config) @destination = destination @declared_version = version - valid? if validate + + validate! raw_config, example: validation_yml.symbolize_keys, context: "" + + # Eager load config to validate it, these are first as they have dependencies later on + @servers = Kamal::Configuration::Servers.new(config: self) + @registry = Kamal::Configuration::Registry.new(config: self) + + @accessories = @raw_config.accessories&.keys&.collect { |name| Kamal::Configuration::Accessory.new(name, config: self) } || [] + @boot = Kamal::Configuration::Boot.new(config: self) + @builder = Kamal::Configuration::Builder.new(config: self) + @env = Kamal::Configuration::Env.new(config: @raw_config.env || {}) + + @healthcheck = Kamal::Configuration::Healthcheck.new(healthcheck_config: @raw_config.healthcheck) + @logging = Kamal::Configuration::Logging.new(logging_config: @raw_config.logging) + @traefik = Kamal::Configuration::Traefik.new(config: self) + @ssh = Kamal::Configuration::Ssh.new(config: self) + @sshkit = Kamal::Configuration::Sshkit.new(config: self) + + ensure_destination_if_required + ensure_required_keys_present + ensure_valid_kamal_version + ensure_retain_containers_valid + ensure_valid_service_name end @@ -71,17 +95,13 @@ def minimum_version def roles - @roles ||= role_names.collect { |role_name| Role.new(role_name, config: self) } + servers.roles end def role(name) roles.detect { |r| r.name == name.to_s } end - def accessories - @accessories ||= raw_config.accessories&.keys&.collect { |name| Kamal::Configuration::Accessory.new(name, config: self) } || [] - end - def accessory(name) accessories.detect { |a| a.name == name.to_s } end @@ -120,7 +140,7 @@ def traefik_hosts end def repository - [ raw_config.registry["server"], image ].compact.join("/") + [ registry.server, image ].compact.join("/") end def absolute_image @@ -157,40 +177,10 @@ def volume_args end def logging_args - if logging.present? - optionize({ "log-driver" => logging["driver"] }.compact) + - argumentize("--log-opt", logging["options"]) - else - argumentize("--log-opt", { "max-size" => "10m" }) - end - end - - - def boot - Kamal::Configuration::Boot.new(config: self) - end - - def builder - Kamal::Configuration::Builder.new(config: self) - end - - def traefik - raw_config.traefik || {} - end - - def ssh - Kamal::Configuration::Ssh.new(config: self) - end - - def sshkit - Kamal::Configuration::Sshkit.new(config: self) + logging.args end - def healthcheck - { "path" => "/up", "port" => 3000, "max_attempts" => 7, "cord" => "/tmp/kamal-cord", "log_lines" => 50 }.merge(raw_config.healthcheck || {}) - end - def healthcheck_service [ "healthcheck", service, destination ].compact.join("-") end @@ -229,10 +219,6 @@ def host_env_directory File.join(run_directory, "env") end - def env - raw_config.env || {} - end - def env_tags @env_tags ||= if (tags = raw_config.env["tags"]) tags.collect { |name, config| Kamal::Configuration::Env::Tag.new(name, config: config) } @@ -246,10 +232,6 @@ def env_tag(name) end - def valid? - ensure_destination_if_required && ensure_required_keys_present && ensure_valid_kamal_version && ensure_retain_containers_valid && ensure_valid_service_name - end - def to_h { roles: role_names, @@ -265,11 +247,10 @@ def to_h builder: builder.to_h, accessories: raw_config.accessories, logging: logging_args, - healthcheck: healthcheck + healthcheck: healthcheck.to_h }.compact end - private # Will raise ArgumentError if any required config keys are missing def ensure_destination_if_required @@ -282,29 +263,21 @@ def ensure_destination_if_required def ensure_required_keys_present %i[ service image registry servers ].each do |key| - raise ArgumentError, "Missing required configuration for #{key}" unless raw_config[key].present? - end - - if raw_config.registry["username"].blank? - raise ArgumentError, "You must specify a username for the registry in config/deploy.yml" - end - - if raw_config.registry["password"].blank? - raise ArgumentError, "You must specify a password for the registry in config/deploy.yml (or set the ENV variable if that's used)" + raise Kamal::ConfigurationError, "Missing required configuration for #{key}" unless raw_config[key].present? end - unless role_names.include?(primary_role_name) - raise ArgumentError, "The primary_role #{primary_role_name} isn't defined" + unless role(primary_role_name).present? + raise Kamal::ConfigurationError, "The primary_role #{primary_role_name} isn't defined" end if primary_role.hosts.empty? - raise ArgumentError, "No servers specified for the #{primary_role.name} primary_role" + raise Kamal::ConfigurationError, "No servers specified for the #{primary_role.name} primary_role" end unless allow_empty_roles? roles.each do |role| if role.hosts.empty? - raise ArgumentError, "No servers specified for the #{role.name} role. You can ignore this with allow_empty_roles: true" + raise Kamal::ConfigurationError, "No servers specified for the #{role.name} role. You can ignore this with allow_empty_roles: true" end end end @@ -313,21 +286,21 @@ def ensure_required_keys_present end def ensure_valid_service_name - raise ArgumentError, "Service name can only include alphanumeric characters, hyphens, and underscores" unless raw_config[:service] =~ /^[a-z0-9_-]+$/i + raise Kamal::ConfigurationError, "Service name can only include alphanumeric characters, hyphens, and underscores" unless raw_config[:service] =~ /^[a-z0-9_-]+$/i true end def ensure_valid_kamal_version if minimum_version && Gem::Version.new(minimum_version) > Gem::Version.new(Kamal::VERSION) - raise ArgumentError, "Current version is #{Kamal::VERSION}, minimum required is #{minimum_version}" + raise Kamal::ConfigurationError, "Current version is #{Kamal::VERSION}, minimum required is #{minimum_version}" end true end def ensure_retain_containers_valid - raise ArgumentError, "Must retain at least 1 container" if retain_containers < 1 + raise Kamal::ConfigurationError, "Must retain at least 1 container" if retain_containers < 1 true end diff --git a/lib/kamal/configuration/accessory.rb b/lib/kamal/configuration/accessory.rb index 42b7754f2..07f40b564 100644 --- a/lib/kamal/configuration/accessory.rb +++ b/lib/kamal/configuration/accessory.rb @@ -1,30 +1,39 @@ class Kamal::Configuration::Accessory + include Kamal::Configuration::Validation + delegate :argumentize, :optionize, to: Kamal::Utils - attr_accessor :name, :specifics + attr_reader :name, :accessory_config, :env def initialize(name, config:) - @name, @config, @specifics = name.inquiry, config, config.raw_config["accessories"][name] + @name, @config, @accessory_config = name.inquiry, config, config.raw_config["accessories"][name] + + validate! \ + accessory_config, + example: validation_yml["accessories"]["mysql"], + context: "accessories/#{name}", + with: Kamal::Configuration::Validator::Accessory + + @env = Kamal::Configuration::Env.new \ + config: accessory_config.fetch("env", {}), + secrets_file: File.join(config.host_env_directory, "accessories", "#{service_name}.env"), + context: "accessories/#{name}/env" end def service_name - specifics["service"] || "#{config.service}-#{name}" + accessory_config["service"] || "#{config.service}-#{name}" end def image - specifics["image"] + accessory_config["image"] end def hosts - if (specifics.keys & [ "host", "hosts", "roles" ]).size != 1 - raise ArgumentError, "Specify one of `host`, `hosts` or `roles` for accessory `#{name}`" - end - hosts_from_host || hosts_from_hosts || hosts_from_roles end def port - if port = specifics["port"]&.to_s + if port = accessory_config["port"]&.to_s port.include?(":") ? port : "#{port}:#{port}" end end @@ -34,32 +43,26 @@ def publish_args end def labels - default_labels.merge(specifics["labels"] || {}) + default_labels.merge(accessory_config["labels"] || {}) end def label_args argumentize "--label", labels end - def env - Kamal::Configuration::Env.from_config \ - config: specifics.fetch("env", {}), - secrets_file: File.join(config.host_env_directory, "accessories", "#{service_name}.env") - end - def env_args env.args end def files - specifics["files"]&.to_h do |local_to_remote_mapping| + accessory_config["files"]&.to_h do |local_to_remote_mapping| local_file, remote_file = local_to_remote_mapping.split(":") [ expand_local_file(local_file), expand_remote_file(remote_file) ] end || {} end def directories - specifics["directories"]&.to_h do |host_to_container_mapping| + accessory_config["directories"]&.to_h do |host_to_container_mapping| host_path, container_path = host_to_container_mapping.split(":") [ expand_host_path(host_path), container_path ] end || {} @@ -74,7 +77,7 @@ def volume_args end def option_args - if args = specifics["options"] + if args = accessory_config["options"] optionize args else [] @@ -82,7 +85,7 @@ def option_args end def cmd - specifics["cmd"] + accessory_config["cmd"] end private @@ -116,18 +119,18 @@ def expand_remote_file(remote_file) end def specific_volumes - specifics["volumes"] || [] + accessory_config["volumes"] || [] end def remote_files_as_volumes - specifics["files"]&.collect do |local_to_remote_mapping| + accessory_config["files"]&.collect do |local_to_remote_mapping| _, remote_file = local_to_remote_mapping.split(":") "#{service_data_directory + remote_file}:#{remote_file}" end || [] end def remote_directories_as_volumes - specifics["directories"]&.collect do |host_to_container_mapping| + accessory_config["directories"]&.collect do |host_to_container_mapping| host_path, container_path = host_to_container_mapping.split(":") [ expand_host_path(host_path), container_path ].join(":") end || [] @@ -146,30 +149,16 @@ def service_data_directory end def hosts_from_host - if specifics.key?("host") - host = specifics["host"] - if host - [ host ] - else - raise ArgumentError, "Missing host for accessory `#{name}`" - end - end + [ accessory_config["host"] ] if accessory_config.key?("host") end def hosts_from_hosts - if specifics.key?("hosts") - hosts = specifics["hosts"] - if hosts.is_a?(Array) - hosts - else - raise ArgumentError, "Hosts should be an Array for accessory `#{name}`" - end - end + accessory_config["hosts"] if accessory_config.key?("hosts") end def hosts_from_roles - if specifics.key?("roles") - specifics["roles"].flat_map { |role| config.role(role).hosts } + if accessory_config.key?("roles") + accessory_config["roles"].flat_map { |role| config.role(role).hosts } end end end diff --git a/lib/kamal/configuration/boot.rb b/lib/kamal/configuration/boot.rb index 6e254b9d9..13886f5e5 100644 --- a/lib/kamal/configuration/boot.rb +++ b/lib/kamal/configuration/boot.rb @@ -1,20 +1,25 @@ class Kamal::Configuration::Boot + include Kamal::Configuration::Validation + + attr_reader :boot_config, :host_count + def initialize(config:) - @options = config.raw_config.boot || {} + @boot_config = config.raw_config.boot || {} @host_count = config.all_hosts.count + validate! boot_config end def limit - limit = @options["limit"] + limit = boot_config["limit"] if limit.to_s.end_with?("%") - [ @host_count * limit.to_i / 100, 1 ].max + [ host_count * limit.to_i / 100, 1 ].max else limit end end def wait - @options["wait"] + boot_config["wait"] end end diff --git a/lib/kamal/configuration/builder.rb b/lib/kamal/configuration/builder.rb index 4663f11fd..74c8d1c66 100644 --- a/lib/kamal/configuration/builder.rb +++ b/lib/kamal/configuration/builder.rb @@ -1,73 +1,79 @@ class Kamal::Configuration::Builder + include Kamal::Configuration::Validation + + attr_reader :config, :builder_config + delegate :image, :service, to: :config + delegate :server, to: :"config.registry" + def initialize(config:) - @options = config.raw_config.builder || {} + @config = config + @builder_config = config.raw_config.builder || {} @image = config.image - @server = config.registry["server"] + @server = config.registry.server @service = config.service - @destination = config.destination - valid? + validate! builder_config, with: Kamal::Configuration::Validator::Builder end def to_h - @options + builder_config end def multiarch? - @options["multiarch"] != false + builder_config["multiarch"] != false end def local? - !!@options["local"] + !!builder_config["local"] end def remote? - !!@options["remote"] + !!builder_config["remote"] end def cached? - !!@options["cache"] + !!builder_config["cache"] end def args - @options["args"] || {} + builder_config["args"] || {} end def secrets - @options["secrets"] || [] + builder_config["secrets"] || [] end def dockerfile - @options["dockerfile"] || "Dockerfile" + builder_config["dockerfile"] || "Dockerfile" end def target - @options["target"] + builder_config["target"] end def context - @options["context"] || "." + builder_config["context"] || "." end def local_arch - @options["local"]["arch"] if local? + builder_config["local"]["arch"] if local? end def local_host - @options["local"]["host"] if local? + builder_config["local"]["host"] if local? end def remote_arch - @options["remote"]["arch"] if remote? + builder_config["remote"]["arch"] if remote? end def remote_host - @options["remote"]["host"] if remote? + builder_config["remote"]["host"] if remote? end def cache_from if cached? - case @options["cache"]["type"] + case builder_config["cache"]["type"] when "gha" cache_from_config_for_gha when "registry" @@ -78,7 +84,7 @@ def cache_from def cache_to if cached? - case @options["cache"]["type"] + case builder_config["cache"]["type"] when "gha" cache_to_config_for_gha when "registry" @@ -88,15 +94,15 @@ def cache_to end def ssh - @options["ssh"] + builder_config["ssh"] end def git_clone? - Kamal::Git.used? && @options["context"].nil? + Kamal::Git.used? && builder_config["context"].nil? end def clone_directory - @clone_directory ||= File.join Dir.tmpdir, "kamal-clones", [ @service, pwd_sha ].compact.join("-") + @clone_directory ||= File.join Dir.tmpdir, "kamal-clones", [ service, pwd_sha ].compact.join("-") end def build_directory @@ -109,18 +115,12 @@ def build_directory end private - def valid? - if @options["cache"] && @options["cache"]["type"] - raise ArgumentError, "Invalid cache type: #{@options["cache"]["type"]}" unless [ "gha", "registry" ].include?(@options["cache"]["type"]) - end - end - def cache_image - @options["cache"]&.fetch("image", nil) || "#{@image}-build-cache" + builder_config["cache"]&.fetch("image", nil) || "#{image}-build-cache" end def cache_image_ref - [ @server, cache_image ].compact.join("/") + [ server, cache_image ].compact.join("/") end def cache_from_config_for_gha @@ -132,11 +132,11 @@ def cache_from_config_for_registry end def cache_to_config_for_gha - [ "type=gha", @options["cache"]&.fetch("options", nil) ].compact.join(",") + [ "type=gha", builder_config["cache"]&.fetch("options", nil) ].compact.join(",") end def cache_to_config_for_registry - [ "type=registry", @options["cache"]&.fetch("options", nil), "ref=#{cache_image_ref}" ].compact.join(",") + [ "type=registry", builder_config["cache"]&.fetch("options", nil), "ref=#{cache_image_ref}" ].compact.join(",") end def repo_basename diff --git a/lib/kamal/configuration/docs/accessory.yml b/lib/kamal/configuration/docs/accessory.yml new file mode 100644 index 000000000..931b80a4c --- /dev/null +++ b/lib/kamal/configuration/docs/accessory.yml @@ -0,0 +1,90 @@ +# Accessories +# +# Accessories can be booted on a single host, a list of hosts, or on specific roles. +# The hosts do not need to be defined in the Kamal servers configuration. +# +# Accessories are managed separately from the main service - they are not updated +# when you deploy and they do not have zero-downtime deployments. +# +# Run `kamal accessory boot ` to boot an accessory. +# See `kamal accessory --help` for more information. + +# Configuring accessories +# +# First define the accessory in the `accessories` +accessories: + mysql: + + # Service name + # + # This is used in the service label and defaults to `-` + # where `` is the main service name from the root configuration + service: mysql + + # Image + # + # The Docker image to use, prefix with a registry if not using Docker hub + image: mysql:8.0 + + # Accessory hosts + # + # Specify one of `host`, `hosts` or `roles` + host: mysql-db1 + hosts: + - mysql-db1 + - mysql-db2 + roles: + - mysql + + # Custom command + # + # You can set a custom command to run in the container, if you do not want to use the default + cmd: "bin/mysqld" + + # Port mappings + # + # See https://docs.docker.com/network/, especially note the warning about the security + # implications of exposing ports publicly. + port: "127.0.0.1:3306:3306" + + # Labels + labels: + app: myapp + + # Options + # These are passed to the Docker run command in the form `-- ` + options: + restart: always + cpus: 2 + + # Environment variables + # See kamal docs env for more information + env: + ... + + # Copying files + # + # You can specify files to mount into the container. + # The format is `local:remote` where `local` is the path to the file on the local machine + # and `remote` is the path to the file in the container. + # + # They will be uploaded from the local repo to the host and then mounted. + # + # ERB files will be evaluated before being copied. + files: + - config/my.cnf.erb:/etc/mysql/my.cnf + - config/myoptions.cnf:/etc/mysql/myoptions.cnf + + # Directories + # + # You can specify directories to mount into the container. They will be created on the host + # before being mounted + directories: + - mysql-logs:/var/log/mysql + + # Volumes + # + # Any other volumes to mount, in addition to the files and directories. + # They are not created or copied before mounting + volumes: + - /path/to/mysql-logs:/var/log/mysql diff --git a/lib/kamal/configuration/docs/boot.yml b/lib/kamal/configuration/docs/boot.yml new file mode 100644 index 000000000..2afb967f9 --- /dev/null +++ b/lib/kamal/configuration/docs/boot.yml @@ -0,0 +1,19 @@ +# Booting +# +# When deploying to large numbers of hosts, you might prefer not to restart your services on every host at the same time. +# +# Kamal’s default is to boot new containers on all hosts in parallel. But you can control this with the boot configuration. + +# Fixed group sizes +# +# Here we boot 2 hosts at a time with a 10 second gap between each group. +boot: + limit: 2 + wait: 10 + +# Percentage of hosts +# +# Here we boot 25% of the hosts at a time with a 2 second gap between each group. +boot: + limit: 25% + wait: 2 diff --git a/lib/kamal/configuration/docs/builder.yml b/lib/kamal/configuration/docs/builder.yml new file mode 100644 index 000000000..b10711058 --- /dev/null +++ b/lib/kamal/configuration/docs/builder.yml @@ -0,0 +1,107 @@ +# Builder +# +# The builder configuration controls how the application is built with `docker build` or `docker buildx build` +# +# If no configuration is specified, Kamal will: +# 1. Create a buildx context called `kamal--multiarch` +# 2. Use `docker buildx build` to build a multiarch image for linux/amd64,linux/arm64 with that context +# +# See https://kamal-deploy.org/docs/configuration/builder-examples/ for more information + +# Builder options +# +# Options go under the builder key in the root configuration. +builder: + + # Multiarch + # + # Enables multiarch builds, defaults to `true` + multiarch: false + + # Local configuration + # + # The build configuration for local builds, only used if multiarch is enabled (the default) + # + # If there is no remote configuration, by default we build for amd64 and arm64. + # If you only want to build for one architecture, you can specify it here. + # The docker socket is optional and uses the default docker host socket when not specified + local: + arch: amd64 + host: /var/run/docker.sock + + # Remote configuration + # + # The build configuration for remote builds, also only used if multiarch is enabled. + # The arch is required and can be either amd64 or arm64. + remote: + arch: arm64 + host: ssh://docker@docker-builder + + # Builder cache + # + # The type must be either 'gha' or 'registry' + # + # The image is only used for registry cache + cache: + type: registry + options: mode=max + image: kamal-app-build-cache + + # Build context + # + # If this is not set, then a local git clone of the repo is used. + # This ensures a clean build with no uncommitted changes. + # + # To use the local checkout instead you can set the context to `.`, or a path to another directory. + context: . + + # Dockerfile + # + # The Dockerfile to use for building, defaults to `Dockerfile` + dockerfile: Dockerfile.production + + # Build target + # + # If not set, then the default target is used + target: production + + # Build Arguments + # + # Any additional build arguments, passed to `docker build` with `--build-arg =` + args: + ENVIRONMENT: production + + # Referencing build arguments + # + # ```shell + # ARG RUBY_VERSION + # FROM ruby:$RUBY_VERSION-slim as base + # ``` + + # Build secrets + # + # Values are read from the environment. + # + secrets: + - SECRET1 + - SECRET2 + + # Referencing Build Secrets + # + # ```shell + # # Copy Gemfiles + # COPY Gemfile Gemfile.lock ./ + # + # # Install dependencies, including private repositories via access token + # # Then remove bundle cache with exposed GITHUB_TOKEN) + # RUN --mount=type=secret,id=GITHUB_TOKEN \ + # BUNDLE_GITHUB__COM=x-access-token:$(cat /run/secrets/GITHUB_TOKEN) \ + # bundle install && \ + # rm -rf /usr/local/bundle/cache + # ``` + + + # SSH + # + # SSH agent socket or keys to expose to the build + ssh: default=$SSH_AUTH_SOCK diff --git a/lib/kamal/configuration/docs/configuration.yml b/lib/kamal/configuration/docs/configuration.yml new file mode 100644 index 000000000..895d2f847 --- /dev/null +++ b/lib/kamal/configuration/docs/configuration.yml @@ -0,0 +1,157 @@ +# Kamal Configuration +# +# Configuration is read from the `config/deploy.yml` +# +# When running commands, you can specify a destination with the `-d` flag, +# e.g. `kamal deploy -d staging` +# +# In this case the configuration will also be read from `config/deploy.staging.yml` +# and merged with the base configuration. +# +# The available configuration options are explained below. + +# The service name +# This is a required value. It is used as the container name prefix. +service: myapp + +# The Docker image name +# +# The image will be pushed to the configured registry. +image: my-image + +# Labels +# +# Additional labels to add to the container +labels: + my-label: my-value + +# Additional volumes to mount into the container +volumes: + - /path/on/host:/path/in/container:ro + +# Registry +# +# The Docker registry configuration, see kamal docs registry +registry: + ... + +# Servers +# +# The servers to deploy to, optionally with custom roles, see kamal docs servers +servers: + ... + +# Environment variables +# +# See kamal docs env +env: + ... + +# Asset Bridging +# +# Used for asset bridging across deployments, default to `nil` +# +# If there are changes to CSS or JS files, we may get requests +# for the old versions on the new container and vice-versa. +# +# To avoid 404s we can specify an asset path. +# Kamal will replace that path in the container with a mapped +# volume containing both sets of files. +# This requires that file names change when the contents change +# (e.g. by including a hash of the contents in the name). + +# 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` +require_destination: true + +# The primary role +# +# This defaults to `web`, but if you have no web role, you can change this +primary_role: workers + +# Allowing empty roles +# +# Whether roles with no servers are allowed. Defaults to `false`. +allow_empty_roles: false + +# Stop wait time +# +# How long we wait for a container to stop before killing it, defaults to 30 seconds +stop_wait_time: 60 + +# Retain containers +# +# How many old containers and images we retain, defaults to 5 +retain_containers: 3 + +# Minimum version +# +# The minimum version of Kamal required to deploy this configuration, defaults to nil +minimum_version: 1.3.0 + +# Readiness delay +# +# Seconds to wait for a container to boot after is running, default 7 +# This only applies to containers that do not specify a healthcheck +readiness_delay: 4 + +# Run directory +# +# Directory to store kamal runtime files in on the host, default `.kamal` +run_directory: /etc/kamal + +# SSH options +# +# See kamal docs ssh +ssh: + ... + +# Builder options +# +# See kamal docs builder +builder: + ... + +# Accessories +# +# Additionals services to run in Docker, see kamal docs accessory +accessories: + ... + +# Traefik +# +# The Traefik proxy is used for zero-downtime deployments, see kamal docs traefik +traefik: + ... + +# SSHKit +# +# See kamal docs sshkit +sshkit: + ... + +# Boot options +# +# See kamal docs boot +boot: + ... + +# Healthcheck +# +# Configuring healthcheck commands, intervals and timeouts, see kamal docs healthcheck +healthcheck: + ... + +# Logging +# +# Docker logging configuration, see kamal docs logging +logging: + ... diff --git a/lib/kamal/configuration/docs/env.yml b/lib/kamal/configuration/docs/env.yml new file mode 100644 index 000000000..b353228a5 --- /dev/null +++ b/lib/kamal/configuration/docs/env.yml @@ -0,0 +1,72 @@ +# Environment variables +# +# Environment variables can be set directory in the Kamal configuration or +# for loaded from a .env file, for secrets that should not be checked into Git. + +# Reading environment variables from the configuration +# +# Environment variables can be set directly in the configuration file. +# +# These are passed to the docker run command when deploying. +env: + DATABASE_HOST: mysql-db1 + DATABASE_PORT: 3306 + +# Using .env file to load required environment variables +# +# Kamal uses dotenv to automatically load environment variables set in the .env file present +# in the application root. +# +# This file can be used to set variables like KAMAL_REGISTRY_PASSWORD or database passwords. +# But for this reason you must ensure that .env files are not checked into Git or included +# in your Dockerfile! The format is just key-value like: +# ``` +# KAMAL_REGISTRY_PASSWORD=pw +# DB_PASSWORD=secret123 +# ``` +# See https://kamal-deploy.org/docs/commands/envify/ 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, +# 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`. +env: + clear: + DB_USER: app + secret: + - DB_PASSWORD + +# Tags +# +# Tags are used to add extra env variables to specific hosts. +# See kamal docs servers for how to tag hosts. +# +# Tags are only allowed in the top level env configuration (i.e not under a role specific env). +# +# The env variables can be specified with secret and clear values as explained above. +env: + tags: + : + MYSQL_USER: monitoring + : + clear: + MYSQL_USER: readonly + secret: + - MYSQL_PASSWORD + +# Example configuration +env: + clear: + MYSQL_USER: app + secret: + - MYSQL_PASSWORD + tags: + monitoring: + MYSQL_USER: monitoring + replica: + clear: + MYSQL_USER: readonly + secret: + - READONLY_PASSWORD diff --git a/lib/kamal/configuration/docs/healthcheck.yml b/lib/kamal/configuration/docs/healthcheck.yml new file mode 100644 index 000000000..e29771f5c --- /dev/null +++ b/lib/kamal/configuration/docs/healthcheck.yml @@ -0,0 +1,59 @@ +# Healthcheck configuration +# +# On roles that are running Traefik, Kamal will supply a default healthcheck to `docker run`. +# For other roles, by default no healthcheck is supplied. +# +# If no healthcheck is supplied and the image does not define one, they we wait for the container +# to reach a running state and then pause for the readiness delay. +# +# The default healthcheck is `curl -f http://localhost:/`, so it assumes that `curl` +# is available within the container. + +# Healthcheck options +# +# These go under the `healthcheck` key in the root or role configuration. +healthcheck: + + # Command + # + # The command to run, defaults to `curl -f http://localhost:/` on roles running Traefik + cmd: "curl -f http://localhost" + + # Interval + # + # The Docker healthcheck interval, defaults to `1s` + interval: 10s + + # Max attempts + # + # The maximum number of times we poll the container to see if it is healthy, defaults to `7` + # Each check is separated by an increasing interval starting with 1 second. + max_attempts: 3 + + # Port + # + # The port to use in the healthcheck, defaults to `3000` + port: "80" + + # Path + # + # The path to use in the healthcheck, defaults to `/up` + path: /health + + # Cords for zero-downtime deployments + # + # The cord file is used for zero-downtime deployments. The healthcheck is augmented with a check + # for the existance of the file. This allows us to delete the file and force the container to + # become unhealthy, causing Traefik to stop routing traffic to it. + # + # Kamal mounts a volume at this location and creates the file before starting the container. + # You can set the value to `false` to disable the cord file, but this loses the zero-downtime + # guarantee. + # + # The default value is `/tmp/kamal-cord` + cord: /cord + + # Log lines + # + # Number of lines to log from the container when the healthcheck fails, defaults to `50` + log_lines: 100 diff --git a/lib/kamal/configuration/docs/logging.yml b/lib/kamal/configuration/docs/logging.yml new file mode 100644 index 000000000..cc30a617f --- /dev/null +++ b/lib/kamal/configuration/docs/logging.yml @@ -0,0 +1,21 @@ +# Custom logging configuration +# +# Set these to control the Docker logging driver and options. + +# Logging settings +# +# These go under the logging key in the configuration file. +# +# This can be specified in the root level or for a specific role. +logging: + + # Driver + # + # The logging driver to use, passed to Docker via `--log-driver` + driver: json-file + + # Options + # + # Any logging options to pass to the driver, passed to Docker via `--log-opt` + options: + max-size: 100m diff --git a/lib/kamal/configuration/docs/registry.yml b/lib/kamal/configuration/docs/registry.yml new file mode 100644 index 000000000..3254e454d --- /dev/null +++ b/lib/kamal/configuration/docs/registry.yml @@ -0,0 +1,49 @@ +# Registry +# +# The default registry is Docker Hub, but you can change it using registry/server: +# +# A reference to secret (in this case DOCKER_REGISTRY_TOKEN) will look up the secret +# in the local environment. + +registry: + server: registry.digitalocean.com + username: + - DOCKER_REGISTRY_TOKEN + password: + - DOCKER_REGISTRY_TOKEN + +# Using AWS ECR as the container registry +# You will need to have the aws CLI installed locally for this to work. +# AWS ECR’s access token is only valid for 12hrs. In order to not have to manually regenerate the token every time, you can use ERB in the deploy.yml file to shell out to the aws cli command, and obtain the token: + +registry: + server: .dkr.ecr..amazonaws.com + username: AWS + password: <%= %x(aws ecr get-login-password) %> + +# Using GCP Artifact Registry as the container registry +# To sign into Artifact Registry, you would need to +# [create a service account](https://cloud.google.com/iam/docs/service-accounts-create#creating) +# and [set up roles and permissions](https://cloud.google.com/artifact-registry/docs/access-control#permissions). +# Normally, assigning a roles/artifactregistry.writer role should be sufficient. +# +# Once the service account is ready, you need to generate and download a JSON key, base64 encode it and add to .env: +# +# ```shell +# echo "KAMAL_REGISTRY_PASSWORD=$(base64 -i /path/to/key.json)" | tr -d "\\n" >> .env +# ``` +# Use the env variable as password along with _json_key_base64 as username. +# Here’s the final configuration: + +registry: + server: -docker.pkg.dev + username: _json_key_base64 + password: + - KAMAL_REGISTRY_PASSWORD + +# Validating the configuration +# +# You can validate the configuration by running: +# ```shell +# kamal registry login +# ``` diff --git a/lib/kamal/configuration/docs/role.yml b/lib/kamal/configuration/docs/role.yml new file mode 100644 index 000000000..9f6962a58 --- /dev/null +++ b/lib/kamal/configuration/docs/role.yml @@ -0,0 +1,52 @@ +# Roles +# +# Roles are used to configure different types of servers in the deployment. +# The most common use for this is to run a web servers and job servers. +# +# Kamal expects there to be a `web` role, unless you set a different `primary_role` +# in the root configuration. + +# Role configuration +# +# Roles are specified under the servers key +servers: + + # Simple role configuration + # + # + # This can be a list of hosts, if you don't need custom configuration for the role. + # + # You can set tags on the hosts for custom env variables (see kamal docs env) + web: + - 172.1.0.1 + - 172.1.0.2: experiment1 + - 172.1.0.2: [ experiment1, experiment2 ] + + # Custom role configuration + # + # When there are other options to set, the list of hosts goes under the `hosts` key + # + # By default only the primary role uses Traefik, but you can set `traefik` to change + # it. + # + # You can also set a custom cmd to run in the container, and overwrite other settings + # from the root configuration. + workers: + hosts: + - 172.1.0.3 + - 172.1.0.4: experiment1 + traefik: true + cmd: "bin/jobs" + options: + memory: 2g + cpus: 4 + healthcheck: + ... + logging: + ... + labels: + my-label: workers + env: + ... + asset_path: /public + diff --git a/lib/kamal/configuration/docs/servers.yml b/lib/kamal/configuration/docs/servers.yml new file mode 100644 index 000000000..01b464bec --- /dev/null +++ b/lib/kamal/configuration/docs/servers.yml @@ -0,0 +1,27 @@ +# Servers +# +# Servers are split into different roles, with each role having its own configuration. +# +# For simpler deployments though where all servers are identical, you can just specify a list of servers +# They will be implicitly assigned to the `web` role. +servers: + - 172.0.0.1 + - 172.0.0.2 + - 172.0.0.3 + +# Tagging servers +# +# Servers can be tagged, with the tags used to add custom env variables (see kamal docs env). +servers: + - 172.0.0.1 + - 172.0.0.2: experiments + - 172.0.0.3: [ experiments, three ] + +# Roles +# +# For more complex deployments (e.g. if you are running job hosts), you can specify roles, and configure each separately (see kamal docs role) +servers: + web: + ... + workers: + ... diff --git a/lib/kamal/configuration/docs/ssh.yml b/lib/kamal/configuration/docs/ssh.yml new file mode 100644 index 000000000..775b82474 --- /dev/null +++ b/lib/kamal/configuration/docs/ssh.yml @@ -0,0 +1,46 @@ +# SSH configuration +# +# Kamal uses SSH to connect run commands on your hosts. +# By default it will attempt to connect to the root user on port 22 +# +# If you are using non-root user, you may need to bootstrap your servers manually, before using them with Kamal. On Ubuntu, you’d do: +# +# ```shell +# sudo apt update +# sudo apt upgrade -y +# sudo apt install -y docker.io curl git +# sudo usermod -a -G docker app +# ``` + + +# SSH options +# +# The options are specified under the ssh key in the configuration file. +ssh: + + # The SSH user + # + # Defaults to `root` + # + user: app + + # The SSH port + # + # Defaults to 22 + port: "2222" + + # Proxy host + # + # Specified in the form or @ + proxy: root@proxy-host + + # Proxy command + # + # A custom proxy command, required for older versions of SSH + proxy_command: "ssh -W %h:%p user@proxy" + + # Log level + # + # Defaults to `fatal`. Set this to debug if you are having + # SSH connection issues. + log_level: debug diff --git a/lib/kamal/configuration/docs/sshkit.yml b/lib/kamal/configuration/docs/sshkit.yml new file mode 100644 index 000000000..0c9d90b36 --- /dev/null +++ b/lib/kamal/configuration/docs/sshkit.yml @@ -0,0 +1,23 @@ +# SSHKit +# +# [SSHKit](https://github.com/capistrano/sshkit) is the SSH toolkit used by Kamal. +# +# The default settings should be sufficient for most use cases, but +# when connecting to a large number of hosts you may need to adjust + +# SSHKit options +# +# The options are specified under the sshkit key in the configuration file. +sshkit: + + # Max concurrent starts + # + # Creating SSH connections concurrently can be an issue when deploying to many servers. + # By default Kamal will limit concurrent connection starts to 30 at a time. + max_concurrent_starts: 10 + + # Pool idle timeout + # + # Kamal sets a long idle timeout of 900 seconds on connections to try to avoid + # re-connection storms after an idle period, like building an image or waiting for CI. + pool_idle_timeout: 300 diff --git a/lib/kamal/configuration/docs/traefik.yml b/lib/kamal/configuration/docs/traefik.yml new file mode 100644 index 000000000..756afa9e2 --- /dev/null +++ b/lib/kamal/configuration/docs/traefik.yml @@ -0,0 +1,62 @@ +# Traefik +# +# Traefik is a reverse proxy, used by Kamal for zero-downtime deployments. +# +# We start an instance on the hosts in it's own container. +# +# During a deployment: +# 1. We start a new container which Traefik automatically detects due to the labels we have applied +# 2. Traefik starts routing traffic to the new container +# 3. We force the old container to fail it's healthcheck, causing Traefik to stop routing traffic to it +# 4. We stop the old container + +# Traefik settings +# +# Traekik is configured in the root configuration under `traefik`. +traefik: + + # Image + # + # The Traefik image to use, defaults to `traefik:v2.10` + image: traefik:v2.9 + + # Host port + # + # The host port to publish the Traefik container on, defaults to `80` + host_port: "8080" + + # Disabling publishing + # + # To avoid publishing the Traefik container, set this to `false` + publish: false + + # Labels + # + # Additional labels to apply to the Traefik container + labels: + traefik.http.routers.catchall.entryPoints: http + traefik.http.routers.catchall.rule: PathPrefix(`/`) + traefik.http.routers.catchall.service: unavailable + traefik.http.routers.catchall.priority: "1" + traefik.http.services.unavailable.loadbalancer.server.port: "0" + + # Arguments + # + # Additional arguments to pass to the Traefik container + args: + entryPoints.http.address: ":80" + entryPoints.http.forwardedHeaders.insecure: true + accesslog: true + accesslog.format: json + + # Options + # + # Additional options to pass to `docker run` + options: + cpus: 2 + + # Environment variables + # + # See kamal docs env + env: + ... diff --git a/lib/kamal/configuration/env.rb b/lib/kamal/configuration/env.rb index a78338493..1c0fb1e93 100644 --- a/lib/kamal/configuration/env.rb +++ b/lib/kamal/configuration/env.rb @@ -1,18 +1,15 @@ class Kamal::Configuration::Env - attr_reader :secrets_keys, :clear, :secrets_file - delegate :argumentize, to: Kamal::Utils - - def self.from_config(config:, secrets_file: nil) - secrets_keys = config.fetch("secret", []) - clear = config.fetch("clear", config.key?("secret") || config.key?("tags") ? {} : config) + include Kamal::Configuration::Validation - new clear: clear, secrets_keys: secrets_keys, secrets_file: secrets_file - end + attr_reader :secrets_keys, :clear, :secrets_file, :context + delegate :argumentize, to: Kamal::Utils - def initialize(clear:, secrets_keys:, secrets_file:) - @clear = clear - @secrets_keys = secrets_keys + def initialize(config:, secrets_file: nil, context: "env") + @clear = config.fetch("clear", config.key?("secret") || config.key?("tags") ? {} : config) + @secrets_keys = config.fetch("secret", []) @secrets_file = secrets_file + @context = context + validate! config, context: context, with: Kamal::Configuration::Validator::Env end def args @@ -33,8 +30,7 @@ def secrets_directory def merge(other) self.class.new \ - clear: @clear.merge(other.clear), - secrets_keys: @secrets_keys | other.secrets_keys, - secrets_file: secrets_file + config: { "clear" => clear.merge(other.clear), "secret" => secrets_keys | other.secrets_keys }, + secrets_file: secrets_file || other.secrets_file end end diff --git a/lib/kamal/configuration/env/tag.rb b/lib/kamal/configuration/env/tag.rb index 2a6a13060..c41512022 100644 --- a/lib/kamal/configuration/env/tag.rb +++ b/lib/kamal/configuration/env/tag.rb @@ -7,6 +7,6 @@ def initialize(name, config:) end def env - Kamal::Configuration::Env.from_config(config: config) + Kamal::Configuration::Env.new(config: config) end end diff --git a/lib/kamal/configuration/healthcheck.rb b/lib/kamal/configuration/healthcheck.rb new file mode 100644 index 000000000..888068a44 --- /dev/null +++ b/lib/kamal/configuration/healthcheck.rb @@ -0,0 +1,63 @@ +class Kamal::Configuration::Healthcheck + include Kamal::Configuration::Validation + + attr_reader :healthcheck_config + + def initialize(healthcheck_config:, context: "healthcheck") + @healthcheck_config = healthcheck_config || {} + validate! @healthcheck_config, context: context + end + + def merge(other) + self.class.new healthcheck_config: healthcheck_config.deep_merge(other.healthcheck_config) + end + + def cmd + healthcheck_config.fetch("cmd", http_health_check) + end + + def port + healthcheck_config.fetch("port", 3000) + end + + def path + healthcheck_config.fetch("path", "/up") + end + + def max_attempts + healthcheck_config.fetch("max_attempts", 7) + end + + def interval + healthcheck_config.fetch("interval", "1s") + end + + def cord + healthcheck_config.fetch("cord", "/tmp/kamal-cord") + end + + def log_lines + healthcheck_config.fetch("log_lines", 50) + end + + def set_port_or_path? + healthcheck_config["port"].present? || healthcheck_config["path"].present? + end + + def to_h + { + "cmd" => cmd, + "interval" => interval, + "max_attempts" => max_attempts, + "port" => port, + "path" => path, + "cord" => cord, + "log_lines" => log_lines + } + end + + private + def http_health_check + "curl -f #{URI.join("http://localhost:#{port}", path)} || exit 1" if path.present? || port.present? + end +end diff --git a/lib/kamal/configuration/logging.rb b/lib/kamal/configuration/logging.rb new file mode 100644 index 000000000..b0592b463 --- /dev/null +++ b/lib/kamal/configuration/logging.rb @@ -0,0 +1,33 @@ +class Kamal::Configuration::Logging + delegate :optionize, :argumentize, to: Kamal::Utils + + include Kamal::Configuration::Validation + + attr_reader :logging_config + + def initialize(logging_config:, context: "logging") + @logging_config = logging_config || {} + validate! @logging_config, context: context + end + + def driver + logging_config["driver"] + end + + def options + logging_config.fetch("options", {}) + end + + def merge(other) + self.class.new logging_config: logging_config.deep_merge(other.logging_config) + end + + def args + if driver.present? || options.present? + optionize({ "log-driver" => driver }.compact) + + argumentize("--log-opt", options) + else + argumentize("--log-opt", { "max-size" => "10m" }) + end + end +end diff --git a/lib/kamal/configuration/registry.rb b/lib/kamal/configuration/registry.rb new file mode 100644 index 000000000..fa0ba04a8 --- /dev/null +++ b/lib/kamal/configuration/registry.rb @@ -0,0 +1,31 @@ +class Kamal::Configuration::Registry + include Kamal::Configuration::Validation + + attr_reader :registry_config + + def initialize(config:) + @registry_config = config.raw_config.registry || {} + validate! registry_config, with: Kamal::Configuration::Validator::Registry + end + + def server + registry_config["server"] + end + + def username + lookup("username") + end + + def password + lookup("password") + end + + private + def lookup(key) + if registry_config[key].is_a?(Array) + ENV.fetch(registry_config[key].first).dup + else + registry_config[key] + end + end +end diff --git a/lib/kamal/configuration/role.rb b/lib/kamal/configuration/role.rb index f0df59244..e9e520a7b 100644 --- a/lib/kamal/configuration/role.rb +++ b/lib/kamal/configuration/role.rb @@ -1,13 +1,33 @@ class Kamal::Configuration::Role + include Kamal::Configuration::Validation + CORD_FILE = "cord" delegate :argumentize, :optionize, to: Kamal::Utils - attr_accessor :name + attr_reader :name, :config, :specialized_env, :specialized_logging, :specialized_healthcheck + alias to_s name def initialize(name, config:) @name, @config = name.inquiry, config - @tagged_hosts ||= extract_tagged_hosts_from_config + validate! \ + specializations, + example: validation_yml["servers"]["workers"], + context: "servers/#{name}", + with: Kamal::Configuration::Validator::Role + + @specialized_env = Kamal::Configuration::Env.new \ + config: specializations.fetch("env", {}), + secrets_file: File.join(config.host_env_directory, "roles", "#{container_prefix}.env"), + context: "servers/#{name}/env" + + @specialized_logging = Kamal::Configuration::Logging.new \ + logging_config: specializations.fetch("logging", {}), + context: "servers/#{name}/logging" + + @specialized_healthcheck = Kamal::Configuration::Healthcheck.new \ + healthcheck_config: specializations.fetch("healthcheck", {}), + context: "servers/#{name}/healthcheck" end def primary_host @@ -43,21 +63,17 @@ def label_args end def logging_args - args = config.logging || {} - args.deep_merge!(specializations["logging"]) if specializations["logging"].present? + logging.args + end - if args.any? - optionize({ "log-driver" => args["driver"] }.compact) + - argumentize("--log-opt", args["options"]) - else - config.logging_args - end + def logging + @logging ||= config.logging.merge(specialized_logging) end def env(host) @envs ||= {} - @envs[host] ||= [ base_env, specialized_env, *env_tags(host).map(&:env) ].reduce(:merge) + @envs[host] ||= [ config.env, specialized_env, *env_tags(host).map(&:env) ].reduce(:merge) end def env_args(host) @@ -70,28 +86,29 @@ def asset_volume_args def health_check_args(cord: true) - if health_check_cmd.present? + if running_traefik? || healthcheck.set_port_or_path? if cord && uses_cord? - optionize({ "health-cmd" => health_check_cmd_with_cord, "health-interval" => health_check_interval }) + optionize({ "health-cmd" => health_check_cmd_with_cord, "health-interval" => healthcheck.interval }) .concat(cord_volume.docker_args) else - optionize({ "health-cmd" => health_check_cmd, "health-interval" => health_check_interval }) + optionize({ "health-cmd" => healthcheck.cmd, "health-interval" => healthcheck.interval }) end else [] end end - def health_check_cmd - health_check_options["cmd"] || http_health_check(port: health_check_options["port"], path: health_check_options["path"]) + def healthcheck + @healthcheck ||= + if running_traefik? + config.healthcheck.merge(specialized_healthcheck) + else + specialized_healthcheck + end end def health_check_cmd_with_cord - "(#{health_check_cmd}) && (stat #{cord_container_file} > /dev/null || exit 1)" - end - - def health_check_interval - health_check_options["interval"] || "1s" + "(#{healthcheck.cmd}) && (stat #{cord_container_file} > /dev/null || exit 1)" end @@ -109,7 +126,7 @@ def primary? def uses_cord? - running_traefik? && cord_volume && health_check_cmd.present? + running_traefik? && cord_volume && healthcheck.cmd.present? end def cord_host_directory @@ -117,7 +134,7 @@ def cord_host_directory end def cord_volume - if (cord = health_check_options["cord"]) + if (cord = healthcheck.cord) @cord_volume ||= Kamal::Configuration::Volume.new \ host_path: File.join(config.run_directory, "cords", [ container_prefix, config.run_id ].join("-")), container_path: cord @@ -170,30 +187,24 @@ def asset_volume_path(version = nil) end private - attr_accessor :config, :tagged_hosts - - def extract_tagged_hosts_from_config + def tagged_hosts {}.tap do |tagged_hosts| extract_hosts_from_config.map do |host_config| if host_config.is_a?(Hash) - raise ArgumentError, "Multiple hosts found: #{host_config.inspect}" unless host_config.size == 1 - host, tags = host_config.first tagged_hosts[host] = Array(tags) - elsif host_config.is_a?(String) || host_config.is_a?(Symbol) + elsif host_config.is_a?(String) tagged_hosts[host_config] = [] - else - raise ArgumentError, "Invalid host config: #{host_config.inspect}" end end end end def extract_hosts_from_config - if config.servers.is_a?(Array) - config.servers + if config.raw_config.servers.is_a?(Array) + config.raw_config.servers else - servers = config.servers[name] + servers = config.raw_config.servers[name] servers.is_a?(Array) ? servers : Array(servers["hosts"]) end end @@ -202,6 +213,14 @@ def default_labels { "service" => config.service, "role" => name, "destination" => config.destination } end + def specializations + if config.raw_config.servers.is_a?(Array) || config.raw_config.servers[name].is_a?(Array) + {} + else + config.raw_config.servers[name] + end + end + def traefik_labels if running_traefik? { @@ -229,35 +248,4 @@ def custom_labels labels.merge!(specializations["labels"]) if specializations["labels"].present? end end - - def specializations - if config.servers.is_a?(Array) || config.servers[name].is_a?(Array) - {} - else - config.servers[name].except("hosts") - end - end - - def specialized_env - Kamal::Configuration::Env.from_config config: specializations.fetch("env", {}) - end - - # Secrets are stored in an array, which won't merge by default, so have to do it by hand. - def base_env - Kamal::Configuration::Env.from_config \ - config: config.env, - secrets_file: File.join(config.host_env_directory, "roles", "#{container_prefix}.env") - end - - def http_health_check(port:, path:) - "curl -f #{URI.join("http://localhost:#{port}", path)} || exit 1" if path.present? || port.present? - end - - def health_check_options - @health_check_options ||= begin - options = specializations["healthcheck"] || {} - options = config.healthcheck.merge(options) if running_traefik? - options - end - end end diff --git a/lib/kamal/configuration/servers.rb b/lib/kamal/configuration/servers.rb new file mode 100644 index 000000000..ef5e79427 --- /dev/null +++ b/lib/kamal/configuration/servers.rb @@ -0,0 +1,18 @@ +class Kamal::Configuration::Servers + include Kamal::Configuration::Validation + + attr_reader :config, :servers_config, :roles + + def initialize(config:) + @config = config + @servers_config = config.raw_config.servers + validate! servers_config, with: Kamal::Configuration::Validator::Servers + + @roles = role_names.map { |role_name| Kamal::Configuration::Role.new role_name, config: config } + end + + private + def role_names + servers_config.is_a?(Array) ? [ "web" ] : servers_config.keys.sort + end +end diff --git a/lib/kamal/configuration/ssh.rb b/lib/kamal/configuration/ssh.rb index b96301027..99ca1d514 100644 --- a/lib/kamal/configuration/ssh.rb +++ b/lib/kamal/configuration/ssh.rb @@ -1,22 +1,27 @@ class Kamal::Configuration::Ssh LOGGER = ::Logger.new(STDERR) + include Kamal::Configuration::Validation + + attr_reader :ssh_config + def initialize(config:) - @config = config.raw_config.ssh || {} + @ssh_config = config.raw_config.ssh || {} + validate! ssh_config end def user - config.fetch("user", "root") + ssh_config.fetch("user", "root") end def port - config.fetch("port", 22) + ssh_config.fetch("port", 22) end def proxy - if (proxy = config["proxy"]) + if (proxy = ssh_config["proxy"]) Net::SSH::Proxy::Jump.new(proxy.include?("@") ? proxy : "root@#{proxy}") - elsif (proxy_command = config["proxy_command"]) + elsif (proxy_command = ssh_config["proxy_command"]) Net::SSH::Proxy::Command.new(proxy_command) end end @@ -30,13 +35,11 @@ def to_h end private - attr_accessor :config - def logger LOGGER.tap { |logger| logger.level = log_level } end def log_level - config.fetch("log_level", :fatal) + ssh_config.fetch("log_level", :fatal) end end diff --git a/lib/kamal/configuration/sshkit.rb b/lib/kamal/configuration/sshkit.rb index 9631a9160..9d4d61ce3 100644 --- a/lib/kamal/configuration/sshkit.rb +++ b/lib/kamal/configuration/sshkit.rb @@ -1,20 +1,22 @@ class Kamal::Configuration::Sshkit + include Kamal::Configuration::Validation + + attr_reader :sshkit_config + def initialize(config:) - @options = config.raw_config.sshkit || {} + @sshkit_config = config.raw_config.sshkit || {} + validate! sshkit_config end def max_concurrent_starts - options.fetch("max_concurrent_starts", 30) + sshkit_config.fetch("max_concurrent_starts", 30) end def pool_idle_timeout - options.fetch("pool_idle_timeout", 900) + sshkit_config.fetch("pool_idle_timeout", 900) end def to_h - options + sshkit_config end - - private - attr_accessor :options end diff --git a/lib/kamal/configuration/traefik.rb b/lib/kamal/configuration/traefik.rb new file mode 100644 index 000000000..c958afdfd --- /dev/null +++ b/lib/kamal/configuration/traefik.rb @@ -0,0 +1,60 @@ +class Kamal::Configuration::Traefik + DEFAULT_IMAGE = "traefik:v2.10" + CONTAINER_PORT = 80 + DEFAULT_ARGS = { + "log.level" => "DEBUG" + } + DEFAULT_LABELS = { + # These ensure we serve a 502 rather than a 404 if no containers are available + "traefik.http.routers.catchall.entryPoints" => "http", + "traefik.http.routers.catchall.rule" => "PathPrefix(`/`)", + "traefik.http.routers.catchall.service" => "unavailable", + "traefik.http.routers.catchall.priority" => 1, + "traefik.http.services.unavailable.loadbalancer.server.port" => "0" + } + + include Kamal::Configuration::Validation + + attr_reader :config, :traefik_config + + def initialize(config:) + @config = config + @traefik_config = config.raw_config.traefik || {} + validate! traefik_config + end + + def publish? + traefik_config["publish"] != false + end + + def labels + DEFAULT_LABELS.merge(traefik_config["labels"] || {}) + end + + def env + Kamal::Configuration::Env.new \ + config: traefik_config.fetch("env", {}), + secrets_file: File.join(config.host_env_directory, "traefik", "traefik.env"), + context: "traefik/env" + end + + def host_port + traefik_config.fetch("host_port", CONTAINER_PORT) + end + + def options + traefik_config.fetch("options", {}) + end + + def port + "#{host_port}:#{CONTAINER_PORT}" + end + + def args + DEFAULT_ARGS.merge(traefik_config.fetch("args", {})) + end + + def image + traefik_config.fetch("image", DEFAULT_IMAGE) + end +end diff --git a/lib/kamal/configuration/validation.rb b/lib/kamal/configuration/validation.rb new file mode 100644 index 000000000..37a0388b7 --- /dev/null +++ b/lib/kamal/configuration/validation.rb @@ -0,0 +1,27 @@ +require "yaml" +require "active_support/inflector" + +module Kamal::Configuration::Validation + extend ActiveSupport::Concern + + class_methods do + def validation_doc + @validation_doc ||= File.read(File.join(File.dirname(__FILE__), "docs", "#{validation_config_key}.yml")) + end + + def validation_config_key + @validation_config_key ||= name.demodulize.underscore + end + end + + def validate!(config, example: nil, context: nil, with: Kamal::Configuration::Validator) + context ||= self.class.validation_config_key + example ||= validation_yml[self.class.validation_config_key] + + with.new(config, example: example, context: context).validate! + end + + def validation_yml + @validation_yml ||= YAML.load(self.class.validation_doc) + end +end diff --git a/lib/kamal/configuration/validator.rb b/lib/kamal/configuration/validator.rb new file mode 100644 index 000000000..7b1665b62 --- /dev/null +++ b/lib/kamal/configuration/validator.rb @@ -0,0 +1,140 @@ +class Kamal::Configuration::Validator + attr_reader :config, :example, :context + + def initialize(config, example:, context:) + @config = config + @example = example + @context = context + end + + def validate! + validate_against_example! config, example + end + + private + def validate_against_example!(validation_config, example) + validate_type! validation_config, Hash + + if (unknown_keys = validation_config.keys - example.keys).any? + unknown_keys_error unknown_keys + end + + validation_config.each do |key, value| + with_context(key) do + example_value = example[key] + + if example_value == "..." + validate_type! value, *(Array if key == :servers), Hash + elsif key == "hosts" + validate_servers! value + elsif example_value.is_a?(Array) + validate_array_of! value, example_value.first.class + elsif example_value.is_a?(Hash) + case key.to_s + when "options" + validate_type! value, Hash + when "args", "labels" + validate_hash_of! value, example_value.first[1].class + else + validate_against_example! value, example_value + end + else + validate_type! value, example_value.class + end + end + end + end + + + def valid_type?(value, type) + value.is_a?(type) || + (type == String && stringish?(value)) || + (boolean?(type) && boolean?(value.class)) + end + + def type_description(type) + if type == Integer || type == Array + "an #{type.name.downcase}" + elsif type == TrueClass || type == FalseClass + "a boolean" + else + "a #{type.name.downcase}" + end + end + + def boolean?(type) + type == TrueClass || type == FalseClass + end + + def stringish?(value) + value.is_a?(String) || value.is_a?(Symbol) || value.is_a?(Numeric) || value.is_a?(TrueClass) || value.is_a?(FalseClass) + end + + def validate_array_of!(array, type) + validate_type! array, Array + + array.each_with_index do |value, index| + with_context(index) do + validate_type! value, type + end + end + end + + def validate_hash_of!(hash, type) + validate_type! hash, Hash + + hash.each do |key, value| + with_context(key) do + validate_type! value, type + end + end + end + + def validate_servers!(servers) + validate_type! servers, Array + + servers.each_with_index do |server, index| + with_context(index) do + validate_type! server, String, Hash + + if server.is_a?(Hash) + error "multiple hosts found" unless server.size == 1 + host, tags = server.first + + with_context(host) do + validate_type! tags, String, Array + validate_array_of! tags, String if tags.is_a?(Array) + end + end + end + end + end + + def validate_type!(value, *types) + type_error(*types) unless types.any? { |type| valid_type?(value, type) } + end + + def error(message) + raise Kamal::ConfigurationError, "#{error_context}#{message}" + end + + def type_error(*expected_types) + error "should be #{expected_types.map { |type| type_description(type) }.join(" or ")}" + end + + def unknown_keys_error(unknown_keys) + error "unknown #{"key".pluralize(unknown_keys.count)}: #{unknown_keys.join(", ")}" + end + + def error_context + "#{context}: " if context.present? + end + + def with_context(context) + old_context = @context + @context = [ @context, context ].select(&:present?).join("/") + yield + ensure + @context = old_context + end +end diff --git a/lib/kamal/configuration/validator/accessory.rb b/lib/kamal/configuration/validator/accessory.rb new file mode 100644 index 000000000..33245e24d --- /dev/null +++ b/lib/kamal/configuration/validator/accessory.rb @@ -0,0 +1,9 @@ +class Kamal::Configuration::Validator::Accessory < Kamal::Configuration::Validator + def validate! + super + + if (config.keys & [ "host", "hosts", "roles" ]).size != 1 + error "specify one of `host`, `hosts` or `roles`" + end + end +end diff --git a/lib/kamal/configuration/validator/builder.rb b/lib/kamal/configuration/validator/builder.rb new file mode 100644 index 000000000..ebccdf817 --- /dev/null +++ b/lib/kamal/configuration/validator/builder.rb @@ -0,0 +1,9 @@ +class Kamal::Configuration::Validator::Builder < Kamal::Configuration::Validator + def validate! + super + + if config["cache"] && config["cache"]["type"] + error "Invalid cache type: #{config["cache"]["type"]}" unless [ "gha", "registry" ].include?(config["cache"]["type"]) + end + end +end diff --git a/lib/kamal/configuration/validator/env.rb b/lib/kamal/configuration/validator/env.rb new file mode 100644 index 000000000..a0d90d6da --- /dev/null +++ b/lib/kamal/configuration/validator/env.rb @@ -0,0 +1,54 @@ +class Kamal::Configuration::Validator::Env < Kamal::Configuration::Validator + SPECIAL_KEYS = [ "clear", "secret", "tags" ] + + def validate! + if known_keys.any? + validate_complex_env! + else + validate_simple_env! + end + end + + private + def validate_simple_env! + validate_hash_of!(config, String) + end + + def validate_complex_env! + unknown_keys_error unknown_keys if unknown_keys.any? + + with_context("clear") { validate_hash_of!(config["clear"], String) } if config.key?("clear") + with_context("secret") { validate_array_of!(config["secret"], String) } if config.key?("secret") + validate_tags! if config.key?("tags") + end + + def known_keys + @known_keys ||= config.keys & SPECIAL_KEYS + end + + def unknown_keys + @unknown_keys ||= config.keys - SPECIAL_KEYS + end + + def validate_tags! + if context == "env" + with_context("tags") do + validate_type! config["tags"], Hash + + config["tags"].each do |tag, value| + with_context(tag) do + validate_type! value, Hash + + Kamal::Configuration::Validator::Env.new( + value, + example: example["tags"].values[1], + context: context + ).validate! + end + end + end + else + error "tags are only allowed in the root env" + end + end +end diff --git a/lib/kamal/configuration/validator/registry.rb b/lib/kamal/configuration/validator/registry.rb new file mode 100644 index 000000000..2b9c0859a --- /dev/null +++ b/lib/kamal/configuration/validator/registry.rb @@ -0,0 +1,25 @@ +class Kamal::Configuration::Validator::Registry < Kamal::Configuration::Validator + STRING_OR_ONE_ITEM_ARRAY_KEYS = [ "username", "password" ] + + def validate! + validate_against_example! \ + config.except(*STRING_OR_ONE_ITEM_ARRAY_KEYS), + example.except(*STRING_OR_ONE_ITEM_ARRAY_KEYS) + + validate_string_or_one_item_array! "username" + validate_string_or_one_item_array! "password" + end + + private + def validate_string_or_one_item_array!(key) + with_context(key) do + value = config[key] + + error "is required" unless value.present? + + unless value.is_a?(String) || (value.is_a?(Array) && value.size == 1 && value.first.is_a?(String)) + error "should be a string or an array with one string (for secret lookup)" + end + end + end +end diff --git a/lib/kamal/configuration/validator/role.rb b/lib/kamal/configuration/validator/role.rb new file mode 100644 index 000000000..ce28c039f --- /dev/null +++ b/lib/kamal/configuration/validator/role.rb @@ -0,0 +1,11 @@ +class Kamal::Configuration::Validator::Role < Kamal::Configuration::Validator + def validate! + validate_type! config, Array, Hash + + if config.is_a?(Array) + validate_servers! "servers", config + else + super + end + end +end diff --git a/lib/kamal/configuration/validator/servers.rb b/lib/kamal/configuration/validator/servers.rb new file mode 100644 index 000000000..5a734c786 --- /dev/null +++ b/lib/kamal/configuration/validator/servers.rb @@ -0,0 +1,7 @@ +class Kamal::Configuration::Validator::Servers < Kamal::Configuration::Validator + def validate! + validate_type! config, Array, Hash + + validate_servers! config if config.is_a?(Array) + end +end diff --git a/test/cli/main_test.rb b/test/cli/main_test.rb index 595556fe0..bf0d47ea4 100644 --- a/test/cli/main_test.rb +++ b/test/cli/main_test.rb @@ -372,19 +372,6 @@ class CliMainTest < CliTestCase end end - test "config with aliases" do - run_command("config", config_file: "deploy_with_aliases").tap do |output| - config = YAML.load(output) - - assert_equal [ "web", "web_tokyo", "workers", "workers_tokyo" ], config[:roles] - assert_equal [ "1.1.1.1", "1.1.1.2", "1.1.1.3", "1.1.1.4" ], config[:hosts] - assert_equal "999", config[:version] - assert_equal "registry.digitalocean.com/dhh/app", config[:repository] - assert_equal "registry.digitalocean.com/dhh/app:999", config[:absolute_image] - assert_equal "app-999", config[:service_with_version] - end - end - test "init" do Pathname.any_instance.expects(:exist?).returns(false).times(3) Pathname.any_instance.stubs(:mkpath) @@ -437,11 +424,10 @@ class CliMainTest < CliTestCase end test "envify" do - Pathname.any_instance.expects(:exist?).returns(true).times(3) - File.expects(:read).with(".env.erb").returns("HELLO=<%= 'world' %>") - File.expects(:write).with(".env", "HELLO=world", perm: 0600) - - run_command("envify") + with_test_dot_env_erb(contents: "HELLO=<%= 'world' %>") do + run_command("envify") + assert_equal("HELLO=world", File.read(".env")) + end end test "envify with blank line trimming" do @@ -452,19 +438,17 @@ class CliMainTest < CliTestCase <% end -%> EOF - Pathname.any_instance.expects(:exist?).returns(true).times(3) - File.expects(:read).with(".env.erb").returns(file.strip) - File.expects(:write).with(".env", "HELLO=world\nKEY=value\n", perm: 0600) - - run_command("envify") + with_test_dot_env_erb(contents: file) do + run_command("envify") + assert_equal("HELLO=world\nKEY=value\n", File.read(".env")) + end end test "envify with destination" do - Pathname.any_instance.expects(:exist?).returns(true).times(4) - File.expects(:read).with(".env.world.erb").returns("HELLO=<%= 'world' %>") - File.expects(:write).with(".env.world", "HELLO=world", perm: 0600) - - run_command("envify", "-d", "world", config_file: "deploy_for_dest") + with_test_dot_env_erb(contents: "HELLO=<%= 'world' %>", file: ".env.world.erb") 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 @@ -500,6 +484,24 @@ class CliMainTest < CliTestCase end end + test "docs" do + run_command("docs").tap do |output| + assert_match "# Kamal Configuration", output + end + end + + test "docs subsection" do + run_command("docs", "accessory").tap do |output| + assert_match "# Accessories", output + end + end + + test "docs unknown" do + run_command("docs", "foo").tap do |output| + assert_match "No documentation found for foo", output + end + end + test "version" do version = stdouted { Kamal::Cli::Main.new.version } assert_equal Kamal::VERSION, version @@ -509,4 +511,17 @@ class CliMainTest < CliTestCase def run_command(*command, config_file: "deploy_simple") stdouted { Kamal::Cli::Main.start([ *command, "-c", "test/fixtures/#{config_file}.yml" ]) } end + + def with_test_dot_env_erb(contents:, file: ".env.erb") + Dir.mktmpdir do |dir| + fixtures_dup = File.join(dir, "test") + FileUtils.mkdir_p(fixtures_dup) + FileUtils.cp_r("test/fixtures/", fixtures_dup) + + Dir.chdir(dir) do + File.write(file, contents) + yield + end + end + end end diff --git a/test/cli/traefik_test.rb b/test/cli/traefik_test.rb index d83529cd4..0c531e011 100644 --- a/test/cli/traefik_test.rb +++ b/test/cli/traefik_test.rb @@ -4,7 +4,7 @@ class CliTraefikTest < CliTestCase test "boot" do run_command("boot").tap do |output| assert_match "docker login", output - assert_match "docker run --name traefik --detach --restart unless-stopped --publish 80:80 --volume /var/run/docker.sock:/var/run/docker.sock --env-file .kamal/env/traefik/traefik.env --log-opt max-size=\"10m\" --label traefik.http.routers.catchall.entryPoints=\"http\" --label traefik.http.routers.catchall.rule=\"PathPrefix(\\`/\\`)\" --label traefik.http.routers.catchall.service=\"unavailable\" --label traefik.http.routers.catchall.priority=\"1\" --label traefik.http.services.unavailable.loadbalancer.server.port=\"0\" #{Kamal::Commands::Traefik::DEFAULT_IMAGE} --providers.docker --log.level=\"DEBUG\"", output + assert_match "docker run --name traefik --detach --restart unless-stopped --publish 80:80 --volume /var/run/docker.sock:/var/run/docker.sock --env-file .kamal/env/traefik/traefik.env --log-opt max-size=\"10m\" --label traefik.http.routers.catchall.entryPoints=\"http\" --label traefik.http.routers.catchall.rule=\"PathPrefix(\\`/\\`)\" --label traefik.http.routers.catchall.service=\"unavailable\" --label traefik.http.routers.catchall.priority=\"1\" --label traefik.http.services.unavailable.loadbalancer.server.port=\"0\" #{Kamal::Configuration::Traefik::DEFAULT_IMAGE} --providers.docker --log.level=\"DEBUG\"", output end end @@ -14,7 +14,7 @@ class CliTraefikTest < CliTestCase run_command("reboot", "-y").tap do |output| assert_match "docker container stop traefik", output assert_match "docker container prune --force --filter label=org.opencontainers.image.title=Traefik", output - assert_match "docker run --name traefik --detach --restart unless-stopped --publish 80:80 --volume /var/run/docker.sock:/var/run/docker.sock --env-file .kamal/env/traefik/traefik.env --log-opt max-size=\"10m\" --label traefik.http.routers.catchall.entryPoints=\"http\" --label traefik.http.routers.catchall.rule=\"PathPrefix(\\`/\\`)\" --label traefik.http.routers.catchall.service=\"unavailable\" --label traefik.http.routers.catchall.priority=\"1\" --label traefik.http.services.unavailable.loadbalancer.server.port=\"0\" #{Kamal::Commands::Traefik::DEFAULT_IMAGE} --providers.docker --log.level=\"DEBUG\"", output + assert_match "docker run --name traefik --detach --restart unless-stopped --publish 80:80 --volume /var/run/docker.sock:/var/run/docker.sock --env-file .kamal/env/traefik/traefik.env --log-opt max-size=\"10m\" --label traefik.http.routers.catchall.entryPoints=\"http\" --label traefik.http.routers.catchall.rule=\"PathPrefix(\\`/\\`)\" --label traefik.http.routers.catchall.service=\"unavailable\" --label traefik.http.routers.catchall.priority=\"1\" --label traefik.http.services.unavailable.loadbalancer.server.port=\"0\" #{Kamal::Configuration::Traefik::DEFAULT_IMAGE} --providers.docker --log.level=\"DEBUG\"", output end end diff --git a/test/commander_test.rb b/test/commander_test.rb index 6a7ec536e..c2b78b213 100644 --- a/test/commander_test.rb +++ b/test/commander_test.rb @@ -137,17 +137,17 @@ class CommanderTest < ActiveSupport::TestCase end test "traefik hosts should observe filtered roles" do - configure_with(:deploy_with_aliases) + configure_with(:deploy_with_multiple_traefik_roles) @kamal.specific_roles = [ "web_tokyo" ] assert_equal [ "1.1.1.3", "1.1.1.4" ], @kamal.traefik_hosts end test "traefik hosts should observe filtered hosts" do - configure_with(:deploy_with_aliases) + configure_with(:deploy_with_multiple_traefik_roles) - @kamal.specific_hosts = [ "1.1.1.4" ] - assert_equal [ "1.1.1.4" ], @kamal.traefik_hosts + @kamal.specific_hosts = [ "1.1.1.2" ] + assert_equal [ "1.1.1.2" ], @kamal.traefik_hosts end private diff --git a/test/commands/accessory_test.rb b/test/commands/accessory_test.rb index da8c30571..85dfa7450 100644 --- a/test/commands/accessory_test.rb +++ b/test/commands/accessory_test.rb @@ -24,7 +24,7 @@ class CommandsAccessoryTest < ActiveSupport::TestCase "host" => "1.1.1.6", "port" => "6379:6379", "labels" => { - "cache" => true + "cache" => "true" }, "env" => { "SOMETHING" => "else" diff --git a/test/commands/traefik_test.rb b/test/commands/traefik_test.rb index 157670a33..3632ddd65 100644 --- a/test/commands/traefik_test.rb +++ b/test/commands/traefik_test.rb @@ -91,7 +91,7 @@ class CommandsTraefikTest < ActiveSupport::TestCase @config.delete(:traefik) assert_equal \ - "docker run --name traefik --detach --restart unless-stopped --publish 80:80 --volume /var/run/docker.sock:/var/run/docker.sock --env-file .kamal/env/traefik/traefik.env --log-opt max-size=\"10m\" --label traefik.http.routers.catchall.entryPoints=\"http\" --label traefik.http.routers.catchall.rule=\"PathPrefix(\\`/\\`)\" --label traefik.http.routers.catchall.service=\"unavailable\" --label traefik.http.routers.catchall.priority=\"1\" --label traefik.http.services.unavailable.loadbalancer.server.port=\"0\" #{Kamal::Commands::Traefik::DEFAULT_IMAGE} --providers.docker --log.level=\"DEBUG\"", + "docker run --name traefik --detach --restart unless-stopped --publish 80:80 --volume /var/run/docker.sock:/var/run/docker.sock --env-file .kamal/env/traefik/traefik.env --log-opt max-size=\"10m\" --label traefik.http.routers.catchall.entryPoints=\"http\" --label traefik.http.routers.catchall.rule=\"PathPrefix(\\`/\\`)\" --label traefik.http.routers.catchall.service=\"unavailable\" --label traefik.http.routers.catchall.priority=\"1\" --label traefik.http.services.unavailable.loadbalancer.server.port=\"0\" #{Kamal::Configuration::Traefik::DEFAULT_IMAGE} --providers.docker --log.level=\"DEBUG\"", new_command.run.join(" ") end diff --git a/test/configuration/accessory_test.rb b/test/configuration/accessory_test.rb index 51c98c6e0..581cdab34 100644 --- a/test/configuration/accessory_test.rb +++ b/test/configuration/accessory_test.rb @@ -35,7 +35,7 @@ class ConfigurationAccessoryTest < ActiveSupport::TestCase "hosts" => [ "1.1.1.6", "1.1.1.7" ], "port" => "6379:6379", "labels" => { - "cache" => true + "cache" => "true" }, "env" => { "SOMETHING" => "else" @@ -44,7 +44,7 @@ class ConfigurationAccessoryTest < ActiveSupport::TestCase "/var/lib/redis:/data" ], "options" => { - "cpus" => 4, + "cpus" => "4", "memory" => "2GB" } }, @@ -54,13 +54,13 @@ class ConfigurationAccessoryTest < ActiveSupport::TestCase "roles" => [ "web" ], "port" => "4321:4321", "labels" => { - "cache" => true + "cache" => "true" }, "env" => { "STATSD_PORT" => "8126" }, "options" => { - "cpus" => 4, + "cpus" => "4", "memory" => "2GB" } } @@ -89,22 +89,20 @@ class ConfigurationAccessoryTest < ActiveSupport::TestCase test "missing host" do @deploy[:accessories]["mysql"]["host"] = nil - @config = Kamal::Configuration.new(@deploy) - assert_raises(ArgumentError) do - @config.accessory(:mysql).hosts + assert_raises(Kamal::ConfigurationError) do + Kamal::Configuration.new(@deploy) end end test "setting host, hosts and roles" do - @deploy[:accessories]["mysql"]["hosts"] = true - @deploy[:accessories]["mysql"]["roles"] = true - @config = Kamal::Configuration.new(@deploy) + @deploy[:accessories]["mysql"]["hosts"] = [ "mysql-db1" ] + @deploy[:accessories]["mysql"]["roles"] = [ "db" ] - exception = assert_raises(ArgumentError) do - @config.accessory(:mysql).hosts + exception = assert_raises(Kamal::ConfigurationError) do + Kamal::Configuration.new(@deploy) end - assert_equal "Specify one of `host`, `hosts` or `roles` for accessory `mysql`", exception.message + assert_equal "accessories/mysql: specify one of `host`, `hosts` or `roles`", exception.message end test "all hosts" do diff --git a/test/configuration/builder_test.rb b/test/configuration/builder_test.rb index a519be676..4b37b5e8a 100644 --- a/test/configuration/builder_test.rb +++ b/test/configuration/builder_test.rb @@ -7,41 +7,37 @@ class ConfigurationBuilderTest < ActiveSupport::TestCase servers: [ "1.1.1.1" ] } - @config = Kamal::Configuration.new(@deploy) - @deploy_with_builder_option = { service: "app", image: "dhh/app", registry: { "username" => "dhh", "password" => "secret" }, servers: [ "1.1.1.1" ], builder: {} } - - @config_with_builder_option = Kamal::Configuration.new(@deploy_with_builder_option) end test "multiarch?" do - assert_equal true, @config.builder.multiarch? + assert_equal true, config.builder.multiarch? end test "setting multiarch to false" do @deploy_with_builder_option[:builder] = { "multiarch" => false } - assert_equal false, @config_with_builder_option.builder.multiarch? + assert_equal false, config_with_builder_option.builder.multiarch? end test "local?" do - assert_equal false, @config.builder.local? + assert_equal false, config.builder.local? end test "remote?" do - assert_equal false, @config.builder.remote? + assert_equal false, config.builder.remote? end test "remote_arch" do - assert_nil @config.builder.remote_arch + assert_nil config.builder.remote_arch end test "remote_host" do - assert_nil @config.builder.remote_host + assert_nil config.builder.remote_host end test "setting both local and remote configs" do @@ -50,112 +46,121 @@ class ConfigurationBuilderTest < ActiveSupport::TestCase "remote" => { "arch" => "amd64", "host" => "ssh://root@192.168.0.1" } } - assert_equal true, @config_with_builder_option.builder.local? - assert_equal true, @config_with_builder_option.builder.remote? + assert_equal true, config_with_builder_option.builder.local? + assert_equal true, config_with_builder_option.builder.remote? - assert_equal "amd64", @config_with_builder_option.builder.remote_arch - assert_equal "ssh://root@192.168.0.1", @config_with_builder_option.builder.remote_host + assert_equal "amd64", config_with_builder_option.builder.remote_arch + assert_equal "ssh://root@192.168.0.1", config_with_builder_option.builder.remote_host - assert_equal "arm64", @config_with_builder_option.builder.local_arch - assert_equal "unix:///Users/<%= `whoami`.strip %>/.docker/run/docker.sock", @config_with_builder_option.builder.local_host + assert_equal "arm64", config_with_builder_option.builder.local_arch + assert_equal "unix:///Users/<%= `whoami`.strip %>/.docker/run/docker.sock", config_with_builder_option.builder.local_host end test "cached?" do - assert_equal false, @config.builder.cached? + assert_equal false, config.builder.cached? end test "invalid cache type specified" do @deploy_with_builder_option[:builder] = { "cache" => { "type" => "invalid" } } - assert_raises(ArgumentError) do - @config_with_builder_option.builder + assert_raises(Kamal::ConfigurationError) do + config_with_builder_option.builder end end test "cache_from" do - assert_nil @config.builder.cache_from + assert_nil config.builder.cache_from end test "cache_to" do - assert_nil @config.builder.cache_to + assert_nil config.builder.cache_to end test "setting gha cache" do @deploy_with_builder_option[:builder] = { "cache" => { "type" => "gha", "options" => "mode=max" } } - assert_equal "type=gha", @config_with_builder_option.builder.cache_from - assert_equal "type=gha,mode=max", @config_with_builder_option.builder.cache_to + assert_equal "type=gha", config_with_builder_option.builder.cache_from + assert_equal "type=gha,mode=max", config_with_builder_option.builder.cache_to end test "setting registry cache" do @deploy_with_builder_option[:builder] = { "cache" => { "type" => "registry", "options" => "mode=max,image-manifest=true,oci-mediatypes=true" } } - assert_equal "type=registry,ref=dhh/app-build-cache", @config_with_builder_option.builder.cache_from - assert_equal "type=registry,mode=max,image-manifest=true,oci-mediatypes=true,ref=dhh/app-build-cache", @config_with_builder_option.builder.cache_to + assert_equal "type=registry,ref=dhh/app-build-cache", config_with_builder_option.builder.cache_from + assert_equal "type=registry,mode=max,image-manifest=true,oci-mediatypes=true,ref=dhh/app-build-cache", config_with_builder_option.builder.cache_to end test "setting registry cache when using a custom registry" do - @config_with_builder_option.registry["server"] = "registry.example.com" + @deploy_with_builder_option[:registry]["server"] = "registry.example.com" @deploy_with_builder_option[:builder] = { "cache" => { "type" => "registry", "options" => "mode=max,image-manifest=true,oci-mediatypes=true" } } - assert_equal "type=registry,ref=registry.example.com/dhh/app-build-cache", @config_with_builder_option.builder.cache_from - assert_equal "type=registry,mode=max,image-manifest=true,oci-mediatypes=true,ref=registry.example.com/dhh/app-build-cache", @config_with_builder_option.builder.cache_to + assert_equal "type=registry,ref=registry.example.com/dhh/app-build-cache", config_with_builder_option.builder.cache_from + assert_equal "type=registry,mode=max,image-manifest=true,oci-mediatypes=true,ref=registry.example.com/dhh/app-build-cache", config_with_builder_option.builder.cache_to end test "setting registry cache with image" do @deploy_with_builder_option[:builder] = { "cache" => { "type" => "registry", "image" => "kamal", "options" => "mode=max" } } - assert_equal "type=registry,ref=kamal", @config_with_builder_option.builder.cache_from - assert_equal "type=registry,mode=max,ref=kamal", @config_with_builder_option.builder.cache_to + assert_equal "type=registry,ref=kamal", config_with_builder_option.builder.cache_from + assert_equal "type=registry,mode=max,ref=kamal", config_with_builder_option.builder.cache_to end test "args" do - assert_equal({}, @config.builder.args) + assert_equal({}, config.builder.args) end test "setting args" do @deploy_with_builder_option[:builder] = { "args" => { "key" => "value" } } - assert_equal({ "key" => "value" }, @config_with_builder_option.builder.args) + assert_equal({ "key" => "value" }, config_with_builder_option.builder.args) end test "secrets" do - assert_equal [], @config.builder.secrets + assert_equal [], config.builder.secrets end test "setting secrets" do @deploy_with_builder_option[:builder] = { "secrets" => [ "GITHUB_TOKEN" ] } - assert_equal [ "GITHUB_TOKEN" ], @config_with_builder_option.builder.secrets + assert_equal [ "GITHUB_TOKEN" ], config_with_builder_option.builder.secrets end test "dockerfile" do - assert_equal "Dockerfile", @config.builder.dockerfile + assert_equal "Dockerfile", config.builder.dockerfile end test "setting dockerfile" do @deploy_with_builder_option[:builder] = { "dockerfile" => "Dockerfile.dev" } - assert_equal "Dockerfile.dev", @config_with_builder_option.builder.dockerfile + assert_equal "Dockerfile.dev", config_with_builder_option.builder.dockerfile end test "context" do - assert_equal ".", @config.builder.context + assert_equal ".", config.builder.context end test "setting context" do @deploy_with_builder_option[:builder] = { "context" => ".." } - assert_equal "..", @config_with_builder_option.builder.context + assert_equal "..", config_with_builder_option.builder.context end test "ssh" do - assert_nil @config.builder.ssh + assert_nil config.builder.ssh end test "setting ssh params" do @deploy_with_builder_option[:builder] = { "ssh" => "default=$SSH_AUTH_SOCK" } - assert_equal "default=$SSH_AUTH_SOCK", @config_with_builder_option.builder.ssh + assert_equal "default=$SSH_AUTH_SOCK", config_with_builder_option.builder.ssh end + + private + def config + Kamal::Configuration.new(@deploy) + end + + def config_with_builder_option + Kamal::Configuration.new(@deploy_with_builder_option) + end end diff --git a/test/configuration/env_test.rb b/test/configuration/env_test.rb index d24c62116..49d800ef3 100644 --- a/test/configuration/env_test.rb +++ b/test/configuration/env_test.rb @@ -19,7 +19,7 @@ class ConfigurationEnvTest < ActiveSupport::TestCase test "secret" do ENV["PASSWORD"] = "hello" - env = Kamal::Configuration::Env.from_config config: { "secret" => [ "PASSWORD" ] } + env = Kamal::Configuration::Env.new config: { "secret" => [ "PASSWORD" ] } assert_config \ config: { "secret" => [ "PASSWORD" ] }, @@ -34,7 +34,7 @@ class ConfigurationEnvTest < ActiveSupport::TestCase "secret" => [ "PASSWORD" ] } - assert_raises(KeyError) { Kamal::Configuration::Env.from_config(config: { "secret" => [ "PASSWORD" ] }).secrets } + assert_raises(KeyError) { Kamal::Configuration::Env.new(config: { "secret" => [ "PASSWORD" ] }).secrets } end test "secret and clear" do @@ -67,7 +67,7 @@ class ConfigurationEnvTest < ActiveSupport::TestCase private def assert_config(config:, clear:, secrets:) - env = Kamal::Configuration::Env.from_config config: config + env = Kamal::Configuration::Env.new config: config, secrets_file: "secrets.env" assert_equal clear, env.clear assert_equal secrets, env.secrets end diff --git a/test/configuration/role_test.rb b/test/configuration/role_test.rb index 84fdfe6be..da9a3d1c5 100644 --- a/test/configuration/role_test.rb +++ b/test/configuration/role_test.rb @@ -18,7 +18,7 @@ class ConfigurationRoleTest < ActiveSupport::TestCase "cmd" => "bin/jobs", "env" => { "REDIS_URL" => "redis://a/b", - "WEB_CONCURRENCY" => 4 + "WEB_CONCURRENCY" => "4" } } } @@ -53,7 +53,7 @@ class ConfigurationRoleTest < ActiveSupport::TestCase test "custom labels via role specialization" do @deploy_with_roles[:labels] = { "my.custom.label" => "50" } @deploy_with_roles[:servers]["workers"]["labels"] = { "my.custom.label" => "70" } - assert_equal "70", @config_with_roles.role(:workers).labels["my.custom.label"] + assert_equal "70", Kamal::Configuration.new(@deploy_with_roles).role(:workers).labels["my.custom.label"] end test "overwriting default traefik label" do @@ -63,7 +63,7 @@ class ConfigurationRoleTest < ActiveSupport::TestCase test "default traefik label on non-web role" do config = Kamal::Configuration.new(@deploy_with_roles.tap { |c| - c[:servers]["beta"] = { "traefik" => "true", "hosts" => [ "1.1.1.5" ] } + c[:servers]["beta"] = { "traefik" => true, "hosts" => [ "1.1.1.5" ] } }) assert_equal [ "--label", "service=\"app\"", "--label", "role=\"beta\"", "--label", "destination", "--label", "traefik.http.services.app-beta.loadbalancer.server.scheme=\"http\"", "--label", "traefik.http.routers.app-beta.rule=\"PathPrefix(\\`/\\`)\"", "--label", "traefik.http.routers.app-beta.priority=\"2\"", "--label", "traefik.http.middlewares.app-beta-retry.retry.attempts=\"5\"", "--label", "traefik.http.middlewares.app-beta-retry.retry.initialinterval=\"500ms\"", "--label", "traefik.http.routers.app-beta.middlewares=\"app-beta-retry@docker\"" ], config.role(:beta).label_args @@ -102,7 +102,7 @@ class ConfigurationRoleTest < ActiveSupport::TestCase @deploy_with_roles[:servers]["workers"]["env"] = { "clear" => { "REDIS_URL" => "redis://a/b", - "WEB_CONCURRENCY" => 4 + "WEB_CONCURRENCY" => "4" }, "secret" => [ "DB_PASSWORD" @@ -117,7 +117,7 @@ class ConfigurationRoleTest < ActiveSupport::TestCase DB_PASSWORD=secret&\"123 ENV - assert_equal expected_secrets_file, @config_with_roles.role(:workers).env("1.1.1.3").secrets_io.string + assert_equal expected_secrets_file, Kamal::Configuration.new(@deploy_with_roles).role(:workers).env("1.1.1.3").secrets_io.string assert_equal [ "--env-file", ".kamal/env/roles/app-workers.env", "--env", "REDIS_URL=\"redis://a/b\"", "--env", "WEB_CONCURRENCY=\"4\"" ], @config_with_roles.role(:workers).env_args("1.1.1.3") ensure ENV["REDIS_PASSWORD"] = nil @@ -128,7 +128,7 @@ class ConfigurationRoleTest < ActiveSupport::TestCase @deploy_with_roles[:servers]["workers"]["env"] = { "clear" => { "REDIS_URL" => "redis://a/b", - "WEB_CONCURRENCY" => 4 + "WEB_CONCURRENCY" => "4" }, "secret" => [ "DB_PASSWORD" @@ -141,7 +141,7 @@ class ConfigurationRoleTest < ActiveSupport::TestCase DB_PASSWORD=secret123 ENV - assert_equal expected_secrets_file, @config_with_roles.role(:workers).env("1.1.1.3").secrets_io.string + assert_equal expected_secrets_file, Kamal::Configuration.new(@deploy_with_roles).role(:workers).env("1.1.1.3").secrets_io.string assert_equal [ "--env-file", ".kamal/env/roles/app-workers.env", "--env", "REDIS_URL=\"redis://a/b\"", "--env", "WEB_CONCURRENCY=\"4\"" ], @config_with_roles.role(:workers).env_args("1.1.1.3") ensure ENV["DB_PASSWORD"] = nil @@ -163,7 +163,7 @@ class ConfigurationRoleTest < ActiveSupport::TestCase REDIS_PASSWORD=secret456 ENV - assert_equal expected_secrets_file, @config_with_roles.role(:workers).env("1.1.1.3").secrets_io.string + assert_equal expected_secrets_file, Kamal::Configuration.new(@deploy_with_roles).role(:workers).env("1.1.1.3").secrets_io.string assert_equal [ "--env-file", ".kamal/env/roles/app-workers.env", "--env", "REDIS_URL=\"redis://a/b\"", "--env", "WEB_CONCURRENCY=\"4\"" ], @config_with_roles.role(:workers).env_args("1.1.1.3") ensure ENV["REDIS_PASSWORD"] = nil @@ -191,8 +191,9 @@ class ConfigurationRoleTest < ActiveSupport::TestCase REDIS_PASSWORD=secret456 ENV - assert_equal expected_secrets_file, @config_with_roles.role(:workers).env("1.1.1.3").secrets_io.string - assert_equal [ "--env-file", ".kamal/env/roles/app-workers.env", "--env", "REDIS_URL=\"redis://c/d\"" ], @config_with_roles.role(:workers).env_args("1.1.1.3") + config = Kamal::Configuration.new(@deploy_with_roles) + assert_equal expected_secrets_file, config.role(:workers).env("1.1.1.3").secrets_io.string + assert_equal [ "--env-file", ".kamal/env/roles/app-workers.env", "--env", "REDIS_URL=\"redis://c/d\"" ], config.role(:workers).env_args("1.1.1.3") ensure ENV["REDIS_PASSWORD"] = nil end diff --git a/test/configuration/validation_test.rb b/test/configuration/validation_test.rb new file mode 100644 index 000000000..71a0aa115 --- /dev/null +++ b/test/configuration/validation_test.rb @@ -0,0 +1,116 @@ +require "test_helper" +class ConfigurationValidationTest < ActiveSupport::TestCase + test "unknown root key" do + assert_error "unknown key: unknown", unknown: "value" + assert_error "unknown keys: unknown, unknown2", unknown: "value", unknown2: "value" + end + + test "wrong root types" do + [ :service, :image, :asset_path, :hooks_path, :primary_role, :minimum_version, :run_directory ].each do |key| + assert_error "#{key}: should be a string", **{ key => [] } + end + + [ :require_destination, :allow_empty_roles ].each do |key| + assert_error "#{key}: should be a boolean", **{ key => "foo" } + end + + [ :stop_wait_time, :retain_containers, :readiness_delay ].each do |key| + assert_error "#{key}: should be an integer", **{ key => "foo" } + end + + assert_error "volumes: should be an array", volumes: "foo" + + assert_error "servers: should be an array or a hash", servers: "foo" + + [ :labels, :registry, :accessories, :env, :ssh, :sshkit, :builder, :traefik, :boot, :healthcheck, :logging ].each do |key| + assert_error "#{key}: should be a hash", **{ key =>[] } + end + end + + test "servers" do + assert_error "servers: should be an array or a hash", servers: "foo" + assert_error "servers/0: should be a string or a hash", servers: [ [] ] + assert_error "servers/0: multiple hosts found", servers: [ { "a" => "b", "c" => "d" } ] + assert_error "servers/0/foo: should be a string or an array", servers: [ { "foo" => {} } ] + assert_error "servers/0/foo/0: should be a string", servers: [ { "foo" => [ [] ] } ] + end + + test "roles" do + assert_error "servers/web: should be an array or a hash", servers: { "web" => "foo" } + assert_error "servers/web/hosts: should be an array", servers: { "web" => { "hosts" => "" } } + assert_error "servers/web/hosts/0: should be a string or a hash", servers: { "web" => { "hosts" => [ [] ] } } + assert_error "servers/web/options: should be a hash", servers: { "web" => { "options" => "" } } + assert_error "servers/web/logging/options: should be a hash", servers: { "web" => { "logging" => { "options" => "" } } } + assert_error "servers/web/logging/driver: should be a string", servers: { "web" => { "logging" => { "driver" => [] } } } + assert_error "servers/web/labels: should be a hash", servers: { "web" => { "labels" => [] } } + assert_error "servers/web/env: should be a hash", servers: { "web" => { "env" => [] } } + assert_error "servers/web/env: tags are only allowed in the root env", servers: { "web" => { "hosts" => [ "1.1.1.1" ], "env" => { "tags" => {} } } } + end + + test "registry" do + assert_error "registry/username: is required", registry: {} + assert_error "registry/password: is required", registry: { "username" => "foo" } + assert_error "registry/password: should be a string or an array with one string (for secret lookup)", registry: { "username" => "foo", "password" => [ "SECRET1", "SECRET2" ] } + assert_error "registry/server: should be a string", registry: { "username" => "foo", "password" => "bar", "server" => [] } + end + + test "accessories" do + assert_error "accessories/accessory1: should be a hash", accessories: { "accessory1" => [] } + assert_error "accessories/accessory1: unknown key: unknown", accessories: { "accessory1" => { "unknown" => "baz" } } + assert_error "accessories/accessory1/options: should be a hash", accessories: { "accessory1" => { "options" => [] } } + assert_error "accessories/accessory1/host: should be a string", accessories: { "accessory1" => { "host" => [] } } + assert_error "accessories/accessory1/env: should be a hash", accessories: { "accessory1" => { "env" => [] } } + assert_error "accessories/accessory1/env: tags are only allowed in the root env", accessories: { "accessory1" => { "host" => "host", "env" => { "tags" => {} } } } + end + + test "env" do + assert_error "env: should be a hash", env: [] + assert_error "env/FOO: should be a string", env: { "FOO" => [] } + assert_error "env/clear/FOO: should be a string", env: { "clear" => { "FOO" => [] } } + assert_error "env/secret: should be an array", env: { "secret" => { "FOO" => [] } } + assert_error "env/secret/0: should be a string", env: { "secret" => [ [] ] } + assert_error "env/tags: should be a hash", env: { "tags" => [] } + assert_error "env/tags/tag1: should be a hash", env: { "tags" => { "tag1" => "foo" } } + assert_error "env/tags/tag1/FOO: should be a string", env: { "tags" => { "tag1" => { "FOO" => [] } } } + assert_error "env/tags/tag1/clear/FOO: should be a string", env: { "tags" => { "tag1" => { "clear" => { "FOO" => [] } } } } + assert_error "env/tags/tag1/secret: should be an array", env: { "tags" => { "tag1" => { "secret" => {} } } } + assert_error "env/tags/tag1/secret/0: should be a string", env: { "tags" => { "tag1" => { "secret" => [ [] ] } } } + assert_error "env/tags/tag1: tags are only allowed in the root env", env: { "tags" => { "tag1" => { "tags" => {} } } } + end + + test "ssh" do + assert_error "ssh: unknown key: foo", ssh: { "foo" => "bar" } + assert_error "ssh/user: should be a string", ssh: { "user" => [] } + end + + test "sshkit" do + assert_error "sshkit: unknown key: foo", sshkit: { "foo" => "bar" } + assert_error "sshkit/max_concurrent_starts: should be an integer", sshkit: { "max_concurrent_starts" => "foo" } + end + + test "builder" do + assert_error "builder: unknown key: foo", builder: { "foo" => "bar" } + assert_error "builder/remote: should be a hash", builder: { "remote" => true } + assert_error "builder/remote: unknown key: foo", builder: { "remote" => { "foo" => "bar" } } + assert_error "builder/local: unknown key: foo", builder: { "local" => { "foo" => "bar" } } + assert_error "builder/remote/arch: should be a string", builder: { "remote" => { "arch" => [] } } + assert_error "builder/args/foo: should be a string", builder: { "args" => { "foo" => [] } } + assert_error "builder/cache/options: should be a string", builder: { "cache" => { "options" => [] } } + end + + private + def assert_error(message, **invalid_config) + valid_config = { + service: "app", + image: "app", + registry: { "username" => "user", "password" => "secret" }, + servers: [ "1.1.1.1" ] + } + + error = assert_raises Kamal::ConfigurationError do + Kamal::Configuration.new(valid_config.merge(invalid_config)) + end + + assert_equal message, error.message + end +end diff --git a/test/configuration_test.rb b/test/configuration_test.rb index e7e7f1511..6e83054b4 100644 --- a/test/configuration_test.rb +++ b/test/configuration_test.rb @@ -28,7 +28,7 @@ class ConfigurationTest < ActiveSupport::TestCase %i[ service image registry ].each do |key| test "#{key} config required" do - assert_raise(ArgumentError) do + assert_raise(Kamal::ConfigurationError) do Kamal::Configuration.new @deploy.tap { _1.delete key } end end @@ -36,19 +36,21 @@ class ConfigurationTest < ActiveSupport::TestCase %w[ username password ].each do |key| test "registry #{key} required" do - assert_raise(ArgumentError) do + assert_raise(Kamal::ConfigurationError) do Kamal::Configuration.new @deploy.tap { _1[:registry].delete key } end end end test "service name valid" do - assert Kamal::Configuration.new(@deploy.tap { _1[:service] = "hey-app1_primary" }).valid? - assert Kamal::Configuration.new(@deploy.tap { _1[:service] = "MyApp" }).valid? + assert_nothing_raised do + Kamal::Configuration.new(@deploy.tap { _1[:service] = "hey-app1_primary" }) + Kamal::Configuration.new(@deploy.tap { _1[:service] = "MyApp" }) + end end test "service name invalid" do - assert_raise(ArgumentError) do + assert_raise(Kamal::ConfigurationError) do Kamal::Configuration.new @deploy.tap { _1[:service] = "app.com" } end end @@ -158,39 +160,34 @@ class ConfigurationTest < ActiveSupport::TestCase assert_equal "healthcheck-app", @config.healthcheck_service end - test "valid config" do - assert @config.valid? - assert @config_with_roles.valid? - end - test "hosts required for all roles" do # Empty server list for implied web role - assert_raises(ArgumentError) do + assert_raises(Kamal::ConfigurationError) do Kamal::Configuration.new @deploy.merge(servers: []) end # Empty server list - assert_raises(ArgumentError) do + assert_raises(Kamal::ConfigurationError) do Kamal::Configuration.new @deploy.merge(servers: { "web" => [] }) end # Missing hosts key - assert_raises(ArgumentError) do + assert_raises(Kamal::ConfigurationError) do Kamal::Configuration.new @deploy.merge(servers: { "web" => {} }) end # Empty hosts list - assert_raises(ArgumentError) do + assert_raises(Kamal::ConfigurationError) do Kamal::Configuration.new @deploy.merge(servers: { "web" => { "hosts" => [] } }) end # Nil hosts - assert_raises(ArgumentError) do + assert_raises(Kamal::ConfigurationError) do Kamal::Configuration.new @deploy.merge(servers: { "web" => { "hosts" => nil } }) end # One role with hosts, one without - assert_raises(ArgumentError) do + assert_raises(Kamal::ConfigurationError) do Kamal::Configuration.new @deploy.merge(servers: { "web" => %w[ web ], "workers" => { "hosts" => %w[ ] } }) end end @@ -200,7 +197,7 @@ class ConfigurationTest < ActiveSupport::TestCase Kamal::Configuration.new @deploy.merge(servers: { "web" => %w[ web ], "workers" => { "hosts" => %w[ ] } }, allow_empty_roles: true) end - assert_raises(ArgumentError) do + assert_raises(Kamal::ConfigurationError) do Kamal::Configuration.new @deploy.merge(servers: { "web" => %w[], "workers" => { "hosts" => %w[] } }, allow_empty_roles: true) end end @@ -215,17 +212,17 @@ class ConfigurationTest < ActiveSupport::TestCase test "logging args with configured options" do config = Kamal::Configuration.new(@deploy.tap { |c| c.merge!(logging: { "options" => { "max-size" => "100m", "max-file" => 5 } }) }) - assert_equal [ "--log-opt", "max-size=\"100m\"", "--log-opt", "max-file=\"5\"" ], @config.logging_args + assert_equal [ "--log-opt", "max-size=\"100m\"", "--log-opt", "max-file=\"5\"" ], config.logging_args end test "logging args with configured driver and options" do config = Kamal::Configuration.new(@deploy.tap { |c| c.merge!(logging: { "driver" => "local", "options" => { "max-size" => "100m", "max-file" => 5 } }) }) - assert_equal [ "--log-driver", "\"local\"", "--log-opt", "max-size=\"100m\"", "--log-opt", "max-file=\"5\"" ], @config.logging_args + assert_equal [ "--log-driver", "\"local\"", "--log-opt", "max-size=\"100m\"", "--log-opt", "max-file=\"5\"" ], config.logging_args end test "erb evaluation of yml config" do config = Kamal::Configuration.create_from config_file: Pathname.new(File.expand_path("fixtures/deploy.erb.yml", __dir__)) - assert_equal "my-user", config.registry["username"] + assert_equal "my-user", config.registry.username end test "destination yml config merge" do @@ -249,7 +246,7 @@ class ConfigurationTest < ActiveSupport::TestCase test "destination required" do dest_config_file = Pathname.new(File.expand_path("fixtures/deploy_for_required_dest.yml", __dir__)) - assert_raises(ArgumentError) do + assert_raises(Kamal::ConfigurationError) do config = Kamal::Configuration.create_from config_file: dest_config_file end @@ -272,7 +269,7 @@ class ConfigurationTest < ActiveSupport::TestCase volume_args: [ "--volume", "/local/path:/container/path" ], builder: {}, logging: [ "--log-opt", "max-size=\"10m\"" ], - healthcheck: { "path"=>"/up", "port"=>3000, "max_attempts" => 7, "cord" => "/tmp/kamal-cord", "log_lines" => 50 } } + healthcheck: { "cmd"=>"curl -f http://localhost:3000/up || exit 1", "interval" => "1s", "path"=>"/up", "port"=>3000, "max_attempts" => 7, "cord" => "/tmp/kamal-cord", "log_lines" => 50 } } assert_equal expected_config, @config.to_h end @@ -288,7 +285,7 @@ class ConfigurationTest < ActiveSupport::TestCase end test "min version is higher" do - assert_raises(ArgumentError) do + assert_raises(Kamal::ConfigurationError) do Kamal::Configuration.new(@deploy.tap { |c| c.merge!(minimum_version: "10000.0.0") }) end end @@ -334,7 +331,7 @@ class ConfigurationTest < ActiveSupport::TestCase end test "primary role missing" do - error = assert_raises(ArgumentError) do + error = assert_raises(Kamal::ConfigurationError) do Kamal::Configuration.new(@deploy.merge(primary_role: "bar")) end assert_match /bar isn't defined/, error.message @@ -345,6 +342,6 @@ class ConfigurationTest < ActiveSupport::TestCase config = Kamal::Configuration.new(@deploy_with_roles.merge(retain_containers: 2)) assert_equal 2, config.retain_containers - assert_raises(ArgumentError) { Kamal::Configuration.new(@deploy_with_roles.merge(retain_containers: 0)) } + assert_raises(Kamal::ConfigurationError) { Kamal::Configuration.new(@deploy_with_roles.merge(retain_containers: 0)) } end end diff --git a/test/fixtures/deploy_primary_web_role_override.yml b/test/fixtures/deploy_primary_web_role_override.yml index e372c6027..694a9be7e 100644 --- a/test/fixtures/deploy_primary_web_role_override.yml +++ b/test/fixtures/deploy_primary_web_role_override.yml @@ -2,12 +2,12 @@ service: app image: dhh/app servers: web_chicago: - traefik: enabled + traefik: true hosts: - 1.1.1.1 - 1.1.1.2 web_tokyo: - traefik: enabled + traefik: true hosts: - 1.1.1.3 - 1.1.1.4 diff --git a/test/fixtures/deploy_with_aliases.yml b/test/fixtures/deploy_with_aliases.yml deleted file mode 100644 index 90c18b57c..000000000 --- a/test/fixtures/deploy_with_aliases.yml +++ /dev/null @@ -1,36 +0,0 @@ -# helper aliases -chicago_hosts: &chicago_hosts - hosts: - - 1.1.1.1 - - 1.1.1.2 -tokyo_hosts: &tokyo_hosts - hosts: - - 1.1.1.3 - - 1.1.1.4 -web_common: &web_common - env: - ROLE: "web" - traefik: true - -# actual config -service: app -image: dhh/app -servers: - web: - <<: *chicago_hosts - <<: *web_common - web_tokyo: - <<: *tokyo_hosts - <<: *web_common - workers: - cmd: bin/jobs - <<: *chicago_hosts - workers_tokyo: - cmd: bin/jobs - <<: *tokyo_hosts -env: - REDIS_URL: redis://x/y -registry: - server: registry.digitalocean.com - username: user - password: pw diff --git a/test/fixtures/deploy_with_multiple_traefik_roles.yml b/test/fixtures/deploy_with_multiple_traefik_roles.yml new file mode 100644 index 000000000..a36a409f1 --- /dev/null +++ b/test/fixtures/deploy_with_multiple_traefik_roles.yml @@ -0,0 +1,34 @@ +# actual config +service: app +image: dhh/app +servers: + web: + hosts: + - 1.1.1.1 + - 1.1.1.2 + env: + ROLE: "web" + traefik: true + web_tokyo: + hosts: + - 1.1.1.3 + - 1.1.1.4 + env: + ROLE: "web" + traefik: true + workers: + cmd: bin/jobs + hosts: + - 1.1.1.1 + - 1.1.1.2 + workers_tokyo: + cmd: bin/jobs + hosts: + - 1.1.1.3 + - 1.1.1.4 +env: + REDIS_URL: redis://x/y +registry: + server: registry.digitalocean.com + username: user + password: pw diff --git a/test/integration/integration_test.rb b/test/integration/integration_test.rb index f8f54d424..ea445d9e7 100644 --- a/test/integration/integration_test.rb +++ b/test/integration/integration_test.rb @@ -146,6 +146,6 @@ def assert_container_not_running(host:, name:) end def container_running?(host:, name:) - docker_compose("exec #{host} docker ps --filter=name=#{name} | tail -n+2", capture: true).tap { |x| p [ x, x.strip, x.strip.present? ] }.strip.present? + docker_compose("exec #{host} docker ps --filter=name=#{name} | tail -n+2", capture: true).strip.present? end end diff --git a/test/integration/main_test.rb b/test/integration/main_test.rb index 62857d4ad..c4558c1d3 100644 --- a/test/integration/main_test.rb +++ b/test/integration/main_test.rb @@ -79,7 +79,7 @@ class MainTest < IntegrationTest assert_equal({ user: "root", port: 22, keepalive: true, keepalive_interval: 30, log_level: :fatal }, config[:ssh_options]) assert_equal({ "multiarch" => false, "args" => { "COMMIT_SHA" => version } }, config[:builder]) assert_equal [ "--log-opt", "max-size=\"10m\"" ], config[:logging] - assert_equal({ "path" => "/up", "port" => 3000, "max_attempts" => 3, "cord"=>"/tmp/kamal-cord", "log_lines" => 50, "cmd"=>"wget -qO- http://localhost > /dev/null || exit 1" }, config[:healthcheck]) + assert_equal({ "cmd"=>"wget -qO- http://localhost > /dev/null || exit 1", "interval"=>"1s", "max_attempts"=>3, "port"=>3000, "path"=>"/up", "cord"=>"/tmp/kamal-cord", "log_lines"=>50 }, config[:healthcheck]) end test "setup and remove" do From 29fbe7a98feeb2aa10f0ccedff2d0636489b7b84 Mon Sep 17 00:00:00 2001 From: Donal McBreen Date: Tue, 4 Jun 2024 16:45:39 +0100 Subject: [PATCH 57/71] Remove redundant Kamal::Configuration:: --- lib/kamal/configuration.rb | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/lib/kamal/configuration.rb b/lib/kamal/configuration.rb index a20a572b9..33eedda17 100644 --- a/lib/kamal/configuration.rb +++ b/lib/kamal/configuration.rb @@ -48,19 +48,19 @@ def initialize(raw_config, destination: nil, version: nil, validate: true) validate! raw_config, example: validation_yml.symbolize_keys, context: "" # Eager load config to validate it, these are first as they have dependencies later on - @servers = Kamal::Configuration::Servers.new(config: self) - @registry = Kamal::Configuration::Registry.new(config: self) + @servers = Servers.new(config: self) + @registry = Registry.new(config: self) - @accessories = @raw_config.accessories&.keys&.collect { |name| Kamal::Configuration::Accessory.new(name, config: self) } || [] - @boot = Kamal::Configuration::Boot.new(config: self) - @builder = Kamal::Configuration::Builder.new(config: self) - @env = Kamal::Configuration::Env.new(config: @raw_config.env || {}) + @accessories = @raw_config.accessories&.keys&.collect { |name| Accessory.new(name, config: self) } || [] + @boot = Boot.new(config: self) + @builder = Builder.new(config: self) + @env = Env.new(config: @raw_config.env || {}) - @healthcheck = Kamal::Configuration::Healthcheck.new(healthcheck_config: @raw_config.healthcheck) - @logging = Kamal::Configuration::Logging.new(logging_config: @raw_config.logging) - @traefik = Kamal::Configuration::Traefik.new(config: self) - @ssh = Kamal::Configuration::Ssh.new(config: self) - @sshkit = Kamal::Configuration::Sshkit.new(config: self) + @healthcheck = Healthcheck.new(healthcheck_config: @raw_config.healthcheck) + @logging = Logging.new(logging_config: @raw_config.logging) + @traefik = Traefik.new(config: self) + @ssh = Ssh.new(config: self) + @sshkit = Sshkit.new(config: self) ensure_destination_if_required ensure_required_keys_present @@ -221,7 +221,7 @@ def host_env_directory def env_tags @env_tags ||= if (tags = raw_config.env["tags"]) - tags.collect { |name, config| Kamal::Configuration::Env::Tag.new(name, config: config) } + tags.collect { |name, config| Env::Tag.new(name, config: config) } else [] end From b52e66814a11836191640d5c50e08f7d8cf19241 Mon Sep 17 00:00:00 2001 From: Donal McBreen Date: Wed, 5 Jun 2024 11:52:45 +0100 Subject: [PATCH 58/71] Check that we have valid contexts before building Load the hosts from the contexts before trying to build. If there is no context, we'll create one. If there is one but the hosts don't match we'll re-create. Where we just have a local context, there won't be any hosts but we still inspect the builder to check that it exists. --- lib/kamal/cli/build.rb | 23 +++++----- lib/kamal/commands/builder.rb | 3 +- lib/kamal/commands/builder/base.rb | 13 ++++++ lib/kamal/commands/builder/multiarch.rb | 4 ++ .../commands/builder/multiarch/remote.rb | 10 +++++ lib/kamal/commands/builder/native/cached.rb | 11 ++++- lib/kamal/commands/builder/native/remote.rb | 8 ++++ test/cli/build_test.rb | 13 ++++-- test/cli/cli_test_case.rb | 3 ++ test/cli/main_test.rb | 8 ++++ test/commands/builder_test.rb | 42 +++++++++++++++++++ 11 files changed, 122 insertions(+), 16 deletions(-) diff --git a/lib/kamal/cli/build.rb b/lib/kamal/cli/build.rb index 45b53eb72..54d4d6db6 100644 --- a/lib/kamal/cli/build.rb +++ b/lib/kamal/cli/build.rb @@ -35,22 +35,25 @@ def push run_locally do begin - KAMAL.with_verbosity(:debug) do - Dir.chdir(KAMAL.config.builder.build_directory) { execute *push } + context_hosts = capture_with_info(*KAMAL.builder.context_hosts).split("\n") + + if context_hosts != KAMAL.builder.config_context_hosts + warn "Context hosts have changed, so re-creating builder, was: #{context_hosts.join(", ")}], now: #{KAMAL.builder.config_context_hosts.join(", ")}" + cli.remove + cli.create end rescue SSHKit::Command::Failed => e - if e.message =~ /(no builder)|(no such file or directory)/ - warn "Missing compatible builder, so creating a new one first" - - if cli.create - KAMAL.with_verbosity(:debug) do - Dir.chdir(KAMAL.config.builder.build_directory) { execute *push } - end - end + warn "Missing compatible builder, so creating a new one first" + if e.message =~ /(context not found|no builder)/ + cli.create else raise end end + + KAMAL.with_verbosity(:debug) do + Dir.chdir(KAMAL.config.builder.build_directory) { execute *push } + end end end diff --git a/lib/kamal/commands/builder.rb b/lib/kamal/commands/builder.rb index 2e5862a8a..ad38fb4f6 100644 --- a/lib/kamal/commands/builder.rb +++ b/lib/kamal/commands/builder.rb @@ -1,7 +1,8 @@ require "active_support/core_ext/string/filters" class Kamal::Commands::Builder < Kamal::Commands::Base - delegate :create, :remove, :push, :clean, :pull, :info, :validate_image, to: :target + delegate :create, :remove, :push, :clean, :pull, :info, :context_hosts, :config_context_hosts, :validate_image, + to: :target include Clone diff --git a/lib/kamal/commands/builder/base.rb b/lib/kamal/commands/builder/base.rb index d4d79e162..112b7090d 100644 --- a/lib/kamal/commands/builder/base.rb +++ b/lib/kamal/commands/builder/base.rb @@ -2,6 +2,8 @@ class Kamal::Commands::Builder::Base < Kamal::Commands::Base class BuilderError < StandardError; end + ENDPOINT_DOCKER_HOST_INSPECT = "'{{.Endpoints.docker.Host}}'" + delegate :argumentize, to: Kamal::Utils delegate :args, :secrets, :dockerfile, :target, :local_arch, :local_host, :remote_arch, :remote_host, :cache_from, :cache_to, :ssh, to: :builder_config @@ -30,6 +32,13 @@ def validate_image ) end + def context_hosts + :true + end + + def config_context_hosts + [] + end private def build_tags @@ -74,4 +83,8 @@ def build_ssh def builder_config config.builder end + + def context_host(builder_name) + docker :context, :inspect, builder_name, "--format", ENDPOINT_DOCKER_HOST_INSPECT + end end diff --git a/lib/kamal/commands/builder/multiarch.rb b/lib/kamal/commands/builder/multiarch.rb index d232226c7..ae1d423f5 100644 --- a/lib/kamal/commands/builder/multiarch.rb +++ b/lib/kamal/commands/builder/multiarch.rb @@ -22,6 +22,10 @@ def push build_context end + def context_hosts + docker :buildx, :inspect, builder_name, "> /dev/null" + end + private def builder_name "kamal-#{config.service}-multiarch" diff --git a/lib/kamal/commands/builder/multiarch/remote.rb b/lib/kamal/commands/builder/multiarch/remote.rb index 1f71979d1..d60bee778 100644 --- a/lib/kamal/commands/builder/multiarch/remote.rb +++ b/lib/kamal/commands/builder/multiarch/remote.rb @@ -12,6 +12,16 @@ def remove super end + def context_hosts + chain \ + context_host(builder_name_with_arch(local_arch)), + context_host(builder_name_with_arch(remote_arch)) + end + + def config_context_hosts + [ local_host, remote_host ].compact + end + private def builder_name super + "-remote" diff --git a/lib/kamal/commands/builder/native/cached.rb b/lib/kamal/commands/builder/native/cached.rb index f72d11923..8f65d5f38 100644 --- a/lib/kamal/commands/builder/native/cached.rb +++ b/lib/kamal/commands/builder/native/cached.rb @@ -1,6 +1,6 @@ class Kamal::Commands::Builder::Native::Cached < Kamal::Commands::Builder::Native def create - docker :buildx, :create, "--use", "--driver=docker-container" + docker :buildx, :create, "--name", builder_name, "--use", "--driver=docker-container" end def remove @@ -13,4 +13,13 @@ def push *build_options, build_context end + + def context_hosts + docker :buildx, :inspect, builder_name, "> /dev/null" + end + + private + def builder_name + "kamal-#{config.service}-native-cached" + end end diff --git a/lib/kamal/commands/builder/native/remote.rb b/lib/kamal/commands/builder/native/remote.rb index a14a776a0..9d03b8dbd 100644 --- a/lib/kamal/commands/builder/native/remote.rb +++ b/lib/kamal/commands/builder/native/remote.rb @@ -26,6 +26,14 @@ def push build_context end + def context_hosts + context_host(builder_name_with_arch) + end + + def config_context_hosts + [ remote_host ] + end + private def builder_name diff --git a/test/cli/build_test.rb b/test/cli/build_test.rb index b0bbd83bb..e7c7b333f 100644 --- a/test/cli/build_test.rb +++ b/test/cli/build_test.rb @@ -21,6 +21,10 @@ class CliBuildTest < CliTestCase .with(:git, "-C", anything, :status, "--porcelain") .returns("") + SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info) + .with(:docker, :buildx, :inspect, "kamal-app-multiarch", "> /dev/null") + .returns("") + run_command("push", "--verbose").tap do |output| assert_hook_ran "pre-build", output, **hook_variables assert_match /Cloning repo into build directory/, output @@ -121,11 +125,9 @@ class CliBuildTest < CliTestCase SSHKit::Backend::Abstract.any_instance.expects(:execute) .with(:docker, :buildx, :create, "--use", "--name", "kamal-app-multiarch") - SSHKit::Backend::Abstract.any_instance.expects(:execute) - .with { |*args| args[0..1] == [ :docker, :buildx ] } + SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info) + .with(:docker, :buildx, :inspect, "kamal-app-multiarch", "> /dev/null") .raises(SSHKit::Command::Failed.new("no builder")) - .then - .returns(true) SSHKit::Backend::Abstract.any_instance.expects(:execute).with { |*args| args.first.start_with?("git") } @@ -137,6 +139,9 @@ class CliBuildTest < CliTestCase .with(:git, "-C", anything, :status, "--porcelain") .returns("") + SSHKit::Backend::Abstract.any_instance.expects(:execute) + .with(:docker, :buildx, :build, "--push", "--platform", "linux/amd64,linux/arm64", "--builder", "kamal-app-multiarch", "-t", "dhh/app:999", "-t", "dhh/app:latest", "--label", "service=\"app\"", "--file", "Dockerfile", ".") + run_command("push").tap do |output| assert_match /WARN Missing compatible builder, so creating a new one first/, output end diff --git a/test/cli/cli_test_case.rb b/test/cli/cli_test_case.rb index af1589eee..5db20c051 100644 --- a/test/cli/cli_test_case.rb +++ b/test/cli/cli_test_case.rb @@ -36,6 +36,9 @@ def stub_setup .with { |arg1, arg2| arg1 == :mkdir && arg2 == ".kamal/locks/app" } SSHKit::Backend::Abstract.any_instance.stubs(:execute) .with { |arg1, arg2| arg1 == :rm && arg2 == ".kamal/locks/app/details" } + SSHKit::Backend::Abstract.any_instance.stubs(:capture_with_info) + .with { |*args| args[0..2] == [ :docker, :buildx, :inspect ] } + .returns("") end def assert_hook_ran(hook, output, version:, service_version:, hosts:, command:, subcommand: nil, runtime: false) diff --git a/test/cli/main_test.rb b/test/cli/main_test.rb index 595556fe0..4d74632f7 100644 --- a/test/cli/main_test.rb +++ b/test/cli/main_test.rb @@ -116,6 +116,10 @@ class CliMainTest < CliTestCase .with(:git, "-C", anything, :status, "--porcelain") .returns("") + SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info) + .with(:docker, :buildx, :inspect, "kamal-app-multiarch", "> /dev/null") + .returns("") + assert_raises(Kamal::Cli::LockError) do run_command("deploy") end @@ -145,6 +149,10 @@ class CliMainTest < CliTestCase .with(:git, "-C", anything, :status, "--porcelain") .returns("") + SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info) + .with(:docker, :buildx, :inspect, "kamal-app-multiarch", "> /dev/null") + .returns("") + assert_raises(SSHKit::Runner::ExecuteError) do run_command("deploy") end diff --git a/test/commands/builder_test.rb b/test/commands/builder_test.rb index 064454a2e..6bc9795d8 100644 --- a/test/commands/builder_test.rb +++ b/test/commands/builder_test.rb @@ -158,6 +158,48 @@ class CommandsBuilderTest < ActiveSupport::TestCase builder.push.join(" ") end + test "multiarch context hosts" do + command = new_builder_command + assert_equal "docker buildx inspect kamal-app-multiarch > /dev/null", command.context_hosts.join(" ") + assert_equal "", command.config_context_hosts.join(" ") + end + + test "native context hosts" do + command = new_builder_command(builder: { "multiarch" => false }) + assert_equal :true, command.context_hosts + assert_equal "", command.config_context_hosts.join(" ") + end + + test "native cached context hosts" do + command = new_builder_command(builder: { "multiarch" => false, "cache" => { "type" => "registry" } }) + assert_equal "docker buildx inspect kamal-app-native-cached > /dev/null", command.context_hosts.join(" ") + assert_equal "", command.config_context_hosts.join(" ") + end + + test "native remote context hosts" do + command = new_builder_command(builder: { "remote" => { "arch" => "amd64", "host" => "ssh://host" } }) + assert_equal "docker context inspect kamal-app-native-remote-amd64 --format '{{.Endpoints.docker.Host}}'", command.context_hosts.join(" ") + assert_equal [ "ssh://host" ], command.config_context_hosts + end + + test "multiarch remote context hosts" do + command = new_builder_command(builder: { + "remote" => { "arch" => "amd64", "host" => "ssh://host" }, + "local" => { "arch" => "arm64" } + }) + assert_equal "docker context inspect kamal-app-multiarch-remote-arm64 --format '{{.Endpoints.docker.Host}}' ; docker context inspect kamal-app-multiarch-remote-amd64 --format '{{.Endpoints.docker.Host}}'", command.context_hosts.join(" ") + assert_equal [ "ssh://host" ], command.config_context_hosts + end + + test "multiarch remote context hosts with local host" do + command = new_builder_command(builder: { + "remote" => { "arch" => "amd64", "host" => "ssh://host" }, + "local" => { "arch" => "arm64", "host" => "unix:///var/run/docker.sock" } + }) + assert_equal "docker context inspect kamal-app-multiarch-remote-arm64 --format '{{.Endpoints.docker.Host}}' ; docker context inspect kamal-app-multiarch-remote-amd64 --format '{{.Endpoints.docker.Host}}'", command.context_hosts.join(" ") + assert_equal [ "unix:///var/run/docker.sock", "ssh://host" ], command.config_context_hosts + end + private def new_builder_command(additional_config = {}) Kamal::Commands::Builder.new(Kamal::Configuration.new(@config.merge(additional_config), version: "123")) From 00e0e5073e76a03d3815895b0c082fcf93114389 Mon Sep 17 00:00:00 2001 From: Donal McBreen Date: Wed, 5 Jun 2024 12:10:36 +0100 Subject: [PATCH 59/71] Allow registry commands to skip local and remote - Add local logout to `kamal registry logout` - Add `skip_local` and `skip_remote` options to `kamal registry` commands - Skip local login in `kamal deploy` when `--skip-push` is used --- lib/kamal/cli/main.rb | 4 ++-- lib/kamal/cli/registry.rb | 19 +++++++++---------- test/cli/main_test.rb | 16 ++++++++-------- test/cli/registry_test.rb | 29 +++++++++++++++++++++++++++++ 4 files changed, 48 insertions(+), 20 deletions(-) diff --git a/lib/kamal/cli/main.rb b/lib/kamal/cli/main.rb index c86418482..25272f3ff 100644 --- a/lib/kamal/cli/main.rb +++ b/lib/kamal/cli/main.rb @@ -25,7 +25,7 @@ def deploy invoke_options = deploy_options say "Log into image registry...", :magenta - invoke "kamal:cli:registry:login", [], invoke_options + invoke "kamal:cli:registry:login", [], invoke_options.merge(skip_local: options[:skip_push]) if options[:skip_push] say "Pull app image...", :magenta @@ -197,7 +197,7 @@ def remove invoke "kamal:cli:traefik:remove", [], options.without(:confirmed) invoke "kamal:cli:app:remove", [], options.without(:confirmed) invoke "kamal:cli:accessory:remove", [ "all" ], options - invoke "kamal:cli:registry:logout", [], options.without(:confirmed) + invoke "kamal:cli:registry:logout", [], options.without(:confirmed).merge(skip_local: true) end end end diff --git a/lib/kamal/cli/registry.rb b/lib/kamal/cli/registry.rb index a8b671edc..9d5d9d937 100644 --- a/lib/kamal/cli/registry.rb +++ b/lib/kamal/cli/registry.rb @@ -1,18 +1,17 @@ class Kamal::Cli::Registry < Kamal::Cli::Base desc "login", "Log in to registry locally and remotely" + option :skip_local, aliases: "-L", type: :boolean, default: false, desc: "Skip local login" + option :skip_remote, aliases: "-R", type: :boolean, default: false, desc: "Skip remote login" def login - run_locally { execute *KAMAL.registry.login } - on(KAMAL.hosts) { execute *KAMAL.registry.login } - # FIXME: This rescue needed? - rescue ArgumentError => e - puts e.message + run_locally { execute *KAMAL.registry.login } unless options[:skip_local] + on(KAMAL.hosts) { execute *KAMAL.registry.login } unless options[:skip_remote] end - desc "logout", "Log out of registry remotely" + desc "logout", "Log out of registry locally and remotely" + option :skip_local, aliases: "-L", type: :boolean, default: false, desc: "Skip local login" + option :skip_remote, aliases: "-R", type: :boolean, default: false, desc: "Skip remote login" def logout - on(KAMAL.hosts) { execute *KAMAL.registry.logout } - # FIXME: This rescue needed? - rescue ArgumentError => e - puts e.message + run_locally { execute *KAMAL.registry.logout } unless options[:skip_local] + on(KAMAL.hosts) { execute *KAMAL.registry.logout } unless options[:skip_remote] end end diff --git a/test/cli/main_test.rb b/test/cli/main_test.rb index 595556fe0..19fabc093 100644 --- a/test/cli/main_test.rb +++ b/test/cli/main_test.rb @@ -22,7 +22,7 @@ class CliMainTest < CliTestCase 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) + Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:registry:login", [], invoke_options.merge(skip_local: true)) Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:build:pull", [], invoke_options) Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:traefik:boot", [], invoke_options) Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:app:stale_containers", [], invoke_options.merge(stop: true)) @@ -46,7 +46,7 @@ class CliMainTest < CliTestCase test "deploy" do invoke_options = { "config_file" => "test/fixtures/deploy_simple.yml", "version" => "999", "skip_hooks" => false, "verbose" => true } - Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:registry:login", [], invoke_options) + Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:registry:login", [], invoke_options.merge(skip_local: false)) Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:build:deliver", [], invoke_options) Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:traefik:boot", [], invoke_options) Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:app:stale_containers", [], invoke_options.merge(stop: true)) @@ -71,7 +71,7 @@ class CliMainTest < CliTestCase test "deploy with skip_push" do invoke_options = { "config_file" => "test/fixtures/deploy_simple.yml", "version" => "999", "skip_hooks" => false } - Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:registry:login", [], invoke_options) + Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:registry:login", [], invoke_options.merge(skip_local: true)) Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:build:pull", [], invoke_options) Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:traefik:boot", [], invoke_options) Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:app:stale_containers", [], invoke_options.merge(stop: true)) @@ -151,10 +151,10 @@ class CliMainTest < CliTestCase end test "deploy errors during outside section leave remove lock" do - invoke_options = { "config_file" => "test/fixtures/deploy_simple.yml", "version" => "999", "skip_hooks" => false } + invoke_options = { "config_file" => "test/fixtures/deploy_simple.yml", "version" => "999", "skip_hooks" => false, :skip_local => false } Kamal::Cli::Main.any_instance.expects(:invoke) - .with("kamal:cli:registry:login", [], invoke_options) + .with("kamal:cli:registry:login", [], invoke_options.merge(skip_local: false)) .raises(RuntimeError) assert_not KAMAL.holding_lock? @@ -167,7 +167,7 @@ class CliMainTest < CliTestCase test "deploy with skipped hooks" do invoke_options = { "config_file" => "test/fixtures/deploy_simple.yml", "version" => "999", "skip_hooks" => true } - Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:registry:login", [], invoke_options) + Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:registry:login", [], invoke_options.merge(skip_local: false)) Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:build:deliver", [], invoke_options) Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:traefik:boot", [], invoke_options) Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:app:stale_containers", [], invoke_options.merge(stop: true)) @@ -184,7 +184,7 @@ class CliMainTest < CliTestCase Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:healthcheck:perform", [], invoke_options).never - Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:registry:login", [], invoke_options) + Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:registry:login", [], invoke_options.merge(skip_local: false)) Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:build:deliver", [], invoke_options) Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:traefik:boot", [], invoke_options) Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:app:stale_containers", [], invoke_options.merge(stop: true)) @@ -197,7 +197,7 @@ class CliMainTest < CliTestCase test "deploy with missing secrets" do invoke_options = { "config_file" => "test/fixtures/deploy_with_secrets.yml", "version" => "999", "skip_hooks" => false } - Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:registry:login", [], invoke_options) + Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:registry:login", [], invoke_options.merge(skip_local: false)) Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:build:deliver", [], invoke_options) Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:traefik:boot", [], invoke_options) Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:app:stale_containers", [], invoke_options.merge(stop: true)) diff --git a/test/cli/registry_test.rb b/test/cli/registry_test.rb index 64f7a7a9a..c5423fe72 100644 --- a/test/cli/registry_test.rb +++ b/test/cli/registry_test.rb @@ -8,12 +8,41 @@ class CliRegistryTest < CliTestCase end end + test "login skip local" do + run_command("login", "-L").tap do |output| + assert_no_match /docker login -u \[REDACTED\] -p \[REDACTED\] as .*@localhost/, output + assert_match /docker login -u \[REDACTED\] -p \[REDACTED\] on 1.1.1.\d/, output + end + end + + test "login skip remote" do + run_command("login", "-R").tap do |output| + assert_match /docker login -u \[REDACTED\] -p \[REDACTED\] as .*@localhost/, output + assert_no_match /docker login -u \[REDACTED\] -p \[REDACTED\] on 1.1.1.\d/, output + end + end + test "logout" do run_command("logout").tap do |output| + assert_match /docker logout as .*@localhost/, output assert_match /docker logout on 1.1.1.\d/, output end end + test "logout skip local" do + run_command("logout", "-L").tap do |output| + assert_no_match /docker logout as .*@localhost/, output + assert_match /docker logout on 1.1.1.\d/, output + end + end + + test "logout skip remote" do + run_command("logout", "-R").tap do |output| + assert_match /docker logout as .*@localhost/, output + assert_no_match /docker logout on 1.1.1.\d/, output + end + end + private def run_command(*command) stdouted { Kamal::Cli::Registry.start([ *command, "-c", "test/fixtures/deploy_with_accessories.yml" ]) } From c7bd377fa58238851833e9ef6e2fab35f6e2ebe7 Mon Sep 17 00:00:00 2001 From: Nick Hammond Date: Thu, 6 Jun 2024 09:26:12 -0700 Subject: [PATCH 60/71] Swap grep context with grep options --- lib/kamal/cli/accessory.rb | 10 +++++----- lib/kamal/cli/app.rb | 10 +++++----- lib/kamal/cli/traefik.rb | 10 +++++----- lib/kamal/commands/accessory.rb | 8 ++++---- lib/kamal/commands/app/logging.rb | 8 ++++---- lib/kamal/commands/traefik.rb | 8 ++++---- test/cli/accessory_test.rb | 8 ++++---- test/cli/app_test.rb | 6 +++--- test/cli/traefik_test.rb | 4 ++-- test/commands/accessory_test.rb | 2 +- test/commands/app_test.rb | 8 ++++---- test/commands/traefik_test.rb | 4 ++-- 12 files changed, 43 insertions(+), 43 deletions(-) diff --git a/lib/kamal/cli/accessory.rb b/lib/kamal/cli/accessory.rb index 928af9209..b3ff10f57 100644 --- a/lib/kamal/cli/accessory.rb +++ b/lib/kamal/cli/accessory.rb @@ -149,25 +149,25 @@ def exec(name, cmd) option :since, aliases: "-s", desc: "Show logs since timestamp (e.g. 2013-01-02T13:23:37Z) or relative (e.g. 42m for 42 minutes)" option :lines, type: :numeric, aliases: "-n", desc: "Number of log lines to pull from each server" option :grep, aliases: "-g", desc: "Show lines with grep match only (use this to fetch specific requests by id)" + option :grep_options, aliases: "-o", desc: "Additional options supplied to grep" option :follow, aliases: "-f", desc: "Follow logs on primary server (or specific host set by --hosts)" - option :context, aliases: "-C", desc: "Show number of lines leading and trailing a grep match (use with --grep)" def logs(name) with_accessory(name) do |accessory, hosts| grep = options[:grep] - context = options[:context] + grep_options = options[:grep_options] if options[:follow] run_locally do info "Following logs on #{hosts}..." - info accessory.follow_logs(grep: grep, context: context) - exec accessory.follow_logs(grep: grep, context: context) + info accessory.follow_logs(grep: grep, grep_options: grep_options) + exec accessory.follow_logs(grep: grep, grep_options: grep_options) end else since = options[:since] lines = options[:lines].presence || ((since || grep) ? nil : 100) # Default to 100 lines if since or grep isn't set on(hosts) do - puts capture_with_info(*accessory.logs(since: since, lines: lines, grep: grep, context: context)) + puts capture_with_info(*accessory.logs(since: since, lines: lines, grep: grep, grep_options: grep_options)) end end end diff --git a/lib/kamal/cli/app.rb b/lib/kamal/cli/app.rb index 685b9944a..149c71c22 100644 --- a/lib/kamal/cli/app.rb +++ b/lib/kamal/cli/app.rb @@ -166,13 +166,13 @@ def images option :since, aliases: "-s", desc: "Show lines since timestamp (e.g. 2013-01-02T13:23:37Z) or relative (e.g. 42m for 42 minutes)" option :lines, type: :numeric, aliases: "-n", desc: "Number of lines to show from each server" option :grep, aliases: "-g", desc: "Show lines with grep match only (use this to fetch specific requests by id)" + option :grep_options, aliases: "-o", desc: "Additional options supplied to grep" option :follow, aliases: "-f", desc: "Follow log on primary server (or specific host set by --hosts)" - option :context, aliases: "-C", desc: "Show number of lines leading and trailing a grep match (use with --grep)" def logs # FIXME: Catch when app containers aren't running grep = options[:grep] - context = options[:context] + grep_options = options[:grep_options] since = options[:since] if options[:follow] @@ -185,8 +185,8 @@ def logs role = KAMAL.roles_on(KAMAL.primary_host).first app = KAMAL.app(role: role, host: host) - info app.follow_logs(host: KAMAL.primary_host, lines: lines, grep: grep, context: context) - exec app.follow_logs(host: KAMAL.primary_host, lines: lines, grep: grep, context: context) + info app.follow_logs(host: KAMAL.primary_host, lines: lines, grep: grep, grep_options: grep_options) + exec app.follow_logs(host: KAMAL.primary_host, lines: lines, grep: grep, grep_options: grep_options) end else lines = options[:lines].presence || ((since || grep) ? nil : 100) # Default to 100 lines if since or grep isn't set @@ -196,7 +196,7 @@ def logs roles.each do |role| begin - puts_by_host host, capture_with_info(*KAMAL.app(role: role, host: host).logs(since: since, lines: lines, grep: grep, context: context)) + puts_by_host host, capture_with_info(*KAMAL.app(role: role, host: host).logs(since: since, lines: lines, grep: grep, grep_options: grep_options)) rescue SSHKit::Command::Failed puts_by_host host, "Nothing found" end diff --git a/lib/kamal/cli/traefik.rb b/lib/kamal/cli/traefik.rb index 0cd4023f3..a8bd21269 100644 --- a/lib/kamal/cli/traefik.rb +++ b/lib/kamal/cli/traefik.rb @@ -69,24 +69,24 @@ def details option :since, aliases: "-s", desc: "Show logs since timestamp (e.g. 2013-01-02T13:23:37Z) or relative (e.g. 42m for 42 minutes)" option :lines, type: :numeric, aliases: "-n", desc: "Number of log lines to pull from each server" option :grep, aliases: "-g", desc: "Show lines with grep match only (use this to fetch specific requests by id)" + option :grep_options, aliases: "-o", desc: "Additional options supplied to grep" option :follow, aliases: "-f", desc: "Follow logs on primary server (or specific host set by --hosts)" - option :context, aliases: "-C", desc: "Show number of lines leading and trailing a grep match (use with --grep)" def logs grep = options[:grep] - context = options[:context] + grep_options = options[:grep_options] if options[:follow] run_locally do info "Following logs on #{KAMAL.primary_host}..." - info KAMAL.traefik.follow_logs(host: KAMAL.primary_host, grep: grep, context: context) - exec KAMAL.traefik.follow_logs(host: KAMAL.primary_host, grep: grep, context: context) + info KAMAL.traefik.follow_logs(host: KAMAL.primary_host, grep: grep, grep_options: grep_options) + exec KAMAL.traefik.follow_logs(host: KAMAL.primary_host, grep: grep, grep_options: grep_options) end else since = options[:since] lines = options[:lines].presence || ((since || grep) ? nil : 100) # Default to 100 lines if since or grep isn't set on(KAMAL.traefik_hosts) do |host| - puts_by_host host, capture(*KAMAL.traefik.logs(since: since, lines: lines, grep: grep, context: context)), type: "Traefik" + puts_by_host host, capture(*KAMAL.traefik.logs(since: since, lines: lines, grep: grep, grep_options: grep_options)), type: "Traefik" end end end diff --git a/lib/kamal/commands/accessory.rb b/lib/kamal/commands/accessory.rb index b475be6c5..23377ab51 100644 --- a/lib/kamal/commands/accessory.rb +++ b/lib/kamal/commands/accessory.rb @@ -36,17 +36,17 @@ def info end - def logs(since: nil, lines: nil, grep: nil, context: nil) + def logs(since: nil, lines: nil, grep: nil, grep_options: nil) pipe \ docker(:logs, service_name, (" --since #{since}" if since), (" --tail #{lines}" if lines), "--timestamps", "2>&1"), - ("grep '#{grep}'#{" -C #{context}" if context}" if grep) + ("grep '#{grep}'#{" #{grep_options}" if grep_options}" if grep) end - def follow_logs(grep: nil, context: nil) + def follow_logs(grep: nil, grep_options: nil) run_over_ssh \ pipe \ docker(:logs, service_name, "--timestamps", "--tail", "10", "--follow", "2>&1"), - (%(grep "#{grep}"#{" -C #{context}" if context}) if grep) + (%(grep "#{grep}"#{" #{grep_options}" if grep_options}) if grep) end diff --git a/lib/kamal/commands/app/logging.rb b/lib/kamal/commands/app/logging.rb index becc88c67..be8a4bad7 100644 --- a/lib/kamal/commands/app/logging.rb +++ b/lib/kamal/commands/app/logging.rb @@ -1,17 +1,17 @@ module Kamal::Commands::App::Logging - def logs(version: nil, since: nil, lines: nil, grep: nil, context: nil) + def logs(version: nil, since: nil, lines: nil, grep: nil, grep_options: nil) pipe \ version ? container_id_for_version(version) : current_running_container_id, "xargs docker logs#{" --since #{since}" if since}#{" --tail #{lines}" if lines} 2>&1", - ("grep '#{grep}'#{" -C #{context}" if context}" if grep) + ("grep '#{grep}'#{" #{grep_options}" if grep_options}" if grep) end - def follow_logs(host:, lines: nil, grep: nil, context: nil) + def follow_logs(host:, lines: nil, grep: nil, grep_options: nil) run_over_ssh \ pipe( current_running_container_id, "xargs docker logs --timestamps#{" --tail #{lines}" if lines} --follow 2>&1", - (%(grep "#{grep}"#{" -C #{context}" if context}) if grep) + (%(grep "#{grep}"#{" #{grep_options}" if grep_options}) if grep) ), host: host end diff --git a/lib/kamal/commands/traefik.rb b/lib/kamal/commands/traefik.rb index 3b0322ba7..6fa9209a2 100644 --- a/lib/kamal/commands/traefik.rb +++ b/lib/kamal/commands/traefik.rb @@ -46,16 +46,16 @@ def info docker :ps, "--filter", "name=^traefik$" end - def logs(since: nil, lines: nil, grep: nil, context: nil) + def logs(since: nil, lines: nil, grep: nil, grep_options: nil) pipe \ docker(:logs, "traefik", (" --since #{since}" if since), (" --tail #{lines}" if lines), "--timestamps", "2>&1"), - ("grep '#{grep}'#{" -C #{context}" if context}" if grep) + ("grep '#{grep}'#{" #{grep_options}" if grep_options}" if grep) end - def follow_logs(host:, grep: nil, context: nil) + def follow_logs(host:, grep: nil, grep_options: nil) run_over_ssh pipe( docker(:logs, "traefik", "--timestamps", "--tail", "10", "--follow", "2>&1"), - (%(grep "#{grep}"#{" -C #{context}" if context}) if grep) + (%(grep "#{grep}"#{" #{grep_options}" if grep_options}) if grep) ).join(" "), host: host end diff --git a/test/cli/accessory_test.rb b/test/cli/accessory_test.rb index 0ca8e42dd..e56eef2d9 100644 --- a/test/cli/accessory_test.rb +++ b/test/cli/accessory_test.rb @@ -123,11 +123,11 @@ class CliAccessoryTest < CliTestCase assert_match "docker logs app-mysql --timestamps 2>&1 | grep 'hey'", run_command("logs", "mysql", "--grep", "hey") end - test "logs with grep and context" do + test "logs with grep and grep options" do SSHKit::Backend::Abstract.any_instance.stubs(:exec) .with("ssh -t root@1.1.1.3 'docker logs app-mysql --timestamps 2>&1 | grep \'hey\' -C 2'") - assert_match "docker logs app-mysql --timestamps 2>&1 | grep 'hey' -C 2", run_command("logs", "mysql", "--grep", "hey", "--context", "2") + assert_match "docker logs app-mysql --timestamps 2>&1 | grep 'hey' -C 2", run_command("logs", "mysql", "--grep", "hey", "--grep-options", "-C 2") end test "logs with follow" do @@ -144,11 +144,11 @@ class CliAccessoryTest < CliTestCase assert_match "docker logs app-mysql --timestamps --tail 10 --follow 2>&1 | grep \"hey\"", run_command("logs", "mysql", "--follow", "--grep", "hey") end - test "logs with follow, grep, and context" do + test "logs with follow, grep, and grep options" do SSHKit::Backend::Abstract.any_instance.stubs(:exec) .with("ssh -t root@1.1.1.3 -p 22 'docker logs app-mysql --timestamps --tail 10 --follow 2>&1 | grep \"hey\" -C 2'") - assert_match "docker logs app-mysql --timestamps --tail 10 --follow 2>&1 | grep \"hey\" -C 2", run_command("logs", "mysql", "--follow", "--grep", "hey", "--context", "2") + assert_match "docker logs app-mysql --timestamps --tail 10 --follow 2>&1 | grep \"hey\" -C 2", run_command("logs", "mysql", "--follow", "--grep", "hey", "--grep-options", "-C 2") end test "remove with confirmation" do diff --git a/test/cli/app_test.rb b/test/cli/app_test.rb index 044eadc3a..27e3ed9dc 100644 --- a/test/cli/app_test.rb +++ b/test/cli/app_test.rb @@ -293,7 +293,7 @@ class CliAppTest < CliTestCase assert_match "sh -c 'docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''{{.ID}}'\\'') ; docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting' | head -1 | xargs docker logs 2>&1 | grep 'hey'", run_command("logs", "--grep", "hey") - assert_match "sh -c 'docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''{{.ID}}'\\'') ; docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting' | head -1 | xargs docker logs 2>&1 | grep 'hey' -C 2", run_command("logs", "--grep", "hey", "--context", "2") + assert_match "sh -c 'docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''{{.ID}}'\\'') ; docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting' | head -1 | xargs docker logs 2>&1 | grep 'hey' -C 2", run_command("logs", "--grep", "hey", "--grep-options", "-C 2") end test "logs with follow" do @@ -310,11 +310,11 @@ class CliAppTest < CliTestCase assert_match "sh -c '\\''docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''\\'\\'''\\''{{.ID}}'\\''\\'\\'''\\'') ; docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting'\\'' | head -1 | xargs docker logs --timestamps --follow 2>&1 | grep \"hey\"", run_command("logs", "--follow", "--grep", "hey") end - test "logs with follow, grep and context" do + test "logs with follow, grep and grep options" do SSHKit::Backend::Abstract.any_instance.stubs(:exec) .with("ssh -t root@1.1.1.1 -p 22 'sh -c '\\''docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''\\'\\'''\\''{{.ID}}'\\''\\'\\'''\\'') ; docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting'\\'' | head -1 | xargs docker logs --timestamps --follow 2>&1 | grep \"hey\" -C 2'") - assert_match "sh -c '\\''docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''\\'\\'''\\''{{.ID}}'\\''\\'\\'''\\'') ; docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting'\\'' | head -1 | xargs docker logs --timestamps --follow 2>&1 | grep \"hey\" -C 2", run_command("logs", "--follow", "--grep", "hey", "--context", "2") + assert_match "sh -c '\\''docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''\\'\\'''\\''{{.ID}}'\\''\\'\\'''\\'') ; docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting'\\'' | head -1 | xargs docker logs --timestamps --follow 2>&1 | grep \"hey\" -C 2", run_command("logs", "--follow", "--grep", "hey", "--grep-options", "-C 2") end test "version" do diff --git a/test/cli/traefik_test.rb b/test/cli/traefik_test.rb index 73e7ad5d8..69f283d4c 100644 --- a/test/cli/traefik_test.rb +++ b/test/cli/traefik_test.rb @@ -76,11 +76,11 @@ class CliTraefikTest < CliTestCase assert_match "docker logs traefik --timestamps --tail 10 --follow 2>&1 | grep \"hey\"", run_command("logs", "--follow", "--grep", "hey") end - test "logs with follow, grep, and context" do + test "logs with follow, grep, and grep options" do SSHKit::Backend::Abstract.any_instance.stubs(:exec) .with("ssh -t root@1.1.1.1 -p 22 'docker logs traefik --timestamps --tail 10 --follow 2>&1 | grep \"hey\" -C 2'") - assert_match "docker logs traefik --timestamps --tail 10 --follow 2>&1 | grep \"hey\" -C 2", run_command("logs", "--follow", "--grep", "hey", "--context", "2") + assert_match "docker logs traefik --timestamps --tail 10 --follow 2>&1 | grep \"hey\" -C 2", run_command("logs", "--follow", "--grep", "hey", "--grep-options", "-C 2") end test "remove" do diff --git a/test/commands/accessory_test.rb b/test/commands/accessory_test.rb index c8b33fa37..3eb80fc9a 100644 --- a/test/commands/accessory_test.rb +++ b/test/commands/accessory_test.rb @@ -128,7 +128,7 @@ class CommandsAccessoryTest < ActiveSupport::TestCase assert_equal \ "docker logs app-mysql --since 5m --tail 100 --timestamps 2>&1 | grep 'thing' -C 2", - new_command(:mysql).logs(since: "5m", lines: 100, grep: "thing", context: 2).join(" ") + new_command(:mysql).logs(since: "5m", lines: 100, grep: "thing", grep_options: "-C 2").join(" ") end test "follow logs" do diff --git a/test/commands/app_test.rb b/test/commands/app_test.rb index 226dd1666..502501cd7 100644 --- a/test/commands/app_test.rb +++ b/test/commands/app_test.rb @@ -165,16 +165,16 @@ class CommandsAppTest < ActiveSupport::TestCase new_command.logs(grep: "my-id").join(" ") end - test "logs with grep and context" do + test "logs with grep and grep options" do assert_equal \ "sh -c 'docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''{{.ID}}'\\'') ; docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting' | head -1 | xargs docker logs 2>&1 | grep 'my-id' -C 2", - new_command.logs(grep: "my-id", context: 2).join(" ") + new_command.logs(grep: "my-id", grep_options: "-C 2").join(" ") end - test "logs with since, grep and context" do + test "logs with since, grep and grep options" do assert_equal \ "sh -c 'docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''{{.ID}}'\\'') ; docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting' | head -1 | xargs docker logs --since 5m 2>&1 | grep 'my-id' -C 2", - new_command.logs(since: "5m", grep: "my-id", context: 2).join(" ") + new_command.logs(since: "5m", grep: "my-id", grep_options: "-C 2").join(" ") end test "logs with since and grep" do diff --git a/test/commands/traefik_test.rb b/test/commands/traefik_test.rb index ececa5fec..74085967e 100644 --- a/test/commands/traefik_test.rb +++ b/test/commands/traefik_test.rb @@ -153,10 +153,10 @@ class CommandsTraefikTest < ActiveSupport::TestCase new_command.logs(grep: "hello!").join(" ") end - test "traefik logs with grep hello! and context" do + test "traefik logs with grep hello! and grep options" do assert_equal \ "docker logs traefik --timestamps 2>&1 | grep 'hello!' -C 2", - new_command.logs(grep: "hello!", context: 2).join(" ") + new_command.logs(grep: "hello!", grep_options: "-C 2").join(" ") end test "traefik remove container" do From 6d6670a221bee94df7938452dcc83839ee4b21db Mon Sep 17 00:00:00 2001 From: Gaspard d'Hautefeuille Date: Sat, 15 Jun 2024 13:42:30 +0200 Subject: [PATCH 61/71] Add x25519 gem, support Curve25519 Fixes: ``` ERROR (Net::SSH::Exception): Exception while executing on host example.com: could not settle on kex algorithm Server kex preferences: curve25519-sha256@libssh.org,ext-info-s,kex-strict-s-v00@openssh.com Client kex preferences: ecdh-sha2-nistp521,ecdh-sha2-nistp384,ecdh-sha2-nistp256,diffie-hellman-group-exchange-sha256,diffie-hellman-group14-sha256,diffie-hellman-group14-sha1``` add x25519 in Gemfile.lock --- Gemfile.lock | 2 ++ kamal.gemspec | 1 + 2 files changed, 3 insertions(+) diff --git a/Gemfile.lock b/Gemfile.lock index b275d9e0b..91187753f 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -11,6 +11,7 @@ PATH net-ssh (~> 7.0) sshkit (>= 1.22.2, < 2.0) thor (~> 1.2) + x25519 (~> 1.0, >= 1.0.10) zeitwerk (~> 2.5) GEM @@ -165,6 +166,7 @@ GEM concurrent-ruby (~> 1.0) unicode-display_width (2.5.0) webrick (1.8.1) + x25519 (1.0.10) zeitwerk (2.6.12) PLATFORMS diff --git a/kamal.gemspec b/kamal.gemspec index 4278ebea8..5595d3d5e 100644 --- a/kamal.gemspec +++ b/kamal.gemspec @@ -18,6 +18,7 @@ Gem::Specification.new do |spec| spec.add_dependency "dotenv", "~> 2.8" spec.add_dependency "zeitwerk", "~> 2.5" spec.add_dependency "ed25519", "~> 1.2" + spec.add_dependency "x25519", "~> 1.0", ">= 1.0.10" spec.add_dependency "bcrypt_pbkdf", "~> 1.0" spec.add_dependency "concurrent-ruby", "~> 1.2" spec.add_dependency "base64", "~> 0.2" From 6bf3f4888a71479e633a240eebdd48bf2f8eeb84 Mon Sep 17 00:00:00 2001 From: Donal McBreen Date: Tue, 18 Jun 2024 08:20:27 +0100 Subject: [PATCH 62/71] Allow aliases still --- lib/kamal/configuration.rb | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/lib/kamal/configuration.rb b/lib/kamal/configuration.rb index 33eedda17..5f1743bdd 100644 --- a/lib/kamal/configuration.rb +++ b/lib/kamal/configuration.rb @@ -29,7 +29,9 @@ def load_config_files(*files) def load_config_file(file) if file.exist? - YAML.load(ERB.new(IO.read(file)).result).symbolize_keys + # Newer Psych doesn't load aliases by default + load_method = YAML.respond_to?(:unsafe_load) ? :unsafe_load : :load + YAML.send(load_method, ERB.new(IO.read(file)).result).symbolize_keys else raise "Configuration file not found in #{file}" end From 1109a864d023bd541a3591854ba5364a87b55025 Mon Sep 17 00:00:00 2001 From: Donal McBreen Date: Tue, 18 Jun 2024 10:33:02 +0100 Subject: [PATCH 63/71] Bump version for 1.7.0 --- Gemfile.lock | 2 +- lib/kamal/version.rb | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index 91187753f..2e5456e52 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,7 +1,7 @@ PATH remote: . specs: - kamal (1.6.0) + kamal (1.7.0) activesupport (>= 7.0) base64 (~> 0.2) bcrypt_pbkdf (~> 1.0) diff --git a/lib/kamal/version.rb b/lib/kamal/version.rb index 39d3008c9..e4e9f71d6 100644 --- a/lib/kamal/version.rb +++ b/lib/kamal/version.rb @@ -1,3 +1,3 @@ module Kamal - VERSION = "1.6.0" + VERSION = "1.7.0" end From 3da7fad9ee0d0c0c8c749760bbefab490a9c911e Mon Sep 17 00:00:00 2001 From: Donal McBreen Date: Thu, 20 Jun 2024 08:11:18 +0100 Subject: [PATCH 64/71] Revert "Envify already env pushes" --- lib/kamal/cli/main.rb | 1 + test/cli/main_test.rb | 2 ++ 2 files changed, 3 insertions(+) diff --git a/lib/kamal/cli/main.rb b/lib/kamal/cli/main.rb index 735c55f15..7f35da952 100644 --- a/lib/kamal/cli/main.rb +++ b/lib/kamal/cli/main.rb @@ -11,6 +11,7 @@ def setup 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 deploy diff --git a/test/cli/main_test.rb b/test/cli/main_test.rb index 94748fe8a..4db97aaff 100644 --- a/test/cli/main_test.rb +++ b/test/cli/main_test.rb @@ -6,6 +6,7 @@ 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: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) @@ -19,6 +20,7 @@ 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: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 From f8f88af534e416006d23a378e90dba7f5bebcae8 Mon Sep 17 00:00:00 2001 From: Donal McBreen Date: Thu, 20 Jun 2024 08:28:37 +0100 Subject: [PATCH 65/71] Log on boot errors with one role We didn't log boot errors if there was one role because there was no barrier and the logging is done by the first host to close the barrier. Let's always create the barrier to fix this. --- lib/kamal/cli/app.rb | 2 +- lib/kamal/cli/app/boot.rb | 4 ++-- test/integration/broken_deploy_test.rb | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/kamal/cli/app.rb b/lib/kamal/cli/app.rb index 149c71c22..19bf84cd9 100644 --- a/lib/kamal/cli/app.rb +++ b/lib/kamal/cli/app.rb @@ -14,7 +14,7 @@ def boot end # Primary hosts and roles are returned first, so they can open the barrier - barrier = Kamal::Cli::Healthcheck::Barrier.new if KAMAL.roles.many? + barrier = Kamal::Cli::Healthcheck::Barrier.new on(KAMAL.hosts, **KAMAL.boot_strategy) do |host| KAMAL.roles_on(host).each do |role| diff --git a/lib/kamal/cli/app/boot.rb b/lib/kamal/cli/app/boot.rb index c41257719..b78763cee 100644 --- a/lib/kamal/cli/app/boot.rb +++ b/lib/kamal/cli/app/boot.rb @@ -72,7 +72,7 @@ def stop_old_version(version) def release_barrier if barrier.open - info "First #{KAMAL.primary_role} container is healthy on #{host}, booting other roles" + info "First #{KAMAL.primary_role} container is healthy on #{host}, booting any other roles" end end @@ -87,7 +87,7 @@ def wait_at_barrier def close_barrier if barrier.close - info "First #{KAMAL.primary_role} container is unhealthy on #{host}, not booting other roles" + info "First #{KAMAL.primary_role} container is unhealthy on #{host}, not booting any other roles" error capture_with_info(*app.logs(version: version)) error capture_with_info(*app.container_health_log(version: version)) end diff --git a/test/integration/broken_deploy_test.rb b/test/integration/broken_deploy_test.rb index a3f74d45f..5ab24f554 100644 --- a/test/integration/broken_deploy_test.rb +++ b/test/integration/broken_deploy_test.rb @@ -26,7 +26,7 @@ class BrokenDeployTest < IntegrationTest private def assert_failed_deploy(output) assert_match "Waiting for the first healthy web container before booting workers on vm3...", output - assert_match /First web container is unhealthy on vm[12], not booting other roles/, output + assert_match /First web container is unhealthy on vm[12], not booting any other roles/, output assert_match "First web container is unhealthy, not booting workers on vm3", output assert_match "nginx: [emerg] unexpected end of file, expecting \";\" or \"}\" in /etc/nginx/conf.d/default.conf:2", output assert_match 'ERROR {"Status":"unhealthy","FailingStreak":0,"Log":[]}', output From 4697f894411af5f6e245c15c84b5073bc48edd04 Mon Sep 17 00:00:00 2001 From: Donal McBreen Date: Thu, 20 Jun 2024 08:50:37 +0100 Subject: [PATCH 66/71] Bump version for 1.7.1 --- Gemfile.lock | 2 +- lib/kamal/version.rb | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index 2e5456e52..68a8882d5 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,7 +1,7 @@ PATH remote: . specs: - kamal (1.7.0) + kamal (1.7.1) activesupport (>= 7.0) base64 (~> 0.2) bcrypt_pbkdf (~> 1.0) diff --git a/lib/kamal/version.rb b/lib/kamal/version.rb index e4e9f71d6..7d99db5ee 100644 --- a/lib/kamal/version.rb +++ b/lib/kamal/version.rb @@ -1,3 +1,3 @@ module Kamal - VERSION = "1.7.0" + VERSION = "1.7.1" end From 69fa7286e2b8ea552c76d39b4de945ab83fc1711 Mon Sep 17 00:00:00 2001 From: Donal McBreen Date: Mon, 24 Jun 2024 08:21:03 +0100 Subject: [PATCH 67/71] Match a "does not exist" error message Only show the warning for building when we are actually going to do that and match `does not exist` in the error message. Fixes: https://github.com/basecamp/kamal/issues/851 --- lib/kamal/cli/build.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/kamal/cli/build.rb b/lib/kamal/cli/build.rb index 54d4d6db6..3508079fa 100644 --- a/lib/kamal/cli/build.rb +++ b/lib/kamal/cli/build.rb @@ -43,8 +43,8 @@ def push cli.create end rescue SSHKit::Command::Failed => e - warn "Missing compatible builder, so creating a new one first" - if e.message =~ /(context not found|no builder)/ + if e.message =~ /(context not found|no builder|does not exist)/ + warn "Missing compatible builder, so creating a new one first" cli.create else raise From ff03891d47dd0918d2e6b13999cd4d7a4e86ca6f Mon Sep 17 00:00:00 2001 From: Donal McBreen Date: Mon, 24 Jun 2024 10:11:27 +0100 Subject: [PATCH 68/71] Bump version for 1.7.2 --- Gemfile.lock | 2 +- lib/kamal/version.rb | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index 68a8882d5..83e9427e1 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,7 +1,7 @@ PATH remote: . specs: - kamal (1.7.1) + kamal (1.7.2) activesupport (>= 7.0) base64 (~> 0.2) bcrypt_pbkdf (~> 1.0) diff --git a/lib/kamal/version.rb b/lib/kamal/version.rb index 7d99db5ee..e1a665922 100644 --- a/lib/kamal/version.rb +++ b/lib/kamal/version.rb @@ -1,3 +1,3 @@ module Kamal - VERSION = "1.7.1" + VERSION = "1.7.2" end From 9e12d32cc3a700ec31bbc96fdfc085637ccb6397 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cezary=20K=C5=82os?= Date: Mon, 24 Jun 2024 12:45:56 +0200 Subject: [PATCH 69/71] Expand on docker-setup.sample script so it creates docker network "kamal" on each of the defined hosts. --- .../cli/templates/sample_hooks/docker-setup.sample | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/lib/kamal/cli/templates/sample_hooks/docker-setup.sample b/lib/kamal/cli/templates/sample_hooks/docker-setup.sample index ce263fffe..d914913d5 100755 --- a/lib/kamal/cli/templates/sample_hooks/docker-setup.sample +++ b/lib/kamal/cli/templates/sample_hooks/docker-setup.sample @@ -1,7 +1,13 @@ -#!/bin/sh +#!/usr/bin/env ruby # A sample docker-setup hook # -# Sets up a Docker network which can then be used by the application’s containers +# Sets up a Docker network on defined hosts which can then be used by the application’s containers -ssh user@example.com docker network create kamal +hosts = ENV["KAMAL_HOSTS"].split(",") + +hosts.each do |ip| + destination = "root@#{ip}" + puts "Creating a Docker network \"kamal\" on #{destination}" + `ssh #{destination} docker network create kamal` +end From b63982c3a77aa10cd622606f47a81ff92fdc483f Mon Sep 17 00:00:00 2001 From: Donal McBreen Date: Tue, 25 Jun 2024 14:11:08 +0100 Subject: [PATCH 70/71] Allow arrays in args Just check that args is a Hash without checking the value types. Fixes: https://github.com/basecamp/kamal/issues/863 --- lib/kamal/configuration/validator.rb | 4 ++-- test/commands/traefik_test.rb | 5 +++++ test/configuration/validation_test.rb | 2 +- 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/lib/kamal/configuration/validator.rb b/lib/kamal/configuration/validator.rb index 7b1665b62..bc443229c 100644 --- a/lib/kamal/configuration/validator.rb +++ b/lib/kamal/configuration/validator.rb @@ -31,9 +31,9 @@ def validate_against_example!(validation_config, example) validate_array_of! value, example_value.first.class elsif example_value.is_a?(Hash) case key.to_s - when "options" + when "options", "args" validate_type! value, Hash - when "args", "labels" + when "labels" validate_hash_of! value, example_value.first[1].class else validate_against_example! value, example_value diff --git a/test/commands/traefik_test.rb b/test/commands/traefik_test.rb index 27a3cb863..a46fc2845 100644 --- a/test/commands/traefik_test.rb +++ b/test/commands/traefik_test.rb @@ -111,6 +111,11 @@ class CommandsTraefikTest < ActiveSupport::TestCase new_command.run.join(" ") end + test "run with args array" do + @config[:traefik]["args"] = { "entrypoints.web.forwardedheaders.trustedips" => %w[ 127.0.0.1 127.0.0.2 ] } + assert_equal "docker run --name traefik --detach --restart unless-stopped --publish 80:80 --volume /var/run/docker.sock:/var/run/docker.sock --env-file .kamal/env/traefik/traefik.env --log-opt max-size=\"10m\" --label traefik.http.routers.catchall.entryPoints=\"http\" --label traefik.http.routers.catchall.rule=\"PathPrefix(\\`/\\`)\" --label traefik.http.routers.catchall.service=\"unavailable\" --label traefik.http.routers.catchall.priority=\"1\" --label traefik.http.services.unavailable.loadbalancer.server.port=\"0\" traefik:test --providers.docker --log.level=\"DEBUG\" --entrypoints.web.forwardedheaders.trustedips=\"127.0.0.1\" --entrypoints.web.forwardedheaders.trustedips=\"127.0.0.2\"", new_command.run.join(" ") + end + test "traefik start" do assert_equal \ "docker container start traefik", diff --git a/test/configuration/validation_test.rb b/test/configuration/validation_test.rb index 71a0aa115..b7bd6b6aa 100644 --- a/test/configuration/validation_test.rb +++ b/test/configuration/validation_test.rb @@ -94,7 +94,7 @@ class ConfigurationValidationTest < ActiveSupport::TestCase assert_error "builder/remote: unknown key: foo", builder: { "remote" => { "foo" => "bar" } } assert_error "builder/local: unknown key: foo", builder: { "local" => { "foo" => "bar" } } assert_error "builder/remote/arch: should be a string", builder: { "remote" => { "arch" => [] } } - assert_error "builder/args/foo: should be a string", builder: { "args" => { "foo" => [] } } + assert_error "builder/args: should be a hash", builder: { "args" => [ "foo" ] } assert_error "builder/cache/options: should be a string", builder: { "cache" => { "options" => [] } } end From 9a1379be6cd0502611234cf1145169c90eb4f34c Mon Sep 17 00:00:00 2001 From: Donal McBreen Date: Tue, 25 Jun 2024 15:03:02 +0100 Subject: [PATCH 71/71] Bump version for 1.7.3 --- Gemfile.lock | 2 +- lib/kamal/version.rb | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index 83e9427e1..a04fd682f 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,7 +1,7 @@ PATH remote: . specs: - kamal (1.7.2) + kamal (1.7.3) activesupport (>= 7.0) base64 (~> 0.2) bcrypt_pbkdf (~> 1.0) diff --git a/lib/kamal/version.rb b/lib/kamal/version.rb index e1a665922..b10e528e9 100644 --- a/lib/kamal/version.rb +++ b/lib/kamal/version.rb @@ -1,3 +1,3 @@ module Kamal - VERSION = "1.7.2" + VERSION = "1.7.3" end