From 88d2a3a1fbf9e8a2bf7f5d4dd3631ea1843bf015 Mon Sep 17 00:00:00 2001 From: apecnascimento <37672469+apecnascimento@users.noreply.github.com> Date: Sun, 3 Dec 2023 09:51:39 -0300 Subject: [PATCH] Feat nomad token module (#7523) * Add nomad_token module * Updatate nomad maintainers list * Fix Example docstring * Fix identations and Flake8 rules * Fix trailing whitespace * Fix SyntaxError error * change stringh format * Fix Return doc string * Fix Examples * Fix flake8 rule W293 * Fix Doc schema * Fix argument_spec * Add maintainer * Fix Example doc * Remove token_info * Change Doc * Change nomad api acl token link * Remove return whitespace * Update plugins/modules/nomad_token.py Co-authored-by: Felix Fontein * Update plugins/modules/nomad_token.py Co-authored-by: Felix Fontein * Update plugins/modules/nomad_token.py Co-authored-by: Felix Fontein * Update plugins/modules/nomad_token.py Co-authored-by: Felix Fontein * Update plugins/modules/nomad_token.py Co-authored-by: Felix Fontein * Update plugins/modules/nomad_token.py Co-authored-by: Felix Fontein * Update plugins/modules/nomad_token.py Co-authored-by: Felix Fontein * Fix add changed state to True * Update plugins/modules/nomad_token.py Co-authored-by: Felix Fontein * Change suport check mode * Add unity tests * Remove unused import * Remove tests unused import * Change python-nomad versions Co-authored-by: Felix Fontein * Change acl for ACL Co-authored-by: Felix Fontein * Add ACL to all docs * Change msg to ansible common return value * Fix flake8 W291 * Update description. --------- Co-authored-by: Felix Fontein --- .github/BOTMETA.yml | 4 +- plugins/modules/nomad_token.py | 301 ++++++++++++++++++ .../unit/plugins/modules/test_nomad_token.py | 222 +++++++++++++ tests/unit/requirements.txt | 4 + 4 files changed, 529 insertions(+), 2 deletions(-) create mode 100644 plugins/modules/nomad_token.py create mode 100644 tests/unit/plugins/modules/test_nomad_token.py diff --git a/.github/BOTMETA.yml b/.github/BOTMETA.yml index b02924d954..ff40380769 100644 --- a/.github/BOTMETA.yml +++ b/.github/BOTMETA.yml @@ -119,7 +119,7 @@ files: labels: hwc maintainers: $team_huawei $doc_fragments/nomad.py: - maintainers: chris93111 + maintainers: chris93111 apecnascimento $doc_fragments/xenserver.py: labels: xenserver maintainers: bvitnik @@ -874,7 +874,7 @@ files: $modules/nmcli.py: maintainers: alcamie101 $modules/nomad_: - maintainers: chris93111 + maintainers: chris93111 apecnascimento $modules/nosh.py: maintainers: tacatac $modules/npm.py: diff --git a/plugins/modules/nomad_token.py b/plugins/modules/nomad_token.py new file mode 100644 index 0000000000..51a2f97163 --- /dev/null +++ b/plugins/modules/nomad_token.py @@ -0,0 +1,301 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright (c) 2023, Pedro Nascimento +# 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 + +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + +DOCUMENTATION = ''' +--- +module: nomad_token +author: Pedro Nascimento (@apecnascimento) +version_added: "8.1.0" +short_description: Manage Nomad ACL tokens +description: + - This module allows to create Bootstrap tokens, create ACL tokens, update ACL tokens, and delete ACL tokens. +requirements: + - python-nomad +extends_documentation_fragment: + - community.general.nomad + - community.general.attributes +attributes: + check_mode: + support: none + diff_mode: + support: none +options: + name: + description: + - Name of ACL token to create. + type: str + token_type: + description: + - The type of the token can be V(client), V(management), or V(bootstrap). + choices: ["client", "management", "bootstrap"] + type: str + default: "client" + policies: + description: + - A list of the policies assigned to the token. + type: list + elements: str + default: [] + global_replicated: + description: + - Indicates whether or not the token was created with the C(--global). + type: bool + default: false + state: + description: + - Create or remove ACL token. + choices: ["present", "absent"] + required: true + type: str + +seealso: + - name: Nomad ACL documentation + description: Complete documentation for Nomad API ACL. + link: https://developer.hashicorp.com/nomad/api-docs/acl/tokens +''' + +EXAMPLES = ''' +- name: Create boostrap token + community.general.nomad_token: + host: localhost + token_type: bootstrap + state: present + +- name: Create ACL token + community.general.nomad_token: + host: localhost + name: "Dev token" + token_type: client + policies: + - readonly + global_replicated: false + state: absent + +- name: Update ACL token Dev token + community.general.nomad_token: + host: localhost + name: "Dev token" + token_type: client + policies: + - readonly + - devpolicy + global_replicated: false + state: absent + +- name: Delete ACL token + community.general.nomad_token: + host: localhost + name: "Dev token" + state: absent +''' + +RETURN = ''' +result: + description: Result returned by nomad. + returned: always + type: dict + sample: { + "accessor_id": "0d01c55f-8d63-f832-04ff-1866d4eb594e", + "create_index": 14, + "create_time": "2023-11-12T18:48:34.248857001Z", + "expiration_time": null, + "expiration_ttl": "", + "global": true, + "hash": "eSn8H8RVqh8As8WQNnC2vlBRqXy6DECogc5umzX0P30=", + "modify_index": 836, + "name": "devs", + "policies": [ + "readonly" + ], + "roles": null, + "secret_id": "12e878ab-e1f6-e103-b4c4-3b5173bb4cea", + "type": "client" + } +''' + +from ansible.module_utils.basic import AnsibleModule, missing_required_lib +from ansible.module_utils.common.text.converters import to_native + +import_nomad = None + +try: + import nomad + + import_nomad = True +except ImportError: + import_nomad = False + + +def get_token(name, nomad_client): + tokens = nomad_client.acl.get_tokens() + token = next((token for token in tokens + if token.get('Name') == name), None) + return token + + +def transform_response(nomad_response): + transformed_response = { + "accessor_id": nomad_response['AccessorID'], + "create_index": nomad_response['CreateIndex'], + "create_time": nomad_response['CreateTime'], + "expiration_ttl": nomad_response['ExpirationTTL'], + "expiration_time": nomad_response['ExpirationTime'], + "global": nomad_response['Global'], + "hash": nomad_response['Hash'], + "modify_index": nomad_response['ModifyIndex'], + "name": nomad_response['Name'], + "policies": nomad_response['Policies'], + "roles": nomad_response['Roles'], + "secret_id": nomad_response['SecretID'], + "type": nomad_response['Type'] + } + + return transformed_response + + +argument_spec = dict( + host=dict(required=True, type='str'), + port=dict(type='int', default=4646), + state=dict(required=True, choices=['present', 'absent']), + use_ssl=dict(type='bool', default=True), + timeout=dict(type='int', default=5), + validate_certs=dict(type='bool', default=True), + client_cert=dict(type='path'), + client_key=dict(type='path'), + namespace=dict(type='str'), + token=dict(type='str', no_log=True), + name=dict(type='str'), + token_type=dict(choices=['client', 'management', 'bootstrap'], default='client'), + policies=dict(type='list', elements='str', default=[]), + global_replicated=dict(type='bool', default=False), +) + + +def setup_module_object(): + module = AnsibleModule( + argument_spec=argument_spec, + supports_check_mode=False, + required_one_of=[ + ['name', 'token_type'] + ], + required_if=[ + ('token_type', 'client', ('name',)), + ('token_type', 'management', ('name',)), + ], + ) + return module + + +def setup_nomad_client(module): + if not import_nomad: + module.fail_json(msg=missing_required_lib("python-nomad")) + + certificate_ssl = (module.params.get('client_cert'), module.params.get('client_key')) + + nomad_client = nomad.Nomad( + host=module.params.get('host'), + port=module.params.get('port'), + secure=module.params.get('use_ssl'), + timeout=module.params.get('timeout'), + verify=module.params.get('validate_certs'), + cert=certificate_ssl, + namespace=module.params.get('namespace'), + token=module.params.get('token') + ) + + return nomad_client + + +def run(module): + nomad_client = setup_nomad_client(module) + + msg = "" + result = {} + changed = False + if module.params.get('state') == "present": + + if module.params.get('token_type') == 'bootstrap': + try: + current_token = get_token('Bootstrap Token', nomad_client) + if current_token: + msg = "ACL bootstrap already exist." + else: + nomad_result = nomad_client.acl.generate_bootstrap() + msg = "Boostrap token created." + result = transform_response(nomad_result) + changed = True + + except nomad.api.exceptions.URLNotAuthorizedNomadException: + try: + nomad_result = nomad_client.acl.generate_bootstrap() + msg = "Boostrap token created." + result = transform_response(nomad_result) + changed = True + + except Exception as e: + module.fail_json(msg=to_native(e)) + else: + try: + token_info = { + "Name": module.params.get('name'), + "Type": module.params.get('token_type'), + "Policies": module.params.get('policies'), + "Global": module.params.get('global_replicated') + } + + current_token = get_token(token_info['Name'], nomad_client) + + if current_token: + token_info['AccessorID'] = current_token['AccessorID'] + nomad_result = nomad_client.acl.update_token(current_token['AccessorID'], token_info) + msg = "ACL token updated." + result = transform_response(nomad_result) + changed = True + + else: + nomad_result = nomad_client.acl.create_token(token_info) + msg = "ACL token Created." + result = transform_response(nomad_result) + changed = True + + except Exception as e: + module.fail_json(msg=to_native(e)) + + if module.params.get('state') == "absent": + + if not module.params.get('name'): + module.fail_json(msg="name is needed to delete token.") + + if module.params.get('token_type') == 'bootstrap' or module.params.get('name') == 'Bootstrap Token': + module.fail_json(msg="Delete ACL bootstrap token is not allowed.") + + try: + token = get_token(module.params.get('name'), nomad_client) + if token: + nomad_client.acl.delete_token(token.get('AccessorID')) + msg = 'ACL token deleted.' + changed = True + else: + msg = "No token with name '{0}' found".format(module.params.get('name')) + + except Exception as e: + module.fail_json(msg=to_native(e)) + + module.exit_json(changed=changed, msg=msg, result=result) + + +def main(): + module = setup_module_object() + run(module) + + +if __name__ == "__main__": + main() diff --git a/tests/unit/plugins/modules/test_nomad_token.py b/tests/unit/plugins/modules/test_nomad_token.py new file mode 100644 index 0000000000..48f060f8be --- /dev/null +++ b/tests/unit/plugins/modules/test_nomad_token.py @@ -0,0 +1,222 @@ +# -*- coding: utf-8 -*- + +# Copyright (c) 2021, 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 + +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + +import nomad +from ansible_collections.community.general.plugins.modules import nomad_token +from ansible_collections.community.general.tests.unit.compat.mock import patch +from ansible_collections.community.general.tests.unit.plugins.modules.utils import AnsibleExitJson, AnsibleFailJson, \ + ModuleTestCase, \ + set_module_args + + +def mock_acl_get_tokens(empty_list=False): + response_object = [] + + if not empty_list: + response_object = [ + { + 'AccessorID': 'bac2b162-2a63-efa2-4e68-55d79dcb7721', + 'Name': 'Bootstrap Token', 'Type': 'management', + 'Policies': None, 'Roles': None, 'Global': True, + 'Hash': 'BUJ3BerTfrqFVm1P+vZr1gz9ubOkd+JAvYjNAJyaU9Y=', + 'CreateTime': '2023-11-12T18:44:39.740562185Z', + 'ExpirationTime': None, + 'CreateIndex': 9, + 'ModifyIndex': 9 + }, + { + 'AccessorID': '0d01c55f-8d63-f832-04ff-1866d4eb594e', + 'Name': 'devs', + 'Type': 'client', 'Policies': ['readonly'], + 'Roles': None, + 'Global': True, + 'Hash': 'eSn8H8RVqh8As8WQNnC2vlBRqXy6DECogc5umzX0P30=', + 'CreateTime': '2023-11-12T18:48:34.248857001Z', + 'ExpirationTime': None, + 'CreateIndex': 14, + 'ModifyIndex': 836 + } + ] + + return response_object + + +def mock_acl_generate_bootstrap(): + response_object = { + 'AccessorID': '0d01c55f-8d63-f832-04ff-1866d4eb594e', + 'Name': 'Bootstrap Token', + 'Type': 'management', + 'Policies': None, + 'Roles': None, + 'Global': True, + 'Hash': 'BUJ3BerTfrqFVm1P+vZr1gz9ubOkd+JAvYjNAJyaU9Y=', + 'CreateTime': '2023-11-12T18:48:34.248857001Z', + 'ExpirationTime': None, + 'ExpirationTTL': '', + 'CreateIndex': 14, + 'ModifyIndex': 836, + 'SecretID': 'd539a03d-337a-8504-6d12-000f861337bc' + } + return response_object + + +def mock_acl_create_update_token(): + response_object = { + 'AccessorID': '0d01c55f-8d63-f832-04ff-1866d4eb594e', + 'Name': 'dev', + 'Type': 'client', + 'Policies': ['readonly'], + 'Roles': None, + 'Global': True, + 'Hash': 'eSn8H8RVqh8As8WQNnC2vlBRqXy6DECogc5umzX0P30=', + 'CreateTime': '2023-11-12T18:48:34.248857001Z', + 'ExpirationTime': None, + 'ExpirationTTL': '', + 'CreateIndex': 14, + 'ModifyIndex': 836, + 'SecretID': 'd539a03d-337a-8504-6d12-000f861337bc' + } + + return response_object + + +def mock_acl_delete_token(): + return {} + + +class TestNomadTokenModule(ModuleTestCase): + + def setUp(self): + super(TestNomadTokenModule, self).setUp() + self.module = nomad_token + + def tearDown(self): + super(TestNomadTokenModule, self).tearDown() + + def test_should_fail_without_parameters(self): + with self.assertRaises(AnsibleFailJson): + set_module_args({}) + self.module.main() + + def test_should_create_token_type_client(self): + module_args = { + 'host': 'localhost', + 'name': 'Dev token', + 'token_type': 'client', + 'state': 'present' + } + + set_module_args(module_args) + with patch.object(nomad.api.acl.Acl, 'get_tokens', return_value=mock_acl_get_tokens()) as mock_get_tokens: + with patch.object(nomad.api.acl.Acl, 'create_token', return_value=mock_acl_create_update_token()) as \ + mock_create_update_token: + with self.assertRaises(AnsibleExitJson): + self.module.main() + + self.assertIs(mock_get_tokens.call_count, 1) + self.assertIs(mock_create_update_token.call_count, 1) + + def test_should_create_token_type_bootstrap(self): + module_args = { + 'host': 'localhost', + 'token_type': 'bootstrap', + 'state': 'present' + } + + set_module_args(module_args) + + with patch.object(nomad.api.acl.Acl, 'get_tokens') as mock_get_tokens: + with patch.object(nomad.api.Acl, 'generate_bootstrap') as mock_generate_bootstrap: + mock_get_tokens.return_value = mock_acl_get_tokens(empty_list=True) + mock_generate_bootstrap.return_value = mock_acl_generate_bootstrap() + + with self.assertRaises(AnsibleExitJson): + self.module.main() + + self.assertIs(mock_get_tokens.call_count, 1) + self.assertIs(mock_generate_bootstrap.call_count, 1) + + def test_should_fail_delete_without_name_parameter(self): + module_args = { + 'host': 'localhost', + 'state': 'absent' + } + + set_module_args(module_args) + with patch.object(nomad.api.acl.Acl, 'get_tokens') as mock_get_tokens: + with patch.object(nomad.api.acl.Acl, 'delete_token') as mock_delete_token: + mock_get_tokens.return_value = mock_acl_get_tokens() + mock_delete_token.return_value = mock_acl_delete_token() + + with self.assertRaises(AnsibleFailJson): + self.module.main() + + def test_should_fail_delete_bootstrap_token(self): + module_args = { + 'host': 'localhost', + 'token_type': 'boostrap', + 'state': 'absent' + } + + set_module_args(module_args) + + with self.assertRaises(AnsibleFailJson): + self.module.main() + + def test_should_fail_delete_boostrap_token_by_name(self): + module_args = { + 'host': 'localhost', + 'name': 'Bootstrap Token', + 'state': 'absent' + } + + set_module_args(module_args) + + with self.assertRaises(AnsibleFailJson): + self.module.main() + + def test_should_delete_client_token(self): + module_args = { + 'host': 'localhost', + 'name': 'devs', + 'state': 'absent' + } + + set_module_args(module_args) + + with patch.object(nomad.api.acl.Acl, 'get_tokens') as mock_get_tokens: + with patch.object(nomad.api.acl.Acl, 'delete_token') as mock_delete_token: + mock_get_tokens.return_value = mock_acl_get_tokens() + mock_delete_token.return_value = mock_acl_delete_token() + + with self.assertRaises(AnsibleExitJson): + self.module.main() + + self.assertIs(mock_delete_token.call_count, 1) + + def test_should_update_client_token(self): + module_args = { + 'host': 'localhost', + 'name': 'devs', + 'token_type': 'client', + 'state': 'present' + } + + set_module_args(module_args) + + with patch.object(nomad.api.acl.Acl, 'get_tokens') as mock_get_tokens: + with patch.object(nomad.api.acl.Acl, 'update_token') as mock_create_update_token: + mock_get_tokens.return_value = mock_acl_get_tokens() + mock_create_update_token.return_value = mock_acl_create_update_token() + + with self.assertRaises(AnsibleExitJson): + self.module.main() + self.assertIs(mock_get_tokens.call_count, 1) + self.assertIs(mock_create_update_token.call_count, 1) diff --git a/tests/unit/requirements.txt b/tests/unit/requirements.txt index be060a3dd3..bcd2edc9e3 100644 --- a/tests/unit/requirements.txt +++ b/tests/unit/requirements.txt @@ -48,3 +48,7 @@ passlib[argon2] # requirements for the proxmox modules proxmoxer < 2.0.0 ; python_version >= '2.7' and python_version <= '3.6' proxmoxer ; python_version > '3.6' + +#requirements for nomad_token modules +python-nomad < 2.0.0 ; python_version <= '3.6' +python-nomad >= 2.0.0 ; python_version >= '3.7'