diff --git a/plugins/action/pgp_keyring.py b/plugins/action/pgp_keyring.py new file mode 100644 index 0000000000..e06b06931f --- /dev/null +++ b/plugins/action/pgp_keyring.py @@ -0,0 +1,103 @@ +# Copyright: (c) 2025, Eero Aaltonen +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + + +from __future__ import annotations + +import os +import shutil +import tempfile + +from ansible import constants as C + +from ansible.config.manager import ensure_type +from ansible.errors import AnsibleActionFail, AnsibleError +from ansible.module_utils.common.text.converters import to_bytes, to_text, to_native +from ansible.module_utils.parsing.convert_bool import boolean +from ansible.plugins.action import ActionBase + +try: + import pgpy +except Exception as e: + raise AnsibleError('PGPym~=0.6.1 must be installed to use pgp_keyring plugin') from e + + +class ActionModule(ActionBase): + + TRANSFERS_FILES = True + + def run(self, tmp=None, task_vars=None): + """ Install PGP keyrings in binary format """ + + if task_vars is None: + task_vars = dict() + + super(ActionModule, self).run(tmp, task_vars) + del tmp # tmp no longer has any effect + + # Options type validation + # strings + for s_type in ('src', 'dest'): + if s_type in self._task.args: + value = ensure_type(self._task.args[s_type], 'string') + if value is not None and not isinstance(value, str): + raise AnsibleActionFail("%s is expected to be a string, but got %s instead" % (s_type, type(value))) + self._task.args[s_type] = value + + # booleans + try: + follow = boolean(self._task.args.get('follow', False), strict=False) + except TypeError as e: + raise AnsibleActionFail(to_native(e)) + + # assign to local vars for ease of use + source = self._task.args.get('src', None) + dest = self._task.args.get('dest', None) + + try: + # logical validation + if source is None or dest is None: + raise AnsibleActionFail("src and dest are required") + + try: + # find in expected paths + source = self._find_needle('files', source) + except AnsibleError as e: + raise AnsibleActionFail(to_text(e)) + + try: + key, po = pgpy.PGPKey.from_file(source) + except FileNotFoundError as e: + raise AnsibleActionFail("could not find src=%s, %s" % (source, to_text(e))) + except Exception as e: + raise AnsibleActionFail("%s: %s" % (type(e).__name__, to_text(e))) + + new_task = self._task.copy() + local_tempdir = tempfile.mkdtemp(dir=C.DEFAULT_LOCAL_TMP) + + try: + result_file = os.path.join(local_tempdir, os.path.basename(source)) + with open(to_bytes(result_file, errors='surrogate_or_strict'), 'wb') as f: + f.write(bytes(key)) + + new_task.args.update( + dict( + src=result_file, + dest=dest, + follow=follow, + ), + ) + # call with ansible.legacy prefix to eliminate collisions with collections while still allowing local override + copy_action = self._shared_loader_obj.action_loader.get('ansible.legacy.copy', + task=new_task, + connection=self._connection, + play_context=self._play_context, + loader=self._loader, + templar=self._templar, + shared_loader_obj=self._shared_loader_obj) + return copy_action.run(task_vars=task_vars) + finally: + shutil.rmtree(to_bytes(local_tempdir, errors='surrogate_or_strict')) + + finally: + self._remove_tmp_path(self._connection._shell.tmpdir) diff --git a/plugins/modules/pgp_keyring.py b/plugins/modules/pgp_keyring.py new file mode 100644 index 0000000000..7cc3166f73 --- /dev/null +++ b/plugins/modules/pgp_keyring.py @@ -0,0 +1,41 @@ +# -*- coding: utf-8 -*- +# Copyright: (c) 2025, Eero Aaltonen +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +# This module is implemented as an action plugin and runs on the controller + +from __future__ import annotations +from __future__ import (absolute_import, division, print_function) +__metaclass__ = type + + +DOCUMENTATION = r""" +--- +module: pgp_keyring +short_description: Install PGP keyrings in binary format +description: Converts PGP keyrings to binary format on the ansible controller, + and installs them to the target systems. +version_added: 12.4.0 +author: "Eero Aaltonen (@eaaltonen)" +options: + src: + description: Source key file (typically ASCII armored) + required: true + type: str + dest: + description: Destination key file. Can be relative, in which case the target system default is used + required: true + type: str + follow: + description: This flag indicates that filesystem links in the destination, if they exist, should be followed. + type: bool + default: false +""" + +EXAMPLES = r""" +- name: Install Microsoft Package signing key + community.general.pgp_keyring: + src: microsoft.asc + dest: microsoft.gpg + become: true +""" diff --git a/tests/integration/requirements.txt b/tests/integration/requirements.txt new file mode 100644 index 0000000000..f4d81333ac --- /dev/null +++ b/tests/integration/requirements.txt @@ -0,0 +1,8 @@ +# Copyright (c) Ansible Project +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + + +# requirement for the pgp_keyring action plugin +PGPy == 0.6.0 ; python_version <= '3.12' +PGPym ~= 0.6.1 ; python_version >= '3.13' diff --git a/tests/integration/targets/pgp_keyring/files/microsoft.asc b/tests/integration/targets/pgp_keyring/files/microsoft.asc new file mode 100644 index 0000000000..1b619e6dc8 --- /dev/null +++ b/tests/integration/targets/pgp_keyring/files/microsoft.asc @@ -0,0 +1,19 @@ +-----BEGIN PGP PUBLIC KEY BLOCK----- +Version: BSN Pgp v1.1.0.0 + +mQENBFYxWIwBCADAKoZhZlJxGNGWzqV+1OG1xiQeoowKhssGAKvd+buXCGISZJwT +LXZqIcIiLP7pqdcZWtE9bSc7yBY2MalDp9Liu0KekywQ6VVX1T72NPf5Ev6x6DLV +7aVWsCzUAF+eb7DC9fPuFLEdxmOEYoPjzrQ7cCnSV4JQxAqhU4T6OjbvRazGl3ag +OeizPXmRljMtUUttHQZnRhtlzkmwIrUivbfFPD+fEoHJ1+uIdfOzZX8/oKHKLe2j +H632kvsNzJFlROVvGLYAk2WRcLu+RjjggixhwiB+Mu/A8Tf4V6b+YppS44q8EvVr +M+QvY7LNSOffSO6Slsy9oisGTdfE39nC7pVRABEBAAG0N01pY3Jvc29mdCAoUmVs +ZWFzZSBzaWduaW5nKSA8Z3Bnc2VjdXJpdHlAbWljcm9zb2Z0LmNvbT6JATQEEwEI +AB4FAlYxWIwCGwMGCwkIBwMCAxUIAwMWAgECHgECF4AACgkQ6z6Urb4SKc+P9gf/ +diY2900wvWEgV7iMgrtGzx79W/PbwWiOkKoD9sdzhARXWiP8Q5teL/t5TUH6TZ3B +ENboDjwr705jLLPwuEDtPI9jz4kvdT86JwwG6N8gnWM8Ldi56SdJEtXrzwtlB/Fe +6tyfMT1E/PrJfgALUG9MWTIJkc0GhRJoyPpGZ6YWSLGXnk4c0HltYKDFR7q4wtI8 +4cBu4mjZHZbxIO6r8Cci+xxuJkpOTIpr4pdpQKpECM6x5SaT2gVnscbN0PE19KK9 +nPsBxyK4wW0AvAhed2qldBPTipgzPhqB2gu0jSryil95bKrSmlYJd1Y1XfNHno5D +xfn5JwgySBIdWWvtOI05gw== +=zPfd +-----END PGP PUBLIC KEY BLOCK----- diff --git a/tests/integration/targets/pgp_keyring/tasks/main.yml b/tests/integration/targets/pgp_keyring/tasks/main.yml new file mode 100644 index 0000000000..e707dd4435 --- /dev/null +++ b/tests/integration/targets/pgp_keyring/tasks/main.yml @@ -0,0 +1,21 @@ +# test code for the pgp_keyring action +# (c) 2025, Eero Aaltonen + +- name: Install Microsoft Package signing key + pgp_keyring: + src: microsoft.asc + dest: microsoft.pgp + +- name: stat output file + stat: + path: microsoft.pgp + register: binary_keyring + +- assert: + that: + binary_keyring.stat.exists + +- name: Remove binary keyring + file: + path: binary_keyring.path + state: absent