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..527054ed27 --- /dev/null +++ b/awx_collection/plugins/doc_fragments/auth_plugin.py @@ -0,0 +1,48 @@ +# -*- coding: utf-8 -*- + +# Copyright: (c) 2020, Ansible by Red Hat, Inc +# 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..872e2a3328 100644 --- a/awx_collection/plugins/inventory/tower.py +++ b/awx_collection/plugins/inventory/tower.py @@ -6,59 +6,35 @@ from __future__ import (absolute_import, division, print_function) __metaclass__ = type DOCUMENTATION = ''' - name: tower - plugin_type: inventory - author: - - Matthew Jones (@matburt) - - Yunfan Zhang (@YunfanZhang42) - short_description: Ansible dynamic inventory plugin for Ansible Tower. - description: - - Reads inventories from Ansible Tower. - - Supports reading configuration from both YAML config file and environment variables. - - If reading from the YAML file, the file name must end with tower.(yml|yaml) or tower_inventory.(yml|yaml), - 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. - 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. - - This is allowed to be either the inventory primary key or its named URL slug. - - Primary key values will be accepted as strings or integers, and URL slugs must be strings. - - Named URL slugs follow the syntax of "inventory_name++organization_name". - type: raw - 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 - default: False +name: tower +plugin_type: inventory +author: + - Matthew Jones (@matburt) + - Yunfan Zhang (@YunfanZhang42) +short_description: Ansible dynamic inventory plugin for Ansible Tower. +description: + - Reads inventories from Ansible Tower. + - Supports reading configuration from both YAML config file and environment variables. + - If reading from the YAML file, the file name must end with tower.(yml|yaml) or tower_inventory.(yml|yaml), + 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: + inventory_id: + description: + - The ID of the Ansible Tower inventory that you wish to import. + - This is allowed to be either the inventory primary key or its named URL slug. + - Primary key values will be accepted as strings or integers, and URL slugs must be strings. + - Named URL slugs follow the syntax of "inventory_name++organization_name". + type: raw + env: + - name: TOWER_INVENTORY + required: True + include_metadata: + description: Make extra requests to provide all group vars with metadata about the source Ansible Tower host. + type: bool + default: False ''' EXAMPLES = ''' diff --git a/awx_collection/plugins/lookup/tower_api.py b/awx_collection/plugins/lookup/tower_api.py new file mode 100644 index 0000000000..9829507125 --- /dev/null +++ b/awx_collection/plugins/lookup/tower_api.py @@ -0,0 +1,196 @@ +# (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 from the Ansible Tower API. See + U(https://docs.ansible.com/ansible-tower/latest/html/towerapi/index.html) for API usage. + - For use that is cross-compatible between the awx.awx and ansible.tower collection + see the tower_meta module +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: False + aliases: [query, data, filter, params] + expect_objects: + description: + - Error if the response does not contain either a detail view or a list view. + type: boolean + default: False + aliases: [expect_object] + expect_one: + description: + - Error if the response contains more than one object. + type: boolean + default: False + return_objects: + description: + - If a list view is returned, promote the list of results to the top-level of list returned. + - Allows using this lookup plugin to loop over objects without additional work. + type: boolean + default: True + return_all: + description: + - If the response is paginated, return all pages. + type: boolean + default: False + return_ids: + description: + - If response contains objects, promote the id key to the top-level entries in the list. + - Allows looking up a related object and passing it as a parameter to another module. + - This will convert the return to a string or list of strings depending on the number of selected items. + type: boolean + aliases: [return_id] + default: False + max_objects: + description: + - if C(return_all) is true, this is the maximum of number of objects to return from the list. + - If a list view returns more an max_objects an exception will be raised + type: integer + default: 1000 + +notes: + - If the query is not filtered properly this can cause a performance impact. +""" + +EXAMPLES = """ +- name: Load the UI settings + set_fact: + tower_settings: "{{ lookup('awx.awx.tower_api', 'settings/ui') }}" + +- name: Report the usernames of all users with admin privs + debug: + msg: "Admin users: {{ query('awx.awx.tower_api', 'users', query_params={ 'is_superuser': true }) | map(attribute='username') | join(', ') }}" + +- name: debug all organizations in a loop # use query to return a list + debug: + msg: "Organization description={{ item['description'] }} id={{ item['id'] }}" + loop: "{{ query('awx.awx.tower_api', 'organizations') }}" + loop_control: + label: "{{ item['name'] }}" + +- name: Make sure user 'john' is an org admin of the default org if the user exists + tower_role: + organization: Default + role: admin + user: john + when: "lookup('awx.awx.tower_api', 'users', query_params={ 'username': 'john' }) | length == 1" + +- name: Create an inventory group with all 'foo' hosts + tower_group: + name: "Foo Group" + inventory: "Demo Inventory" + hosts: >- + {{ query( + 'awx.awx.tower_api', + 'hosts', + query_params={ 'name__startswith' : 'foo', }, + ) | map(attribute='name') | list }} + register: group_creation +""" + +RETURN = """ +_raw: + description: + - Response from the API + type: dict + returned: on successful request +""" + +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) + + response = module.get_endpoint(terms[0], data=self.get_option('query_params', {})) + + if 'status_code' not in response: + raise AnsibleError("Unclear response from API: {0}".format(response)) + + if response['status_code'] != 200: + raise AnsibleError("Failed to query the API: {0}".format(response['json'].get('detail', response['json']))) + + return_data = response['json'] + + if self.get_option('expect_objects') or self.get_option('expect_one'): + if ('id' not in return_data) and ('results' not in return_data): + raise AnsibleError( + 'Did not obtain a list or detail view at {0}, and ' + 'expect_objects or expect_one is set to True'.format(terms[0]) + ) + + if self.get_option('expect_one'): + if 'results' in return_data and len(return_data['results']) != 1: + raise AnsibleError( + 'Expected one object from endpoint {0}, ' + 'but obtained {1} from API'.format(terms[0], len(return_data['results'])) + ) + + if self.get_option('return_all') and 'results' in return_data: + if return_data['count'] > self.get_option('max_objects'): + raise AnsibleError( + 'List view at {0} returned {1} objects, which is more than the maximum allowed ' + 'by max_objects, {2}'.format(terms[0], return_data['count'], self.get_option('max_objects')) + ) + + next_page = return_data['next'] + while next_page is not None: + next_response = module.get_endpoint(next_page) + return_data['results'] += next_response['json']['results'] + next_page = next_response['json']['next'] + return_data['next'] = None + + if self.get_option('return_ids'): + if 'results' in return_data: + return_data['results'] = [str(item['id']) for item in return_data['results']] + elif 'id' in return_data: + return_data = str(return_data['id']) + + if self.get_option('return_objects') and 'results' in return_data: + return return_data['results'] + else: + return [return_data] diff --git a/awx_collection/plugins/modules/tower_meta.py b/awx_collection/plugins/modules/tower_meta.py new file mode 100644 index 0000000000..6d5c801ade --- /dev/null +++ b/awx_collection/plugins/modules/tower_meta.py @@ -0,0 +1,84 @@ +#!/usr/bin/python +# coding: utf-8 -*- + +# (c) 2020, Ansible by Red Hat, Inc +# 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 + + +ANSIBLE_METADATA = {'metadata_version': '1.1', + 'status': ['preview'], + 'supported_by': 'community'} + +DOCUMENTATION = ''' +--- +module: tower_meta +author: "Alan Rominger (@alancoding)" +short_description: Returns metadata about the collection this module lives in. +description: + - Allows a user to find out what collection this module exists in. + - This takes common module parameters, but does nothing with them. +options: {} +extends_documentation_fragment: awx.awx.auth +''' + + +RETURN = ''' +prefix: + description: Collection namespace and name in the namespace.name format + returned: success + sample: awx.awx + type: str +name: + description: Collection name + returned: success + sample: awx + type: str +namespace: + description: Collection namespace + returned: success + sample: awx + type: str +version: + description: Version of the collection + returned: success + sample: 0.0.1-devel + type: str +''' + + +EXAMPLES = ''' +- tower_meta: + register: result + +- name: Show details about the collection + debug: var=result + +- name: Load the UI setting without hard-coding the collection name + debug: + msg: "{{ lookup(result.prefix + '.tower_api', 'settings/ui') }}" +''' + + +from ..module_utils.tower_api import TowerModule + + +def main(): + module = TowerModule(argument_spec={}) + namespace = { + 'awx': 'awx', + 'tower': 'ansible' + }.get(module._COLLECTION_TYPE, 'unknown') + namespace_name = '{0}.{1}'.format(namespace, module._COLLECTION_TYPE) + module.exit_json( + prefix=namespace_name, + name=module._COLLECTION_TYPE, + namespace=namespace, + version=module._COLLECTION_VERSION + ) + + +if __name__ == '__main__': + main() 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..9f29b0d9ab --- /dev/null +++ b/awx_collection/tests/integration/targets/tower_lookup_api_plugin/tasks/main.yml @@ -0,0 +1,238 @@ +--- +- 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 }}" + hosts: + - "AWX-Collection-tests-tower_api_lookup-host1-{{ test_id }}" + - "AWX-Collection-tests-tower_api_lookup-host2-{{ test_id }}" + group_name: "AWX-Collection-tests-tower_api_lookup-group1-{{ test_id }}" + +- name: Get our collection package + tower_meta: + register: tower_meta + +- name: Generate the name of our plugin + set_fact: + plugin_name: "{{ tower_meta.prefix }}.tower_api" + +- name: Create all of our users + tower_user: + username: "{{ item }}" + is_superuser: true + password: "{{ test_id }}" + loop: "{{ usernames }}" + register: user_creation_results + +- block: + - name: Create our hosts + tower_host: + name: "{{ item }}" + inventory: "Demo Inventory" + loop: "{{ hosts }}" + + - name: Test too many params (failure from validation of terms) + set_fact: + junk: "{{ query(plugin_name, 'users', 'teams', query_params={}, ) }}" + ignore_errors: true + register: result + + - assert: + that: + - result is failed + - "'You must pass exactly one endpoint to query' in result.msg" + + - name: Try to load invalid endpoint + set_fact: + junk: "{{ query(plugin_name, '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 without promoting objects + set_fact: + users_list: "{{ lookup(plugin_name, 'users', query_params={ 'username' : user_creation_results['results'][0]['item'] }, return_objects=False) }}" + + - assert: + that: + - users_list['results'] | length() == 1 + - users_list['count'] == 1 + - users_list['results'][0]['id'] == user_creation_results['results'][0]['id'] + + - name: Load user of a specific name with promoting objects + set_fact: + user_objects: "{{ query(plugin_name, 'users', query_params={ 'username' : user_creation_results['results'][0]['item'] }, return_objects=True ) }}" + + - assert: + that: + - user_objects | length() == 1 + - users_list['results'][0]['id'] == user_objects[0]['id'] + + - name: Loop over one user with the loop syntax + assert: + that: + - item['id'] == user_creation_results['results'][0]['id'] + loop: "{{ query(plugin_name, 'users', query_params={ 'username' : user_creation_results['results'][0]['item'] } ) }}" + loop_control: + label: "{{ item.id }}" + + - name: Get a page of users as just ids + set_fact: + users: "{{ query(plugin_name, 'users', query_params={ 'username__endswith': test_id, 'page_size': 2 }, return_ids=True ) }}" + + - name: Assert that user list has 2 ids only and that they are strings, not ints + assert: + that: + - users | length() == 2 + - user_creation_results['results'][0]['id'] not in users + - user_creation_results['results'][0]['id'] | string in users + + - name: Get all users of a system through next attribute + set_fact: + users: "{{ query(plugin_name, 'users', query_params={ 'username__endswith': test_id, 'page_size': 1 }, return_all=true ) }}" + + - assert: + that: + - users | length() >= 3 + + - name: Get all of the users created with a max_objects of 1 + set_fact: + users: "{{ lookup(plugin_name, 'users', query_params={ 'username__endswith': test_id, 'page_size': 1 }, return_all=true, max_objects=1 ) }}" + ignore_errors: true + register: max_user_errors + + - assert: + that: + - max_user_errors is failed + - "'List view at users returned 3 objects, which is more than the maximum allowed by max_objects' in max_user_errors.msg" + + - name: Get the ID of the first user created and verify that it is correct + assert: + that: "{{ query(plugin_name, 'users', query_params={ 'username' : user_creation_results['results'][0]['item'] }, return_ids=True)[0] }} == {{ user_creation_results['results'][0]['id'] }}" + + - name: Try to get an ID of someone who does not exist + set_fact: + failed_user_id: "{{ query(plugin_name, 'users', query_params={ 'username': 'john jacob jingleheimer schmidt' }, expect_one=True) }}" + register: result + ignore_errors: true + + - assert: + that: + - result is failed + - "'Expected one object from endpoint users' in result['msg']" + + - name: Lookup too many users + set_fact: + too_many_user_ids: " {{ query(plugin_name, 'users', query_params={ 'username__endswith': test_id }, expect_one=True) }}" + register: results + ignore_errors: true + + - assert: + that: + - results is failed + - "'Expected one object from endpoint users, but obtained 3' in results['msg']" + + - name: Get the ping page + set_fact: + ping_data: "{{ lookup(plugin_name, 'ping' ) }}" + register: results + + - assert: + that: + - results is succeeded + - "'active_node' in ping_data" + + - name: "Make sure that expect_objects fails on an API page" + set_fact: + my_var: "{{ lookup(plugin_name, 'settings/ui', expect_objects=True) }}" + ignore_errors: true + register: results + + - assert: + that: + - results is failed + - "'Did not obtain a list or detail view at settings/ui, and expect_objects or expect_one is set to True' in results.msg" + + # DOCS Example Tests + - name: Load the UI settings + set_fact: + tower_settings: "{{ lookup('awx.awx.tower_api', 'settings/ui') }}" + + - assert: + that: + - "'CUSTOM_LOGO' in tower_settings" + + - name: Display the usernames of all admin users + debug: + msg: "Admin users: {{ query('awx.awx.tower_api', 'users', query_params={ 'is_superuser': true }) | map(attribute='username') | join(', ') }}" + register: results + + - assert: + that: + - "'admin' in results.msg" + + - name: debug all organizations in a loop # use query to return a list + debug: + msg: "Organization description={{ item['description'] }} id={{ item['id'] }}" + loop: "{{ query('awx.awx.tower_api', 'organizations') }}" + loop_control: + label: "{{ item['name'] }}" + + - name: Make sure user 'john' is an org admin of the default org if the user exists + tower_role: + organization: Default + role: admin + user: "{{ usernames[0] }}" + state: absent + register: tower_role_revoke + when: "query('awx.awx.tower_api', 'users', query_params={ 'username': 'DNE_TESTING' }) | length == 1" + + - assert: + that: + - tower_role_revoke is skipped + + - name: Create an inventory group with all 'foo' hosts + tower_group: + name: "{{ group_name }}" + inventory: "Demo Inventory" + hosts: >- + {{ query( + 'awx.awx.tower_api', + 'hosts', + query_params={ 'name__endswith' : test_id, }, + ) | map(attribute='name') | list }} + register: group_creation + + - assert: + that: group_creation is changed + + always: + - name: Cleanup group + tower_group: + name: "{{ group_name }}" + inventory: "Demo Inventory" + state: absent + + - name: Cleanup hosts + tower_host: + name: "{{ item }}" + inventory: "Demo Inventory" + state: absent + loop: "{{ hosts }}" + + - name: Cleanup users + tower_user: + username: "{{ item }}" + state: absent + loop: "{{ usernames }}" diff --git a/awx_collection/tests/integration/targets/tower_schedule_rrule/tasks/main.yml b/awx_collection/tests/integration/targets/tower_schedule_rrule/tasks/main.yml index a2468a697f..837821bac2 100644 --- a/awx_collection/tests/integration/targets/tower_schedule_rrule/tasks/main.yml +++ b/awx_collection/tests/integration/targets/tower_schedule_rrule/tasks/main.yml @@ -1,7 +1,15 @@ --- +- name: Get our collection package + tower_meta: + register: tower_meta + +- name: Generate the name of our plugin + set_fact: + plugin_name: "{{ tower_meta.prefix }}.tower_schedule_rrule" + - name: Test too many params (failure from validation of terms) debug: - msg: "{{ query('awx.awx.tower_schedule_rrule', 'none', 'weekly', start_date='2020-4-16 03:45:07') }}" + msg: "{{ query(plugin_name, 'none', 'weekly', start_date='2020-4-16 03:45:07') }}" ignore_errors: true register: result @@ -12,7 +20,7 @@ - name: Test invalid frequency (failure from validation of term) debug: - msg: "{{ query('awx.awx.tower_schedule_rrule', 'john', start_date='2020-4-16 03:45:07') }}" + msg: "{{ query(plugin_name, 'john', start_date='2020-4-16 03:45:07') }}" ignore_errors: true register: result @@ -23,7 +31,7 @@ - name: Test an invalid start date (generic failure case from get_rrule) debug: - msg: "{{ query('awx.awx.tower_schedule_rrule', 'none', start_date='invalid') }}" + msg: "{{ query(plugin_name, 'none', start_date='invalid') }}" ignore_errors: true register: result @@ -34,7 +42,7 @@ - name: Test end_on as count (generic success case) debug: - msg: "{{ query('awx.awx.tower_schedule_rrule', 'minute', start_date='2020-4-16 03:45:07', end_on='2') }}" + msg: "{{ query(plugin_name, 'minute', start_date='2020-4-16 03:45:07', end_on='2') }}" register: result - assert: diff --git a/awx_collection/tools/roles/template_galaxy/tasks/main.yml b/awx_collection/tools/roles/template_galaxy/tasks/main.yml index d2f7b2929d..414f55b7de 100644 --- a/awx_collection/tools/roles/template_galaxy/tasks/main.yml +++ b/awx_collection/tools/roles/template_galaxy/tasks/main.yml @@ -11,7 +11,7 @@ replace: path: "{{ collection_path }}/plugins/module_utils/tower_api.py" regexp: '^ _COLLECTION_TYPE = "awx"' - replace: ' _COLLECTION_TYPE = "{{ collection_namespace }}"' + replace: ' _COLLECTION_TYPE = "{{ collection_package }}"' - name: Do file content replacements for non-default namespace or package name block: @@ -19,9 +19,12 @@ - name: Change module doc_fragments to support desired namespace and package names replace: path: "{{ item }}" - regexp: '^extends_documentation_fragment: awx.awx.auth' - replace: 'extends_documentation_fragment: {{ collection_namespace }}.{{ collection_package }}.auth' - with_fileglob: "{{ collection_path }}/plugins/modules/tower_*.py" + regexp: '^extends_documentation_fragment: awx.awx.auth([a-zA-Z0-9_]*)$' + replace: 'extends_documentation_fragment: {{ collection_namespace }}.{{ collection_package }}.auth\1' + with_fileglob: + - "{{ collection_path }}/plugins/inventory/*.py" + - "{{ collection_path }}/plugins/lookup/*.py" + - "{{ collection_path }}/plugins/modules/tower_*.py" loop_control: label: "{{ item | basename }}"