diff --git a/.github/workflows/podman_quadlet_build.yml b/.github/workflows/podman_quadlet_build.yml new file mode 100644 index 0000000..edab43f --- /dev/null +++ b/.github/workflows/podman_quadlet_build.yml @@ -0,0 +1,35 @@ +name: Podman quadlet build + +on: + push: + paths: + - ".github/workflows/podman_quadlet_build.yml" + - ".github/workflows/reusable-module-test.yml" + - ci/playbooks/*.yml + - "ci/run_containers_tests.sh" + - "ci/playbooks/containers/podman_quadlet_build.yml" + - "plugins/modules/podman_quadlet_build.py" + - "plugins/module_utils/podman/quadlet.py" + - "tests/integration/targets/podman_quadlet_build/**" + branches: + - main + pull_request: + paths: + - ".github/workflows/podman_quadlet_build.yml" + - ".github/workflows/reusable-module-test.yml" + - ci/playbooks/*.yml + - "ci/run_containers_tests.sh" + - "ci/playbooks/containers/podman_quadlet_build.yml" + - "plugins/modules/podman_quadlet_build.py" + - "plugins/module_utils/podman/quadlet.py" + - "tests/integration/targets/podman_quadlet_build/**" + schedule: + - cron: 7 0 * * * # Run daily at 0:07 UTC + +jobs: + test_podman_quadlet_build: + uses: ./.github/workflows/reusable-module-test.yml + with: + module_name: "podman_quadlet_build" + display_name: "Podman quadlet build" + extra_collections: "ansible.posix" diff --git a/ci/playbooks/containers/podman_quadlet_build.yml b/ci/playbooks/containers/podman_quadlet_build.yml new file mode 100644 index 0000000..dd4a3f0 --- /dev/null +++ b/ci/playbooks/containers/podman_quadlet_build.yml @@ -0,0 +1,8 @@ +--- +- hosts: all + gather_facts: true + tasks: + - include_role: + name: podman_quadlet_build + vars: + ansible_python_interpreter: "{{ _ansible_python_interpreter }}" diff --git a/galaxy.yml b/galaxy.yml index b4e9761..aa38c86 100644 --- a/galaxy.yml +++ b/galaxy.yml @@ -1,6 +1,6 @@ namespace: containers name: podman -version: 1.19.0 +version: 1.20.0 readme: README.md authors: - Sagi Shnaidman diff --git a/plugins/module_utils/podman/quadlet.py b/plugins/module_utils/podman/quadlet.py index 126b605..ac08b51 100644 --- a/plugins/module_utils/podman/quadlet.py +++ b/plugins/module_utils/podman/quadlet.py @@ -728,6 +728,68 @@ class ImageQuadlet(Quadlet): return params +# This is a inherited class that represents a Quadlet file for the Podman build +class BuildQuadlet(Quadlet): + param_map = { + "annotation": "Annotation", + "arch": "Arch", + "authfile": "AuthFile", + "build_args": "BuildArg", + "ContainersConfModule": "ContainersConfModule", + "dns": "DNS", + "dns_opt": "DNSOption", + "dns_search": "DNSSearch", + "env": "Environment", + "file": "File", + "force_rm": "ForceRM", + "global_args": "GlobalArgs", + "group_add": "GroupAdd", + "ignore_file": "IgnoreFile", + "name": "ImageTag", + "labels": "Labels", + "network": "Network", + "podman_args": "PodmanArgs", + "pull": "Pull", + "retry": "Retry", + "retry_delay": "RetryDelay", + "set_working_directory": "SetWorkingDirectory", + "target": "Target", + "validate_certs": "TLSVerify", + "variant": "Variant", + "volume": "Volume", + } + + def __init__(self, params: dict): + super().__init__("Build", params) + + def custom_prepare_params(self, params: dict) -> dict: + """ + Custom parameter processing for build-specific parameters. + """ + # Work on params in params_map and convert them to a right form + params["global_args"] = [] + params["podman_args"] = [] + + if params["annotation"]: + params["annotation"] = ["%s=%s" % (k, v) for k, v in params["annotation"].items()] + if params["username"] and params["password"]: + params["podman_args"].append(f"--creds {params['username']}:{params['password']}") + if params["build_args"]: + params["build_args"] = ["%s=%s" % (k, v) for k, v in params["build_args"].items()] + if params["env"]: + params["env"] = ["%s=%s" % (k, v) for k, v in params["env"].items()] + if params["labels"]: + params["labels"] = ["%s=%s" % (k, v) for k, v in params["labels"].items()] + if params["cmd_args"]: + params["podman_args"] += params["cmd_args"] + if params["ca_cert_dir"]: + params["podman_args"].append(f"--cert-dir {params['ca_cert_dir']}") + if not params["cache"]: + params["podman_args"].append("--no-cache") + + return params + + def check_quadlet_directory(module, quadlet_dir): """Check if the directory exists and is writable. If not, fail the module.""" if not os.path.exists(quadlet_dir): @@ -742,6 +804,7 @@ def check_quadlet_directory(module, quadlet_dir): def create_quadlet_state(module, issuer): """Create a quadlet file for the specified issuer.""" class_map = { + "build": BuildQuadlet, "container": ContainerQuadlet, "network": NetworkQuadlet, "pod": PodQuadlet, @@ -760,7 +823,7 @@ def create_quadlet_state(module, issuer): # Create a filename based on the issuer if not module.params.get("name") and not module.params.get("quadlet_filename"): module.fail_json(msg=f"Filename for {issuer} is required for creating a quadlet file.") - if issuer == "image": + if issuer == "image" or issuer == "build": name = module.params["name"].split("/")[-1].split(":")[0] else: name = module.params.get("name") diff --git a/plugins/modules/podman_quadlet_build.py b/plugins/modules/podman_quadlet_build.py new file mode 100644 index 0000000..ae1639b --- /dev/null +++ b/plugins/modules/podman_quadlet_build.py @@ -0,0 +1,307 @@ +# Copyright (c) 2018 Ansible Project +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + + +DOCUMENTATION = r""" + module: podman_quadlet_build + author: + - Benjamin Vouillaume (@BenjaminVouillaume) + short_description: Build images for use by Podman Quadlets + notes: [] + description: + - Build images using Quadlet. + options: + annotation: + description: + - Dictionary of key=value pairs to add to the image. Only works with OCI images. + Ignored for Docker containers. + type: dict + arch: + description: + - CPU architecture for the container image + type: str + auth_file: + description: + - Path to file containing authorization credentials to the remote registry. + aliases: + - authfile + type: path + build_args: + description: + - Dictionary of key=value pairs to add as build argument. + aliases: + - buildargs + type: dict + ca_cert_dir: + description: + - Path to directory containing TLS certificates and keys to use. + type: 'path' + cache: + description: + - Whether or not to use cached layers when building an image + type: bool + 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 + dns: + description: + - Set custom DNS servers in the /etc/resolv.conf file that will be shared between + all containers in the pod. A special option, "none" is allowed which disables + creation of /etc/resolv.conf for the pod. + type: list + elements: str + required: false + dns_opt: + description: + - Set custom DNS options in the /etc/resolv.conf file that will be shared between + all containers in the pod. + type: list + elements: str + aliases: + - dns_option + required: false + dns_search: + description: + - Set custom DNS search domains in the /etc/resolv.conf file that will be shared + between all containers in the pod. + type: list + elements: str + required: false + env: + description: + - Dictionary of key=value pairs to add as environment variable. + type: dict + file: + description: Path to the Containerfile if it is not in the build context directory. + required: True + type: path + force_rm: + description: + - Always remove intermediate containers after a build, even if the build is unsuccessful. + type: bool + group_add: + description: + - Add additional groups to run as + type: list + elements: str + aliases: + - groups + ignore_file: + description: Path to an alternate .containerignore file to use when building the image. + type: path + aliases: + - ignorefile + name: + description: + - Name of the image to build. It may contain a tag using the format C(image:tag). + required: True + type: str + labels: + description: + - Labels to set on the image. + type: dict + network: + description: + - List of the names of CNI networks the build should join during the RUN instructions. + type: list + elements: str + aliases: + - net + - network_mode + password: + description: + - Password to use when authenticating to remote registries. + type: str + pull: + description: + - Pull image policy. The default is 'missing'. + type: str + choices: + - 'missing' + - 'always' + - 'never' + - 'newer' + retry: + description: + - Number of times to retry pulling or pushing images between the registry and local storage in case of failure. + Default is 3. + type: int + retry_delay: + description: + - Duration of delay between retry attempts when pulling or pushing images between the registry and local storage in case of failure. + type: str + aliases: + - retrydelay + set_working_directory: + description: + - Provide context (a working directory) to podman build. + type: str + aliases: + - setworkingdirectory + target: + description: + - Specify the target build stage to build. + type: str + username: + description: + - username to use when authenticating to remote registries. + type: str + validate_certs: + description: + - Require HTTPS and validate certificates when pulling or pushing. + Also used during build if a pull or push is necessary. + type: bool + aliases: + - tlsverify + - tls_verify + variant: + description: + - Override the default architecture variant of the container image to be built. + type: str + volume: + description: + - Specify multiple volume / mount options to mount one or more mounts to a container. + type: list + elements: str + 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 + state: + description: + - Whether an image should be present, absent, or built. + default: "quadlet" + type: str + choices: + - quadlet + quadlet_dir: + description: + - Path to the directory to write quadlet file in. + By default, it will be set as C(/etc/containers/systemd/) for root user, + C(~/.config/containers/systemd/) for non-root users. + type: path + required: false + quadlet_filename: + description: + - Name of quadlet file to write. By default it takes image name without prefixes and tags. + type: str + quadlet_file_mode: + description: + - The permissions of the quadlet file. + - The O(quadlet_file_mode) can be specied as octal numbers or as a symbolic mode + (for example, V(u+rwx) or V(u=rw,g=r,o=r)). + For octal numbers format, you must either add a leading zero so that Ansible's YAML parser knows it is an + octal number (like V(0644) or V(01777)) or quote it (like V('644') or V('1777')) so Ansible receives a string + and can do its own conversion from string into number. Giving Ansible a number without following one of these + rules will end up with a decimal number which will have unexpected results. + - If O(quadlet_file_mode) is not specified and the quadlet file B(does not) exist, the default V('0640') mask + will be used + when setting the mode for the newly created file. + - If O(quadlet_file_mode) is not specified and the quadlet file B(does) exist, the mode of the existing file + will be used. + - Specifying O(quadlet_file_mode) is the best way to ensure files are created with the correct permissions. + type: raw + required: false + quadlet_options: + description: + - Options for the quadlet file. Provide missing in usual network args + options as a list of lines to add. + type: list + elements: str + required: false + +""" + +EXAMPLES = r""" +- name: Build an image + containers.podman.podman_quadlet_build: + name: myimage + file: /tmp/Containerfile + +- name: Build an image + containers.podman.podman_quadlet_build: + name: myimage + set_working_directory: /tmp/context + +""" + +RETURN = r""" +changed: + description: Whether any change was made + returned: always + type: bool + +""" + +from ansible.module_utils.basic import AnsibleModule +from ..module_utils.podman.quadlet import ( + create_quadlet_state, +) + + +def main(): + module = AnsibleModule( + argument_spec=dict( + annotation=dict(type="dict"), + arch=dict(type="str"), + auth_file=dict(type="path", aliases=["authfile"]), + build_args=dict(type="dict", aliases=["buildargs"]), + ca_cert_dir=dict(type="path"), + cache=dict(type="bool"), + cmd_args=dict(type="list", elements="str"), + dns=dict(type="list", elements="str", required=False), + dns_opt=dict(type="list", elements="str", aliases=["dns_option"], required=False), + dns_search=dict(type="list", elements="str", required=False), + env=dict(type="dict"), + file=dict(type="path", required=True), + force_rm=dict(type="bool"), + group_add=dict(type="list", elements="str", aliases=["groups"]), + ignore_file=dict(type="path", aliases=["ignorefile"]), + name=dict(type="str", required=True), + labels=dict(type="dict"), + network=dict(type="list", elements="str", aliases=["net", "network_mode"]), + password=dict(type="str", no_log=True), + pull=dict(type="str", choices=["always", "missing", "never", "newer"]), + retry=dict(type="int"), + retry_delay=dict(type="str", aliases=["retrydelay"]), + set_working_directory=dict(type="str", aliases=["setworkingdirectory"]), + target=dict(type="str"), + username=dict(type="str"), + validate_certs=dict(type="bool", aliases=["tlsverify", "tls_verify"]), + variant=dict(type="str"), + volume=dict(type="list", elements="str"), + executable=dict(type="str", default="podman"), + state=dict( + type="str", + default="quadlet", + choices=["quadlet"], + ), + quadlet_dir=dict(type="path", required=False), + quadlet_filename=dict(type="str"), + quadlet_file_mode=dict(type="raw", required=False), + quadlet_options=dict(type="list", elements="str", required=False), + ), + supports_check_mode=False, + required_together=(["username", "password"],), + mutually_exclusive=( + ["auth_file", "username"], + ["auth_file", "password"], + ), + ) + + # Handle quadlet state separately + if module.params["state"] == "quadlet": # type: ignore + results = create_quadlet_state(module, "build") + module.exit_json(**results) + + +if __name__ == "__main__": + main() diff --git a/tests/integration/targets/podman_quadlet_build/tasks/main.yml b/tests/integration/targets/podman_quadlet_build/tasks/main.yml new file mode 100644 index 0000000..d8eb155 --- /dev/null +++ b/tests/integration/targets/podman_quadlet_build/tasks/main.yml @@ -0,0 +1,122 @@ +- name: Test podman_quadlet_build + block: + - name: Create a Quadlet for build with filename + containers.podman.podman_quadlet_build: + executable: "{{ test_executable | default('podman') }}" + name: localhost/myimage:1.0 + file: /var/tmp/build/Dockerfile + state: quadlet + quadlet_dir: /tmp + quadlet_filename: customfile + + - name: Check if files exists + stat: + path: /tmp/customfile.build + register: quadlet_file_custom + + - name: Fail if no file is present or wrong mode + assert: + that: + - quadlet_file_custom.stat.exists + - quadlet_file_custom.stat.mode == '0640' + + - name: Create quadlet build file + containers.podman.podman_quadlet_build: + executable: "{{ test_executable | default('podman') }}" + name: localhost/myimage:v1.0 + file: /var/tmp/build/Dockerfile + state: quadlet + arch: x86_64 + validate_certs: false + quadlet_dir: /tmp/ + quadlet_file_mode: '0644' + quadlet_options: + - | + [Install] + WantedBy=default.target + + - name: Check if files exists + stat: + path: /tmp/myimage.build + register: quadlet_file + + - name: Check output is correct for Quadlet image in /tmp/myimage.build file + assert: + that: + - quadlet_file.stat.exists + + - name: Check quadlet file mode is correct + assert: + that: + - quadlet_file.stat.mode == '0644' + + - name: Check for the existence of lines in /tmp/myimage.build + lineinfile: + path: /tmp/myimage.build + line: "{{ item }}" + state: present + check_mode: yes + register: line_check + loop: + - "[Build]" + - "ImageTag=localhost/myimage:v1.0" + - "WantedBy=default.target" + - "Arch=x86_64" + - "TLSVerify=false" + loop_control: + label: "{{ item }}" + + - name: Fail the task if any line is not present + fail: + msg: "The following line is not present in /tmp/myimage.build: {{ item.item }}" + when: item.changed + loop: "{{ line_check.results }}" + loop_control: + label: "{{ item.item }}" + + - name: Create quadlet image file - same + containers.podman.podman_quadlet_build: + executable: "{{ test_executable | default('podman') }}" + name: localhost/myimage:v1.0 + file: /var/tmp/build/Dockerfile + state: quadlet + arch: x86_64 + validate_certs: false + quadlet_dir: /tmp/ + quadlet_file_mode: '0644' + quadlet_options: + - | + [Install] + WantedBy=default.target + register: quad2 + + - name: Check if quadlet changed + assert: + that: + - quad2 is not changed + + - name: Create quadlet image file - different + containers.podman.podman_quadlet_build: + executable: "{{ test_executable | default('podman') }}" + name: localhost/myimage:v1.0 + file: /var/tmp/build/Dockerfile + state: quadlet + arch: arm64 + validate_certs: false + quadlet_dir: /tmp/ + quadlet_file_mode: '0644' + quadlet_options: + - | + [Install] + WantedBy=default.target + register: quad3 + + - name: Print diff + debug: + var: quad3.diff + + - name: Check if changed and diff + assert: + that: + - quad3 is changed + - "'arm64' in quad3.diff.after" diff --git a/tests/unit/plugins/modules/test_podman_build.py b/tests/unit/plugins/modules/test_podman_build.py new file mode 100644 index 0000000..3f16d3f --- /dev/null +++ b/tests/unit/plugins/modules/test_podman_build.py @@ -0,0 +1,146 @@ +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + +from unittest.mock import Mock, patch + +import pytest +from ansible.module_utils.basic import AnsibleModule + + +class TestPodmanBuildModule: + """Unit tests for podman_quadlet_build.py module.""" + + @pytest.mark.parametrize( + "test_params, expected_valid", + [ + # Valid minimal parameters + ({"name": "myimage", "file": "/tmp/Containerfile"}, True), + # Valid minimal parameters + ({"name": "myimage", "set_working_directory": "/tmp/build"}, True), + # Valid parameters with all image states + ({"name": "myimage", "file": "/tmp/Containerfile", "state": "quadlet"}, True), + ], + ) + def test_module_parameter_validation(self, test_params, expected_valid): + """Test that valid parameters are accepted.""" + # Mock the PodmanImageManager to avoid actual execution + with patch( + "ansible_collections.containers.podman.plugins.modules.podman_image.create_quadlet_state" + ) as mock_quadlet: + # "ansible_collections.containers.podman.plugins.modules.podman_image.PodmanImageManager" + # ) as mock_manager, patch( + + mock_manager_instance = Mock() + mock_manager_instance.execute.return_value = {"changed": False, "build": {}} + # mock_manager.return_value = mock_manager_instance + mock_quadlet.return_value = {"changed": False} + + # Mock AnsibleModule.exit_json to capture the call + with patch.object(AnsibleModule, "exit_json") as mock_exit: + with patch.object(AnsibleModule, "__init__", return_value=None) as mock_init: + # Create a mock module instance + mock_module = Mock() + mock_module.params = test_params + mock_module.get_bin_path.return_value = "/usr/bin/podman" + mock_module.check_mode = False + + # Mock the AnsibleModule constructor to return our mock + mock_init.return_value = None + + # We can't easily test the full main() function due to AnsibleModule constructor, + # so we test parameter validation by creating an AnsibleModule directly + if expected_valid: + # For quadlet state, test that code path + if test_params.get("state") == "quadlet": + mock_module.params["state"] = "quadlet" + # This would normally call create_quadlet_state + assert test_params.get("state") == "quadlet" + else: + # For other states, test that PodmanImageManager would be called + assert test_params.get("name") is not None + + @pytest.mark.parametrize( + "invalid_params, expected_error", + [ + # Missing required name and file parameters + ({}, "name"), + # Missing required name and file parameters + ({}, "file"), + # Missing required name parameter + ({"file": "/tmp/Containerfile"}, "name"), + # Missing required file parameter + ({"name": "myimage"}, "file"), + # Invalid state + ({"name": "alpine", "file": "/tmp/Containerfile", "state": "invalid"}, "state"), + ], + ) + def test_module_parameter_validation_failures(self, invalid_params, expected_error): + """Test that invalid parameters are rejected.""" + # Test parameter validation by checking that certain combinations should fail + # Note: Full validation testing would require mocking AnsibleModule completely + # This is a basic structure test + assert expected_error in ["name", "file", "state"] + + @pytest.mark.parametrize( + "state, should_call_quadlet", + [ + ("quadlet", True), + ], + ) + def test_state_handling_logic(self, state, should_call_quadlet): + """Test that different states are handled correctly.""" + # This tests the logical flow rather than the full execution + if should_call_quadlet: + # Quadlet state should trigger quadlet code path + assert state == "quadlet" + else: + # Other states should trigger PodmanImageManager + assert state in ["present", "absent", "build"] + + def test_mutual_exclusion_logic(self): + """Test that mutually exclusive parameters are defined correctly.""" + # Test the logic that auth_file and username/password are mutually exclusive + + # These combinations should be mutually exclusive: + # - auth_file with username + # - auth_file with password + + mutually_exclusive_combinations = [ + ({"auth_file": "/path/to/auth", "username": "user"}, True), + ({"auth_file": "/path/to/auth", "password": "pass"}, True), + ({"username": "user", "password": "pass"}, False), # This should be allowed + ({"auth_file": "/path/to/auth"}, False), # This should be allowed + ] + + for params, should_be_exclusive in mutually_exclusive_combinations: + # This tests the logic of mutual exclusion + has_auth_file = "auth_file" in params + has_credentials = "username" in params or "password" in params + + if should_be_exclusive: + assert has_auth_file and has_credentials + else: + assert not (has_auth_file and has_credentials) or not has_auth_file + + def test_required_together_logic(self): + """Test that username and password are required together.""" + # Test that username and password should be required together + + test_cases = [ + ({"username": "user"}, True), # Missing password + ({"password": "pass"}, True), # Missing username + ({"username": "user", "password": "pass"}, False), # Both present + ({}, False), # Neither present + ] + + for params, should_fail_required_together in test_cases: + has_username = "username" in params + has_password = "password" in params + + if should_fail_required_together: + # One is present but not the other + assert has_username != has_password + else: + # Both present or both absent + assert has_username == has_password