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
This commit is contained in:
Donal McBreen
2025-04-18 11:52:55 +01:00
parent 5c71f2ba5a
commit e4e39c31e3
9 changed files with 54 additions and 39 deletions

View File

@@ -7,7 +7,7 @@ class Kamal::Cli::App < Kamal::Cli::Base
say "Start container with version #{version} (or reboot if already running)...", :magenta 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 # 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::Cli::App::ErrorPages.new(host, self).run
KAMAL.roles_on(host).each do |role| KAMAL.roles_on(host).each do |role|
@@ -33,7 +33,7 @@ class Kamal::Cli::App < Kamal::Cli::Base
end end
# Tag once the app booted on all hosts # 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.auditor.record("Tagging #{KAMAL.config.absolute_image} as the latest image"), verbosity: :debug
execute *KAMAL.app.tag_latest_image execute *KAMAL.app.tag_latest_image
end end
@@ -44,7 +44,7 @@ class Kamal::Cli::App < Kamal::Cli::Base
desc "start", "Start existing app container on servers" desc "start", "Start existing app container on servers"
def start def start
with_lock do with_lock do
on(KAMAL.hosts) do |host| on(KAMAL.app_hosts) do |host|
roles = KAMAL.roles_on(host) roles = KAMAL.roles_on(host)
roles.each do |role| roles.each do |role|
@@ -67,7 +67,7 @@ class Kamal::Cli::App < Kamal::Cli::Base
desc "stop", "Stop app container on servers" desc "stop", "Stop app container on servers"
def stop def stop
with_lock do with_lock do
on(KAMAL.hosts) do |host| on(KAMAL.app_hosts) do |host|
roles = KAMAL.roles_on(host) roles = KAMAL.roles_on(host)
roles.each do |role| roles.each do |role|
@@ -91,7 +91,7 @@ class Kamal::Cli::App < Kamal::Cli::Base
# FIXME: Drop in favor of just containers? # FIXME: Drop in favor of just containers?
desc "details", "Show details about app containers" desc "details", "Show details about app containers"
def details def details
on(KAMAL.hosts) do |host| on(KAMAL.app_hosts) do |host|
roles = KAMAL.roles_on(host) roles = KAMAL.roles_on(host)
roles.each do |role| roles.each do |role|
@@ -135,7 +135,7 @@ class Kamal::Cli::App < Kamal::Cli::Base
using_version(options[:version] || current_running_version) do |version| using_version(options[:version] || current_running_version) do |version|
say "Launching command with version #{version} from existing container...", :magenta 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 = KAMAL.roles_on(host)
roles.each do |role| 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] say "Get most recent version available as an image...", :magenta unless options[:version]
using_version(version_or_latest) do |version| using_version(version_or_latest) do |version|
say "Launching command with version #{version} from new container...", :magenta 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 = KAMAL.roles_on(host)
roles.each do |role| roles.each do |role|
@@ -163,7 +163,7 @@ class Kamal::Cli::App < Kamal::Cli::Base
desc "containers", "Show app containers on servers" desc "containers", "Show app containers on servers"
def containers 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 end
desc "stale_containers", "Detect app stale containers" desc "stale_containers", "Detect app stale containers"
@@ -172,7 +172,7 @@ class Kamal::Cli::App < Kamal::Cli::Base
stop = options[:stop] stop = options[:stop]
with_lock_if_stopping do with_lock_if_stopping do
on(KAMAL.hosts) do |host| on(KAMAL.app_hosts) do |host|
roles = KAMAL.roles_on(host) roles = KAMAL.roles_on(host)
roles.each do |role| roles.each do |role|
@@ -195,7 +195,7 @@ class Kamal::Cli::App < Kamal::Cli::Base
desc "images", "Show app images on servers" desc "images", "Show app images on servers"
def images 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 end
desc "logs", "Show log lines from app on servers (use --help to show options)" 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 else
lines = options[:lines].presence || ((since || grep) ? nil : 100) # Default to 100 lines if since or grep isn't set 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 = KAMAL.roles_on(host)
roles.each do |role| 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 desc "remove_container [VERSION]", "Remove app container with given version from servers", hide: true
def remove_container(version) def remove_container(version)
with_lock do with_lock do
on(KAMAL.hosts) do |host| on(KAMAL.app_hosts) do |host|
roles = KAMAL.roles_on(host) roles = KAMAL.roles_on(host)
roles.each do |role| 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 desc "remove_containers", "Remove all app containers from servers", hide: true
def remove_containers def remove_containers
with_lock do with_lock do
on(KAMAL.hosts) do |host| on(KAMAL.app_hosts) do |host|
roles = KAMAL.roles_on(host) roles = KAMAL.roles_on(host)
roles.each do |role| 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 desc "remove_images", "Remove all app images from servers", hide: true
def remove_images def remove_images
with_lock do with_lock do
on(KAMAL.hosts) do on(KAMAL.app_hosts) do
execute *KAMAL.auditor.record("Removed all app images"), verbosity: :debug execute *KAMAL.auditor.record("Removed all app images"), verbosity: :debug
execute *KAMAL.app.remove_images execute *KAMAL.app.remove_images
end end
@@ -326,7 +326,7 @@ class Kamal::Cli::App < Kamal::Cli::Base
desc "remove_app_directories", "Remove the app directories from servers", hide: true desc "remove_app_directories", "Remove the app directories from servers", hide: true
def remove_app_directories def remove_app_directories
with_lock do with_lock do
on(KAMAL.hosts) do |host| on(KAMAL.app_hosts) do |host|
roles = KAMAL.roles_on(host) roles = KAMAL.roles_on(host)
roles.each do |role| roles.each do |role|
@@ -342,7 +342,7 @@ class Kamal::Cli::App < Kamal::Cli::Base
desc "version", "Show app version currently running on servers" desc "version", "Show app version currently running on servers"
def version def version
on(KAMAL.hosts) do |host| on(KAMAL.app_hosts) do |host|
role = KAMAL.roles_on(host).first role = KAMAL.roles_on(host).first
puts_by_host host, capture_with_info(*KAMAL.app(role: role, host: host).current_running_version).strip puts_by_host host, capture_with_info(*KAMAL.app(role: role, host: host).current_running_version).strip
end end
@@ -385,6 +385,6 @@ class Kamal::Cli::App < Kamal::Cli::Base
end end
def host_boot_groups 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
end end

