docker_image(_push): fix push detection (#1199)

* Fix IP address retrieval for registry setup.

* Adjust push detection to Docker 29.

* Idempotency for export no longer works.

* Disable pull idempotency checks that play with architecture.

* Add more known image IDs.

* Adjust load tests.

* Adjust error message check.

* Allow for more digests.

* Make sure a new enough cryptography version is installed.
This commit is contained in:
Felix Fontein 2025-11-16 10:09:23 +01:00 committed by GitHub
parent 90c4b4c543
commit d207643e0c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 125 additions and 37 deletions

View File

@ -0,0 +1,5 @@
bugfixes:
- "docker_image, docker_image_push - adjust image push detection to Docker 29 (https://github.com/ansible-collections/community.docker/pull/1199)."
known_issues:
- "docker_image, docker_image_export - idempotency for archiving images depends on whether the image IDs used by the image storage backend correspond to the IDs used in the tarball's ``manifest.json`` files.
The new default backend in Docker 29 apparently uses image IDs that no longer correspond, whence idempotency no longer works (https://github.com/ansible-collections/community.docker/pull/1199)."

View File

@ -21,6 +21,8 @@ description:
notes: notes:
- Building images is done using Docker daemon's API. It is not possible to use BuildKit / buildx this way. Use M(community.docker.docker_image_build) - Building images is done using Docker daemon's API. It is not possible to use BuildKit / buildx this way. Use M(community.docker.docker_image_build)
to build images with BuildKit. to build images with BuildKit.
- Exporting images is generally not idempotent. It depends on whether the image ID equals the IDs found in the generated tarball's C(manifest.json).
This was the case with the default storage backend up to Docker 28, but seems to have changed in Docker 29.
extends_documentation_fragment: extends_documentation_fragment:
- community.docker._docker.api_documentation - community.docker._docker.api_documentation
- community.docker._attributes - community.docker._attributes
@ -803,7 +805,7 @@ class ImageManager(DockerBaseClass):
if line.get("errorDetail"): if line.get("errorDetail"):
raise RuntimeError(line["errorDetail"]["message"]) raise RuntimeError(line["errorDetail"]["message"])
status = line.get("status") status = line.get("status")
if status == "Pushing": if status in ("Pushing", "Pushed"):
changed = True changed = True
self.results["changed"] = changed self.results["changed"] = changed
except Exception as exc: # pylint: disable=broad-exception-caught except Exception as exc: # pylint: disable=broad-exception-caught

View File

@ -28,7 +28,13 @@ attributes:
diff_mode: diff_mode:
support: none support: none
idempotent: idempotent:
support: full support: partial
details:
- Whether the module is idempotent depends on the storage API used for images,
which determines how the image ID is computed. The idempotency check needs
that the image ID equals the ID stored in archive's C(manifest.json).
This seemed to have worked fine with the default storage backend up to Docker 28,
but seems to have changed in Docker 29.
options: options:
names: names:

View File

@ -159,7 +159,7 @@ class ImagePusher(DockerBaseClass):
if line.get("errorDetail"): if line.get("errorDetail"):
raise RuntimeError(line["errorDetail"]["message"]) raise RuntimeError(line["errorDetail"]["message"])
status = line.get("status") status = line.get("status")
if status == "Pushing": if status in ("Pushing", "Pushed"):
results["changed"] = True results["changed"] = True
except Exception as exc: # pylint: disable=broad-exception-caught except Exception as exc: # pylint: disable=broad-exception-caught
if "unauthorized" in str(exc): if "unauthorized" in str(exc):

View File

@ -219,6 +219,7 @@ class ImageRemover(DockerBaseClass):
elif is_image_name_id(name): elif is_image_name_id(name):
deleted.append(image["Id"]) deleted.append(image["Id"])
# TODO: the following is no longer correct with Docker 29+...
untagged[:] = sorted( untagged[:] = sorted(
(image.get("RepoTags") or []) + (image.get("RepoDigests") or []) (image.get("RepoTags") or []) + (image.get("RepoDigests") or [])
) )

View File

@ -256,6 +256,10 @@
- ansible.builtin.assert: - ansible.builtin.assert:
that: that:
- archive_image_2 is not changed - archive_image_2 is not changed
when: docker_cli_version is version("29.0.0", "<")
# Apparently idempotency no longer works with the default storage backend
# in Docker 29.0.0.
# https://github.com/ansible-collections/community.docker/pull/1199
- name: Archive image 3rd time, should overwrite due to different id - name: Archive image 3rd time, should overwrite due to different id
community.docker.docker_image: community.docker.docker_image:

View File

@ -67,3 +67,7 @@
manifests_json: "{{ manifests.results | map(attribute='stdout') | map('from_json') }}" manifests_json: "{{ manifests.results | map(attribute='stdout') | map('from_json') }}"
manifest_json_images: "{{ item.2 | map(attribute='Config') | map('regex_replace', '.json$', '') | map('regex_replace', '^blobs/sha256/', '') | sort }}" manifest_json_images: "{{ item.2 | map(attribute='Config') | map('regex_replace', '.json$', '') | map('regex_replace', '^blobs/sha256/', '') | sort }}"
export_image_ids: "{{ item.1 | map('regex_replace', '^sha256:', '') | unique | sort }}" export_image_ids: "{{ item.1 | map('regex_replace', '^sha256:', '') | unique | sort }}"
when: docker_cli_version is version("29.0.0", "<")
# Apparently idempotency no longer works with the default storage backend
# in Docker 29.0.0.
# https://github.com/ansible-collections/community.docker/pull/1199

View File

@ -73,11 +73,17 @@
loop: "{{ all_images }}" loop: "{{ all_images }}"
when: remove_all_images is failed when: remove_all_images is failed
- name: Show all images
ansible.builtin.command: docker image ls
- name: Load all images (IDs) - name: Load all images (IDs)
community.docker.docker_image_load: community.docker.docker_image_load:
path: "{{ remote_tmp_dir }}/archive-2.tar" path: "{{ remote_tmp_dir }}/archive-2.tar"
register: result register: result
- name: Show all images
ansible.builtin.command: docker image ls
- name: Print loaded image names - name: Print loaded image names
ansible.builtin.debug: ansible.builtin.debug:
var: result.image_names var: result.image_names
@ -110,11 +116,17 @@
name: "{{ item }}" name: "{{ item }}"
loop: "{{ all_images }}" loop: "{{ all_images }}"
- name: Show all images
ansible.builtin.command: docker image ls
- name: Load all images (mixed images and IDs) - name: Load all images (mixed images and IDs)
community.docker.docker_image_load: community.docker.docker_image_load:
path: "{{ remote_tmp_dir }}/archive-3.tar" path: "{{ remote_tmp_dir }}/archive-3.tar"
register: result register: result
- name: Show all images
ansible.builtin.command: docker image ls
- name: Print loading log - name: Print loading log
ansible.builtin.debug: ansible.builtin.debug:
var: result.stdout_lines var: result.stdout_lines
@ -127,10 +139,14 @@
that: that:
- result is changed - result is changed
# For some reason, *sometimes* only the named image is found; in fact, in that case, the log only mentions that image and nothing else # For some reason, *sometimes* only the named image is found; in fact, in that case, the log only mentions that image and nothing else
- "result.images | length == 3 or ('Loaded image: ' ~ docker_test_image_hello_world) == result.stdout" # With Docker 29, a third possibility appears: just two entries.
- (result.image_names | sort) in [[image_names[0], image_ids[0], image_ids[1]] | sort, [image_names[0]]] - >-
- result.images | length in [1, 3] result.images | length == 3
- (result.images | map(attribute='Id') | sort) in [[image_ids[0], image_ids[0], image_ids[1]] | sort, [image_ids[0]]] or ('Loaded image: ' ~ docker_test_image_hello_world) == result.stdout
or result.images | length == 2
- (result.image_names | sort) in [[image_names[0], image_ids[0], image_ids[1]] | sort, [image_names[0], image_ids[1]] | sort, [image_names[0]]]
- result.images | length in [1, 2, 3]
- (result.images | map(attribute='Id') | sort) in [[image_ids[0], image_ids[0], image_ids[1]] | sort, [image_ids[0], image_ids[1]] | sort, [image_ids[0]]]
# Same image twice # Same image twice
@ -139,11 +155,17 @@
name: "{{ item }}" name: "{{ item }}"
loop: "{{ all_images }}" loop: "{{ all_images }}"
- name: Show all images
ansible.builtin.command: docker image ls
- name: Load all images (same image twice) - name: Load all images (same image twice)
community.docker.docker_image_load: community.docker.docker_image_load:
path: "{{ remote_tmp_dir }}/archive-4.tar" path: "{{ remote_tmp_dir }}/archive-4.tar"
register: result register: result
- name: Show all images
ansible.builtin.command: docker image ls
- name: Print loaded image names - name: Print loaded image names
ansible.builtin.debug: ansible.builtin.debug:
var: result.image_names var: result.image_names
@ -151,10 +173,11 @@
- ansible.builtin.assert: - ansible.builtin.assert:
that: that:
- result is changed - result is changed
- result.image_names | length == 1 - result.image_names | length in [1, 2]
- result.image_names[0] == image_names[0] - (result.image_names | sort) in [[image_names[0]], [image_names[0], image_ids[0]] | sort]
- result.images | length == 1 - result.images | length in [1, 2]
- result.images[0].Id == image_ids[0] - result.images[0].Id == image_ids[0]
- result.images[1].Id | default(image_ids[0]) == image_ids[0]
# Single image by ID # Single image by ID
@ -163,11 +186,17 @@
name: "{{ item }}" name: "{{ item }}"
loop: "{{ all_images }}" loop: "{{ all_images }}"
- name: Show all images
ansible.builtin.command: docker image ls
- name: Load all images (single image by ID) - name: Load all images (single image by ID)
community.docker.docker_image_load: community.docker.docker_image_load:
path: "{{ remote_tmp_dir }}/archive-5.tar" path: "{{ remote_tmp_dir }}/archive-5.tar"
register: result register: result
- name: Show all images
ansible.builtin.command: docker image ls
- name: Print loaded image names - name: Print loaded image names
ansible.builtin.debug: ansible.builtin.debug:
var: result.image_names var: result.image_names
@ -197,11 +226,17 @@
name: "{{ item }}" name: "{{ item }}"
loop: "{{ all_images }}" loop: "{{ all_images }}"
- name: Show all images
ansible.builtin.command: docker image ls
- name: Load all images (names) - name: Load all images (names)
community.docker.docker_image_load: community.docker.docker_image_load:
path: "{{ remote_tmp_dir }}/archive-1.tar" path: "{{ remote_tmp_dir }}/archive-1.tar"
register: result register: result
- name: Show all images
ansible.builtin.command: docker image ls
- name: Print loaded image names - name: Print loaded image names
ansible.builtin.debug: ansible.builtin.debug:
var: result.image_names var: result.image_names

View File

@ -142,6 +142,8 @@
- present_3_check.actions[0] == ('Pulled image ' ~ image_name) - present_3_check.actions[0] == ('Pulled image ' ~ image_name)
- present_3_check.diff.before.id == present_1.diff.after.id - present_3_check.diff.before.id == present_1.diff.after.id
- present_3_check.diff.after.id == 'unknown' - present_3_check.diff.after.id == 'unknown'
- ansible.builtin.assert:
that:
- present_3 is changed - present_3 is changed
- present_3.actions | length == 1 - present_3.actions | length == 1
- present_3.actions[0] == ('Pulled image ' ~ image_name) - present_3.actions[0] == ('Pulled image ' ~ image_name)
@ -166,6 +168,11 @@
- present_5.actions[0] == ('Pulled image ' ~ image_name) - present_5.actions[0] == ('Pulled image ' ~ image_name)
- present_5.diff.before.id == present_3.diff.after.id - present_5.diff.before.id == present_3.diff.after.id
- present_5.diff.after.id == present_1.diff.after.id - present_5.diff.after.id == present_1.diff.after.id
when: docker_cli_version is version("29.0.0", "<")
# From Docker 29 on, Docker won't pull images for other architectures
# if there are better matching ones. The above tests assume it will
# just do what it is told, and thus fail from 29.0.0 on.
# https://github.com/ansible-collections/community.docker/pull/1199
always: always:
- name: cleanup - name: cleanup

View File

@ -7,11 +7,9 @@
block: block:
- name: Make sure images are not there - name: Make sure images are not there
community.docker.docker_image_remove: community.docker.docker_image_remove:
name: "{{ item }}" name: "sha256:{{ item }}"
force: true force: true
loop: loop: "{{ docker_test_image_digest_v1_image_ids + docker_test_image_digest_v2_image_ids }}"
- "sha256:{{ docker_test_image_digest_v1_image_id }}"
- "sha256:{{ docker_test_image_digest_v2_image_id }}"
- name: Pull image 1 - name: Pull image 1
community.docker.docker_image_pull: community.docker.docker_image_pull:
@ -82,8 +80,6 @@
always: always:
- name: cleanup - name: cleanup
community.docker.docker_image_remove: community.docker.docker_image_remove:
name: "{{ item }}" name: "sha256:{{ item }}"
force: true force: true
loop: loop: "{{ docker_test_image_digest_v1_image_ids + docker_test_image_digest_v2_image_ids }}"
- "sha256:{{ docker_test_image_digest_v1_image_id }}"
- "sha256:{{ docker_test_image_digest_v2_image_id }}"

View File

@ -18,7 +18,7 @@
- name: Push image ID (must fail) - name: Push image ID (must fail)
community.docker.docker_image_push: community.docker.docker_image_push:
name: "sha256:{{ docker_test_image_digest_v1_image_id }}" name: "sha256:{{ docker_test_image_digest_v1_image_ids[0] }}"
register: fail_2 register: fail_2
ignore_errors: true ignore_errors: true

View File

@ -80,4 +80,6 @@
that: that:
- push_4 is failed - push_4 is failed
- >- - >-
push_4.msg == ('Error pushing image ' ~ image_name_base2 ~ ':' ~ image_tag ~ ': no basic auth credentials') push_4.msg.startswith('Error pushing image ' ~ image_name_base2 ~ ':' ~ image_tag ~ ': ')
- >-
push_4.msg.endswith(': no basic auth credentials')

View File

@ -8,15 +8,16 @@
# and should not be used as examples of how to write Ansible roles # # and should not be used as examples of how to write Ansible roles #
#################################################################### ####################################################################
- block: - vars:
image: "{{ docker_test_image_hello_world }}"
image_ids: "{{ docker_test_image_hello_world_image_ids }}"
block:
- name: Pick image prefix - name: Pick image prefix
ansible.builtin.set_fact: ansible.builtin.set_fact:
iname_prefix: "{{ 'ansible-docker-test-%0x' % ((2**32) | random) }}" iname_prefix: "{{ 'ansible-docker-test-%0x' % ((2**32) | random) }}"
- name: Define image names - name: Define image names
ansible.builtin.set_fact: ansible.builtin.set_fact:
image: "{{ docker_test_image_hello_world }}"
image_id: "{{ docker_test_image_hello_world_image_id }}"
image_names: image_names:
- "{{ iname_prefix }}-tagged-1:latest" - "{{ iname_prefix }}-tagged-1:latest"
- "{{ iname_prefix }}-tagged-1:foo" - "{{ iname_prefix }}-tagged-1:foo"
@ -24,8 +25,9 @@
- name: Remove image complete - name: Remove image complete
community.docker.docker_image_remove: community.docker.docker_image_remove:
name: "{{ image_id }}" name: "{{ item }}"
force: true force: true
loop: "{{ image_ids }}"
- name: Remove tagged images - name: Remove tagged images
community.docker.docker_image_remove: community.docker.docker_image_remove:
@ -102,10 +104,11 @@
- remove_2 is changed - remove_2 is changed
- remove_2.diff.before.id == pulled_image.image.Id - remove_2.diff.before.id == pulled_image.image.Id
- remove_2.diff.before.tags | length == 4 - remove_2.diff.before.tags | length == 4
- remove_2.diff.before.digests | length == 1 # With Docker 29, there are now two digests in before and after:
- remove_2.diff.before.digests | length in [1, 2]
- remove_2.diff.after.id == pulled_image.image.Id - remove_2.diff.after.id == pulled_image.image.Id
- remove_2.diff.after.tags | length == 3 - remove_2.diff.after.tags | length == 3
- remove_2.diff.after.digests | length == 1 - remove_2.diff.after.digests | length in [1, 2]
- remove_2.deleted | length == 0 - remove_2.deleted | length == 0
- remove_2.untagged | length == 1 - remove_2.untagged | length == 1
- remove_2.untagged[0] == (iname_prefix ~ '-tagged-1:latest') - remove_2.untagged[0] == (iname_prefix ~ '-tagged-1:latest')
@ -174,10 +177,11 @@
- remove_4 is changed - remove_4 is changed
- remove_4.diff.before.id == pulled_image.image.Id - remove_4.diff.before.id == pulled_image.image.Id
- remove_4.diff.before.tags | length == 3 - remove_4.diff.before.tags | length == 3
- remove_4.diff.before.digests | length == 1 # With Docker 29, there are now two digests in before and after:
- remove_4.diff.before.digests | length in [1, 2]
- remove_4.diff.after.id == pulled_image.image.Id - remove_4.diff.after.id == pulled_image.image.Id
- remove_4.diff.after.tags | length == 2 - remove_4.diff.after.tags | length == 2
- remove_4.diff.after.digests | length == 1 - remove_4.diff.after.digests | length in [1, 2]
- remove_4.deleted | length == 0 - remove_4.deleted | length == 0
- remove_4.untagged | length == 1 - remove_4.untagged | length == 1
- remove_4.untagged[0] == (iname_prefix ~ '-tagged-1:foo') - remove_4.untagged[0] == (iname_prefix ~ '-tagged-1:foo')
@ -245,16 +249,22 @@
- remove_6 is changed - remove_6 is changed
- remove_6.diff.before.id == pulled_image.image.Id - remove_6.diff.before.id == pulled_image.image.Id
- remove_6.diff.before.tags | length == 2 - remove_6.diff.before.tags | length == 2
- remove_6.diff.before.digests | length == 1 # With Docker 29, there are now two digests in before and after:
- remove_6.diff.before.digests | length in [1, 2]
- remove_6.diff.after.exists is false - remove_6.diff.after.exists is false
- remove_6.deleted | length > 1 - remove_6.deleted | length >= 1
- pulled_image.image.Id in remove_6.deleted - pulled_image.image.Id in remove_6.deleted
- remove_6.untagged | length == 3 - remove_6.untagged | length in [2, 3]
- (iname_prefix ~ '-tagged-1:bar') in remove_6.untagged - (iname_prefix ~ '-tagged-1:bar') in remove_6.untagged
- image in remove_6.untagged - image in remove_6.untagged
- remove_6_check.deleted | length == 1 - remove_6_check.deleted | length == 1
- remove_6_check.deleted[0] == pulled_image.image.Id - remove_6_check.deleted[0] == pulled_image.image.Id
- remove_6_check.untagged == remove_6.untagged # The following is only true for Docker < 29...
# We use the CLI version as a proxy...
- >-
remove_6_check.untagged == remove_6.untagged
or
docker_cli_version is version("29.0.0", ">=")
- info_5.images | length == 0 - info_5.images | length == 0
- name: Remove image ID (force, idempotent, check mode) - name: Remove image ID (force, idempotent, check mode)

View File

@ -4,12 +4,18 @@
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-3.0-or-later
docker_test_image_digest_v1: e004c2cc521c95383aebb1fb5893719aa7a8eae2e7a71f316a4410784edb00a9 docker_test_image_digest_v1: e004c2cc521c95383aebb1fb5893719aa7a8eae2e7a71f316a4410784edb00a9
docker_test_image_digest_v1_image_id: 758ec7f3a1ee85f8f08399b55641bfb13e8c1109287ddc5e22b68c3d653152ee docker_test_image_digest_v1_image_ids:
- 758ec7f3a1ee85f8f08399b55641bfb13e8c1109287ddc5e22b68c3d653152ee # Docker 28 and before
- e004c2cc521c95383aebb1fb5893719aa7a8eae2e7a71f316a4410784edb00a9 # Docker 29
docker_test_image_digest_v2: ee44b399df993016003bf5466bd3eeb221305e9d0fa831606bc7902d149c775b docker_test_image_digest_v2: ee44b399df993016003bf5466bd3eeb221305e9d0fa831606bc7902d149c775b
docker_test_image_digest_v2_image_id: dc3bacd8b5ea796cea5d6070c8f145df9076f26a6bc1c8981fd5b176d37de843 docker_test_image_digest_v2_image_ids:
- dc3bacd8b5ea796cea5d6070c8f145df9076f26a6bc1c8981fd5b176d37de843 # Docker 28 and before
- ee44b399df993016003bf5466bd3eeb221305e9d0fa831606bc7902d149c775b # Docker 29
docker_test_image_digest_base: quay.io/ansible/docker-test-containers docker_test_image_digest_base: quay.io/ansible/docker-test-containers
docker_test_image_hello_world: quay.io/ansible/docker-test-containers:hello-world docker_test_image_hello_world: quay.io/ansible/docker-test-containers:hello-world
docker_test_image_hello_world_image_id: sha256:bf756fb1ae65adf866bd8c456593cd24beb6a0a061dedf42b26a993176745f6b docker_test_image_hello_world_image_ids:
- sha256:bf756fb1ae65adf866bd8c456593cd24beb6a0a061dedf42b26a993176745f6b # Docker 28 and before
- sha256:90659bf80b44ce6be8234e6ff90a1ac34acbeb826903b02cfa0da11c82cbc042 # Docker 29
docker_test_image_hello_world_base: quay.io/ansible/docker-test-containers docker_test_image_hello_world_base: quay.io/ansible/docker-test-containers
docker_test_image_busybox: quay.io/ansible/docker-test-containers:busybox docker_test_image_busybox: quay.io/ansible/docker-test-containers:busybox
docker_test_image_alpine: quay.io/ansible/docker-test-containers:alpine3.8 docker_test_image_alpine: quay.io/ansible/docker-test-containers:alpine3.8

View File

@ -102,7 +102,17 @@
# This host/port combination cannot be used if the tests are running inside a docker container. # This host/port combination cannot be used if the tests are running inside a docker container.
docker_registry_frontend_address: localhost:{{ nginx_container.container.NetworkSettings.Ports['5000/tcp'].0.HostPort }} docker_registry_frontend_address: localhost:{{ nginx_container.container.NetworkSettings.Ports['5000/tcp'].0.HostPort }}
# The following host/port combination can be used from inside the docker container. # The following host/port combination can be used from inside the docker container.
docker_registry_frontend_address_internal: "{{ nginx_container.container.NetworkSettings.Networks[current_container_network_ip].IPAddress if current_container_network_ip else nginx_container.container.NetworkSettings.IPAddress }}:5000" docker_registry_frontend_address_internal: >-
{{
nginx_container.container.NetworkSettings.Networks[current_container_network_ip].IPAddress
if current_container_network_ip else
(
nginx_container.container.NetworkSettings.IPAddress
| default(nginx_container.container.NetworkSettings.Networks['bridge'].IPAddress)
)
}}:5000
# Since Docker 29, nginx_container.container.NetworkSettings.IPAddress no longer exists.
# Use the bridge network's IP address instead...
- name: Wait for registry frontend - name: Wait for registry frontend
ansible.builtin.uri: ansible.builtin.uri:

View File

@ -27,7 +27,7 @@
- name: Install cryptography (Darwin, and potentially upgrade for other OSes) - name: Install cryptography (Darwin, and potentially upgrade for other OSes)
become: true become: true
ansible.builtin.pip: ansible.builtin.pip:
name: cryptography>=1.3.0 name: cryptography>=3.3.0
extra_args: "-c {{ remote_constraints }}" extra_args: "-c {{ remote_constraints }}"
- name: Register cryptography version - name: Register cryptography version