diff --git a/.github/workflows/podman_quadlet.yml b/.github/workflows/podman_quadlet.yml new file mode 100644 index 0000000..83467fc --- /dev/null +++ b/.github/workflows/podman_quadlet.yml @@ -0,0 +1,34 @@ +name: Podman quadlet + +on: + push: + paths: + - ".github/workflows/podman_quadlet.yml" + - ".github/workflows/reusable-module-test.yml" + - "ci/*.yml" + - "ci/run_containers_tests.sh" + - "ci/playbooks/containers/podman_quadlet.yml" + - "plugins/modules/podman_quadlet.py" + - "plugins/module_utils/podman/quadlet.py" + - "tests/integration/targets/podman_quadlet/**" + branches: + - main + pull_request: + paths: + - ".github/workflows/podman_quadlet.yml" + - ".github/workflows/reusable-module-test.yml" + - "ci/*.yml" + - "ci/run_containers_tests.sh" + - "ci/playbooks/containers/podman_quadlet.yml" + - "plugins/modules/podman_quadlet.py" + - "plugins/module_utils/podman/quadlet.py" + - "tests/integration/targets/podman_quadlet/**" + schedule: + - cron: 4 0 * * * # Run daily at 0:04 UTC + +jobs: + test_podman_quadlet: + uses: ./.github/workflows/reusable-module-test.yml + with: + module_name: "podman_quadlet" + display_name: "Podman quadlet" diff --git a/.github/workflows/podman_quadlet_info.yml b/.github/workflows/podman_quadlet_info.yml new file mode 100644 index 0000000..32b79a7 --- /dev/null +++ b/.github/workflows/podman_quadlet_info.yml @@ -0,0 +1,34 @@ +name: Podman quadlet info + +on: + push: + paths: + - ".github/workflows/podman_quadlet_info.yml" + - ".github/workflows/reusable-module-test.yml" + - "ci/*.yml" + - "ci/run_containers_tests.sh" + - "ci/playbooks/containers/podman_quadlet_info.yml" + - "plugins/modules/podman_quadlet_info.py" + - "plugins/module_utils/podman/quadlet.py" + - "tests/integration/targets/podman_quadlet_info/**" + branches: + - main + pull_request: + paths: + - ".github/workflows/podman_quadlet_info.yml" + - ".github/workflows/reusable-module-test.yml" + - "ci/*.yml" + - "ci/run_containers_tests.sh" + - "ci/playbooks/containers/podman_quadlet_info.yml" + - "plugins/modules/podman_quadlet_info.py" + - "plugins/module_utils/podman/quadlet.py" + - "tests/integration/targets/podman_quadlet_info/**" + schedule: + - cron: 5 0 * * * # Run daily at 0:05 UTC + +jobs: + test_podman_quadlet_info: + uses: ./.github/workflows/reusable-module-test.yml + with: + module_name: "podman_quadlet_info" + display_name: "Podman quadlet info" diff --git a/ci/playbooks/containers/podman_quadlet.yml b/ci/playbooks/containers/podman_quadlet.yml new file mode 100644 index 0000000..5a35494 --- /dev/null +++ b/ci/playbooks/containers/podman_quadlet.yml @@ -0,0 +1,8 @@ +--- +- hosts: all + gather_facts: true + tasks: + - include_role: + name: podman_quadlet + vars: + ansible_python_interpreter: "{{ _ansible_python_interpreter }}" diff --git a/ci/playbooks/containers/podman_quadlet_info.yml b/ci/playbooks/containers/podman_quadlet_info.yml new file mode 100644 index 0000000..e601aee --- /dev/null +++ b/ci/playbooks/containers/podman_quadlet_info.yml @@ -0,0 +1,8 @@ +--- +- hosts: all + gather_facts: true + tasks: + - include_role: + name: podman_quadlet_info + vars: + ansible_python_interpreter: "{{ _ansible_python_interpreter }}" diff --git a/plugins/module_utils/podman/quadlet.py b/plugins/module_utils/podman/quadlet.py index f109117..126b605 100644 --- a/plugins/module_utils/podman/quadlet.py +++ b/plugins/module_utils/podman/quadlet.py @@ -15,6 +15,30 @@ from ansible_collections.containers.podman.plugins.module_utils.podman.common im QUADLET_ROOT_PATH = "/etc/containers/systemd/" QUADLET_NON_ROOT_PATH = "~/.config/containers/systemd/" +# https://github.com/containers/podman/blob/main/pkg/systemd/quadlet/quadlet_common.go +QUADLET_SUFFIXES = [ + ".artifact", + ".container", + ".volume", + ".kube", + ".network", + ".image", + ".build", + ".pod", + ".quadlets", +] + + +def resolve_quadlet_dir(module): + quadlet_dir = module.params.get("quadlet_dir") + if not quadlet_dir: + user_is_root = os.geteuid() == 0 + if user_is_root: + quadlet_dir = QUADLET_ROOT_PATH + else: + quadlet_dir = os.path.expanduser(QUADLET_NON_ROOT_PATH) + return quadlet_dir + class Quadlet: param_map = {} diff --git a/plugins/modules/podman_quadlet.py b/plugins/modules/podman_quadlet.py new file mode 100644 index 0000000..1bdc0f7 --- /dev/null +++ b/plugins/modules/podman_quadlet.py @@ -0,0 +1,815 @@ +#!/usr/bin/python +# Copyright (c) 2025 Red Hat +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +# flake8: noqa: E501 +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + + +DOCUMENTATION = r""" +module: podman_quadlet +author: + - "Sagi Shnaidman (@sshnaidm)" +short_description: Install or remove Podman Quadlets +description: + - Install or remove Podman Quadlets using C(podman quadlet install) and C(podman quadlet rm). + - Creation of quadlet files is handled by resource modules with I(state=quadlet). + - Updates are handled by removing the existing quadlet and installing the new one. + - "Idempotency for local sources uses Podman's .app/.asset manifest files and direct content comparison." + - "For remote URLs, the module always reinstalls to ensure the host matches the configured source (reports changed=true)." + - Supports C(.quadlets) files containing multiple quadlet sections separated by C(---) delimiter (requires Podman 6.0+). + - Each section in a C(.quadlets) file must include a C(# FileName=) comment to specify the output filename. +requirements: + - podman +options: + state: + description: + - Desired state of quadlet(s). + type: str + default: present + choices: + - present + - absent + name: + description: + - Name (filename without path) of an installed quadlet to remove when I(state=absent). + - If the name does not include the type suffix (e.g. C(.container)), the module will attempt to find a matching quadlet file. + type: list + elements: str + src: + description: + - Path to a quadlet file, a directory containing a quadlet application, or a URL to install when I(state=present). + - For local files and directories, full idempotency is provided (content comparison). + - For remote URLs, the module always installs fresh and reports C(changed=true) since content cannot be verified. + - Directory installs support only top-level files; nested subdirectories will cause an error. + type: str + files: + description: + - Additional non-quadlet files or URLs to install along with the primary I(src) (quadlet application use-case). + - Passed positionally to C(podman quadlet install) after I(src). + - For local files, full idempotency is provided. + - If any file is a URL, the entire install always reports C(changed=true) since remote content cannot be verified. + type: list + elements: str + quadlet_dir: + description: + - Override the target quadlet directory used for idempotency checks. + - By default it follows Podman defaults. + - C(/etc/containers/systemd/) for root, C(~/.config/containers/systemd/) for non-root. + - Note this is used for content comparison only and is not passed to Podman. + type: path + reload_systemd: + description: + - Control systemd reload behavior in Podman. When true, pass C(--reload-systemd). + - When false, pass C(--reload-systemd=false). + type: bool + default: true + force: + description: + - Force removal when I(state=absent) (maps to C(podman quadlet rm --force)). + type: bool + default: true + all: + description: + - Remove all installed quadlets when I(state=absent) (maps to C(podman quadlet rm --all)). + type: bool + default: false + executable: + description: + - Path to C(podman) executable if it is not in the C($PATH) on the machine running C(podman) + default: 'podman' + type: str + cmd_args: + description: + - Extra global arguments to pass to the C(podman) command (e.g., C(--log-level=debug)). + - These are placed after the executable and before the subcommand. + type: list + elements: str + debug: + description: + - Return additional information which can be helpful for investigations. + type: bool + default: false +""" + + +RETURN = r""" +changed: + description: Whether any change was made + returned: always + type: bool +actions: + description: Human-readable actions performed + returned: always + type: list +podman_actions: + description: Executed podman command lines + returned: always + type: list +quadlets: + description: List of affected quadlets with name, path, and scope + returned: always + type: list +stdout: + description: podman stdout + returned: when debug=true + type: str +stderr: + description: podman stderr + returned: when debug=true + type: str +_debug_spec: + description: Internal specification used for idempotency detection + returned: when debug=true and state=present + type: dict + contains: + mode: + description: Install mode (dir_app, quadlets_app, single_file, or remote) + type: str + marker_name: + description: The .app or .asset marker filename used by Podman + type: str + desired_files: + description: List of filenames that should be installed + type: list + removal_target: + description: What will be passed to 'podman quadlet rm' for updates + type: str +_debug_installed_files: + description: List of currently installed files detected from Podman manifests + returned: when debug=true and state=present and mode is not remote + type: list +""" + + +EXAMPLES = r""" +- name: Install a simple quadlet file + containers.podman.podman_quadlet: + state: present + src: /tmp/myapp.container + +- name: Install a quadlet application with additional config files + containers.podman.podman_quadlet: + state: present + src: /tmp/myapp.container + files: + - /tmp/myapp.conf + - /tmp/secrets.env + +- name: Install quadlet application from a directory + containers.podman.podman_quadlet: + state: present + src: /tmp/myapp_dir/ + +- name: Install with custom quadlet directory (e.g. for system-wide install) + containers.podman.podman_quadlet: + state: present + src: /tmp/myapp.container + quadlet_dir: /etc/containers/systemd + become: true + +- name: Remove a specific quadlet + containers.podman.podman_quadlet: + state: absent + name: + - myapp.container + +- name: Remove multiple quadlets + containers.podman.podman_quadlet: + state: absent + name: + - myapp.container + - database.container + - cache.container + +- name: Remove quadlet without suffix (module resolves to .container, .pod, etc.) + containers.podman.podman_quadlet: + state: absent + name: + - myapp + +- name: Remove all quadlets (use with caution) + containers.podman.podman_quadlet: + state: absent + all: true + +- name: Install quadlet from a URL (always reports changed=true) + containers.podman.podman_quadlet: + state: present + src: https://example.com/myapp.container + +- name: Install multi-quadlet application from .quadlets file (Podman 6.0+) + containers.podman.podman_quadlet: + state: present + src: /tmp/webapp.quadlets +""" + + +import os +import json + +from ansible.module_utils.basic import AnsibleModule # noqa: F402 + +try: + from ansible.module_utils.common.text.converters import to_native # noqa: F402 +except ImportError: + from ansible.module_utils.common.text import to_native # noqa: F402 +from ..module_utils.podman.quadlet import ( + resolve_quadlet_dir, + QUADLET_SUFFIXES, +) +from ..module_utils.podman.common import get_podman_version + +# Install modes +MODE_DIR_APP = "dir_app" +MODE_QUADLETS_APP = "quadlets_app" +MODE_SINGLE_FILE = "single_file" +MODE_REMOTE = "remote" + + +def _is_remote_ref(path): + """Check if the path is a remote URL or OCI artifact reference.""" + if path is None: + return False + path_lower = path.lower() + return path_lower.startswith("http://") or path_lower.startswith("https://") + + +def _read_lines_if_exists(path): + """Read lines from a file if it exists, returning a set of non-empty lines.""" + if not os.path.exists(path): + return set() + try: + with open(path, "r") as f: + return {line.strip() for line in f if line.strip()} + except (IOError, OSError): + return set() + + +def _read_file_bytes(path): + """Read file contents as bytes, return None if cannot read.""" + try: + with open(path, "rb") as f: + return f.read() + except (IOError, OSError): + return None + + +def _get_asset_marker_for_quadlet(quadlet_name): + """Get the .asset marker filename for a single quadlet file.""" + return ".%s.asset" % quadlet_name + + +def _add_extra_files(module, extra_files, desired_files): + """Add extra files to desired_files dict, validating they exist.""" + for f in extra_files: + if not os.path.isfile(f): + module.fail_json(msg="Extra file %s is not a file" % f) + content = _read_file_bytes(f) + if content is not None: + desired_files[os.path.basename(f)] = content + + +def _parse_quadlets_file(path): + """Parse a .quadlets file and return a dict of {filename: content}. + + Each section is separated by '---' and must have a '# FileName=' comment. + The extension is detected from the section content (e.g. [Container] -> .container). + """ + try: + with open(path, "r") as f: + content = f.read() + except (IOError, OSError): + return None + + sections = [] + current_section = [] + + for line in content.split("\n"): + if line.strip() == "---": + if current_section: + sections.append("\n".join(current_section)) + current_section = [] + else: + current_section.append(line) + + if current_section: + sections.append("\n".join(current_section)) + + result = {} + for section in sections: + section = section.strip() + if not section: + continue + + # Extract FileName from comments + filename = None + extension = None + for line in section.split("\n"): + line_stripped = line.strip() + if line_stripped.startswith("#"): + comment_content = line_stripped[1:].strip() + if comment_content.startswith("FileName="): + filename = comment_content[9:].strip() + elif line_stripped.startswith("[") and line_stripped.endswith("]"): + section_name = line_stripped[1:-1].lower() + extension = ".%s" % section_name + + if filename and extension: + full_filename = filename + extension + result[full_filename] = section.encode("utf-8") + + return result + + +def _build_desired_spec(module, src, extra_files): + """Build a specification of what should be installed. + + Returns a dict with: + - mode: one of MODE_DIR_APP, MODE_QUADLETS_APP, MODE_SINGLE_FILE, MODE_REMOTE + - marker_name: the .app or .asset marker filename (None for remote) + - desired_files: dict of {installed_filename: bytes} for local sources + - removal_target: what to pass to 'podman quadlet rm' for updates + """ + extra_files = extra_files or [] + + # Check if src is a remote reference + if _is_remote_ref(src): + return { + "mode": MODE_REMOTE, + "marker_name": None, + "desired_files": {}, + "removal_target": None, + } + + # Check if any extra file is remote + for f in extra_files: + if _is_remote_ref(f): + return { + "mode": MODE_REMOTE, + "marker_name": None, + "desired_files": {}, + "removal_target": None, + } + + # Local source - check existence + if not os.path.exists(src): + module.fail_json(msg="Source file or directory %s does not exist" % src) + + desired_files = {} + + if os.path.isdir(src): + # Directory install - creates .app marker + basename = os.path.basename(src.rstrip("/")) + marker_name = ".%s.app" % basename + + # Validate: no subdirectories allowed (Podman doesn't support them) + for entry in os.listdir(src): + full_path = os.path.join(src, entry) + if os.path.isdir(full_path): + module.fail_json( + msg="Directory %s contains subdirectory '%s'. " + "Podman quadlet install does not support nested directories; " + "only top-level files are supported." % (src, entry) + ) + if os.path.isfile(full_path): + content = _read_file_bytes(full_path) + if content is not None: + desired_files[entry] = content + + _add_extra_files(module, extra_files, desired_files) + + return { + "mode": MODE_DIR_APP, + "marker_name": marker_name, + "desired_files": desired_files, + "removal_target": marker_name, + } + + elif os.path.isfile(src): + basename = os.path.basename(src) + + # Check if it's a .quadlets file + if src.endswith(".quadlets"): + # .quadlets file requires Podman 6.0+ + version_str = get_podman_version(module, fail=False) + if version_str: + try: + major_version = int(version_str.split(".")[0]) + if major_version < 6: + module.fail_json( + msg=".quadlets files require Podman 6.0 or later (current: %s)" % version_str + ) + except (ValueError, IndexError): + pass # If we can't parse version, let Podman handle it + + # .quadlets file - creates .app marker with extracted quadlets + marker_name = ".%s.app" % os.path.splitext(basename)[0] + parsed = _parse_quadlets_file(src) + if parsed is None: + module.fail_json(msg="Failed to parse .quadlets file %s" % src) + desired_files = parsed + + _add_extra_files(module, extra_files, desired_files) + + return { + "mode": MODE_QUADLETS_APP, + "marker_name": marker_name, + "desired_files": desired_files, + "removal_target": marker_name, + } + else: + # Single quadlet file - creates .asset marker for extra files only + content = _read_file_bytes(src) + if content is not None: + desired_files[basename] = content + + _add_extra_files(module, extra_files, desired_files) + + return { + "mode": MODE_SINGLE_FILE, + "marker_name": _get_asset_marker_for_quadlet(basename) if extra_files else None, + "desired_files": desired_files, + "removal_target": basename, + } + else: + module.fail_json(msg="Source %s is not a file or directory" % src) + + +def _get_installed_files_for_spec(spec, quadlet_dir): + """Get the set of installed filenames based on the spec mode. + + For .app modes: read the .app marker file + For single_file mode: the quadlet file + contents of .asset marker + """ + if spec["mode"] == MODE_REMOTE: + return set() + + if spec["mode"] in (MODE_DIR_APP, MODE_QUADLETS_APP): + # Read .app marker + marker_path = os.path.join(quadlet_dir, spec["marker_name"]) + return _read_lines_if_exists(marker_path) + + elif spec["mode"] == MODE_SINGLE_FILE: + # The primary quadlet file + any assets + installed = set() + primary_quadlet_name = None + + # Get the primary file name from desired_files + for name in spec["desired_files"]: + # Check if it's the primary quadlet (has a quadlet suffix) + for suffix in QUADLET_SUFFIXES: + if name.endswith(suffix): + primary_quadlet_name = name + # Only add to installed set if file actually exists + if os.path.exists(os.path.join(quadlet_dir, name)): + installed.add(name) + break + + # ALWAYS check for .asset marker based on primary quadlet name + # This is needed to detect when assets are removed from the install + if primary_quadlet_name: + asset_marker_path = os.path.join(quadlet_dir, _get_asset_marker_for_quadlet(primary_quadlet_name)) + installed.update(_read_lines_if_exists(asset_marker_path)) + + return installed + + return set() + + +def _needs_change(spec, quadlet_dir): + """Determine if installation/update is needed. + + For remote mode: always returns True (best-effort, let Podman decide) + For local modes: compare desired vs installed file sets and contents + """ + if spec["mode"] == MODE_REMOTE: + # For remote, we'll try to install and let Podman tell us if it exists + return True + + desired_set = set(spec["desired_files"].keys()) + installed_set = _get_installed_files_for_spec(spec, quadlet_dir) + + # If sets differ, definitely need change + if desired_set != installed_set: + return True + + # Compare content of each file + for filename, desired_content in spec["desired_files"].items(): + installed_path = os.path.join(quadlet_dir, filename) + installed_content = _read_file_bytes(installed_path) + if installed_content is None or installed_content != desired_content: + return True + + return False + + +class PodmanQuadletManager: + def __init__(self, module): + self.module = module + self.results = { + "changed": False, + "actions": [], + "podman_actions": [], + "quadlets": [], + } + self.executable = module.get_bin_path(module.params["executable"], required=True) + self.quadlet_dir = resolve_quadlet_dir(module) + + def _build_base_cmd(self): + """Build base command with executable and global args.""" + cmd = [self.executable] + if self.module.params.get("cmd_args"): + cmd.extend(self.module.params["cmd_args"]) + return cmd + + def _build_install_cmd(self): + """Build quadlet install command.""" + cmd = self._build_base_cmd() + cmd.extend(["quadlet", "install"]) + if self.module.params["reload_systemd"]: + cmd.append("--reload-systemd") + else: + cmd.append("--reload-systemd=false") + cmd.append(self.module.params["src"]) + if self.module.params.get("files"): + cmd.extend(self.module.params["files"]) + return cmd + + def _build_rm_cmd(self, names=None): + """Build quadlet rm command.""" + cmd = self._build_base_cmd() + cmd.extend(["quadlet", "rm"]) + if self.module.params["reload_systemd"]: + cmd.append("--reload-systemd") + else: + cmd.append("--reload-systemd=false") + if self.module.params.get("force"): + cmd.append("--force") + if self.module.params.get("all"): + cmd.append("--all") + if names: + cmd.extend(names) + return cmd + + def _build_list_cmd(self): + """Build quadlet list command.""" + cmd = self._build_base_cmd() + cmd.extend(["quadlet", "list", "--format", "json"]) + return cmd + + def _run(self, cmd, record=True): + """Run a command and optionally record it.""" + self.module.log("PODMAN-QUADLET-DEBUG: %s" % " ".join([to_native(i) for i in cmd])) + if record: + self.results["podman_actions"].append(" ".join([to_native(i) for i in cmd])) + if self.module.check_mode: + return 0, "", "" + return self.module.run_command(cmd) + + def _get_installed_quadlets(self): + """Get set of installed quadlet names. + + This is a read-only operation that runs even in check_mode. + """ + cmd = self._build_list_cmd() + self.module.log("PODMAN-QUADLET-DEBUG: %s" % " ".join([to_native(i) for i in cmd])) + # Always run list command, even in check_mode (it's read-only) + rc, out, err = self.module.run_command(cmd) + if rc != 0: + self.module.fail_json( + msg="Failed to list quadlets: %s" % err, + stdout=out, + stderr=err, + **self.results, + ) + try: + quadlets = json.loads(out) if out.strip() else [] + except json.JSONDecodeError as e: + self.module.fail_json( + msg="Failed to parse quadlet list output: %s" % str(e), + stdout=out, + stderr=err, + **self.results, + ) + return {name for name in (q.get("Name") for q in quadlets) if name} + + def _install(self): + src = self.module.params["src"] + extra_files = self.module.params.get("files") or [] + + # Build the desired spec using Podman's manifest-based approach + spec = _build_desired_spec(self.module, src, extra_files) + + # Add debug info if requested + if self.module.params["debug"]: + self.results["_debug_spec"] = { + "mode": spec["mode"], + "marker_name": spec["marker_name"], + "desired_files": list(spec["desired_files"].keys()), + "removal_target": spec["removal_target"], + } + if spec["mode"] != MODE_REMOTE: + installed_set = _get_installed_files_for_spec(spec, self.quadlet_dir) + self.results["_debug_installed_files"] = list(installed_set) + + # Check if change is needed + needs_change = _needs_change(spec, self.quadlet_dir) + + if not needs_change: + # Already up to date + return + + # For remote sources, we cannot verify content matches the URL. + # To ensure Ansible's contract (what's configured = what's on host), + # we always install fresh. Try install first, if "already exists", + # remove and reinstall. + if spec["mode"] == MODE_REMOTE: + cmd = self._build_install_cmd() + rc, out, err = self._run(cmd) + if rc != 0: + err_lower = err.lower() + if "already exists" in err_lower or "refusing to overwrite" in err_lower: + # Need to remove existing and reinstall to ensure fresh content + # Extract the quadlet name from the error or URL + quadlet_name = os.path.basename(src) + rm_cmd = self._build_rm_cmd([quadlet_name]) + rm_rc, rm_out, rm_err = self._run(rm_cmd) + # Ignore rm errors (might not exist with exact name) + if rm_rc != 0: + rm_err_lower = rm_err.lower() + if "does not exist" not in rm_err_lower and "no such" not in rm_err_lower: + # Try to proceed anyway - maybe Podman can handle it + pass + self.results["actions"].append("removed existing quadlet for reinstall from remote") + + # Retry install + cmd = self._build_install_cmd() + rc, out, err = self._run(cmd) + if rc != 0: + self.module.fail_json( + msg="Failed to install quadlet(s) from remote: %s" % err, + stdout=out, + stderr=err, + **self.results, + ) + else: + self.module.fail_json( + msg="Failed to install quadlet(s): %s" % err, + stdout=out, + stderr=err, + **self.results, + ) + + # Remote installs always report changed=true since we can't verify content + self.results["changed"] = True + self.results["actions"].append("installed quadlets from %s" % src) + self.results["quadlets"].append({"source": src, "path": self.quadlet_dir}) + if self.module.params["debug"]: + self.results.update({"stdout": out, "stderr": err}) + return + + # For local sources with changes needed, remove existing then install + removal_target = spec["removal_target"] + if removal_target: + # Check if the removal target exists + marker_path = os.path.join(self.quadlet_dir, removal_target) + target_exists = False + + if spec["mode"] in (MODE_DIR_APP, MODE_QUADLETS_APP): + # For app modes, check if .app marker exists + target_exists = os.path.exists(marker_path) + else: + # For single file mode, check if the quadlet file exists + quadlet_path = os.path.join(self.quadlet_dir, removal_target) + target_exists = os.path.exists(quadlet_path) + + if target_exists: + rm_cmd = self._build_rm_cmd([removal_target]) + rc, out, err = self._run(rm_cmd) + if rc != 0: + err_lower = err.lower() + if "does not exist" not in err_lower and "no such" not in err_lower: + self.module.fail_json( + msg="Failed to remove existing quadlet for update: %s" % err, + stdout=out, + stderr=err, + **self.results, + ) + self.results["actions"].append("removed existing quadlet %s for update" % removal_target) + + # Install + cmd = self._build_install_cmd() + rc, out, err = self._run(cmd) + if rc != 0: + self.module.fail_json( + msg="Failed to install quadlet(s): %s" % err, + stdout=out, + stderr=err, + **self.results, + ) + + self.results["changed"] = True + self.results["actions"].append("installed quadlets from %s" % src) + self.results["quadlets"].append({"source": src, "path": self.quadlet_dir}) + if self.module.params["debug"]: + self.results.update({"stdout": out, "stderr": err}) + + def _absent(self): + names = self.module.params.get("name") or [] + resolved_names = [] + + # If not removing all, resolve names first for idempotency + if not self.module.params.get("all") and names: + installed = self._get_installed_quadlets() + for name in names: + if name in installed: + resolved_names.append(name) + else: + # Try with suffixes + for suffix in QUADLET_SUFFIXES: + if name + suffix in installed: + resolved_names.append(name + suffix) + break + # If not found, already absent - idempotent + + if not resolved_names: + # All quadlets already absent + return + + # Build and run rm command + if self.module.params.get("all"): + cmd = self._build_rm_cmd() + else: + cmd = self._build_rm_cmd(resolved_names) + + rc, out, err = self._run(cmd) + if rc != 0: + # Treat "not found" errors as idempotent (race condition safe) + if "does not exist" in err.lower() or "no such" in err.lower(): + return + + if self.module.params.get("all"): + msg = "Failed to remove all quadlets: %s" % err + else: + msg = "Failed to remove quadlet(s) %s: %s" % (", ".join(resolved_names), err) + self.module.fail_json(msg=msg, stdout=out, stderr=err, **self.results) + + self.results["changed"] = True + + if self.module.params.get("all"): + self.results["actions"].append("removed all quadlets") + self.results["quadlets"].append({"name": "all", "path": self.quadlet_dir}) + else: + self.results["actions"].append("removed %s" % ", ".join(resolved_names)) + for name in resolved_names: + self.results["quadlets"].append({"name": name, "path": self.quadlet_dir}) + + if self.module.params["debug"]: + self.results.update({"stdout": out, "stderr": err}) + + def execute(self): + state = self.module.params["state"] + if state == "present": + self._install() + elif state == "absent": + self._absent() + self.module.exit_json(**self.results) + + +def main(): + module = AnsibleModule( + argument_spec=dict( + state=dict(type="str", default="present", choices=["present", "absent"]), + name=dict(type="list", elements="str", required=False), + src=dict(type="str", required=False), + files=dict(type="list", elements="str", required=False), + quadlet_dir=dict(type="path", required=False), + reload_systemd=dict(type="bool", default=True), + force=dict(type="bool", default=True), + all=dict(type="bool", default=False), + executable=dict(type="str", default="podman"), + cmd_args=dict(type="list", elements="str", required=False), + debug=dict(type="bool", default=False), + ), + required_if=[ + ("state", "present", ["src"]), + ], + mutually_exclusive=[ + ["all", "name"], + ], + supports_check_mode=True, + ) + + # Custom validation for state=absent + if module.params["state"] == "absent": + if not module.params["name"] and not module.params["all"]: + module.fail_json(msg="For state='absent', either 'name' or 'all' must be specified.") + + PodmanQuadletManager(module).execute() + + +if __name__ == "__main__": + main() diff --git a/plugins/modules/podman_quadlet_info.py b/plugins/modules/podman_quadlet_info.py new file mode 100644 index 0000000..43d2933 --- /dev/null +++ b/plugins/modules/podman_quadlet_info.py @@ -0,0 +1,255 @@ +#!/usr/bin/python +# Copyright (c) 2025 Red Hat +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +# flake8: noqa: E501 +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + + +DOCUMENTATION = r""" +module: podman_quadlet_info +author: + - "Sagi Shnaidman (@sshnaidm)" +short_description: Gather information about Podman Quadlets +description: + - List installed Podman Quadlets or print one quadlet content using C(podman quadlet list/print). + - Gather information about Podman Quadlets available on the system. +options: + name: + description: + - Name of the quadlet to print content for. + - When specified, runs C(podman quadlet print) instead of list. + type: str + required: false + kinds: + description: + - List of quadlet kinds to filter by (based on file suffix). + - For example, C(container) matches quadlets ending with C(.container). + type: list + elements: str + choices: + - container + - pod + - network + - volume + - kube + - image + required: false + quadlet_dir: + description: + - Filter results to quadlets whose path is under this directory. + - By default no filtering is applied. + type: path + required: false + executable: + description: + - Path to the podman executable. + type: str + default: podman + cmd_args: + description: + - Extra global arguments to pass to the C(podman) command (e.g., C(--log-level=debug)). + - These are placed after the executable and before the subcommand. + type: list + elements: str + required: false + debug: + description: + - Return additional debug information. + type: bool + default: false +""" + +EXAMPLES = r""" +- name: List all quadlets + containers.podman.podman_quadlet_info: + +- name: Get information about a specific quadlet + containers.podman.podman_quadlet_info: + name: myapp.container + +- name: List only container quadlets + containers.podman.podman_quadlet_info: + kinds: + - container + +- name: List quadlets in a custom directory + containers.podman.podman_quadlet_info: + quadlet_dir: /etc/containers/systemd +""" + + +RETURN = r""" +changed: + description: Always false + returned: always + type: bool +quadlets: + description: List of installed quadlets when listing + returned: when name is not provided + type: list +content: + description: Content of the quadlet when name is provided + returned: when name is provided + type: str +stdout: + description: podman stdout + returned: when debug=true + type: str +stderr: + description: podman stderr + returned: when debug=true + type: str +""" + + +import json +import os + +from ansible.module_utils.basic import AnsibleModule # noqa: F402 + +try: + from ansible.module_utils.common.text.converters import to_native # noqa: F402 +except ImportError: + from ansible.module_utils.common.text import to_native # noqa: F402 + + +# Mapping from kind name to file suffix +KIND_SUFFIXES = { + "container": ".container", + "pod": ".pod", + "network": ".network", + "volume": ".volume", + "kube": ".kube", + "image": ".image", +} + + +def _get_quadlet_kind(name): + """Extract kind from quadlet name based on suffix.""" + if not name: + return None + for kind, suffix in KIND_SUFFIXES.items(): + if name.endswith(suffix): + return kind + return None + + +def _build_base_cmd(module, executable): + """Build base command with executable and global args.""" + cmd = [executable] + if module.params.get("cmd_args"): + cmd.extend(module.params["cmd_args"]) + return cmd + + +def _list_quadlets(module, executable): + """List installed quadlets with optional filtering.""" + cmd = _build_base_cmd(module, executable) + cmd.extend(["quadlet", "list", "--format", "json"]) + + module.log("PODMAN-QUADLET-INFO-DEBUG: %s" % " ".join([to_native(i) for i in cmd])) + rc, out, err = module.run_command(cmd) + + if rc != 0: + module.fail_json(msg="Failed to list quadlets: %s" % err, stdout=out, stderr=err) + + # Strict JSON parsing - fail on errors instead of returning empty + try: + data = json.loads(out) if out.strip() else [] + except json.JSONDecodeError as e: + module.fail_json( + msg="Failed to parse quadlet list output: %s" % str(e), + stdout=out, + stderr=err, + ) + + # Filter by kinds (based on file suffix in Name) + kinds = module.params.get("kinds") + if kinds: + kinds_set = set(kinds) + filtered = [] + for q in data: + name = q.get("Name", "") + kind = _get_quadlet_kind(name) + if kind and kind in kinds_set: + filtered.append(q) + data = filtered + + # Filter by quadlet_dir (based on Path) + quadlet_dir = module.params.get("quadlet_dir") + if quadlet_dir: + # Normalize the directory path + quadlet_dir = os.path.normpath(quadlet_dir) + filtered = [] + for q in data: + path = q.get("Path", "") + if path: + # Check if the quadlet's path is under the specified directory + normalized_path = os.path.normpath(path) + if normalized_path.startswith(quadlet_dir + os.sep) or os.path.dirname(normalized_path) == quadlet_dir: + filtered.append(q) + data = filtered + + result = { + "changed": False, + "quadlets": data, + } + if module.params["debug"]: + result.update({"stdout": out, "stderr": err}) + return result + + +def _print_quadlet(module, executable): + """Print content of a specific quadlet.""" + name = module.params["name"] + cmd = _build_base_cmd(module, executable) + cmd.extend(["quadlet", "print", name]) + + module.log("PODMAN-QUADLET-INFO-DEBUG: %s" % " ".join([to_native(i) for i in cmd])) + rc, out, err = module.run_command(cmd) + + if rc != 0: + module.fail_json(msg="Failed to print quadlet %s: %s" % (name, err), stdout=out, stderr=err) + + result = { + "changed": False, + "content": out, + } + if module.params["debug"]: + result.update({"stdout": out, "stderr": err}) + return result + + +def main(): + module = AnsibleModule( + argument_spec=dict( + name=dict(type="str", required=False), + quadlet_dir=dict(type="path", required=False), + kinds=dict( + type="list", + elements="str", + required=False, + choices=["container", "pod", "network", "volume", "kube", "image"], + ), + executable=dict(type="str", default="podman"), + cmd_args=dict(type="list", elements="str", required=False), + debug=dict(type="bool", default=False), + ), + supports_check_mode=True, + ) + + executable = module.get_bin_path(module.params["executable"], required=True) + + if module.params.get("name"): + result = _print_quadlet(module, executable) + else: + result = _list_quadlets(module, executable) + + module.exit_json(**result) + + +if __name__ == "__main__": + main() diff --git a/tests/integration/targets/podman_quadlet/tasks/main.yml b/tests/integration/targets/podman_quadlet/tasks/main.yml new file mode 100644 index 0000000..a13db05 --- /dev/null +++ b/tests/integration/targets/podman_quadlet/tasks/main.yml @@ -0,0 +1,794 @@ +- name: Test podman_quadlet + block: + - name: Discover podman version + shell: podman version | grep "^Version:" | awk {'print $2'} + register: podman_v + + - name: Set podman version fact + set_fact: + podman_version: "{{ podman_v.stdout | string }}" + + - name: Print podman version + debug: var=podman_v.stdout + + - name: Define quadlet user dir + set_fact: + quadlet_user_dir: "{{ ansible_env.HOME }}/.config/containers/systemd" + + - name: Create temporary directory for single-file quadlet + ansible.builtin.tempfile: + state: directory + prefix: quadlet_single_ + register: quadlet_single_dir + + - name: Write a simple container quadlet file + copy: + dest: "{{ quadlet_single_dir.path }}/test-single.container" + mode: "0644" + content: | + [Container] + Image=docker.io/library/alpine:latest + Exec=/bin/sh -c 'sleep 600' + + - name: Install quadlet from a single file + containers.podman.podman_quadlet: + state: present + src: "{{ quadlet_single_dir.path }}/test-single.container" + reload_systemd: true + register: install_single + + - name: Assert install_single changed + assert: + that: + - install_single.changed + + - name: Verify quadlet file exists in user dir + stat: + path: "{{ quadlet_user_dir }}/test-single.container" + register: single_stat + + - name: Assert quadlet file present + assert: + that: + - single_stat.stat.exists + + - name: Reinstall quadlet single file should be idempotent + containers.podman.podman_quadlet: + state: present + src: "{{ quadlet_single_dir.path }}/test-single.container" + reload_systemd: false + register: reinstall_single + + - name: Assert reinstall_single not changed + assert: + that: + - not reinstall_single.changed + + - name: Remove quadlet without suffix (module should resolve) + containers.podman.podman_quadlet: + state: absent + name: + - test-single + register: rm_no_suffix + + - name: Assert rm_no_suffix changed + assert: + that: + - rm_no_suffix.changed + + - name: Create temporary directory for quadlet application + ansible.builtin.tempfile: + state: directory + prefix: quadlet_app_ + register: quadlet_app_dir + + - name: Write two quadlet files into application dir + copy: + dest: "{{ item.dest }}" + mode: "0644" + content: "{{ item.content }}" + loop: + - { + dest: "{{ quadlet_app_dir.path }}/app-a.container", + content: "[Container]\nImage=docker.io/library/alpine:latest\nExec=/bin/sh -c 'sleep 600'\n", + } + - { + dest: "{{ quadlet_app_dir.path }}/app-b.container", + content: "[Container]\nImage=docker.io/library/alpine:latest\nExec=/bin/sh -c 'sleep 600'\n", + } + + - name: Install quadlet application (directory) + containers.podman.podman_quadlet: + state: present + src: "{{ quadlet_app_dir.path }}" + register: install_app + + - name: Verify both app quadlets installed + stat: + path: "{{ item }}" + loop: + - "{{ quadlet_user_dir }}/app-a.container" + - "{{ quadlet_user_dir }}/app-b.container" + register: app_stats + + - name: Assert app files present + assert: + that: + - app_stats.results | map(attribute='stat.exists') | list | min + + - name: Remove one quadlet from the application (should remove whole app) + containers.podman.podman_quadlet: + state: absent + name: + - app-a.container + register: rm_app_one + + - name: Recreate two standalone quadlets and remove by names list + block: + - name: Write two standalone quadlets + copy: + dest: "{{ item.dest }}" + mode: "0644" + content: "{{ item.content }}" + loop: + - { + dest: "{{ quadlet_single_dir.path }}/standalone-a.container", + content: "[Container]\nImage=docker.io/library/alpine:latest\nExec=/bin/sh -c 'sleep 600'\n", + } + - { + dest: "{{ quadlet_single_dir.path }}/standalone-b.container", + content: "[Container]\nImage=docker.io/library/alpine:latest\nExec=/bin/sh -c 'sleep 600'\n", + } + + - name: Install two standalone quadlets + containers.podman.podman_quadlet: + state: present + src: "{{ item }}" + loop: + - "{{ quadlet_single_dir.path }}/standalone-a.container" + - "{{ quadlet_single_dir.path }}/standalone-b.container" + + - name: Remove both via names list + containers.podman.podman_quadlet: + state: absent + name: + - standalone-a.container + - standalone-b.container + register: rm_both + + - name: Assert removal succeeded and return value structure is correct + assert: + that: + - rm_both.changed + - rm_both.quadlets is defined + - rm_both.quadlets | length == 2 + - rm_both.quadlets[0].name in ['standalone-a.container', 'standalone-b.container'] + - rm_both.quadlets[1].name in ['standalone-a.container', 'standalone-b.container'] + + - name: Verify both app quadlets are removed + stat: + path: "{{ item }}" + loop: + - "{{ quadlet_user_dir }}/app-a.container" + - "{{ quadlet_user_dir }}/app-b.container" + register: app_rm_stats + + - name: Assert app files absent + assert: + that: + - (app_rm_stats.results | map(attribute='stat.exists') | list) | select('equalto', true) | list | length == 0 + + - name: Remove non-existent quadlet (should be idempotent) + containers.podman.podman_quadlet: + state: absent + name: + - does-not-exist.container + register: rm_non_existent + + - name: Assert rm_non_existent succeeded but not changed + assert: + that: + - not rm_non_existent.changed + + # Edge case and negative tests + - name: Test invalid src parameter (missing file) + containers.podman.podman_quadlet: + state: present + src: /nonexistent/path/file.container + register: invalid_src + ignore_errors: true + + - name: Assert invalid src fails appropriately + assert: + that: + - invalid_src is failed + + - name: Test absent state without name or all (should fail) + containers.podman.podman_quadlet: + state: absent + register: absent_missing_args + ignore_errors: true + + - name: Assert absent missing args fails + assert: + that: + - absent_missing_args is failed + - absent_missing_args.msg is search("must be specified") + + - name: Test force parameter + block: + - name: Create a quadlet file for force test + copy: + dest: "{{ quadlet_single_dir.path }}/force-test.container" + mode: "0644" + content: | + [Container] + Image=docker.io/library/alpine:latest + Exec=/bin/sh -c 'sleep 600' + + - name: Install quadlet for force test + containers.podman.podman_quadlet: + state: present + src: "{{ quadlet_single_dir.path }}/force-test.container" + register: force_install + + - name: Assert install succeeded + assert: + that: + - force_install.changed + + - name: Remove quadlet with force=false (testing non-default) + containers.podman.podman_quadlet: + state: absent + name: + - force-test.container + force: false + register: rm_no_force + + - name: Assert removal succeeded even without force + assert: + that: + - rm_no_force.changed + + - name: Test files parameter (additional files) + block: + - name: Create main quadlet and additional config file + copy: + dest: "{{ item.dest }}" + mode: "0644" + content: "{{ item.content }}" + loop: + - { + dest: "{{ quadlet_single_dir.path }}/app-with-config.container", + content: "[Container]\nImage=docker.io/library/alpine:latest\nExec=/bin/sh -c 'sleep 600'\n", + } + - { + dest: "{{ quadlet_single_dir.path }}/app.conf", + content: "# Configuration file\nkey=value\n", + } + + - name: Install quadlet with additional files + containers.podman.podman_quadlet: + state: present + src: "{{ quadlet_single_dir.path }}/app-with-config.container" + files: + - "{{ quadlet_single_dir.path }}/app.conf" + register: install_with_files + + - name: Assert install with files succeeded + assert: + that: + - install_with_files.changed + + - name: Verify both files are installed + stat: + path: "{{ item }}" + loop: + - "{{ quadlet_user_dir }}/app-with-config.container" + - "{{ quadlet_user_dir }}/app.conf" + register: files_stats + + - name: Assert both files present + assert: + that: + - files_stats.results | map(attribute='stat.exists') | list | min + + - name: Remove quadlet with files + containers.podman.podman_quadlet: + state: absent + name: + - app-with-config.container + + - name: Test malformed quadlet file handling + block: + - name: Create malformed quadlet file + copy: + dest: "{{ quadlet_single_dir.path }}/malformed.container" + mode: "0644" + content: | + [Container + Image=docker.io/library/alpine:latest + # Missing closing bracket + + - name: Try to install malformed quadlet + containers.podman.podman_quadlet: + state: present + src: "{{ quadlet_single_dir.path }}/malformed.container" + register: malformed_install + ignore_errors: true + + # Note: Podman may accept malformed files, so we don't assert failure + - name: Cleanup malformed quadlet if installed + containers.podman.podman_quadlet: + state: absent + name: + - malformed.container + ignore_errors: true + + - name: Test update scenario (content change triggers remove+install) + block: + - name: Create quadlet for update test + copy: + dest: "{{ quadlet_single_dir.path }}/update-test.container" + mode: "0644" + content: | + [Container] + Image=docker.io/library/alpine:latest + Exec=/bin/sh -c 'sleep 100' + + - name: Install quadlet for update test + containers.podman.podman_quadlet: + state: present + src: "{{ quadlet_single_dir.path }}/update-test.container" + register: update_install_first + + - name: Assert first install changed + assert: + that: + - update_install_first.changed + + - name: Modify the quadlet file content + copy: + dest: "{{ quadlet_single_dir.path }}/update-test.container" + mode: "0644" + content: | + [Container] + Image=docker.io/library/alpine:latest + Exec=/bin/sh -c 'sleep 200' + + - name: Reinstall modified quadlet (should trigger update) + containers.podman.podman_quadlet: + state: present + src: "{{ quadlet_single_dir.path }}/update-test.container" + register: update_install_second + + - name: Assert update changed + assert: + that: + - update_install_second.changed + + - name: Read installed file content + slurp: + src: "{{ quadlet_user_dir }}/update-test.container" + register: installed_content + + - name: Assert installed file has updated content + assert: + that: + - "'sleep 200' in (installed_content.content | b64decode)" + + - name: Reinstall same content again (should be idempotent) + containers.podman.podman_quadlet: + state: present + src: "{{ quadlet_single_dir.path }}/update-test.container" + register: update_install_third + + - name: Assert idempotent after update + assert: + that: + - not update_install_third.changed + + - name: Cleanup update test quadlet + containers.podman.podman_quadlet: + state: absent + name: + - update-test.container + + - name: Test update with files parameter + block: + - name: Create quadlet and config for files update test + copy: + dest: "{{ item.dest }}" + mode: "0644" + content: "{{ item.content }}" + loop: + - { + dest: "{{ quadlet_single_dir.path }}/files-update.container", + content: "[Container]\nImage=docker.io/library/alpine:latest\nExec=/bin/sh -c 'sleep 600'\n", + } + - { + dest: "{{ quadlet_single_dir.path }}/files-update.conf", + content: "# Config v1\nkey=value1\n", + } + + - name: Install quadlet with config file + containers.podman.podman_quadlet: + state: present + src: "{{ quadlet_single_dir.path }}/files-update.container" + files: + - "{{ quadlet_single_dir.path }}/files-update.conf" + register: files_update_first + + - name: Assert first install changed + assert: + that: + - files_update_first.changed + + - name: Modify the config file only + copy: + dest: "{{ quadlet_single_dir.path }}/files-update.conf" + mode: "0644" + content: | + # Config v2 + key=value2 + + - name: Reinstall with modified config file + containers.podman.podman_quadlet: + state: present + src: "{{ quadlet_single_dir.path }}/files-update.container" + files: + - "{{ quadlet_single_dir.path }}/files-update.conf" + register: files_update_second + + - name: Assert config change triggered update + assert: + that: + - files_update_second.changed + + - name: Read installed config file content + slurp: + src: "{{ quadlet_user_dir }}/files-update.conf" + register: installed_conf_content + + - name: Assert installed config has updated content + assert: + that: + - "'value2' in (installed_conf_content.content | b64decode)" + + - name: Cleanup files update test + containers.podman.podman_quadlet: + state: absent + name: + - files-update.container + + - name: Test check mode + block: + - name: Create quadlet for check mode test + copy: + dest: "{{ quadlet_single_dir.path }}/check-mode.container" + mode: "0644" + content: | + [Container] + Image=docker.io/library/alpine:latest + Exec=/bin/sh -c 'sleep 600' + + - name: Install quadlet in check mode + containers.podman.podman_quadlet: + state: present + src: "{{ quadlet_single_dir.path }}/check-mode.container" + check_mode: true + register: check_mode_install + + - name: Assert check mode shows change but doesn't install + assert: + that: + - check_mode_install.changed + + - name: Verify file not actually installed in check mode + stat: + path: "{{ quadlet_user_dir }}/check-mode.container" + register: check_mode_stat + + - name: Assert file not present after check mode + assert: + that: + - not check_mode_stat.stat.exists + + - name: Remove in check mode (should show change) + containers.podman.podman_quadlet: + state: absent + name: + - check-mode.container + check_mode: true + register: check_mode_remove + + - name: Assert check mode remove shows no change (idempotent) + assert: + that: + - not check_mode_remove.changed + + - name: Install quadlet for real to test check mode removal + containers.podman.podman_quadlet: + state: present + src: "{{ quadlet_single_dir.path }}/check-mode.container" + + - name: Remove in check mode (should show change now) + containers.podman.podman_quadlet: + state: absent + name: + - check-mode.container + check_mode: true + register: check_mode_remove_existing + + - name: Assert check mode remove existing shows change + assert: + that: + - check_mode_remove_existing.changed + + - name: Verify file still exists after check mode remove + stat: + path: "{{ quadlet_user_dir }}/check-mode.container" + register: check_mode_stat_existing + + - name: Assert file present + assert: + that: + - check_mode_stat_existing.stat.exists + + - name: Test asset removal detection (files removed from install) + block: + - name: Create quadlet and config for asset removal test + copy: + dest: "{{ item.dest }}" + mode: "0644" + content: "{{ item.content }}" + loop: + - { + dest: "{{ quadlet_single_dir.path }}/asset-removal.container", + content: "[Container]\nImage=docker.io/library/alpine:latest\nExec=/bin/sh -c 'sleep 600'\n", + } + - { + dest: "{{ quadlet_single_dir.path }}/asset-removal.conf", + content: "# Asset config\nkey=value\n", + } + + - name: Install quadlet with config file + containers.podman.podman_quadlet: + state: present + src: "{{ quadlet_single_dir.path }}/asset-removal.container" + files: + - "{{ quadlet_single_dir.path }}/asset-removal.conf" + register: asset_removal_first + + - name: Assert first install changed + assert: + that: + - asset_removal_first.changed + + - name: Verify both files are installed + stat: + path: "{{ item }}" + loop: + - "{{ quadlet_user_dir }}/asset-removal.container" + - "{{ quadlet_user_dir }}/asset-removal.conf" + register: asset_files_stats + + - name: Assert both files present + assert: + that: + - asset_files_stats.results | map(attribute='stat.exists') | list | min + + - name: Reinstall without the config file (should detect asset removal) + containers.podman.podman_quadlet: + state: present + src: "{{ quadlet_single_dir.path }}/asset-removal.container" + register: asset_removal_second + + - name: Assert reinstall detected change due to asset removal + assert: + that: + - asset_removal_second.changed + + - name: Verify config file is removed + stat: + path: "{{ quadlet_user_dir }}/asset-removal.conf" + register: asset_conf_stat + + - name: Assert config file removed + assert: + that: + - not asset_conf_stat.stat.exists + + - name: Cleanup asset removal test + containers.podman.podman_quadlet: + state: absent + name: + - asset-removal.container + ignore_errors: true + + - name: Test .quadlets file install (Podman 6.0+) + when: podman_version is version('6.0', '>=') + block: + - name: Create a .quadlets file with multiple sections + copy: + dest: "{{ quadlet_single_dir.path }}/webapp.quadlets" + mode: "0644" + content: | + # FileName=web-server + [Container] + Image=docker.io/library/alpine:latest + Exec=/bin/sh -c 'sleep 600' + --- + # FileName=app-storage + [Volume] + Label=app=webapp + + - name: Install .quadlets file + containers.podman.podman_quadlet: + state: present + src: "{{ quadlet_single_dir.path }}/webapp.quadlets" + register: quadlets_install + + - name: Assert .quadlets install changed + assert: + that: + - quadlets_install.changed + + - name: Verify generated quadlet files exist + stat: + path: "{{ item }}" + loop: + - "{{ quadlet_user_dir }}/web-server.container" + - "{{ quadlet_user_dir }}/app-storage.volume" + register: quadlets_stats + + - name: Assert generated files present + assert: + that: + - quadlets_stats.results | map(attribute='stat.exists') | list | min + + - name: Reinstall same .quadlets file (should be idempotent) + containers.podman.podman_quadlet: + state: present + src: "{{ quadlet_single_dir.path }}/webapp.quadlets" + register: quadlets_reinstall + + - name: Assert .quadlets reinstall idempotent + assert: + that: + - not quadlets_reinstall.changed + + - name: Modify .quadlets file content + copy: + dest: "{{ quadlet_single_dir.path }}/webapp.quadlets" + mode: "0644" + content: | + # FileName=web-server + [Container] + Image=docker.io/library/alpine:latest + Exec=/bin/sh -c 'sleep 999' + --- + # FileName=app-storage + [Volume] + Label=app=webapp + + - name: Reinstall modified .quadlets file + containers.podman.podman_quadlet: + state: present + src: "{{ quadlet_single_dir.path }}/webapp.quadlets" + register: quadlets_update + + - name: Assert .quadlets update detected change + assert: + that: + - quadlets_update.changed + + - name: Read installed container file content + slurp: + src: "{{ quadlet_user_dir }}/web-server.container" + register: quadlets_container_content + + - name: Assert container file has updated content + assert: + that: + - "'sleep 999' in (quadlets_container_content.content | b64decode)" + + - name: Cleanup .quadlets test (remove via app marker) + containers.podman.podman_quadlet: + state: absent + name: + - web-server.container + ignore_errors: true + + - name: Skip .quadlets test notice + debug: + msg: "Skipping .quadlets test - requires Podman 6.0+ (current: {{ podman_version }})" + when: podman_version is version('6.0', '<') + + - name: Test nested subdirectory validation + block: + - name: Create temporary directory for subdir test + ansible.builtin.tempfile: + state: directory + prefix: quadlet_subdir_ + register: quadlet_subdir_test_dir + + - name: Create a subdirectory inside the app directory + file: + path: "{{ quadlet_subdir_test_dir.path }}/nested_subdir" + state: directory + + - name: Create a quadlet file in the parent directory + copy: + dest: "{{ quadlet_subdir_test_dir.path }}/test.container" + mode: "0644" + content: | + [Container] + Image=docker.io/library/alpine:latest + Exec=/bin/sh -c 'sleep 600' + + - name: Create a file in the nested subdirectory + copy: + dest: "{{ quadlet_subdir_test_dir.path }}/nested_subdir/nested.conf" + mode: "0644" + content: "# Nested config\n" + + - name: Try to install directory with subdirectory (should fail) + containers.podman.podman_quadlet: + state: present + src: "{{ quadlet_subdir_test_dir.path }}" + register: subdir_install + ignore_errors: true + + - name: Assert subdir install fails with expected message + assert: + that: + - subdir_install is failed + - subdir_install.msg is search("nested") or subdir_install.msg is search("subdirector") + + always: + # clean the test quadlets + - name: Cleanup installed quadlets + containers.podman.podman_quadlet: + state: absent + name: + - test-single.container + - app-a.container + - app-b.container + - force-test.container + - app-with-config.container + - malformed.container + - check-mode.container + - update-test.container + - files-update.container + - asset-removal.container + - web-server.container + - app-storage.volume + ignore_errors: true + + - name: Verify cleanup - files should be removed from filesystem + stat: + path: "{{ item }}" + loop: + - "{{ quadlet_user_dir }}/test-single.container" + - "{{ quadlet_user_dir }}/app-a.container" + - "{{ quadlet_user_dir }}/app-b.container" + - "{{ quadlet_user_dir }}/force-test.container" + - "{{ quadlet_user_dir }}/app-with-config.container" + - "{{ quadlet_user_dir }}/app.conf" + - "{{ quadlet_user_dir }}/update-test.container" + - "{{ quadlet_user_dir }}/files-update.container" + - "{{ quadlet_user_dir }}/files-update.conf" + - "{{ quadlet_user_dir }}/asset-removal.container" + - "{{ quadlet_user_dir }}/asset-removal.conf" + - "{{ quadlet_user_dir }}/web-server.container" + - "{{ quadlet_user_dir }}/app-storage.volume" + register: cleanup_stats + ignore_errors: true + + - name: Assert all test files removed from filesystem + assert: + that: + - (cleanup_stats.results | map(attribute='stat.exists') | list) | select('equalto', true) | list | length == 0 + quiet: true + ignore_errors: true diff --git a/tests/integration/targets/podman_quadlet_info/tasks/main.yml b/tests/integration/targets/podman_quadlet_info/tasks/main.yml new file mode 100644 index 0000000..2d419de --- /dev/null +++ b/tests/integration/targets/podman_quadlet_info/tasks/main.yml @@ -0,0 +1,119 @@ +- name: Test podman_quadlet_info + block: + - name: Discover podman version + shell: podman version | grep "^Version:" | awk {'print $2'} + register: podman_v + + - name: Set podman version fact + set_fact: + podman_version: "{{ podman_v.stdout | string }}" + + - name: Print podman version + debug: var=podman_v.stdout + + - name: Define quadlet user dir + set_fact: + quadlet_user_dir: "{{ ansible_env.HOME }}/.config/containers/systemd" + + - name: Create a temporary file quadlet to inspect + ansible.builtin.tempfile: + state: file + suffix: .container + register: info_quadlet_tmp + + - name: Write a quadlet content to temp file + copy: + dest: "{{ info_quadlet_tmp.path }}" + mode: "0644" + content: | + [Container] + Image=docker.io/library/alpine:latest + Exec=/bin/sh -c 'sleep 600' + + - name: Install temp quadlet + containers.podman.podman_quadlet: + state: present + src: "{{ info_quadlet_tmp.path }}" + + - name: List quadlets + containers.podman.podman_quadlet_info: {} + register: list_info + + - name: Assert quadlets list includes our file + assert: + that: + - list_info.quadlets is defined + + - name: Print specific quadlet + containers.podman.podman_quadlet_info: + name: "{{ info_quadlet_tmp.path | basename }}" + register: print_info + + - name: Assert print contains our section + assert: + that: + - print_info.content is search('\\[Container\\]') + + # Test filtering and edge cases + - name: Test kinds filtering (container only) + containers.podman.podman_quadlet_info: + kinds: [container] + register: filtered_info + + - name: Assert filtered results are only containers + assert: + that: + - filtered_info.quadlets is defined + # If there are results, all should end with .container + - filtered_info.quadlets | length == 0 or (filtered_info.quadlets | map(attribute='Name') | select('search', '\\.container$') | list | length == filtered_info.quadlets | length) + + - name: Test quadlet_dir filtering + containers.podman.podman_quadlet_info: + quadlet_dir: "{{ quadlet_user_dir }}" + register: dir_filtered_info + + - name: Assert quadlet_dir filtered results have correct paths + assert: + that: + - dir_filtered_info.quadlets is defined + # If there are results, all paths should be under the specified directory + - dir_filtered_info.quadlets | length == 0 or (dir_filtered_info.quadlets | map(attribute='Path') | select('search', quadlet_user_dir) | list | length == dir_filtered_info.quadlets | length) + + - name: Test non-existent quadlet print + containers.podman.podman_quadlet_info: + name: non-existent-quadlet.container + register: nonexistent_print + ignore_errors: true + + - name: Assert non-existent print fails appropriately + assert: + that: + - nonexistent_print is failed + + - name: Test check mode for info module + containers.podman.podman_quadlet_info: {} + check_mode: true + register: check_mode_info + + - name: Assert check mode works for info + assert: + that: + - not check_mode_info.changed + - check_mode_info.quadlets is defined + + - name: Test debug mode + containers.podman.podman_quadlet_info: + debug: true + register: debug_info + + - name: Assert debug output present + assert: + that: + - debug_info.stdout is defined or debug_info.stderr is defined + + always: + # clean the test quadlets + - name: Cleanup installed quadlet + containers.podman.podman_quadlet: + state: absent + name: "{{ info_quadlet_tmp.path | basename }}"