View File

@@ -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 say "Pulling image on #{first_hosts.join(", ")} to seed the #{"mirror".pluralize(first_hosts.count)}...", :magenta
pull_on_hosts(first_hosts) pull_on_hosts(first_hosts)
say "Pulling image on remaining hosts...", :magenta say "Pulling image on remaining hosts...", :magenta
pull_on_hosts(KAMAL.hosts - first_hosts) pull_on_hosts(KAMAL.app_hosts - first_hosts)
else else
pull_on_hosts(KAMAL.hosts) pull_on_hosts(KAMAL.app_hosts)
end end
end end
@@ -163,9 +163,9 @@ class Kamal::Cli::Build < Kamal::Cli::Base
end end
def mirror_hosts def mirror_hosts
if KAMAL.hosts.many? if KAMAL.app_hosts.many?
mirror_hosts = Concurrent::Hash.new 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 first_mirror = capture_with_info(*KAMAL.builder.first_mirror).strip.presence
mirror_hosts[first_mirror] ||= host.to_s if first_mirror mirror_hosts[first_mirror] ||= host.to_s if first_mirror
rescue SSHKit::Command::Failed => e rescue SSHKit::Command::Failed => e
@@ -193,7 +193,7 @@ class Kamal::Cli::Build < Kamal::Cli::Base
end end
def login_to_registry_remotely def login_to_registry_remotely
on(KAMAL.hosts) do on(KAMAL.app_hosts) do
execute *KAMAL.registry.login execute *KAMAL.registry.login
end end
end end

View File

@@ -194,10 +194,10 @@ class Kamal::Cli::Main < Kamal::Cli::Base
confirming "This will replace Traefik with kamal-proxy and restart all accessories" do confirming "This will replace Traefik with kamal-proxy and restart all accessories" do
with_lock do with_lock do
if options[:rolling] if options[:rolling]
(KAMAL.hosts | KAMAL.accessory_hosts).each do |host| KAMAL.hosts.each do |host|
KAMAL.with_specific_hosts(host) do KAMAL.with_specific_hosts(host) do
say "Upgrading #{host}...", :magenta 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) invoke "kamal:cli:proxy:upgrade", [], options.merge(confirmed: true, rolling: false)
reset_invocation(Kamal::Cli::Proxy) reset_invocation(Kamal::Cli::Proxy)
end end
@@ -253,7 +253,7 @@ class Kamal::Cli::Main < Kamal::Cli::Base
private private
def container_available?(version) def container_available?(version)
begin begin
on(KAMAL.hosts) do on(KAMAL.app_hosts) do
KAMAL.roles_on(host).each do |role| KAMAL.roles_on(host).each do |role|
container_id = capture_with_info(*KAMAL.app(role: role, host: host).container_id_for_version(version)) container_id = capture_with_info(*KAMAL.app(role: role, host: host).container_id_for_version(version))
raise "Container not found" unless container_id.present? raise "Container not found" unless container_id.present?

