awx/awx_collection/test/awx/test_completeness.py
softwarefactory-project-zuul[bot] 0bdd873bd3
Merge pull request #9896 from sean-m-sullivan/approval_node
Add workflow approval and node wait modules

SUMMARY
Please see #9878 this is a clean PR after redoing my fork.
Add a module to find a workflow approval node and approve or deny it, based on Issue #8013.
Add a module to wait for a specific workflow node to complete and return information on it.
Both of these are based on tests I have been creating for testing workflows.
Scenario
Launch workflow
Wait for A node in the workflow to finish, compare output to expected output.
If it matches, approve the approval node, otherwise deny the approval node.
Workflow completes.
Even used in concert I've added the wait feature to both of these so a user can wait on either to appear.
This does require a workflow to use unique names on the job nodes they are waiting on, As the job # is created on the fly, it would be difficult for user to specify, A future update could explore searching for a specific identifier among a workflow template and then finding that job created by that identifier.
Currently without the modules this depends on generous use of the uri module, with until and retry coupled together.
ISSUE TYPE
Feature Pull Request

COMPONENT NAME
awx-collection
AWX VERSION
19.0.0

Reviewed-by: Bianca Henderson <beeankha@gmail.com>
2021-04-24 22:52:50 +00:00

317 lines
14 KiB
Python

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', 'tower_project_update']
# 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_inventory_source_update',
'tower_job_launch',
'tower_job_wait',
'tower_job_list',
'tower_license',
'tower_ping',
'tower_receive',
'tower_send',
'tower_workflow_launch',
'tower_workflow_node_wait',
'tower_job_cancel',
'tower_workflow_template',
'tower_ad_hoc_command_wait',
'tower_ad_hoc_command_cancel',
]
# Global module parameters we can ignore
ignore_parameters = ['state', 'new_name', 'update_secrets', 'copy_from']
# 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', 'interval', 'update_project'],
# 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'],
'tower_inventory_source': ['organization'],
# Organization is how we are looking up job templates, Approval node is for workflow_approval_templates
'tower_workflow_job_template_node': ['organization', 'approval_node'],
# Survey is how we handle associations
'tower_workflow_job_template': ['survey_spec', 'destroy_current_schema'],
# ad hoc commands support interval and timeout since its more like tower_job_launch
'tower_ad_hoc_command': ['interval', 'timeout', 'wait'],
# tower_group parameters to perserve hosts and children.
'tower_group': ['preserve_existing_children', 'preserve_existing_hosts'],
# tower_workflow_approval parameters that do not apply when approving an approval node.
'tower_workflow_approval': ['action', 'interval', 'timeout', 'workflow_job_id'],
}
# 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_inventory_script']
needs_param_development = {
'tower_host': ['instance_id'],
'tower_workflow_approval': ['description', 'execution_environment'],
}
# -----------------------------------------------------------------------------------------------------------
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, execution_environment):
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")