mirror of
https://github.com/containers/ansible-podman-collections.git
synced 2026-02-03 23:01:48 +00:00
Add podman Quadlet modules
This commit introduces two new modules for managing Podman Quadlets: - podman_quadlet: Install and remove Podman Quadlet files * Supports installing single files, directories, and additional config files * Implements idempotent state management (present/absent) * Validates parameters and provides meaningful error messages * Default force=true for removal operations * Removed deprecated 'ignore' parameter in favor of built-in idempotency - podman_quadlet_info: Gather information about installed Quadlets * Lists all installed quadlets or prints specific quadlet content * Supports filtering by quadlet kinds (container, pod, network, etc.) * Provides detailed quadlet metadata including status and paths Key features: - Shared utilities in module_utils/podman/quadlet.py for code reuse - Comprehensive integration tests for both modules - Full idempotency support for all operations - Proper handling of edge cases (missing files, malformed quadlets, etc.) - Check mode support for safe dry-run operations - Extensive documentation and examples The modules use relative imports for module_utils to support local development and testing with the containers.podman collection. Signed-off-by: Sagi Shnaidman <sshnaidm@redhat.com>
This commit is contained in:
parent
a808c18fbc
commit
09bb5454a9
9 changed files with 2091 additions and 0 deletions
34
.github/workflows/podman_quadlet.yml
vendored
Normal file
34
.github/workflows/podman_quadlet.yml
vendored
Normal file
|
|
@ -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"
|
||||||
34
.github/workflows/podman_quadlet_info.yml
vendored
Normal file
34
.github/workflows/podman_quadlet_info.yml
vendored
Normal file
|
|
@ -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"
|
||||||
8
ci/playbooks/containers/podman_quadlet.yml
Normal file
8
ci/playbooks/containers/podman_quadlet.yml
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
---
|
||||||
|
- hosts: all
|
||||||
|
gather_facts: true
|
||||||
|
tasks:
|
||||||
|
- include_role:
|
||||||
|
name: podman_quadlet
|
||||||
|
vars:
|
||||||
|
ansible_python_interpreter: "{{ _ansible_python_interpreter }}"
|
||||||
8
ci/playbooks/containers/podman_quadlet_info.yml
Normal file
8
ci/playbooks/containers/podman_quadlet_info.yml
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
---
|
||||||
|
- hosts: all
|
||||||
|
gather_facts: true
|
||||||
|
tasks:
|
||||||
|
- include_role:
|
||||||
|
name: podman_quadlet_info
|
||||||
|
vars:
|
||||||
|
ansible_python_interpreter: "{{ _ansible_python_interpreter }}"
|
||||||
|
|
@ -15,6 +15,30 @@ from ansible_collections.containers.podman.plugins.module_utils.podman.common im
|
||||||
QUADLET_ROOT_PATH = "/etc/containers/systemd/"
|
QUADLET_ROOT_PATH = "/etc/containers/systemd/"
|
||||||
QUADLET_NON_ROOT_PATH = "~/.config/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:
|
class Quadlet:
|
||||||
param_map = {}
|
param_map = {}
|
||||||
|
|
|
||||||
815
plugins/modules/podman_quadlet.py
Normal file
815
plugins/modules/podman_quadlet.py
Normal file
|
|
@ -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=<name>) 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=<name>' 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()
|
||||||
255
plugins/modules/podman_quadlet_info.py
Normal file
255
plugins/modules/podman_quadlet_info.py
Normal file
|
|
@ -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()
|
||||||
794
tests/integration/targets/podman_quadlet/tasks/main.yml
Normal file
794
tests/integration/targets/podman_quadlet/tasks/main.yml
Normal file
|
|
@ -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
|
||||||
119
tests/integration/targets/podman_quadlet_info/tasks/main.yml
Normal file
119
tests/integration/targets/podman_quadlet_info/tasks/main.yml
Normal file
|
|
@ -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 }}"
|
||||||
Loading…
Add table
Add a link
Reference in a new issue