From f333fe7fca767bf8db405bd94d688657bfe6fd63 Mon Sep 17 00:00:00 2001 From: Sergey <6213510+sshnaidm@users.noreply.github.com> Date: Mon, 25 Aug 2025 09:35:27 +0300 Subject: [PATCH] Add podman system connection modules (#971) Signed-off-by: Sagi Shnaidman --- .../workflows/podman_system_connection.yml | 32 + .../podman_system_connection_info.yml | 32 + .../containers/podman_system_connection.yml | 8 + .../podman_system_connection_info.yml | 8 + plugins/modules/podman_system_connection.py | 689 ++++++++++++++++++ .../modules/podman_system_connection_info.py | 174 +++++ .../podman_system_connection/tasks/main.yml | 280 +++++++ .../tasks/main.yml | 187 +++++ 8 files changed, 1410 insertions(+) create mode 100644 .github/workflows/podman_system_connection.yml create mode 100644 .github/workflows/podman_system_connection_info.yml create mode 100644 ci/playbooks/containers/podman_system_connection.yml create mode 100644 ci/playbooks/containers/podman_system_connection_info.yml create mode 100644 plugins/modules/podman_system_connection.py create mode 100644 plugins/modules/podman_system_connection_info.py create mode 100644 tests/integration/targets/podman_system_connection/tasks/main.yml create mode 100644 tests/integration/targets/podman_system_connection_info/tasks/main.yml diff --git a/.github/workflows/podman_system_connection.yml b/.github/workflows/podman_system_connection.yml new file mode 100644 index 0000000..9697025 --- /dev/null +++ b/.github/workflows/podman_system_connection.yml @@ -0,0 +1,32 @@ +name: Podman system connection + +on: + push: + paths: + - '.github/workflows/podman_system_connection.yml' + - '.github/workflows/reusable-module-test.yml' + - 'ci/*.yml' + - 'ci/run_containers_tests.sh' + - 'ci/playbooks/containers/podman_system_connection.yml' + - 'plugins/modules/podman_system_connection.py' + - 'tests/integration/targets/podman_system_connection/**' + branches: + - main + pull_request: + paths: + - '.github/workflows/podman_system_connection.yml' + - '.github/workflows/reusable-module-test.yml' + - 'ci/*.yml' + - 'ci/run_containers_tests.sh' + - 'ci/playbooks/containers/podman_system_connection.yml' + - 'plugins/modules/podman_system_connection.py' + - 'tests/integration/targets/podman_system_connection/**' + schedule: + - cron: 4 0 * * * # Run daily at 0:03 UTC + +jobs: + test_podman_system_connection: + uses: ./.github/workflows/reusable-module-test.yml + with: + module_name: 'podman_system_connection' + display_name: 'Podman system connection' diff --git a/.github/workflows/podman_system_connection_info.yml b/.github/workflows/podman_system_connection_info.yml new file mode 100644 index 0000000..5767b7a --- /dev/null +++ b/.github/workflows/podman_system_connection_info.yml @@ -0,0 +1,32 @@ +name: Podman system connection info + +on: + push: + paths: + - '.github/workflows/podman_system_connection_info.yml' + - '.github/workflows/reusable-module-test.yml' + - 'ci/*.yml' + - 'ci/run_containers_tests.sh' + - 'ci/playbooks/containers/podman_system_connection_info.yml' + - 'plugins/modules/podman_system_connection_info.py' + - 'tests/integration/targets/podman_system_connection_info/**' + branches: + - main + pull_request: + paths: + - '.github/workflows/podman_system_connection_info.yml' + - '.github/workflows/reusable-module-test.yml' + - 'ci/*.yml' + - 'ci/run_containers_tests.sh' + - 'ci/playbooks/containers/podman_system_connection_info.yml' + - 'plugins/modules/podman_system_connection_info.py' + - 'tests/integration/targets/podman_system_connection_info/**' + schedule: + - cron: 4 0 * * * # Run daily at 0:03 UTC + +jobs: + test_podman_system_connection_info: + uses: ./.github/workflows/reusable-module-test.yml + with: + module_name: 'podman_system_connection_info' + display_name: 'Podman system connection info' diff --git a/ci/playbooks/containers/podman_system_connection.yml b/ci/playbooks/containers/podman_system_connection.yml new file mode 100644 index 0000000..dfd0820 --- /dev/null +++ b/ci/playbooks/containers/podman_system_connection.yml @@ -0,0 +1,8 @@ +--- +- hosts: all + gather_facts: true + tasks: + - include_role: + name: podman_system_connection + vars: + ansible_python_interpreter: "{{ _ansible_python_interpreter }}" diff --git a/ci/playbooks/containers/podman_system_connection_info.yml b/ci/playbooks/containers/podman_system_connection_info.yml new file mode 100644 index 0000000..f04f68b --- /dev/null +++ b/ci/playbooks/containers/podman_system_connection_info.yml @@ -0,0 +1,8 @@ +--- +- hosts: all + gather_facts: true + tasks: + - include_role: + name: podman_system_connection_info + vars: + ansible_python_interpreter: "{{ _ansible_python_interpreter }}" diff --git a/plugins/modules/podman_system_connection.py b/plugins/modules/podman_system_connection.py new file mode 100644 index 0000000..469de84 --- /dev/null +++ b/plugins/modules/podman_system_connection.py @@ -0,0 +1,689 @@ +#!/usr/bin/python +# Copyright (c) 2025 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_system_connection +author: + - "Sagi Shnaidman (@sshnaidm)" +version_added: '1.18.0' +short_description: Manage podman system connections +notes: [] +description: + - Manage podman system connections with podman system connection command. + - Add, remove, rename and set default connections to Podman services. +requirements: + - podman +options: + name: + description: + - Name of the connection + type: str + required: True + state: + description: + - State of the connection + type: str + choices: + - present + - absent + default: present + 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 + destination: + description: + - Destination for the connection. Required when I(state=present). + - Can be in the format C([user@]hostname[:port]), C(ssh://[user@]hostname[:port]), + C(unix://path), C(tcp://hostname:port) + type: str + default: + description: + - Make this connection the default for this user + type: bool + identity: + description: + - Path to SSH identity file + type: str + port: + description: + - SSH port (default is 22) + type: int + socket_path: + description: + - Path to the Podman service unix domain socket on the ssh destination host + type: str + new_name: + description: + - New name for the connection when renaming (used with I(state=present)) + type: str +""" + +EXAMPLES = r""" +- name: Add a basic SSH connection + containers.podman.podman_system_connection: + name: production + destination: root@server.example.com + state: present + +- name: Add SSH connection with custom port and identity + containers.podman.podman_system_connection: + name: staging + destination: user@staging.example.com + port: 2222 + identity: ~/.ssh/staging_rsa + state: present + +- name: Add connection and set as default + containers.podman.podman_system_connection: + name: development + destination: dev@dev.example.com + default: true + state: present + +- name: Add unix socket connection + containers.podman.podman_system_connection: + name: local + destination: unix:///run/podman/podman.sock + state: present + +- name: Add TCP connection + containers.podman.podman_system_connection: + name: remote_tcp + destination: tcp://remote.example.com:8080 + state: present + +- name: Rename a connection + containers.podman.podman_system_connection: + name: old_name + new_name: new_name + state: present + +- name: Remove a connection + containers.podman.podman_system_connection: + name: old_connection + state: absent +""" + +RETURN = r""" +connection: + description: Connection information in podman JSON format + returned: always + type: dict + sample: { + "Name": "production", + "URI": "ssh://root@server.example.com:22/run/user/0/podman/podman.sock", + "Default": true, + "ReadWrite": true + } +actions: + description: Actions performed on the connection + returned: always + type: list + sample: + - "Connection 'production' added" + - "Connection 'production' set as default" +""" + +import json +from ansible.module_utils.basic import AnsibleModule + + +def get_connection_info(module, executable, name): + """Get specific connection information from podman system connection list. + + Retrieves connection information by executing 'podman system connection list --format json' + and searches for a connection with the specified name. + + Args: + module (AnsibleModule): The Ansible module instance for running commands and error handling + executable (str): Path to the podman executable + name (str): Name of the specific connection to search for + + Returns: + dict or None: Connection dictionary if found, None if not found + + Connection dictionary may contain: + - Name (str): Connection name + - URI (str): Connection URI (ssh://, unix://, tcp://) + - Default (bool): Whether this is the default connection + - ReadWrite (bool, optional): Read/write access (Podman version dependent) + - Identity (str, optional): SSH identity file path (Podman version dependent) + - IsMachine (bool, optional): Whether this is a machine connection (Podman version dependent) + + Raises: + AnsibleFailJson: If the podman command fails or JSON parsing fails + + Note: + This function differs from the _info module's get_connection_info by returning + a single connection dict or None, rather than a list of connections. + """ + command = [executable, "system", "connection", "list", "--format", "json"] + rc, out, err = module.run_command(command) + if rc != 0: + module.fail_json(msg="Failed to list connections: %s" % err) + + try: + connections = json.loads(out) if out.strip() else [] + for conn in connections: + if conn.get("Name") == name: + return conn + return None + except json.JSONDecodeError as e: + module.fail_json(msg="Failed to parse connection list JSON: %s" % str(e)) + + +def connections_are_identical(conn1, conn2): + """Check if two connections are identical by comparing their attributes (excluding Name). + + Compares all relevant connection attributes to determine if two connections + represent the same configuration. The Name field is excluded from comparison + to allow for rename operations. + + Args: + conn1 (dict or None): First connection dictionary to compare + conn2 (dict or None): Second connection dictionary to compare + + Returns: + bool: True if connections have identical attributes, False otherwise + + Compared attributes: + - URI (str): Connection URI (must match exactly) + - Default (bool): Default status (must match) + - ReadWrite (bool, optional): Read/write access (Podman version dependent) + - Identity (str, optional): SSH identity file path (Podman version dependent) + - IsMachine (bool, optional): Machine connection flag (Podman version dependent) + + Note: + Returns False if either connection is None or empty. Optional fields + (ReadWrite, Identity, IsMachine) are handled gracefully - missing fields + are treated as None for comparison purposes. + """ + if not conn1 or not conn2: + return False + # Compare all relevant attributes except Name + return {**conn1, "Name": None} == {**conn2, "Name": None} + + +def destination_matches_uri(destination, current_uri): + """Check if a destination specification matches an existing connection URI. + + Compares a user-provided destination with the current connection URI, + handling both full protocol URIs and simplified hostname formats. + + Args: + destination (str): User-provided destination specification + current_uri (str): Existing connection URI from podman + + Returns: + bool: True if destination matches the current URI, False otherwise + + Matching logic: + - Full URIs (ssh://, unix://, tcp://): Requires exact match + - Simple format (user@host): Checks if contained in expanded URI + + Examples: + destination_matches_uri("ssh://user@host:22", "ssh://user@host:22/path") -> True + destination_matches_uri("user@host", "ssh://user@host:22/path") -> True + destination_matches_uri("unix:///tmp/sock", "unix:///tmp/sock") -> True + destination_matches_uri("user@host", "ssh://other@host:22/path") -> False + + Note: + Podman typically expands simple formats like "user@host" into full URIs + like "ssh://user@host:22/run/user/uid/podman/podman.sock", so this + function allows matching against the simplified form. + """ + if destination.startswith(("ssh://", "unix://", "tcp://")): + # Exact protocol match required for full URIs + return current_uri == destination + # Simple hostname format - check if it's contained in the expanded URI + # podman expands "user@host" to "ssh://user@host:22/run/user/uid/podman/podman.sock" + return destination in current_uri + + +def connection_needs_update(current_conn, params): + """Check if a connection needs to be updated based on current vs desired state. + + Compares the current connection configuration with the desired module parameters + to determine if changes are needed. Generates diff information for changed fields. + + Args: + current_conn (dict or None): Current connection dictionary from podman, or None if not exists + params (dict): Module parameters containing desired connection configuration + + Returns: + tuple: A tuple containing: + - needs_update (bool): True if connection needs changes, False if current state matches desired + - diffs (dict): Dictionary with 'before' and 'after' keys containing changed fields + + Checked parameters: + - destination: Compared against current URI using destination_matches_uri() + - default: Compared against current Default field + - identity: Compared against current Identity field (if provided) + + Examples: + # Connection doesn't exist + connection_needs_update(None, {...}) -> (True, {"before": {}, "after": {}}) + + # URI change needed + connection_needs_update({"URI": "old"}, {"destination": "new"}) -> + (True, {"before": {"URI": "old"}, "after": {"URI": "new"}}) + + # No changes needed + connection_needs_update({"URI": "same", "Default": False}, {"destination": "same", "default": False}) -> + (False, {"before": {}, "after": {}}) + + Note: + The identity parameter is only checked if explicitly provided (not None). + Missing identity in params is not treated as a change requirement. + """ + if not current_conn: + return True, {"before": {}, "after": {}} + + destination = params["destination"] + default = params["default"] + diffs = {"before": {}, "after": {}} + + # Check if URI matches the expected destination format + current_uri = current_conn.get("URI", "") + if not destination_matches_uri(destination, current_uri): + diffs["before"]["URI"] = current_uri + diffs["after"]["URI"] = destination + + # Check if default status needs to change + # Compare "Default" only when it's set explicitly in parameters, + # first system connection is always default + if default is not None and default != current_conn.get("Default"): + diffs["before"]["Default"] = current_conn.get("Default") + diffs["after"]["Default"] = default + + # Check Identity if provided + identity = params.get("identity") + if identity is not None and identity != current_conn.get("Identity", ""): + diffs["before"]["Identity"] = current_conn.get("Identity", "") + diffs["after"]["Identity"] = identity + + if diffs["before"] or diffs["after"]: + return True, diffs + + return False, diffs + + +def add_connection(module, executable, name, destination, default, identity, port, socket_path): + """Add a new podman system connection with specified parameters. + + Executes 'podman system connection add' with the provided configuration parameters. + Supports all connection options including SSH settings and default flag. + + Args: + module (AnsibleModule): The Ansible module instance for running commands and error handling + executable (str): Path to the podman executable + name (str): Name for the new connection + destination (str): Connection destination (URI or simple hostname format) + default (bool): Whether to set this connection as the default + identity (str or None): Path to SSH identity file (optional) + port (int or None): SSH port number (optional, defaults to 22 for SSH) + socket_path (str or None): Path to podman socket on remote host (optional) + + Returns: + tuple: A tuple containing: + - changed (bool): Always True since a connection is being added + - actions (list): List of action messages describing what was done + + Command construction: + Base: 'podman system connection add' + Options added conditionally: + --default (if default=True) + --identity (if identity provided) + --port (if port provided) + --socket-path (if socket_path provided) + Arguments: + + Actions generated: + - "Connection '' added" (always) + - "Connection '' set as default" (if default=True) + + Raises: + AnsibleFailJson: If the podman command fails + + Note: + In check mode, the command is not executed but actions are still generated + to show what would happen. This allows for proper diff and change reporting. + """ + actions = [] + command = [executable, "system", "connection", "add"] + + if default: + command.extend(["--default"]) + + if identity: + command.extend(["--identity", identity]) + + if port: + command.extend(["--port", str(port)]) + + if socket_path: + command.extend(["--socket-path", socket_path]) + + command.extend([name, destination]) + + if not module.check_mode: + rc, out, err = module.run_command(command) + if rc != 0: + module.fail_json(msg="Failed to add connection '%s': %s" % (name, err)) + + actions.append("Connection '%s' added" % name) + + if default: + actions.append("Connection '%s' set as default" % name) + + return True, actions + + +def remove_connection(module, executable, name): + """Remove an existing podman system connection. + + Executes 'podman system connection remove' to delete the specified connection. + + Args: + module (AnsibleModule): The Ansible module instance for running commands and error handling + executable (str): Path to the podman executable + name (str): Name of the connection to remove + + Returns: + tuple: A tuple containing: + - changed (bool): Always True since a connection is being removed + - actions (list): List containing single action message about the removal + + Command executed: + 'podman system connection remove ' + + Actions generated: + - "Connection '' removed" + + Raises: + AnsibleFailJson: If the podman command fails (e.g., connection doesn't exist) + + Note: + In check mode, the command is not executed but the action message is still + generated to show what would happen. The caller should verify the connection + exists before calling this function if idempotent behavior is required. + """ + command = [executable, "system", "connection", "remove", name] + + if not module.check_mode: + rc, out, err = module.run_command(command) + if rc != 0: + module.fail_json(msg="Failed to remove connection '%s': %s" % (name, err)) + + return True, ["Connection '%s' removed" % name] + + +def rename_connection(module, executable, old_name, new_name): + """Rename an existing podman system connection. + + Executes 'podman system connection rename' to change the name of an existing connection. + All connection attributes (URI, default status, etc.) remain unchanged. + + Args: + module (AnsibleModule): The Ansible module instance for running commands and error handling + executable (str): Path to the podman executable + old_name (str): Current name of the connection to rename + new_name (str): New name for the connection + + Returns: + tuple: A tuple containing: + - changed (bool): Always True since a connection is being renamed + - actions (list): List containing single action message about the rename + + Command executed: + 'podman system connection rename ' + + Actions generated: + - "Connection '' renamed to ''" + + Raises: + AnsibleFailJson: If the podman command fails (e.g., old connection doesn't exist, + new name already exists, etc.) + + Note: + In check mode, the command is not executed but the action message is still + generated to show what would happen. The caller should verify the old connection + exists and the new name is available before calling this function. + """ + command = [executable, "system", "connection", "rename", old_name, new_name] + + if not module.check_mode: + rc, out, err = module.run_command(command) + if rc != 0: + module.fail_json(msg="Failed to rename connection '%s' to '%s': %s" % (old_name, new_name, err)) + + return True, ["Connection '%s' renamed to '%s'" % (old_name, new_name)] + + +def set_default_connection(module, executable, name): + """Set an existing connection as the default connection. + + Executes 'podman system connection default' to mark the specified connection + as the default for the current user. Any previously default connection will + no longer be the default. + + Args: + module (AnsibleModule): The Ansible module instance for running commands and error handling + executable (str): Path to the podman executable + name (str): Name of the connection to set as default + + Returns: + tuple: A tuple containing: + - changed (bool): Always True since default status is being changed + - actions (list): List containing single action message about setting default + + Command executed: + 'podman system connection default ' + + Actions generated: + - "Connection '' set as default" + + Raises: + AnsibleFailJson: If the podman command fails (e.g., connection doesn't exist) + + Note: + In check mode, the command is not executed but the action message is still + generated to show what would happen. This function does not verify that + the connection exists - the caller should handle that validation. + + Setting a connection as default automatically unsets any previous default + connection, but this function only reports the action for the specified connection. + """ + command = [executable, "system", "connection", "default", name] + + if not module.check_mode: + rc, out, err = module.run_command(command) + if rc != 0: + module.fail_json(msg="Failed to set connection '%s' as default: %s" % (name, err)) + + return True, ["Connection '%s' set as default" % name] + + +def main(): + """Main entry point for the podman_system_connection Ansible module. + + This function sets up the Ansible module, validates parameters, and orchestrates + the management of podman system connections based on the desired state. + + The module supports the following operations: + - Creating new connections (state=present with destination) + - Updating existing connections (state=present with different parameters) + - Renaming connections (state=present with new_name) + - Removing connections (state=absent) + - Setting connections as default (default=true) + + Module Parameters: + name (str, required): Name of the connection + state (str): 'present' or 'absent' (default: 'present') + executable (str): Path to podman executable (default: 'podman') + destination (str): Connection destination (required for state=present, except rename) + default (bool): Set as default connection (default: False) + identity (str): SSH identity file path (optional) + port (int): SSH port number (optional) + socket_path (str): Remote podman socket path (optional) + new_name (str): New name for rename operation (optional) + + Parameter constraints: + - destination and new_name are mutually exclusive + - destination is required when state=present (except for rename operations) + + Operation logic: + state=present with new_name: Rename operation + - Fails if source connection doesn't exist + - Idempotent if target name exists with identical configuration + - Performs rename if target name doesn't exist + + state=present with destination: Add/update operation + - Checks if connection needs update using connection_needs_update() + - Removes existing connection if changes needed + - Adds new connection with desired parameters + - Idempotent if existing connection matches desired state + + state=absent: Remove operation + - Removes connection if it exists + - Idempotent if connection doesn't exist + + Cross-version compatibility: + Handles optional fields (ReadWrite, Identity, IsMachine) that may not be + present in all Podman versions. Missing fields are treated gracefully. + + Note: + The module follows the 'always require full description of desired state' + design pattern, requiring destination for all present operations except rename. + """ + module = AnsibleModule( + argument_spec=dict( + name=dict(type="str", required=True), + state=dict(type="str", choices=["present", "absent"], default="present"), + executable=dict(type="str", default="podman"), + destination=dict(type="str"), + default=dict(type="bool"), + identity=dict(type="str"), + port=dict(type="int"), + socket_path=dict(type="str"), + new_name=dict(type="str"), + ), + supports_check_mode=True, + mutually_exclusive=[ + ("destination", "new_name"), + ], + ) + + name = module.params["name"] + state = module.params["state"] + executable = module.get_bin_path(module.params["executable"], required=True) + destination = module.params["destination"] + default = module.params["default"] + identity = module.params["identity"] + port = module.params["port"] + socket_path = module.params["socket_path"] + new_name = module.params["new_name"] + + # Validate required parameters - always require destination when state=present (except for rename) + if state == "present" and not new_name and not destination: + module.fail_json(msg="destination is required when state=present") + + # Get current connection state + current_conn = get_connection_info(module, executable, name) + + changed = False + actions = [] + connection_info = {} + diff = {"before": {}, "after": {}} + + if state == "present": + if new_name: + # Handle rename operation + if not current_conn: + module.fail_json(msg="Cannot rename non-existent connection '%s'" % name) + + # Check if new name already exists + new_conn = get_connection_info(module, executable, new_name) + if new_conn: + # Check if the existing connection with new_name is identical to current_conn + if connections_are_identical(current_conn, new_conn): + # Connections are identical - idempotent, no changes needed + connection_info = new_conn + else: + # Different connection exists with the same name - fail + module.fail_json(msg="Connection with name '%s' already exists and is different" % new_name) + else: + # Perform rename + changed, rename_actions = rename_connection(module, executable, name, new_name) + actions.extend(rename_actions) + + # Get connection info after rename + if not module.check_mode: + final_conn = get_connection_info(module, executable, new_name) + connection_info = final_conn + else: + old_conn_info = get_connection_info(module, executable, name) + old_conn_info["Name"] = new_name + connection_info = old_conn_info + + else: + # Handle add/update operation (destination is always provided) + needs_update, diff = connection_needs_update(current_conn, module.params) + if needs_update: + # Remove existing connection if it exists with different parameters + if current_conn: + changed, remove_actions = remove_connection(module, executable, name) + actions.extend(remove_actions) + + # Add the connection with desired parameters + changed, add_actions = add_connection( + module, executable, name, destination, default, identity, port, socket_path + ) + actions.extend(add_actions) + + # Get final connection state + if not module.check_mode: + final_conn = get_connection_info(module, executable, name) + connection_info = final_conn + else: + # In check mode, provide expected values + connection_info = { + "Name": name, + "URI": destination, + "Default": default, + } + + else: + # Connection exists and matches desired state - idempotent + connection_info = current_conn + + elif state == "absent": + if current_conn: + changed, remove_actions = remove_connection(module, executable, name) + actions.extend(remove_actions) + connection_info = {} + module.log("PODMAN-SYSTEM-CONNECTION: changed=%s, actions=%s, diff=%s" % (changed, actions, diff)) + diff_info = {} + if diff["before"] or diff["after"]: + diff_info = { + "before": "\n".join(["%s - %s" % (k, v) for k, v in sorted(diff["before"].items())]) + "\n", + "after": "\n".join(["%s - %s" % (k, v) for k, v in sorted(diff["after"].items())]) + "\n", + } + module.exit_json( + changed=changed, + connection=connection_info, + actions=actions, + diff=diff_info, + ) + + +if __name__ == "__main__": + main() diff --git a/plugins/modules/podman_system_connection_info.py b/plugins/modules/podman_system_connection_info.py new file mode 100644 index 0000000..7582915 --- /dev/null +++ b/plugins/modules/podman_system_connection_info.py @@ -0,0 +1,174 @@ +#!/usr/bin/python +# Copyright (c) 2025 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_system_connection_info +author: + - "Sagi Shnaidman (@sshnaidm)" +version_added: '1.18.0' +short_description: Gather info about podman system connections +notes: [] +description: + - Gather info about podman system connections with podman system connection list command. +requirements: + - "Podman installed on host" +options: + name: + description: + - Name of the connection to gather info about + - If not provided, info about all connections will be returned + type: 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 +""" + +EXAMPLES = r""" +- name: Gather info about all connections + containers.podman.podman_system_connection_info: + +- name: Gather info about specific connection + containers.podman.podman_system_connection_info: + name: production + +- name: Get connection info and register result + containers.podman.podman_system_connection_info: + name: staging + register: staging_connection + +- name: Display connection URI + debug: + msg: "Staging connection URI: {{ staging_connection.connections[0].URI }}" + when: staging_connection.connections | length > 0 +""" + +RETURN = r""" +connections: + description: Facts from all or specified connections + returned: always + type: list + sample: [ + { + "Name": "production", + "URI": "ssh://root@server.example.com:22/run/user/0/podman/podman.sock", + "Identity": "/home/user/.ssh/id_rsa", + "Default": true, + "ReadWrite": true + }, + { + "Name": "local", + "URI": "unix:///run/user/1000/podman/podman.sock", + "Identity": "", + "Default": false, + "ReadWrite": false + }, + { + "Name": "development", + "URI": "ssh://dev@dev.example.com:22/run/user/1000/podman/podman.sock", + "Identity": "/home/user/.ssh/dev_rsa", + "Default": false, + "ReadWrite": true + } + ] +""" + +import json +from ansible.module_utils.basic import AnsibleModule + + +def get_connection_info(module, executable, name): + """Get connection information from podman system connection list. + + Retrieves connection information by executing 'podman system connection list --format json' + and optionally filters the results for a specific connection by name. + + Args: + module (AnsibleModule): The Ansible module instance for running commands and error handling + executable (str): Path to the podman executable + name (str or None): Name of specific connection to filter for, or None to get all connections + + Returns: + tuple: A tuple containing: + - connections (list): List of connection dictionaries matching the filter criteria + - out (str): Raw stdout from the podman command + - err (str): Raw stderr from the podman command + + Raises: + AnsibleFailJson: If the podman command fails or JSON parsing fails + + Note: + When a specific connection name is provided but not found, an empty list is returned + rather than failing. This allows for idempotent behavior when checking if connections exist. + """ + command = [executable, "system", "connection", "list", "--format", "json"] + rc, out, err = module.run_command(command) + + if rc != 0: + module.fail_json(msg="Unable to get list of connections: %s" % err) + + try: + connections = json.loads(out) if out.strip() else [] + except json.JSONDecodeError as e: + module.fail_json(msg="Failed to parse connection list JSON: %s" % str(e)) + + if name: + # Filter for specific connection + filtered_connections = [conn for conn in connections if conn.get("Name") == name] + # if not filtered_connections: + # module.fail_json(msg="Connection '%s' not found" % name) + return filtered_connections, out, err + + return connections, out, err + + +def main(): + """Main entry point for the podman_system_connection_info Ansible module. + + This function sets up the Ansible module, validates parameters, retrieves connection + information from podman, and returns the results in the expected format. + + The module supports check mode and never reports changes since it's an info-gathering + module that doesn't modify system state. + + Module Parameters: + executable (str): Path to podman executable (default: "podman") + name (str, optional): Name of specific connection to get info about. + If not provided, returns info about all connections. + + Each connection dictionary may contain the following fields: + - Name (str): Connection name + - URI (str): Connection URI (ssh://, unix://, tcp://) + - Default (bool): Whether this is the default connection + - ReadWrite (bool, optional): Read/write access (Podman version dependent) + - Identity (str, optional): SSH identity file path (Podman version dependent) + - IsMachine (bool, optional): Whether this is a machine connection (Podman version dependent) + + Note: + Some fields like ReadWrite, Identity, and IsMachine are optional and may not be present + in all Podman versions. The module handles this gracefully. + """ + module = AnsibleModule( + argument_spec=dict(executable=dict(type="str", default="podman"), name=dict(type="str")), + supports_check_mode=True, + ) + + name = module.params["name"] + executable = module.get_bin_path(module.params["executable"], required=True) + + inspect_results, out, err = get_connection_info(module, executable, name) + + results = {"changed": False, "connections": inspect_results, "stderr": err} + + module.exit_json(**results) + + +if __name__ == "__main__": + main() diff --git a/tests/integration/targets/podman_system_connection/tasks/main.yml b/tests/integration/targets/podman_system_connection/tasks/main.yml new file mode 100644 index 0000000..6f3e238 --- /dev/null +++ b/tests/integration/targets/podman_system_connection/tasks/main.yml @@ -0,0 +1,280 @@ +- name: Test podman_system_connection + block: + + - name: Print podman version + command: podman version + + - name: Generate random values for connection names + set_fact: + test_connection_1: "{{ 'ansible-test-conn1-%0x' % ((2**32) | random) }}" + test_connection_2: "{{ 'ansible-test-conn2-%0x' % ((2**32) | random) }}" + test_connection_3: "{{ 'ansible-test-conn3-%0x' % ((2**32) | random) }}" + + - name: Clean up any existing test connections + containers.podman.podman_system_connection: + executable: "{{ test_executable | default('podman') }}" + name: "{{ item }}" + state: absent + ignore_errors: true + loop: + - "{{ test_connection_1 }}" + - "{{ test_connection_2 }}" + - "{{ test_connection_3 }}" + + - name: Test adding a basic unix socket connection + containers.podman.podman_system_connection: + executable: "{{ test_executable | default('podman') }}" + name: "{{ test_connection_1 }}" + destination: "unix:///tmp/test-socket-{{ test_connection_1 }}.sock" + state: present + register: add_result + + - name: Check add connection results + assert: + that: + - add_result is changed + - add_result.connection.Name == test_connection_1 + - "'unix://' in add_result.connection.URI" + - add_result.actions | length > 0 + - "'added' in add_result.actions[0]" + + - name: Test idempotency - add same connection again + containers.podman.podman_system_connection: + executable: "{{ test_executable | default('podman') }}" + name: "{{ test_connection_1 }}" + destination: "unix:///tmp/test-socket-{{ test_connection_1 }}.sock" + state: present + register: add_idem_result + + - name: Check idempotency + assert: + that: + - add_idem_result is not changed + - add_idem_result.connection.Name == test_connection_1 + + - name: Test adding connection with unix socket + containers.podman.podman_system_connection: + executable: "{{ test_executable | default('podman') }}" + name: "{{ test_connection_2 }}" + destination: "unix:///tmp/test-socket-{{ test_connection_2 }}.sock" + state: present + register: add_custom_result + + - name: Check custom connection results + assert: + that: + - add_custom_result is changed + - add_custom_result.connection.Name == test_connection_2 + - "'unix://' in add_custom_result.connection.URI" + - add_custom_result.actions | length > 0 + + - name: Test adding connection with default flag + containers.podman.podman_system_connection: + executable: "{{ test_executable | default('podman') }}" + name: "{{ test_connection_3 }}" + destination: "unix:///tmp/test-socket-{{ test_connection_3 }}.sock" + default: true + state: present + register: add_default_result + + - name: Check default connection results + assert: + that: + - add_default_result is changed + - add_default_result.connection.Name == test_connection_3 + - add_default_result.connection.Default == true + + - name: Test unix socket connection + containers.podman.podman_system_connection: + executable: "{{ test_executable | default('podman') }}" + name: "test-unix" + destination: "unix:///run/podman/podman.sock" + state: present + register: unix_result + + - name: Check unix socket connection + assert: + that: + - unix_result is changed + - unix_result.connection.Name == "test-unix" + - "'unix://' in unix_result.connection.URI" + + - name: Test unix socket connection again for idempotency + containers.podman.podman_system_connection: + executable: "{{ test_executable | default('podman') }}" + name: "test-unix" + destination: "unix:///run/podman/podman.sock" + state: present + register: unix_result2 + + - name: Check unix socket connection + assert: + that: + - unix_result2 is not changed + - unix_result2.connection.Name == "test-unix" + - "'unix://' in unix_result2.connection.URI" + + - name: Test unix socket connection - different + containers.podman.podman_system_connection: + executable: "{{ test_executable | default('podman') }}" + name: "test-unix" + destination: "unix:///run/podman/podman2.sock" + state: present + register: unix_result3 + + - name: Check unix socket connection + assert: + that: + - unix_result3 is changed + - unix_result3.connection.Name == "test-unix" + - "'unix://' in unix_result3.connection.URI" + + - name: Test TCP connection + containers.podman.podman_system_connection: + executable: "{{ test_executable | default('podman') }}" + name: "test-tcp" + destination: "tcp://localhost:8080" + state: present + register: tcp_result + + - name: Check TCP connection + assert: + that: + - tcp_result is changed + - tcp_result.connection.Name == "test-tcp" + - "'tcp://' in tcp_result.connection.URI" + + - name: Test connection rename + containers.podman.podman_system_connection: + executable: "{{ test_executable | default('podman') }}" + name: "{{ test_connection_1 }}" + new_name: "{{ test_connection_1 }}-renamed" + state: present + register: rename_result + + - name: Check rename results + assert: + that: + - rename_result is changed + - rename_result.connection.Name == test_connection_1 + "-renamed" + + - name: Test rename non-existent connection (should fail) + containers.podman.podman_system_connection: + executable: "{{ test_executable | default('podman') }}" + name: "non-existent-connection" + new_name: "new-name" + state: present + register: rename_fail_result + ignore_errors: true + + - name: Check rename failure + assert: + that: + - rename_fail_result is failed + - "'Cannot rename non-existent connection' in rename_fail_result.msg" + + - name: Test setting existing connection as default + containers.podman.podman_system_connection: + executable: "{{ test_executable | default('podman') }}" + name: "{{ test_connection_2 }}" + destination: "unix:///tmp/test-socket-{{ test_connection_2 }}.sock" + default: true + state: present + register: set_default_result + + - name: Check set default results + assert: + that: + - set_default_result is changed + - set_default_result.actions | select('search', 'set as default') | list | length > 0 + + - name: Test removing connection + containers.podman.podman_system_connection: + executable: "{{ test_executable | default('podman') }}" + name: "{{ test_connection_2 }}" + state: absent + register: remove_result + + - name: Check remove results + assert: + that: + - remove_result is changed + - "'removed' in remove_result.actions[0]" + + - name: Test idempotency - remove non-existent connection + containers.podman.podman_system_connection: + executable: "{{ test_executable | default('podman') }}" + name: "{{ test_connection_2 }}" + state: absent + register: remove_idem_result + + - name: Check remove idempotency + assert: + that: + - remove_idem_result is not changed + + - name: Test check mode for adding connection + containers.podman.podman_system_connection: + executable: "{{ test_executable | default('podman') }}" + name: "check-mode-test" + destination: "unix:///tmp/test-socket-check.sock" + state: present + check_mode: true + register: check_add_result + + - name: Verify check mode add + assert: + that: + - check_add_result is changed + - check_add_result.connection.Name == "check-mode-test" + + - name: Verify connection was not actually added in check mode + containers.podman.podman_system_connection_info: + executable: "{{ test_executable | default('podman') }}" + name: "check-mode-test" + register: check_verify + + - name: Check that connection doesn't exist + assert: + that: + - check_verify.connections | length == 0 + + - name: Test check mode for removing connection + containers.podman.podman_system_connection: + executable: "{{ test_executable | default('podman') }}" + name: "{{ test_connection_3 }}" + state: absent + check_mode: true + register: check_remove_result + + - name: Verify check mode remove + assert: + that: + - check_remove_result is changed + + - name: Verify connection still exists after check mode + containers.podman.podman_system_connection_info: + executable: "{{ test_executable | default('podman') }}" + name: "{{ test_connection_3 }}" + register: check_still_exists + + - name: Check that connection still exists + assert: + that: + - check_still_exists.connections | length > 0 + + always: + - name: Clean up test connections + containers.podman.podman_system_connection: + executable: "{{ test_executable | default('podman') }}" + name: "{{ item }}" + state: absent + ignore_errors: true + loop: + - "{{ test_connection_1 }}" + - "{{ test_connection_1 }}-renamed" + - "{{ test_connection_2 }}" + - "{{ test_connection_3 }}" + - "test-unix" + - "test-tcp" + - "check-mode-test" diff --git a/tests/integration/targets/podman_system_connection_info/tasks/main.yml b/tests/integration/targets/podman_system_connection_info/tasks/main.yml new file mode 100644 index 0000000..ed438b6 --- /dev/null +++ b/tests/integration/targets/podman_system_connection_info/tasks/main.yml @@ -0,0 +1,187 @@ +- name: Test podman_system_connection_info + block: + + - name: Print podman version + command: podman version + + - name: Generate random values for connection names + set_fact: + test_connection_1: "{{ 'ansible-test-info1-%0x' % ((2**32) | random) }}" + test_connection_2: "{{ 'ansible-test-info2-%0x' % ((2**32) | random) }}" + + - name: Clean up any existing test connections + containers.podman.podman_system_connection: + executable: "{{ test_executable | default('podman') }}" + name: "{{ item }}" + state: absent + ignore_errors: true + loop: + - "{{ test_connection_1 }}" + - "{{ test_connection_2 }}" + + - name: Get info about all connections (empty list) + containers.podman.podman_system_connection_info: + executable: "{{ test_executable | default('podman') }}" + register: empty_list_result + + - name: Check empty connections list + assert: + that: + - empty_list_result is not changed + - empty_list_result.connections is defined + - empty_list_result.connections is iterable + + - name: Create test connection 1 (unix socket) + containers.podman.podman_system_connection: + executable: "{{ test_executable | default('podman') }}" + name: "{{ test_connection_1 }}" + destination: "unix:///tmp/test-socket-1.sock" + state: present + + - name: Create test connection 2 as default (tcp) + containers.podman.podman_system_connection: + executable: "{{ test_executable | default('podman') }}" + name: "{{ test_connection_2 }}" + destination: "tcp://127.0.0.1:8080" + default: true + state: present + + - name: Get info about all connections + containers.podman.podman_system_connection_info: + executable: "{{ test_executable | default('podman') }}" + register: all_connections_result + + - name: Check all connections results + assert: + that: + - all_connections_result is not changed + - all_connections_result.connections | length >= 2 + - all_connections_result.connections | selectattr('Name', 'equalto', test_connection_1) | list | length == 1 + - all_connections_result.connections | selectattr('Name', 'equalto', test_connection_2) | list | length == 1 + + - name: Get info about specific connection + containers.podman.podman_system_connection_info: + executable: "{{ test_executable | default('podman') }}" + name: "{{ test_connection_1 }}" + register: specific_connection_result + + - name: Check specific connection results + assert: + that: + - specific_connection_result is not changed + - specific_connection_result.connections | length == 1 + - specific_connection_result.connections[0].Name == test_connection_1 + - "'unix://' in specific_connection_result.connections[0].URI" + + - name: Get info about default connection + containers.podman.podman_system_connection_info: + executable: "{{ test_executable | default('podman') }}" + name: "{{ test_connection_2 }}" + register: default_connection_result + + - name: Check default connection results + assert: + that: + - default_connection_result is not changed + - default_connection_result.connections | length == 1 + - default_connection_result.connections[0].Name == test_connection_2 + - default_connection_result.connections[0].Default == true + - "'tcp://' in default_connection_result.connections[0].URI" + + - name: Try to get info about non-existent connection + containers.podman.podman_system_connection_info: + executable: "{{ test_executable | default('podman') }}" + name: "non-existent-connection" + register: non_existent_result + + - name: Check non-existent connection failure + assert: + that: + - non_existent_result.connections | length == 0 + + - name: Test check mode + containers.podman.podman_system_connection_info: + executable: "{{ test_executable | default('podman') }}" + check_mode: true + register: check_mode_result + + - name: Check check mode results + assert: + that: + - check_mode_result is not changed + - check_mode_result.connections is defined + + - name: Verify connection attributes for readwrite connections + set_fact: + test_conn_1_info: "{{ all_connections_result.connections | selectattr('Name', 'equalto', test_connection_1) | first }}" + test_conn_2_info: "{{ all_connections_result.connections | selectattr('Name', 'equalto', test_connection_2) | first }}" + + - name: Check connection 1 attributes + assert: + that: + - test_conn_1_info.Name == test_connection_1 + - test_conn_1_info.URI is defined + - test_conn_1_info.Default is defined + - test_conn_1_info.Default == false + # ReadWrite, Identity, and IsMachine are optional fields + + - name: Check connection 2 attributes (default) + assert: + that: + - test_conn_2_info.Name == test_connection_2 + - test_conn_2_info.URI is defined + - test_conn_2_info.Default == true + # ReadWrite, Identity, and IsMachine are optional fields + + - name: Test with unix socket connection + containers.podman.podman_system_connection: + executable: "{{ test_executable | default('podman') }}" + name: "test-unix-info" + destination: "unix:///run/podman/podman.sock" + state: present + + - name: Get info about unix socket connection + containers.podman.podman_system_connection_info: + executable: "{{ test_executable | default('podman') }}" + name: "test-unix-info" + register: unix_info_result + + - name: Check unix socket connection info + assert: + that: + - unix_info_result.connections | length == 1 + - unix_info_result.connections[0].Name == "test-unix-info" + - "'unix://' in unix_info_result.connections[0].URI" + + - name: Test with TCP connection + containers.podman.podman_system_connection: + executable: "{{ test_executable | default('podman') }}" + name: "test-tcp-info" + destination: "tcp://localhost:8080" + state: present + + - name: Get info about TCP connection + containers.podman.podman_system_connection_info: + executable: "{{ test_executable | default('podman') }}" + name: "test-tcp-info" + register: tcp_info_result + + - name: Check TCP connection info + assert: + that: + - tcp_info_result.connections | length == 1 + - tcp_info_result.connections[0].Name == "test-tcp-info" + - "'tcp://' in tcp_info_result.connections[0].URI" + + always: + - name: Clean up test connections + containers.podman.podman_system_connection: + executable: "{{ test_executable | default('podman') }}" + name: "{{ item }}" + state: absent + ignore_errors: true + loop: + - "{{ test_connection_1 }}" + - "{{ test_connection_2 }}" + - "test-unix-info" + - "test-tcp-info"