From b4bbc8cb42c52c4d1ebf763d82ca3dfc10b1021e Mon Sep 17 00:00:00 2001 From: gw Date: Sat, 3 Aug 2024 19:38:41 +0100 Subject: [PATCH] Trigger a new image build when we detect that the Containerfile has changed. Signed-off-by: gw --- plugins/modules/podman_image.py | 94 ++++++++++++++++++- .../targets/podman_image/tasks/main.yml | 30 ++++++ 2 files changed, 121 insertions(+), 3 deletions(-) diff --git a/plugins/modules/podman_image.py b/plugins/modules/podman_image.py index 052d7fb3..c2c60626 100644 --- a/plugins/modules/podman_image.py +++ b/plugins/modules/podman_image.py @@ -416,6 +416,7 @@ import shlex # noqa: E402 import tempfile # noqa: E402 import time # noqa: E402 +import hashlib # noqa: E402 from ansible.module_utils._text import to_native from ansible.module_utils.basic import AnsibleModule @@ -491,15 +492,99 @@ def _get_id_from_output(self, lines, startswith=None, contains=None, split_on=' return layer_ids[-1] + def _find_containerfile_from_context(self): + """ + Find a Containerfile/Dockerfile path inside a podman build context. + Return an empty string if none exist. + """ + + containerfile_path = "" + for filename in [os.path.join(self.path, fname) for fname in ["Containerfile", "Dockerfile"]]: + if os.path.exists(filename): + containerfile_path = filename + break + return containerfile_path + + def _get_containerfile_contents(self): + """ + Get the path to the Containerfile for an invocation + of the module, and return its contents. + + See if either `file` or `container_file` in build args are populated, + fetch their contents if so. If not, return the contents of the Containerfile + or Dockerfile from inside the build context. + """ + + build_file_arg = self.build.get('file') if self.build else None + containerfile_contents = self.build.get('container_file') if self.build else None + + container_filename = "" + if build_file_arg: + container_filename = build_file_arg + elif self.path and not build_file_arg: + container_filename = self._find_containerfile_from_context() + + if not containerfile_contents: + with open(container_filename) as f: + containerfile_contents = f.read() + + return containerfile_contents + + def _hash_containerfile_contents(self, containerfile_contents): + """ + When given the contents of a Containerfile/Dockerfile, + return a sha256 hash of these contents. + """ + return hashlib.sha256( + containerfile_contents.encode(), + usedforsecurity=False + ).hexdigest() + + def _get_args_containerfile_hash(self): + """ + If we can find a Containerfile in any of the module args + or inside the build context, hash its contents. + + If we don't have this, return an empty string. + """ + + args_containerfile_hash = "" + + context_has_containerfile = self.path and self._find_containerfile_from_context() != "" + + should_hash_args_containerfile = ( + context_has_containerfile or + self.build.get('file') is not None or + self.build.get('container_file') is not None + ) + + if should_hash_args_containerfile: + args_containerfile_hash = self._hash_containerfile_contents( + self._get_containerfile_contents() + ) + return args_containerfile_hash + def present(self): image = self.find_image() + existing_image_containerfile_hash = "" + args_containerfile_hash = self._get_args_containerfile_hash() + if image: digest_before = image[0].get('Digest', image[0].get('digest')) + labels = image[0].get('Labels') or {} + if "containerfile.hash" in labels: + existing_image_containerfile_hash = labels["containerfile.hash"] else: digest_before = None - if not image or self.force: + both_hashes_exist_and_differ = ( + args_containerfile_hash != "" and + existing_image_containerfile_hash != "" and + args_containerfile_hash != existing_image_containerfile_hash + ) + + if not image or self.force or both_hashes_exist_and_differ: if self.state == 'build' or self.path: # Build the image build_file = self.build.get('file') if self.build else None @@ -513,7 +598,7 @@ def present(self): self.results['actions'].append('Built image {image_name} from {path}'.format( image_name=self.image_name, path=self.path or 'default context')) if not self.module.check_mode: - self.results['image'], self.results['stdout'] = self.build_image() + self.results['image'], self.results['stdout'] = self.build_image(args_containerfile_hash) image = self.results['image'] else: # Pull the image @@ -649,7 +734,7 @@ def pull_image(self, image_name=None): msg='Failed to pull image {image_name}'.format(image_name=image_name)) return self.inspect_image(out.strip()) - def build_image(self): + def build_image(self, containerfile_hash): args = ['build'] args.extend(['-t', self.image_name]) @@ -698,6 +783,9 @@ def build_image(self): f.write(container_file_txt) args.extend(['--file', container_file_path]) + if containerfile_hash != "": + args.extend(['--label', f"containerfile.hash={containerfile_hash}"]) + volume = self.build.get('volume') if volume: for v in volume: diff --git a/tests/integration/targets/podman_image/tasks/main.yml b/tests/integration/targets/podman_image/tasks/main.yml index 421f2c73..b5a3ca56 100644 --- a/tests/integration/targets/podman_image/tasks/main.yml +++ b/tests/integration/targets/podman_image/tasks/main.yml @@ -237,6 +237,29 @@ register: oci_build6 ignore_errors: true + - name: Build OCI image testimage6 twice with the same Containerfile + containers.podman.podman_image: + executable: "{{ test_executable | default('podman') }}" + name: testimage6 + state: build + build: + format: oci + container_file: |- + FROM quay.io/coreos/alpine-sh + register: oci_build7 + loop: [0, 1] + + - name: Build OCI image testimage6 with a different Containerfile + containers.podman.podman_image: + executable: "{{ test_executable | default('podman') }}" + name: testimage6 + state: build + build: + format: oci + container_file: |- + FROM docker.io/alpine + register: oci_build8 + - name: Inspect first image containers.podman.podman_image_info: executable: "{{ test_executable | default('podman') }}" @@ -259,6 +282,13 @@ - oci_build4 is success - oci_build5 is success - oci_build6 is failed + # The following line tests that building an image twice with + # the same Containerfile doesn't rebuild the image. + - oci_build7.results[1] is not changed + # oci_build8 tests that building an image with the same name + # but a different Containerfile results in a new image being + # built. + - oci_build8 is changed - "'localhost/testimage:latest' in testimage_info.images[0]['RepoTags'][0]" - "'localhost/testimage2:latest' in testimage2_info.images[0]['RepoTags'][0]" - "'no such file or directory' in oci_build3.msg"