Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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 (#{running_image} → #{accessory.image}), run `kamal accessory reboot #{name}` to update"
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 raised "image has changed" error doesn't include which host triggered the mismatch. Since accessories can run on multiple hosts, this can be hard to act on when only some hosts are stale. Consider including host in the message (or collecting all mismatching hosts/images and raising once after the on(hosts) block).

Suggested change
raise "Accessory `#{name}` image has changed (#{running_image}#{accessory.image}), run `kamal accessory reboot #{name}` to update"
raise "Accessory `#{name}` image has changed on host #{host} (#{running_image}#{accessory.image}), run `kamal accessory reboot #{name}` to update"

Copilot uses AI. Check for mistakes.
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(", ")}, already running with the correct image", :yellow
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.

accessory.info(all: true, ...) uses docker ps -a, so it will match stopped containers as well as running ones. The skip message says "already running with the correct image", which can be inaccurate when a container exists but is not running; consider wording that reflects "container already exists" (or switch the existence check to running-only if that’s the intent).

Suggested change
say "Skipping booting `#{name}` on #{booted_hosts.sort.join(", ")}, already running with the correct image", :yellow
say "Skipping booting `#{name}` on #{booted_hosts.sort.join(", ")}, container already exists with the correct image", :yellow

Copilot uses AI. Check for mistakes.
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
36 changes: 36 additions & 0 deletions test/cli/accessory_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,42 @@ 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 (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
Comment on lines +23 to +42
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.

This test mutates the global Thread.report_on_exception and then forces it back to true in ensure. To avoid leaking global state across the suite (especially if another test sets it differently), save the original value and restore that value in the ensure block instead of always setting true.

Copilot uses AI. Check for mistakes.

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 "already running 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
Loading