diff --git a/plugins/module_utils/podman/podman_container_lib.py b/plugins/module_utils/podman/podman_container_lib.py index ff4c1862..29132baf 100644 --- a/plugins/module_utils/podman/podman_container_lib.py +++ b/plugins/module_utils/podman/podman_container_lib.py @@ -10,6 +10,8 @@ from ansible_collections.containers.podman.plugins.module_utils.podman.common import delete_systemd from ansible_collections.containers.podman.plugins.module_utils.podman.common import normalize_signal from ansible_collections.containers.podman.plugins.module_utils.podman.common import ARGUMENTS_OPTS_DICT +from .quadlet import ContainerQuadlet +from .common import compare_systemd_file_content __metaclass__ = type @@ -17,7 +19,7 @@ name=dict(required=True, type='str'), executable=dict(default='podman', type='str'), state=dict(type='str', default='started', choices=[ - 'absent', 'present', 'stopped', 'started', 'created']), + 'absent', 'present', 'stopped', 'started', 'created', 'quadlet']), image=dict(type='str'), annotation=dict(type='dict'), attach=dict(type='list', elements='str', choices=['stdout', 'stderr', 'stdin']), @@ -116,6 +118,7 @@ publish=dict(type='list', elements='str', aliases=[ 'ports', 'published', 'published_ports']), publish_all=dict(type='bool'), + quadlet_file_path=dict(type='str'), read_only=dict(type='bool'), read_only_tmpfs=dict(type='bool'), recreate=dict(type='bool', default=False), @@ -1800,6 +1803,25 @@ def make_absent(self): self.results.update({'container': {}, 'podman_actions': self.container.actions}) + def make_quadlet(self): + quadlet_file_path = os.path.expanduser(self.module_params['quadlet_file_path']) + if not quadlet_file_path: + self.module.fail_json(msg="quadlet_file_path is required for quadlet state") + if not os.path.exists(os.path.dirname(quadlet_file_path)): + self.module.fail_json(msg="Directory for quadlet_file_path doesn't exist") + # Check if file already exists and if it's different + quadlet = ContainerQuadlet(self.module_params) + quadlet_content = quadlet.create_quadlet_content() + file_diff = compare_systemd_file_content(quadlet_file_path, quadlet_content) + if bool(file_diff): + quadlet.write_to_file(quadlet_file_path) + self.results.update({ + 'changed': True, + "diff": { + "before": "\n".join(file_diff[0]) if isinstance(file_diff[0], list) else file_diff[0] + "\n", + "after": "\n".join(file_diff[1]) if isinstance(file_diff[1], list) else file_diff[1] + "\n", + }}) + def execute(self): """Execute the desired action according to map of actions & states.""" states_map = { @@ -1808,6 +1830,7 @@ def execute(self): 'absent': self.make_absent, 'stopped': self.make_stopped, 'created': self.make_created, + 'quadlet': self.make_quadlet, } process_action = states_map[self.state] process_action() diff --git a/plugins/module_utils/podman/quadlet.py b/plugins/module_utils/podman/quadlet.py new file mode 100644 index 00000000..466187a8 --- /dev/null +++ b/plugins/module_utils/podman/quadlet.py @@ -0,0 +1,153 @@ +# Copyright (c) 2024 Sagi Shnaidman +# 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 + + +class Quadlet: + param_map = {} + + def __init__(self, section: str, params: dict): + self.section = section + self.custom_params = self.custom_prepare_params(params) + self.dict_params = self.prepare_params() + + def custom_prepare_params(self, params: dict) -> dict: + """ + Custom parameter processing for specific Quadlet types. + """ + # This should be implemented in child classes if needed. + return params + + def prepare_params(self) -> dict: + """ + Convert parameter values as per param_map using a dictionary comprehension. + """ + processed_params = [] + for param_key, quadlet_key in self.param_map.items(): + value = self.custom_params.get(param_key) + if value is not None: + if isinstance(value, list): + # Add an entry for each item in the list + for item in value: + processed_params.append([quadlet_key, item]) + else: + if isinstance(value, bool): + value = str(value).lower() + # Add a single entry for the key + processed_params.append([quadlet_key, value]) + return processed_params + + def create_quadlet_content(self) -> str: + """ + Construct the quadlet content as a string. + """ + return f"[{self.section}]\n" + "\n".join( + f"{key}={value}" for key, value in self.dict_params + ) + "\n" + + def write_to_file(self, path: str): + """ + Write the quadlet content to a file at the specified path. + """ + content = self.create_quadlet_content() + with open(path, 'w') as file: + file.write(content) + + +class ContainerQuadlet(Quadlet): + param_map = { + 'annotation': 'Annotation', + 'name': 'ContainerName', + 'image': 'Image', + 'volume': 'Volume', + 'network': 'Network', + 'command': 'Exec', + 'cap_add': 'AddCapability', + # Add more parameter mappings specific to containers + } + + def __init__(self, params: dict): + super().__init__("Container", params) + + def custom_prepare_params(self, params: dict) -> dict: + """ + Custom parameter processing for container-specific parameters. + """ + if params["annotation"]: + params['annotation'] = ["%s=%s" % + (k, v) for k, v in params['annotation'].items()] + if params["cap_add"]: + params["cap_add"] = " ".join(params["cap_add"]) + if params["command"]: + params["command"] = (" ".join(params["command"]) + if isinstance(params["command"], list) + else params["command"]) + # Return params with custom processing applied + return params + + +class NetworkQuadlet(Quadlet): + param_map = { + 'name': 'NetworkName', + 'internal': 'Internal', + 'driver': 'Driver', + 'gateway': 'Gateway', + 'disable_dns': 'DisableDNS', + 'subnet': 'Subnet', + # Add more parameter mappings specific to networks + } + + def __init__(self, params: dict): + super().__init__("Network", params) + + +# This is a inherited class that represents a Quadlet file for the Podman image +class Image(Quadlet): + pass + + +# This is a inherited class that represents a Quadlet file for the Podman pod +class Pod(Quadlet): + pass + + +# This is a inherited class that represents a Quadlet file for the Podman volume +class Volume(Quadlet): + pass + + +# This is a inherited class that represents a Quadlet file for the Podman kube +class Kube(Quadlet): + pass + + +if __name__ == "__main__": + container_params = { + "annotation": {"key1": "value1", "key2": "value2"}, + "name": "my_container", + "command": ["sleep", "10"], + "image": "quay.io/aaa/alpine", + "volume": ["/my/host/path:/my/container/path:ro", "/another/host/path:/another/container/path:rw"], + "cap_add": ["CAP_DAC_OVERRIDE", "CAP_IPC_OWNER"], + "network": None, + "foo": "bar", + + # ... other container-specific parameters + } + + container_quadlet = ContainerQuadlet(container_params) + # print(container_quadlet.create_quadlet_content()) + + network_params = { + "name": "my_network", + "internal": True, + "driver": "bridge", + "gateway": "1.2.3.4", + "disable_dns": False, + "subnet": None, + # ... other network-specific parameters + } + network_quadlet = NetworkQuadlet(network_params) + # print(network_quadlet.create_quadlet_content()) diff --git a/plugins/modules/podman_container.py b/plugins/modules/podman_container.py index 51cb57a5..381c3beb 100644 --- a/plugins/modules/podman_container.py +++ b/plugins/modules/podman_container.py @@ -55,6 +55,8 @@ If container doesn't exist, the module creates it and leaves it in 'created' state. If configuration doesn't match or 'recreate' option is set, the container will be recreated + - I(quadlet) - Write a quadlet file with the specified configuration. + Requires the C(quadlet_file_path) option to be set. type: str default: started choices: @@ -63,6 +65,7 @@ - stopped - started - created + - quadlet image: description: - Repository path (or image name) and tag used to create the container. @@ -721,6 +724,10 @@ - Publish all exposed ports to random ports on the host interfaces. The default is false. type: bool + quadlet_file_path: + description: + - Path to the quadlet file to write. + type: str read_only: description: - Mount the container's root filesystem as read only. Default is false @@ -994,6 +1001,18 @@ - --deploy-hook - "echo 1 > /var/lib/letsencrypt/complete" +- name: Create a Quadlet file + containers.podman.podman_container: + name: quadlet-container + image: nginx + state: quadlet + quadlet_file_path: ~/.config/containers/systemd/nginx.container + device: "/dev/sda:/dev/xvda:rwm" + ports: + - "8080:80" + volumes: + - "/var/www:/usr/share/nginx/html" + """ RETURN = r""" diff --git a/plugins/modules/podman_network.py b/plugins/modules/podman_network.py index 3f52af4c..5c6f26a7 100644 --- a/plugins/modules/podman_network.py +++ b/plugins/modules/podman_network.py @@ -127,11 +127,17 @@ choices: - present - absent + - quadlet recreate: description: - Recreate network even if exists. type: bool default: false + quadlet_file_path: + description: + - Path to the quadlet file to be created. + type: str + required: false """ EXAMPLES = r""" @@ -203,11 +209,14 @@ HAS_IP_ADDRESS_MODULE = True except ImportError: HAS_IP_ADDRESS_MODULE = False +import os # noqa: F402 from ansible.module_utils.basic import AnsibleModule # noqa: F402 from ansible.module_utils._text import to_bytes, to_native # noqa: F402 from ansible_collections.containers.podman.plugins.module_utils.podman.common import LooseVersion from ansible_collections.containers.podman.plugins.module_utils.podman.common import lower_keys +from ..module_utils.podman.quadlet import NetworkQuadlet +from ..module_utils.podman.common import compare_systemd_file_content class PodmanNetworkModuleParams: @@ -620,6 +629,7 @@ def execute(self): states_map = { 'present': self.make_present, 'absent': self.make_absent, + 'quadlet': self.make_quadlet, } process_action = states_map[self.state] process_action() @@ -652,12 +662,33 @@ def make_absent(self): 'podman_actions': self.network.actions}) self.module.exit_json(**self.results) + def make_quadlet(self): + """Create a quadlet file for the network.""" + quadlet_file_path = os.path.expanduser(self.module.params['quadlet_file_path']) + if not quadlet_file_path: + self.module.fail_json(msg="quadlet_file_path is required for quadlet state") + if not os.path.exists(os.path.dirname(quadlet_file_path)): + self.module.fail_json(msg="Directory for quadlet_file_path doesn't exist") + # Check if file already exists and if it's different + quadlet = NetworkQuadlet(self.module.params) + quadlet_content = quadlet.create_quadlet_content() + file_diff = compare_systemd_file_content(quadlet_file_path, quadlet_content) + if bool(file_diff): + quadlet.write_to_file(quadlet_file_path) + self.results.update({ + 'changed': True, + "diff": { + "before": "\n".join(file_diff[0]) if isinstance(file_diff[0], list) else file_diff[0] + "\n", + "after": "\n".join(file_diff[1]) if isinstance(file_diff[1], list) else file_diff[1] + "\n", + }}) + self.module.exit_json(**self.results) + def main(): module = AnsibleModule( argument_spec=dict( state=dict(type='str', default="present", - choices=['present', 'absent']), + choices=['present', 'absent', 'quadlet']), name=dict(type='str', required=True), disable_dns=dict(type='bool', required=False), driver=dict(type='str', required=False), @@ -681,6 +712,7 @@ def main(): executable=dict(type='str', required=False, default='podman'), debug=dict(type='bool', default=False), recreate=dict(type='bool', default=False), + quadlet_file_path=dict(type='str', required=False), ), required_by=dict( # for IP range and GW to set 'subnet' is required ip_range=('subnet'), diff --git a/tests/integration/targets/podman_container/tasks/main.yml b/tests/integration/targets/podman_container/tasks/main.yml index 56d03c08..4c070860 100644 --- a/tests/integration/targets/podman_container/tasks/main.yml +++ b/tests/integration/targets/podman_container/tasks/main.yml @@ -1080,6 +1080,78 @@ - attach3 is failed - "'No such file or directory' in attach3.stderr" + - name: Create a Quadlet for container + containers.podman.podman_container: + executable: "{{ test_executable | default('podman') }}" + name: container-quadlet + image: alpine:3.12 + state: quadlet + quadlet_file_path: /tmp/quadlet.container + command: sleep 1d + recreate: true + etc_hosts: + host1: 127.0.0.1 + host2: 127.0.0.1 + annotation: + this: "annotation_value" + dns: + - 1.1.1.1 + - 8.8.4.4 + dns_search: example.com + cap_add: + - SYS_TIME + - NET_ADMIN + publish: + - "9000:80" + - "9001:8000" + workdir: "/bin" + env: + FOO: bar=1 + BAR: foo + TEST: 1 + BOOL: false + label: + somelabel: labelvalue + otheralbe: othervalue + volumes: + - /tmp:/data + mounts: + - type=devpts,destination=/dev/pts + + - name: Check if files exists + stat: + path: /tmp/quadlet.container + register: quadlet_file + + - name: Check output is correct for Quadlet container in /tmp/quadlet.container file + assert: + that: + - quadlet_file.stat.exists + + - name: Check for the existence of lines in /tmp/quadlet.container + lineinfile: + path: /tmp/quadlet.container + line: "{{ item }}" + state: present + check_mode: yes + register: line_check + loop: + - "Annotation=this=annotation_value" + - "ContainerName=container-quadlet" + - "Image=alpine:3.12" + - "Exec=sleep 1d" + - "Volume=/tmp:/data" + loop_control: + label: "{{ item }}" + + - name: Fail the task if any line is not present + fail: + msg: "The following line is not present in /tmp/quadlet.container: {{ item.item }}" + when: item.changed + loop: "{{ line_check.results }}" + loop_control: + label: "{{ item.item }}" + always: - name: Remove container diff --git a/tests/integration/targets/podman_network/tasks/main.yml b/tests/integration/targets/podman_network/tasks/main.yml index d207e4ce..660a6824 100644 --- a/tests/integration/targets/podman_network/tasks/main.yml +++ b/tests/integration/targets/podman_network/tasks/main.yml @@ -350,6 +350,49 @@ that: - info17 is not changed + - name: Create quadlet network file + containers.podman.podman_network: + executable: "{{ test_executable | default('podman') }}" + name: testnet + state: quadlet + disable_dns: true + subnet: "10.123.12.0" + internal: false + quadlet_file_path: /tmp/quadlet.network + + - name: Check if files exists + stat: + path: /tmp/quadlet.network + register: quadlet_file + + - name: Check output is correct for Quadlet network in /tmp/quadlet.network file + assert: + that: + - quadlet_file.stat.exists + + - name: Check for the existence of lines in /tmp/quadlet.network + lineinfile: + path: /tmp/quadlet.network + line: "{{ item }}" + state: present + check_mode: yes + register: line_check + loop: + - "NetworkName=testnet" + - "Subnet=10.123.12.0" + - "DisableDNS=true" + - "Internal=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/quadlet.network: {{ item.item }}" + when: item.changed + loop: "{{ line_check.results }}" + loop_control: + label: "{{ item.item }}" + always: - name: Cleanup