Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions lib/kamal/cli/accessory.rb
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,17 @@ def boot(name, prepare: true)
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
if capture_with_info(*accessory.info(all: true, quiet: true)).strip.presence
running_image = capture_with_info(*accessory.running_image).strip.presence
Comment on lines +16 to +17
Copy link

Copilot AI Mar 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The container existence check uses docker ps -a -q --filter label=service=..., but the follow-up docker inspect targets service_name (container name). If a container matches the label but is not named exactly service_name (e.g., renamed/manual container), docker inspect will fail and abort boot. Consider capturing the container id(s) from accessory.info(...) and inspecting that id (or first id) instead, so the inspect target matches the existence filter.

Suggested change
if capture_with_info(*accessory.info(all: true, quiet: true)).strip.presence
running_image = capture_with_info(*accessory.running_image).strip.presence
container_ids = capture_with_info(*accessory.info(all: true, quiet: true)).strip
if container_ids.present?
container_id = container_ids.split(/\s+/).first
running_image = capture_with_info("docker", "inspect", "--format", "{{.Config.Image}}", container_id).strip.presence

Copilot uses AI. Check for mistakes.
if running_image && running_image != accessory.image
raise "Accessory `#{name}` image has changed on host #{host} (#{running_image} → #{accessory.image}), run `kamal accessory reboot #{name}` to update"
end
booted_hosts << host.to_s
end
end

if booted_hosts.any?
say "Skipping booting `#{name}` on #{booted_hosts.sort.join(", ")}, a container already exists", :yellow
say "Skipping booting `#{name}` on #{booted_hosts.sort.join(", ")}, container already exists with the correct image", :yellow
hosts -= booted_hosts
end

Expand Down
4 changes: 4 additions & 0 deletions lib/kamal/commands/accessory.rb
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,10 @@ def ensure_local_file_present(local_file)
end
end

def running_image
docker :inspect, service_name, "--format '{{.Config.Image}}'"
end

def pull_image
docker :image, :pull, image
end
Expand Down
37 changes: 37 additions & 0 deletions test/cli/accessory_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,43 @@ class CliAccessoryTest < CliTestCase
end
end

test "boot with image changed" do
Thread.report_on_exception = false

SSHKit::Backend::Abstract.any_instance.stubs(:capture_with_info)
.with(:docker, :ps, "-a", "-q", "--filter", "label=service=app-mysql")
.returns("abc123")

SSHKit::Backend::Abstract.any_instance.stubs(:capture_with_info)
.with(:docker, :inspect, "app-mysql", "--format '{{.Config.Image}}'")
.returns("private.registry/mysql:5.6")

exception = assert_raises do
run_command("boot", "mysql")
end

assert_includes exception.message, "Accessory `mysql` image has changed on host"
assert_includes exception.message, "(private.registry/mysql:5.6 → private.registry/mysql:5.7)"
assert_includes exception.message, "run `kamal accessory reboot mysql` to update"
ensure
Thread.report_on_exception = true
end

test "boot with same image" do
SSHKit::Backend::Abstract.any_instance.stubs(:capture_with_info)
.with(:docker, :ps, "-a", "-q", "--filter", "label=service=app-mysql")
.returns("abc123")

SSHKit::Backend::Abstract.any_instance.stubs(:capture_with_info)
.with(:docker, :inspect, "app-mysql", "--format '{{.Config.Image}}'")
.returns("private.registry/mysql:5.7")

run_command("boot", "mysql").tap do |output|
assert_match "container already exists with the correct image", output
assert_no_match(/docker run/, output)
end
end

test "boot all" do
Kamal::Cli::Accessory.any_instance.expects(:directories).with("mysql")
Kamal::Cli::Accessory.any_instance.expects(:upload).with("mysql")
Expand Down