From 8a2e132f10850bc73d2fa5564dd52f199ee80daf Mon Sep 17 00:00:00 2001 From: Gilberto Mautner Date: Sat, 21 Mar 2026 12:51:28 -0300 Subject: [PATCH 1/2] Detect accessory image changes during boot and raise actionable error MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Currently `kamal accessory boot` silently skips an accessory when a container already exists, regardless of whether the configured image has changed. This makes it impossible for CI/CD pipelines to detect stale accessories — the only option is to unconditionally reboot all accessories on every deploy, causing unnecessary downtime. This follows the same pattern established for kamal-proxy in the `proxy boot` command, which checks the running proxy version and raises an error when it is outdated, telling the user to run `kamal proxy reboot`. With this change, `accessory boot` inspects the running container's image and compares it to the configured image. When they differ, it raises an error with an actionable message: Accessory `db` image has changed (postgres:16 → postgres:17), run `kamal accessory reboot db` to update This enables CI/CD pipelines to handle accessory updates the same way they handle proxy updates: kamal accessory boot all || kamal accessory reboot all -y When the image has not changed, the accessory is skipped as before. Signed-off-by: Gilberto Mautner --- lib/kamal/cli/accessory.rb | 10 +++++++-- lib/kamal/commands/accessory.rb | 4 ++++ test/cli/accessory_test.rb | 36 +++++++++++++++++++++++++++++++++ 3 files changed, 48 insertions(+), 2 deletions(-) diff --git a/lib/kamal/cli/accessory.rb b/lib/kamal/cli/accessory.rb index 72643ff44..a87f77e6c 100644 --- a/lib/kamal/cli/accessory.rb +++ b/lib/kamal/cli/accessory.rb @@ -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 + if running_image && running_image != accessory.image + raise "Accessory `#{name}` image has changed (#{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(", ")}, already running with the correct image", :yellow hosts -= booted_hosts end diff --git a/lib/kamal/commands/accessory.rb b/lib/kamal/commands/accessory.rb index 12dd5418a..4a38bfb75 100644 --- a/lib/kamal/commands/accessory.rb +++ b/lib/kamal/commands/accessory.rb @@ -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 diff --git a/test/cli/accessory_test.rb b/test/cli/accessory_test.rb index f590be88a..d5b9f323e 100644 --- a/test/cli/accessory_test.rb +++ b/test/cli/accessory_test.rb @@ -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 + + 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") From e7a084cf29e0272a70ede9f8f4612327f2e86f04 Mon Sep 17 00:00:00 2001 From: Gilberto Mautner Date: Sat, 21 Mar 2026 13:02:39 -0300 Subject: [PATCH 2/2] Address review feedback: include host in error, fix skip message - Include host in the image mismatch error message, so multi-host accessories are easier to debug - Change skip message from "already running" to "container already exists" since `docker ps -a` also matches stopped containers Signed-off-by: Gilberto Mautner --- lib/kamal/cli/accessory.rb | 4 ++-- test/cli/accessory_test.rb | 5 +++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/lib/kamal/cli/accessory.rb b/lib/kamal/cli/accessory.rb index a87f77e6c..1def93711 100644 --- a/lib/kamal/cli/accessory.rb +++ b/lib/kamal/cli/accessory.rb @@ -16,14 +16,14 @@ def boot(name, prepare: true) if capture_with_info(*accessory.info(all: true, quiet: true)).strip.presence running_image = capture_with_info(*accessory.running_image).strip.presence if running_image && running_image != accessory.image - 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" end booted_hosts << host.to_s end end if booted_hosts.any? - 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 hosts -= booted_hosts end diff --git a/test/cli/accessory_test.rb b/test/cli/accessory_test.rb index d5b9f323e..2b47476af 100644 --- a/test/cli/accessory_test.rb +++ b/test/cli/accessory_test.rb @@ -34,7 +34,8 @@ class CliAccessoryTest < CliTestCase 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, "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 @@ -50,7 +51,7 @@ class CliAccessoryTest < CliTestCase .returns("private.registry/mysql:5.7") run_command("boot", "mysql").tap do |output| - assert_match "already running with the correct image", output + assert_match "container already exists with the correct image", output assert_no_match(/docker run/, output) end end