From e37123e06fb35e94dd4ce51742d3c80d88c30eff Mon Sep 17 00:00:00 2001 From: Sergey <6213510+sshnaidm@users.noreply.github.com> Date: Mon, 4 Aug 2025 16:28:35 +0300 Subject: [PATCH] Rewrite podman_image and add tests (#957) Signed-off-by: Sagi Shnaidman --- .../module_utils/podman/podman_image_lib.py | 660 ++++++++++++++++++ plugins/modules/podman_image.py | 582 +-------------- .../podman_image/tasks/additional_tests.yml | 574 +++++++++++++++ .../targets/podman_image/tasks/main.yml | 13 +- 4 files changed, 1265 insertions(+), 564 deletions(-) create mode 100644 plugins/module_utils/podman/podman_image_lib.py create mode 100644 tests/integration/targets/podman_image/tasks/additional_tests.yml diff --git a/plugins/module_utils/podman/podman_image_lib.py b/plugins/module_utils/podman/podman_image_lib.py new file mode 100644 index 0000000..f28a976 --- /dev/null +++ b/plugins/module_utils/podman/podman_image_lib.py @@ -0,0 +1,660 @@ +# Copyright (c) 2024 Red Hat +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import absolute_import, division, print_function +import json +import os +import shlex +import tempfile +import time +import hashlib +import sys + +from ansible_collections.containers.podman.plugins.module_utils.podman.common import run_podman_command + +__metaclass__ = type + + +class PodmanImageError(Exception): + """Custom exception for Podman image operations.""" + + +class ImageRepository: + """Parse and manage image repository information.""" + + def __init__(self, name, tag="latest"): + self.original_name = name + self.name, self.parsed_tag = self._parse_repository_tag(name) + self.tag = self.parsed_tag or tag + self.delimiter = "@" if "sha256" in self.tag else ":" + self.full_name = f"{self.name}{self.delimiter}{self.tag}" + + @staticmethod + def _parse_repository_tag(repo_name): + """Parse repository name and tag/digest.""" + parts = repo_name.rsplit("@", 1) + if len(parts) == 2: + return tuple(parts) + parts = repo_name.rsplit(":", 1) + if len(parts) == 2 and "/" not in parts[1]: + return tuple(parts) + return repo_name, None + + +class ContainerFileProcessor: + """Handle Containerfile/Dockerfile processing and hashing.""" + + def __init__(self, build_config, path=None): + self.build_config = build_config or {} + self.path = path + + def get_containerfile_contents(self): + """Get Containerfile contents from various sources.""" + build_file_arg = self.build_config.get("file") + containerfile_contents = self.build_config.get("container_file") + + container_filename = None + 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 and container_filename and os.access(container_filename, os.R_OK): + with open(container_filename, "r", encoding='utf-8') as f: + containerfile_contents = f.read() + + return containerfile_contents + + def _find_containerfile_from_context(self): + """Find Containerfile/Dockerfile in build context.""" + if not self.path: + return None + + for filename in ["Containerfile", "Dockerfile"]: + full_path = os.path.join(self.path, filename) + if os.path.exists(full_path): + return full_path + return None + + def hash_containerfile_contents(self, contents): + """Generate SHA256 hash of Containerfile contents.""" + if not contents: + return None + + if sys.version_info < (3, 9): + return hashlib.sha256(contents.encode()).hexdigest() + return hashlib.sha256(contents.encode(), usedforsecurity=False).hexdigest() + + +class PodmanImageInspector: + """Handle image inspection operations.""" + + def __init__(self, module, executable): + self.module = module + self.executable = executable + + def inspect_image(self, image_name): + """Inspect an image and return its data.""" + args = ["inspect", image_name, "--format", "json"] + rc, out, unused = run_podman_command(self.module, self.executable, args, ignore_errors=True) + + if rc != 0: + return None + + try: + image_data = json.loads(out) + return image_data if image_data else None + except json.JSONDecodeError: + self.module.fail_json(msg=f"Failed to parse JSON output from podman inspect: {out}") + + def image_exists(self, image_name): + """Check if an image exists.""" + rc, out, err = run_podman_command( + self.module, self.executable, ["image", "exists", image_name], ignore_errors=True + ) + return rc == 0 + + def list_images(self, image_name): + """List images matching the given name.""" + args = ["image", "ls", image_name, "--format", "json"] + rc, out, err = run_podman_command(self.module, self.executable, args, ignore_errors=True) + + if rc != 0: + return [] + + try: + return json.loads(out) or [] + except json.JSONDecodeError: + self.module.fail_json(msg=f"Failed to parse JSON output from podman image ls: {out}") + + +class PodmanImageBuilder: + """Handle image building operations.""" + + def __init__(self, module, executable, auth_config=None): + self.module = module + self.executable = executable + self.auth_config = auth_config or {} + + def build_image(self, image_name, build_config, path=None, containerfile_hash=None): + """Build an image with the given configuration.""" + args = self._construct_build_args(image_name, build_config, path, containerfile_hash) + + # Handle inline container file + temp_file_path = None + if build_config.get("container_file"): + temp_file_path = self._create_temp_containerfile(build_config["container_file"], path) + args.extend(["--file", temp_file_path]) + + try: + # Return the command that will be executed for podman_actions tracking + podman_command = " ".join([self.executable] + args) + rc, out, err = run_podman_command(self.module, self.executable, args, ignore_errors=True) + + if rc != 0: + self.module.fail_json(msg=f"Failed to build image {image_name}: {out} {err}") + + # Extract image ID from output + last_id = self._extract_image_id_from_output(out) + return last_id, out + err, podman_command + + finally: + if temp_file_path and os.path.exists(temp_file_path): + os.remove(temp_file_path) + + def _construct_build_args(self, image_name, build_config, path, containerfile_hash): + """Construct build command arguments.""" + args = ["build", "-t", image_name] + + # Add authentication + self._add_auth_args(args) + + # Add build-specific arguments + if build_config.get("force_rm"): + args.append("--force-rm") + + if build_config.get("format"): + args.extend(["--format", build_config["format"]]) + + if not build_config.get("cache", True): + args.append("--no-cache") + + if build_config.get("rm", True): + args.append("--rm") + + # Add annotations + annotations = build_config.get("annotation") or {} + for key, value in annotations.items(): + args.extend(["--annotation", f"{key}={value}"]) + + # Add containerfile hash as label + if containerfile_hash: + args.extend(["--label", f"containerfile.hash={containerfile_hash}"]) + + # Add volumes + volumes = build_config.get("volume") or [] + for volume in volumes: + if volume: + args.extend(["--volume", volume]) + + # Add build file + if build_config.get("file"): + args.extend(["--file", build_config["file"]]) + + # Add target + if build_config.get("target"): + args.extend(["--target", build_config["target"]]) + + # Add extra args + extra_args = build_config.get("extra_args") + if extra_args: + args.extend(shlex.split(extra_args)) + + # Add build context path + if path: + args.append(path) + + return args + + def _add_auth_args(self, args): + """Add authentication arguments to command.""" + if self.auth_config.get("validate_certs") is not None: + if self.auth_config["validate_certs"]: + args.append("--tls-verify") + else: + args.append("--tls-verify=false") + + if self.auth_config.get("ca_cert_dir"): + args.extend(["--cert-dir", self.auth_config["ca_cert_dir"]]) + + if self.auth_config.get("auth_file"): + args.extend(["--authfile", self.auth_config["auth_file"]]) + + if self.auth_config.get("username") and self.auth_config.get("password"): + cred_string = f"{self.auth_config['username']}:{self.auth_config['password']}" + args.extend(["--creds", cred_string]) + + def _create_temp_containerfile(self, content, path): + """Create a temporary Containerfile with the given content.""" + if path: + temp_path = os.path.join(path, f"Containerfile.generated_by_ansible_{time.time()}") + else: + temp_dir = tempfile.gettempdir() + temp_path = os.path.join(temp_dir, f"Containerfile.generated_by_ansible_{time.time()}") + + with open(temp_path, "w", encoding='utf-8') as f: + f.write(content) + + return temp_path + + def _extract_image_id_from_output(self, output): + """Extract image ID from build output.""" + for line in output.splitlines(): + if line.startswith("-->"): + parts = line.rsplit(" ", 1) + if len(parts) > 1: + return parts[1] + + # Fallback to last line if no --> found + lines = output.splitlines() + return lines[-1] if lines else "" + + +class PodmanImagePuller: + """Handle image pulling operations.""" + + def __init__(self, module, executable, auth_config=None): + self.module = module + self.executable = executable + self.auth_config = auth_config or {} + + def pull_image(self, image_name, arch=None, pull_extra_args=None): + """Pull an image from a registry.""" + args = ["pull", image_name, "-q"] + + if arch: + args.extend(["--arch", arch]) + + self._add_auth_args(args) + + if pull_extra_args: + args.extend(shlex.split(pull_extra_args)) + + # Return the command that will be executed for podman_actions tracking + podman_command = " ".join([self.executable] + args) + rc, out, err = run_podman_command(self.module, self.executable, args, ignore_errors=True) + + if rc != 0: + self.module.fail_json(msg=f"Failed to pull image {image_name}: {err}") + + return out.strip(), podman_command + + def _add_auth_args(self, args): + """Add authentication arguments to command.""" + if self.auth_config.get("validate_certs") is not None: + if self.auth_config["validate_certs"]: + args.append("--tls-verify") + else: + args.append("--tls-verify=false") + + if self.auth_config.get("ca_cert_dir"): + args.extend(["--cert-dir", self.auth_config["ca_cert_dir"]]) + + if self.auth_config.get("auth_file"): + args.extend(["--authfile", self.auth_config["auth_file"]]) + + if self.auth_config.get("username") and self.auth_config.get("password"): + cred_string = f"{self.auth_config['username']}:{self.auth_config['password']}" + args.extend(["--creds", cred_string]) + + +class PodmanImagePusher: + """Handle image pushing operations.""" + + def __init__(self, module, executable, auth_config=None): + self.module = module + self.executable = executable + self.auth_config = auth_config or {} + + def push_image(self, image_name, push_config): + """Push an image to a registry.""" + args = ["push"] + + self._add_auth_args(args) + self._add_push_args(args, push_config) + + args.append(image_name) + + # Build destination + dest_string = self._build_destination(image_name, push_config) + args.append(dest_string) + action = " ".join(args) + + rc, out, err = run_podman_command(self.module, self.executable, args, ignore_errors=True) + + if rc != 0: + self.module.fail_json(msg=f"Failed to push image {image_name}", stdout=out, stderr=err, actions=[action]) + + return out + err, action + + def _add_auth_args(self, args): + """Add authentication arguments to command.""" + if self.auth_config.get("validate_certs") is not None: + if self.auth_config["validate_certs"]: + args.append("--tls-verify") + else: + args.append("--tls-verify=false") + + if self.auth_config.get("ca_cert_dir"): + args.extend(["--cert-dir", self.auth_config["ca_cert_dir"]]) + + if self.auth_config.get("auth_file"): + args.extend(["--authfile", self.auth_config["auth_file"]]) + + if self.auth_config.get("username") and self.auth_config.get("password"): + cred_string = f"{self.auth_config['username']}:{self.auth_config['password']}" + args.extend(["--creds", cred_string]) + + def _add_push_args(self, args, push_config): + """Add push-specific arguments.""" + if push_config.get("compress"): + args.append("--compress") + + if push_config.get("format"): + args.extend(["--format", push_config["format"]]) + + if push_config.get("remove_signatures"): + args.append("--remove-signatures") + + if push_config.get("sign_by"): + args.extend(["--sign-by", push_config["sign_by"]]) + + if push_config.get("extra_args"): + args.extend(shlex.split(push_config["extra_args"])) + + def _build_destination(self, image_name, push_config): + """Build the destination string for push.""" + dest = push_config.get("dest") or image_name + transport = push_config.get("transport") + + if not transport: + # Handle simple registry destination + if ":" not in dest and "@" not in dest and len(dest.rstrip("/").split("/")) == 2: + return f"{dest.rstrip('/')}/{image_name}" + if "/" not in dest and "@" not in dest and "docker-daemon" not in dest: + self.module.fail_json( + msg="Destination must be a full URL or path to a directory with image name and tag." + ) + return dest + + # Handle transport-specific destinations + name_parts = image_name.split("/") + image_base_name = name_parts[-1].split(":")[0] + + if transport == "docker": + return f"{transport}://{dest}" + if transport == "ostree": + return f"{transport}:{image_base_name}@{dest}" + if transport == "docker-daemon": + if ":" not in dest: + return f"{transport}:{dest}:latest" + return f"{transport}:{dest}" + return f"{transport}:{dest}" + + +class PodmanImageRemover: + """Handle image removal operations.""" + + def __init__(self, module, executable): + self.module = module + self.executable = executable + + def remove_image(self, image_name, force=False): + """Remove an image by name.""" + args = ["rmi", image_name] + if force: + args.append("--force") + + # Return the command that will be executed for podman_actions tracking + podman_command = " ".join([self.executable] + args) + rc, out, err = run_podman_command(self.module, self.executable, args, ignore_errors=True) + + if rc != 0: + self.module.fail_json(msg=f"Failed to remove image {image_name}: {err}") + + return out, podman_command + + def remove_image_by_id(self, image_id, force=False): + """Remove an image by ID.""" + args = ["rmi", image_id] + if force: + args.append("--force") + + # Return the command that will be executed for podman_actions tracking + podman_command = " ".join([self.executable] + args) + rc, out, err = run_podman_command(self.module, self.executable, args, ignore_errors=True) + + if rc != 0: + self.module.fail_json(msg=f"Failed to remove image with ID {image_id}: {err}") + + return out, podman_command + + +class PodmanImageManager: + """Main image management class that orchestrates all operations.""" + + def __init__(self, module): + self.module = module + self.params = module.params + self.executable = module.get_bin_path(self.params.get("executable", "podman"), required=True) + + # Initialize repository info + self.repository = ImageRepository(self.params["name"], self.params.get("tag", "latest")) + + # Initialize components + self.inspector = PodmanImageInspector(self.module, self.executable) + + auth_config = self._build_auth_config() + self.builder = PodmanImageBuilder(self.module, self.executable, auth_config) + self.puller = PodmanImagePuller(self.module, self.executable, auth_config) + self.pusher = PodmanImagePusher(self.module, self.executable, auth_config) + self.remover = PodmanImageRemover(self.module, self.executable) + + # Initialize containerfile processor + self.containerfile_processor = ContainerFileProcessor(self.params.get("build", {}), self.params.get("path")) + + # Results tracking + self.results = { + "changed": False, + "actions": [], + "podman_actions": [], + "image": {}, + "stdout": "", + } + + def _build_auth_config(self): + """Build authentication configuration.""" + return { + "validate_certs": self.params.get("validate_certs"), + "ca_cert_dir": self.params.get("ca_cert_dir"), + "auth_file": self.params.get("auth_file"), + "username": self.params.get("username"), + "password": self.params.get("password"), + } + + def find_image(self, image_name=None): + """Find an image and return its information.""" + image_name = image_name or self.repository.full_name + + if not self.inspector.image_exists(image_name): + return None + + images = self.inspector.list_images(image_name) + if not images: + return None + + # Check architecture if specified + arch = self.params.get("arch") + if arch: + inspect_data = self.inspector.inspect_image(image_name) + if inspect_data and inspect_data[0].get("Architecture") != arch: + return None + + return images + + def _should_rebuild_image(self, existing_image): + """Determine if an image should be rebuilt based on Containerfile changes.""" + if not existing_image: + return True + + if self.params.get("force"): + return True + + # Check Containerfile hash + containerfile_contents = self.containerfile_processor.get_containerfile_contents() + if not containerfile_contents: + return False + + current_hash = self.containerfile_processor.hash_containerfile_contents(containerfile_contents) + if not current_hash: + return False + + # Get existing hash from image labels + existing_hash = "" + if existing_image: + labels = existing_image[0].get("Labels") or {} + existing_hash = labels.get("containerfile.hash", "") + + return current_hash != existing_hash + + def present(self): + """Ensure image is present (pull or build if needed).""" + image = self.find_image() + + if not image or self._should_rebuild_image(image): + if self.params.get("state") == "build" or self.params.get("path"): + self._build_image() + else: + self._pull_image() + + if self.params.get("push"): + self._push_image() + + # Update results with final image info + final_image = self.find_image() + if final_image: + self.results["image"] = self.inspector.inspect_image(self.repository.full_name) + + def _build_image(self): + """Build an image.""" + build_config = self.params.get("build", {}) + path = self.params.get("path") + + # Validate build configuration + if build_config.get("file") and build_config.get("container_file"): + self.module.fail_json(msg="Cannot specify both build file and container file content!") + + if not path and not build_config.get("file") and not build_config.get("container_file"): + self.module.fail_json(msg="Path to build context or file is required when building an image") + + # Get containerfile hash + containerfile_contents = self.containerfile_processor.get_containerfile_contents() + containerfile_hash = self.containerfile_processor.hash_containerfile_contents(containerfile_contents) + + # Build the image + if not self.module.check_mode: + image_id, output, podman_command = self.builder.build_image( + self.repository.full_name, build_config, path, containerfile_hash + ) + self.results["stdout"] = output + self.results["image"] = self.inspector.inspect_image(image_id) + self.results["podman_actions"].append(podman_command) + + self.results["changed"] = True + self.results["actions"].append(f"Built image {self.repository.full_name} from {path or 'context'}") + + def _pull_image(self): + """Pull an image.""" + if not self.params.get("pull", True): + self.module.fail_json(msg=f"Image {self.repository.full_name} not found locally and pull is disabled") + + if not self.module.check_mode: + unused, podman_command = self.puller.pull_image( + self.repository.full_name, self.params.get("arch"), self.params.get("pull_extra_args") + ) + self.results["image"] = self.inspector.inspect_image(self.repository.full_name) + self.results["podman_actions"].append(podman_command) + + self.results["changed"] = True + self.results["actions"].append(f"Pulled image {self.repository.full_name}") + + def _push_image(self): + """Push an image.""" + push_config = self.params.get("push_args", {}) + + if not self.module.check_mode: + output, action = self.pusher.push_image(self.repository.full_name, push_config) + self.results["stdout"] += "\n" + output + self.results["actions"].append(action) + self.results["podman_actions"].append(f"{self.executable} {action}") + + self.results["changed"] = True + self.results["actions"].append(f"Pushed image {self.repository.full_name}") + + def absent(self): + """Ensure image is absent.""" + image = self.find_image() + + if image: + if not self.module.check_mode: + unused, podman_command = self.remover.remove_image( + self.repository.full_name, self.params.get("force", False) + ) + self.results["podman_actions"].append(podman_command) + + self.results["changed"] = True + self.results["actions"].append(f"Removed image {self.repository.full_name}") + self.results["image"] = {"state": "Deleted"} + else: + # Try removing by ID if provided + image_id = self._extract_image_id(self.repository.original_name) + if image_id and self._image_id_exists(image_id): + if not self.module.check_mode: + unused, podman_command = self.remover.remove_image_by_id(image_id, self.params.get("force", False)) + self.results["podman_actions"].append(podman_command) + + self.results["changed"] = True + self.results["actions"].append(f"Removed image with ID {image_id}") + self.results["image"] = {"state": "Deleted"} + + def _extract_image_id(self, name): + """Extract image ID from name if it looks like an ID.""" + # Remove tag if present + if ":" in name and not name.startswith("sha256:"): + name = name.split(":")[0] + return name + + def _image_id_exists(self, image_id): + """Check if an image ID exists.""" + args = ["image", "ls", "--quiet", "--no-trunc"] + rc, out, err = run_podman_command(self.module, self.executable, args, ignore_errors=True) + + if rc != 0: + return False + + candidates = [line.replace("sha256:", "") for line in out.splitlines()] + return any(candidate.startswith(image_id) for candidate in candidates) + + def execute(self): + """Execute the requested operation.""" + state = self.params.get("state", "present") + + if state in ["present", "build"]: + self.present() + elif state == "absent": + self.absent() + elif state == "quadlet": + # Quadlet functionality will be handled by the main module + pass + + return self.results diff --git a/plugins/modules/podman_image.py b/plugins/modules/podman_image.py index 0fc25ff..df2b1da 100644 --- a/plugins/modules/podman_image.py +++ b/plugins/modules/podman_image.py @@ -1,4 +1,3 @@ -#!/usr/bin/python # Copyright (c) 2018 Ansible Project # GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) @@ -10,7 +9,7 @@ __metaclass__ = type DOCUMENTATION = r""" module: podman_image author: - - Sam Doran (@samdoran) + - Sagi Shnaidman (@sshnaidm) short_description: Pull images for use by podman notes: [] description: @@ -208,14 +207,17 @@ DOCUMENTATION = r""" quadlet_file_mode: description: - The permissions of the quadlet file. - - The O(quadlet_file_mode) can be specied as octal numbers or as a symbolic mode (for example, V(u+rwx) or V(u=rw,g=r,o=r)). + - The O(quadlet_file_mode) can be specied as octal numbers or as a symbolic mode + (for example, V(u+rwx) or V(u=rw,g=r,o=r)). For octal numbers format, you must either add a leading zero so that Ansible's YAML parser knows it is an octal number (like V(0644) or V(01777)) or quote it (like V('644') or V('1777')) so Ansible receives a string and can do its own conversion from string into number. Giving Ansible a number without following one of these rules will end up with a decimal number which will have unexpected results. - - If O(quadlet_file_mode) is not specified and the quadlet file B(does not) exist, the default V('0640') mask will be used + - If O(quadlet_file_mode) is not specified and the quadlet file B(does not) exist, the default V('0640') mask + will be used when setting the mode for the newly created file. - - If O(quadlet_file_mode) is not specified and the quadlet file B(does) exist, the mode of the existing file will be used. + - If O(quadlet_file_mode) is not specified and the quadlet file B(does) exist, the mode of the existing file + will be used. - Specifying O(quadlet_file_mode) is the best way to ensure files are created with the correct permissions. type: raw required: false @@ -426,556 +428,13 @@ RETURN = r""" """ -import json # noqa: E402 -import os # noqa: E402 -import re # noqa: E402 -import shlex # noqa: E402 -import tempfile # noqa: E402 -import time # noqa: E402 -import hashlib # noqa: E402 -import sys # noqa: E402 - -from ansible.module_utils._text import to_native from ansible.module_utils.basic import AnsibleModule -from ansible_collections.containers.podman.plugins.module_utils.podman.common import ( - run_podman_command, -) -from ansible_collections.containers.podman.plugins.module_utils.podman.quadlet import ( +from ..module_utils.podman.quadlet import ( create_quadlet_state, ) - - -class PodmanImageManager(object): - - def __init__(self, module, results): - - super(PodmanImageManager, self).__init__() - - self.module = module - self.results = results - self.name = self.module.params.get("name") - self.executable = self.module.get_bin_path(module.params.get("executable"), required=True) - self.tag = self.module.params.get("tag") - self.pull = self.module.params.get("pull") - self.pull_extra_args = self.module.params.get("pull_extra_args") - self.push = self.module.params.get("push") - self.path = self.module.params.get("path") - self.force = self.module.params.get("force") - self.state = self.module.params.get("state") - self.validate_certs = self.module.params.get("validate_certs") - self.auth_file = self.module.params.get("auth_file") - self.username = self.module.params.get("username") - self.password = self.module.params.get("password") - self.ca_cert_dir = self.module.params.get("ca_cert_dir") - self.build = self.module.params.get("build") - self.push_args = self.module.params.get("push_args") - self.arch = self.module.params.get("arch") - - repo, repo_tag = parse_repository_tag(self.name) - if repo_tag: - self.name = repo - self.tag = repo_tag - - delimiter = ":" if "sha256" not in self.tag else "@" - self.image_name = "{name}{d}{tag}".format(name=self.name, d=delimiter, tag=self.tag) - - if self.state in ["present", "build"]: - self.present() - - if self.state in ["absent"]: - self.absent() - - if self.state == "quadlet": - self.make_quadlet() - - def _run(self, args, expected_rc=0, ignore_errors=False): - cmd = " ".join([self.executable] + [to_native(i) for i in args]) - self.module.log("PODMAN-IMAGE-DEBUG: %s" % cmd) - self.results["podman_actions"].append(cmd) - return run_podman_command( - module=self.module, - executable=self.executable, - args=args, - expected_rc=expected_rc, - ignore_errors=ignore_errors, - ) - - def _get_id_from_output(self, lines, startswith=None, contains=None, split_on=" ", maxsplit=1): - layer_ids = [] - for line in lines.splitlines(): - if startswith and line.startswith(startswith) or contains and contains in line: - splitline = line.rsplit(split_on, maxsplit) - layer_ids.append(splitline[1]) - - # Podman 1.4 changed the output to only include the layer id when run in quiet mode - if not layer_ids: - layer_ids = lines.splitlines() - - return layer_ids[-1] - - def _find_containerfile_from_context(self): - """ - Find a Containerfile/Dockerfile path inside a podman build context. - Return 'None' if none exist. - """ - - containerfile_path = None - 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, if present. - - If we don't find a Containerfile/Dockerfile in any of the above - locations, return 'None'. - """ - - 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 = None - 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 and os.access(container_filename, os.R_OK): - 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. - """ - if not containerfile_contents: - return None - - # usedforsecurity keyword arg was introduced in python 3.9 - if sys.version_info < (3, 9): - return hashlib.sha256( - containerfile_contents.encode(), - ).hexdigest() - else: - 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 = None - - 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 - - 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 - container_file_txt = self.build.get("container_file") if self.build else None - if build_file and container_file_txt: - self.module.fail_json(msg="Cannot specify both build file and container file content!") - if not self.path and build_file: - self.path = os.path.dirname(build_file) - elif not self.path and not build_file and not container_file_txt: - self.module.fail_json(msg="Path to build context or file is required when building an image") - 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(args_containerfile_hash) - image = self.results["image"] - else: - # Pull the image - self.results["actions"].append("Pulled image {image_name}".format(image_name=self.image_name)) - if not self.module.check_mode: - image = self.results["image"] = self.pull_image() - - if not image: - image = self.find_image() - if not self.module.check_mode: - digest_after = image[0].get("Digest", image[0].get("digest")) - self.results["changed"] = digest_before != digest_after - else: - self.results["changed"] = True - - if self.push: - self.results["image"], output = self.push_image() - self.results["stdout"] += "\n" + output - if image and not self.results.get("image"): - self.results["image"] = image - - def absent(self): - image = self.find_image() - image_id = self.find_image_id() - - if image: - self.results["actions"].append("Removed image {name}".format(name=self.name)) - self.results["changed"] = True - self.results["image"]["state"] = "Deleted" - if not self.module.check_mode: - self.remove_image() - elif image_id: - self.results["actions"].append("Removed image with id {id}".format(id=self.image_name)) - self.results["changed"] = True - self.results["image"]["state"] = "Deleted" - if not self.module.check_mode: - self.remove_image_id() - - def make_quadlet(self): - results_update = create_quadlet_state(self.module, "image") - self.results.update(results_update) - self.module.exit_json(**self.results) - - def find_image(self, image_name=None): - if image_name is None: - image_name = self.image_name - # Let's find out if image exists - rc, out, err = self._run(["image", "exists", image_name], ignore_errors=True) - if rc == 0: - inspect_json = self.inspect_image(image_name) - else: - return None - args = ["image", "ls", image_name, "--format", "json"] - rc, images, err = self._run(args, ignore_errors=True) - try: - images = json.loads(images) - except json.decoder.JSONDecodeError: - self.module.fail_json(msg="Failed to parse JSON output from podman image ls: {out}".format(out=images)) - if len(images) == 0: - return None - inspect_json = self.inspect_image(image_name) - if self._is_target_arch(inspect_json, self.arch) or not self.arch: - return images or inspect_json - return None - - def _is_target_arch(self, inspect_json=None, arch=None): - return arch and inspect_json[0]["Architecture"] == arch - - def find_image_id(self, image_id=None): - if image_id is None: - # If image id is set as image_name, remove tag - image_id = re.sub(":.*$", "", self.image_name) - args = ["image", "ls", "--quiet", "--no-trunc"] - rc, candidates, err = self._run(args, ignore_errors=True) - candidates = [re.sub("^sha256:", "", c) for c in str.splitlines(candidates)] - for c in candidates: - if c.startswith(image_id): - return image_id - return None - - def inspect_image(self, image_name=None): - if image_name is None: - image_name = self.image_name - args = ["inspect", image_name, "--format", "json"] - rc, image_data, err = self._run(args) - try: - image_data = json.loads(image_data) - except json.decoder.JSONDecodeError: - self.module.fail_json(msg="Failed to parse JSON output from podman inspect: {out}".format(out=image_data)) - if len(image_data) > 0: - return image_data - else: - return None - - def pull_image(self, image_name=None): - if image_name is None: - image_name = self.image_name - - args = ["pull", image_name, "-q"] - - if self.arch: - args.extend(["--arch", self.arch]) - - if self.auth_file: - args.extend(["--authfile", self.auth_file]) - - if self.username and self.password: - cred_string = "{user}:{password}".format(user=self.username, password=self.password) - args.extend(["--creds", cred_string]) - - if self.validate_certs is not None: - if self.validate_certs: - args.append("--tls-verify") - else: - args.append("--tls-verify=false") - - if self.ca_cert_dir: - args.extend(["--cert-dir", self.ca_cert_dir]) - - if self.pull_extra_args: - args.extend(shlex.split(self.pull_extra_args)) - - rc, out, err = self._run(args, ignore_errors=True) - if rc != 0: - if not self.pull: - self.module.fail_json( - msg="Failed to find image {image_name} locally, image pull set to {pull_bool}".format( - pull_bool=self.pull, image_name=image_name - ) - ) - else: - self.module.fail_json(msg="Failed to pull image {image_name}".format(image_name=image_name)) - return self.inspect_image(out.strip()) - - def build_image(self, containerfile_hash): - args = ["build"] - args.extend(["-t", self.image_name]) - - if self.validate_certs is not None: - if self.validate_certs: - args.append("--tls-verify") - else: - args.append("--tls-verify=false") - - annotation = self.build.get("annotation") - if annotation: - for k, v in annotation.items(): - args.extend(["--annotation", "{k}={v}".format(k=k, v=v)]) - - if self.ca_cert_dir: - args.extend(["--cert-dir", self.ca_cert_dir]) - - if self.build.get("force_rm"): - args.append("--force-rm") - - image_format = self.build.get("format") - if image_format: - args.extend(["--format", image_format]) - - if self.arch: - args.extend(["--arch", self.arch]) - - if not self.build.get("cache"): - args.append("--no-cache") - - if self.build.get("rm"): - args.append("--rm") - - containerfile = self.build.get("file") - if containerfile: - args.extend(["--file", containerfile]) - container_file_txt = self.build.get("container_file") - if container_file_txt: - # create a temporarly file with the content of the Containerfile - if self.path: - container_file_path = os.path.join(self.path, "Containerfile.generated_by_ansible_%s" % time.time()) - else: - container_file_path = os.path.join( - tempfile.gettempdir(), - "Containerfile.generated_by_ansible_%s" % time.time(), - ) - with open(container_file_path, "w") as f: - 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: - if v: - args.extend(["--volume", v]) - - if self.auth_file: - args.extend(["--authfile", self.auth_file]) - - if self.username and self.password: - cred_string = "{user}:{password}".format(user=self.username, password=self.password) - args.extend(["--creds", cred_string]) - - extra_args = self.build.get("extra_args") - if extra_args: - args.extend(shlex.split(extra_args)) - - target = self.build.get("target") - if target: - args.extend(["--target", target]) - if self.path: - args.append(self.path) - - rc, out, err = self._run(args, ignore_errors=True) - if rc != 0: - self.module.fail_json( - msg="Failed to build image {image}: {out} {err}".format(image=self.image_name, out=out, err=err) - ) - # remove the temporary file if it was created - if container_file_txt: - os.remove(container_file_path) - last_id = self._get_id_from_output(out, startswith="-->") - return self.inspect_image(last_id), out + err - - def push_image(self): - args = ["push"] - - if self.validate_certs is not None: - if self.validate_certs: - args.append("--tls-verify") - else: - args.append("--tls-verify=false") - - if self.ca_cert_dir: - args.extend(["--cert-dir", self.ca_cert_dir]) - - if self.username and self.password: - cred_string = "{user}:{password}".format(user=self.username, password=self.password) - args.extend(["--creds", cred_string]) - - if self.auth_file: - args.extend(["--authfile", self.auth_file]) - - if self.push_args.get("compress"): - args.append("--compress") - - push_format = self.push_args.get("format") - if push_format: - args.extend(["--format", push_format]) - - if self.push_args.get("remove_signatures"): - args.append("--remove-signatures") - - sign_by_key = self.push_args.get("sign_by") - if sign_by_key: - args.extend(["--sign-by", sign_by_key]) - - push_extra_args = self.push_args.get("extra_args") - if push_extra_args: - args.extend(shlex.split(push_extra_args)) - - args.append(self.image_name) - - # Build the destination argument - dest = self.push_args.get("dest") - transport = self.push_args.get("transport") - - if dest is None: - dest = self.image_name - - if transport: - if transport == "docker": - dest_format_string = "{transport}://{dest}" - elif transport == "ostree": - dest_format_string = "{transport}:{name}@{dest}" - else: - dest_format_string = "{transport}:{dest}" - if transport == "docker-daemon" and ":" not in dest: - dest_format_string = "{transport}:{dest}:latest" - dest_string = dest_format_string.format(transport=transport, name=self.name, dest=dest) - else: - dest_string = dest - # In case of dest as a repository with org name only, append image name to it - if ":" not in dest and "@" not in dest and len(dest.rstrip("/").split("/")) == 2: - dest_string = dest.rstrip("/") + "/" + self.image_name - - if "/" not in dest_string and "@" not in dest_string and "docker-daemon" not in dest_string: - self.module.fail_json(msg="Destination must be a full URL or path to a directory with image name and tag.") - - args.append(dest_string) - self.module.log( - "PODMAN-IMAGE-DEBUG: Pushing image {image_name} to {dest_string}".format( - image_name=self.image_name, dest_string=dest_string - ) - ) - self.results["actions"].append(" ".join(args)) - self.results["changed"] = True - out, err = "", "" - if not self.module.check_mode: - rc, out, err = self._run(args, ignore_errors=True) - if rc != 0: - self.module.fail_json( - msg="Failed to push image {image_name}".format(image_name=self.image_name), - stdout=out, - stderr=err, - actions=self.results["actions"], - podman_actions=self.results["podman_actions"], - ) - - return self.inspect_image(self.image_name), out + err - - def remove_image(self, image_name=None): - if image_name is None: - image_name = self.image_name - - args = ["rmi", image_name] - if self.force: - args.append("--force") - rc, out, err = self._run(args, ignore_errors=True) - if rc != 0: - self.module.fail_json( - msg="Failed to remove image {image_name}. {err}".format(image_name=image_name, err=err) - ) - return out - - def remove_image_id(self, image_id=None): - if image_id is None: - image_id = re.sub(":.*$", "", self.image_name) - - args = ["rmi", image_id] - if self.force: - args.append("--force") - rc, out, err = self._run(args, ignore_errors=True) - if rc != 0: - self.module.fail_json( - msg="Failed to remove image with id {image_id}. {err}".format(image_id=image_id, err=err) - ) - return out - - -def parse_repository_tag(repo_name): - parts = repo_name.rsplit("@", 1) - if len(parts) == 2: - return tuple(parts) - parts = repo_name.rsplit(":", 1) - if len(parts) == 2 and "/" not in parts[1]: - return tuple(parts) - return repo_name, None +from ..module_utils.podman.podman_image_lib import ( + PodmanImageManager, +) def main(): @@ -1056,16 +515,17 @@ def main(): ), ) - results = dict( - changed=False, - actions=[], - podman_actions=[], - image={}, - stdout="", - ) + # Handle quadlet state separately + if module.params["state"] == "quadlet": # type: ignore + results = create_quadlet_state(module, "image") + module.exit_json(**results) - PodmanImageManager(module, results) - module.exit_json(**results) + try: + manager = PodmanImageManager(module) + results = manager.execute() + module.exit_json(**results) + except Exception as e: + module.fail_json(msg=f"Failed to manage image: {str(e)}", exception=str(e)) if __name__ == "__main__": diff --git a/tests/integration/targets/podman_image/tasks/additional_tests.yml b/tests/integration/targets/podman_image/tasks/additional_tests.yml new file mode 100644 index 0000000..642afa0 --- /dev/null +++ b/tests/integration/targets/podman_image/tasks/additional_tests.yml @@ -0,0 +1,574 @@ +--- +- name: Additional podman_image tests + block: + # Authentication and certificate tests + - name: Test image pull with username/password (should fail gracefully) + containers.podman.podman_image: + executable: "{{ test_executable | default('podman') }}" + name: quay.io/testing/private-repo + username: invaliduser + password: invalidpass + validate_certs: false + register: auth_pull_result + ignore_errors: true + + - name: Verify authentication failure is handled properly + assert: + that: + - auth_pull_result is failed + - auth_pull_result is not changed + + # Test with auth_file parameter + - name: Create temporary auth file for testing + tempfile: + state: file + suffix: .json + register: auth_file + + - name: Write valid JSON to auth file + copy: + content: "{}" + dest: "{{ auth_file.path }}" + + - name: Test image pull with auth_file parameter + containers.podman.podman_image: + executable: "{{ test_executable | default('podman') }}" + name: quay.io/sshnaidm1/alpine:3.16 + auth_file: "{{ auth_file.path }}" + register: auth_file_result + + - name: Verify auth_file parameter works + assert: + that: + - auth_file_result is changed + + # Test with ca_cert_dir parameter + - name: Test image pull with ca_cert_dir parameter + containers.podman.podman_image: + executable: "{{ test_executable | default('podman') }}" + name: quay.io/sshnaidm1/alpine:3.17 + ca_cert_dir: /etc/ssl/certs + validate_certs: true + register: cert_dir_result + + - name: Verify ca_cert_dir parameter works + assert: + that: + - cert_dir_result is changed + + # Build tests with annotations and volume mounts + - name: Create directory for build context with annotations + file: + path: /tmp/build_annotations + state: directory + + - name: Test build with annotations and volume mounts + containers.podman.podman_image: + executable: "{{ test_executable | default('podman') }}" + name: test-annotations + path: /tmp/build_annotations + build: + format: oci + annotation: + version: "1.0" + maintainer: "test@example.com" + description: "Test image with annotations" + volume: + - "/tmp:/host-tmp:ro" + - "/var/log:/host-logs" + container_file: | + FROM quay.io/sshnaidm1/alpine:latest + RUN echo "Testing annotations and volumes" > /tmp/test.txt + LABEL test=annotation-build + register: annotation_build + + - name: Verify annotation build succeeded + assert: + that: + - annotation_build is changed + - "'Built image test-annotations:latest from' in annotation_build.actions[0]" + + # Test idempotency with containerfile hash + - name: Build same image again (should not rebuild due to containerfile hash) + containers.podman.podman_image: + executable: "{{ test_executable | default('podman') }}" + name: test-annotations + path: /tmp/build_annotations + build: + format: oci + annotation: + version: "1.0" + maintainer: "test@example.com" + description: "Test image with annotations" + volume: + - "/tmp:/host-tmp:ro" + - "/var/log:/host-logs" + container_file: | + FROM quay.io/sshnaidm1/alpine:latest + RUN echo "Testing annotations and volumes" > /tmp/test.txt + LABEL test=annotation-build + register: annotation_build_idempotent + + - name: Verify idempotency based on containerfile hash + assert: + that: + - annotation_build_idempotent is not changed + + # Test force rebuild + - name: Force rebuild same image + containers.podman.podman_image: + executable: "{{ test_executable | default('podman') }}" + name: test-annotations + path: /tmp/build_annotations + force: true + build: + format: oci + annotation: + version: "1.0" + maintainer: "test@example.com" + description: "Test image with annotations" + volume: + - "/tmp:/host-tmp:ro" + - "/var/log:/host-logs" + container_file: | + FROM quay.io/sshnaidm1/alpine:latest + RUN echo "Testing annotations and volumes" > /tmp/test.txt + LABEL test=annotation-build + register: force_rebuild + + - name: Verify force rebuild works + assert: + that: + - force_rebuild is changed + - "'Built image test-annotations:latest from' in force_rebuild.actions[0]" + + # Test build with target stage + - name: Test multi-stage build with target + containers.podman.podman_image: + executable: "{{ test_executable | default('podman') }}" + name: test-multistage + state: build + build: + format: oci + target: runtime + container_file: | + FROM quay.io/sshnaidm1/alpine:latest AS builder + RUN echo "builder stage" > /tmp/builder.txt + + FROM quay.io/sshnaidm1/alpine:latest AS runtime + COPY --from=builder /tmp/builder.txt /tmp/runtime.txt + RUN echo "runtime stage" >> /tmp/runtime.txt + register: multistage_build + + - name: Verify multi-stage build with target works + assert: + that: + - multistage_build is changed + - "'Built image test-multistage:latest from' in multistage_build.actions[0]" + + # Test build with extra_args + - name: Test build with extra arguments + containers.podman.podman_image: + executable: "{{ test_executable | default('podman') }}" + name: test-extra-args + state: build + build: + format: oci + extra_args: "--pull --no-cache" + container_file: | + FROM quay.io/sshnaidm1/alpine:latest + RUN echo "Testing extra args" > /tmp/extra.txt + register: extra_args_build + + - name: Verify build with extra args works + assert: + that: + - extra_args_build is changed + - "'Built image test-extra-args:latest from' in extra_args_build.actions[0]" + + # Test push functionality with different transports + - name: Test push to directory transport + containers.podman.podman_image: + executable: "{{ test_executable | default('podman') }}" + name: test-annotations + pull: false + push: true + push_args: + dest: /tmp/push-test-dir + transport: dir + register: push_dir_result + + - name: Verify push to directory works + assert: + that: + - push_dir_result is changed + - "'Pushed image test-annotations:latest' in push_dir_result.actions" + + - name: Test push to oci-archive + containers.podman.podman_image: + executable: "{{ test_executable | default('podman') }}" + name: test-annotations + pull: false + push: true + push_args: + dest: /tmp/test-oci-export.tar + transport: oci-archive + register: push_oci_result + + - name: Verify push to oci-archive works + assert: + that: + - push_oci_result is changed + - "'Pushed image test-annotations:latest' in push_oci_result.actions" + + # Test pull with architecture specification + - name: Test pull with specific architecture + containers.podman.podman_image: + executable: "{{ test_executable | default('podman') }}" + name: docker.io/library/busybox + arch: amd64 + tag: latest + register: arch_pull_result + + - name: Verify architecture-specific pull + assert: + that: + - arch_pull_result is changed + + # Test pull with extra arguments + - name: Test pull with extra arguments + containers.podman.podman_image: + executable: "{{ test_executable | default('podman') }}" + name: quay.io/sshnaidm1/alpine:3.18 + pull_extra_args: "--quiet" + register: pull_extra_args_result + + - name: Verify pull with extra args + assert: + that: + - pull_extra_args_result is changed + + # Error handling tests + - name: Test pull non-existent image + containers.podman.podman_image: + executable: "{{ test_executable | default('podman') }}" + name: docker.io/nonexistent/imagethatdoesnotexist + tag: nonexistent + register: nonexistent_pull + ignore_errors: true + + - name: Verify non-existent image pull fails properly + assert: + that: + - nonexistent_pull is failed + - nonexistent_pull is not changed + + # Test build with missing containerfile + - name: Create empty directory for missing containerfile test + file: + path: /tmp/empty_build_dir + state: directory + + - name: Test build with missing containerfile (should fail) + containers.podman.podman_image: + executable: "{{ test_executable | default('podman') }}" + name: test-missing-containerfile + path: /tmp/empty_build_dir + register: missing_containerfile + ignore_errors: true + + - name: Verify missing containerfile fails properly + assert: + that: + - missing_containerfile is failed + - missing_containerfile is not changed + + # Test conflicting build parameters + - name: Test build with conflicting file and container_file (should fail) + containers.podman.podman_image: + executable: "{{ test_executable | default('podman') }}" + name: test-conflict + state: build + build: + file: /tmp/some/file + container_file: | + FROM alpine + RUN echo "conflict test" + register: conflict_build + ignore_errors: true + + - name: Verify conflicting build parameters fail + assert: + that: + - conflict_build is failed + - conflict_build is not changed + - "'Cannot specify both build file and container file content' in conflict_build.msg" + + # Test removal by image ID + - name: Get image ID for removal test + containers.podman.podman_image_info: + executable: "{{ test_executable | default('podman') }}" + name: test-multistage + register: multistage_info + + - name: Test removal by image ID + containers.podman.podman_image: + executable: "{{ test_executable | default('podman') }}" + name: "{{ multistage_info.images[0].Id }}" + state: absent + register: remove_by_id + when: multistage_info.images | length > 0 + + - name: Verify removal by ID works + assert: + that: + - remove_by_id is changed + - "'Removed image with ID' in remove_by_id.actions[0]" + when: multistage_info.images | length > 0 + + # Test image repository parsing edge cases + - name: Test image with digest + containers.podman.podman_image: + executable: "{{ test_executable | default('podman') }}" + name: "quay.io/sshnaidm1/alpine@sha256:e7d88de73db3d3fd9b2d63aa7f447a10fd0220b7cbf39803c803f2af9ba256b3" + register: digest_pull + ignore_errors: true + + - name: Test image with port in registry name + containers.podman.podman_image: + executable: "{{ test_executable | default('podman') }}" + name: localhost:5000/test-image + tag: latest + register: port_registry + ignore_errors: true + + # Test push failure scenarios + - name: Test push without pull and image doesn't exist + containers.podman.podman_image: + executable: "{{ test_executable | default('podman') }}" + name: nonexistent-local-image + pull: false + push: true + register: push_nonexistent + ignore_errors: true + + - name: Verify push nonexistent image fails + assert: + that: + - push_nonexistent is failed + - push_nonexistent is not changed + - "'Image nonexistent-local-image:latest not found' in push_nonexistent.msg" + - "'pull is disabled' in push_nonexistent.msg" + + # Test validate_certs parameter variations + - name: Test with validate_certs explicitly true + containers.podman.podman_image: + executable: "{{ test_executable | default('podman') }}" + name: quay.io/sshnaidm1/alpine:3.19 + validate_certs: true + register: validate_true_result + + - name: Test with validate_certs explicitly false + containers.podman.podman_image: + executable: "{{ test_executable | default('podman') }}" + name: quay.io/sshnaidm1/alpine:3.20 + validate_certs: false + register: validate_false_result + + - name: Verify validate_certs parameter works + assert: + that: + - validate_true_result is changed + - validate_false_result is changed + + # Test containerfile hash changes trigger rebuild + - name: Create directory for hash change test + file: + path: /tmp/hash_change_test + state: directory + + - name: Build image with first containerfile + containers.podman.podman_image: + executable: "{{ test_executable | default('podman') }}" + name: test-hash-change + path: /tmp/hash_change_test + build: + container_file: | + FROM quay.io/sshnaidm1/alpine:latest + RUN echo "version 1" > /tmp/version.txt + register: hash_build_1 + + - name: Build image with modified containerfile (should rebuild) + containers.podman.podman_image: + executable: "{{ test_executable | default('podman') }}" + name: test-hash-change + path: /tmp/hash_change_test + build: + container_file: | + FROM quay.io/sshnaidm1/alpine:latest + RUN echo "version 2" > /tmp/version.txt + register: hash_build_2 + + - name: Verify containerfile change triggers rebuild + assert: + that: + - hash_build_1 is changed + - hash_build_2 is changed + - "'Built image test-hash-change:latest from' in hash_build_1.actions[0]" + - "'Built image test-hash-change:latest from' in hash_build_2.actions[0]" + + # Test build without cache + - name: Test build with cache disabled + containers.podman.podman_image: + executable: "{{ test_executable | default('podman') }}" + name: test-no-cache + state: build + build: + cache: false + container_file: | + FROM quay.io/sshnaidm1/alpine:latest + RUN echo "no cache build" > /tmp/nocache.txt + register: no_cache_build + + - name: Verify no-cache build works + assert: + that: + - no_cache_build is changed + - "'Built image test-no-cache:latest from' in no_cache_build.actions[0]" + + # Test build with force_rm + - name: Test build with force_rm option + containers.podman.podman_image: + executable: "{{ test_executable | default('podman') }}" + name: test-force-rm + state: build + build: + force_rm: true + container_file: | + FROM quay.io/sshnaidm1/alpine:latest + RUN echo "force rm test" > /tmp/forcerm.txt + register: force_rm_build + + - name: Verify force_rm build works + assert: + that: + - force_rm_build is changed + - "'Built image test-force-rm:latest from' in force_rm_build.actions[0]" + + # Test push with compress option + - name: Test push with compression + containers.podman.podman_image: + executable: "{{ test_executable | default('podman') }}" + name: test-force-rm + pull: false + push: true + push_args: + dest: /tmp/compressed-export.tar + transport: docker-archive + compress: true + register: compress_push + + - name: Verify compressed push works + assert: + that: + - compress_push is changed + - "'Pushed image test-force-rm:latest' in compress_push.actions" + + # Test edge case: empty volumes list + - name: Test build with empty volumes list + containers.podman.podman_image: + executable: "{{ test_executable | default('podman') }}" + name: test-empty-volumes + state: build + build: + volume: [] + container_file: | + FROM quay.io/sshnaidm1/alpine:latest + RUN echo "empty volumes test" > /tmp/empty.txt + register: empty_volumes_build + + - name: Verify empty volumes build works + assert: + that: + - empty_volumes_build is changed + + # Test edge case: empty annotations dict + - name: Test build with empty annotations + containers.podman.podman_image: + executable: "{{ test_executable | default('podman') }}" + name: test-empty-annotations + state: build + build: + annotation: {} + container_file: | + FROM quay.io/sshnaidm1/alpine:latest + RUN echo "empty annotations test" > /tmp/empty-ann.txt + register: empty_annotations_build + + - name: Verify empty annotations build works + assert: + that: + - empty_annotations_build is changed + + always: + # Cleanup all test images and directories + - name: Remove test images + containers.podman.podman_image: + executable: "{{ test_executable | default('podman') }}" + name: "{{ item }}" + state: absent + force: true + loop: + - quay.io/testing/private-repo + - quay.io/sshnaidm1/alpine:3.16 + - quay.io/sshnaidm1/alpine:3.17 + - quay.io/sshnaidm1/alpine:3.18 + - quay.io/sshnaidm1/alpine:3.19 + - quay.io/sshnaidm1/alpine:3.20 + - test-annotations + - test-multistage + - test-extra-args + - docker.io/library/busybox + - test-missing-containerfile + - test-conflict + - nonexistent-local-image + - test-hash-change + - test-no-cache + - test-force-rm + - test-empty-volumes + - test-empty-annotations + - docker.io/nonexistent/imagethatdoesnotexist + ignore_errors: true + + - name: Remove test directories + file: + path: "{{ item }}" + state: absent + loop: + - /tmp/build_annotations + - /tmp/push-test-dir + - /tmp/empty_build_dir + - /tmp/hash_change_test + - "{{ auth_file.path | default('/tmp/nonexistent') }}" + ignore_errors: true + + - name: Remove test archive files + file: + path: "{{ item }}" + state: absent + loop: + - /tmp/test-oci-export.tar + - /tmp/compressed-export.tar + - /tmp/testimage-dir + - /tmp/testimage-dir1 + - /tmp/testimage-dir15 + - /tmp/testimage-dir25 + - /tmp/test-docker-arch + - /tmp/test-docker-arch1 + - /tmp/test-docker-arch5 + - /tmp/test-docker-arch15 + - /tmp/test-oci-arch + - /tmp/test-oci-arch1 + - /tmp/test-oci-arch5 + - /tmp/test-oci-arch15 + ignore_errors: true diff --git a/tests/integration/targets/podman_image/tasks/main.yml b/tests/integration/targets/podman_image/tasks/main.yml index 4ea6492..a8fa331 100644 --- a/tests/integration/targets/podman_image/tasks/main.yml +++ b/tests/integration/targets/podman_image/tasks/main.yml @@ -338,8 +338,8 @@ that: - "bad_push is failed" - "bad_push is not changed" - - "'Failed to find image bad_image' in bad_push.msg" - - "'image pull set to False' in bad_push.msg" + - "'Image bad_image:latest not found' in bad_push.msg" + - "'pull is disabled' in bad_push.msg" - name: Pull an image for a specific CPU architecture containers.podman.podman_image: @@ -400,11 +400,16 @@ path: /var/tmp/build register: build_image2 + - name: Create a temporary directory for custom image + file: + path: /tmp/containers_test + state: directory + - name: Build image from a given Containerfile containers.podman.podman_image: executable: "{{ test_executable | default('podman') }}" name: testimage2:customfile - path: "{{ playbook_dir }}" + path: /tmp/containers_test build: container_file: |- FROM quay.io/sshnaidm1/alpine-sh @@ -581,6 +586,8 @@ - quad3 is changed - "'arm64' in quad3.diff.after" + - include_tasks: additional_tests.yml + always: - name: Cleanup images containers.podman.podman_image: