From 6d626b3793a37770e19161d0982bfcd01a551db4 Mon Sep 17 00:00:00 2001 From: John Westcott IV Date: Mon, 8 Jun 2020 13:20:32 -0400 Subject: [PATCH] Adding tower_api and tower_get_id lookup plugins --- .../plugins/doc_fragments/auth_plugin.py | 48 ++++++++ awx_collection/plugins/inventory/tower.py | 27 +---- awx_collection/plugins/lookup/tower_api.py | 110 ++++++++++++++++++ awx_collection/plugins/lookup/tower_get_id.py | 81 +++++++++++++ .../tower_lookup_api_plugin/tasks/main.yml | 84 +++++++++++++ .../tower_lookup_get_id_plugin/tasks/main.yml | 78 +++++++++++++ 6 files changed, 403 insertions(+), 25 deletions(-) create mode 100644 awx_collection/plugins/doc_fragments/auth_plugin.py create mode 100644 awx_collection/plugins/lookup/tower_api.py create mode 100644 awx_collection/plugins/lookup/tower_get_id.py create mode 100644 awx_collection/tests/integration/targets/tower_lookup_api_plugin/tasks/main.yml create mode 100644 awx_collection/tests/integration/targets/tower_lookup_get_id_plugin/tasks/main.yml diff --git a/awx_collection/plugins/doc_fragments/auth_plugin.py b/awx_collection/plugins/doc_fragments/auth_plugin.py new file mode 100644 index 0000000000..25c0b9e8e2 --- /dev/null +++ b/awx_collection/plugins/doc_fragments/auth_plugin.py @@ -0,0 +1,48 @@ +# -*- coding: utf-8 -*- + +# Copyright: (c) 2017, Wayne Witzel III +# 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 ModuleDocFragment(object): + + # Ansible Tower documentation fragment + DOCUMENTATION = r''' +options: + host: + description: The network address of your Ansible Tower host. + env: + - name: TOWER_HOST + username: + description: The user that you plan to use to access inventories on Ansible Tower. + env: + - name: TOWER_USERNAME + password: + description: The password for your Ansible Tower user. + env: + - name: TOWER_PASSWORD + oauth_token: + description: + - The Tower OAuth token to use. + env: + - name: TOWER_OAUTH_TOKEN + verify_ssl: + description: + - Specify whether Ansible should verify the SSL certificate of Ansible Tower host. + - Defaults to True, but this is handled by the shared module_utils code + type: bool + env: + - name: TOWER_VERIFY_SSL + aliases: [ validate_certs ] + +notes: +- If no I(config_file) is provided we will attempt to use the tower-cli library + defaults to find your Tower host information. +- I(config_file) should contain Tower configuration in the following format + host=hostname + username=username + password=password +''' diff --git a/awx_collection/plugins/inventory/tower.py b/awx_collection/plugins/inventory/tower.py index c906795a8e..3a650552b6 100644 --- a/awx_collection/plugins/inventory/tower.py +++ b/awx_collection/plugins/inventory/tower.py @@ -19,24 +19,9 @@ DOCUMENTATION = ''' the path in the command would be /path/to/tower_inventory.(yml|yaml). If some arguments in the config file are missing, this plugin will try to fill in missing arguments by reading from environment variables. - If reading configurations from environment variables, the path in the command must be @tower_inventory. + extends_documentation_fragment: + - awx.awx.auth_plugin options: - host: - description: The network address of your Ansible Tower host. - env: - - name: TOWER_HOST - username: - description: The user that you plan to use to access inventories on Ansible Tower. - env: - - name: TOWER_USERNAME - password: - description: The password for your Ansible Tower user. - env: - - name: TOWER_PASSWORD - oauth_token: - description: - - The Tower OAuth token to use. - env: - - name: TOWER_OAUTH_TOKEN inventory_id: description: - The ID of the Ansible Tower inventory that you wish to import. @@ -47,14 +32,6 @@ DOCUMENTATION = ''' env: - name: TOWER_INVENTORY required: True - verify_ssl: - description: - - Specify whether Ansible should verify the SSL certificate of Ansible Tower host. - - Defaults to True, but this is handled by the shared module_utils code - type: bool - env: - - name: TOWER_VERIFY_SSL - aliases: [ validate_certs ] include_metadata: description: Make extra requests to provide all group vars with metadata about the source Ansible Tower host. type: bool diff --git a/awx_collection/plugins/lookup/tower_api.py b/awx_collection/plugins/lookup/tower_api.py new file mode 100644 index 0000000000..b2a366a2c5 --- /dev/null +++ b/awx_collection/plugins/lookup/tower_api.py @@ -0,0 +1,110 @@ +# (c) 2020 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 = """ +lookup: tower_api +author: John Westcott IV (@john-westcott-iv) +short_description: Search the API for objects +requirements: + - None +description: + - Returns GET requests to the Ansible Tower API. See + U(https://docs.ansible.com/ansible-tower/latest/html/towerapi/index.html) for API usage. +extends_documentation_fragment: + - awx.awx.auth_plugin +options: + _terms: + description: + - The endpoint to query. i.e. teams, users, tokens, job_templates, etc + required: True + query_params: + description: + - The query parameters to search for in the form of key/value pairs. + type: dict + required: True + get_all: + description: + - If the resulting query is pagenated, retriest all pages + - note: If the query is not filtered properly this can cause a performance impact + type: boolean + default: False +""" + +EXAMPLES = """ +- name: Lookup any users who are admins + debug: + msg: "{{ query('awx.awx.tower_api', 'users', query_params={ 'is_superuser': true }) }}" +""" + +RETURN = """ +_raw: + description: + - Response of objects from API + type: dict + contains: + count: + description: The number of objects your filter returned in total (not necessarally on this page) + type: str + next: + description: The URL path for the next page + type: str + previous: + description: The URL path for the previous page + type: str + results: + description: An array of results that were returned + type: list + returned: on successful create +""" + +from ansible.plugins.lookup import LookupBase +from ansible.errors import AnsibleError +from ansible.module_utils._text import to_native +from ansible.utils.display import Display +from ..module_utils.tower_api import TowerModule + +class LookupModule(LookupBase): + display = Display() + + def handle_error(self, **kwargs): + raise AnsibleError(to_native(kwargs.get('msg'))) + + def warn_callback(self, warning): + self.display.warning(warning) + + def run(self, terms, variables=None, **kwargs): + if len(terms) != 1: + raise AnsibleError('You must pass exactly one endpoint to query') + + # Defer processing of params to logic shared with the modules + module_params = {} + for plugin_param, module_param in TowerModule.short_params.items(): + opt_val = self.get_option(plugin_param) + if opt_val is not None: + module_params[module_param] = opt_val + + # Create our module + module = TowerModule( + argument_spec={}, direct_params=module_params, + error_callback=self.handle_error, warn_callback=self.warn_callback + ) + + self.set_options(direct=kwargs) + + if self.get_option('get_all'): + return_data = module.get_all_endpoint(terms[0], data=self.get_option('query_params')) + else: + return_data = module.get_endpoint(terms[0], data=self.get_option('query_params')) + with open('/tmp/john', 'w') as f: + import json + f.write(json.dumps(return_data, indent=4)) + + if return_data['status_code'] != 200: + error = return_data + if return_data.get('json', {}).get('detail', False): + error = return_data['json']['detail'] + raise AnsibleError("Failed to query the API: {0}".format(error)) + + return return_data['json'] diff --git a/awx_collection/plugins/lookup/tower_get_id.py b/awx_collection/plugins/lookup/tower_get_id.py new file mode 100644 index 0000000000..34b94fc566 --- /dev/null +++ b/awx_collection/plugins/lookup/tower_get_id.py @@ -0,0 +1,81 @@ +# (c) 2020 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 = """ +lookup: tower_get_id +author: John Westcott IV (@john-westcott-iv) +short_description: Search for a specific ID of an option +requirements: + - None +description: + - Returns an ID of an object found in tower by the fiter criteria. See + U(https://docs.ansible.com/ansible-tower/latest/html/towerapi/index.html) for API usage. + Raises an exception if not exactly one object is found. +extends_documentation_fragment: + - awx.awx.auth_plugin +options: + _terms: + description: + - The endpoint to query. i.e. teams, users, tokens, job_templates, etc + required: True + query_params: + description: + - The query parameters to search for in the form of key/value pairs. + type: dict + required: True +""" + +EXAMPLES = """ +- name: Lookup a users ID + debug: + msg: "{{ query('awx.awx.tower_api', 'users', query_params={ 'username': 'admin' }) }}" +""" + +RETURN = """ +_raw: + description: + - The ID found for the filter criteria + type: str +""" + +from ansible.plugins.lookup import LookupBase +from ansible.errors import AnsibleError +from ansible.module_utils._text import to_native +from ansible.utils.display import Display +from ..module_utils.tower_api import TowerModule + +class LookupModule(LookupBase): + display = Display() + + def handle_error(self, **kwargs): + raise AnsibleError(to_native(kwargs.get('msg'))) + + def warn_callback(self, warning): + self.display.warning(warning) + + def run(self, terms, variables=None, **kwargs): + if len(terms) != 1: + raise AnsibleError('You must pass exactly one endpoint to query') + + # Defer processing of params to logic shared with the modules + module_params = {} + for plugin_param, module_param in TowerModule.short_params.items(): + opt_val = self.get_option(plugin_param) + if opt_val is not None: + module_params[module_param] = opt_val + + # Create our module + module = TowerModule( + argument_spec={}, direct_params=module_params, + error_callback=self.handle_error, warn_callback=self.warn_callback + ) + + self.set_options(direct=kwargs) + + found_object = module.get_one(terms[0], data=self.get_option('query_params')) + if found_object is None: + self.handle_error(msg='No objects matched that criteria') + else: + return found_object['id'] diff --git a/awx_collection/tests/integration/targets/tower_lookup_api_plugin/tasks/main.yml b/awx_collection/tests/integration/targets/tower_lookup_api_plugin/tasks/main.yml new file mode 100644 index 0000000000..d57744a506 --- /dev/null +++ b/awx_collection/tests/integration/targets/tower_lookup_api_plugin/tasks/main.yml @@ -0,0 +1,84 @@ +--- +- name: Generate a random string for test + set_fact: + test_id: "{{ lookup('password', '/dev/null chars=ascii_letters length=16') }}" + when: test_id is not defined + +- name: Generate usernames + set_fact: + usernames: + - "AWX-Collection-tests-tower_api_lookup-user1-{{ test_id }}" + - "AWX-Collection-tests-tower_api_lookup-user2-{{ test_id }}" + - "AWX-Collection-tests-tower_api_lookup-user3-{{ test_id }}" + +- name: Create all of our users + tower_user: + username: "{{ item }}" + is_superuser: true + password: "{{ test_id }}" + loop: "{{ usernames }}" + register: user_creation_results + +- block: + - name: Test too many params (failure from validation of terms) + set_fact: + junk: "{{ query('awx.awx.tower_api', 'users', 'teams', query_params={}, ) }}" + ignore_errors: true + register: result + + - assert: + that: + - result is failed + - "'ou must pass exactly one endpoint to query' in result.msg" + + - name: Try to load invalid endpoint + set_fact: + junk: "{{ query('awx.awx.tower_api', 'john', query_params={}, ) }}" + ignore_errors: true + register: result + + - assert: + that: + - result is failed + - "'The requested object could not be found at' in result.msg" + + - name: Load user of a specific name + set_fact: + users: "{{ query('awx.awx.tower_api', 'users', query_params={ 'username' : user_creation_results['results'][0]['item'] }) }}" + + - assert: + that: + - users['results'] | length() == 1 + - users['count'] == 1 + + - name: Get the id of the admin users + set_fact: + user_id: "{{ (query('awx.awx.tower_api', 'users', query_params=query_params) | json_query(jmes_query))[0] }}" + vars: + query_params: + username: "{{ user_creation_results['results'][0]['item'] }}" + jmes_query: 'results[*].id' + + - assert: + that: "{{ user_id }} == {{ user_creation_results['results'][0]['id'] }}" + + - name: Get a page of users + set_fact: + users: "{{ query('awx.awx.tower_api', 'users', query_params={ 'page_size': 2 } ) }}" + + - assert: + that: users['results'] | length() == 2 + + - name: Get all users of a system through next attribute + set_fact: + users: "{{ query('awx.awx.tower_api', 'users', query_params={ 'page_size': 1, }, get_all=true ) }}" + + - assert: + that: users['results'] | length() >= 3 + + always: + - name: Cleanup users + tower_user: + username: "{{ item }}" + state: absent + loop: "{{ usernames }}" diff --git a/awx_collection/tests/integration/targets/tower_lookup_get_id_plugin/tasks/main.yml b/awx_collection/tests/integration/targets/tower_lookup_get_id_plugin/tasks/main.yml new file mode 100644 index 0000000000..4cd71177be --- /dev/null +++ b/awx_collection/tests/integration/targets/tower_lookup_get_id_plugin/tasks/main.yml @@ -0,0 +1,78 @@ +--- +- name: Generate a random string for test + set_fact: + test_id: "{{ lookup('password', '/dev/null chars=ascii_letters length=16') }}" + when: test_id is not defined + +- name: Generate usernames + set_fact: + usernames: + - "AWX-Collection-tests-tower_get_id-user1-{{ test_id }}" + - "AWX-Collection-tests-tower_get_id-user2-{{ test_id }}" + - "AWX-Collection-tests-tower_get_id-user3-{{ test_id }}" + +- name: Create all of our users + tower_user: + username: "{{ item }}" + is_superuser: true + password: "{{ test_id }}" + loop: "{{ usernames }}" + register: user_creation_results + +- block: + - name: Test too many params (failure from validation of terms) + debug: + msg: "{{ query('awx.awx.tower_get_id', 'users', 'teams', query_params={}, ) }}" + ignore_errors: true + register: result + + - assert: + that: + - result is failed + - "'ou must pass exactly one endpoint to query' in result.msg" + + - name: Try to load invalid endpoint + debug: + msg: "{{ query('awx.awx.tower_get_id', 'john', query_params={}, ) }}" + ignore_errors: true + register: result + + - assert: + that: + - result is failed + - "'The requested object could not be found at' in result.msg" + + - name: Get the ID of the admin user + set_fact: + user_id: "{{ query('awx.awx.tower_get_id', 'users', query_params={ 'username' : user_creation_results['results'][0]['item'] }) }}" + + - assert: + that: "{{ user_id }} == {{ user_creation_results['results'][0]['id'] }}" + + - name: Try to get an ID of someone who does not exist + set_fact: + failed_user_id: "{{ query('awx.awx.tower_get_id', 'users', query_params={ 'username': 'john jacob jingleheimer schmidt' }) }}" + register: results + ignore_errors: true + + - assert: + that: + - results is failed + - "'No objects matched that criteria' in results['msg']" + + - name: Lookup too many users + set_fact: + too_many_user_ids: " {{ query('awx.awx.tower_get_id', 'users', query_params={ 'username__startswith': 'AWX-Collection-tests-tower_get_id-' }) }}" + register: results + ignore_errors: True + + - assert: + that: + - results is failed + - "'An unexpected number of items was returned from the API (3)' in results['msg']" + always: + - name: Cleanup users + tower_user: + username: "{{ item }}" + state: absent + loop: "{{ usernames }}"