From eeb5c01fc5e9502bf33fd050a3150698f51896c4 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 21 Mar 2025 23:59:58 +0000 Subject: [PATCH 01/20] Bump nokogiri in the bundler group across 1 directory Bumps the bundler group with 1 update in the / directory: [nokogiri](https://github.com/sparklemotion/nokogiri). Updates `nokogiri` from 1.18.3 to 1.18.4 - [Release notes](https://github.com/sparklemotion/nokogiri/releases) - [Changelog](https://github.com/sparklemotion/nokogiri/blob/main/CHANGELOG.md) - [Commits](https://github.com/sparklemotion/nokogiri/compare/v1.18.3...v1.18.4) --- updated-dependencies: - dependency-name: nokogiri dependency-type: indirect dependency-group: bundler ... Signed-off-by: dependabot[bot] --- Gemfile.lock | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index 5875f349..17b354ee 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -82,15 +82,15 @@ GEM net-sftp (4.0.0) net-ssh (>= 5.0.0, < 8.0.0) net-ssh (7.3.0) - nokogiri (1.18.3-aarch64-linux-musl) + nokogiri (1.18.4-aarch64-linux-musl) racc (~> 1.4) - nokogiri (1.18.3-arm64-darwin) + nokogiri (1.18.4-arm64-darwin) racc (~> 1.4) - nokogiri (1.18.3-x86_64-darwin) + nokogiri (1.18.4-x86_64-darwin) racc (~> 1.4) - nokogiri (1.18.3-x86_64-linux-gnu) + nokogiri (1.18.4-x86_64-linux-gnu) racc (~> 1.4) - nokogiri (1.18.3-x86_64-linux-musl) + nokogiri (1.18.4-x86_64-linux-musl) racc (~> 1.4) ostruct (0.6.1) parallel (1.26.3) From 0b28a54518f7eb0b5c57ebacc5b7c05414e8964a Mon Sep 17 00:00:00 2001 From: Ali Ismayilov <993934+aliismayilov@users.noreply.github.com> Date: Wed, 2 Apr 2025 18:12:14 +0200 Subject: [PATCH 02/20] Enforce JSON output format for aws command --- lib/kamal/secrets/adapters/aws_secrets_manager.rb | 1 + test/secrets/aws_secrets_manager_adapter_test.rb | 10 +++++----- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/lib/kamal/secrets/adapters/aws_secrets_manager.rb b/lib/kamal/secrets/adapters/aws_secrets_manager.rb index d37b4246..27d413ed 100644 --- a/lib/kamal/secrets/adapters/aws_secrets_manager.rb +++ b/lib/kamal/secrets/adapters/aws_secrets_manager.rb @@ -26,6 +26,7 @@ class Kamal::Secrets::Adapters::AwsSecretsManager < Kamal::Secrets::Adapters::Ba def get_from_secrets_manager(secrets, account: nil) args = [ "aws", "secretsmanager", "batch-get-secret-value", "--secret-id-list" ] + secrets.map(&:shellescape) args += [ "--profile", account.shellescape ] if account + args += [ "--output", "json" ] cmd = args.join(" ") `#{cmd}`.tap do |secrets| diff --git a/test/secrets/aws_secrets_manager_adapter_test.rb b/test/secrets/aws_secrets_manager_adapter_test.rb index 00f3de08..0ba9c35e 100644 --- a/test/secrets/aws_secrets_manager_adapter_test.rb +++ b/test/secrets/aws_secrets_manager_adapter_test.rb @@ -4,7 +4,7 @@ class AwsSecretsManagerAdapterTest < SecretAdapterTestCase test "fails when errors are present" do stub_ticks.with("aws --version 2> /dev/null") stub_ticks - .with("aws secretsmanager batch-get-secret-value --secret-id-list unknown1 unknown2 --profile default") + .with("aws secretsmanager batch-get-secret-value --secret-id-list unknown1 unknown2 --profile default --output json") .returns(<<~JSON) { "SecretValues": [], @@ -33,7 +33,7 @@ class AwsSecretsManagerAdapterTest < SecretAdapterTestCase test "fetch" do stub_ticks.with("aws --version 2> /dev/null") stub_ticks - .with("aws secretsmanager batch-get-secret-value --secret-id-list secret/KEY1 secret/KEY2 secret2/KEY3 --profile default") + .with("aws secretsmanager batch-get-secret-value --secret-id-list secret/KEY1 secret/KEY2 secret2/KEY3 --profile default --output json") .returns(<<~JSON) { "SecretValues": [ @@ -76,7 +76,7 @@ class AwsSecretsManagerAdapterTest < SecretAdapterTestCase test "fetch with string value" do stub_ticks.with("aws --version 2> /dev/null") stub_ticks - .with("aws secretsmanager batch-get-secret-value --secret-id-list secret secret2/KEY1 --profile default") + .with("aws secretsmanager batch-get-secret-value --secret-id-list secret secret2/KEY1 --profile default --output json") .returns(<<~JSON) { "SecretValues": [ @@ -118,7 +118,7 @@ class AwsSecretsManagerAdapterTest < SecretAdapterTestCase test "fetch with secret names" do stub_ticks.with("aws --version 2> /dev/null") stub_ticks - .with("aws secretsmanager batch-get-secret-value --secret-id-list secret/KEY1 secret/KEY2 --profile default") + .with("aws secretsmanager batch-get-secret-value --secret-id-list secret/KEY1 secret/KEY2 --profile default --output json") .returns(<<~JSON) { "SecretValues": [ @@ -159,7 +159,7 @@ class AwsSecretsManagerAdapterTest < SecretAdapterTestCase test "fetch without account option omits --profile" do stub_ticks.with("aws --version 2> /dev/null") stub_ticks - .with("aws secretsmanager batch-get-secret-value --secret-id-list secret/KEY1 secret/KEY2") + .with("aws secretsmanager batch-get-secret-value --secret-id-list secret/KEY1 secret/KEY2 --output json") .returns(<<~JSON) { "SecretValues": [ From 215fd2faed88d9b1d09d4bbbe1991d32e1a8d2a3 Mon Sep 17 00:00:00 2001 From: Donal McBreen Date: Tue, 15 Apr 2025 10:55:36 +0100 Subject: [PATCH 03/20] Use registry:3 image for the integration tests v3 was recently released which broke the integration tests. Update them to use the correct config file. Set the major version to prevent this from happening when v4 is released. --- test/integration/docker/registry/Dockerfile | 2 +- test/integration/docker/registry/boot.sh | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/test/integration/docker/registry/Dockerfile b/test/integration/docker/registry/Dockerfile index f5eefc33..01bd0245 100644 --- a/test/integration/docker/registry/Dockerfile +++ b/test/integration/docker/registry/Dockerfile @@ -1,4 +1,4 @@ -FROM registry +FROM registry:3 COPY boot.sh . diff --git a/test/integration/docker/registry/boot.sh b/test/integration/docker/registry/boot.sh index 895838f5..bbaa40a7 100755 --- a/test/integration/docker/registry/boot.sh +++ b/test/integration/docker/registry/boot.sh @@ -2,4 +2,4 @@ while [ ! -f /certs/domain.crt ]; do sleep 1; done -exec /entrypoint.sh /etc/docker/registry/config.yml +exec /entrypoint.sh /etc/distribution/config.yml From c9a755bde694a562564f9891fe3e94a978e87a46 Mon Sep 17 00:00:00 2001 From: Donal McBreen Date: Tue, 15 Apr 2025 12:04:06 +0100 Subject: [PATCH 04/20] Extract echo_boot_config/docker_run methods --- lib/kamal/commands/proxy.rb | 23 +++++++++++++++-------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/lib/kamal/commands/proxy.rb b/lib/kamal/commands/proxy.rb index 6ca87e02..236ce0c1 100644 --- a/lib/kamal/commands/proxy.rb +++ b/lib/kamal/commands/proxy.rb @@ -2,14 +2,7 @@ class Kamal::Commands::Proxy < Kamal::Commands::Base delegate :argumentize, :optionize, to: Kamal::Utils def run - pipe \ - [ :echo, "\$\(#{get_boot_options.join(" ")}\) #{config.proxy_image}" ], - xargs(docker(:run, - "--name", container_name, - "--network", "kamal", - "--detach", - "--restart", "unless-stopped", - "--volume", "kamal-proxy-config:/home/kamal-proxy/.config/kamal-proxy")) + pipe echo_boot_config, xargs(docker_run) end def start @@ -84,4 +77,18 @@ class Kamal::Commands::Proxy < Kamal::Commands::Base def container_name config.proxy_container_name end + + def echo_boot_config + [ :echo, "\$\(#{get_boot_options.join(" ")}\) #{config.proxy_image}" ] + end + + def docker_run + docker \ + :run, + "--name", container_name, + "--network", "kamal", + "--detach", + "--restart", "unless-stopped", + "--volume", "kamal-proxy-config:/home/kamal-proxy/.config/kamal-proxy" + end end From 85320dbc518b96d2341c70be76ca5b588b48cf00 Mon Sep 17 00:00:00 2001 From: Donal McBreen Date: Wed, 16 Apr 2025 16:01:34 +0100 Subject: [PATCH 05/20] Custom proxy image registry, repo and version Use the --registry, --repository and --image_version options of `kamal proxy boot_config set` to change the kamal-proxy image used. We'll still insist that the image version is at least as high as the minimum. --- lib/kamal/cli/proxy.rb | 35 +++++++- lib/kamal/commands/base.rb | 4 + lib/kamal/commands/proxy.rb | 32 +++++-- lib/kamal/configuration.rb | 20 ++++- test/cli/proxy_test.rb | 84 +++++++++++++++---- test/commands/proxy_test.rb | 34 ++++++-- .../deployer/app/.kamal/hooks/pre-deploy | 3 + .../config/deploy.yml | 2 +- .../app_with_roles/.kamal/hooks/pre-deploy | 3 + .../app_with_traefik/.kamal/hooks/pre-deploy | 5 +- test/integration/docker/deployer/setup.sh | 1 + test/integration/integration_test.rb | 10 +-- test/integration/proxy_test.rb | 4 +- 13 files changed, 197 insertions(+), 40 deletions(-) diff --git a/lib/kamal/cli/proxy.rb b/lib/kamal/cli/proxy.rb index f73177ad..9ce8373e 100644 --- a/lib/kamal/cli/proxy.rb +++ b/lib/kamal/cli/proxy.rb @@ -27,6 +27,9 @@ class Kamal::Cli::Proxy < Kamal::Cli::Base option :http_port, type: :numeric, default: Kamal::Configuration::PROXY_HTTP_PORT, desc: "HTTP port to publish on the host" option :https_port, type: :numeric, default: Kamal::Configuration::PROXY_HTTPS_PORT, desc: "HTTPS port to publish on the host" option :log_max_size, type: :string, default: Kamal::Configuration::PROXY_LOG_MAX_SIZE, desc: "Max size of proxy logs" + option :registry, type: :string, default: nil, desc: "Registry to use for the proxy image" + option :repository, type: :string, default: nil, desc: "Repository for the proxy image" + option :image_version, type: :string, default: nil, desc: "Version of the proxy to run" option :docker_options, type: :array, default: [], desc: "Docker options to pass to the proxy container", banner: "option=value option2=value2" def boot_config(subcommand) case subcommand @@ -37,17 +40,43 @@ class Kamal::Cli::Proxy < Kamal::Cli::Base *options[:docker_options].map { |option| "--#{option}" } ] + default_boot_options = [ + *(KAMAL.config.proxy_publish_args(Kamal::Configuration::PROXY_HTTP_PORT, Kamal::Configuration::PROXY_HTTPS_PORT, nil)), + *(KAMAL.config.proxy_logging_args(Kamal::Configuration::PROXY_LOG_MAX_SIZE)), + ] + + image = [ options[:registry].presence, options[:repository] || KAMAL.config.proxy_repository_name, KAMAL.config.proxy_image_name ].compact.join("/") + image_version = options[:image_version] + on(KAMAL.proxy_hosts) do |host| execute(*KAMAL.proxy.ensure_proxy_directory) - upload! StringIO.new(boot_options.join(" ")), KAMAL.config.proxy_options_file + if boot_options != default_boot_options + upload! StringIO.new(boot_options.join(" ")), KAMAL.config.proxy_options_file + else + execute *KAMAL.proxy.reset_boot_options, raise_on_non_zero_exit: false + end + + if image != KAMAL.config.proxy_image_default + upload! StringIO.new(image), KAMAL.config.proxy_image_file + else + execute *KAMAL.proxy.reset_image, raise_on_non_zero_exit: false + end + + if image_version + upload! StringIO.new(image_version), KAMAL.config.proxy_image_version_file + else + execute *KAMAL.proxy.reset_image_version, raise_on_non_zero_exit: false + end end when "get" on(KAMAL.proxy_hosts) do |host| - puts "Host #{host}: #{capture_with_info(*KAMAL.proxy.get_boot_options)}" + puts "Host #{host}: #{capture_with_info(*KAMAL.proxy.boot_config)}" end when "reset" on(KAMAL.proxy_hosts) do |host| - execute *KAMAL.proxy.reset_boot_options + execute *KAMAL.proxy.reset_boot_options, raise_on_non_zero_exit: false + execute *KAMAL.proxy.reset_image, raise_on_non_zero_exit: false + execute *KAMAL.proxy.reset_image_version, raise_on_non_zero_exit: false end else raise ArgumentError, "Unknown boot_config subcommand #{subcommand}" diff --git a/lib/kamal/commands/base.rb b/lib/kamal/commands/base.rb index 535d17c0..ba3b86ba 100644 --- a/lib/kamal/commands/base.rb +++ b/lib/kamal/commands/base.rb @@ -68,6 +68,10 @@ module Kamal::Commands combine *commands, by: "||" end + def substitute(*commands) + "\$\(#{commands.join(" ")}\)" + end + def xargs(command) [ :xargs, command ].flatten end diff --git a/lib/kamal/commands/proxy.rb b/lib/kamal/commands/proxy.rb index 236ce0c1..25fc1efe 100644 --- a/lib/kamal/commands/proxy.rb +++ b/lib/kamal/commands/proxy.rb @@ -2,7 +2,7 @@ class Kamal::Commands::Proxy < Kamal::Commands::Base delegate :argumentize, :optionize, to: Kamal::Utils def run - pipe echo_boot_config, xargs(docker_run) + pipe boot_config, xargs(docker_run) end def start @@ -24,7 +24,7 @@ class Kamal::Commands::Proxy < Kamal::Commands::Base def version pipe \ docker(:inspect, container_name, "--format '{{.Config.Image}}'"), - [ :cut, "-d:", "-f2" ] + [ :awk, "-F:", "'{print \$NF}'" ] end def logs(timestamps: true, since: nil, lines: nil, grep: nil, grep_options: nil) @@ -65,21 +65,41 @@ class Kamal::Commands::Proxy < Kamal::Commands::Base remove_directory config.proxy_directory end - def get_boot_options - combine [ :cat, config.proxy_options_file, "2>", "/dev/null" ], [ :echo, "\"#{config.proxy_options_default.join(" ")}\"" ], by: "||" + def boot_config + [ :echo, "#{substitute(read_boot_options)} #{substitute(read_image)}:#{substitute(read_image_version)}" ] + end + + def read_boot_options + read_file(config.proxy_options_file, default: config.proxy_options_default.join(" ")) + end + + def read_image + read_file(config.proxy_image_file, default: config.proxy_image_default) + end + + def read_image_version + read_file(config.proxy_image_version_file, default: Kamal::Configuration::PROXY_MINIMUM_VERSION) end def reset_boot_options remove_file config.proxy_options_file end + def reset_image + remove_file config.proxy_image_file + end + + def reset_image_version + remove_file config.proxy_image_version_file + end + private def container_name config.proxy_container_name end - def echo_boot_config - [ :echo, "\$\(#{get_boot_options.join(" ")}\) #{config.proxy_image}" ] + def read_file(file, default: nil) + combine [ :cat, file, "2>", "/dev/null" ], [ :echo, "\"#{default}\"" ], by: "||" end def docker_run diff --git a/lib/kamal/configuration.rb b/lib/kamal/configuration.rb index 1c0b79cf..1e9cd667 100644 --- a/lib/kamal/configuration.rb +++ b/lib/kamal/configuration.rb @@ -261,8 +261,16 @@ class Kamal::Configuration [ *proxy_publish_args(PROXY_HTTP_PORT, PROXY_HTTPS_PORT), *proxy_logging_args(PROXY_LOG_MAX_SIZE) ] end - def proxy_image - "basecamp/kamal-proxy:#{PROXY_MINIMUM_VERSION}" + def proxy_repository_name + "basecamp" + end + + def proxy_image_name + "kamal-proxy" + end + + def proxy_image_default + "#{proxy_repository_name}/#{proxy_image_name}" end def proxy_container_name @@ -277,6 +285,14 @@ class Kamal::Configuration File.join proxy_directory, "options" end + def proxy_image_file + File.join proxy_directory, "image" + end + + def proxy_image_version_file + File.join proxy_directory, "image_version" + end + def to_h { roles: role_names, diff --git a/test/cli/proxy_test.rb b/test/cli/proxy_test.rb index 4880a839..cd9cd5b9 100644 --- a/test/cli/proxy_test.rb +++ b/test/cli/proxy_test.rb @@ -4,21 +4,21 @@ class CliProxyTest < CliTestCase test "boot" do run_command("boot").tap do |output| assert_match "docker login", output - assert_match "echo $(cat .kamal/proxy/options 2> /dev/null || echo \"--publish 80:80 --publish 443:443 --log-opt max-size=10m\") basecamp/kamal-proxy:#{Kamal::Configuration::PROXY_MINIMUM_VERSION} | xargs docker run --name kamal-proxy --network kamal --detach --restart unless-stopped --volume kamal-proxy-config:/home/kamal-proxy/.config/kamal-proxy", output + assert_match "echo $(cat .kamal/proxy/options 2> /dev/null || echo \"--publish 80:80 --publish 443:443 --log-opt max-size=10m\") $(cat .kamal/proxy/image 2> /dev/null || echo \"basecamp/kamal-proxy\"):$(cat .kamal/proxy/image_version 2> /dev/null || echo \"#{Kamal::Configuration::PROXY_MINIMUM_VERSION}\") | xargs docker run --name kamal-proxy --network kamal --detach --restart unless-stopped --volume kamal-proxy-config:/home/kamal-proxy/.config/kamal-proxy", output end end test "boot old version" do Thread.report_on_exception = false SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info) - .with(:docker, :inspect, "kamal-proxy", "--format '{{.Config.Image}}'", "|", :cut, "-d:", "-f2") + .with(:docker, :inspect, "kamal-proxy", "--format '{{.Config.Image}}'", "|", :awk, "-F:", "'{print $NF}'") .returns("v0.0.1") .at_least_once exception = assert_raises do run_command("boot").tap do |output| assert_match "docker login", output - assert_match "echo $(cat .kamal/proxy/options 2> /dev/null || echo \"--publish 80:80 --publish 443:443 --log-opt max-size=10m\") basecamp/kamal-proxy:#{Kamal::Configuration::PROXY_MINIMUM_VERSION} | xargs docker run --name kamal-proxy --network kamal --detach --restart unless-stopped --volume kamal-proxy-config:/home/kamal-proxy/.config/kamal-proxy", output + assert_match "echo $(cat .kamal/proxy/options 2> /dev/null || echo \"--publish 80:80 --publish 443:443 --log-opt max-size=10m\") $(cat .kamal/proxy/image 2> /dev/null || echo \"basecamp/kamal-proxy\"):$(cat .kamal/proxy/image_version 2> /dev/null || echo \"#{Kamal::Configuration::PROXY_MINIMUM_VERSION}\") | xargs docker run --name kamal-proxy --network kamal --detach --restart unless-stopped --volume kamal-proxy-config:/home/kamal-proxy/.config/kamal-proxy", output end end @@ -30,13 +30,13 @@ class CliProxyTest < CliTestCase test "boot correct version" do Thread.report_on_exception = false SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info) - .with(:docker, :inspect, "kamal-proxy", "--format '{{.Config.Image}}'", "|", :cut, "-d:", "-f2") + .with(:docker, :inspect, "kamal-proxy", "--format '{{.Config.Image}}'", "|", :awk, "-F:", "'{print $NF}'") .returns(Kamal::Configuration::PROXY_MINIMUM_VERSION) .at_least_once run_command("boot").tap do |output| assert_match "docker login", output - assert_match "docker container start kamal-proxy || echo $(cat .kamal/proxy/options 2> /dev/null || echo \"--publish 80:80 --publish 443:443 --log-opt max-size=10m\") basecamp/kamal-proxy:#{Kamal::Configuration::PROXY_MINIMUM_VERSION} | xargs docker run --name kamal-proxy --network kamal --detach --restart unless-stopped --volume kamal-proxy-config:/home/kamal-proxy/.config/kamal-proxy", output + assert_match "docker container start kamal-proxy || echo $(cat .kamal/proxy/options 2> /dev/null || echo \"--publish 80:80 --publish 443:443 --log-opt max-size=10m\") $(cat .kamal/proxy/image 2> /dev/null || echo \"basecamp/kamal-proxy\"):$(cat .kamal/proxy/image_version 2> /dev/null || echo \"#{Kamal::Configuration::PROXY_MINIMUM_VERSION}\") | xargs docker run --name kamal-proxy --network kamal --detach --restart unless-stopped --volume kamal-proxy-config:/home/kamal-proxy/.config/kamal-proxy", output end ensure Thread.report_on_exception = false @@ -56,12 +56,12 @@ class CliProxyTest < CliTestCase run_command("reboot", "-y").tap do |output| assert_match "docker container stop kamal-proxy on 1.1.1.1", output assert_match "docker container prune --force --filter label=org.opencontainers.image.title=kamal-proxy on 1.1.1.1", output - assert_match "echo $(cat .kamal/proxy/options 2> /dev/null || echo \"--publish 80:80 --publish 443:443 --log-opt max-size=10m\") basecamp/kamal-proxy:#{Kamal::Configuration::PROXY_MINIMUM_VERSION} | xargs docker run --name kamal-proxy --network kamal --detach --restart unless-stopped --volume kamal-proxy-config:/home/kamal-proxy/.config/kamal-proxy on 1.1.1.1", output + assert_match "echo $(cat .kamal/proxy/options 2> /dev/null || echo \"--publish 80:80 --publish 443:443 --log-opt max-size=10m\") $(cat .kamal/proxy/image 2> /dev/null || echo \"basecamp/kamal-proxy\"):$(cat .kamal/proxy/image_version 2> /dev/null || echo \"#{Kamal::Configuration::PROXY_MINIMUM_VERSION}\") | xargs docker run --name kamal-proxy --network kamal --detach --restart unless-stopped --volume kamal-proxy-config:/home/kamal-proxy/.config/kamal-proxy on 1.1.1.1", output assert_match "docker exec kamal-proxy kamal-proxy deploy app-web --target=\"abcdefabcdef:80\" --deploy-timeout=\"6s\" --drain-timeout=\"30s\" --buffer-requests --buffer-responses --log-request-header=\"Cache-Control\" --log-request-header=\"Last-Modified\" --log-request-header=\"User-Agent\" on 1.1.1.1", output assert_match "docker container stop kamal-proxy on 1.1.1.2", output assert_match "docker container prune --force --filter label=org.opencontainers.image.title=kamal-proxy on 1.1.1.2", output - assert_match "echo $(cat .kamal/proxy/options 2> /dev/null || echo \"--publish 80:80 --publish 443:443 --log-opt max-size=10m\") basecamp/kamal-proxy:#{Kamal::Configuration::PROXY_MINIMUM_VERSION} | xargs docker run --name kamal-proxy --network kamal --detach --restart unless-stopped --volume kamal-proxy-config:/home/kamal-proxy/.config/kamal-proxy on 1.1.1.2", output + assert_match "echo $(cat .kamal/proxy/options 2> /dev/null || echo \"--publish 80:80 --publish 443:443 --log-opt max-size=10m\") $(cat .kamal/proxy/image 2> /dev/null || echo \"basecamp/kamal-proxy\"):$(cat .kamal/proxy/image_version 2> /dev/null || echo \"#{Kamal::Configuration::PROXY_MINIMUM_VERSION}\") | xargs docker run --name kamal-proxy --network kamal --detach --restart unless-stopped --volume kamal-proxy-config:/home/kamal-proxy/.config/kamal-proxy on 1.1.1.2", output assert_match "docker exec kamal-proxy kamal-proxy deploy app-web --target=\"abcdefabcdef:80\" --deploy-timeout=\"6s\" --drain-timeout=\"30s\" --buffer-requests --buffer-responses --log-request-header=\"Cache-Control\" --log-request-header=\"Last-Modified\" --log-request-header=\"User-Agent\" on 1.1.1.2", output end end @@ -179,7 +179,7 @@ class CliProxyTest < CliTestCase SSHKit::Backend::Abstract.any_instance.stubs(:capture_with_info).returns("12345678") SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info) - .with(:docker, :inspect, "kamal-proxy", "--format '{{.Config.Image}}'", "|", :cut, "-d:", "-f2") + .with(:docker, :inspect, "kamal-proxy", "--format '{{.Config.Image}}'", "|", :awk, "-F:", "'{print $NF}'") .returns(Kamal::Configuration::PROXY_MINIMUM_VERSION) SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info) @@ -196,7 +196,7 @@ class CliProxyTest < CliTestCase assert_match "/usr/bin/env mkdir -p .kamal", output assert_match "docker network create kamal", output assert_match "docker login -u [REDACTED] -p [REDACTED]", output - assert_match "docker container start kamal-proxy || echo $(cat .kamal/proxy/options 2> /dev/null || echo \"--publish 80:80 --publish 443:443 --log-opt max-size=10m\") basecamp/kamal-proxy:#{Kamal::Configuration::PROXY_MINIMUM_VERSION} | xargs docker run --name kamal-proxy --network kamal --detach --restart unless-stopped --volume kamal-proxy-config:/home/kamal-proxy/.config/kamal-proxy", output + assert_match "docker container start kamal-proxy || echo $(cat .kamal/proxy/options 2> /dev/null || echo \"--publish 80:80 --publish 443:443 --log-opt max-size=10m\") $(cat .kamal/proxy/image 2> /dev/null || echo \"basecamp/kamal-proxy\"):$(cat .kamal/proxy/image_version 2> /dev/null || echo \"#{Kamal::Configuration::PROXY_MINIMUM_VERSION}\") | xargs docker run --name kamal-proxy --network kamal --detach --restart unless-stopped --volume kamal-proxy-config:/home/kamal-proxy/.config/kamal-proxy", output assert_match "/usr/bin/env mkdir -p .kamal", output assert_match %r{docker rename app-web-latest app-web-latest_replaced_.*}, output assert_match "/usr/bin/env mkdir -p .kamal/apps/app/env/roles", output @@ -218,7 +218,7 @@ class CliProxyTest < CliTestCase SSHKit::Backend::Abstract.any_instance.stubs(:capture_with_info).returns("12345678") SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info) - .with(:docker, :inspect, "kamal-proxy", "--format '{{.Config.Image}}'", "|", :cut, "-d:", "-f2") + .with(:docker, :inspect, "kamal-proxy", "--format '{{.Config.Image}}'", "|", :awk, "-F:", "'{print $NF}'") .returns(Kamal::Configuration::PROXY_MINIMUM_VERSION) SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info) @@ -238,8 +238,9 @@ class CliProxyTest < CliTestCase run_command("boot_config", "set").tap do |output| %w[ 1.1.1.1 1.1.1.2 ].each do |host| assert_match "Running /usr/bin/env mkdir -p .kamal/proxy on #{host}", output - assert_match "Uploading \"--publish 80:80 --publish 443:443 --log-opt max-size=10m\" to .kamal/proxy/options on #{host}", output - end + assert_match "Running /usr/bin/env rm .kamal/proxy/options on #{host}", output + assert_match "Running /usr/bin/env rm .kamal/proxy/image on #{host}", output + assert_match "Running /usr/bin/env rm .kamal/proxy/image_version on #{host}", output end end end @@ -248,6 +249,8 @@ class CliProxyTest < CliTestCase %w[ 1.1.1.1 1.1.1.2 ].each do |host| assert_match "Running /usr/bin/env mkdir -p .kamal/proxy on #{host}", output assert_match "Uploading \"--log-opt max-size=10m\" to .kamal/proxy/options on #{host}", output + assert_match "Running /usr/bin/env rm .kamal/proxy/image on #{host}", output + assert_match "Running /usr/bin/env rm .kamal/proxy/image_version on #{host}", output end end end @@ -257,6 +260,8 @@ class CliProxyTest < CliTestCase %w[ 1.1.1.1 1.1.1.2 ].each do |host| assert_match "Running /usr/bin/env mkdir -p .kamal/proxy on #{host}", output assert_match "Uploading \"--publish 80:80 --publish 443:443 --log-opt max-size=100m\" to .kamal/proxy/options on #{host}", output + assert_match "Running /usr/bin/env rm .kamal/proxy/image on #{host}", output + assert_match "Running /usr/bin/env rm .kamal/proxy/image_version on #{host}", output end end end @@ -266,6 +271,8 @@ class CliProxyTest < CliTestCase %w[ 1.1.1.1 1.1.1.2 ].each do |host| assert_match "Running /usr/bin/env mkdir -p .kamal/proxy on #{host}", output assert_match "Uploading \"--publish 80:80 --publish 443:443\" to .kamal/proxy/options on #{host}", output + assert_match "Running /usr/bin/env rm .kamal/proxy/image on #{host}", output + assert_match "Running /usr/bin/env rm .kamal/proxy/image_version on #{host}", output end end end @@ -310,19 +317,64 @@ class CliProxyTest < CliTestCase %w[ 1.1.1.1 1.1.1.2 ].each do |host| assert_match "Running /usr/bin/env mkdir -p .kamal/proxy on #{host}", output assert_match "Uploading \"--publish 80:80 --publish 443:443 --log-opt max-size=10m --label=foo=bar --add_host=thishost:thathost\" to .kamal/proxy/options on #{host}", output + assert_match "Running /usr/bin/env rm .kamal/proxy/image on #{host}", output + assert_match "Running /usr/bin/env rm .kamal/proxy/image_version on #{host}", output + end + end + end + + test "boot_config set registry" do + run_command("boot_config", "set", "--registry", "myreg").tap do |output| + %w[ 1.1.1.1 1.1.1.2 ].each do |host| + assert_match "Running /usr/bin/env mkdir -p .kamal/proxy on #{host}", output + assert_match "Running /usr/bin/env rm .kamal/proxy/options on #{host}", output + assert_match "Uploading \"myreg/basecamp/kamal-proxy\" to .kamal/proxy/image on #{host}", output + assert_match "Running /usr/bin/env rm .kamal/proxy/image_version on #{host}", output + end + end + end + + test "boot_config set repository" do + run_command("boot_config", "set", "--repository", "myrepo").tap do |output| + %w[ 1.1.1.1 1.1.1.2 ].each do |host| + assert_match "Running /usr/bin/env mkdir -p .kamal/proxy on #{host}", output + assert_match "Running /usr/bin/env rm .kamal/proxy/options on #{host}", output + assert_match "Uploading \"myrepo/kamal-proxy\" to .kamal/proxy/image on #{host}", output + assert_match "Running /usr/bin/env rm .kamal/proxy/image_version on #{host}", output + end + end + end + + test "boot_config set image_version" do + run_command("boot_config", "set", "--image_version", "0.9.9").tap do |output| + %w[ 1.1.1.1 1.1.1.2 ].each do |host| + assert_match "Running /usr/bin/env mkdir -p .kamal/proxy on #{host}", output + assert_match "Running /usr/bin/env rm .kamal/proxy/options on #{host}", output + assert_match "Running /usr/bin/env rm .kamal/proxy/image on #{host}", output + assert_match "Uploading \"0.9.9\" to .kamal/proxy/image_version on #{host}", output + end + end + end + + test "boot_config set all" do + run_command("boot_config", "set", "--docker_options", "label=foo=bar", "--registry", "myreg", "--repository", "myrepo", "--image_version", "0.9.9").tap do |output| + %w[ 1.1.1.1 1.1.1.2 ].each do |host| + assert_match "Uploading \"--publish 80:80 --publish 443:443 --log-opt max-size=10m --label=foo=bar\" to .kamal/proxy/options on #{host}", output + assert_match "Uploading \"myreg/myrepo/kamal-proxy\" to .kamal/proxy/image on #{host}", output + assert_match "Uploading \"0.9.9\" to .kamal/proxy/image_version on #{host}", output end end end test "boot_config get" do SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info) - .with(:cat, ".kamal/proxy/options", "2>", "/dev/null", "||", :echo, "\"--publish 80:80 --publish 443:443 --log-opt max-size=10m\"") - .returns("--publish 80:80 --publish 8443:443 --label=foo=bar") + .with(:echo, "$(cat .kamal/proxy/options 2> /dev/null || echo \"--publish 80:80 --publish 443:443 --log-opt max-size=10m\") $(cat .kamal/proxy/image 2> /dev/null || echo \"basecamp/kamal-proxy\"):$(cat .kamal/proxy/image_version 2> /dev/null || echo \"v0.8.7\")") + .returns("--publish 80:80 --publish 8443:443 --label=foo=bar basecamp/kamal-proxy:v1.0.0") .twice run_command("boot_config", "get").tap do |output| - assert_match "Host 1.1.1.1: --publish 80:80 --publish 8443:443 --label=foo=bar", output - assert_match "Host 1.1.1.2: --publish 80:80 --publish 8443:443 --label=foo=bar", output + assert_match "Host 1.1.1.1: --publish 80:80 --publish 8443:443 --label=foo=bar basecamp/kamal-proxy:v1.0.0", output + assert_match "Host 1.1.1.2: --publish 80:80 --publish 8443:443 --label=foo=bar basecamp/kamal-proxy:v1.0.0", output end end diff --git a/test/commands/proxy_test.rb b/test/commands/proxy_test.rb index e42415b1..1ebd4009 100644 --- a/test/commands/proxy_test.rb +++ b/test/commands/proxy_test.rb @@ -15,7 +15,7 @@ class CommandsProxyTest < ActiveSupport::TestCase test "run" do assert_equal \ - "echo $(cat .kamal/proxy/options 2> /dev/null || echo \"--publish 80:80 --publish 443:443 --log-opt max-size=10m\") basecamp/kamal-proxy:#{Kamal::Configuration::PROXY_MINIMUM_VERSION} | xargs docker run --name kamal-proxy --network kamal --detach --restart unless-stopped --volume kamal-proxy-config:/home/kamal-proxy/.config/kamal-proxy", + "echo $(cat .kamal/proxy/options 2> /dev/null || echo \"--publish 80:80 --publish 443:443 --log-opt max-size=10m\") $(cat .kamal/proxy/image 2> /dev/null || echo \"basecamp/kamal-proxy\"):$(cat .kamal/proxy/image_version 2> /dev/null || echo \"#{Kamal::Configuration::PROXY_MINIMUM_VERSION}\") | xargs docker run --name kamal-proxy --network kamal --detach --restart unless-stopped --volume kamal-proxy-config:/home/kamal-proxy/.config/kamal-proxy", new_command.run.join(" ") end @@ -23,7 +23,7 @@ class CommandsProxyTest < ActiveSupport::TestCase @config.delete(:proxy) assert_equal \ - "echo $(cat .kamal/proxy/options 2> /dev/null || echo \"--publish 80:80 --publish 443:443 --log-opt max-size=10m\") basecamp/kamal-proxy:#{Kamal::Configuration::PROXY_MINIMUM_VERSION} | xargs docker run --name kamal-proxy --network kamal --detach --restart unless-stopped --volume kamal-proxy-config:/home/kamal-proxy/.config/kamal-proxy", + "echo $(cat .kamal/proxy/options 2> /dev/null || echo \"--publish 80:80 --publish 443:443 --log-opt max-size=10m\") $(cat .kamal/proxy/image 2> /dev/null || echo \"basecamp/kamal-proxy\"):$(cat .kamal/proxy/image_version 2> /dev/null || echo \"#{Kamal::Configuration::PROXY_MINIMUM_VERSION}\") | xargs docker run --name kamal-proxy --network kamal --detach --restart unless-stopped --volume kamal-proxy-config:/home/kamal-proxy/.config/kamal-proxy", new_command.run.join(" ") end @@ -101,7 +101,7 @@ class CommandsProxyTest < ActiveSupport::TestCase test "version" do assert_equal \ - "docker inspect kamal-proxy --format '{{.Config.Image}}' | cut -d: -f2", + "docker inspect kamal-proxy --format '{{.Config.Image}}' | awk -F: '{print $NF}'", new_command.version.join(" ") end @@ -111,10 +111,22 @@ class CommandsProxyTest < ActiveSupport::TestCase new_command.ensure_proxy_directory.join(" ") end - test "get_boot_options" do + test "read_boot_options" do assert_equal \ "cat .kamal/proxy/options 2> /dev/null || echo \"--publish 80:80 --publish 443:443 --log-opt max-size=10m\"", - new_command.get_boot_options.join(" ") + new_command.read_boot_options.join(" ") + end + + test "read_image" do + assert_equal \ + "cat .kamal/proxy/image 2> /dev/null || echo \"basecamp/kamal-proxy\"", + new_command.read_image.join(" ") + end + + test "read_image_version" do + assert_equal \ + "cat .kamal/proxy/image_version 2> /dev/null || echo \"#{Kamal::Configuration::PROXY_MINIMUM_VERSION}\"", + new_command.read_image_version.join(" ") end test "reset_boot_options" do @@ -123,6 +135,18 @@ class CommandsProxyTest < ActiveSupport::TestCase new_command.reset_boot_options.join(" ") end + test "reset_image" do + assert_equal \ + "rm .kamal/proxy/image", + new_command.reset_image.join(" ") + end + + test "reset_image_version" do + assert_equal \ + "rm .kamal/proxy/image_version", + new_command.reset_image_version.join(" ") + end + private def new_command Kamal::Commands::Proxy.new(Kamal::Configuration.new(@config, version: "123")) diff --git a/test/integration/docker/deployer/app/.kamal/hooks/pre-deploy b/test/integration/docker/deployer/app/.kamal/hooks/pre-deploy index 32fa04c6..eff40875 100755 --- a/test/integration/docker/deployer/app/.kamal/hooks/pre-deploy +++ b/test/integration/docker/deployer/app/.kamal/hooks/pre-deploy @@ -1,3 +1,6 @@ #!/bin/sh +set -e + +kamal proxy boot_config set --registry registry:4443 echo "Deployed!" mkdir -p /tmp/${TEST_ID} && touch /tmp/${TEST_ID}/pre-deploy diff --git a/test/integration/docker/deployer/app_with_proxied_accessory/config/deploy.yml b/test/integration/docker/deployer/app_with_proxied_accessory/config/deploy.yml index bdb547ae..ae4e217b 100644 --- a/test/integration/docker/deployer/app_with_proxied_accessory/config/deploy.yml +++ b/test/integration/docker/deployer/app_with_proxied_accessory/config/deploy.yml @@ -41,4 +41,4 @@ accessories: interval: 1 timeout: 1 path: "/" - +drain_timeout: 2 diff --git a/test/integration/docker/deployer/app_with_roles/.kamal/hooks/pre-deploy b/test/integration/docker/deployer/app_with_roles/.kamal/hooks/pre-deploy index 32fa04c6..eff40875 100755 --- a/test/integration/docker/deployer/app_with_roles/.kamal/hooks/pre-deploy +++ b/test/integration/docker/deployer/app_with_roles/.kamal/hooks/pre-deploy @@ -1,3 +1,6 @@ #!/bin/sh +set -e + +kamal proxy boot_config set --registry registry:4443 echo "Deployed!" mkdir -p /tmp/${TEST_ID} && touch /tmp/${TEST_ID}/pre-deploy diff --git a/test/integration/docker/deployer/app_with_traefik/.kamal/hooks/pre-deploy b/test/integration/docker/deployer/app_with_traefik/.kamal/hooks/pre-deploy index b2d8b112..5d7b7372 100755 --- a/test/integration/docker/deployer/app_with_traefik/.kamal/hooks/pre-deploy +++ b/test/integration/docker/deployer/app_with_traefik/.kamal/hooks/pre-deploy @@ -1,4 +1,7 @@ -kamal proxy boot_config set --publish false \ +set -e + +kamal proxy boot_config set --registry registry:4443 \ + --publish false \ --docker_options label=traefik.http.services.kamal_proxy.loadbalancer.server.scheme=http \ label=traefik.http.routers.kamal_proxy.rule=PathPrefix\(\`/\`\) \ sysctl=net.ipv4.ip_local_port_range=\"10000\ 60999\" diff --git a/test/integration/docker/deployer/setup.sh b/test/integration/docker/deployer/setup.sh index 4867519e..24f39d7f 100755 --- a/test/integration/docker/deployer/setup.sh +++ b/test/integration/docker/deployer/setup.sh @@ -20,6 +20,7 @@ push_image_to_registry_4443() { install_kamal push_image_to_registry_4443 nginx 1-alpine-slim push_image_to_registry_4443 busybox 1.36.0 +push_image_to_registry_4443 basecamp/kamal-proxy v0.8.7 # .ssh is on a shared volume that persists between runs. Clean it up as the # churn of temporary vm IPs can eventually create conflicts. diff --git a/test/integration/integration_test.rb b/test/integration/integration_test.rb index 39ef9bc7..88c262c9 100644 --- a/test/integration/integration_test.rb +++ b/test/integration/integration_test.rb @@ -12,11 +12,11 @@ class IntegrationTest < ActiveSupport::TestCase teardown do unless passed? - [ :deployer, :vm1, :vm2, :shared, :load_balancer, :registry ].each do |container| - puts - puts "Logs for #{container}:" - docker_compose :logs, container - end + # [ :deployer, :vm1, :vm2, :shared, :load_balancer, :registry ].each do |container| + # puts + # puts "Logs for #{container}:" + # docker_compose :logs, container + # end end docker_compose "down -t 1" end diff --git a/test/integration/proxy_test.rb b/test/integration/proxy_test.rb index 39cacb94..5a53a08e 100644 --- a/test/integration/proxy_test.rb +++ b/test/integration/proxy_test.rb @@ -6,6 +6,8 @@ class ProxyTest < IntegrationTest end test "boot, reboot, stop, start, restart, logs, remove" do + kamal :proxy, :boot_config, :set, "--registry", "registry:4443" + kamal :proxy, :boot assert_proxy_running @@ -46,7 +48,7 @@ class ProxyTest < IntegrationTest logs = kamal :proxy, :logs, capture: true assert_match /No previous state to restore/, logs - kamal :proxy, :boot_config, :set, "--docker-options='sysctl net.ipv4.ip_local_port_range=\"10000 60999\"'" + kamal :proxy, :boot_config, :set, "--registry", "registry:4443", "--docker-options='sysctl net.ipv4.ip_local_port_range=\"10000 60999\"'" assert_docker_options_in_file kamal :proxy, :reboot, "-y" From bd81632439d9024daf2f3f1f603f06d2f8519a27 Mon Sep 17 00:00:00 2001 From: Donal McBreen Date: Wed, 16 Apr 2025 16:14:11 +0100 Subject: [PATCH 06/20] Set DEBUG for integration test output --- test/cli/build_test.rb | 2 +- test/integration/integration_test.rb | 16 ++++++++-------- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/test/cli/build_test.rb b/test/cli/build_test.rb index 76db2924..8690e00b 100644 --- a/test/cli/build_test.rb +++ b/test/cli/build_test.rb @@ -321,7 +321,7 @@ class CliBuildTest < CliTestCase private def run_command(*command, fixture: :with_accessories) - stdouted { Kamal::Cli::Build.start([ *command, "-c", "test/fixtures/deploy_#{fixture}.yml" ]) } + stdouted { stderred { Kamal::Cli::Build.start([ *command, "-c", "test/fixtures/deploy_#{fixture}.yml" ]) } } end def stub_dependency_checks diff --git a/test/integration/integration_test.rb b/test/integration/integration_test.rb index 88c262c9..e6e5520d 100644 --- a/test/integration/integration_test.rb +++ b/test/integration/integration_test.rb @@ -11,12 +11,12 @@ class IntegrationTest < ActiveSupport::TestCase end teardown do - unless passed? - # [ :deployer, :vm1, :vm2, :shared, :load_balancer, :registry ].each do |container| - # puts - # puts "Logs for #{container}:" - # docker_compose :logs, container - # end + if !passed? && ENV["DEBUG"] + [ :deployer, :vm1, :vm2, :shared, :load_balancer, :registry ].each do |container| + puts + puts "Logs for #{container}:" + docker_compose :logs, container + end end docker_compose "down -t 1" end @@ -25,8 +25,8 @@ class IntegrationTest < ActiveSupport::TestCase def docker_compose(*commands, capture: false, raise_on_error: true) command = "TEST_ID=#{ENV["TEST_ID"]} docker compose #{commands.join(" ")}" succeeded = false - if capture - result = stdouted { succeeded = system("cd test/integration && #{command}") } + if capture || !ENV["DEBUG"] + result = stdouted { stderred { succeeded = system("cd test/integration && #{command}") } } else succeeded = system("cd test/integration && #{command}") end From dd9048e09ce55414c4b21d35782ec67dda1fd9af Mon Sep 17 00:00:00 2001 From: Donal McBreen Date: Thu, 17 Apr 2025 08:20:25 +0100 Subject: [PATCH 07/20] Allow version 'next' --- lib/kamal/cli/proxy.rb | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/lib/kamal/cli/proxy.rb b/lib/kamal/cli/proxy.rb index 9ce8373e..f56f8ad9 100644 --- a/lib/kamal/cli/proxy.rb +++ b/lib/kamal/cli/proxy.rb @@ -1,4 +1,6 @@ class Kamal::Cli::Proxy < Kamal::Cli::Base + delegate :older_version?, to: Kamal::Utils + desc "boot", "Boot proxy on servers" def boot with_lock do @@ -13,7 +15,7 @@ class Kamal::Cli::Proxy < Kamal::Cli::Base version = capture_with_info(*KAMAL.proxy.version).strip.presence - if version && Kamal::Utils.older_version?(version, Kamal::Configuration::PROXY_MINIMUM_VERSION) + if version && version != "next" && older_version?(version, Kamal::Configuration::PROXY_MINIMUM_VERSION) raise "kamal-proxy version #{version} is too old, run `kamal proxy reboot` in order to update to at least #{Kamal::Configuration::PROXY_MINIMUM_VERSION}" end execute *KAMAL.proxy.start_or_run From 8ceeda6ac9496290d86569ebd5e60ef605fa650f Mon Sep 17 00:00:00 2001 From: Donal McBreen Date: Thu, 17 Apr 2025 08:26:10 +0100 Subject: [PATCH 08/20] Extract proxy_default_boot_options --- lib/kamal/cli/proxy.rb | 16 +++++++--------- lib/kamal/configuration.rb | 7 +++++++ 2 files changed, 14 insertions(+), 9 deletions(-) diff --git a/lib/kamal/cli/proxy.rb b/lib/kamal/cli/proxy.rb index f56f8ad9..05c43d60 100644 --- a/lib/kamal/cli/proxy.rb +++ b/lib/kamal/cli/proxy.rb @@ -1,6 +1,4 @@ class Kamal::Cli::Proxy < Kamal::Cli::Base - delegate :older_version?, to: Kamal::Utils - desc "boot", "Boot proxy on servers" def boot with_lock do @@ -15,7 +13,7 @@ class Kamal::Cli::Proxy < Kamal::Cli::Base version = capture_with_info(*KAMAL.proxy.version).strip.presence - if version && version != "next" && older_version?(version, Kamal::Configuration::PROXY_MINIMUM_VERSION) + if version && version != "next" && Kamal::Utils.older_version?(version, Kamal::Configuration::PROXY_MINIMUM_VERSION) raise "kamal-proxy version #{version} is too old, run `kamal proxy reboot` in order to update to at least #{Kamal::Configuration::PROXY_MINIMUM_VERSION}" end execute *KAMAL.proxy.start_or_run @@ -42,17 +40,17 @@ class Kamal::Cli::Proxy < Kamal::Cli::Base *options[:docker_options].map { |option| "--#{option}" } ] - default_boot_options = [ - *(KAMAL.config.proxy_publish_args(Kamal::Configuration::PROXY_HTTP_PORT, Kamal::Configuration::PROXY_HTTPS_PORT, nil)), - *(KAMAL.config.proxy_logging_args(Kamal::Configuration::PROXY_LOG_MAX_SIZE)), - ] + image = [ + options[:registry].presence, + options[:repository].presence || KAMAL.config.proxy_repository_name, + KAMAL.config.proxy_image_name + ].compact.join("/") - image = [ options[:registry].presence, options[:repository] || KAMAL.config.proxy_repository_name, KAMAL.config.proxy_image_name ].compact.join("/") image_version = options[:image_version] on(KAMAL.proxy_hosts) do |host| execute(*KAMAL.proxy.ensure_proxy_directory) - if boot_options != default_boot_options + if boot_options != KAMAL.config.proxy_default_boot_options upload! StringIO.new(boot_options.join(" ")), KAMAL.config.proxy_options_file else execute *KAMAL.proxy.reset_boot_options, raise_on_non_zero_exit: false diff --git a/lib/kamal/configuration.rb b/lib/kamal/configuration.rb index 1e9cd667..6e9b7b70 100644 --- a/lib/kamal/configuration.rb +++ b/lib/kamal/configuration.rb @@ -257,6 +257,13 @@ class Kamal::Configuration argumentize "--log-opt", "max-size=#{max_size}" if max_size.present? end + def proxy_default_boot_options + [ + *(KAMAL.config.proxy_publish_args(Kamal::Configuration::PROXY_HTTP_PORT, Kamal::Configuration::PROXY_HTTPS_PORT, nil)), + *(KAMAL.config.proxy_logging_args(Kamal::Configuration::PROXY_LOG_MAX_SIZE)) + ] + end + def proxy_options_default [ *proxy_publish_args(PROXY_HTTP_PORT, PROXY_HTTPS_PORT), *proxy_logging_args(PROXY_LOG_MAX_SIZE) ] end From 26b6c072f334ede8932a043262eb94677b491339 Mon Sep 17 00:00:00 2001 From: Donal McBreen Date: Tue, 15 Apr 2025 13:51:34 +0100 Subject: [PATCH 09/20] Add a writable proxy volume Maps in and external /home/kamal-proxy/.app-config volume that we can use to map files to the proxy. Can be used to store custom maintenance pages or SSL certificates. --- lib/kamal/commands/proxy.rb | 3 ++- lib/kamal/configuration.rb | 10 ++++++++++ test/cli/proxy_test.rb | 4 ++-- test/commands/proxy_test.rb | 4 ++-- 4 files changed, 16 insertions(+), 5 deletions(-) diff --git a/lib/kamal/commands/proxy.rb b/lib/kamal/commands/proxy.rb index 25fc1efe..c6b9929c 100644 --- a/lib/kamal/commands/proxy.rb +++ b/lib/kamal/commands/proxy.rb @@ -109,6 +109,7 @@ class Kamal::Commands::Proxy < Kamal::Commands::Base "--network", "kamal", "--detach", "--restart", "unless-stopped", - "--volume", "kamal-proxy-config:/home/kamal-proxy/.config/kamal-proxy" + "--volume", "kamal-proxy-config:/home/kamal-proxy/.config/kamal-proxy", + *config.proxy_app_config_volume.docker_args end end diff --git a/lib/kamal/configuration.rb b/lib/kamal/configuration.rb index 6e9b7b70..7772985b 100644 --- a/lib/kamal/configuration.rb +++ b/lib/kamal/configuration.rb @@ -300,6 +300,16 @@ class Kamal::Configuration File.join proxy_directory, "image_version" end + def proxy_app_config_directory + File.join proxy_directory, "app-config" + end + + def proxy_app_config_volume + Volume.new \ + host_path: proxy_app_config_directory, + container_path: "/home/kamal-proxy/.app-config" + end + def to_h { roles: role_names, diff --git a/test/cli/proxy_test.rb b/test/cli/proxy_test.rb index cd9cd5b9..ae717f70 100644 --- a/test/cli/proxy_test.rb +++ b/test/cli/proxy_test.rb @@ -56,12 +56,12 @@ class CliProxyTest < CliTestCase run_command("reboot", "-y").tap do |output| assert_match "docker container stop kamal-proxy on 1.1.1.1", output assert_match "docker container prune --force --filter label=org.opencontainers.image.title=kamal-proxy on 1.1.1.1", output - assert_match "echo $(cat .kamal/proxy/options 2> /dev/null || echo \"--publish 80:80 --publish 443:443 --log-opt max-size=10m\") $(cat .kamal/proxy/image 2> /dev/null || echo \"basecamp/kamal-proxy\"):$(cat .kamal/proxy/image_version 2> /dev/null || echo \"#{Kamal::Configuration::PROXY_MINIMUM_VERSION}\") | xargs docker run --name kamal-proxy --network kamal --detach --restart unless-stopped --volume kamal-proxy-config:/home/kamal-proxy/.config/kamal-proxy on 1.1.1.1", output + assert_match "echo $(cat .kamal/proxy/options 2> /dev/null || echo \"--publish 80:80 --publish 443:443 --log-opt max-size=10m\") $(cat .kamal/proxy/image 2> /dev/null || echo \"basecamp/kamal-proxy\"):$(cat .kamal/proxy/image_version 2> /dev/null || echo \"#{Kamal::Configuration::PROXY_MINIMUM_VERSION}\") | xargs docker run --name kamal-proxy --network kamal --detach --restart unless-stopped --volume kamal-proxy-config:/home/kamal-proxy/.config/kamal-proxy --volume $(pwd)/.kamal/proxy/app-config:/home/kamal-proxy/.app-config on 1.1.1.1", output assert_match "docker exec kamal-proxy kamal-proxy deploy app-web --target=\"abcdefabcdef:80\" --deploy-timeout=\"6s\" --drain-timeout=\"30s\" --buffer-requests --buffer-responses --log-request-header=\"Cache-Control\" --log-request-header=\"Last-Modified\" --log-request-header=\"User-Agent\" on 1.1.1.1", output assert_match "docker container stop kamal-proxy on 1.1.1.2", output assert_match "docker container prune --force --filter label=org.opencontainers.image.title=kamal-proxy on 1.1.1.2", output - assert_match "echo $(cat .kamal/proxy/options 2> /dev/null || echo \"--publish 80:80 --publish 443:443 --log-opt max-size=10m\") $(cat .kamal/proxy/image 2> /dev/null || echo \"basecamp/kamal-proxy\"):$(cat .kamal/proxy/image_version 2> /dev/null || echo \"#{Kamal::Configuration::PROXY_MINIMUM_VERSION}\") | xargs docker run --name kamal-proxy --network kamal --detach --restart unless-stopped --volume kamal-proxy-config:/home/kamal-proxy/.config/kamal-proxy on 1.1.1.2", output + assert_match "echo $(cat .kamal/proxy/options 2> /dev/null || echo \"--publish 80:80 --publish 443:443 --log-opt max-size=10m\") $(cat .kamal/proxy/image 2> /dev/null || echo \"basecamp/kamal-proxy\"):$(cat .kamal/proxy/image_version 2> /dev/null || echo \"#{Kamal::Configuration::PROXY_MINIMUM_VERSION}\") | xargs docker run --name kamal-proxy --network kamal --detach --restart unless-stopped --volume kamal-proxy-config:/home/kamal-proxy/.config/kamal-proxy --volume $(pwd)/.kamal/proxy/app-config:/home/kamal-proxy/.app-config on 1.1.1.2", output assert_match "docker exec kamal-proxy kamal-proxy deploy app-web --target=\"abcdefabcdef:80\" --deploy-timeout=\"6s\" --drain-timeout=\"30s\" --buffer-requests --buffer-responses --log-request-header=\"Cache-Control\" --log-request-header=\"Last-Modified\" --log-request-header=\"User-Agent\" on 1.1.1.2", output end end diff --git a/test/commands/proxy_test.rb b/test/commands/proxy_test.rb index 1ebd4009..c55fbdeb 100644 --- a/test/commands/proxy_test.rb +++ b/test/commands/proxy_test.rb @@ -15,7 +15,7 @@ class CommandsProxyTest < ActiveSupport::TestCase test "run" do assert_equal \ - "echo $(cat .kamal/proxy/options 2> /dev/null || echo \"--publish 80:80 --publish 443:443 --log-opt max-size=10m\") $(cat .kamal/proxy/image 2> /dev/null || echo \"basecamp/kamal-proxy\"):$(cat .kamal/proxy/image_version 2> /dev/null || echo \"#{Kamal::Configuration::PROXY_MINIMUM_VERSION}\") | xargs docker run --name kamal-proxy --network kamal --detach --restart unless-stopped --volume kamal-proxy-config:/home/kamal-proxy/.config/kamal-proxy", + "echo $(cat .kamal/proxy/options 2> /dev/null || echo \"--publish 80:80 --publish 443:443 --log-opt max-size=10m\") $(cat .kamal/proxy/image 2> /dev/null || echo \"basecamp/kamal-proxy\"):$(cat .kamal/proxy/image_version 2> /dev/null || echo \"#{Kamal::Configuration::PROXY_MINIMUM_VERSION}\") | xargs docker run --name kamal-proxy --network kamal --detach --restart unless-stopped --volume kamal-proxy-config:/home/kamal-proxy/.config/kamal-proxy --volume $(pwd)/.kamal/proxy/app-config:/home/kamal-proxy/.app-config", new_command.run.join(" ") end @@ -23,7 +23,7 @@ class CommandsProxyTest < ActiveSupport::TestCase @config.delete(:proxy) assert_equal \ - "echo $(cat .kamal/proxy/options 2> /dev/null || echo \"--publish 80:80 --publish 443:443 --log-opt max-size=10m\") $(cat .kamal/proxy/image 2> /dev/null || echo \"basecamp/kamal-proxy\"):$(cat .kamal/proxy/image_version 2> /dev/null || echo \"#{Kamal::Configuration::PROXY_MINIMUM_VERSION}\") | xargs docker run --name kamal-proxy --network kamal --detach --restart unless-stopped --volume kamal-proxy-config:/home/kamal-proxy/.config/kamal-proxy", + "echo $(cat .kamal/proxy/options 2> /dev/null || echo \"--publish 80:80 --publish 443:443 --log-opt max-size=10m\") $(cat .kamal/proxy/image 2> /dev/null || echo \"basecamp/kamal-proxy\"):$(cat .kamal/proxy/image_version 2> /dev/null || echo \"#{Kamal::Configuration::PROXY_MINIMUM_VERSION}\") | xargs docker run --name kamal-proxy --network kamal --detach --restart unless-stopped --volume kamal-proxy-config:/home/kamal-proxy/.config/kamal-proxy --volume $(pwd)/.kamal/proxy/app-config:/home/kamal-proxy/.app-config", new_command.run.join(" ") end From 354530f3b8a4d18443f71cb1a03091e93df95bef Mon Sep 17 00:00:00 2001 From: Donal McBreen Date: Wed, 16 Apr 2025 12:21:47 +0100 Subject: [PATCH 10/20] Maintenance mode Adds support for maintenance mode to Kamal. There are two new commands: - `kamal app maintenance` - puts the app in maintenance mode - `kamal app live` - puts the app back in live mode In maintenance mode, the kamal proxy will respond to requests with a 503 status code. It will use an error page built into kamal proxy. You can use your own error page by setting `error_pages_path` in the configuration. This will copy any 4xx.html or 5xx.html files from that page to a volume mounted into the proxy container. --- lib/kamal/cli/app.rb | 45 ++++++++++++++++-- .../cli/app/{prepare_assets.rb => assets.rb} | 2 +- lib/kamal/cli/app/boot.rb | 1 + lib/kamal/cli/app/error_pages.rb | 33 +++++++++++++ lib/kamal/commands/app.rb | 2 +- lib/kamal/commands/app/error_pages.rb | 9 ++++ lib/kamal/commands/app/proxy.rb | 12 +++++ lib/kamal/commands/proxy.rb | 2 +- lib/kamal/configuration.rb | 40 +++++++++++++--- .../configuration/docs/configuration.yml | 6 +++ lib/kamal/configuration/proxy.rb | 18 +++++++- test/cli/app_test.rb | 46 +++++++++++++++++-- test/cli/proxy_test.rb | 4 +- test/commands/app_test.rb | 24 ++++++++++ test/commands/proxy_test.rb | 4 +- test/configuration_test.rb | 9 ++++ test/fixtures/deploy_with_error_pages.yml | 11 +++++ test/integration/app_test.rb | 27 +++++++++++ .../deployer/app_with_roles/config/deploy.yml | 1 + .../app_with_roles/error_pages/503.html | 8 ++++ test/integration/integration_test.rb | 17 +++++-- test/test_helper.rb | 28 +++++++++-- 22 files changed, 319 insertions(+), 30 deletions(-) rename lib/kamal/cli/app/{prepare_assets.rb => assets.rb} (93%) create mode 100644 lib/kamal/cli/app/error_pages.rb create mode 100644 lib/kamal/commands/app/error_pages.rb create mode 100644 test/fixtures/deploy_with_error_pages.yml create mode 100644 test/integration/docker/deployer/app_with_roles/error_pages/503.html diff --git a/lib/kamal/cli/app.rb b/lib/kamal/cli/app.rb index 553ccbb2..d2f9c3f9 100644 --- a/lib/kamal/cli/app.rb +++ b/lib/kamal/cli/app.rb @@ -8,8 +8,10 @@ class Kamal::Cli::App < Kamal::Cli::Base # Assets are prepared in a separate step to ensure they are on all hosts before booting on(KAMAL.hosts) do + Kamal::Cli::App::ErrorPages.new(host, self).run + KAMAL.roles_on(host).each do |role| - Kamal::Cli::App::PrepareAssets.new(host, role, self).run + Kamal::Cli::App::Assets.new(host, role, self).run end end @@ -249,7 +251,37 @@ class Kamal::Cli::App < Kamal::Cli::Base stop remove_containers remove_images - remove_app_directory + remove_app_directories + end + end + + desc "live", "Set the app to live mode" + def live + with_lock do + on(KAMAL.proxy_hosts) do |host| + roles = KAMAL.roles_on(host) + + roles.each do |role| + execute *KAMAL.app(role: role, host: host).live if role.running_proxy? + end + end + end + end + + desc "maintenance", "Set the app to maintenance mode" + option :drain_timeout, type: :numeric, desc: "How long to allow in-flight requests to complete (defaults to drain_timeout from config)" + option :message, type: :string, desc: "Message to display to clients while stopped" + def maintenance + maintenance_options = { drain_timeout: options[:drain_timeout] || KAMAL.config.drain_timeout, message: options[:message] } + + with_lock do + on(KAMAL.proxy_hosts) do |host| + roles = KAMAL.roles_on(host) + + roles.each do |role| + execute *KAMAL.app(role: role, host: host).maintenance(**maintenance_options) if role.running_proxy? + end + end end end @@ -291,16 +323,19 @@ class Kamal::Cli::App < Kamal::Cli::Base end end - desc "remove_app_directory", "Remove the service directory from servers", hide: true - def remove_app_directory + desc "remove_app_directories", "Remove the app directories from servers", hide: true + def remove_app_directories with_lock do on(KAMAL.hosts) do |host| roles = KAMAL.roles_on(host) roles.each do |role| - execute *KAMAL.auditor.record("Removed #{KAMAL.config.app_directory} on all servers", role: role), verbosity: :debug + execute *KAMAL.auditor.record("Removed #{KAMAL.config.app_directory}", role: role), verbosity: :debug execute *KAMAL.server.remove_app_directory, raise_on_non_zero_exit: false end + + execute *KAMAL.auditor.record("Removed #{KAMAL.config.app_directory}"), verbosity: :debug + execute *KAMAL.app.remove_proxy_app_directory, raise_on_non_zero_exit: false end end end diff --git a/lib/kamal/cli/app/prepare_assets.rb b/lib/kamal/cli/app/assets.rb similarity index 93% rename from lib/kamal/cli/app/prepare_assets.rb rename to lib/kamal/cli/app/assets.rb index dd28fa41..2cab1423 100644 --- a/lib/kamal/cli/app/prepare_assets.rb +++ b/lib/kamal/cli/app/assets.rb @@ -1,4 +1,4 @@ -class Kamal::Cli::App::PrepareAssets +class Kamal::Cli::App::Assets attr_reader :host, :role, :sshkit delegate :execute, :capture_with_info, :info, to: :sshkit delegate :assets?, to: :role diff --git a/lib/kamal/cli/app/boot.rb b/lib/kamal/cli/app/boot.rb index 8ddcaac3..a7eb1c1a 100644 --- a/lib/kamal/cli/app/boot.rb +++ b/lib/kamal/cli/app/boot.rb @@ -70,6 +70,7 @@ class Kamal::Cli::App::Boot def stop_old_version(version) execute *app.stop(version: version), raise_on_non_zero_exit: false execute *app.clean_up_assets if assets? + execute *app.clean_up_error_pages if KAMAL.config.error_pages_path end def release_barrier diff --git a/lib/kamal/cli/app/error_pages.rb b/lib/kamal/cli/app/error_pages.rb new file mode 100644 index 00000000..664f7eff --- /dev/null +++ b/lib/kamal/cli/app/error_pages.rb @@ -0,0 +1,33 @@ +class Kamal::Cli::App::ErrorPages + ERROR_PAGES_GLOB = "{4??.html,5??.html}" + + attr_reader :host, :sshkit + delegate :upload!, :execute, to: :sshkit + + def initialize(host, sshkit) + @host = host + @sshkit = sshkit + end + + def run + if KAMAL.config.error_pages_path + with_error_pages_tmpdir do |local_error_pages_dir| + execute *KAMAL.app.create_error_pages_directory + upload! local_error_pages_dir, KAMAL.config.proxy_error_pages_directory, mode: "0700", recursive: true + end + end + end + + private + def with_error_pages_tmpdir + Dir.mktmpdir("kamal-error-pages") do |tmpdir| + error_pages_dir = File.join(tmpdir, KAMAL.config.version) + FileUtils.mkdir(error_pages_dir) + + if (files = Dir[File.join(KAMAL.config.error_pages_path, ERROR_PAGES_GLOB)]).any? + FileUtils.cp(files, error_pages_dir) + yield error_pages_dir + end + end + end +end diff --git a/lib/kamal/commands/app.rb b/lib/kamal/commands/app.rb index 6c4df0e4..d80684fb 100644 --- a/lib/kamal/commands/app.rb +++ b/lib/kamal/commands/app.rb @@ -1,5 +1,5 @@ class Kamal::Commands::App < Kamal::Commands::Base - include Assets, Containers, Execution, Images, Logging, Proxy + include Assets, Containers, ErrorPages, Execution, Images, Logging, Proxy ACTIVE_DOCKER_STATUSES = [ :running, :restarting ] diff --git a/lib/kamal/commands/app/error_pages.rb b/lib/kamal/commands/app/error_pages.rb new file mode 100644 index 00000000..d51dba3f --- /dev/null +++ b/lib/kamal/commands/app/error_pages.rb @@ -0,0 +1,9 @@ +module Kamal::Commands::App::ErrorPages + def create_error_pages_directory + make_directory(config.proxy_error_pages_directory) + end + + def clean_up_error_pages + [ :find, config.proxy_error_pages_directory, "-mindepth", "1", "-maxdepth", "1", "!", "-name", KAMAL.config.version, "-exec", "rm", "-rf", "{} +" ] + end +end diff --git a/lib/kamal/commands/app/proxy.rb b/lib/kamal/commands/app/proxy.rb index 777a4aaf..56d0ccea 100644 --- a/lib/kamal/commands/app/proxy.rb +++ b/lib/kamal/commands/app/proxy.rb @@ -9,6 +9,18 @@ module Kamal::Commands::App::Proxy proxy_exec :remove, role.container_prefix end + def live + proxy_exec :resume, role.container_prefix + end + + def maintenance(**options) + proxy_exec :stop, role.container_prefix, *role.proxy.stop_command_args(**options) + end + + def remove_proxy_app_directory + remove_directory config.proxy_app_directory + end + private def proxy_exec(*command) docker :exec, proxy_container_name, "kamal-proxy", *command diff --git a/lib/kamal/commands/proxy.rb b/lib/kamal/commands/proxy.rb index c6b9929c..658252de 100644 --- a/lib/kamal/commands/proxy.rb +++ b/lib/kamal/commands/proxy.rb @@ -110,6 +110,6 @@ class Kamal::Commands::Proxy < Kamal::Commands::Base "--detach", "--restart", "unless-stopped", "--volume", "kamal-proxy-config:/home/kamal-proxy/.config/kamal-proxy", - *config.proxy_app_config_volume.docker_args + *config.proxy_apps_volume.docker_args end end diff --git a/lib/kamal/configuration.rb b/lib/kamal/configuration.rb index 7772985b..bd22178f 100644 --- a/lib/kamal/configuration.rb +++ b/lib/kamal/configuration.rb @@ -105,6 +105,10 @@ class Kamal::Configuration raw_config.minimum_version end + def service_and_destination + [ service, destination ].compact.join("-") + end + def roles servers.roles end @@ -210,7 +214,7 @@ class Kamal::Configuration end def app_directory - File.join apps_directory, [ service, destination ].compact.join("-") + File.join apps_directory, service_and_destination end def env_directory @@ -229,6 +233,10 @@ class Kamal::Configuration raw_config.asset_path end + def error_pages_path + raw_config.error_pages_path + end + def env_tags @env_tags ||= if (tags = raw_config.env["tags"]) tags.collect { |name, config| Env::Tag.new(name, config: config, secrets: secrets) } @@ -300,14 +308,34 @@ class Kamal::Configuration File.join proxy_directory, "image_version" end - def proxy_app_config_directory - File.join proxy_directory, "app-config" + def proxy_apps_directory + File.join proxy_directory, "apps-config" end - def proxy_app_config_volume + def proxy_apps_container_directory + "/home/kamal-proxy/.apps-config" + end + + def proxy_apps_volume Volume.new \ - host_path: proxy_app_config_directory, - container_path: "/home/kamal-proxy/.app-config" + host_path: proxy_apps_directory, + container_path: proxy_apps_container_directory + end + + def proxy_app_directory + File.join proxy_apps_directory, service_and_destination + end + + def proxy_app_container_directory + File.join proxy_apps_container_directory, service_and_destination + end + + def proxy_error_pages_directory + File.join proxy_app_directory, "error_pages" + end + + def proxy_error_pages_container_directory + File.join proxy_app_container_directory, "error_pages" end def to_h diff --git a/lib/kamal/configuration/docs/configuration.yml b/lib/kamal/configuration/docs/configuration.yml index 6b059346..76f57eae 100644 --- a/lib/kamal/configuration/docs/configuration.yml +++ b/lib/kamal/configuration/docs/configuration.yml @@ -82,6 +82,12 @@ asset_path: /path/to/assets # See https://kamal-deploy.org/docs/hooks for more information: hooks_path: /user_home/kamal/hooks +# Error pages +# +# A directory relative to the app root to find error pages for the proxy to serve. +# Any files in the format 4xx.html or 5xx.html will be copied to the hosts. +error_pages_path: public + # Require destinations # # Whether deployments require a destination to be specified, defaults to `false`: diff --git a/lib/kamal/configuration/proxy.rb b/lib/kamal/configuration/proxy.rb index a8272e88..19bc25f7 100644 --- a/lib/kamal/configuration/proxy.rb +++ b/lib/kamal/configuration/proxy.rb @@ -44,7 +44,8 @@ class Kamal::Configuration::Proxy "forward-headers": proxy_config.dig("forward_headers"), "tls-redirect": proxy_config.dig("ssl_redirect"), "log-request-header": proxy_config.dig("logging", "request_headers") || DEFAULT_LOG_REQUEST_HEADERS, - "log-response-header": proxy_config.dig("logging", "response_headers") + "log-response-header": proxy_config.dig("logging", "response_headers"), + "error-pages": error_pages }.compact end @@ -52,6 +53,17 @@ class Kamal::Configuration::Proxy optionize ({ target: "#{target}:#{app_port}" }).merge(deploy_options), with: "=" end + def stop_options(drain_timeout: nil, message: nil) + { + "drain-timeout": seconds_duration(drain_timeout), + message: message + }.compact + end + + def stop_command_args(**options) + optionize stop_options(**options), with: "=" + end + def merge(other) self.class.new config: config, proxy_config: proxy_config.deep_merge(other.proxy_config) end @@ -60,4 +72,8 @@ class Kamal::Configuration::Proxy def seconds_duration(value) value ? "#{value}s" : nil end + + def error_pages + File.join config.proxy_error_pages_container_directory, config.version if config.error_pages_path + end end diff --git a/test/cli/app_test.rb b/test/cli/app_test.rb index 19190032..2ed00182 100644 --- a/test/cli/app_test.rb +++ b/test/cli/app_test.rb @@ -192,6 +192,19 @@ class CliAppTest < CliTestCase Thread.report_on_exception = true end + test "boot with error pages" do + with_error_pages(directory: "public") do + stub_running + run_command("boot", config: :with_error_pages).tap do |output| + assert_match /Uploading .*kamal-error-pages.*\/latest to \.kamal\/proxy\/apps-config\/app\/error_pages/, output + assert_match "docker tag dhh/app:latest dhh/app:latest", output + assert_match /docker run --detach --restart unless-stopped --name app-web-latest --network kamal --hostname 1.1.1.1-[0-9a-f]{12} /, output + assert_match "docker container ls --all --filter name=^app-web-123$ --quiet | xargs docker stop", output + assert_match "Running /usr/bin/env find .kamal/proxy/apps-config/app/error_pages -mindepth 1 -maxdepth 1 ! -name latest -exec rm -rf {} + on 1.1.1.1", output + end + end + end + test "start" do SSHKit::Backend::Abstract.any_instance.stubs(:capture_with_info).returns("999") # old version @@ -244,9 +257,11 @@ class CliAppTest < CliTestCase test "remove" do run_command("remove").tap do |output| - assert_match /#{Regexp.escape("sh -c 'docker ps --latest --quiet --filter label=service=app --filter label=destination= --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=destination= --filter label=role=web --filter status=running --filter status=restarting' | head -1 | xargs docker stop")}/, output - assert_match /#{Regexp.escape("docker container prune --force --filter label=service=app")}/, output - assert_match /#{Regexp.escape("docker image prune --all --force --filter label=service=app")}/, output + assert_match "sh -c 'docker ps --latest --quiet --filter label=service=app --filter label=destination= --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=destination= --filter label=role=web --filter status=running --filter status=restarting' | head -1 | xargs docker stop", output + assert_match "docker container prune --force --filter label=service=app", output + assert_match "docker image prune --all --force --filter label=service=app", output + assert_match "rm -r .kamal/apps/app on 1.1.1.1", output + assert_match "rm -r .kamal/proxy/apps-config/app on 1.1.1.1", output end end @@ -268,6 +283,13 @@ class CliAppTest < CliTestCase end end + test "remove_app_directories" do + run_command("remove_app_directories").tap do |output| + assert_match "rm -r .kamal/apps/app on 1.1.1.1", output + assert_match "rm -r .kamal/proxy/apps-config/app on 1.1.1.1", output + end + end + test "exec" do run_command("exec", "ruby -v").tap do |output| assert_match "docker run --rm --network kamal --env-file .kamal/apps/app/env/roles/web.env --log-opt max-size=\"10m\" dhh/app:latest ruby -v", output @@ -437,6 +459,24 @@ class CliAppTest < CliTestCase end end + test "live" do + run_command("live").tap do |output| + assert_match "docker exec kamal-proxy kamal-proxy resume app-web on 1.1.1.1", output + end + end + + test "maintenance" do + run_command("maintenance").tap do |output| + assert_match "docker exec kamal-proxy kamal-proxy stop app-web --drain-timeout=\"30s\" on 1.1.1.1", output + end + end + + test "maintenance with options" do + run_command("maintenance", "--message", "Hello", "--drain_timeout", "10").tap do |output| + assert_match "docker exec kamal-proxy kamal-proxy stop app-web --drain-timeout=\"10s\" --message=\"Hello\" on 1.1.1.1", output + end + end + private def run_command(*command, config: :with_accessories, host: "1.1.1.1", allow_execute_error: false) stdouted do diff --git a/test/cli/proxy_test.rb b/test/cli/proxy_test.rb index ae717f70..2ec4c726 100644 --- a/test/cli/proxy_test.rb +++ b/test/cli/proxy_test.rb @@ -56,12 +56,12 @@ class CliProxyTest < CliTestCase run_command("reboot", "-y").tap do |output| assert_match "docker container stop kamal-proxy on 1.1.1.1", output assert_match "docker container prune --force --filter label=org.opencontainers.image.title=kamal-proxy on 1.1.1.1", output - assert_match "echo $(cat .kamal/proxy/options 2> /dev/null || echo \"--publish 80:80 --publish 443:443 --log-opt max-size=10m\") $(cat .kamal/proxy/image 2> /dev/null || echo \"basecamp/kamal-proxy\"):$(cat .kamal/proxy/image_version 2> /dev/null || echo \"#{Kamal::Configuration::PROXY_MINIMUM_VERSION}\") | xargs docker run --name kamal-proxy --network kamal --detach --restart unless-stopped --volume kamal-proxy-config:/home/kamal-proxy/.config/kamal-proxy --volume $(pwd)/.kamal/proxy/app-config:/home/kamal-proxy/.app-config on 1.1.1.1", output + assert_match "echo $(cat .kamal/proxy/options 2> /dev/null || echo \"--publish 80:80 --publish 443:443 --log-opt max-size=10m\") $(cat .kamal/proxy/image 2> /dev/null || echo \"basecamp/kamal-proxy\"):$(cat .kamal/proxy/image_version 2> /dev/null || echo \"#{Kamal::Configuration::PROXY_MINIMUM_VERSION}\") | xargs docker run --name kamal-proxy --network kamal --detach --restart unless-stopped --volume kamal-proxy-config:/home/kamal-proxy/.config/kamal-proxy --volume $(pwd)/.kamal/proxy/apps-config:/home/kamal-proxy/.apps-config on 1.1.1.1", output assert_match "docker exec kamal-proxy kamal-proxy deploy app-web --target=\"abcdefabcdef:80\" --deploy-timeout=\"6s\" --drain-timeout=\"30s\" --buffer-requests --buffer-responses --log-request-header=\"Cache-Control\" --log-request-header=\"Last-Modified\" --log-request-header=\"User-Agent\" on 1.1.1.1", output assert_match "docker container stop kamal-proxy on 1.1.1.2", output assert_match "docker container prune --force --filter label=org.opencontainers.image.title=kamal-proxy on 1.1.1.2", output - assert_match "echo $(cat .kamal/proxy/options 2> /dev/null || echo \"--publish 80:80 --publish 443:443 --log-opt max-size=10m\") $(cat .kamal/proxy/image 2> /dev/null || echo \"basecamp/kamal-proxy\"):$(cat .kamal/proxy/image_version 2> /dev/null || echo \"#{Kamal::Configuration::PROXY_MINIMUM_VERSION}\") | xargs docker run --name kamal-proxy --network kamal --detach --restart unless-stopped --volume kamal-proxy-config:/home/kamal-proxy/.config/kamal-proxy --volume $(pwd)/.kamal/proxy/app-config:/home/kamal-proxy/.app-config on 1.1.1.2", output + assert_match "echo $(cat .kamal/proxy/options 2> /dev/null || echo \"--publish 80:80 --publish 443:443 --log-opt max-size=10m\") $(cat .kamal/proxy/image 2> /dev/null || echo \"basecamp/kamal-proxy\"):$(cat .kamal/proxy/image_version 2> /dev/null || echo \"#{Kamal::Configuration::PROXY_MINIMUM_VERSION}\") | xargs docker run --name kamal-proxy --network kamal --detach --restart unless-stopped --volume kamal-proxy-config:/home/kamal-proxy/.config/kamal-proxy --volume $(pwd)/.kamal/proxy/apps-config:/home/kamal-proxy/.apps-config on 1.1.1.2", output assert_match "docker exec kamal-proxy kamal-proxy deploy app-web --target=\"abcdefabcdef:80\" --deploy-timeout=\"6s\" --drain-timeout=\"30s\" --buffer-requests --buffer-responses --log-request-header=\"Cache-Control\" --log-request-header=\"Last-Modified\" --log-request-header=\"User-Agent\" on 1.1.1.2", output end end diff --git a/test/commands/app_test.rb b/test/commands/app_test.rb index 75241597..33418a00 100644 --- a/test/commands/app_test.rb +++ b/test/commands/app_test.rb @@ -497,6 +497,30 @@ class CommandsAppTest < ActiveSupport::TestCase ], new_command(asset_path: "/public/assets").clean_up_assets end + test "live" do + assert_equal \ + "docker exec kamal-proxy kamal-proxy resume app-web", + new_command.live.join(" ") + end + + test "maintenance" do + assert_equal \ + "docker exec kamal-proxy kamal-proxy stop app-web", + new_command.maintenance.join(" ") + end + + test "maintenance with options" do + assert_equal \ + "docker exec kamal-proxy kamal-proxy stop app-web --drain-timeout=\"10s\" --message=\"Hi\"", + new_command.maintenance(drain_timeout: 10, message: "Hi").join(" ") + end + + test "remove_proxy_app_directory" do + assert_equal \ + "rm -r .kamal/proxy/apps-config/app", + new_command.remove_proxy_app_directory.join(" ") + end + private def new_command(role: "web", host: "1.1.1.1", **additional_config) config = Kamal::Configuration.new(@config.merge(additional_config), destination: @destination, version: "999") diff --git a/test/commands/proxy_test.rb b/test/commands/proxy_test.rb index c55fbdeb..21863fc6 100644 --- a/test/commands/proxy_test.rb +++ b/test/commands/proxy_test.rb @@ -15,7 +15,7 @@ class CommandsProxyTest < ActiveSupport::TestCase test "run" do assert_equal \ - "echo $(cat .kamal/proxy/options 2> /dev/null || echo \"--publish 80:80 --publish 443:443 --log-opt max-size=10m\") $(cat .kamal/proxy/image 2> /dev/null || echo \"basecamp/kamal-proxy\"):$(cat .kamal/proxy/image_version 2> /dev/null || echo \"#{Kamal::Configuration::PROXY_MINIMUM_VERSION}\") | xargs docker run --name kamal-proxy --network kamal --detach --restart unless-stopped --volume kamal-proxy-config:/home/kamal-proxy/.config/kamal-proxy --volume $(pwd)/.kamal/proxy/app-config:/home/kamal-proxy/.app-config", + "echo $(cat .kamal/proxy/options 2> /dev/null || echo \"--publish 80:80 --publish 443:443 --log-opt max-size=10m\") $(cat .kamal/proxy/image 2> /dev/null || echo \"basecamp/kamal-proxy\"):$(cat .kamal/proxy/image_version 2> /dev/null || echo \"#{Kamal::Configuration::PROXY_MINIMUM_VERSION}\") | xargs docker run --name kamal-proxy --network kamal --detach --restart unless-stopped --volume kamal-proxy-config:/home/kamal-proxy/.config/kamal-proxy --volume $(pwd)/.kamal/proxy/apps-config:/home/kamal-proxy/.apps-config", new_command.run.join(" ") end @@ -23,7 +23,7 @@ class CommandsProxyTest < ActiveSupport::TestCase @config.delete(:proxy) assert_equal \ - "echo $(cat .kamal/proxy/options 2> /dev/null || echo \"--publish 80:80 --publish 443:443 --log-opt max-size=10m\") $(cat .kamal/proxy/image 2> /dev/null || echo \"basecamp/kamal-proxy\"):$(cat .kamal/proxy/image_version 2> /dev/null || echo \"#{Kamal::Configuration::PROXY_MINIMUM_VERSION}\") | xargs docker run --name kamal-proxy --network kamal --detach --restart unless-stopped --volume kamal-proxy-config:/home/kamal-proxy/.config/kamal-proxy --volume $(pwd)/.kamal/proxy/app-config:/home/kamal-proxy/.app-config", + "echo $(cat .kamal/proxy/options 2> /dev/null || echo \"--publish 80:80 --publish 443:443 --log-opt max-size=10m\") $(cat .kamal/proxy/image 2> /dev/null || echo \"basecamp/kamal-proxy\"):$(cat .kamal/proxy/image_version 2> /dev/null || echo \"#{Kamal::Configuration::PROXY_MINIMUM_VERSION}\") | xargs docker run --name kamal-proxy --network kamal --detach --restart unless-stopped --volume kamal-proxy-config:/home/kamal-proxy/.config/kamal-proxy --volume $(pwd)/.kamal/proxy/apps-config:/home/kamal-proxy/.apps-config", new_command.run.join(" ") end diff --git a/test/configuration_test.rb b/test/configuration_test.rb index c1aaa697..04be59ea 100644 --- a/test/configuration_test.rb +++ b/test/configuration_test.rb @@ -395,4 +395,13 @@ class ConfigurationTest < ActiveSupport::TestCase assert_equal "Different roles can't share the same host for SSL: foo.example.com", exception.message end + + test "proxy directories" do + assert_equal ".kamal/proxy/apps-config", @config.proxy_apps_directory + assert_equal "/home/kamal-proxy/.apps-config", @config.proxy_apps_container_directory + assert_equal ".kamal/proxy/apps-config/app", @config.proxy_app_directory + assert_equal "/home/kamal-proxy/.apps-config/app", @config.proxy_app_container_directory + assert_equal ".kamal/proxy/apps-config/app/error_pages", @config.proxy_error_pages_directory + assert_equal "/home/kamal-proxy/.apps-config/app/error_pages", @config.proxy_error_pages_container_directory + end end diff --git a/test/fixtures/deploy_with_error_pages.yml b/test/fixtures/deploy_with_error_pages.yml new file mode 100644 index 00000000..7806c54c --- /dev/null +++ b/test/fixtures/deploy_with_error_pages.yml @@ -0,0 +1,11 @@ +service: app +image: dhh/app +servers: + - "1.1.1.1" + - "1.1.1.2" +registry: + username: user + password: pw +builder: + arch: amd64 +error_pages_path: public diff --git a/test/integration/app_test.rb b/test/integration/app_test.rb index f128054b..29d122f3 100644 --- a/test/integration/app_test.rb +++ b/test/integration/app_test.rb @@ -48,9 +48,36 @@ class AppTest < IntegrationTest assert_match "App Host: vm1", exec_output assert_match /1 root 0:\d\d nginx/, exec_output + kamal :app, :maintenance + assert_app_in_maintenance + + kamal :app, :live + assert_app_is_up + kamal :app, :remove assert_app_not_found assert_app_directory_removed end + + test "custom error pages" do + @app = "app_with_roles" + + kamal :deploy + assert_app_is_up + + kamal :app, :maintenance + assert_app_in_maintenance message: "Custom Maintenance Page" + + kamal :app, :live + kamal :app, :maintenance, "--message", "\"Testing Maintence Mode\"" + assert_app_in_maintenance message: "Custom Maintenance Page: Testing Maintence Mode" + + second_version = update_app_rev + + kamal :redeploy + + kamal :app, :maintenance + assert_app_in_maintenance message: "Custom Maintenance Page" + end end 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 28449e0e..6c5ef9f4 100644 --- a/test/integration/docker/deployer/app_with_roles/config/deploy.yml +++ b/test/integration/docker/deployer/app_with_roles/config/deploy.yml @@ -37,6 +37,7 @@ proxy: - X-Request-Start asset_path: /usr/share/nginx/html/versions +error_pages_path: error_pages registry: server: registry:4443 diff --git a/test/integration/docker/deployer/app_with_roles/error_pages/503.html b/test/integration/docker/deployer/app_with_roles/error_pages/503.html new file mode 100644 index 00000000..3f844fb8 --- /dev/null +++ b/test/integration/docker/deployer/app_with_roles/error_pages/503.html @@ -0,0 +1,8 @@ + + + 503 Service Interrupted + + +

Custom Maintenance Page: {{ .Message }}

+ + diff --git a/test/integration/integration_test.rb b/test/integration/integration_test.rb index e6e5520d..631d8fc4 100644 --- a/test/integration/integration_test.rb +++ b/test/integration/integration_test.rb @@ -45,15 +45,22 @@ class IntegrationTest < ActiveSupport::TestCase end def assert_app_is_down - response = app_response - debug_response_code(response, "502") - assert_equal "502", response.code + assert_app_error_code("502") + end + + def assert_app_in_maintenance(message: nil) + assert_app_error_code("503", message: message) end def assert_app_not_found + assert_app_error_code("404") + end + + def assert_app_error_code(code, message: nil) response = app_response - debug_response_code(response, "404") - assert_equal "404", response.code + debug_response_code(response, code) + assert_equal code, response.code + assert_match message, response.body.strip if message end def assert_app_is_up(version: nil, app: @app) diff --git a/test/test_helper.rb b/test/test_helper.rb index e77a0a8a..1704fa29 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -58,9 +58,7 @@ class ActiveSupport::TestCase def setup_test_secrets(**files) @original_pwd = Dir.pwd @secrets_tmpdir = Dir.mktmpdir - fixtures_dup = File.join(@secrets_tmpdir, "test") - FileUtils.mkdir_p(fixtures_dup) - FileUtils.cp_r("test/fixtures/", fixtures_dup) + copy_fixtures(@secrets_tmpdir) Dir.chdir(@secrets_tmpdir) FileUtils.mkdir_p(".kamal") @@ -75,6 +73,30 @@ class ActiveSupport::TestCase Dir.chdir(@original_pwd) FileUtils.rm_rf(@secrets_tmpdir) end + + def with_error_pages(directory:) + error_pages_tmpdir = Dir.mktmpdir + + Dir.mktmpdir do |tmpdir| + copy_fixtures(tmpdir) + + Dir.chdir(tmpdir) do + FileUtils.mkdir_p(directory) + Dir.chdir(directory) do + File.write("404.html", "404 page") + File.write("503.html", "503 page") + end + + yield + end + end + end + + def copy_fixtures(to_dir) + new_test_dir = File.join(to_dir, "test") + FileUtils.mkdir_p(new_test_dir) + FileUtils.cp_r("test/fixtures/", new_test_dir) + end end class SecretAdapterTestCase < ActiveSupport::TestCase From 55ec6ca0a6b43b925d520195881298d89b5d0ab4 Mon Sep 17 00:00:00 2001 From: Donal McBreen Date: Thu, 17 Apr 2025 11:40:03 +0100 Subject: [PATCH 11/20] Allow accessory only configurations If there are accessories defined in the configuration, we'll not require servers to be defined as well. This allows for accessory-only configurations which allows you to run external images with kamal-proxy for zero-downtime deployments. We don't manage image cleanup for accessories though so the user will need to deal with that themselves. --- lib/kamal/configuration.rb | 32 ++++++---- lib/kamal/configuration/servers.rb | 9 ++- lib/kamal/configuration/validator/servers.rb | 2 +- test/configuration_test.rb | 27 ++++++-- test/integration/accessory_test.rb | 47 ++++++++++++++ .../config/deploy.yml | 8 +-- test/integration/proxied_accessory_test.rb | 63 ------------------- 7 files changed, 99 insertions(+), 89 deletions(-) delete mode 100644 test/integration/proxied_accessory_test.rb diff --git a/lib/kamal/configuration.rb b/lib/kamal/configuration.rb index bd22178f..b291dfde 100644 --- a/lib/kamal/configuration.rb +++ b/lib/kamal/configuration.rb @@ -149,8 +149,12 @@ class Kamal::Configuration proxy_roles.flat_map(&:name) end + def proxy_accessories + accessories.select(&:running_proxy?) + end + def proxy_hosts - proxy_roles.flat_map(&:hosts).uniq + (proxy_roles.flat_map(&:hosts) + proxy_accessories.flat_map(&:hosts)).uniq end def repository @@ -367,22 +371,26 @@ class Kamal::Configuration end def ensure_required_keys_present - %i[ service image registry servers ].each do |key| + %i[ service image registry ].each do |key| raise Kamal::ConfigurationError, "Missing required configuration for #{key}" unless raw_config[key].present? end - unless role(primary_role_name).present? - raise Kamal::ConfigurationError, "The primary_role #{primary_role_name} isn't defined" - end + if raw_config.servers.nil? + raise Kamal::ConfigurationError, "No servers or accessories specified" unless raw_config.accessories.present? + else + 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 Kamal::ConfigurationError, "No servers specified for the #{primary_role.name} primary_role" - end + if primary_role.hosts.empty? + 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 Kamal::ConfigurationError, "No servers specified for the #{role.name} role. You can ignore this with allow_empty_roles: true" + unless allow_empty_roles? + roles.each do |role| + if role.hosts.empty? + raise Kamal::ConfigurationError, "No servers specified for the #{role.name} role. You can ignore this with allow_empty_roles: true" + end end end end diff --git a/lib/kamal/configuration/servers.rb b/lib/kamal/configuration/servers.rb index ef5e7942..d0ccffd1 100644 --- a/lib/kamal/configuration/servers.rb +++ b/lib/kamal/configuration/servers.rb @@ -13,6 +13,13 @@ class Kamal::Configuration::Servers private def role_names - servers_config.is_a?(Array) ? [ "web" ] : servers_config.keys.sort + case servers_config + when Array + [ "web" ] + when NilClass + [] + else + servers_config.keys.sort + end end end diff --git a/lib/kamal/configuration/validator/servers.rb b/lib/kamal/configuration/validator/servers.rb index 5a734c78..7e80de95 100644 --- a/lib/kamal/configuration/validator/servers.rb +++ b/lib/kamal/configuration/validator/servers.rb @@ -1,6 +1,6 @@ class Kamal::Configuration::Validator::Servers < Kamal::Configuration::Validator def validate! - validate_type! config, Array, Hash + validate_type! config, Array, Hash, NilClass validate_servers! config if config.is_a?(Array) end diff --git a/test/configuration_test.rb b/test/configuration_test.rb index 04be59ea..571ed0a7 100644 --- a/test/configuration_test.rb +++ b/test/configuration_test.rb @@ -30,7 +30,7 @@ class ConfigurationTest < ActiveSupport::TestCase %i[ service image registry ].each do |key| test "#{key} config required" do assert_raise(Kamal::ConfigurationError) do - Kamal::Configuration.new @deploy.tap { _1.delete key } + Kamal::Configuration.new @deploy.tap { |config| config.delete key } end end end @@ -38,21 +38,36 @@ class ConfigurationTest < ActiveSupport::TestCase %w[ username password ].each do |key| test "registry #{key} required" do assert_raise(Kamal::ConfigurationError) do - Kamal::Configuration.new @deploy.tap { _1[:registry].delete key } + Kamal::Configuration.new @deploy.tap { |config| config[:registry].delete key } end end end test "service name valid" do assert_nothing_raised do - Kamal::Configuration.new(@deploy.tap { _1[:service] = "hey-app1_primary" }) - Kamal::Configuration.new(@deploy.tap { _1[:service] = "MyApp" }) + Kamal::Configuration.new(@deploy.tap { |config| config[:service] = "hey-app1_primary" }) + Kamal::Configuration.new(@deploy.tap { |config| config[:service] = "MyApp" }) end end test "service name invalid" do assert_raise(Kamal::ConfigurationError) do - Kamal::Configuration.new @deploy.tap { _1[:service] = "app.com" } + Kamal::Configuration.new @deploy.tap { |config| config[:service] = "app.com" } + end + end + + test "servers required" do + assert_raise(Kamal::ConfigurationError) do + Kamal::Configuration.new @deploy.tap { |config| config.delete(:servers) } + end + end + + test "servers not required with accessories" do + assert_nothing_raised do + @deploy.delete(:servers) + @deploy[:accessories] = { "foo" => { "image" => "foo/bar", "host" => "1.1.1.1" } } + + Kamal::Configuration.new(@deploy) end end @@ -250,7 +265,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(Kamal::ConfigurationError) do + assert_raises(ArgumentError, "You must specify a destination") do config = Kamal::Configuration.create_from config_file: dest_config_file end diff --git a/test/integration/accessory_test.rb b/test/integration/accessory_test.rb index bc2fb378..0ed7a7b9 100644 --- a/test/integration/accessory_test.rb +++ b/test/integration/accessory_test.rb @@ -21,6 +21,32 @@ class AccessoryTest < IntegrationTest assert_accessory_not_running :busybox end + test "proxied: boot, stop, start, restart, logs, remove" do + @app = "app_with_proxied_accessory" + + kamal :proxy, :boot + + kamal :accessory, :boot, :netcat + assert_accessory_running :netcat + assert_netcat_is_up + + kamal :accessory, :stop, :netcat + assert_accessory_not_running :netcat + assert_netcat_not_found + + kamal :accessory, :start, :netcat + assert_accessory_running :netcat + assert_netcat_is_up + + kamal :accessory, :restart, :netcat + assert_accessory_running :netcat + assert_netcat_is_up + + kamal :accessory, :remove, :netcat, "-y" + assert_accessory_not_running :netcat + assert_netcat_not_found + end + private def assert_accessory_running(name) assert_match /registry:4443\/busybox:1.36.0 "sh -c 'echo \\"Start/, accessory_details(name) @@ -33,4 +59,25 @@ class AccessoryTest < IntegrationTest def accessory_details(name) kamal :accessory, :details, name, capture: true end + + def assert_netcat_is_up + response = netcat_response + debug_response_code(response, "200") + assert_equal "200", response.code + end + + def assert_netcat_not_found + response = netcat_response + debug_response_code(response, "404") + assert_equal "404", response.code + end + + def netcat_response + uri = URI.parse("http://127.0.0.1:12345/up") + http = Net::HTTP.new(uri.host, uri.port) + request = Net::HTTP::Get.new(uri) + request["Host"] = "netcat" + + http.request(request) + end end diff --git a/test/integration/docker/deployer/app_with_proxied_accessory/config/deploy.yml b/test/integration/docker/deployer/app_with_proxied_accessory/config/deploy.yml index ae4e217b..e904544c 100644 --- a/test/integration/docker/deployer/app_with_proxied_accessory/config/deploy.yml +++ b/test/integration/docker/deployer/app_with_proxied_accessory/config/deploy.yml @@ -1,7 +1,5 @@ service: app_with_proxied_accessory image: app_with_proxied_accessory -servers: - - vm1 env: clear: CLEAR_TOKEN: 4321 @@ -24,15 +22,13 @@ accessories: service: custom-busybox image: registry:4443/busybox:1.36.0 cmd: sh -c 'echo "Starting busybox..."; trap exit term; while true; do sleep 1; done' - roles: - - web + host: vm1 netcat: service: netcat image: registry:4443/busybox:1.36.0 cmd: > sh -c 'echo "Starting netcat..."; while true; do echo -e "HTTP/1.1 200 OK\r\nContent-Length: 11\r\n\r\nHello Ruby" | nc -l -p 80; done' - roles: - - web + host: vm1 port: 12345:80 proxy: host: netcat diff --git a/test/integration/proxied_accessory_test.rb b/test/integration/proxied_accessory_test.rb deleted file mode 100644 index 10f3cff8..00000000 --- a/test/integration/proxied_accessory_test.rb +++ /dev/null @@ -1,63 +0,0 @@ -require_relative "integration_test" - -class ProxiedAccessoryTest < IntegrationTest - test "boot, stop, start, restart, logs, remove" do - @app = "app_with_proxied_accessory" - - kamal :deploy - - kamal :accessory, :boot, :netcat - assert_accessory_running :netcat - assert_netcat_is_up - - kamal :accessory, :stop, :netcat - assert_accessory_not_running :netcat - assert_netcat_not_found - - kamal :accessory, :start, :netcat - assert_accessory_running :netcat - assert_netcat_is_up - - kamal :accessory, :restart, :netcat - assert_accessory_running :netcat - assert_netcat_is_up - - kamal :accessory, :remove, :netcat, "-y" - assert_accessory_not_running :netcat - assert_netcat_not_found - end - - private - def assert_accessory_running(name) - assert_match /registry:4443\/busybox:1.36.0 "sh -c 'echo \\"Start/, accessory_details(name) - end - - def assert_accessory_not_running(name) - assert_no_match /registry:4443\/busybox:1.36.0 "sh -c 'echo \\"Start/, accessory_details(name) - end - - def accessory_details(name) - kamal :accessory, :details, name, capture: true - end - - def assert_netcat_is_up - response = netcat_response - debug_response_code(response, "200") - assert_equal "200", response.code - end - - def assert_netcat_not_found - response = netcat_response - debug_response_code(response, "404") - assert_equal "404", response.code - end - - def netcat_response - uri = URI.parse("http://127.0.0.1:12345/up") - http = Net::HTTP.new(uri.host, uri.port) - request = Net::HTTP::Get.new(uri) - request["Host"] = "netcat" - - http.request(request) - end -end From 521425c3867ff6f90261d1260c48cca8d839d3af Mon Sep 17 00:00:00 2001 From: Donal McBreen Date: Thu, 17 Apr 2025 13:18:46 +0100 Subject: [PATCH 12/20] Rely on semver for version checks --- lib/kamal/cli/proxy.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/kamal/cli/proxy.rb b/lib/kamal/cli/proxy.rb index 05c43d60..ad189b8c 100644 --- a/lib/kamal/cli/proxy.rb +++ b/lib/kamal/cli/proxy.rb @@ -13,7 +13,7 @@ class Kamal::Cli::Proxy < Kamal::Cli::Base version = capture_with_info(*KAMAL.proxy.version).strip.presence - if version && version != "next" && Kamal::Utils.older_version?(version, Kamal::Configuration::PROXY_MINIMUM_VERSION) + if version && Kamal::Utils.older_version?(version, Kamal::Configuration::PROXY_MINIMUM_VERSION) raise "kamal-proxy version #{version} is too old, run `kamal proxy reboot` in order to update to at least #{Kamal::Configuration::PROXY_MINIMUM_VERSION}" end execute *KAMAL.proxy.start_or_run From 91f01ece1bc37eebc1a0238e43823f4ebe822789 Mon Sep 17 00:00:00 2001 From: Donal McBreen Date: Thu, 17 Apr 2025 13:31:18 +0100 Subject: [PATCH 13/20] Ensure that the restart policy is unless-stopped No other restart policy makes sense to don't let it be changed. Fixes: https://github.com/basecamp/kamal/issues/749 --- lib/kamal/configuration/validator.rb | 6 ++++++ lib/kamal/configuration/validator/accessory.rb | 2 ++ lib/kamal/configuration/validator/role.rb | 1 + test/configuration/accessory_test.rb | 8 ++++++++ test/configuration/role_test.rb | 8 ++++++++ 5 files changed, 25 insertions(+) diff --git a/lib/kamal/configuration/validator.rb b/lib/kamal/configuration/validator.rb index 0d46e4ae..e67a4579 100644 --- a/lib/kamal/configuration/validator.rb +++ b/lib/kamal/configuration/validator.rb @@ -168,4 +168,10 @@ class Kamal::Configuration::Validator unknown_keys.reject! { |key| extension?(key) } if allow_extensions? unknown_keys_error unknown_keys if unknown_keys.present? end + + def validate_docker_options!(options) + if options + error "Cannot set restart policy in docker options, unless-stopped is required" if options["restart"] + end + end end diff --git a/lib/kamal/configuration/validator/accessory.rb b/lib/kamal/configuration/validator/accessory.rb index 33245e24..8b3d5b71 100644 --- a/lib/kamal/configuration/validator/accessory.rb +++ b/lib/kamal/configuration/validator/accessory.rb @@ -5,5 +5,7 @@ class Kamal::Configuration::Validator::Accessory < Kamal::Configuration::Validat if (config.keys & [ "host", "hosts", "roles" ]).size != 1 error "specify one of `host`, `hosts` or `roles`" end + + validate_docker_options!(config["options"]) end end diff --git a/lib/kamal/configuration/validator/role.rb b/lib/kamal/configuration/validator/role.rb index de7a1969..e631746a 100644 --- a/lib/kamal/configuration/validator/role.rb +++ b/lib/kamal/configuration/validator/role.rb @@ -6,6 +6,7 @@ class Kamal::Configuration::Validator::Role < Kamal::Configuration::Validator validate_servers!(config) else super + validate_docker_options!(config["options"]) end end end diff --git a/test/configuration/accessory_test.rb b/test/configuration/accessory_test.rb index fc01dc90..a652c2ac 100644 --- a/test/configuration/accessory_test.rb +++ b/test/configuration/accessory_test.rb @@ -187,4 +187,12 @@ class ConfigurationAccessoryTest < ActiveSupport::TestCase assert @config.accessory(:monitoring).running_proxy? assert_equal [ "monitoring.example.com" ], @config.accessory(:monitoring).proxy.hosts end + + test "can't set restart in options" do + @deploy[:accessories]["mysql"]["options"] = { "restart" => "always" } + + assert_raises Kamal::ConfigurationError, "servers/workers: Cannot set restart policy in docker options, unless-stopped is required" do + Kamal::Configuration.new(@deploy) + end + end end diff --git a/test/configuration/role_test.rb b/test/configuration/role_test.rb index c7a94de0..c7b650b7 100644 --- a/test/configuration/role_test.rb +++ b/test/configuration/role_test.rb @@ -258,6 +258,14 @@ class ConfigurationRoleTest < ActiveSupport::TestCase assert_equal "18s", config_with_roles.role(:workers).proxy.deploy_options[:"target-timeout"] end + test "can't set restart in options" do + @deploy_with_roles[:servers]["workers"]["options"] = { "restart" => "always" } + + assert_raises Kamal::ConfigurationError, "servers/workers: Cannot set restart policy in docker options, unless-stopped is required" do + Kamal::Configuration.new(@deploy_with_roles) + end + end + private def config Kamal::Configuration.new(@deploy) From 098c937bab84ea9e4b62fc13e0fd9b181bf036a8 Mon Sep 17 00:00:00 2001 From: Donal McBreen Date: Fri, 18 Apr 2025 09:52:22 +0100 Subject: [PATCH 14/20] Move docker login into build command We only need to run the docker login commands for pushing and pulling images. So let's move the logins into those commands. This ensures we are logged in when calling `kamal build` commands directly. Fixes: https://github.com/basecamp/kamal/issues/919 --- lib/kamal/cli/build.rb | 16 ++++++++++++++++ lib/kamal/cli/main.rb | 5 +---- test/cli/build_test.rb | 5 +++++ test/cli/main_test.rb | 18 +++++------------- 4 files changed, 27 insertions(+), 17 deletions(-) diff --git a/lib/kamal/cli/build.rb b/lib/kamal/cli/build.rb index 0187e686..310c2689 100644 --- a/lib/kamal/cli/build.rb +++ b/lib/kamal/cli/build.rb @@ -15,6 +15,8 @@ class Kamal::Cli::Build < Kamal::Cli::Base cli = self ensure_docker_installed + login_to_registry_locally + run_hook "pre-build" uncommitted_changes = Kamal::Git.uncommitted_changes @@ -61,6 +63,8 @@ class Kamal::Cli::Build < Kamal::Cli::Base desc "pull", "Pull app image from registry onto servers" def pull + login_to_registry_remotely + if (first_hosts = mirror_hosts).any? #  Pull on a single host per mirror first to seed them say "Pulling image on #{first_hosts.join(", ")} to seed the #{"mirror".pluralize(first_hosts.count)}...", :magenta @@ -181,4 +185,16 @@ class Kamal::Cli::Build < Kamal::Cli::Base execute *KAMAL.builder.validate_image end end + + def login_to_registry_locally + run_locally do + execute *KAMAL.registry.login + end + end + + def login_to_registry_remotely + on(KAMAL.hosts) do + execute *KAMAL.registry.login + end + end end diff --git a/lib/kamal/cli/main.rb b/lib/kamal/cli/main.rb index 2fae36e8..cd28ab35 100644 --- a/lib/kamal/cli/main.rb +++ b/lib/kamal/cli/main.rb @@ -20,9 +20,6 @@ class Kamal::Cli::Main < Kamal::Cli::Base runtime = print_runtime do invoke_options = deploy_options - say "Log into image registry...", :magenta - invoke "kamal:cli:registry:login", [], invoke_options.merge(skip_local: options[:skip_push]) - if options[:skip_push] say "Pull app image...", :magenta invoke "kamal:cli:build:pull", [], invoke_options @@ -52,7 +49,7 @@ class Kamal::Cli::Main < Kamal::Cli::Base run_hook "post-deploy", secrets: true, runtime: runtime.round.to_s end - desc "redeploy", "Deploy app to servers without bootstrapping servers, starting kamal-proxy, pruning, and registry login" + desc "redeploy", "Deploy app to servers without bootstrapping servers, starting kamal-proxy and pruning" option :skip_push, aliases: "-P", type: :boolean, default: false, desc: "Skip image build and push" def redeploy runtime = print_runtime do diff --git a/test/cli/build_test.rb b/test/cli/build_test.rb index 8690e00b..f2dba886 100644 --- a/test/cli/build_test.rb +++ b/test/cli/build_test.rb @@ -57,6 +57,7 @@ class CliBuildTest < CliTestCase stub_setup SSHKit::Backend::Abstract.any_instance.expects(:execute).with(:docker, "--version", "&&", :docker, :buildx, "version") + SSHKit::Backend::Abstract.any_instance.stubs(:execute).with { |*args| args[0..1] == [ :docker, :login ] } SSHKit::Backend::Abstract.any_instance.expects(:execute) .with(:git, "-C", "#{Dir.tmpdir}/kamal-clones/app-#{pwd_sha}", :clone, Dir.pwd, "--recurse-submodules") @@ -103,6 +104,7 @@ class CliBuildTest < CliTestCase stub_setup SSHKit::Backend::Abstract.any_instance.expects(:execute).with(:docker, "--version", "&&", :docker, :buildx, "version") + SSHKit::Backend::Abstract.any_instance.stubs(:execute).with { |*args| args[0..1] == [ :docker, :login ] } SSHKit::Backend::Abstract.any_instance.expects(:execute) .with(:git, "-C", "#{Dir.tmpdir}/kamal-clones/app-#{pwd_sha}", :clone, Dir.pwd, "--recurse-submodules") @@ -139,6 +141,9 @@ class CliBuildTest < CliTestCase SSHKit::Backend::Abstract.any_instance.expects(:execute) .with(:docker, "--version", "&&", :docker, :buildx, "version") + SSHKit::Backend::Abstract.any_instance.stubs(:execute) + .with { |*args| args[0..1] == [ :docker, :login ] } + SSHKit::Backend::Abstract.any_instance.expects(:execute) .with(:docker, :buildx, :rm, "kamal-local-docker-container") diff --git a/test/cli/main_test.rb b/test/cli/main_test.rb index becace43..d37c9352 100644 --- a/test/cli/main_test.rb +++ b/test/cli/main_test.rb @@ -21,7 +21,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:accessory:boot", [ "all" ], invoke_options) # deploy - 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:proxy:boot", [], invoke_options) Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:app:stale_containers", [], invoke_options.merge(stop: true)) @@ -32,7 +31,6 @@ class CliMainTest < CliTestCase assert_match /Ensure Docker is installed.../, output # deploy assert_match /Acquiring the deploy lock/, output - assert_match /Log into image registry/, output assert_match /Pull app image/, output assert_match /Ensure kamal-proxy is running/, output assert_match /Detect stale containers/, output @@ -45,8 +43,7 @@ class CliMainTest < CliTestCase with_test_secrets("secrets" => "DB_PASSWORD=secret") 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.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:build:deliver", [], invoke_options) Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:proxy:boot", [], 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) @@ -56,8 +53,7 @@ class CliMainTest < CliTestCase run_command("deploy", "--verbose").tap do |output| assert_hook_ran "pre-connect", output - assert_match /Log into image registry/, output - assert_match /Build and push app image/, output + assert_match /Build and push app image/, output assert_hook_ran "pre-deploy", output assert_match /Ensure kamal-proxy is running/, output assert_match /Detect stale containers/, output @@ -70,7 +66,6 @@ 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.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:proxy:boot", [], invoke_options) Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:app:stale_containers", [], invoke_options.merge(stop: true)) @@ -79,7 +74,6 @@ class CliMainTest < CliTestCase run_command("deploy", "--skip_push").tap do |output| assert_match /Acquiring the deploy lock/, output - assert_match /Log into image registry/, output assert_match /Pull app image/, output assert_match /Ensure kamal-proxy is running/, output assert_match /Detect stale containers/, output @@ -153,11 +147,11 @@ class CliMainTest < CliTestCase end 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, :skip_local => false } + test "deploy errors during outside section leave remote lock" 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.merge(skip_local: false)) + .with("kamal:cli:build:deliver", [], invoke_options) .raises(RuntimeError) assert_not KAMAL.holding_lock? @@ -170,7 +164,6 @@ 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.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:proxy:boot", [], invoke_options) Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:app:stale_containers", [], invoke_options.merge(stop: true)) @@ -185,7 +178,6 @@ 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.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:proxy:boot", [], invoke_options) Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:app:stale_containers", [], invoke_options.merge(stop: true)) From 399f1526afd3469884c9fefae24c023f43d36058 Mon Sep 17 00:00:00 2001 From: Donal McBreen Date: Fri, 18 Apr 2025 10:20:54 +0100 Subject: [PATCH 15/20] Handle role filter when booting accessories Filter the accessory hosts via KAMAL.accessory_hosts, which correctly handles role and host filters. Fixes: https://github.com/basecamp/kamal/issues/935 --- lib/kamal/cli/accessory.rb | 6 +----- test/cli/accessory_test.rb | 13 +++++++++++++ 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/lib/kamal/cli/accessory.rb b/lib/kamal/cli/accessory.rb index c95cbb1e..326717ac 100644 --- a/lib/kamal/cli/accessory.rb +++ b/lib/kamal/cli/accessory.rb @@ -275,11 +275,7 @@ class Kamal::Cli::Accessory < Kamal::Cli::Base end def accessory_hosts(accessory) - if KAMAL.specific_hosts&.any? - KAMAL.specific_hosts & accessory.hosts - else - accessory.hosts - end + KAMAL.accessory_hosts & accessory.hosts end def remove_accessory(name) diff --git a/test/cli/accessory_test.rb b/test/cli/accessory_test.rb index cc517e59..c11426c0 100644 --- a/test/cli/accessory_test.rb +++ b/test/cli/accessory_test.rb @@ -251,6 +251,19 @@ class CliAccessoryTest < CliTestCase end end + test "boot with web role filter" do + run_command("boot", "redis", "-r", "web").tap do |output| + assert_match "docker run --name app-redis --detach --restart unless-stopped --network kamal --log-opt max-size=\"10m\" --publish 6379:6379 --env-file .kamal/apps/app/env/accessories/redis.env --volume $PWD/app-redis/data:/data --label service=\"app-redis\" redis:latest on 1.1.1.1", output + assert_match "docker run --name app-redis --detach --restart unless-stopped --network kamal --log-opt max-size=\"10m\" --publish 6379:6379 --env-file .kamal/apps/app/env/accessories/redis.env --volume $PWD/app-redis/data:/data --label service=\"app-redis\" redis:latest on 1.1.1.2", output + end + end + + test "boot with workers role filter" do + run_command("boot", "redis", "-r", "workers").tap do |output| + assert_no_match "docker run", output + end + end + private def run_command(*command) stdouted { Kamal::Cli::Accessory.start([ *command, "-c", "test/fixtures/deploy_with_accessories_with_different_registries.yml" ]) } From 03cac7ae3ddaf5e3fc81c374ec2b05e06229bcb0 Mon Sep 17 00:00:00 2001 From: Donal McBreen Date: Fri, 18 Apr 2025 10:49:19 +0100 Subject: [PATCH 16/20] Skip existing containers on accessory boot When booting an accessory, check for the container first and skip boot if it exists. This allows us to rerun `kamal setup` on hosts with accessories without raising an error. Fixes: https://github.com/basecamp/kamal/issues/488 --- lib/kamal/cli/accessory.rb | 11 +++++++++++ lib/kamal/commands/accessory.rb | 4 ++-- test/integration/accessory_test.rb | 3 +++ test/integration/integration_test.rb | 2 +- 4 files changed, 17 insertions(+), 3 deletions(-) diff --git a/lib/kamal/cli/accessory.rb b/lib/kamal/cli/accessory.rb index 326717ac..735d554e 100644 --- a/lib/kamal/cli/accessory.rb +++ b/lib/kamal/cli/accessory.rb @@ -1,4 +1,5 @@ require "active_support/core_ext/array/conversions" +require "concurrent/array" class Kamal::Cli::Accessory < Kamal::Cli::Base desc "boot [NAME]", "Boot new accessory service on host (use NAME=all to boot all accessories)" @@ -10,6 +11,16 @@ class Kamal::Cli::Accessory < Kamal::Cli::Base prepare(name) if prepare with_accessory(name) do |accessory, hosts| + booted_hosts = Concurrent::Array.new + on(hosts) do |host| + booted_hosts << host.to_s if capture_with_info(*accessory.info(all: true, quiet: true)).strip.presence + end + + if booted_hosts.any? + say "Skipping booting `#{name}` on #{booted_hosts.sort.join(", ")}, a container already exists", :yellow + hosts -= booted_hosts + end + directories(name) upload(name) diff --git a/lib/kamal/commands/accessory.rb b/lib/kamal/commands/accessory.rb index 77ceb607..60279dfc 100644 --- a/lib/kamal/commands/accessory.rb +++ b/lib/kamal/commands/accessory.rb @@ -37,8 +37,8 @@ class Kamal::Commands::Accessory < Kamal::Commands::Base docker :container, :stop, service_name end - def info - docker :ps, *service_filter + def info(all: false, quiet: false) + docker :ps, *("-a" if all), *("-q" if quiet), *service_filter end def logs(timestamps: true, since: nil, lines: nil, grep: nil, grep_options: nil) diff --git a/test/integration/accessory_test.rb b/test/integration/accessory_test.rb index 0ed7a7b9..f5ce21de 100644 --- a/test/integration/accessory_test.rb +++ b/test/integration/accessory_test.rb @@ -17,6 +17,9 @@ class AccessoryTest < IntegrationTest logs = kamal :accessory, :logs, :busybox, capture: true assert_match /Starting busybox.../, logs + boot = kamal :accessory, :boot, :busybox, capture: true + assert_match /Skipping booting `busybox` on vm1, vm2, a container already exists/, boot + kamal :accessory, :remove, :busybox, "-y" assert_accessory_not_running :busybox end diff --git a/test/integration/integration_test.rb b/test/integration/integration_test.rb index 631d8fc4..ef92a278 100644 --- a/test/integration/integration_test.rb +++ b/test/integration/integration_test.rb @@ -11,7 +11,7 @@ class IntegrationTest < ActiveSupport::TestCase end teardown do - if !passed? && ENV["DEBUG"] + if !passed? && ENV["DEBUG_CONTAINER_LOGS"] [ :deployer, :vm1, :vm2, :shared, :load_balancer, :registry ].each do |container| puts puts "Logs for #{container}:" From e4e39c31e34c0aebf6947a4a50b9c0428e91c688 Mon Sep 17 00:00:00 2001 From: Donal McBreen Date: Fri, 18 Apr 2025 11:52:55 +0100 Subject: [PATCH 17/20] Add KAMAL.app_hosts KAMAL.hosts includes accessory and apps hosts. Add KAMAL.app_hosts which does not include accessory only hosts and use it for app specific commands. Fixes: - https://github.com/basecamp/kamal/issues/1059 - https://github.com/basecamp/kamal/issues/1148 --- lib/kamal/cli/app.rb | 34 +++++++++---------- lib/kamal/cli/build.rb | 10 +++--- lib/kamal/cli/main.rb | 6 ++-- lib/kamal/cli/server.rb | 4 +-- lib/kamal/commander.rb | 2 +- lib/kamal/commander/specifics.rb | 4 +++ lib/kamal/configuration.rb | 4 +++ .../docker/deployer/app/config/deploy.yml | 5 +++ test/integration/main_test.rb | 24 +++++++------ 9 files changed, 54 insertions(+), 39 deletions(-) diff --git a/lib/kamal/cli/app.rb b/lib/kamal/cli/app.rb index d2f9c3f9..54d8a164 100644 --- a/lib/kamal/cli/app.rb +++ b/lib/kamal/cli/app.rb @@ -7,7 +7,7 @@ class Kamal::Cli::App < Kamal::Cli::Base say "Start container with version #{version} (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 + on(KAMAL.app_hosts) do Kamal::Cli::App::ErrorPages.new(host, self).run KAMAL.roles_on(host).each do |role| @@ -33,7 +33,7 @@ class Kamal::Cli::App < Kamal::Cli::Base end # Tag once the app booted on all hosts - on(KAMAL.hosts) do |host| + on(KAMAL.app_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 @@ -44,7 +44,7 @@ class Kamal::Cli::App < Kamal::Cli::Base desc "start", "Start existing app container on servers" def start with_lock do - on(KAMAL.hosts) do |host| + on(KAMAL.app_hosts) do |host| roles = KAMAL.roles_on(host) roles.each do |role| @@ -67,7 +67,7 @@ class Kamal::Cli::App < Kamal::Cli::Base desc "stop", "Stop app container on servers" def stop with_lock do - on(KAMAL.hosts) do |host| + on(KAMAL.app_hosts) do |host| roles = KAMAL.roles_on(host) roles.each do |role| @@ -91,7 +91,7 @@ class Kamal::Cli::App < Kamal::Cli::Base # FIXME: Drop in favor of just containers? desc "details", "Show details about app containers" def details - on(KAMAL.hosts) do |host| + on(KAMAL.app_hosts) do |host| roles = KAMAL.roles_on(host) roles.each do |role| @@ -135,7 +135,7 @@ class Kamal::Cli::App < Kamal::Cli::Base using_version(options[:version] || current_running_version) do |version| say "Launching command with version #{version} from existing container...", :magenta - on(KAMAL.hosts) do |host| + on(KAMAL.app_hosts) do |host| roles = KAMAL.roles_on(host) roles.each do |role| @@ -149,7 +149,7 @@ class Kamal::Cli::App < Kamal::Cli::Base say "Get most recent version available as an image...", :magenta unless options[:version] using_version(version_or_latest) do |version| say "Launching command with version #{version} from new container...", :magenta - on(KAMAL.hosts) do |host| + on(KAMAL.app_hosts) do |host| roles = KAMAL.roles_on(host) roles.each do |role| @@ -163,7 +163,7 @@ class Kamal::Cli::App < Kamal::Cli::Base desc "containers", "Show app containers on servers" def containers - on(KAMAL.hosts) { |host| puts_by_host host, capture_with_info(*KAMAL.app.list_containers) } + on(KAMAL.app_hosts) { |host| puts_by_host host, capture_with_info(*KAMAL.app.list_containers) } end desc "stale_containers", "Detect app stale containers" @@ -172,7 +172,7 @@ class Kamal::Cli::App < Kamal::Cli::Base stop = options[:stop] with_lock_if_stopping do - on(KAMAL.hosts) do |host| + on(KAMAL.app_hosts) do |host| roles = KAMAL.roles_on(host) roles.each do |role| @@ -195,7 +195,7 @@ class Kamal::Cli::App < Kamal::Cli::Base desc "images", "Show app images on servers" def images - on(KAMAL.hosts) { |host| puts_by_host host, capture_with_info(*KAMAL.app.list_images) } + on(KAMAL.app_hosts) { |host| puts_by_host host, capture_with_info(*KAMAL.app.list_images) } end desc "logs", "Show log lines from app on servers (use --help to show options)" @@ -231,7 +231,7 @@ class Kamal::Cli::App < Kamal::Cli::Base else lines = options[:lines].presence || ((since || grep) ? nil : 100) # Default to 100 lines if since or grep isn't set - on(KAMAL.hosts) do |host| + on(KAMAL.app_hosts) do |host| roles = KAMAL.roles_on(host) roles.each do |role| @@ -288,7 +288,7 @@ class Kamal::Cli::App < Kamal::Cli::Base desc "remove_container [VERSION]", "Remove app container with given version from servers", hide: true def remove_container(version) with_lock do - on(KAMAL.hosts) do |host| + on(KAMAL.app_hosts) do |host| roles = KAMAL.roles_on(host) roles.each do |role| @@ -302,7 +302,7 @@ class Kamal::Cli::App < Kamal::Cli::Base desc "remove_containers", "Remove all app containers from servers", hide: true def remove_containers with_lock do - on(KAMAL.hosts) do |host| + on(KAMAL.app_hosts) do |host| roles = KAMAL.roles_on(host) roles.each do |role| @@ -316,7 +316,7 @@ class Kamal::Cli::App < Kamal::Cli::Base desc "remove_images", "Remove all app images from servers", hide: true def remove_images with_lock do - on(KAMAL.hosts) do + on(KAMAL.app_hosts) do execute *KAMAL.auditor.record("Removed all app images"), verbosity: :debug execute *KAMAL.app.remove_images end @@ -326,7 +326,7 @@ class Kamal::Cli::App < Kamal::Cli::Base desc "remove_app_directories", "Remove the app directories from servers", hide: true def remove_app_directories with_lock do - on(KAMAL.hosts) do |host| + on(KAMAL.app_hosts) do |host| roles = KAMAL.roles_on(host) roles.each do |role| @@ -342,7 +342,7 @@ class Kamal::Cli::App < Kamal::Cli::Base desc "version", "Show app version currently running on servers" def version - on(KAMAL.hosts) do |host| + on(KAMAL.app_hosts) do |host| role = KAMAL.roles_on(host).first puts_by_host host, capture_with_info(*KAMAL.app(role: role, host: host).current_running_version).strip end @@ -385,6 +385,6 @@ class Kamal::Cli::App < Kamal::Cli::Base end def host_boot_groups - KAMAL.config.boot.limit ? KAMAL.hosts.each_slice(KAMAL.config.boot.limit).to_a : [ KAMAL.hosts ] + KAMAL.config.boot.limit ? KAMAL.app_hosts.each_slice(KAMAL.config.boot.limit).to_a : [ KAMAL.app_hosts ] end end diff --git a/lib/kamal/cli/build.rb b/lib/kamal/cli/build.rb index 310c2689..b9b6a5d5 100644 --- a/lib/kamal/cli/build.rb +++ b/lib/kamal/cli/build.rb @@ -70,9 +70,9 @@ class Kamal::Cli::Build < Kamal::Cli::Base say "Pulling image on #{first_hosts.join(", ")} to seed the #{"mirror".pluralize(first_hosts.count)}...", :magenta pull_on_hosts(first_hosts) say "Pulling image on remaining hosts...", :magenta - pull_on_hosts(KAMAL.hosts - first_hosts) + pull_on_hosts(KAMAL.app_hosts - first_hosts) else - pull_on_hosts(KAMAL.hosts) + pull_on_hosts(KAMAL.app_hosts) end end @@ -163,9 +163,9 @@ class Kamal::Cli::Build < Kamal::Cli::Base end def mirror_hosts - if KAMAL.hosts.many? + if KAMAL.app_hosts.many? mirror_hosts = Concurrent::Hash.new - on(KAMAL.hosts) do |host| + on(KAMAL.app_hosts) do |host| first_mirror = capture_with_info(*KAMAL.builder.first_mirror).strip.presence mirror_hosts[first_mirror] ||= host.to_s if first_mirror rescue SSHKit::Command::Failed => e @@ -193,7 +193,7 @@ class Kamal::Cli::Build < Kamal::Cli::Base end def login_to_registry_remotely - on(KAMAL.hosts) do + on(KAMAL.app_hosts) do execute *KAMAL.registry.login end end diff --git a/lib/kamal/cli/main.rb b/lib/kamal/cli/main.rb index cd28ab35..4af6d878 100644 --- a/lib/kamal/cli/main.rb +++ b/lib/kamal/cli/main.rb @@ -194,10 +194,10 @@ class Kamal::Cli::Main < Kamal::Cli::Base confirming "This will replace Traefik with kamal-proxy and restart all accessories" do with_lock do if options[:rolling] - (KAMAL.hosts | KAMAL.accessory_hosts).each do |host| + KAMAL.hosts.each do |host| KAMAL.with_specific_hosts(host) do say "Upgrading #{host}...", :magenta - if KAMAL.hosts.include?(host) + if KAMAL.app_hosts.include?(host) invoke "kamal:cli:proxy:upgrade", [], options.merge(confirmed: true, rolling: false) reset_invocation(Kamal::Cli::Proxy) end @@ -253,7 +253,7 @@ class Kamal::Cli::Main < Kamal::Cli::Base private def container_available?(version) begin - on(KAMAL.hosts) do + on(KAMAL.app_hosts) do KAMAL.roles_on(host).each do |role| container_id = capture_with_info(*KAMAL.app(role: role, host: host).container_id_for_version(version)) raise "Container not found" unless container_id.present? diff --git a/lib/kamal/cli/server.rb b/lib/kamal/cli/server.rb index 452ab089..db464e7b 100644 --- a/lib/kamal/cli/server.rb +++ b/lib/kamal/cli/server.rb @@ -3,7 +3,7 @@ class Kamal::Cli::Server < Kamal::Cli::Base option :interactive, type: :boolean, aliases: "-i", default: false, desc: "Run the command interactively (use for console/bash)" def exec(*cmd) cmd = Kamal::Utils.join_commands(cmd) - hosts = KAMAL.hosts | KAMAL.accessory_hosts + hosts = KAMAL.hosts case when options[:interactive] @@ -27,7 +27,7 @@ class Kamal::Cli::Server < Kamal::Cli::Base with_lock do missing = [] - on(KAMAL.hosts | KAMAL.accessory_hosts) do |host| + on(KAMAL.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…" diff --git a/lib/kamal/commander.rb b/lib/kamal/commander.rb index b0e8f0ba..8a4356ed 100644 --- a/lib/kamal/commander.rb +++ b/lib/kamal/commander.rb @@ -5,7 +5,7 @@ require "active_support/core_ext/object/blank" class Kamal::Commander attr_accessor :verbosity, :holding_lock, :connected attr_reader :specific_roles, :specific_hosts - delegate :hosts, :roles, :primary_host, :primary_role, :roles_on, :proxy_hosts, :accessory_hosts, to: :specifics + delegate :hosts, :roles, :primary_host, :primary_role, :roles_on, :app_hosts, :proxy_hosts, :accessory_hosts, to: :specifics def initialize reset diff --git a/lib/kamal/commander/specifics.rb b/lib/kamal/commander/specifics.rb index 238cc012..e609baba 100644 --- a/lib/kamal/commander/specifics.rb +++ b/lib/kamal/commander/specifics.rb @@ -18,6 +18,10 @@ class Kamal::Commander::Specifics roles.select { |role| role.hosts.include?(host.to_s) } end + def app_hosts + config.app_hosts & specified_hosts + end + def proxy_hosts config.proxy_hosts & specified_hosts end diff --git a/lib/kamal/configuration.rb b/lib/kamal/configuration.rb index b291dfde..5f0abde8 100644 --- a/lib/kamal/configuration.rb +++ b/lib/kamal/configuration.rb @@ -125,6 +125,10 @@ class Kamal::Configuration (roles + accessories).flat_map(&:hosts).uniq end + def app_hosts + roles.flat_map(&:hosts).uniq + end + def primary_host primary_role&.primary_host end diff --git a/test/integration/docker/deployer/app/config/deploy.yml b/test/integration/docker/deployer/app/config/deploy.yml index 242d893a..c0a576a4 100644 --- a/test/integration/docker/deployer/app/config/deploy.yml +++ b/test/integration/docker/deployer/app/config/deploy.yml @@ -41,3 +41,8 @@ accessories: cmd: sh -c 'echo "Starting busybox..."; trap exit term; while true; do sleep 1; done' roles: - web + busybox2: + service: custom-busybox + image: registry:4443/busybox:1.36.0 + cmd: sh -c 'echo "Starting busybox..."; trap exit term; while true; do sleep 1; done' + host: vm3 diff --git a/test/integration/main_test.rb b/test/integration/main_test.rb index 04040c38..0a918a78 100644 --- a/test/integration/main_test.rb +++ b/test/integration/main_test.rb @@ -60,7 +60,7 @@ class MainTest < IntegrationTest version = latest_app_version assert_equal [ "web" ], config[:roles] - assert_equal [ "vm1", "vm2" ], config[:hosts] + assert_equal [ "vm1", "vm2", "vm3" ], config[:hosts] assert_equal "vm1", config[:primary_host] assert_equal version, config[:version] assert_equal "registry:4443/app", config[:repository] @@ -88,8 +88,6 @@ class MainTest < IntegrationTest end test "setup and remove" do - @app = "app_with_roles" - kamal :proxy, :boot_config, "set", "--publish=false", "--docker-options=label=traefik.http.services.kamal_proxy.loadbalancer.server.scheme=http", @@ -172,21 +170,25 @@ class MainTest < IntegrationTest assert_equal "200", Net::HTTP.get_response(URI.parse("http://#{app_host}:12345/versions/.hidden")).code end - def vm1_image_ids - docker_compose("exec vm1 docker image ls -q", capture: true).strip.split("\n") + def image_ids(vm:) + docker_compose("exec #{vm} docker image ls -q", capture: true).strip.split("\n") end - def vm1_container_ids - docker_compose("exec vm1 docker ps -a -q", capture: true).strip.split("\n") + def container_ids(vm:) + docker_compose("exec #{vm} docker ps -a -q", capture: true).strip.split("\n") end def assert_no_images_or_containers - assert vm1_image_ids.empty? - assert vm1_container_ids.empty? + [ :vm1, :vm2, :vm3 ].each do |vm| + assert image_ids(vm: vm).empty? + assert container_ids(vm: vm).empty? + end end def assert_images_and_containers - assert vm1_image_ids.any? - assert vm1_container_ids.any? + [ :vm1, :vm2, :vm3 ].each do |vm| + assert image_ids(vm: vm).any? + assert container_ids(vm: vm).any? + end end end From 8d5ed62d3044e5f9394b6999b0eda848b8c3b6e8 Mon Sep 17 00:00:00 2001 From: Donal McBreen Date: Fri, 18 Apr 2025 14:10:00 +0100 Subject: [PATCH 18/20] Don't allow booleans for root proxy config Setting it to a false or true doesn't affect the config so shouldn't be allowed. true/false are for role level configurations. Fixes: https://github.com/basecamp/kamal/issues/1120 --- lib/kamal/configuration.rb | 2 +- test/cli/app_test.rb | 15 +++++++++++++++ test/configuration/proxy_test.rb | 7 +++++++ ...kers_only.yml => deploy_with_only_workers.yml} | 0 4 files changed, 23 insertions(+), 1 deletion(-) rename test/fixtures/{deploy_workers_only.yml => deploy_with_only_workers.yml} (100%) diff --git a/lib/kamal/configuration.rb b/lib/kamal/configuration.rb index 5f0abde8..326e1f71 100644 --- a/lib/kamal/configuration.rb +++ b/lib/kamal/configuration.rb @@ -68,7 +68,7 @@ class Kamal::Configuration @env = Env.new(config: @raw_config.env || {}, secrets: secrets) @logging = Logging.new(logging_config: @raw_config.logging) - @proxy = Proxy.new(config: self, proxy_config: @raw_config.proxy || {}) + @proxy = Proxy.new(config: self, proxy_config: @raw_config.key?(:proxy) ? @raw_config.proxy : {}) @ssh = Ssh.new(config: self) @sshkit = Sshkit.new(config: self) diff --git a/test/cli/app_test.rb b/test/cli/app_test.rb index 2ed00182..76b299b6 100644 --- a/test/cli/app_test.rb +++ b/test/cli/app_test.rb @@ -192,6 +192,21 @@ class CliAppTest < CliTestCase Thread.report_on_exception = true end + test "boot with only workers" 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-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_only_workers, host: nil).tap do |output| + assert_match /First workers container is healthy on 1.1.1.\d, booting any other roles/, output + assert_no_match "kamal-proxy", output + end + end + test "boot with error pages" do with_error_pages(directory: "public") do stub_running diff --git a/test/configuration/proxy_test.rb b/test/configuration/proxy_test.rb index 588e5a35..460d30cb 100644 --- a/test/configuration/proxy_test.rb +++ b/test/configuration/proxy_test.rb @@ -38,6 +38,13 @@ class ConfigurationProxyTest < ActiveSupport::TestCase assert_not config.proxy.ssl? end + test "false not allowed" do + @deploy[:proxy] = false + assert_raises(Kamal::ConfigurationError, "proxy: should be a hash") do + config.proxy + end + end + private def config Kamal::Configuration.new(@deploy) diff --git a/test/fixtures/deploy_workers_only.yml b/test/fixtures/deploy_with_only_workers.yml similarity index 100% rename from test/fixtures/deploy_workers_only.yml rename to test/fixtures/deploy_with_only_workers.yml From bf64d9a0f50cc8b5ff4a3dbd232e60b26426b553 Mon Sep 17 00:00:00 2001 From: Donal McBreen Date: Fri, 18 Apr 2025 14:30:52 +0100 Subject: [PATCH 19/20] Run pre-connect hooks before ssh commands We hook into the SSHKit `on` method to run the pre-connect hook before the first SSH command. This doesn't work for interactive exec commands where ssh is called directly. Fixes: https://github.com/basecamp/kamal/issues/1157 --- lib/kamal/cli/accessory.rb | 2 ++ lib/kamal/cli/app.rb | 2 ++ lib/kamal/cli/base.rb | 8 ++++++-- lib/kamal/cli/server.rb | 2 ++ test/cli/app_test.rb | 6 ++++++ 5 files changed, 18 insertions(+), 2 deletions(-) diff --git a/lib/kamal/cli/accessory.rb b/lib/kamal/cli/accessory.rb index 735d554e..e906b3b3 100644 --- a/lib/kamal/cli/accessory.rb +++ b/lib/kamal/cli/accessory.rb @@ -141,6 +141,8 @@ class Kamal::Cli::Accessory < Kamal::Cli::Base 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(name, *cmd) + pre_connect_if_required + cmd = Kamal::Utils.join_commands(cmd) with_accessory(name) do |accessory, hosts| case diff --git a/lib/kamal/cli/app.rb b/lib/kamal/cli/app.rb index 54d8a164..a449efdf 100644 --- a/lib/kamal/cli/app.rb +++ b/lib/kamal/cli/app.rb @@ -106,6 +106,8 @@ class Kamal::Cli::App < Kamal::Cli::Base option :env, aliases: "-e", type: :hash, desc: "Set environment variables for the command" option :detach, type: :boolean, default: false, desc: "Execute command in a detached container" def exec(*cmd) + pre_connect_if_required + if (incompatible_options = [ :interactive, :reuse ].select { |key| options[:detach] && options[key] }.presence) raise ArgumentError, "Detach is not compatible with #{incompatible_options.join(" or ")}" end diff --git a/lib/kamal/cli/base.rb b/lib/kamal/cli/base.rb index 0a6dd6e7..45b1411e 100644 --- a/lib/kamal/cli/base.rb +++ b/lib/kamal/cli/base.rb @@ -147,12 +147,16 @@ module Kamal::Cli end def on(*args, &block) + pre_connect_if_required + + super + end + + def pre_connect_if_required if !KAMAL.connected? run_hook "pre-connect" KAMAL.connected = true end - - super end def command diff --git a/lib/kamal/cli/server.rb b/lib/kamal/cli/server.rb index db464e7b..e3da8c18 100644 --- a/lib/kamal/cli/server.rb +++ b/lib/kamal/cli/server.rb @@ -2,6 +2,8 @@ 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) + pre_connect_if_required + cmd = Kamal::Utils.join_commands(cmd) hosts = KAMAL.hosts diff --git a/test/cli/app_test.rb b/test/cli/app_test.rb index 2ed00182..c39176c3 100644 --- a/test/cli/app_test.rb +++ b/test/cli/app_test.rb @@ -334,18 +334,24 @@ class CliAppTest < CliTestCase end test "exec interactive" do + Kamal::Commands::Hook.any_instance.stubs(:hook_exists?).returns(true) SSHKit::Backend::Abstract.any_instance.expects(:exec) .with("ssh -t root@1.1.1.1 -p 22 'docker run -it --rm --network kamal --env-file .kamal/apps/app/env/roles/web.env --log-opt max-size=\"10m\" dhh/app:latest ruby -v'") + run_command("exec", "-i", "ruby -v").tap do |output| + assert_hook_ran "pre-connect", output assert_match "Get most recent version available as an image...", output assert_match "Launching interactive command with version latest via SSH from new container on 1.1.1.1...", output end end test "exec interactive with reuse" do + Kamal::Commands::Hook.any_instance.stubs(:hook_exists?).returns(true) SSHKit::Backend::Abstract.any_instance.expects(:exec) .with("ssh -t root@1.1.1.1 -p 22 'docker exec -it app-web-999 ruby -v'") + run_command("exec", "-i", "--reuse", "ruby -v").tap do |output| + assert_hook_ran "pre-connect", output assert_match "Get current version of running container...", output assert_match "Running /usr/bin/env sh -c 'docker ps --latest --format '\\''{{.Names}}'\\'' --filter label=service=app --filter label=destination= --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=destination= --filter label=role=web --filter status=running --filter status=restarting' | head -1 | while read line; do echo ${line#app-web-}; done on 1.1.1.1", output assert_match "Launching interactive command with version 999 via SSH from existing container on 1.1.1.1...", output From 7a63cacb09f78de9a9a72a6ea0971a31f6a09950 Mon Sep 17 00:00:00 2001 From: Donal McBreen Date: Fri, 18 Apr 2025 14:44:43 +0100 Subject: [PATCH 20/20] Docker login if exec might pull image The `app exec` and `accessory exec` commands will run `docker run` if they are not set to reuse existing containers. This might need to pull an image so let's make sure we are logged in before running the command. Fixes: https://github.com/basecamp/kamal/issues/1163 --- lib/kamal/cli/accessory.rb | 2 ++ lib/kamal/cli/app.rb | 3 +++ test/cli/accessory_test.rb | 1 + test/cli/app_test.rb | 2 ++ 4 files changed, 8 insertions(+) diff --git a/lib/kamal/cli/accessory.rb b/lib/kamal/cli/accessory.rb index e906b3b3..f2bfa400 100644 --- a/lib/kamal/cli/accessory.rb +++ b/lib/kamal/cli/accessory.rb @@ -152,6 +152,7 @@ class Kamal::Cli::Accessory < Kamal::Cli::Base when options[:interactive] say "Launching interactive command via SSH from new container...", :magenta + on(accessory.hosts.first) { execute *KAMAL.registry.login } run_locally { exec accessory.execute_in_new_container_over_ssh(cmd) } when options[:reuse] @@ -164,6 +165,7 @@ class Kamal::Cli::Accessory < Kamal::Cli::Base else say "Launching command from new container...", :magenta on(hosts) do |host| + execute *KAMAL.registry.login execute *KAMAL.auditor.record("Executed cmd '#{cmd}' on #{name} accessory"), verbosity: :debug puts_by_host host, capture_with_info(*accessory.execute_in_new_container(cmd)) end diff --git a/lib/kamal/cli/app.rb b/lib/kamal/cli/app.rb index a449efdf..ed1870ba 100644 --- a/lib/kamal/cli/app.rb +++ b/lib/kamal/cli/app.rb @@ -127,6 +127,7 @@ class Kamal::Cli::App < Kamal::Cli::Base say "Get most recent version available as an image...", :magenta unless options[:version] using_version(version_or_latest) do |version| say "Launching interactive command with version #{version} via SSH from new container on #{KAMAL.primary_host}...", :magenta + on(KAMAL.primary_host) { execute *KAMAL.registry.login } run_locally do exec KAMAL.app(role: KAMAL.primary_role, host: KAMAL.primary_host).execute_in_new_container_over_ssh(cmd, env: env) end @@ -152,6 +153,8 @@ class Kamal::Cli::App < Kamal::Cli::Base using_version(version_or_latest) do |version| say "Launching command with version #{version} from new container...", :magenta on(KAMAL.app_hosts) do |host| + execute *KAMAL.registry.login + roles = KAMAL.roles_on(host) roles.each do |role| diff --git a/test/cli/accessory_test.rb b/test/cli/accessory_test.rb index c11426c0..3abf3e32 100644 --- a/test/cli/accessory_test.rb +++ b/test/cli/accessory_test.rb @@ -115,6 +115,7 @@ class CliAccessoryTest < CliTestCase test "exec" do run_command("exec", "mysql", "mysql -v").tap do |output| + assert_match "docker login private.registry -u [REDACTED] -p [REDACTED]", output assert_match "Launching command from new container", output assert_match "mysql -v", output end diff --git a/test/cli/app_test.rb b/test/cli/app_test.rb index 18bec2c1..af673ce1 100644 --- a/test/cli/app_test.rb +++ b/test/cli/app_test.rb @@ -307,6 +307,7 @@ class CliAppTest < CliTestCase test "exec" do run_command("exec", "ruby -v").tap do |output| + assert_match "docker login -u [REDACTED] -p [REDACTED]", output assert_match "docker run --rm --network kamal --env-file .kamal/apps/app/env/roles/web.env --log-opt max-size=\"10m\" dhh/app:latest ruby -v", output end end @@ -355,6 +356,7 @@ class CliAppTest < CliTestCase run_command("exec", "-i", "ruby -v").tap do |output| assert_hook_ran "pre-connect", output + assert_match "docker login -u [REDACTED] -p [REDACTED]", output assert_match "Get most recent version available as an image...", output assert_match "Launching interactive command with version latest via SSH from new container on 1.1.1.1...", output end