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
26 changes: 26 additions & 0 deletions lib/kamal/cli/accessory.rb
Original file line number Diff line number Diff line change
Expand Up @@ -82,9 +82,11 @@ def reboot(name)
else
prepare(name)
pull_image(name)
superseded = superseded_image_ids(name)
stop(name)
remove_container(name)
boot(name, prepare: false)
remove_superseded_images(name, superseded)
Comment thread
djmb marked this conversation as resolved.
end
end
end
Expand Down Expand Up @@ -305,6 +307,30 @@ def upgrade(name)
end

private
# Read the image the accessory is on before its container goes, so the old
# image can be removed once the new one is up.
def superseded_image_ids(name)
Concurrent::Hash.new.tap do |image_ids|
with_accessory(name) do |accessory, hosts|
on(hosts) do |host|
image_ids[host.to_s] = capture_with_info(*accessory.current_image_id, raise_on_non_zero_exit: false).strip.presence
end
end
end
end

def remove_superseded_images(name, image_ids)
with_accessory(name) do |accessory, hosts|
on(hosts) do |host|
next unless (image_id = image_ids[host.to_s])

unless execute(*accessory.remove_image_id(image_id), raise_on_non_zero_exit: false)
info "Kept #{name} image #{image_id[0..18]} on #{host}, docker declined to remove it"
end
end
end
end

def with_accessory(name)
if KAMAL.config.accessory(name)
accessory = KAMAL.accessory(name)
Expand Down
10 changes: 10 additions & 0 deletions lib/kamal/commands/accessory.rb
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,16 @@ def remove_image
docker :image, :rm, "--force", image
end

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

# Refuses while any container still references it, so nothing has to compare
# the old image against the new one.
def remove_image_id(image_id)
docker :image, :rm, image_id
end

def ensure_env_directory
make_directory env_directory
end
Expand Down
12 changes: 12 additions & 0 deletions test/commands/accessory_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,18 @@ class CommandsAccessoryTest < ActiveSupport::TestCase
new_command(:mysql).follow_logs(host: "1.1.1.5", since: "5m", lines: 123)
end

test "current image id names the container, so a stray label match cannot be picked" do
assert_equal \
"docker inspect app-mysql --format '{{.Image}}'",
new_command(:mysql).current_image_id.join(" ")
end

test "remove image id is unforced, so docker refuses while it is in use" do
assert_equal \
"docker image rm sha256:abc123",
new_command(:mysql).remove_image_id("sha256:abc123").join(" ")
end

test "remove container" do
assert_equal \
"docker container prune --force --filter label=service=app-mysql",
Expand Down
29 changes: 27 additions & 2 deletions test/integration/accessory_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,23 @@ class AccessoryTest < IntegrationTest
boot = kamal :accessory, :boot, :busybox, capture: true
assert_match /Skipping booting `busybox` on vm1, vm2, a container already exists/, boot

# Rebooting onto the same image must keep it — docker refuses to remove an
# image a container still references.
reboot = kamal :accessory, :reboot, :busybox, capture: true
assert_match /Kept busybox image sha256:\h+ on vm\d, docker declined to remove it/, reboot
assert_accessory_running :busybox
assert_includes docker_compose("exec vm1 docker image ls busybox --format '{{.Repository}}:{{.Tag}}'", capture: true), "busybox:1.36.0"

# Point the accessory at a different image, so the reboot supersedes the one
# it is running and the old id becomes unreferenced.
superseded = accessory_image_id
deployer_exec "sed -i 's|image: busybox:1.36.0|image: busybox:1.37.0|' config/deploy.yml"

kamal :accessory, :reboot, :busybox
assert_accessory_running :busybox, version: "1.37.0"
assert_not_equal superseded, accessory_image_id
assert_not_includes vm1_image_ids, superseded

@fern4lvarez fern4lvarez Sep 7, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Fixed — the superseded image id is now collected and asserted per host, and assert_accessory_not_running no longer names 1.36.0.


kamal :accessory, :remove, :busybox, "-y"
assert_accessory_not_running :busybox
end
Expand Down Expand Up @@ -54,8 +71,8 @@ class AccessoryTest < IntegrationTest
end

private
def assert_accessory_running(name)
assert_match /busybox:1.36.0 "sh -c 'echo \\"Start/, accessory_details(name)
def assert_accessory_running(name, version: "1.36.0")
assert_match /busybox:#{version} "sh -c 'echo \\"Start/, accessory_details(name)
end

def assert_accessory_not_running(name)
Expand All @@ -77,6 +94,14 @@ def assert_accessory_directory_mode_and_owner(name)
assert_match /750 1000:1000/, dir_stat, "Expected directory to have 750 mode and 1000:1000 owner"
end

def accessory_image_id
docker_compose("exec vm1 docker inspect custom-busybox --format '{{.Image}}'", capture: true).strip
end

def vm1_image_ids
docker_compose("exec vm1 docker image ls -aq --no-trunc", capture: true).lines.map(&:strip)
end

def accessory_details(name)
kamal :accessory, :details, name, capture: true
end
Expand Down