diff --git a/awx_collection/meta/runtime.yml b/awx_collection/meta/runtime.yml index 3980ccc14c..d8c2535871 100644 --- a/awx_collection/meta/runtime.yml +++ b/awx_collection/meta/runtime.yml @@ -13,3 +13,5 @@ plugin_routing: deprecation: removal_date: TBD warning_text: see plugin documentation for details + tower_notifitcation: + redirect: tower_notification_template diff --git a/awx_collection/plugins/modules/tower_notification.py b/awx_collection/plugins/modules/tower_notification_template.py similarity index 98% rename from awx_collection/plugins/modules/tower_notification.py rename to awx_collection/plugins/modules/tower_notification_template.py index 12dd28ef31..ec25038e34 100644 --- a/awx_collection/plugins/modules/tower_notification.py +++ b/awx_collection/plugins/modules/tower_notification_template.py @@ -15,7 +15,7 @@ ANSIBLE_METADATA = {'metadata_version': '1.1', DOCUMENTATION = ''' --- -module: tower_notification +module: tower_notification_template author: "Samuel Carpentier (@samcarpentier)" short_description: create, update, or destroy Ansible Tower notification. description: @@ -203,7 +203,7 @@ extends_documentation_fragment: awx.awx.auth EXAMPLES = ''' - name: Add Slack notification with custom messages - tower_notification: + tower_notification_template: name: slack notification organization: Default notification_type: slack @@ -222,7 +222,7 @@ EXAMPLES = ''' tower_config_file: "~/tower_cli.cfg" - name: Add webhook notification - tower_notification: + tower_notification_template: name: webhook notification notification_type: webhook notification_configuration: @@ -233,7 +233,7 @@ EXAMPLES = ''' tower_config_file: "~/tower_cli.cfg" - name: Add email notification - tower_notification: + tower_notification_template: name: email notification notification_type: email notification_configuration: @@ -250,7 +250,7 @@ EXAMPLES = ''' tower_config_file: "~/tower_cli.cfg" - name: Add twilio notification - tower_notification: + tower_notification_template: name: twilio notification notification_type: twilio notification_configuration: @@ -263,7 +263,7 @@ EXAMPLES = ''' tower_config_file: "~/tower_cli.cfg" - name: Add PagerDuty notification - tower_notification: + tower_notification_template: name: pagerduty notification notification_type: pagerduty notification_configuration: @@ -275,7 +275,7 @@ EXAMPLES = ''' tower_config_file: "~/tower_cli.cfg" - name: Add IRC notification - tower_notification: + tower_notification_template: name: irc notification notification_type: irc notification_configuration: @@ -290,7 +290,7 @@ EXAMPLES = ''' tower_config_file: "~/tower_cli.cfg" - name: Delete notification - tower_notification: + tower_notification_template: name: old notification state: absent tower_config_file: "~/tower_cli.cfg" diff --git a/awx_collection/plugins/modules/tower_project.py b/awx_collection/plugins/modules/tower_project.py index 36a4f8666a..8662029f66 100644 --- a/awx_collection/plugins/modules/tower_project.py +++ b/awx_collection/plugins/modules/tower_project.py @@ -55,10 +55,12 @@ options: - The refspec to use for the SCM resource. type: str default: '' - scm_credential: + credential: description: - Name of the credential to use with this SCM resource. type: str + aliases: + - scm_credential scm_clean: description: - Remove local modifications before updating. @@ -86,11 +88,13 @@ options: type: bool aliases: - scm_allow_override - job_timeout: + timeout: description: - The amount of time (in seconds) to run before the SCM Update is canceled. A value of 0 means no timeout. default: 0 type: int + aliases: + - job_timeout custom_virtualenv: description: - Local absolute file path containing a custom Python virtualenv to use @@ -188,13 +192,13 @@ def main(): local_path=dict(), scm_branch=dict(default=''), scm_refspec=dict(default=''), - scm_credential=dict(), + credential=dict(aliases=['scm_credential']), scm_clean=dict(type='bool', default=False), scm_delete_on_update=dict(type='bool', default=False), scm_update_on_launch=dict(type='bool', default=False), scm_update_cache_timeout=dict(type='int', default=0), allow_override=dict(type='bool', aliases=['scm_allow_override']), - job_timeout=dict(type='int', default=0), + timeout=dict(type='int', default=0, aliases=['job_timeout']), custom_virtualenv=dict(), organization=dict(required=True), notification_templates_started=dict(type="list", elements='str'), @@ -217,13 +221,13 @@ def main(): local_path = module.params.get('local_path') scm_branch = module.params.get('scm_branch') scm_refspec = module.params.get('scm_refspec') - scm_credential = module.params.get('scm_credential') + credential = module.params.get('credential') scm_clean = module.params.get('scm_clean') scm_delete_on_update = module.params.get('scm_delete_on_update') scm_update_on_launch = module.params.get('scm_update_on_launch') scm_update_cache_timeout = module.params.get('scm_update_cache_timeout') allow_override = module.params.get('allow_override') - job_timeout = module.params.get('job_timeout') + timeout = module.params.get('timeout') custom_virtualenv = module.params.get('custom_virtualenv') organization = module.params.get('organization') state = module.params.get('state') @@ -231,8 +235,8 @@ def main(): # Attempt to look up the related items the user specified (these will fail the module if not found) org_id = module.resolve_name_to_id('organizations', organization) - if scm_credential is not None: - scm_credential_id = module.resolve_name_to_id('credentials', scm_credential) + if credential is not None: + credential = module.resolve_name_to_id('credentials', credential) # Attempt to look up project based on the provided name and org ID project = module.get_one('projects', **{ @@ -276,7 +280,7 @@ def main(): 'scm_refspec': scm_refspec, 'scm_clean': scm_clean, 'scm_delete_on_update': scm_delete_on_update, - 'timeout': job_timeout, + 'timeout': timeout, 'organization': org_id, 'scm_update_on_launch': scm_update_on_launch, 'scm_update_cache_timeout': scm_update_cache_timeout, @@ -284,8 +288,8 @@ def main(): } if description is not None: project_fields['description'] = description - if scm_credential is not None: - project_fields['credential'] = scm_credential_id + if credential is not None: + project_fields['credential'] = credential if allow_override is not None: project_fields['allow_override'] = allow_override if scm_type == '': diff --git a/awx_collection/test/awx/test_completeness.py b/awx_collection/test/awx/test_completeness.py new file mode 100644 index 0000000000..a4ffad3c48 --- /dev/null +++ b/awx_collection/test/awx/test_completeness.py @@ -0,0 +1,268 @@ +from __future__ import (absolute_import, division, print_function) +__metaclass__ = type + +import pytest +from awx.main.tests.functional.conftest import _request +from ansible.module_utils.six import PY2, string_types +import yaml +import os +import re + +# Analysis variables +# ----------------------------------------------------------------------------------------------------------- + +# Read-only endpoints are dynamically created by an options page with no POST section. +# Normally a read-only endpoint should not have a module (i.e. /api/v2/me) but sometimes we reuse a name +# For example, we have a tower_role module but /api/v2/roles is a read only endpoint. +# This list indicates which read-only endpoints have associated modules with them. +read_only_endpoints_with_modules = ['tower_settings', 'tower_role'] + +# If a module should not be created for an endpoint and the endpoint is not read-only add it here +# THINK HARD ABOUT DOING THIS +no_module_for_endpoint = [] + +# Some modules work on the related fields of an endpoint. These modules will not have an auto-associated endpoint +no_endpoint_for_module = [ + 'tower_import', 'tower_meta', 'tower_export', 'tower_job_launch', 'tower_job_wait', 'tower_job_list', + 'tower_license', 'tower_ping', 'tower_receive', 'tower_send', 'tower_workflow_launch', 'tower_job_cancel', + 'tower_workflow_template', +] + +# Global module parameters we can ignore +ignore_parameters = [ + 'state', 'new_name', +] + +# Some modules take additional parameters that do not appear in the API +# Add the module name as the key with the value being the list of params to ignore +no_api_parameter_ok = { + # The wait is for whether or not to wait for a project update on change + 'tower_project': ['wait'], + # Existing_token and id are for working with an existing tokens + 'tower_token': ['existing_token', 'existing_token_id'], + # /survey spec is now how we handle associations + # We take an organization here to help with the lookups only + 'tower_job_template': ['survey_spec', 'organization'], + # Organization is how we looking job templates + 'tower_workflow_job_template_node': ['organization'], + # Survey is how we handle associations + 'tower_workflow_job_template': ['survey'], +} + +# When this tool was created we were not feature complete. Adding something in here indicates a module +# that needs to be developed. If the module is found on the file system it will auto-detect that the +# work is being done and will bypass this check. At some point this module should be removed from this list. +needs_development = [ + 'tower_ad_hoc_command', 'tower_application', 'tower_instance_group', 'tower_inventory_script', + 'tower_workflow_approval' +] +needs_param_development = { + 'tower_host': ['instance_id'], + 'tower_inventory': ['insights_credential'], +} +# ----------------------------------------------------------------------------------------------------------- + +return_value = 0 +read_only_endpoint = [] + + +def cause_error(msg): + global return_value + return_value = 255 + return msg + + +def determine_state(module_id, endpoint, module, parameter, api_option, module_option): + # This is a hierarchical list of things that are ok/failures based on conditions + + # If we know this module needs development this is a non-blocking failure + if module_id in needs_development and module == 'N/A': + return "Failed (non-blocking), module needs development" + + # If the module is a read only endpoint: + # If it has no module on disk that is ok. + # If it has a module on disk but its listed in read_only_endpoints_with_modules that is ok + # Else we have a module for a read only endpoint that should not exit + if module_id in read_only_endpoint: + if module == 'N/A': + # There may be some cases where a read only endpoint has a module + return "OK, this endpoint is read-only and should not have a module" + elif module_id in read_only_endpoints_with_modules: + return "OK, module params can not be checked to read-only" + else: + return cause_error("Failed, read-only endpoint should not have an associated module") + + # If the endpoint is listed as not needing a module and we don't have one we are ok + if module_id in no_module_for_endpoint and module == 'N/A': + return "OK, this endpoint should not have a module" + + # If module is listed as not needing an endpoint and we don't have one we are ok + if module_id in no_endpoint_for_module and endpoint == 'N/A': + return "OK, this module does not require an endpoint" + + # All of the end/point module conditionals are done so if we don't have a module or endpoint we have a problem + if module == 'N/A': + return cause_error('Failed, missing module') + if endpoint == 'N/A': + return cause_error('Failed, why does this module have no endpoint') + + # Now perform parameter checks + + # First, if the parameter is in the ignore_parameters list we are ok + if parameter in ignore_parameters: + return "OK, globally ignored parameter" + + # If both the api option and the module option are both either objects or none + if (api_option is None) ^ (module_option is None): + # If the API option is node and the parameter is in the no_api_parameter list we are ok + if api_option is None and parameter in no_api_parameter_ok.get(module, {}): + return 'OK, no api parameter is ok' + # If we know this parameter needs development and we don't have a module option we are non-blocking + if module_option is None and parameter in needs_param_development.get(module_id, {}): + return "Failed (non-blocking), parameter needs development" + # Check for deprecated in the node, if its deprecated and has no api option we are ok, otherwise we have a problem + if module_option and module_option.get('description'): + description = '' + if isinstance(module_option.get('description'), string_types): + description = module_option.get('description') + else: + description = " ".join(module_option.get('description')) + + if 'deprecated' in description.lower(): + if api_option is None: + return 'OK, deprecated module option' + else: + return cause_error('Failed, module marks option as deprecated but option still exists in API') + # If we don't have a corresponding API option but we are a list then we are likely a relation + if not api_option and module_option and module_option.get('type', 'str') == 'list': + return "OK, Field appears to be relation" + # TODO, at some point try and check the object model to confirm its actually a relation + return cause_error('Failed, option mismatch') + + # We made it through all of the checks so we are ok + return 'OK' + + +def test_completeness(collection_import, request, admin_user, job_template): + option_comparison = {} + # Load a list of existing module files from disk + base_folder = os.path.abspath( + os.path.join(os.path.dirname(__file__), os.pardir, os.pardir) + ) + module_directory = os.path.join(base_folder, 'plugins', 'modules') + for root, dirs, files in os.walk(module_directory): + if root == module_directory: + for filename in files: + if re.match('^tower_.*.py$', filename): + module_name = filename[:-3] + option_comparison[module_name] = { + 'endpoint': 'N/A', + 'api_options': {}, + 'module_options': {}, + 'module_name': module_name, + } + resource_module = collection_import('plugins.modules.{0}'.format(module_name)) + option_comparison[module_name]['module_options'] = yaml.load( + resource_module.DOCUMENTATION, + Loader=yaml.SafeLoader + )['options'] + + endpoint_response = _request('get')( + url='/api/v2/', + user=admin_user, + expect=None, + ) + for endpoint in endpoint_response.data.keys(): + # Module names are singular and endpoints are plural so we need to convert to singular + singular_endpoint = '{0}'.format(endpoint) + if singular_endpoint.endswith('ies'): + singular_endpoint = singular_endpoint[:-3] + if singular_endpoint != 'settings' and singular_endpoint.endswith('s'): + singular_endpoint = singular_endpoint[:-1] + module_name = 'tower_{0}'.format(singular_endpoint) + + endpoint_url = endpoint_response.data.get(endpoint) + + # If we don't have a module for this endpoint then we can create an empty one + if module_name not in option_comparison: + option_comparison[module_name] = {} + option_comparison[module_name]['module_name'] = 'N/A' + option_comparison[module_name]['module_options'] = {} + + # Add in our endpoint and an empty api_options + option_comparison[module_name]['endpoint'] = endpoint_url + option_comparison[module_name]['api_options'] = {} + + # Get out the endpoint, load and parse its options page + options_response = _request('options')( + url=endpoint_url, + user=admin_user, + expect=None, + ) + if 'POST' in options_response.data.get('actions', {}): + option_comparison[module_name]['api_options'] = options_response.data.get('actions').get('POST') + else: + read_only_endpoint.append(module_name) + + # Parse through our data to get string lengths to make a pretty report + longest_module_name = 0 + longest_option_name = 0 + longest_endpoint = 0 + for module in option_comparison: + if len(option_comparison[module]['module_name']) > longest_module_name: + longest_module_name = len(option_comparison[module]['module_name']) + if len(option_comparison[module]['endpoint']) > longest_endpoint: + longest_endpoint = len(option_comparison[module]['endpoint']) + for option in option_comparison[module]['api_options'], option_comparison[module]['module_options']: + if len(option) > longest_option_name: + longest_option_name = len(option) + + # Print out some headers + print("".join([ + "End Point", " " * (longest_endpoint - len("End Point")), + " | Module Name", " " * (longest_module_name - len("Module Name")), + " | Option", " " * (longest_option_name - len("Option")), + " | API | Module | State", + ])) + print("-|-".join([ + "-" * longest_endpoint, + "-" * longest_module_name, + "-" * longest_option_name, + "---", + "------", + "---------------------------------------------", + ])) + + # Print out all of our data + for module in sorted(option_comparison): + module_data = option_comparison[module] + all_param_names = list(set(module_data['api_options']) | set(module_data['module_options'])) + for parameter in sorted(all_param_names): + print("".join([ + module_data['endpoint'], " " * (longest_endpoint - len(module_data['endpoint'])), " | ", + module_data['module_name'], " " * (longest_module_name - len(module_data['module_name'])), " | ", + parameter, " " * (longest_option_name - len(parameter)), " | ", + " X " if (parameter in module_data['api_options']) else ' ', " | ", + ' X ' if (parameter in module_data['module_options']) else ' ', " | ", + determine_state( + module, + module_data['endpoint'], + module_data['module_name'], + parameter, + module_data['api_options'][parameter] if (parameter in module_data['api_options']) else None, + module_data['module_options'][parameter] if (parameter in module_data['module_options']) else None, + ), + ])) + # This handles cases were we got no params from the options page nor from the modules + if len(all_param_names) == 0: + print("".join([ + module_data['endpoint'], " " * (longest_endpoint - len(module_data['endpoint'])), " | ", + module_data['module_name'], " " * (longest_module_name - len(module_data['module_name'])), " | ", + "N/A", " " * (longest_option_name - len("N/A")), " | ", + ' ', " | ", + ' ', " | ", + determine_state(module, module_data['endpoint'], module_data['module_name'], 'N/A', None, None), + ])) + + if return_value != 0: + raise Exception("One or more failures caused issues") diff --git a/awx_collection/test/awx/test_notification.py b/awx_collection/test/awx/test_notification_template.py similarity index 91% rename from awx_collection/test/awx/test_notification.py rename to awx_collection/test/awx/test_notification_template.py index 9d916d1dc9..28f7c4ecee 100644 --- a/awx_collection/test/awx/test_notification.py +++ b/awx_collection/test/awx/test_notification_template.py @@ -34,7 +34,7 @@ def test_create_modify_notification_template(run_module, admin_user, organizatio 'use_tls': False, 'use_ssl': False, 'timeout': 4 } - result = run_module('tower_notification', dict( + result = run_module('tower_notification_template', dict( name='foo-notification-template', organization=organization.name, notification_type='email', @@ -49,7 +49,7 @@ def test_create_modify_notification_template(run_module, admin_user, organizatio # Test no-op, this is impossible if the notification_configuration is given # because we cannot determine if password fields changed - result = run_module('tower_notification', dict( + result = run_module('tower_notification_template', dict( name='foo-notification-template', organization=organization.name, notification_type='email', @@ -59,7 +59,7 @@ def test_create_modify_notification_template(run_module, admin_user, organizatio # Test a change in the configuration nt_config['timeout'] = 12 - result = run_module('tower_notification', dict( + result = run_module('tower_notification_template', dict( name='foo-notification-template', organization=organization.name, notification_type='email', @@ -74,7 +74,7 @@ def test_create_modify_notification_template(run_module, admin_user, organizatio @pytest.mark.django_db def test_invalid_notification_configuration(run_module, admin_user, organization): - result = run_module('tower_notification', dict( + result = run_module('tower_notification_template', dict( name='foo-notification-template', organization=organization.name, notification_type='email', @@ -92,7 +92,7 @@ def test_deprecated_to_modern_no_op(run_module, admin_user, organization): 'X-Custom-Header': 'value123' } } - result = run_module('tower_notification', dict( + result = run_module('tower_notification_template', dict( name='foo-notification-template', organization=organization.name, notification_type='webhook', @@ -101,7 +101,7 @@ def test_deprecated_to_modern_no_op(run_module, admin_user, organization): assert not result.get('failed', False), result.get('msg', result) assert result.pop('changed', None), result - result = run_module('tower_notification', dict( + result = run_module('tower_notification_template', dict( name='foo-notification-template', organization=organization.name, notification_type='webhook', diff --git a/awx_collection/tests/integration/targets/tower_notification/tasks/main.yml b/awx_collection/tests/integration/targets/tower_notification_template/tasks/main.yml similarity index 77% rename from awx_collection/tests/integration/targets/tower_notification/tasks/main.yml rename to awx_collection/tests/integration/targets/tower_notification_template/tasks/main.yml index ea3e96b90a..a4d41571cf 100644 --- a/awx_collection/tests/integration/targets/tower_notification/tasks/main.yml +++ b/awx_collection/tests/integration/targets/tower_notification_template/tasks/main.yml @@ -1,14 +1,14 @@ --- - name: Generate names set_fact: - slack_not: "AWX-Collection-tests-tower_notification-slack-not-{{ lookup('password', '/dev/null chars=ascii_letters length=16') }}" - webhook_not: "AWX-Collection-tests-tower_notification-wehbook-not-{{ lookup('password', '/dev/null chars=ascii_letters length=16') }}" - email_not: "AWX-Collection-tests-tower_notification-email-not-{{ lookup('password', '/dev/null chars=ascii_letters length=16') }}" - twillo_not: "AWX-Collection-tests-tower_notification-twillo-not-{{ lookup('password', '/dev/null chars=ascii_letters length=16') }}" - pd_not: "AWX-Collection-tests-tower_notification-pd-not-{{ lookup('password', '/dev/null chars=ascii_letters length=16') }}" - irc_not: "AWX-Collection-tests-tower_notification-irc-not-{{ lookup('password', '/dev/null chars=ascii_letters length=16') }}" + slack_not: "AWX-Collection-tests-tower_notification_template-slack-not-{{ lookup('password', '/dev/null chars=ascii_letters length=16') }}" + webhook_not: "AWX-Collection-tests-tower_notification_template-wehbook-not-{{ lookup('password', '/dev/null chars=ascii_letters length=16') }}" + email_not: "AWX-Collection-tests-tower_notification_template-email-not-{{ lookup('password', '/dev/null chars=ascii_letters length=16') }}" + twillo_not: "AWX-Collection-tests-tower_notification_template-twillo-not-{{ lookup('password', '/dev/null chars=ascii_letters length=16') }}" + pd_not: "AWX-Collection-tests-tower_notification_template-pd-not-{{ lookup('password', '/dev/null chars=ascii_letters length=16') }}" + irc_not: "AWX-Collection-tests-tower_notification_template-irc-not-{{ lookup('password', '/dev/null chars=ascii_letters length=16') }}" -- name: Test deprecation warnings +- name: Test deprecation warnings with legacy name tower_notification: name: "{{ slack_not }}" organization: Default @@ -54,7 +54,7 @@ - result['deprecations'] | length() == 25 - name: Create Slack notification with custom messages - tower_notification: + tower_notification_template: name: "{{ slack_not }}" organization: Default notification_type: slack @@ -76,7 +76,7 @@ - result is changed - name: Delete Slack notification - tower_notification: + tower_notification_template: name: "{{ slack_not }}" organization: Default state: absent @@ -87,7 +87,7 @@ - result is changed - name: Add webhook notification - tower_notification: + tower_notification_template: name: "{{ webhook_not }}" organization: Default notification_type: webhook @@ -102,7 +102,7 @@ - result is changed - name: Delete webhook notification - tower_notification: + tower_notification_template: name: "{{ webhook_not }}" organization: Default state: absent @@ -113,7 +113,7 @@ - result is changed - name: Add email notification - tower_notification: + tower_notification_template: name: "{{ email_not }}" organization: Default notification_type: email @@ -134,7 +134,7 @@ - result is changed - name: Delete email notification - tower_notification: + tower_notification_template: name: "{{ email_not }}" organization: Default state: absent @@ -145,7 +145,7 @@ - result is changed - name: Add twilio notification - tower_notification: + tower_notification_template: name: "{{ twillo_not }}" organization: Default notification_type: twilio @@ -162,7 +162,7 @@ - result is changed - name: Delete twilio notification - tower_notification: + tower_notification_template: name: "{{ twillo_not }}" organization: Default state: absent @@ -173,7 +173,7 @@ - result is changed - name: Add PagerDuty notification - tower_notification: + tower_notification_template: name: "{{ pd_not }}" organization: Default notification_type: pagerduty @@ -189,7 +189,7 @@ - result is changed - name: Delete PagerDuty notification - tower_notification: + tower_notification_template: name: "{{ pd_not }}" organization: Default state: absent @@ -200,7 +200,7 @@ - result is changed - name: Add IRC notification - tower_notification: + tower_notification_template: name: "{{ irc_not }}" organization: Default notification_type: irc @@ -219,7 +219,7 @@ - result is changed - name: Delete IRC notification - tower_notification: + tower_notification_template: name: "{{ irc_not }}" organization: Default state: absent