View File

@@ -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)" option :interactive, type: :boolean, aliases: "-i", default: false, desc: "Run the command interactively (use for console/bash)"
def exec(*cmd) def exec(*cmd)
cmd = Kamal::Utils.join_commands(cmd) cmd = Kamal::Utils.join_commands(cmd)
hosts = KAMAL.hosts | KAMAL.accessory_hosts hosts = KAMAL.hosts
case case
when options[:interactive] when options[:interactive]
@@ -27,7 +27,7 @@ class Kamal::Cli::Server < Kamal::Cli::Base
with_lock do with_lock do
missing = [] 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) unless execute(*KAMAL.docker.installed?, raise_on_non_zero_exit: false)
if execute(*KAMAL.docker.superuser?, raise_on_non_zero_exit: false) if execute(*KAMAL.docker.superuser?, raise_on_non_zero_exit: false)
info "Missing Docker on #{host}. Installing…" info "Missing Docker on #{host}. Installing…"

View File

@@ -5,7 +5,7 @@ require "active_support/core_ext/object/blank"
class Kamal::Commander class Kamal::Commander
attr_accessor :verbosity, :holding_lock, :connected attr_accessor :verbosity, :holding_lock, :connected
attr_reader :specific_roles, :specific_hosts 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 def initialize
reset reset

View File

@@ -18,6 +18,10 @@ class Kamal::Commander::Specifics
roles.select { |role| role.hosts.include?(host.to_s) } roles.select { |role| role.hosts.include?(host.to_s) }
end end
def app_hosts
config.app_hosts & specified_hosts
end
def proxy_hosts def proxy_hosts
config.proxy_hosts & specified_hosts config.proxy_hosts & specified_hosts
end end

View File

@@ -125,6 +125,10 @@ class Kamal::Configuration
(roles + accessories).flat_map(&:hosts).uniq (roles + accessories).flat_map(&:hosts).uniq
end end
def app_hosts
roles.flat_map(&:hosts).uniq
end
def primary_host def primary_host
primary_role&.primary_host primary_role&.primary_host
end end

View File

@@ -41,3 +41,8 @@ accessories:
cmd: sh -c 'echo "Starting busybox..."; trap exit term; while true; do sleep 1; done' cmd: sh -c 'echo "Starting busybox..."; trap exit term; while true; do sleep 1; done'
roles: roles:
- web - 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

View File

@@ -60,7 +60,7 @@ class MainTest < IntegrationTest
version = latest_app_version version = latest_app_version
assert_equal [ "web" ], config[:roles] 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 "vm1", config[:primary_host]
assert_equal version, config[:version] assert_equal version, config[:version]
assert_equal "registry:4443/app", config[:repository] assert_equal "registry:4443/app", config[:repository]
@@ -88,8 +88,6 @@ class MainTest < IntegrationTest
end end
test "setup and remove" do test "setup and remove" do
@app = "app_with_roles"
kamal :proxy, :boot_config, "set", kamal :proxy, :boot_config, "set",
"--publish=false", "--publish=false",
"--docker-options=label=traefik.http.services.kamal_proxy.loadbalancer.server.scheme=http", "--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 assert_equal "200", Net::HTTP.get_response(URI.parse("http://#{app_host}:12345/versions/.hidden")).code
end end
def vm1_image_ids def image_ids(vm:)
docker_compose("exec vm1 docker image ls -q", capture: true).strip.split("\n") docker_compose("exec #{vm} docker image ls -q", capture: true).strip.split("\n")
end end
def vm1_container_ids def container_ids(vm:)
docker_compose("exec vm1 docker ps -a -q", capture: true).strip.split("\n") docker_compose("exec #{vm} docker ps -a -q", capture: true).strip.split("\n")
end end
def assert_no_images_or_containers def assert_no_images_or_containers
assert vm1_image_ids.empty? [ :vm1, :vm2, :vm3 ].each do |vm|
assert vm1_container_ids.empty? assert image_ids(vm: vm).empty?
assert container_ids(vm: vm).empty?
end
end end
def assert_images_and_containers def assert_images_and_containers
assert vm1_image_ids.any? [ :vm1, :vm2, :vm3 ].each do |vm|
assert vm1_container_ids.any? assert image_ids(vm: vm).any?
assert container_ids(vm: vm).any?
end
end end
end end