From d49a61b63eb80c330b9a11e8c34fcee54d361fb7 Mon Sep 17 00:00:00 2001 From: Benoit Bayszczak Date: Mon, 24 Aug 2020 18:05:23 +0200 Subject: [PATCH 001/242] add missing creds types to tower_credential module Add the following native credentials types to the tower_credential module - 'satellite': 'Red Hat Satellite 6' # renaming of satellite6 - 'aim': 'CyberArk AIM Central Credential Provider Lookup' - 'conjur': 'CyberArk Conjur Secret Lookup' - 'hashivault_kv': 'HashiCorp Vault Secret Lookup' - 'hashivault_ssh': 'HashiCorp Vault Signed SSH' - 'azure_kv': 'Microsoft Azure Key Vault' - 'kubernetes_bearer_token': 'OpenShift or Kubernetes API Bearer Token' - 'github_token': 'GitHub Personal Access Token' - 'gitlab_token': 'GitLab Personal Access Token' --- .../plugins/modules/tower_credential.py | 26 ++++++++++++------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/awx_collection/plugins/modules/tower_credential.py b/awx_collection/plugins/modules/tower_credential.py index 2ca50dea4d..3f32161f82 100644 --- a/awx_collection/plugins/modules/tower_credential.py +++ b/awx_collection/plugins/modules/tower_credential.py @@ -272,20 +272,28 @@ EXAMPLES = ''' from ..module_utils.tower_api import TowerModule KIND_CHOICES = { - 'ssh': 'Machine', - 'vault': 'Vault', - 'net': 'Network', - 'scm': 'Source Control', - 'aws': 'Amazon Web Services', - 'vmware': 'VMware vCenter', - 'satellite6': 'Red Hat Satellite 6', - 'cloudforms': 'Red Hat CloudForms', + 'aws':'Amazon Web Services', + 'tower': 'Ansible Tower', 'gce': 'Google Compute Engine', 'azure_rm': 'Microsoft Azure Resource Manager', 'openstack': 'OpenStack', + 'cloudforms': 'Red Hat CloudForms', + 'satellite': 'Red Hat Satellite 6', 'rhv': 'Red Hat Virtualization', + 'vmware': 'VMware vCenter', + 'aim': 'CyberArk AIM Central Credential Provider Lookup', + 'conjur': 'CyberArk Conjur Secret Lookup', + 'hashivault_kv': 'HashiCorp Vault Secret Lookup', + 'hashivault_ssh': 'HashiCorp Vault Signed SSH', + 'azure_kv': 'Microsoft Azure Key Vault', 'insights': 'Insights', - 'tower': 'Ansible Tower', + 'kubernetes_bearer_token': 'OpenShift or Kubernetes API Bearer Token', + 'net': 'Network', + 'scm': 'Source Control', + 'ssh': 'Machine', + 'github_token': 'GitHub Personal Access Token', + 'gitlab_token': 'GitLab Personal Access Token', + 'vault': 'Vault', } From 98b9d4358db61491ee6aa3f01861ff106bb32a13 Mon Sep 17 00:00:00 2001 From: nixocio Date: Thu, 24 Sep 2020 15:51:58 -0400 Subject: [PATCH 002/242] Add username to tooltip Add username to tooltip when user cannot be deleted. See: https://github.com/ansible/awx/issues/7751 --- .../PaginatedDataList/ToolbarDeleteButton.jsx | 2 +- .../ToolbarDeleteButton.test.jsx | 15 +++++++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/awx/ui_next/src/components/PaginatedDataList/ToolbarDeleteButton.jsx b/awx/ui_next/src/components/PaginatedDataList/ToolbarDeleteButton.jsx index 25a280d549..65b7095938 100644 --- a/awx/ui_next/src/components/PaginatedDataList/ToolbarDeleteButton.jsx +++ b/awx/ui_next/src/components/PaginatedDataList/ToolbarDeleteButton.jsx @@ -87,7 +87,7 @@ function ToolbarDeleteButton({ const renderTooltip = () => { const itemsUnableToDelete = itemsToDelete .filter(cannotDelete) - .map(item => item.name) + .map(item => item.name || item.username) .join(', '); if (itemsToDelete.some(cannotDelete)) { return ( diff --git a/awx/ui_next/src/components/PaginatedDataList/ToolbarDeleteButton.test.jsx b/awx/ui_next/src/components/PaginatedDataList/ToolbarDeleteButton.test.jsx index c85730226c..487f2c17f0 100644 --- a/awx/ui_next/src/components/PaginatedDataList/ToolbarDeleteButton.test.jsx +++ b/awx/ui_next/src/components/PaginatedDataList/ToolbarDeleteButton.test.jsx @@ -12,6 +12,11 @@ const itemB = { name: 'Foo', summary_fields: { user_capabilities: { delete: false } }, }; +const itemC = { + id: 1, + username: 'Foo', + summary_fields: { user_capabilities: { delete: false } }, +}; describe('', () => { test('should render button', () => { @@ -61,4 +66,14 @@ describe('', () => { expect(wrapper.find('Tooltip')).toHaveLength(1); expect(wrapper.find('Tooltip').prop('content')).toEqual('Delete'); }); + + test('should render tooltip for username', () => { + const wrapper = mountWithContexts( + {}} itemsToDelete={[itemC]} /> + ); + expect(wrapper.find('Tooltip')).toHaveLength(1); + expect(wrapper.find('Tooltip').prop('content').props.children).toEqual( + 'You do not have permission to delete Items: Foo' + ); + }); }); From 52f37242fcc961a439720c453a400e41d921f927 Mon Sep 17 00:00:00 2001 From: Keith Grant Date: Tue, 6 Oct 2020 14:48:23 -0700 Subject: [PATCH 003/242] clean up & unit test form error handling --- .../components/FormField/FormSubmitError.jsx | 57 ++----------- .../FormField/FormSubmitError.test.jsx | 28 +------ .../components/FormField/sortErrorMessages.js | 35 ++++++++ .../FormField/sortErrorMessages.test.js | 81 +++++++++++++++++++ 4 files changed, 125 insertions(+), 76 deletions(-) create mode 100644 awx/ui_next/src/components/FormField/sortErrorMessages.js create mode 100644 awx/ui_next/src/components/FormField/sortErrorMessages.test.js diff --git a/awx/ui_next/src/components/FormField/FormSubmitError.jsx b/awx/ui_next/src/components/FormField/FormSubmitError.jsx index 2453720246..67efb31e02 100644 --- a/awx/ui_next/src/components/FormField/FormSubmitError.jsx +++ b/awx/ui_next/src/components/FormField/FormSubmitError.jsx @@ -2,62 +2,21 @@ import React, { useState, useEffect } from 'react'; import { useFormikContext } from 'formik'; import { Alert } from '@patternfly/react-core'; import { FormFullWidthLayout } from '../FormLayout'; - -const findErrorStrings = (obj, messages = []) => { - if (typeof obj === 'string') { - messages.push(obj); - } else if (typeof obj === 'object') { - Object.keys(obj).forEach(key => { - const value = obj[key]; - if (typeof value === 'string') { - messages.push(value); - } else if (Array.isArray(value)) { - value.forEach(arrValue => { - messages = findErrorStrings(arrValue, messages); - }); - } else if (typeof value === 'object') { - messages = findErrorStrings(value, messages); - } - }); - } - return messages; -}; +import sortErrorMessages from './sortErrorMessages'; function FormSubmitError({ error }) { const [errorMessage, setErrorMessage] = useState(null); - const { setErrors } = useFormikContext(); + const { values, setErrors } = useFormikContext(); useEffect(() => { - if (!error) { - return; + const { formError, fieldErrors } = sortErrorMessages(error, values); + if (formError) { + setErrorMessage(formError); } - if ( - error?.response?.data && - typeof error.response.data === 'object' && - Object.keys(error.response.data).length > 0 - ) { - const errorMessages = {}; - Object.keys(error.response.data).forEach(fieldName => { - const errors = error.response.data[fieldName]; - if (!errors) { - return; - } - if (Array.isArray(errors.length)) { - errorMessages[fieldName] = errors.join(' '); - } else { - errorMessages[fieldName] = errors; - } - }); - setErrors(errorMessages); - - const messages = findErrorStrings(error.response.data); - setErrorMessage(messages.length > 0 ? messages : null); - } else { - /* eslint-disable-next-line no-console */ - console.error(error); - setErrorMessage(error.message); + if (fieldErrors) { + setErrors(fieldErrors); } - }, [error, setErrors]); + }, [error, setErrors, values]); if (!errorMessage) { return null; diff --git a/awx/ui_next/src/components/FormField/FormSubmitError.test.jsx b/awx/ui_next/src/components/FormField/FormSubmitError.test.jsx index 30656b4d62..86c6f39d8d 100644 --- a/awx/ui_next/src/components/FormField/FormSubmitError.test.jsx +++ b/awx/ui_next/src/components/FormField/FormSubmitError.test.jsx @@ -21,7 +21,7 @@ describe('', () => { }, }; const wrapper = mountWithContexts( - + {({ errors }) => (

{errors.name}

@@ -52,30 +52,4 @@ describe('', () => { expect(global.console.error).toHaveBeenCalledWith(error); global.console = realConsole; }); - - test('should display error message if field error is nested', async () => { - const error = { - response: { - data: { - name: 'There was an error with name', - inputs: { - url: 'Error with url', - }, - }, - }, - }; - let wrapper; - await act(async () => { - wrapper = mountWithContexts( - {() => } - ); - }); - wrapper.update(); - expect( - wrapper.find('Alert').contains(
There was an error with name
) - ).toEqual(true); - expect(wrapper.find('Alert').contains(
Error with url
)).toEqual( - true - ); - }); }); diff --git a/awx/ui_next/src/components/FormField/sortErrorMessages.js b/awx/ui_next/src/components/FormField/sortErrorMessages.js new file mode 100644 index 0000000000..dc1cd999cd --- /dev/null +++ b/awx/ui_next/src/components/FormField/sortErrorMessages.js @@ -0,0 +1,35 @@ +export default function sortErrorMessages(error, formValues = {}) { + if (!error) { + return {}; + } + + const fieldErrors = {}; + let formErrors = []; + if ( + error?.response?.data && + typeof error.response.data === 'object' && + Object.keys(error.response.data).length > 0 + ) { + Object.keys(error.response.data).forEach(fieldName => { + const errors = error.response.data[fieldName]; + if (!errors) { + return; + } + const errorsArray = Array.isArray(errors) ? errors : [errors]; + if (typeof formValues[fieldName] === 'undefined') { + formErrors = [...formErrors, ...errorsArray]; + } else { + fieldErrors[fieldName] = errorsArray.join('; '); + } + }); + } else { + /* eslint-disable-next-line no-console */ + console.error(error); + formErrors.push(error.message); + } + + return { + formError: formErrors.join('; '), + fieldErrors: Object.keys(fieldErrors).length ? fieldErrors : null, + }; +} diff --git a/awx/ui_next/src/components/FormField/sortErrorMessages.test.js b/awx/ui_next/src/components/FormField/sortErrorMessages.test.js new file mode 100644 index 0000000000..c16ed7cc9a --- /dev/null +++ b/awx/ui_next/src/components/FormField/sortErrorMessages.test.js @@ -0,0 +1,81 @@ +import sortErrorMessages from './sortErrorMessages'; + +describe('sortErrorMessages', () => { + let consoleError; + beforeEach(() => { + consoleError = global.console.error; + global.console.error = () => {}; + }); + + afterEach(() => { + global.console.error = consoleError; + }); + + test('should give general error message', () => { + const error = { + message: 'An error occurred', + }; + const parsed = sortErrorMessages(error); + + expect(parsed).toEqual({ + formError: 'An error occurred', + fieldErrors: null, + }); + }); + + test('should give field error messages', () => { + const error = { + response: { + data: { + foo: 'bar', + baz: 'bam', + }, + }, + }; + const parsed = sortErrorMessages(error, { foo: '', baz: '' }); + expect(parsed).toEqual({ + formError: '', + fieldErrors: { + foo: 'bar', + baz: 'bam', + }, + }); + }); + + test('should give form error for nonexistent field', () => { + const error = { + response: { + data: { + alpha: 'oopsie', + baz: 'bam', + }, + }, + }; + const parsed = sortErrorMessages(error, { foo: '', baz: '' }); + expect(parsed).toEqual({ + formError: 'oopsie', + fieldErrors: { + baz: 'bam', + }, + }); + }); + + test('should join multiple field error messages', () => { + const error = { + response: { + data: { + foo: ['bar', 'bar2'], + baz: 'bam', + }, + }, + }; + const parsed = sortErrorMessages(error, { foo: '', baz: '' }); + expect(parsed).toEqual({ + formError: '', + fieldErrors: { + foo: 'bar; bar2', + baz: 'bam', + }, + }); + }); +}); From f5e414750234a67031e90205d632a2e4586f7b10 Mon Sep 17 00:00:00 2001 From: Keith Grant Date: Wed, 7 Oct 2020 08:30:11 -0700 Subject: [PATCH 004/242] add comment to tests --- awx/ui_next/src/components/FormField/sortErrorMessages.test.js | 1 + 1 file changed, 1 insertion(+) diff --git a/awx/ui_next/src/components/FormField/sortErrorMessages.test.js b/awx/ui_next/src/components/FormField/sortErrorMessages.test.js index c16ed7cc9a..ff327fd964 100644 --- a/awx/ui_next/src/components/FormField/sortErrorMessages.test.js +++ b/awx/ui_next/src/components/FormField/sortErrorMessages.test.js @@ -3,6 +3,7 @@ import sortErrorMessages from './sortErrorMessages'; describe('sortErrorMessages', () => { let consoleError; beforeEach(() => { + // Component logs errors to console. Hide those during testing. consoleError = global.console.error; global.console.error = () => {}; }); From 2133b83db4de6b34af105d29a15f74db2ec3e982 Mon Sep 17 00:00:00 2001 From: Keith Grant Date: Thu, 8 Oct 2020 09:14:51 -0700 Subject: [PATCH 005/242] fix handling of nested API form errors --- .../components/FormField/sortErrorMessages.js | 65 +++++++++++++------ .../FormField/sortErrorMessages.test.js | 64 ++++++++++++++++++ 2 files changed, 108 insertions(+), 21 deletions(-) diff --git a/awx/ui_next/src/components/FormField/sortErrorMessages.js b/awx/ui_next/src/components/FormField/sortErrorMessages.js index dc1cd999cd..81055c3355 100644 --- a/awx/ui_next/src/components/FormField/sortErrorMessages.js +++ b/awx/ui_next/src/components/FormField/sortErrorMessages.js @@ -3,33 +3,56 @@ export default function sortErrorMessages(error, formValues = {}) { return {}; } - const fieldErrors = {}; - let formErrors = []; if ( error?.response?.data && typeof error.response.data === 'object' && Object.keys(error.response.data).length > 0 ) { - Object.keys(error.response.data).forEach(fieldName => { - const errors = error.response.data[fieldName]; - if (!errors) { - return; - } - const errorsArray = Array.isArray(errors) ? errors : [errors]; - if (typeof formValues[fieldName] === 'undefined') { - formErrors = [...formErrors, ...errorsArray]; - } else { - fieldErrors[fieldName] = errorsArray.join('; '); - } - }); - } else { - /* eslint-disable-next-line no-console */ - console.error(error); - formErrors.push(error.message); + const parsed = parseFieldErrors(error.response.data, formValues); + return { + formError: parsed.formErrors.join('; '), + fieldErrors: Object.keys(parsed.fieldErrors).length + ? parsed.fieldErrors + : null, + }; } - + /* eslint-disable-next-line no-console */ + console.error(error); return { - formError: formErrors.join('; '), - fieldErrors: Object.keys(fieldErrors).length ? fieldErrors : null, + formError: error.message, + fieldErrors: null, }; } + +// Recursively traverse field errors object and build up field/form errors +function parseFieldErrors(obj, formValues) { + let fieldErrors = {}; + let formErrors = []; + Object.keys(obj).forEach(key => { + const value = obj[key]; + if (typeof value === 'string') { + if (typeof formValues[key] === 'undefined') { + formErrors.push(value); + } else { + fieldErrors[key] = value; + } + } else if (Array.isArray(value)) { + if (typeof formValues[key] === 'undefined') { + formErrors = formErrors.concat(value); + } else { + fieldErrors[key] = value.join('; '); + } + } else if (typeof value === 'object') { + const parsed = parseFieldErrors(value, formValues[key] || {}); + if (Object.keys(parsed.fieldErrors).length) { + fieldErrors = { + ...fieldErrors, + [key]: parsed.fieldErrors, + }; + } + formErrors = formErrors.concat(parsed.formErrors); + } + }); + + return { fieldErrors, formErrors }; +} diff --git a/awx/ui_next/src/components/FormField/sortErrorMessages.test.js b/awx/ui_next/src/components/FormField/sortErrorMessages.test.js index ff327fd964..2b042f0120 100644 --- a/awx/ui_next/src/components/FormField/sortErrorMessages.test.js +++ b/awx/ui_next/src/components/FormField/sortErrorMessages.test.js @@ -79,4 +79,68 @@ describe('sortErrorMessages', () => { }, }); }); + + test('should give nested field error messages', () => { + const error = { + response: { + data: { + inputs: { + url: ['URL Error'], + other: { + stuff: ['Other stuff error'], + }, + }, + }, + }, + }; + const formValues = { + inputs: { + url: '', + other: { + stuff: '', + }, + }, + }; + const parsed = sortErrorMessages(error, formValues); + expect(parsed).toEqual({ + formError: '', + fieldErrors: { + inputs: { + url: 'URL Error', + other: { + stuff: 'Other stuff error', + }, + }, + }, + }); + }); + + test('should give unknown nested field error as form error', () => { + const error = { + response: { + data: { + inputs: { + url: ['URL Error'], + other: { + stuff: ['Other stuff error'], + }, + }, + }, + }, + }; + const formValues = { + inputs: { + url: '', + }, + }; + const parsed = sortErrorMessages(error, formValues); + expect(parsed).toEqual({ + formError: 'Other stuff error', + fieldErrors: { + inputs: { + url: 'URL Error', + }, + }, + }); + }); }); From 9bcb5ef0c96381e87ffecd5dbf5734a2a1402eba Mon Sep 17 00:00:00 2001 From: sean-m-sullivan Date: Sat, 10 Oct 2020 04:06:49 -0500 Subject: [PATCH 006/242] intial update for workflow approval nodes --- .../plugins/module_utils/tower_api.py | 46 +++++++++++++++---- .../tower_workflow_job_template_node.py | 39 ++++++++++++++-- 2 files changed, 70 insertions(+), 15 deletions(-) diff --git a/awx_collection/plugins/module_utils/tower_api.py b/awx_collection/plugins/module_utils/tower_api.py index d41c32b772..2e03abbb1f 100644 --- a/awx_collection/plugins/module_utils/tower_api.py +++ b/awx_collection/plugins/module_utils/tower_api.py @@ -336,8 +336,8 @@ class TowerAPIModule(TowerModule): # If we have neither of these, then we can try un-authenticated access self.authenticated = True - - def delete_if_needed(self, existing_item, on_delete=None): + + def delete_if_needed(self, existing_item, on_delete=None, on_continue=None): # This will exit from the module on its own. # If the method successfully deletes an item and on_delete param is defined, # the on_delete parameter will be called as a method pasing in this object and the json from the response @@ -363,6 +363,10 @@ class TowerAPIModule(TowerModule): self.json_output['changed'] = True self.json_output['id'] = item_id self.exit_json(**self.json_output) + if on_continue is not None: + return self.json_output + else: + self.exit_json(**self.json_output) else: if 'json' in response and '__all__' in response['json']: self.fail_json(msg="Unable to delete {0} {1}: {2}".format(item_type, item_name, response['json']['__all__'][0])) @@ -375,7 +379,10 @@ class TowerAPIModule(TowerModule): else: self.fail_json(msg="Unable to delete {0} {1}: {2}".format(item_type, item_name, response['status_code'])) else: - self.exit_json(**self.json_output) + if on_continue is not None: + return None + else: + self.exit_json(**self.json_output) def modify_associations(self, association_endpoint, new_association_list): # if we got None instead of [] we are not modifying the association_list @@ -403,8 +410,8 @@ class TowerAPIModule(TowerModule): else: self.fail_json(msg="Failed to associate item {0}".format(response['json']['detail'])) - def create_if_needed(self, existing_item, new_item, endpoint, on_create=None, item_type='unknown', associations=None): - + def create_if_needed(self, existing_item, new_item, endpoint, on_create=None, on_continue=None, item_type='unknown', associations=None): + # This will exit from the module on its own # If the method successfully creates an item and on_create param is defined, # the on_create parameter will be called as a method pasing in this object and the json from the response @@ -430,6 +437,7 @@ class TowerAPIModule(TowerModule): item_name = self.get_item_name(new_item, allow_unknown=True) response = self.post_endpoint(endpoint, **{'data': new_item}) + if response['status_code'] == 201: self.json_output['name'] = 'unknown' for key in ('name', 'username', 'identifier', 'hostname'): @@ -438,6 +446,15 @@ class TowerAPIModule(TowerModule): self.json_output['id'] = response['json']['id'] self.json_output['changed'] = True item_url = response['json']['url'] + # 200 is response from approval node creation + elif response['status_code'] == 200 and item_type == 'workflow_job_template_approval_node': + self.json_output['name'] = 'unknown' + for key in ('name', 'username', 'identifier', 'hostname'): + if key in response['json']: + self.json_output['name'] = response['json'][key] + self.json_output['id'] = response['json']['id'] + self.json_output['changed'] = True + item_url = response['json']['url'] else: if 'json' in response and '__all__' in response['json']: self.fail_json(msg="Unable to create {0} {1}: {2}".format(item_type, item_name, response['json']['__all__'][0])) @@ -455,6 +472,9 @@ class TowerAPIModule(TowerModule): # If we have an on_create method and we actually changed something we can call on_create if on_create is not None and self.json_output['changed']: on_create(self, response['json']) + elif on_continue is not None: + last_data = response['json'] + return last_data else: self.exit_json(**self.json_output) @@ -518,7 +538,7 @@ class TowerAPIModule(TowerModule): return True return False - def update_if_needed(self, existing_item, new_item, on_update=None, associations=None): + def update_if_needed(self, existing_item, new_item, on_update=None, on_continue=None, associations=None): # This will exit from the module on its own # If the method successfully updates an item and on_update param is defined, # the on_update parameter will be called as a method pasing in this object and the json from the response @@ -578,14 +598,20 @@ class TowerAPIModule(TowerModule): else: last_data = response['json'] on_update(self, last_data) + elif on_continue is not None: + if response is None: + last_data = existing_item + else: + last_data = response['json'] + return last_data else: self.exit_json(**self.json_output) - - def create_or_update_if_needed(self, existing_item, new_item, endpoint=None, item_type='unknown', on_create=None, on_update=None, associations=None): + + def create_or_update_if_needed(self, existing_item, new_item, endpoint=None, item_type='unknown', on_create=None, on_update=None, on_continue=None, associations=None): if existing_item: - return self.update_if_needed(existing_item, new_item, on_update=on_update, associations=associations) + return self.update_if_needed(existing_item, new_item, on_update=on_update, on_continue=on_continue, associations=associations) else: - return self.create_if_needed(existing_item, new_item, endpoint, on_create=on_create, item_type=item_type, associations=associations) + return self.create_if_needed(existing_item, new_item, endpoint, on_create=on_create, item_type=item_type, on_continue=on_continue, associations=associations) def logout(self): if self.authenticated and self.oauth_token_id: diff --git a/awx_collection/plugins/modules/tower_workflow_job_template_node.py b/awx_collection/plugins/modules/tower_workflow_job_template_node.py index 0705de6785..f1e65b4d75 100644 --- a/awx_collection/plugins/modules/tower_workflow_job_template_node.py +++ b/awx_collection/plugins/modules/tower_workflow_job_template_node.py @@ -166,6 +166,10 @@ def main(): identifier=dict(required=True), workflow_job_template=dict(required=True, aliases=['workflow']), organization=dict(), + approval_node=dict(type='bool'), + name=dict(), + description=dict(), + timeout=dict(type='int'), extra_data=dict(type='dict'), inventory=dict(), scm_branch=dict(), @@ -180,6 +184,7 @@ def main(): success_nodes=dict(type='list', elements='str'), always_nodes=dict(type='list', elements='str'), failure_nodes=dict(type='list', elements='str'), + approval_nodes=dict(type='list', elements='str'), credentials=dict(type='list', elements='str'), state=dict(choices=['present', 'absent'], default='present'), ) @@ -190,7 +195,10 @@ def main(): # Extract our parameters identifier = module.params.get('identifier') state = module.params.get('state') - + approval_node = module.params.get('approval_node') + name = module.params.get('name') + description = module.params.get('description') + timeout = module.params.get('timeout') new_fields = {} search_fields = {'identifier': identifier} @@ -237,7 +245,7 @@ def main(): new_fields[field_name] = field_val association_fields = {} - for association in ('always_nodes', 'success_nodes', 'failure_nodes', 'credentials'): + for association in ('always_nodes', 'success_nodes', 'failure_nodes', 'approval_nodes', 'credentials'): name_list = module.params.get(association) if name_list is None: continue @@ -264,10 +272,31 @@ def main(): # If the state was present and we can let the module build or update the existing item, this will return on its own module.create_or_update_if_needed( existing_item, new_fields, - endpoint='workflow_job_template_nodes', item_type='workflow_job_template_node', + endpoint='workflow_job_template_nodes', item_type='workflow_job_template_node', on_continue=approval_node, associations=association_fields ) - - + if approval_node: + # Set Approval Fields + new_fields = {} + if name is not None: + new_fields['name'] = name + if description is not None: + new_fields['description'] = description + if timeout is not None: + new_fields['timeout'] = timeout + # Find created approval node ID + search_fields = {'identifier': identifier} + search_fields['workflow_job_template'] = workflow_job_template_id + workflow_job_template_node = module.get_one('workflow_job_template_nodes', **{'data': search_fields}) + workflow_job_template_node_id = workflow_job_template_node['id'] + # Due to not able to lookup workflow_approval_templates, none existing item + existing_item = {} + # module.fail_json(msg="workflow_job_template_nodes/{0}/create_approval_template/".format(workflow_job_template_node_id)) + module.create_or_update_if_needed( + existing_item, new_fields, + endpoint='workflow_job_template_nodes/' + str(workflow_job_template_node_id) + '/create_approval_template/', item_type='workflow_job_template_approval_node', on_continue=approval_node, + associations=association_fields + ) + module.exit_json(**module.json_output) if __name__ == '__main__': main() From a2c8e3d87e324c5f920c089a6cce72562ca87dae Mon Sep 17 00:00:00 2001 From: sean-m-sullivan Date: Sat, 10 Oct 2020 11:53:50 -0500 Subject: [PATCH 007/242] clean up and tests added --- .../tower_workflow_job_template_node.py | 57 ++++++++++++------- .../targets/tower_job_wait/tasks/main.yml | 2 +- .../tasks/main.yml | 31 ++++++++++ 3 files changed, 70 insertions(+), 20 deletions(-) diff --git a/awx_collection/plugins/modules/tower_workflow_job_template_node.py b/awx_collection/plugins/modules/tower_workflow_job_template_node.py index f1e65b4d75..6b3b90a151 100644 --- a/awx_collection/plugins/modules/tower_workflow_job_template_node.py +++ b/awx_collection/plugins/modules/tower_workflow_job_template_node.py @@ -93,6 +93,24 @@ options: - Can be a job template, project, inventory source, etc. - Omit if creating an approval node (not yet implemented). type: str + approval_node: + description: + - A dictionary of Name, description, and timeout values for the approval node. + type: dict + suboptions: + name: + description: + - Name of this workflow approval template. + type: str + required: True + description: + description: + - Optional description of this workflow approval template. + type: str + timeout: + description: + - The amount of time (in seconds) before the approval node expires and fails. + type: int all_parents_must_converge: description: - If enabled then the node will only run if all of the parent nodes have met the criteria to reach this node @@ -166,10 +184,6 @@ def main(): identifier=dict(required=True), workflow_job_template=dict(required=True, aliases=['workflow']), organization=dict(), - approval_node=dict(type='bool'), - name=dict(), - description=dict(), - timeout=dict(type='int'), extra_data=dict(type='dict'), inventory=dict(), scm_branch=dict(), @@ -180,11 +194,11 @@ def main(): diff_mode=dict(type='bool'), verbosity=dict(choices=['0', '1', '2', '3', '4', '5']), unified_job_template=dict(), + approval_node=dict(type='dict'), all_parents_must_converge=dict(type='bool'), success_nodes=dict(type='list', elements='str'), always_nodes=dict(type='list', elements='str'), failure_nodes=dict(type='list', elements='str'), - approval_nodes=dict(type='list', elements='str'), credentials=dict(type='list', elements='str'), state=dict(choices=['present', 'absent'], default='present'), ) @@ -196,9 +210,6 @@ def main(): identifier = module.params.get('identifier') state = module.params.get('state') approval_node = module.params.get('approval_node') - name = module.params.get('name') - description = module.params.get('description') - timeout = module.params.get('timeout') new_fields = {} search_fields = {'identifier': identifier} @@ -245,7 +256,7 @@ def main(): new_fields[field_name] = field_val association_fields = {} - for association in ('always_nodes', 'success_nodes', 'failure_nodes', 'approval_nodes', 'credentials'): + for association in ('always_nodes', 'success_nodes', 'failure_nodes', 'credentials'): name_list = module.params.get(association) if name_list is None: continue @@ -275,23 +286,31 @@ def main(): endpoint='workflow_job_template_nodes', item_type='workflow_job_template_node', on_continue=approval_node, associations=association_fields ) + + # Create approval node unified template or update existing if approval_node: # Set Approval Fields new_fields = {} - if name is not None: - new_fields['name'] = name - if description is not None: - new_fields['description'] = description - if timeout is not None: - new_fields['timeout'] = timeout - # Find created approval node ID + + # Extract Parameters + if approval_node.get('name') is None: + module.fail_json(msg="Approval node name is required to create approval node.") + if approval_node.get('name') is not None: + new_fields['name'] = approval_node['name'] + if approval_node.get('description') is not None: + new_fields['description'] = approval_node['description'] + if approval_node.get('timeout') is not None: + new_fields['timeout'] = approval_node['timeout'] + + # Find created workflow node ID search_fields = {'identifier': identifier} search_fields['workflow_job_template'] = workflow_job_template_id workflow_job_template_node = module.get_one('workflow_job_template_nodes', **{'data': search_fields}) workflow_job_template_node_id = workflow_job_template_node['id'] - # Due to not able to lookup workflow_approval_templates, none existing item - existing_item = {} - # module.fail_json(msg="workflow_job_template_nodes/{0}/create_approval_template/".format(workflow_job_template_node_id)) + module.json_output['workflow_node_id'] = workflow_job_template_node_id + # Due to not able to lookup workflow_approval_templates, find the existing item in another place + if workflow_job_template_node['related'].get('unified_job_template') is not None: + existing_item = module.get_endpoint(workflow_job_template_node['related']['unified_job_template'])['json'] module.create_or_update_if_needed( existing_item, new_fields, endpoint='workflow_job_template_nodes/' + str(workflow_job_template_node_id) + '/create_approval_template/', item_type='workflow_job_template_approval_node', on_continue=approval_node, diff --git a/awx_collection/tests/integration/targets/tower_job_wait/tasks/main.yml b/awx_collection/tests/integration/targets/tower_job_wait/tasks/main.yml index b04fa62ff8..e56856da95 100644 --- a/awx_collection/tests/integration/targets/tower_job_wait/tasks/main.yml +++ b/awx_collection/tests/integration/targets/tower_job_wait/tasks/main.yml @@ -100,7 +100,7 @@ # Make sure that we failed and that we have some data in our results - assert: that: - - "wait_results.msg == 'Monitoring aborted due to timeout' or 'Timeout waiting for job to finish.'" + - "wait_results.msg == 'Approval node name is required to create approval node.'" - "'id' in wait_results" - name: Async cancel the long running job diff --git a/awx_collection/tests/integration/targets/tower_workflow_job_template/tasks/main.yml b/awx_collection/tests/integration/targets/tower_workflow_job_template/tasks/main.yml index 393fbe33da..10a7a6f5ff 100644 --- a/awx_collection/tests/integration/targets/tower_workflow_job_template/tasks/main.yml +++ b/awx_collection/tests/integration/targets/tower_workflow_job_template/tasks/main.yml @@ -9,6 +9,7 @@ demo_project_name: "AWX-Collection-tests-tower_workflow_job_template-proj-{{ test_id }}" jt1_name: "AWX-Collection-tests-tower_workflow_job_template-jt1-{{ test_id }}" jt2_name: "AWX-Collection-tests-tower_workflow_job_template-jt2-{{ test_id }}" + approval_node_name: "AWX-Collection-tests-tower_workflow_approval_node-{{ test_id }}" lab1: "AWX-Collection-tests-tower_job_template-lab1-{{ test_id }}" wfjt_name: "AWX-Collection-tests-tower_workflow_job_template-wfjt-{{ test_id }}" email_not: "AWX-Collection-tests-tower_job_template-email-not-{{ test_id }}" @@ -145,6 +146,36 @@ unified_job_template: "{{ jt1_name }}" workflow: "{{ wfjt_name }}" +- name: Fail if no name is set for approval + tower_workflow_job_template_node: + identifier: approval_test + approval_node: + description: "{{ approval_node_name }}" + workflow: "{{ wfjt_name }}" + register: no_name_results + ignore_errors: true + +- assert: + that: + - "no_name_results.msg == 'Approval node name is required to create approval node.'" + +- name: Create approval node + awx.awx.tower_workflow_job_template_node: + identifier: approval_test + approval_node: + name: "{{ approval_node_name }}" + timeout: 900 + workflow: test + +- name: Create link for root node + tower_workflow_job_template_node: + identifier: root + workflow: "{{ wfjt_name }}" + success_nodes: + - approval_test + always_nodes: + - leaf + - name: Add started notifications to workflow job template tower_workflow_job_template: name: "{{ wfjt_name }}" From 5655f766f04dae0f755d0bdbb0cc7d511240ad24 Mon Sep 17 00:00:00 2001 From: sean-m-sullivan Date: Sat, 10 Oct 2020 11:56:00 -0500 Subject: [PATCH 008/242] linting --- awx_collection/plugins/module_utils/tower_api.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/awx_collection/plugins/module_utils/tower_api.py b/awx_collection/plugins/module_utils/tower_api.py index 2e03abbb1f..4c180cead7 100644 --- a/awx_collection/plugins/module_utils/tower_api.py +++ b/awx_collection/plugins/module_utils/tower_api.py @@ -336,7 +336,7 @@ class TowerAPIModule(TowerModule): # If we have neither of these, then we can try un-authenticated access self.authenticated = True - + def delete_if_needed(self, existing_item, on_delete=None, on_continue=None): # This will exit from the module on its own. # If the method successfully deletes an item and on_delete param is defined, @@ -411,7 +411,7 @@ class TowerAPIModule(TowerModule): self.fail_json(msg="Failed to associate item {0}".format(response['json']['detail'])) def create_if_needed(self, existing_item, new_item, endpoint, on_create=None, on_continue=None, item_type='unknown', associations=None): - + # This will exit from the module on its own # If the method successfully creates an item and on_create param is defined, # the on_create parameter will be called as a method pasing in this object and the json from the response @@ -437,7 +437,7 @@ class TowerAPIModule(TowerModule): item_name = self.get_item_name(new_item, allow_unknown=True) response = self.post_endpoint(endpoint, **{'data': new_item}) - + if response['status_code'] == 201: self.json_output['name'] = 'unknown' for key in ('name', 'username', 'identifier', 'hostname'): @@ -447,7 +447,7 @@ class TowerAPIModule(TowerModule): self.json_output['changed'] = True item_url = response['json']['url'] # 200 is response from approval node creation - elif response['status_code'] == 200 and item_type == 'workflow_job_template_approval_node': + elif response['status_code'] == 200 and item_type == 'workflow_job_template_approval_node': self.json_output['name'] = 'unknown' for key in ('name', 'username', 'identifier', 'hostname'): if key in response['json']: @@ -606,7 +606,7 @@ class TowerAPIModule(TowerModule): return last_data else: self.exit_json(**self.json_output) - + def create_or_update_if_needed(self, existing_item, new_item, endpoint=None, item_type='unknown', on_create=None, on_update=None, on_continue=None, associations=None): if existing_item: return self.update_if_needed(existing_item, new_item, on_update=on_update, on_continue=on_continue, associations=associations) From 7ffa70422a90ba3f4ac43a85443a8cff662f39d1 Mon Sep 17 00:00:00 2001 From: sean-m-sullivan Date: Sat, 10 Oct 2020 11:57:34 -0500 Subject: [PATCH 009/242] remove typo changes --- .../tests/integration/targets/tower_job_wait/tasks/main.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/awx_collection/tests/integration/targets/tower_job_wait/tasks/main.yml b/awx_collection/tests/integration/targets/tower_job_wait/tasks/main.yml index e56856da95..b04fa62ff8 100644 --- a/awx_collection/tests/integration/targets/tower_job_wait/tasks/main.yml +++ b/awx_collection/tests/integration/targets/tower_job_wait/tasks/main.yml @@ -100,7 +100,7 @@ # Make sure that we failed and that we have some data in our results - assert: that: - - "wait_results.msg == 'Approval node name is required to create approval node.'" + - "wait_results.msg == 'Monitoring aborted due to timeout' or 'Timeout waiting for job to finish.'" - "'id' in wait_results" - name: Async cancel the long running job From c3045f6a2982be7fc22928a9b4bf65027cb049d8 Mon Sep 17 00:00:00 2001 From: sean-m-sullivan Date: Sat, 10 Oct 2020 13:02:58 -0500 Subject: [PATCH 010/242] update delete --- .../plugins/modules/tower_workflow_job_template_node.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/awx_collection/plugins/modules/tower_workflow_job_template_node.py b/awx_collection/plugins/modules/tower_workflow_job_template_node.py index 6b3b90a151..bee8a84a81 100644 --- a/awx_collection/plugins/modules/tower_workflow_job_template_node.py +++ b/awx_collection/plugins/modules/tower_workflow_job_template_node.py @@ -237,7 +237,7 @@ def main(): if state == 'absent': # If the state was absent we can let the module delete it if needed, the module will handle exiting from this - module.delete_if_needed(existing_item) + module.delete_if_needed(existing_item, on_continue=True,) unified_job_template = module.params.get('unified_job_template') if unified_job_template: @@ -311,6 +311,9 @@ def main(): # Due to not able to lookup workflow_approval_templates, find the existing item in another place if workflow_job_template_node['related'].get('unified_job_template') is not None: existing_item = module.get_endpoint(workflow_job_template_node['related']['unified_job_template'])['json'] + if state == 'absent': + # If the state was absent we can let the module delete it if needed, the module will handle exiting from this + module.delete_if_needed(existing_item) module.create_or_update_if_needed( existing_item, new_fields, endpoint='workflow_job_template_nodes/' + str(workflow_job_template_node_id) + '/create_approval_template/', item_type='workflow_job_template_approval_node', on_continue=approval_node, From c57ec1ea79555146a89f5cec36ef12b9efa1e825 Mon Sep 17 00:00:00 2001 From: sean-m-sullivan Date: Sat, 10 Oct 2020 13:25:09 -0500 Subject: [PATCH 011/242] update delete --- .../tower_workflow_job_template_node.py | 86 +++++++++---------- 1 file changed, 43 insertions(+), 43 deletions(-) diff --git a/awx_collection/plugins/modules/tower_workflow_job_template_node.py b/awx_collection/plugins/modules/tower_workflow_job_template_node.py index bee8a84a81..af235b958e 100644 --- a/awx_collection/plugins/modules/tower_workflow_job_template_node.py +++ b/awx_collection/plugins/modules/tower_workflow_job_template_node.py @@ -238,54 +238,54 @@ def main(): if state == 'absent': # If the state was absent we can let the module delete it if needed, the module will handle exiting from this module.delete_if_needed(existing_item, on_continue=True,) + else: + unified_job_template = module.params.get('unified_job_template') + if unified_job_template: + new_fields['unified_job_template'] = module.resolve_name_to_id('unified_job_templates', unified_job_template) - unified_job_template = module.params.get('unified_job_template') - if unified_job_template: - new_fields['unified_job_template'] = module.resolve_name_to_id('unified_job_templates', unified_job_template) + inventory = module.params.get('inventory') + if inventory: + new_fields['inventory'] = module.resolve_name_to_id('inventories', inventory) - inventory = module.params.get('inventory') - if inventory: - new_fields['inventory'] = module.resolve_name_to_id('inventories', inventory) + # Create the data that gets sent for create and update + for field_name in ( + 'identifier', 'extra_data', 'scm_branch', 'job_type', 'job_tags', 'skip_tags', + 'limit', 'diff_mode', 'verbosity', 'all_parents_must_converge',): + field_val = module.params.get(field_name) + if field_val: + new_fields[field_name] = field_val - # Create the data that gets sent for create and update - for field_name in ( - 'identifier', 'extra_data', 'scm_branch', 'job_type', 'job_tags', 'skip_tags', - 'limit', 'diff_mode', 'verbosity', 'all_parents_must_converge',): - field_val = module.params.get(field_name) - if field_val: - new_fields[field_name] = field_val + association_fields = {} + for association in ('always_nodes', 'success_nodes', 'failure_nodes', 'credentials'): + name_list = module.params.get(association) + if name_list is None: + continue + id_list = [] + for sub_name in name_list: + if association == 'credentials': + endpoint = 'credentials' + lookup_data = {'name': sub_name} + else: + endpoint = 'workflow_job_template_nodes' + lookup_data = {'identifier': sub_name} + if workflow_job_template_id: + lookup_data['workflow_job_template'] = workflow_job_template_id + sub_obj = module.get_one(endpoint, **{'data': lookup_data}) + if sub_obj is None: + module.fail_json(msg='Could not find {0} entry with name {1}'.format(association, sub_name)) + id_list.append(sub_obj['id']) + if id_list: + association_fields[association] = id_list - association_fields = {} - for association in ('always_nodes', 'success_nodes', 'failure_nodes', 'credentials'): - name_list = module.params.get(association) - if name_list is None: - continue - id_list = [] - for sub_name in name_list: - if association == 'credentials': - endpoint = 'credentials' - lookup_data = {'name': sub_name} - else: - endpoint = 'workflow_job_template_nodes' - lookup_data = {'identifier': sub_name} - if workflow_job_template_id: - lookup_data['workflow_job_template'] = workflow_job_template_id - sub_obj = module.get_one(endpoint, **{'data': lookup_data}) - if sub_obj is None: - module.fail_json(msg='Could not find {0} entry with name {1}'.format(association, sub_name)) - id_list.append(sub_obj['id']) - if id_list: - association_fields[association] = id_list + # In the case of a new object, the utils need to know it is a node + new_fields['type'] = 'workflow_job_template_node' - # In the case of a new object, the utils need to know it is a node - new_fields['type'] = 'workflow_job_template_node' - - # If the state was present and we can let the module build or update the existing item, this will return on its own - module.create_or_update_if_needed( - existing_item, new_fields, - endpoint='workflow_job_template_nodes', item_type='workflow_job_template_node', on_continue=approval_node, - associations=association_fields - ) + # If the state was present and we can let the module build or update the existing item, this will return on its own + module.create_or_update_if_needed( + existing_item, new_fields, + endpoint='workflow_job_template_nodes', item_type='workflow_job_template_node', on_continue=approval_node, + associations=association_fields + ) # Create approval node unified template or update existing if approval_node: From c205ee81f0cb66e384b4f100b5078a2765f3345d Mon Sep 17 00:00:00 2001 From: sean-m-sullivan Date: Mon, 12 Oct 2020 00:17:18 -0500 Subject: [PATCH 012/242] update delete --- .../tower_workflow_job_template_node.py | 97 ++++++++++--------- 1 file changed, 50 insertions(+), 47 deletions(-) diff --git a/awx_collection/plugins/modules/tower_workflow_job_template_node.py b/awx_collection/plugins/modules/tower_workflow_job_template_node.py index af235b958e..173f3b1173 100644 --- a/awx_collection/plugins/modules/tower_workflow_job_template_node.py +++ b/awx_collection/plugins/modules/tower_workflow_job_template_node.py @@ -236,56 +236,62 @@ def main(): existing_item = module.get_one('workflow_job_template_nodes', **{'data': search_fields}) if state == 'absent': + # Look up existing approval node for deletion + if existing_item['related'].get('unified_job_template') is not None: + existing_approval_node = module.get_endpoint(existing_item['related']['unified_job_template'])['json'] # If the state was absent we can let the module delete it if needed, the module will handle exiting from this - module.delete_if_needed(existing_item, on_continue=True,) - else: - unified_job_template = module.params.get('unified_job_template') - if unified_job_template: - new_fields['unified_job_template'] = module.resolve_name_to_id('unified_job_templates', unified_job_template) + # Delete the Approval Node + module.delete_if_needed(existing_approval_node, on_continue=True,) + # Delete Workflow Node + module.delete_if_needed(existing_item) - inventory = module.params.get('inventory') - if inventory: - new_fields['inventory'] = module.resolve_name_to_id('inventories', inventory) + unified_job_template = module.params.get('unified_job_template') + if unified_job_template: + new_fields['unified_job_template'] = module.resolve_name_to_id('unified_job_templates', unified_job_template) - # Create the data that gets sent for create and update - for field_name in ( - 'identifier', 'extra_data', 'scm_branch', 'job_type', 'job_tags', 'skip_tags', - 'limit', 'diff_mode', 'verbosity', 'all_parents_must_converge',): - field_val = module.params.get(field_name) - if field_val: - new_fields[field_name] = field_val + inventory = module.params.get('inventory') + if inventory: + new_fields['inventory'] = module.resolve_name_to_id('inventories', inventory) - association_fields = {} - for association in ('always_nodes', 'success_nodes', 'failure_nodes', 'credentials'): - name_list = module.params.get(association) - if name_list is None: - continue - id_list = [] - for sub_name in name_list: - if association == 'credentials': - endpoint = 'credentials' - lookup_data = {'name': sub_name} - else: - endpoint = 'workflow_job_template_nodes' - lookup_data = {'identifier': sub_name} - if workflow_job_template_id: - lookup_data['workflow_job_template'] = workflow_job_template_id - sub_obj = module.get_one(endpoint, **{'data': lookup_data}) - if sub_obj is None: - module.fail_json(msg='Could not find {0} entry with name {1}'.format(association, sub_name)) - id_list.append(sub_obj['id']) - if id_list: - association_fields[association] = id_list + # Create the data that gets sent for create and update + for field_name in ( + 'identifier', 'extra_data', 'scm_branch', 'job_type', 'job_tags', 'skip_tags', + 'limit', 'diff_mode', 'verbosity', 'all_parents_must_converge',): + field_val = module.params.get(field_name) + if field_val: + new_fields[field_name] = field_val - # In the case of a new object, the utils need to know it is a node - new_fields['type'] = 'workflow_job_template_node' + association_fields = {} + for association in ('always_nodes', 'success_nodes', 'failure_nodes', 'credentials'): + name_list = module.params.get(association) + if name_list is None: + continue + id_list = [] + for sub_name in name_list: + if association == 'credentials': + endpoint = 'credentials' + lookup_data = {'name': sub_name} + else: + endpoint = 'workflow_job_template_nodes' + lookup_data = {'identifier': sub_name} + if workflow_job_template_id: + lookup_data['workflow_job_template'] = workflow_job_template_id + sub_obj = module.get_one(endpoint, **{'data': lookup_data}) + if sub_obj is None: + module.fail_json(msg='Could not find {0} entry with name {1}'.format(association, sub_name)) + id_list.append(sub_obj['id']) + if id_list: + association_fields[association] = id_list - # If the state was present and we can let the module build or update the existing item, this will return on its own - module.create_or_update_if_needed( - existing_item, new_fields, - endpoint='workflow_job_template_nodes', item_type='workflow_job_template_node', on_continue=approval_node, - associations=association_fields - ) + # In the case of a new object, the utils need to know it is a node + new_fields['type'] = 'workflow_job_template_node' + + # If the state was present and we can let the module build or update the existing item, this will return on its own + module.create_or_update_if_needed( + existing_item, new_fields, + endpoint='workflow_job_template_nodes', item_type='workflow_job_template_node', on_continue=approval_node, + associations=association_fields + ) # Create approval node unified template or update existing if approval_node: @@ -311,9 +317,6 @@ def main(): # Due to not able to lookup workflow_approval_templates, find the existing item in another place if workflow_job_template_node['related'].get('unified_job_template') is not None: existing_item = module.get_endpoint(workflow_job_template_node['related']['unified_job_template'])['json'] - if state == 'absent': - # If the state was absent we can let the module delete it if needed, the module will handle exiting from this - module.delete_if_needed(existing_item) module.create_or_update_if_needed( existing_item, new_fields, endpoint='workflow_job_template_nodes/' + str(workflow_job_template_node_id) + '/create_approval_template/', item_type='workflow_job_template_approval_node', on_continue=approval_node, From 226046dd16dd3455a64ead4d063c0bf58a752ee8 Mon Sep 17 00:00:00 2001 From: sean-m-sullivan Date: Mon, 12 Oct 2020 12:03:37 -0500 Subject: [PATCH 013/242] update --- awx_collection/test/awx/test_completeness.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/awx_collection/test/awx/test_completeness.py b/awx_collection/test/awx/test_completeness.py index 3626c42239..08bbd8b0ad 100644 --- a/awx_collection/test/awx/test_completeness.py +++ b/awx_collection/test/awx/test_completeness.py @@ -44,8 +44,8 @@ no_api_parameter_ok = { # 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 - 'tower_workflow_job_template_node': ['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'], # ad hoc commands support interval and timeout since its more like tower_job_launc From 6b27ee6a3cb5042dacd1d5547c71308bb4d52a04 Mon Sep 17 00:00:00 2001 From: sean-m-sullivan Date: Tue, 13 Oct 2020 14:24:22 -0500 Subject: [PATCH 014/242] updated workflow name --- .../targets/tower_workflow_job_template/tasks/main.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/awx_collection/tests/integration/targets/tower_workflow_job_template/tasks/main.yml b/awx_collection/tests/integration/targets/tower_workflow_job_template/tasks/main.yml index 10a7a6f5ff..3338a68c12 100644 --- a/awx_collection/tests/integration/targets/tower_workflow_job_template/tasks/main.yml +++ b/awx_collection/tests/integration/targets/tower_workflow_job_template/tasks/main.yml @@ -165,7 +165,7 @@ approval_node: name: "{{ approval_node_name }}" timeout: 900 - workflow: test + workflow: "{{ wfjt_name }}" - name: Create link for root node tower_workflow_job_template_node: From e16a910062290674e19e7c2bf550d5e5a0e4ec99 Mon Sep 17 00:00:00 2001 From: sean-m-sullivan Date: Tue, 13 Oct 2020 14:24:56 -0500 Subject: [PATCH 015/242] updated workflow task name --- .../targets/tower_workflow_job_template/tasks/main.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/awx_collection/tests/integration/targets/tower_workflow_job_template/tasks/main.yml b/awx_collection/tests/integration/targets/tower_workflow_job_template/tasks/main.yml index 3338a68c12..7df132c139 100644 --- a/awx_collection/tests/integration/targets/tower_workflow_job_template/tasks/main.yml +++ b/awx_collection/tests/integration/targets/tower_workflow_job_template/tasks/main.yml @@ -160,7 +160,7 @@ - "no_name_results.msg == 'Approval node name is required to create approval node.'" - name: Create approval node - awx.awx.tower_workflow_job_template_node: + tower_workflow_job_template_node: identifier: approval_test approval_node: name: "{{ approval_node_name }}" From 51eb4e6d6bff0a6e50357e518bed59ddb0c88001 Mon Sep 17 00:00:00 2001 From: sean-m-sullivan Date: Tue, 13 Oct 2020 18:17:22 -0500 Subject: [PATCH 016/242] update to auto_exit, add tests, add mutual exclusive parameters --- .../plugins/module_utils/tower_api.py | 36 +++++++++---------- .../tower_workflow_job_template_node.py | 22 ++++++------ .../tasks/main.yml | 8 +++++ 3 files changed, 38 insertions(+), 28 deletions(-) diff --git a/awx_collection/plugins/module_utils/tower_api.py b/awx_collection/plugins/module_utils/tower_api.py index 4c180cead7..30879a332b 100644 --- a/awx_collection/plugins/module_utils/tower_api.py +++ b/awx_collection/plugins/module_utils/tower_api.py @@ -337,7 +337,7 @@ class TowerAPIModule(TowerModule): # If we have neither of these, then we can try un-authenticated access self.authenticated = True - def delete_if_needed(self, existing_item, on_delete=None, on_continue=None): + def delete_if_needed(self, existing_item, on_delete=None, auto_exit=True): # This will exit from the module on its own. # If the method successfully deletes an item and on_delete param is defined, # the on_delete parameter will be called as a method pasing in this object and the json from the response @@ -363,10 +363,10 @@ class TowerAPIModule(TowerModule): self.json_output['changed'] = True self.json_output['id'] = item_id self.exit_json(**self.json_output) - if on_continue is not None: - return self.json_output - else: + if auto_exit: self.exit_json(**self.json_output) + else: + return self.json_output ######### else: if 'json' in response and '__all__' in response['json']: self.fail_json(msg="Unable to delete {0} {1}: {2}".format(item_type, item_name, response['json']['__all__'][0])) @@ -379,10 +379,10 @@ class TowerAPIModule(TowerModule): else: self.fail_json(msg="Unable to delete {0} {1}: {2}".format(item_type, item_name, response['status_code'])) else: - if on_continue is not None: - return None - else: + if auto_exit: self.exit_json(**self.json_output) + else: + return None def modify_associations(self, association_endpoint, new_association_list): # if we got None instead of [] we are not modifying the association_list @@ -410,7 +410,7 @@ class TowerAPIModule(TowerModule): else: self.fail_json(msg="Failed to associate item {0}".format(response['json']['detail'])) - def create_if_needed(self, existing_item, new_item, endpoint, on_create=None, on_continue=None, item_type='unknown', associations=None): + def create_if_needed(self, existing_item, new_item, endpoint, on_create=None, auto_exit=True, item_type='unknown', associations=None): # This will exit from the module on its own # If the method successfully creates an item and on_create param is defined, @@ -472,11 +472,11 @@ class TowerAPIModule(TowerModule): # If we have an on_create method and we actually changed something we can call on_create if on_create is not None and self.json_output['changed']: on_create(self, response['json']) - elif on_continue is not None: + elif auto_exit: + self.exit_json(**self.json_output) + else: last_data = response['json'] return last_data - else: - self.exit_json(**self.json_output) def _encrypted_changed_warning(self, field, old, warning=False): if not warning: @@ -538,7 +538,7 @@ class TowerAPIModule(TowerModule): return True return False - def update_if_needed(self, existing_item, new_item, on_update=None, on_continue=None, associations=None): + def update_if_needed(self, existing_item, new_item, on_update=None, auto_exit=True, associations=None): # This will exit from the module on its own # If the method successfully updates an item and on_update param is defined, # the on_update parameter will be called as a method pasing in this object and the json from the response @@ -598,20 +598,20 @@ class TowerAPIModule(TowerModule): else: last_data = response['json'] on_update(self, last_data) - elif on_continue is not None: + elif auto_exit: + self.exit_json(**self.json_output) + else: if response is None: last_data = existing_item else: last_data = response['json'] return last_data - else: - self.exit_json(**self.json_output) - def create_or_update_if_needed(self, existing_item, new_item, endpoint=None, item_type='unknown', on_create=None, on_update=None, on_continue=None, associations=None): + def create_or_update_if_needed(self, existing_item, new_item, endpoint=None, item_type='unknown', on_create=None, on_update=None, auto_exit=True, associations=None): if existing_item: - return self.update_if_needed(existing_item, new_item, on_update=on_update, on_continue=on_continue, associations=associations) + return self.update_if_needed(existing_item, new_item, on_update=on_update, auto_exit=auto_exit, associations=associations) else: - return self.create_if_needed(existing_item, new_item, endpoint, on_create=on_create, item_type=item_type, on_continue=on_continue, associations=associations) + return self.create_if_needed(existing_item, new_item, endpoint, on_create=on_create, item_type=item_type, auto_exit=auto_exit, associations=associations) def logout(self): if self.authenticated and self.oauth_token_id: diff --git a/awx_collection/plugins/modules/tower_workflow_job_template_node.py b/awx_collection/plugins/modules/tower_workflow_job_template_node.py index 173f3b1173..dc3ac1cf8a 100644 --- a/awx_collection/plugins/modules/tower_workflow_job_template_node.py +++ b/awx_collection/plugins/modules/tower_workflow_job_template_node.py @@ -91,11 +91,13 @@ options: description: - Name of unified job template to run in the workflow. - Can be a job template, project, inventory source, etc. - - Omit if creating an approval node (not yet implemented). + - Omit if creating an approval node. + - This parameter is mutually exclusive with C(approval_node). type: str approval_node: description: - A dictionary of Name, description, and timeout values for the approval node. + - This parameter is mutually exclusive with C(unified_job_template). type: dict suboptions: name: @@ -202,9 +204,15 @@ def main(): credentials=dict(type='list', elements='str'), state=dict(choices=['present', 'absent'], default='present'), ) + mutually_exclusive = [("unified_job_template", "approval_node")] + required_one_of = [["unified_job_template", "approval_node", "success_nodes", "always_nodes", "failure_nodes"]] # Create a module for ourselves - module = TowerAPIModule(argument_spec=argument_spec) + module = TowerAPIModule( + argument_spec=argument_spec, + mutually_exclusive=mutually_exclusive, + required_one_of=required_one_of, + ) # Extract our parameters identifier = module.params.get('identifier') @@ -236,13 +244,7 @@ def main(): existing_item = module.get_one('workflow_job_template_nodes', **{'data': search_fields}) if state == 'absent': - # Look up existing approval node for deletion - if existing_item['related'].get('unified_job_template') is not None: - existing_approval_node = module.get_endpoint(existing_item['related']['unified_job_template'])['json'] # If the state was absent we can let the module delete it if needed, the module will handle exiting from this - # Delete the Approval Node - module.delete_if_needed(existing_approval_node, on_continue=True,) - # Delete Workflow Node module.delete_if_needed(existing_item) unified_job_template = module.params.get('unified_job_template') @@ -289,7 +291,7 @@ def main(): # If the state was present and we can let the module build or update the existing item, this will return on its own module.create_or_update_if_needed( existing_item, new_fields, - endpoint='workflow_job_template_nodes', item_type='workflow_job_template_node', on_continue=approval_node, + endpoint='workflow_job_template_nodes', item_type='workflow_job_template_node', auto_exit=not approval_node, associations=association_fields ) @@ -319,7 +321,7 @@ def main(): existing_item = module.get_endpoint(workflow_job_template_node['related']['unified_job_template'])['json'] module.create_or_update_if_needed( existing_item, new_fields, - endpoint='workflow_job_template_nodes/' + str(workflow_job_template_node_id) + '/create_approval_template/', item_type='workflow_job_template_approval_node', on_continue=approval_node, + endpoint='workflow_job_template_nodes/' + str(workflow_job_template_node_id) + '/create_approval_template/', item_type='workflow_job_template_approval_node', associations=association_fields ) module.exit_json(**module.json_output) diff --git a/awx_collection/tests/integration/targets/tower_workflow_job_template/tasks/main.yml b/awx_collection/tests/integration/targets/tower_workflow_job_template/tasks/main.yml index 7df132c139..5c6b4911be 100644 --- a/awx_collection/tests/integration/targets/tower_workflow_job_template/tasks/main.yml +++ b/awx_collection/tests/integration/targets/tower_workflow_job_template/tasks/main.yml @@ -176,6 +176,14 @@ always_nodes: - leaf +- name: Delete approval node + awx.awx.tower_workflow_job_template_node: + identifier: approval_test + approval_node: + name: "{{ approval_node_name }}" + state: absent + workflow: "{{ wfjt_name }}" + - name: Add started notifications to workflow job template tower_workflow_job_template: name: "{{ wfjt_name }}" From c72c335b0ce600258e3c0e3459ea35f8e9d45da4 Mon Sep 17 00:00:00 2001 From: sean-m-sullivan Date: Tue, 13 Oct 2020 18:27:27 -0500 Subject: [PATCH 017/242] fix pep8 issues --- awx_collection/plugins/module_utils/tower_api.py | 7 +++++-- .../plugins/modules/tower_workflow_job_template_node.py | 5 ++++- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/awx_collection/plugins/module_utils/tower_api.py b/awx_collection/plugins/module_utils/tower_api.py index 30879a332b..b609f76e67 100644 --- a/awx_collection/plugins/module_utils/tower_api.py +++ b/awx_collection/plugins/module_utils/tower_api.py @@ -607,11 +607,14 @@ class TowerAPIModule(TowerModule): last_data = response['json'] return last_data - def create_or_update_if_needed(self, existing_item, new_item, endpoint=None, item_type='unknown', on_create=None, on_update=None, auto_exit=True, associations=None): + def create_or_update_if_needed( + self, existing_item, new_item, endpoint=None, item_type='unknown', on_create=None, on_update=None, auto_exit=True, associations=None + ): if existing_item: return self.update_if_needed(existing_item, new_item, on_update=on_update, auto_exit=auto_exit, associations=associations) else: - return self.create_if_needed(existing_item, new_item, endpoint, on_create=on_create, item_type=item_type, auto_exit=auto_exit, associations=associations) + return self.create_if_needed( + existing_item, new_item, endpoint, on_create=on_create, item_type=item_type, auto_exit=auto_exit, associations=associations) def logout(self): if self.authenticated and self.oauth_token_id: diff --git a/awx_collection/plugins/modules/tower_workflow_job_template_node.py b/awx_collection/plugins/modules/tower_workflow_job_template_node.py index dc3ac1cf8a..71e312c625 100644 --- a/awx_collection/plugins/modules/tower_workflow_job_template_node.py +++ b/awx_collection/plugins/modules/tower_workflow_job_template_node.py @@ -319,11 +319,14 @@ def main(): # Due to not able to lookup workflow_approval_templates, find the existing item in another place if workflow_job_template_node['related'].get('unified_job_template') is not None: existing_item = module.get_endpoint(workflow_job_template_node['related']['unified_job_template'])['json'] + approval_endpoint = 'workflow_job_template_nodes/' + str(workflow_job_template_node_id) + '/create_approval_template/' module.create_or_update_if_needed( existing_item, new_fields, - endpoint='workflow_job_template_nodes/' + str(workflow_job_template_node_id) + '/create_approval_template/', item_type='workflow_job_template_approval_node', + endpoint=approval_endpoint, item_type='workflow_job_template_approval_node', associations=association_fields ) + + module.exit_json(**module.json_output) if __name__ == '__main__': main() From 3b903a7459ce586d1f0fe51bcf991e88c734d821 Mon Sep 17 00:00:00 2001 From: sean-m-sullivan Date: Tue, 13 Oct 2020 18:41:52 -0500 Subject: [PATCH 018/242] fix typo --- .../targets/tower_workflow_job_template/tasks/main.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/awx_collection/tests/integration/targets/tower_workflow_job_template/tasks/main.yml b/awx_collection/tests/integration/targets/tower_workflow_job_template/tasks/main.yml index 5c6b4911be..1542b42029 100644 --- a/awx_collection/tests/integration/targets/tower_workflow_job_template/tasks/main.yml +++ b/awx_collection/tests/integration/targets/tower_workflow_job_template/tasks/main.yml @@ -177,7 +177,7 @@ - leaf - name: Delete approval node - awx.awx.tower_workflow_job_template_node: + tower_workflow_job_template_node: identifier: approval_test approval_node: name: "{{ approval_node_name }}" From d9184e02f5139a07c8c4d34b6c6e45c50e14d164 Mon Sep 17 00:00:00 2001 From: sean-m-sullivan Date: Tue, 13 Oct 2020 20:40:35 -0500 Subject: [PATCH 019/242] update pytest --- awx_collection/test/awx/test_workflow_job_template_node.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/awx_collection/test/awx/test_workflow_job_template_node.py b/awx_collection/test/awx/test_workflow_job_template_node.py index 935b01541a..33fdc5fb58 100644 --- a/awx_collection/test/awx/test_workflow_job_template_node.py +++ b/awx_collection/test/awx/test_workflow_job_template_node.py @@ -54,14 +54,13 @@ def test_create_workflow_job_template_node(run_module, admin_user, wfjt, job_tem @pytest.mark.django_db def test_create_workflow_job_template_node_no_template(run_module, admin_user, wfjt, job_template): - """This is a part of the API contract for creating approval nodes - and at some point in the future, tha feature will be supported by the collection - """ + """This is a part of the API contract for creating approval nodes""" this_identifier = '42🐉' result = run_module('tower_workflow_job_template_node', { 'identifier': this_identifier, 'workflow_job_template': wfjt.name, 'organization': wfjt.organization.name, + 'approval_node': {'name': 'foo-jt'} }, admin_user) assert not result.get('failed', False), result.get('msg', result) assert result.get('changed', False), result From 237727dd62df3ab06fdce433b55742ac4850a806 Mon Sep 17 00:00:00 2001 From: sean-m-sullivan Date: Tue, 13 Oct 2020 20:56:50 -0500 Subject: [PATCH 020/242] update pytest --- awx_collection/test/awx/test_workflow_job_template_node.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/awx_collection/test/awx/test_workflow_job_template_node.py b/awx_collection/test/awx/test_workflow_job_template_node.py index 33fdc5fb58..7fdc606dc5 100644 --- a/awx_collection/test/awx/test_workflow_job_template_node.py +++ b/awx_collection/test/awx/test_workflow_job_template_node.py @@ -65,10 +65,7 @@ def test_create_workflow_job_template_node_no_template(run_module, admin_user, w assert not result.get('failed', False), result.get('msg', result) assert result.get('changed', False), result - node = WorkflowJobTemplateNode.objects.get(pk=result['id']) - # node = WorkflowJobTemplateNode.objects.first() - - assert result['id'] == node.id + node = WorkflowJobTemplateNode.objects.get(identifier=this_identifier) assert node.identifier == this_identifier assert node.workflow_job_template_id == wfjt.id From df43221c24337955065eaff42e90cd06841978e0 Mon Sep 17 00:00:00 2001 From: Shane McDonald Date: Thu, 15 Oct 2020 10:43:48 -0400 Subject: [PATCH 021/242] Makefile logic to retry failed galaxy installs --- Makefile | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/Makefile b/Makefile index ca76648499..ed73f4a5b6 100644 --- a/Makefile +++ b/Makefile @@ -214,7 +214,11 @@ requirements_awx_dev: requirements_collections: mkdir -p $(COLLECTION_BASE) - ansible-galaxy collection install -r requirements/collections_requirements.yml -p $(COLLECTION_BASE) + n=0; \ + until [ "$$n" -ge 5 ]; do \ + ansible-galaxy collection install -r requirements/collections_requirements.yml -p $(COLLECTION_BASE) && break; \ + n=$$((n+1)); \ + done requirements: requirements_ansible requirements_awx requirements_collections From 8217d14e367e85db93f00ac7652261aec0de1c6d Mon Sep 17 00:00:00 2001 From: Marco Lussetti Date: Thu, 15 Oct 2020 20:38:29 -0700 Subject: [PATCH 022/242] Point installer to community.general.docker_image Fixes issue in Ansible 2.10.2 where docker_image command is not found. --- INSTALL.md | 1 + installer/roles/image_build/tasks/main.yml | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/INSTALL.md b/INSTALL.md index 6bc3f869d0..e7f06ec86e 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -78,6 +78,7 @@ Before you can run a deployment, you'll need the following installed in your loc - [docker](https://pypi.org/project/docker/) Python module + This is incompatible with `docker-py`. If you have previously installed `docker-py`, please uninstall it. + We use this module instead of `docker-py` because it is what the `docker-compose` Python module requires. +- [community.general.docker_image collection](https://docs.ansible.com/ansible/latest/collections/community/general/docker_image_module.html) - [GNU Make](https://www.gnu.org/software/make/) - [Git](https://git-scm.com/) Requires Version 1.8.4+ - Python 3.6+ diff --git a/installer/roles/image_build/tasks/main.yml b/installer/roles/image_build/tasks/main.yml index 9679161a86..4d8b184fcd 100644 --- a/installer/roles/image_build/tasks/main.yml +++ b/installer/roles/image_build/tasks/main.yml @@ -42,7 +42,7 @@ delegate_to: localhost - name: Build sdist builder image - docker_image: + community.general.docker_image: build: path: "{{ role_path }}/files" dockerfile: Dockerfile.sdist From 0fee6d8b865b2d94e8bf399bd37e6f64f2464cf0 Mon Sep 17 00:00:00 2001 From: sean-m-sullivan Date: Fri, 16 Oct 2020 10:52:56 -0500 Subject: [PATCH 023/242] update test --- awx_collection/test/awx/test_workflow_job_template_node.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/awx_collection/test/awx/test_workflow_job_template_node.py b/awx_collection/test/awx/test_workflow_job_template_node.py index 7fdc606dc5..b603ea2fd8 100644 --- a/awx_collection/test/awx/test_workflow_job_template_node.py +++ b/awx_collection/test/awx/test_workflow_job_template_node.py @@ -4,7 +4,7 @@ __metaclass__ = type import pytest -from awx.main.models import WorkflowJobTemplateNode, WorkflowJobTemplate, JobTemplate +from awx.main.models import WorkflowJobTemplateNode, WorkflowJobTemplate, JobTemplate, UnifiedJobTemplate @pytest.fixture @@ -60,16 +60,17 @@ def test_create_workflow_job_template_node_no_template(run_module, admin_user, w 'identifier': this_identifier, 'workflow_job_template': wfjt.name, 'organization': wfjt.organization.name, - 'approval_node': {'name': 'foo-jt'} + 'approval_node': {'name': 'foo-jt-approval'} }, admin_user) assert not result.get('failed', False), result.get('msg', result) assert result.get('changed', False), result node = WorkflowJobTemplateNode.objects.get(identifier=this_identifier) + approval_node = UnifiedJobTemplate.objects.get(name='foo-jt-approval') assert node.identifier == this_identifier assert node.workflow_job_template_id == wfjt.id - assert node.unified_job_template_id is None + assert node.unified_job_template_id is approval_node.id @pytest.mark.django_db From ad1937b3945b8168affe7247d0019a2a6500367f Mon Sep 17 00:00:00 2001 From: sean-m-sullivan Date: Fri, 16 Oct 2020 11:16:15 -0500 Subject: [PATCH 024/242] update test --- awx_collection/test/awx/test_workflow_job_template_node.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/awx_collection/test/awx/test_workflow_job_template_node.py b/awx_collection/test/awx/test_workflow_job_template_node.py index b603ea2fd8..6e3d7db23a 100644 --- a/awx_collection/test/awx/test_workflow_job_template_node.py +++ b/awx_collection/test/awx/test_workflow_job_template_node.py @@ -68,6 +68,8 @@ def test_create_workflow_job_template_node_no_template(run_module, admin_user, w node = WorkflowJobTemplateNode.objects.get(identifier=this_identifier) approval_node = UnifiedJobTemplate.objects.get(name='foo-jt-approval') + assert result['id'] == node.id + assert node.identifier == this_identifier assert node.workflow_job_template_id == wfjt.id assert node.unified_job_template_id is approval_node.id From 7ca2f33112b7655f10c723a45d148ddf7c409d22 Mon Sep 17 00:00:00 2001 From: sean-m-sullivan Date: Fri, 16 Oct 2020 11:22:12 -0500 Subject: [PATCH 025/242] update test --- awx_collection/test/awx/test_workflow_job_template_node.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/awx_collection/test/awx/test_workflow_job_template_node.py b/awx_collection/test/awx/test_workflow_job_template_node.py index 6e3d7db23a..1641aaf153 100644 --- a/awx_collection/test/awx/test_workflow_job_template_node.py +++ b/awx_collection/test/awx/test_workflow_job_template_node.py @@ -68,7 +68,7 @@ def test_create_workflow_job_template_node_no_template(run_module, admin_user, w node = WorkflowJobTemplateNode.objects.get(identifier=this_identifier) approval_node = UnifiedJobTemplate.objects.get(name='foo-jt-approval') - assert result['id'] == node.id + assert result['id'] == approval_node.id assert node.identifier == this_identifier assert node.workflow_job_template_id == wfjt.id From 8a09731a528088b7a9b5a8f937ce32a01e8f5da5 Mon Sep 17 00:00:00 2001 From: Anton Nesterov Date: Wed, 7 Oct 2020 22:08:46 +0200 Subject: [PATCH 026/242] Rename inventory_source param to name * fixes #8347 * Rename inventory_source to name in the tower_inventory_source_update * Allow to specify both name or id for `name` and `inventory` params --- .../modules/tower_inventory_source_update.py | 28 +++++++++---------- .../tasks/main.yml | 4 +-- 2 files changed, 15 insertions(+), 17 deletions(-) diff --git a/awx_collection/plugins/modules/tower_inventory_source_update.py b/awx_collection/plugins/modules/tower_inventory_source_update.py index 945cd304b5..af01caee91 100644 --- a/awx_collection/plugins/modules/tower_inventory_source_update.py +++ b/awx_collection/plugins/modules/tower_inventory_source_update.py @@ -22,14 +22,14 @@ description: - Update Ansible Tower inventory source(s). See U(https://www.ansible.com/tower) for an overview. options: - inventory: + name: description: - - Name of the inventory that contains the inventory source(s) to update. + - The name or id of the inventory source to update. required: True type: str - inventory_source: + inventory: description: - - The name of the inventory source to update. + - Name or id of the inventory that contains the inventory source(s) to update. required: True type: str organization: @@ -58,14 +58,14 @@ extends_documentation_fragment: awx.awx.auth EXAMPLES = ''' - name: Update a single inventory source tower_inventory_source_update: + name: "Example Inventory Source" inventory: "My Inventory" - inventory_source: "Example Inventory Source" organization: Default - name: Update all inventory sources tower_inventory_source_update: + name: "{{ item }}" inventory: "My Other Inventory" - inventory_source: "{{ item }}" loop: "{{ query('awx.awx.tower_api', 'inventory_sources', query_params={ 'inventory': 30 }, return_ids=True ) }}" ''' @@ -88,8 +88,8 @@ from ..module_utils.tower_api import TowerAPIModule def main(): # Any additional arguments that are not fields of the item can be added here argument_spec = dict( + name=dict(required=True), inventory=dict(required=True), - inventory_source=dict(required=True), organization=dict(), wait=dict(default=False, type='bool'), interval=dict(default=1.0, type='float'), @@ -100,8 +100,8 @@ def main(): module = TowerAPIModule(argument_spec=argument_spec) # Extract our parameters + name = module.params.get('name') inventory = module.params.get('inventory') - inventory_source = module.params.get('inventory_source') organization = module.params.get('organization') wait = module.params.get('wait') interval = module.params.get('interval') @@ -115,20 +115,18 @@ def main(): if not inventory_object: module.fail_json(msg='The specified inventory, {0}, was not found.'.format(lookup_data)) - inventory_source_object = module.get_one('inventory_sources', name_or_id=inventory_source, **{ - 'data': { - 'inventory': inventory_object['id'], - } - }) + inventory_source_object = module.get_one('inventory_sources', + name_or_id=name, + data={'inventory': inventory_object['id']}) if not inventory_source_object: module.fail_json(msg='The specified inventory source was not found.') # Sync the inventory source(s) - inventory_source_update_results = module.post_endpoint(inventory_source_object['related']['update'], **{'data': {}}) + inventory_source_update_results = module.post_endpoint(inventory_source_object['related']['update']) if inventory_source_update_results['status_code'] != 202: - module.fail_json(msg="Failed to update inventory source, see response for details", **{'response': inventory_source_update_results}) + module.fail_json(msg="Failed to update inventory source, see response for details", response=inventory_source_update_results) module.json_output['changed'] = True module.json_output['id'] = inventory_source_update_results['json']['id'] diff --git a/awx_collection/tests/integration/targets/tower_inventory_source_update/tasks/main.yml b/awx_collection/tests/integration/targets/tower_inventory_source_update/tasks/main.yml index 36a4e4b058..afb1bd962f 100644 --- a/awx_collection/tests/integration/targets/tower_inventory_source_update/tasks/main.yml +++ b/awx_collection/tests/integration/targets/tower_inventory_source_update/tasks/main.yml @@ -73,8 +73,8 @@ - name: Test Inventory Source Update tower_inventory_source_update: + name: "{{ inv_source2 }}" inventory: "{{ inv_name }}" - inventory_source: "{{ inv_source2 }}" organization: Default register: result @@ -84,8 +84,8 @@ - name: Test Inventory Source Update for All Sources tower_inventory_source_update: + name: "{{ item.name }}" inventory: "{{ inv_name }}" - inventory_source: "{{ item.name }}" organization: Default wait: true loop: "{{ query('awx.awx.tower_api', 'inventory_sources', query_params={ 'inventory': created_inventory.id }, expect_objects=True, return_objects=True) }}" From 2e237661f8206342de33a7f097e5260285de749d Mon Sep 17 00:00:00 2001 From: Anton Nesterov Date: Sat, 10 Oct 2020 14:23:01 +0200 Subject: [PATCH 027/242] Add the inventory_source as alias. --- awx_collection/plugins/modules/tower_inventory_source_update.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/awx_collection/plugins/modules/tower_inventory_source_update.py b/awx_collection/plugins/modules/tower_inventory_source_update.py index af01caee91..2b401cad57 100644 --- a/awx_collection/plugins/modules/tower_inventory_source_update.py +++ b/awx_collection/plugins/modules/tower_inventory_source_update.py @@ -27,6 +27,8 @@ options: - The name or id of the inventory source to update. required: True type: str + aliases: + - inventory_source inventory: description: - Name or id of the inventory that contains the inventory source(s) to update. From 9818440d0f91ce4b4fee513fba73dcc99f188271 Mon Sep 17 00:00:00 2001 From: Marco Lussetti Date: Mon, 19 Oct 2020 06:28:50 -0700 Subject: [PATCH 028/242] curtail change to ansible >= 2.10 per feedback --- INSTALL.md | 1 + installer/roles/image_build/tasks/main.yml | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/INSTALL.md b/INSTALL.md index e7f06ec86e..dfbd0cbe7e 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -79,6 +79,7 @@ Before you can run a deployment, you'll need the following installed in your loc + This is incompatible with `docker-py`. If you have previously installed `docker-py`, please uninstall it. + We use this module instead of `docker-py` because it is what the `docker-compose` Python module requires. - [community.general.docker_image collection](https://docs.ansible.com/ansible/latest/collections/community/general/docker_image_module.html) + + This is only required if you are using Ansible >= 2.10 - [GNU Make](https://www.gnu.org/software/make/) - [Git](https://git-scm.com/) Requires Version 1.8.4+ - Python 3.6+ diff --git a/installer/roles/image_build/tasks/main.yml b/installer/roles/image_build/tasks/main.yml index 4d8b184fcd..9679161a86 100644 --- a/installer/roles/image_build/tasks/main.yml +++ b/installer/roles/image_build/tasks/main.yml @@ -42,7 +42,7 @@ delegate_to: localhost - name: Build sdist builder image - community.general.docker_image: + docker_image: build: path: "{{ role_path }}/files" dockerfile: Dockerfile.sdist From 09a0448c3e29d8be5c662cc78e58f05cff77026f Mon Sep 17 00:00:00 2001 From: Chris Meyers Date: Thu, 15 Oct 2020 15:25:47 -0400 Subject: [PATCH 029/242] reduce parent->child lock contention * We update the parent unified job template to point at new jobs created. We also update a similar foreign key when the job finishes running. This causes lock contention when the job template is allow_simultaneous and there are a lot of jobs from that job template running in parallel. I've seen as bad as 5 minutes waiting for the lock when a job finishes. * This change moves the parent->child update to OUTSIDE of the transaction if the job is allow_simultaneous (inherited from the parent unified job). We sacrafice a bit of correctness for performance. The logic is, if you are launching 1,000 parallel jobs do you really care that the job template contains a pointer to the last one you launched? Probably not. If you do, you can always query jobs related to the job template sorted by created time. --- awx/main/models/unified_jobs.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/awx/main/models/unified_jobs.py b/awx/main/models/unified_jobs.py index 1abbb29fcb..c50c8668d5 100644 --- a/awx/main/models/unified_jobs.py +++ b/awx/main/models/unified_jobs.py @@ -873,7 +873,13 @@ class UnifiedJob(PolymorphicModel, PasswordFieldsModel, CommonModelNameNotUnique # If status changed, update the parent instance. if self.status != status_before: - self._update_parent_instance() + # Update parent outside of the transaction for Job w/ allow_simultaneous=True + # This dodges lock contention at the expense of the foreign key not being + # completely correct. + if getattr(self, 'allow_simultaneous', False): + connection.on_commit(self._update_parent_instance) + else: + self._update_parent_instance() # Done. return result From 2eac5a88730deff82b542c0a135dc8f7fa8be400 Mon Sep 17 00:00:00 2001 From: Chris Meyers Date: Fri, 2 Oct 2020 13:16:21 -0400 Subject: [PATCH 030/242] reduce per-job database query count * Do not query the database for the set of Instance that belong to the group for which we are trying to fit a job on, for each job. * Instead, cache the set of instances per-instance group. --- awx/main/models/ha.py | 10 +++-- awx/main/scheduler/task_manager.py | 53 +++++++++++++++++++++------ awx/main/tests/unit/models/test_ha.py | 35 +++++++----------- 3 files changed, 61 insertions(+), 37 deletions(-) diff --git a/awx/main/models/ha.py b/awx/main/models/ha.py index fc4e9c022e..5071786653 100644 --- a/awx/main/models/ha.py +++ b/awx/main/models/ha.py @@ -261,18 +261,20 @@ class InstanceGroup(HasPolicyEditsMixin, BaseModel, RelatedJobsMixin): app_label = 'main' - def fit_task_to_most_remaining_capacity_instance(self, task): + @staticmethod + def fit_task_to_most_remaining_capacity_instance(task, instances): instance_most_capacity = None - for i in self.instances.filter(capacity__gt=0, enabled=True).order_by('hostname'): + for i in instances: if i.remaining_capacity >= task.task_impact and \ (instance_most_capacity is None or i.remaining_capacity > instance_most_capacity.remaining_capacity): instance_most_capacity = i return instance_most_capacity - def find_largest_idle_instance(self): + @staticmethod + def find_largest_idle_instance(instances): largest_instance = None - for i in self.instances.filter(capacity__gt=0, enabled=True).order_by('hostname'): + for i in instances: if i.jobs_running == 0: if largest_instance is None: largest_instance = i diff --git a/awx/main/scheduler/task_manager.py b/awx/main/scheduler/task_manager.py index 9f4818bd37..861aa0b63f 100644 --- a/awx/main/scheduler/task_manager.py +++ b/awx/main/scheduler/task_manager.py @@ -7,6 +7,7 @@ import logging import uuid import json import random +from types import SimpleNamespace # Django from django.db import transaction, connection @@ -45,6 +46,15 @@ logger = logging.getLogger('awx.main.scheduler') class TaskManager(): def __init__(self): + ''' + Do NOT put database queries or other potentially expensive operations + in the task manager init. The task manager object is created every time a + job is created, transitions state, and every 30 seconds on each tower node. + More often then not, the object is destroyed quickly because the NOOP case is hit. + + The NOOP case is short-circuit logic. If the task manager realizes that another instance + of the task manager is already running, then it short-circuits and decides not to run. + ''' self.graph = dict() # start task limit indicates how many pending jobs can be started on this # .schedule() run. Starting jobs is expensive, and there is code in place to reap @@ -52,10 +62,30 @@ class TaskManager(): # 5 minutes to start pending jobs. If this limit is reached, pending jobs # will no longer be started and will be started on the next task manager cycle. self.start_task_limit = settings.START_TASK_LIMIT + + def after_lock_init(self): + ''' + Init AFTER we know this instance of the task manager will run because the lock is acquired. + ''' + instances = Instance.objects.filter(capacity__gt=0, enabled=True) + self.real_instances = {i.hostname: i for i in instances} + + instances_partial = [SimpleNamespace(obj=instance, + remaining_capacity=instance.remaining_capacity, + capacity=instance.capacity, + jobs_running=instance.jobs_running, + hostname=instance.hostname) for instance in instances] + + instances_by_hostname = {i.hostname: i for i in instances_partial} + for rampart_group in InstanceGroup.objects.prefetch_related('instances'): self.graph[rampart_group.name] = dict(graph=DependencyGraph(rampart_group.name), capacity_total=rampart_group.capacity, - consumed_capacity=0) + consumed_capacity=0, + instances=[]) + for instance in rampart_group.instances.filter(capacity__gt=0, enabled=True).order_by('hostname'): + if instance.hostname in instances_by_hostname: + self.graph[rampart_group.name]['instances'].append(instances_by_hostname[instance.hostname]) def is_job_blocked(self, task): # TODO: I'm not happy with this, I think blocking behavior should be decided outside of the dependency graph @@ -466,7 +496,6 @@ class TaskManager(): continue preferred_instance_groups = task.preferred_instance_groups found_acceptable_queue = False - idle_instance_that_fits = None if isinstance(task, WorkflowJob): if task.unified_job_template_id in running_workflow_templates: if not task.allow_simultaneous: @@ -483,24 +512,23 @@ class TaskManager(): found_acceptable_queue = True break - if idle_instance_that_fits is None: - idle_instance_that_fits = rampart_group.find_largest_idle_instance() remaining_capacity = self.get_remaining_capacity(rampart_group.name) if not rampart_group.is_containerized and self.get_remaining_capacity(rampart_group.name) <= 0: logger.debug("Skipping group {}, remaining_capacity {} <= 0".format( rampart_group.name, remaining_capacity)) continue - execution_instance = rampart_group.fit_task_to_most_remaining_capacity_instance(task) - if execution_instance: - logger.debug("Starting {} in group {} instance {} (remaining_capacity={})".format( - task.log_format, rampart_group.name, execution_instance.hostname, remaining_capacity)) - elif not execution_instance and idle_instance_that_fits: + execution_instance = InstanceGroup.fit_task_to_most_remaining_capacity_instance(task, self.graph[rampart_group.name]['instances']) or \ + InstanceGroup.find_largest_idle_instance(self.graph[rampart_group.name]['instances']) + + if execution_instance or rampart_group.is_containerized: if not rampart_group.is_containerized: - execution_instance = idle_instance_that_fits + execution_instance.remaining_capacity = max(0, execution_instance.remaining_capacity - task.task_impact) + execution_instance.jobs_running += 1 logger.debug("Starting {} in group {} instance {} (remaining_capacity={})".format( task.log_format, rampart_group.name, execution_instance.hostname, remaining_capacity)) - if execution_instance or rampart_group.is_containerized: + + execution_instance = self.real_instances[execution_instance.hostname] self.graph[rampart_group.name]['graph'].add_job(task) self.start_task(task, rampart_group, task.get_jobs_fail_chain(), execution_instance) found_acceptable_queue = True @@ -572,6 +600,9 @@ class TaskManager(): def _schedule(self): finished_wfjs = [] all_sorted_tasks = self.get_tasks() + + self.after_lock_init() + if len(all_sorted_tasks) > 0: # TODO: Deal with # latest_project_updates = self.get_latest_project_update_tasks(all_sorted_tasks) diff --git a/awx/main/tests/unit/models/test_ha.py b/awx/main/tests/unit/models/test_ha.py index 0e29caf8aa..2534acfd15 100644 --- a/awx/main/tests/unit/models/test_ha.py +++ b/awx/main/tests/unit/models/test_ha.py @@ -45,19 +45,14 @@ class TestInstanceGroup(object): (T(100), Is([50, 0, 20, 99, 11, 1, 5, 99]), None, "The task don't a fit, you must a quit!"), ]) def test_fit_task_to_most_remaining_capacity_instance(self, task, instances, instance_fit_index, reason): - with mock.patch.object(InstanceGroup, - 'instances', - Mock(spec_set=['filter'], - filter=lambda *args, **kargs: Mock(spec_set=['order_by'], - order_by=lambda x: instances))): - ig = InstanceGroup(id=10) + ig = InstanceGroup(id=10) - if instance_fit_index is None: - assert ig.fit_task_to_most_remaining_capacity_instance(task) is None, reason - else: - assert ig.fit_task_to_most_remaining_capacity_instance(task) == \ - instances[instance_fit_index], reason + instance_picked = ig.fit_task_to_most_remaining_capacity_instance(task, instances) + if instance_fit_index is None: + assert instance_picked is None, reason + else: + assert instance_picked == instances[instance_fit_index], reason @pytest.mark.parametrize('instances,instance_fit_index,reason', [ (Is([(0, 100)]), 0, "One idle instance, pick it"), @@ -70,16 +65,12 @@ class TestInstanceGroup(object): def filter_offline_instances(*args): return filter(lambda i: i.capacity > 0, instances) - with mock.patch.object(InstanceGroup, - 'instances', - Mock(spec_set=['filter'], - filter=lambda *args, **kargs: Mock(spec_set=['order_by'], - order_by=filter_offline_instances))): - ig = InstanceGroup(id=10) + ig = InstanceGroup(id=10) + instances_online_only = filter_offline_instances(instances) - if instance_fit_index is None: - assert ig.find_largest_idle_instance() is None, reason - else: - assert ig.find_largest_idle_instance() == \ - instances[instance_fit_index], reason + if instance_fit_index is None: + assert ig.find_largest_idle_instance(instances_online_only) is None, reason + else: + assert ig.find_largest_idle_instance(instances_online_only) == \ + instances[instance_fit_index], reason From bdabe3602931acfcd61f441da823205377c23868 Mon Sep 17 00:00:00 2001 From: Chris Meyers Date: Thu, 15 Oct 2020 15:25:47 -0400 Subject: [PATCH 031/242] reduce parent->child lock contention * We update the parent unified job template to point at new jobs created. We also update a similar foreign key when the job finishes running. This causes lock contention when the job template is allow_simultaneous and there are a lot of jobs from that job template running in parallel. I've seen as bad as 5 minutes waiting for the lock when a job finishes. * This change moves the parent->child update to OUTSIDE of the transaction if the job is allow_simultaneous (inherited from the parent unified job). We sacrafice a bit of correctness for performance. The logic is, if you are launching 1,000 parallel jobs do you really care that the job template contains a pointer to the last one you launched? Probably not. If you do, you can always query jobs related to the job template sorted by created time. --- awx/main/models/unified_jobs.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/awx/main/models/unified_jobs.py b/awx/main/models/unified_jobs.py index 1abbb29fcb..c50c8668d5 100644 --- a/awx/main/models/unified_jobs.py +++ b/awx/main/models/unified_jobs.py @@ -873,7 +873,13 @@ class UnifiedJob(PolymorphicModel, PasswordFieldsModel, CommonModelNameNotUnique # If status changed, update the parent instance. if self.status != status_before: - self._update_parent_instance() + # Update parent outside of the transaction for Job w/ allow_simultaneous=True + # This dodges lock contention at the expense of the foreign key not being + # completely correct. + if getattr(self, 'allow_simultaneous', False): + connection.on_commit(self._update_parent_instance) + else: + self._update_parent_instance() # Done. return result From 11cc6362b5ce63137adeb4b653ec359f3adfc781 Mon Sep 17 00:00:00 2001 From: Chris Meyers Date: Fri, 2 Oct 2020 13:16:21 -0400 Subject: [PATCH 032/242] reduce per-job database query count * Do not query the database for the set of Instance that belong to the group for which we are trying to fit a job on, for each job. * Instead, cache the set of instances per-instance group. --- awx/main/models/ha.py | 10 +++-- awx/main/scheduler/task_manager.py | 53 +++++++++++++++++++++------ awx/main/tests/unit/models/test_ha.py | 35 +++++++----------- 3 files changed, 61 insertions(+), 37 deletions(-) diff --git a/awx/main/models/ha.py b/awx/main/models/ha.py index fc4e9c022e..5071786653 100644 --- a/awx/main/models/ha.py +++ b/awx/main/models/ha.py @@ -261,18 +261,20 @@ class InstanceGroup(HasPolicyEditsMixin, BaseModel, RelatedJobsMixin): app_label = 'main' - def fit_task_to_most_remaining_capacity_instance(self, task): + @staticmethod + def fit_task_to_most_remaining_capacity_instance(task, instances): instance_most_capacity = None - for i in self.instances.filter(capacity__gt=0, enabled=True).order_by('hostname'): + for i in instances: if i.remaining_capacity >= task.task_impact and \ (instance_most_capacity is None or i.remaining_capacity > instance_most_capacity.remaining_capacity): instance_most_capacity = i return instance_most_capacity - def find_largest_idle_instance(self): + @staticmethod + def find_largest_idle_instance(instances): largest_instance = None - for i in self.instances.filter(capacity__gt=0, enabled=True).order_by('hostname'): + for i in instances: if i.jobs_running == 0: if largest_instance is None: largest_instance = i diff --git a/awx/main/scheduler/task_manager.py b/awx/main/scheduler/task_manager.py index 9f4818bd37..861aa0b63f 100644 --- a/awx/main/scheduler/task_manager.py +++ b/awx/main/scheduler/task_manager.py @@ -7,6 +7,7 @@ import logging import uuid import json import random +from types import SimpleNamespace # Django from django.db import transaction, connection @@ -45,6 +46,15 @@ logger = logging.getLogger('awx.main.scheduler') class TaskManager(): def __init__(self): + ''' + Do NOT put database queries or other potentially expensive operations + in the task manager init. The task manager object is created every time a + job is created, transitions state, and every 30 seconds on each tower node. + More often then not, the object is destroyed quickly because the NOOP case is hit. + + The NOOP case is short-circuit logic. If the task manager realizes that another instance + of the task manager is already running, then it short-circuits and decides not to run. + ''' self.graph = dict() # start task limit indicates how many pending jobs can be started on this # .schedule() run. Starting jobs is expensive, and there is code in place to reap @@ -52,10 +62,30 @@ class TaskManager(): # 5 minutes to start pending jobs. If this limit is reached, pending jobs # will no longer be started and will be started on the next task manager cycle. self.start_task_limit = settings.START_TASK_LIMIT + + def after_lock_init(self): + ''' + Init AFTER we know this instance of the task manager will run because the lock is acquired. + ''' + instances = Instance.objects.filter(capacity__gt=0, enabled=True) + self.real_instances = {i.hostname: i for i in instances} + + instances_partial = [SimpleNamespace(obj=instance, + remaining_capacity=instance.remaining_capacity, + capacity=instance.capacity, + jobs_running=instance.jobs_running, + hostname=instance.hostname) for instance in instances] + + instances_by_hostname = {i.hostname: i for i in instances_partial} + for rampart_group in InstanceGroup.objects.prefetch_related('instances'): self.graph[rampart_group.name] = dict(graph=DependencyGraph(rampart_group.name), capacity_total=rampart_group.capacity, - consumed_capacity=0) + consumed_capacity=0, + instances=[]) + for instance in rampart_group.instances.filter(capacity__gt=0, enabled=True).order_by('hostname'): + if instance.hostname in instances_by_hostname: + self.graph[rampart_group.name]['instances'].append(instances_by_hostname[instance.hostname]) def is_job_blocked(self, task): # TODO: I'm not happy with this, I think blocking behavior should be decided outside of the dependency graph @@ -466,7 +496,6 @@ class TaskManager(): continue preferred_instance_groups = task.preferred_instance_groups found_acceptable_queue = False - idle_instance_that_fits = None if isinstance(task, WorkflowJob): if task.unified_job_template_id in running_workflow_templates: if not task.allow_simultaneous: @@ -483,24 +512,23 @@ class TaskManager(): found_acceptable_queue = True break - if idle_instance_that_fits is None: - idle_instance_that_fits = rampart_group.find_largest_idle_instance() remaining_capacity = self.get_remaining_capacity(rampart_group.name) if not rampart_group.is_containerized and self.get_remaining_capacity(rampart_group.name) <= 0: logger.debug("Skipping group {}, remaining_capacity {} <= 0".format( rampart_group.name, remaining_capacity)) continue - execution_instance = rampart_group.fit_task_to_most_remaining_capacity_instance(task) - if execution_instance: - logger.debug("Starting {} in group {} instance {} (remaining_capacity={})".format( - task.log_format, rampart_group.name, execution_instance.hostname, remaining_capacity)) - elif not execution_instance and idle_instance_that_fits: + execution_instance = InstanceGroup.fit_task_to_most_remaining_capacity_instance(task, self.graph[rampart_group.name]['instances']) or \ + InstanceGroup.find_largest_idle_instance(self.graph[rampart_group.name]['instances']) + + if execution_instance or rampart_group.is_containerized: if not rampart_group.is_containerized: - execution_instance = idle_instance_that_fits + execution_instance.remaining_capacity = max(0, execution_instance.remaining_capacity - task.task_impact) + execution_instance.jobs_running += 1 logger.debug("Starting {} in group {} instance {} (remaining_capacity={})".format( task.log_format, rampart_group.name, execution_instance.hostname, remaining_capacity)) - if execution_instance or rampart_group.is_containerized: + + execution_instance = self.real_instances[execution_instance.hostname] self.graph[rampart_group.name]['graph'].add_job(task) self.start_task(task, rampart_group, task.get_jobs_fail_chain(), execution_instance) found_acceptable_queue = True @@ -572,6 +600,9 @@ class TaskManager(): def _schedule(self): finished_wfjs = [] all_sorted_tasks = self.get_tasks() + + self.after_lock_init() + if len(all_sorted_tasks) > 0: # TODO: Deal with # latest_project_updates = self.get_latest_project_update_tasks(all_sorted_tasks) diff --git a/awx/main/tests/unit/models/test_ha.py b/awx/main/tests/unit/models/test_ha.py index 0e29caf8aa..2534acfd15 100644 --- a/awx/main/tests/unit/models/test_ha.py +++ b/awx/main/tests/unit/models/test_ha.py @@ -45,19 +45,14 @@ class TestInstanceGroup(object): (T(100), Is([50, 0, 20, 99, 11, 1, 5, 99]), None, "The task don't a fit, you must a quit!"), ]) def test_fit_task_to_most_remaining_capacity_instance(self, task, instances, instance_fit_index, reason): - with mock.patch.object(InstanceGroup, - 'instances', - Mock(spec_set=['filter'], - filter=lambda *args, **kargs: Mock(spec_set=['order_by'], - order_by=lambda x: instances))): - ig = InstanceGroup(id=10) + ig = InstanceGroup(id=10) - if instance_fit_index is None: - assert ig.fit_task_to_most_remaining_capacity_instance(task) is None, reason - else: - assert ig.fit_task_to_most_remaining_capacity_instance(task) == \ - instances[instance_fit_index], reason + instance_picked = ig.fit_task_to_most_remaining_capacity_instance(task, instances) + if instance_fit_index is None: + assert instance_picked is None, reason + else: + assert instance_picked == instances[instance_fit_index], reason @pytest.mark.parametrize('instances,instance_fit_index,reason', [ (Is([(0, 100)]), 0, "One idle instance, pick it"), @@ -70,16 +65,12 @@ class TestInstanceGroup(object): def filter_offline_instances(*args): return filter(lambda i: i.capacity > 0, instances) - with mock.patch.object(InstanceGroup, - 'instances', - Mock(spec_set=['filter'], - filter=lambda *args, **kargs: Mock(spec_set=['order_by'], - order_by=filter_offline_instances))): - ig = InstanceGroup(id=10) + ig = InstanceGroup(id=10) + instances_online_only = filter_offline_instances(instances) - if instance_fit_index is None: - assert ig.find_largest_idle_instance() is None, reason - else: - assert ig.find_largest_idle_instance() == \ - instances[instance_fit_index], reason + if instance_fit_index is None: + assert ig.find_largest_idle_instance(instances_online_only) is None, reason + else: + assert ig.find_largest_idle_instance(instances_online_only) == \ + instances[instance_fit_index], reason From be6ed623f6cd373d3dae128c26dd7b667f78728c Mon Sep 17 00:00:00 2001 From: Jake Jackson Date: Fri, 28 Aug 2020 16:32:27 -0400 Subject: [PATCH 033/242] updating project_update.yml to accept yaml extension --- awx/playbooks/project_update.yml | 28 ++++++++++++++++++---------- 1 file changed, 18 insertions(+), 10 deletions(-) diff --git a/awx/playbooks/project_update.yml b/awx/playbooks/project_update.yml index 169273d628..74e55e7ada 100644 --- a/awx/playbooks/project_update.yml +++ b/awx/playbooks/project_update.yml @@ -159,23 +159,29 @@ gather_facts: false connection: local name: Install content with ansible-galaxy command if necessary + vars: + yaml_exts: + - {ext: .yml} + - {ext: .yaml} tasks: - block: - - name: detect requirements.yml + - name: detect roles/requirements.(yml/yaml) stat: - path: '{{project_path|quote}}/roles/requirements.yml' + path: "{{project_path|quote}}/roles/requirements{{ item.ext }}" + with_items: "{{ yaml_exts }}" register: doesRequirementsExist - - name: fetch galaxy roles from requirements.yml + - name: fetch galaxy roles from requirements.(yml/yaml) command: > - ansible-galaxy role install -r roles/requirements.yml + ansible-galaxy role install -r {{ item.stat.path }} --roles-path {{projects_root}}/.__awx_cache/{{local_path}}/stage/requirements_roles {{ ' -' + 'v' * ansible_verbosity if ansible_verbosity else '' }} args: chdir: "{{project_path|quote}}" register: galaxy_result - when: doesRequirementsExist.stat.exists + with_items: "{{ doesRequirementsExist.results }}" + when: item.stat.exists changed_when: "'was installed successfully' in galaxy_result.stdout" environment: ANSIBLE_FORCE_COLOR: false @@ -186,20 +192,22 @@ - install_roles - block: - - name: detect collections/requirements.yml + - name: detect collections/requirements.(yml/yaml) stat: - path: '{{project_path|quote}}/collections/requirements.yml' + path: "{{project_path|quote}}/collections/requirements{{ item.ext }}" + with_items: "{{ yaml_exts }}" register: doesCollectionRequirementsExist - - name: fetch galaxy collections from collections/requirements.yml + - name: fetch galaxy collections from collections/requirements.(yml/yaml) command: > - ansible-galaxy collection install -r collections/requirements.yml + ansible-galaxy collection install -r {{ item.stat.path }} --collections-path {{projects_root}}/.__awx_cache/{{local_path}}/stage/requirements_collections {{ ' -' + 'v' * ansible_verbosity if ansible_verbosity else '' }} args: chdir: "{{project_path|quote}}" register: galaxy_collection_result - when: doesCollectionRequirementsExist.stat.exists + with_items: "{{ doesCollectionRequirementsExist.results }}" + when: item.stat.exists changed_when: "'Installing ' in galaxy_collection_result.stdout" environment: ANSIBLE_FORCE_COLOR: false From 862cd974ffc96c5ee7e01e1beed0b6ad1e9d2925 Mon Sep 17 00:00:00 2001 From: sean-m-sullivan Date: Mon, 19 Oct 2020 12:12:08 -0500 Subject: [PATCH 034/242] linting --- awx_collection/plugins/module_utils/tower_api.py | 2 +- .../plugins/modules/tower_workflow_job_template_node.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/awx_collection/plugins/module_utils/tower_api.py b/awx_collection/plugins/module_utils/tower_api.py index b609f76e67..643fbd05fc 100644 --- a/awx_collection/plugins/module_utils/tower_api.py +++ b/awx_collection/plugins/module_utils/tower_api.py @@ -366,7 +366,7 @@ class TowerAPIModule(TowerModule): if auto_exit: self.exit_json(**self.json_output) else: - return self.json_output ######### + return self.json_output else: if 'json' in response and '__all__' in response['json']: self.fail_json(msg="Unable to delete {0} {1}: {2}".format(item_type, item_name, response['json']['__all__'][0])) diff --git a/awx_collection/plugins/modules/tower_workflow_job_template_node.py b/awx_collection/plugins/modules/tower_workflow_job_template_node.py index 71e312c625..1718af91e8 100644 --- a/awx_collection/plugins/modules/tower_workflow_job_template_node.py +++ b/awx_collection/plugins/modules/tower_workflow_job_template_node.py @@ -325,8 +325,8 @@ def main(): endpoint=approval_endpoint, item_type='workflow_job_template_approval_node', associations=association_fields ) - - module.exit_json(**module.json_output) + + if __name__ == '__main__': main() From 91ef686fe05daf436616390d7dba321529b8ec01 Mon Sep 17 00:00:00 2001 From: sean-m-sullivan Date: Mon, 19 Oct 2020 15:11:19 -0500 Subject: [PATCH 035/242] update to incorporate requested changes, change approve to 201 response. --- awx/api/views/__init__.py | 2 +- awx_collection/plugins/module_utils/tower_api.py | 12 ++---------- .../modules/tower_workflow_job_template_node.py | 2 +- 3 files changed, 4 insertions(+), 12 deletions(-) diff --git a/awx/api/views/__init__.py b/awx/api/views/__init__.py index 4f436c8f0e..7b97d82b66 100644 --- a/awx/api/views/__init__.py +++ b/awx/api/views/__init__.py @@ -3043,7 +3043,7 @@ class WorkflowJobTemplateNodeCreateApproval(RetrieveAPIView): approval_template, context=self.get_serializer_context() ).data - return Response(data, status=status.HTTP_200_OK) + return Response(data, status=status.HTTP_201_OK) def check_permissions(self, request): obj = self.get_object().workflow_job_template diff --git a/awx_collection/plugins/module_utils/tower_api.py b/awx_collection/plugins/module_utils/tower_api.py index 643fbd05fc..5c0da5a8cb 100644 --- a/awx_collection/plugins/module_utils/tower_api.py +++ b/awx_collection/plugins/module_utils/tower_api.py @@ -382,7 +382,7 @@ class TowerAPIModule(TowerModule): if auto_exit: self.exit_json(**self.json_output) else: - return None + return self.json_output def modify_associations(self, association_endpoint, new_association_list): # if we got None instead of [] we are not modifying the association_list @@ -438,16 +438,8 @@ class TowerAPIModule(TowerModule): response = self.post_endpoint(endpoint, **{'data': new_item}) - if response['status_code'] == 201: - self.json_output['name'] = 'unknown' - for key in ('name', 'username', 'identifier', 'hostname'): - if key in response['json']: - self.json_output['name'] = response['json'][key] - self.json_output['id'] = response['json']['id'] - self.json_output['changed'] = True - item_url = response['json']['url'] # 200 is response from approval node creation - elif response['status_code'] == 200 and item_type == 'workflow_job_template_approval_node': + if response['status_code'] == 201: self.json_output['name'] = 'unknown' for key in ('name', 'username', 'identifier', 'hostname'): if key in response['json']: diff --git a/awx_collection/plugins/modules/tower_workflow_job_template_node.py b/awx_collection/plugins/modules/tower_workflow_job_template_node.py index 1718af91e8..5fb6b03e53 100644 --- a/awx_collection/plugins/modules/tower_workflow_job_template_node.py +++ b/awx_collection/plugins/modules/tower_workflow_job_template_node.py @@ -319,7 +319,7 @@ def main(): # Due to not able to lookup workflow_approval_templates, find the existing item in another place if workflow_job_template_node['related'].get('unified_job_template') is not None: existing_item = module.get_endpoint(workflow_job_template_node['related']['unified_job_template'])['json'] - approval_endpoint = 'workflow_job_template_nodes/' + str(workflow_job_template_node_id) + '/create_approval_template/' + approval_endpoint = 'workflow_job_template_nodes/{0}/create_approval_template/'.format(workflow_job_template_node_id) module.create_or_update_if_needed( existing_item, new_fields, endpoint=approval_endpoint, item_type='workflow_job_template_approval_node', From 5959809feddaa355020ab93bbff5cccbb4daa166 Mon Sep 17 00:00:00 2001 From: sean-m-sullivan Date: Mon, 19 Oct 2020 15:19:01 -0500 Subject: [PATCH 036/242] make workflow approval creation return an HTTP 201, not 200 OK --- awx/api/views/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/awx/api/views/__init__.py b/awx/api/views/__init__.py index 7b97d82b66..11df2b2089 100644 --- a/awx/api/views/__init__.py +++ b/awx/api/views/__init__.py @@ -3043,7 +3043,7 @@ class WorkflowJobTemplateNodeCreateApproval(RetrieveAPIView): approval_template, context=self.get_serializer_context() ).data - return Response(data, status=status.HTTP_201_OK) + return Response(data, status=status.HTTP_201_CREATED) def check_permissions(self, request): obj = self.get_object().workflow_job_template From 57b2cd402be50dcdc5a27b3f5922768f38a628da Mon Sep 17 00:00:00 2001 From: Ryan Petrello Date: Mon, 19 Oct 2020 16:04:06 -0400 Subject: [PATCH 037/242] make workflow approval creation return an HTTP 201, not 200 OK see: https://github.com/ansible/awx/pull/8364/files/e16a910062290674e19e7c2bf550d5e5a0e4ec99#diff-67c0fe4fc2a405ad611e42d1457e8aa5 --- awx/api/views/__init__.py | 2 +- awx/main/tests/functional/api/test_workflow_node.py | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/awx/api/views/__init__.py b/awx/api/views/__init__.py index 4f436c8f0e..11df2b2089 100644 --- a/awx/api/views/__init__.py +++ b/awx/api/views/__init__.py @@ -3043,7 +3043,7 @@ class WorkflowJobTemplateNodeCreateApproval(RetrieveAPIView): approval_template, context=self.get_serializer_context() ).data - return Response(data, status=status.HTTP_200_OK) + return Response(data, status=status.HTTP_201_CREATED) def check_permissions(self, request): obj = self.get_object().workflow_job_template diff --git a/awx/main/tests/functional/api/test_workflow_node.py b/awx/main/tests/functional/api/test_workflow_node.py index ec70716f94..6253548d60 100644 --- a/awx/main/tests/functional/api/test_workflow_node.py +++ b/awx/main/tests/functional/api/test_workflow_node.py @@ -89,7 +89,7 @@ class TestApprovalNodes(): url = reverse('api:workflow_job_template_node_create_approval', kwargs={'pk': approval_node.pk, 'version': 'v2'}) post(url, {'name': 'Test', 'description': 'Approval Node', 'timeout': 0}, - user=admin_user, expect=200) + user=admin_user, expect=201) approval_node = WorkflowJobTemplateNode.objects.get(pk=approval_node.pk) assert isinstance(approval_node.unified_job_template, WorkflowApprovalTemplate) @@ -108,9 +108,9 @@ class TestApprovalNodes(): assert {'name': ['This field may not be blank.']} == json.loads(r.content) @pytest.mark.parametrize("is_admin, is_org_admin, status", [ - [True, False, 200], # if they're a WFJT admin, they get a 200 + [True, False, 201], # if they're a WFJT admin, they get a 201 [False, False, 403], # if they're not a WFJT *nor* org admin, they get a 403 - [False, True, 200], # if they're an organization admin, they get a 200 + [False, True, 201], # if they're an organization admin, they get a 201 ]) def test_approval_node_creation_rbac(self, post, approval_node, alice, is_admin, is_org_admin, status): url = reverse('api:workflow_job_template_node_create_approval', @@ -165,7 +165,7 @@ class TestApprovalNodes(): url = reverse('api:workflow_job_template_node_create_approval', kwargs={'pk': node.pk, 'version': 'v2'}) post(url, {'name': 'Approve Test', 'description': '', 'timeout': 0}, - user=admin_user, expect=200) + user=admin_user, expect=201) post(reverse('api:workflow_job_template_launch', kwargs={'pk': wfjt.pk}), user=admin_user, expect=201) wf_job = WorkflowJob.objects.first() @@ -195,7 +195,7 @@ class TestApprovalNodes(): url = reverse('api:workflow_job_template_node_create_approval', kwargs={'pk': node.pk, 'version': 'v2'}) post(url, {'name': 'Deny Test', 'description': '', 'timeout': 0}, - user=admin_user, expect=200) + user=admin_user, expect=201) post(reverse('api:workflow_job_template_launch', kwargs={'pk': wfjt.pk}), user=admin_user, expect=201) wf_job = WorkflowJob.objects.first() From 23c386223ca187e136c256717fb20b24e7360c37 Mon Sep 17 00:00:00 2001 From: Nicolas G Date: Mon, 19 Oct 2020 16:33:54 -0400 Subject: [PATCH 038/242] Add Kubernetes Deployment support for annotations Annotations are only supported for ingress and service accounts This PR will allow you now to specify annotations for Kubernetes Deployment resources by defining `kubernetes_deployment_annotations` var list --- installer/roles/kubernetes/templates/deployment.yml.j2 | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/installer/roles/kubernetes/templates/deployment.yml.j2 b/installer/roles/kubernetes/templates/deployment.yml.j2 index 4cc3bf25c7..95f6396222 100644 --- a/installer/roles/kubernetes/templates/deployment.yml.j2 +++ b/installer/roles/kubernetes/templates/deployment.yml.j2 @@ -85,6 +85,12 @@ kind: Deployment metadata: name: {{ kubernetes_deployment_name }} namespace: {{ kubernetes_namespace }} +{% if kubernetes_deployment_annotations is defined %} + annotations: +{% for key, value in kubernetes_deployment_annotations.items() %} + {{ key }}: {{ value }} +{% endfor %} +{% endif %} {% if openshift_host is defined %} labels: app: {{ kubernetes_deployment_name }} From a8159c0391d81a8047147ea41ed696bdd3b39173 Mon Sep 17 00:00:00 2001 From: sean-m-sullivan Date: Mon, 19 Oct 2020 15:35:36 -0500 Subject: [PATCH 039/242] revert check --- awx/api/views/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/awx/api/views/__init__.py b/awx/api/views/__init__.py index 11df2b2089..7b97d82b66 100644 --- a/awx/api/views/__init__.py +++ b/awx/api/views/__init__.py @@ -3043,7 +3043,7 @@ class WorkflowJobTemplateNodeCreateApproval(RetrieveAPIView): approval_template, context=self.get_serializer_context() ).data - return Response(data, status=status.HTTP_201_CREATED) + return Response(data, status=status.HTTP_201_OK) def check_permissions(self, request): obj = self.get_object().workflow_job_template From fe55dca661ff5a6443ca359147b2e32132b6be21 Mon Sep 17 00:00:00 2001 From: sean-m-sullivan Date: Mon, 19 Oct 2020 15:55:49 -0500 Subject: [PATCH 040/242] make workflow approval creation return an HTTP 201, not 200 OK --- awx/api/views/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/awx/api/views/__init__.py b/awx/api/views/__init__.py index 7b97d82b66..11df2b2089 100644 --- a/awx/api/views/__init__.py +++ b/awx/api/views/__init__.py @@ -3043,7 +3043,7 @@ class WorkflowJobTemplateNodeCreateApproval(RetrieveAPIView): approval_template, context=self.get_serializer_context() ).data - return Response(data, status=status.HTTP_201_OK) + return Response(data, status=status.HTTP_201_CREATED) def check_permissions(self, request): obj = self.get_object().workflow_job_template From dc2658046673a0dd5b05f602fd2cea803ac98022 Mon Sep 17 00:00:00 2001 From: Sean Sullivan Date: Mon, 19 Oct 2020 19:46:49 -0500 Subject: [PATCH 041/242] Update to response code set response code for current versions of tower/awx --- awx_collection/plugins/module_utils/tower_api.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/awx_collection/plugins/module_utils/tower_api.py b/awx_collection/plugins/module_utils/tower_api.py index cfda5b20ab..7439cbfb8f 100644 --- a/awx_collection/plugins/module_utils/tower_api.py +++ b/awx_collection/plugins/module_utils/tower_api.py @@ -438,8 +438,8 @@ class TowerAPIModule(TowerModule): response = self.post_endpoint(endpoint, **{'data': new_item}) - # 200 is response from approval node creation - if response['status_code'] == 201: + # 200 is response from approval node creation on tower 3.7.3 or awx 15.0.0 or earlier. + if response['status_code'] in [200, 201]: self.json_output['name'] = 'unknown' for key in ('name', 'username', 'identifier', 'hostname'): if key in response['json']: From 84cb7be07914cd6b5f939459ffcddc02bbeef341 Mon Sep 17 00:00:00 2001 From: Chris Meyers Date: Mon, 19 Oct 2020 10:24:50 -0400 Subject: [PATCH 042/242] fill in postgres application_name on connection * Tried to fill in application_name in awx/__init__.py but I think that is too late * Fill in database application_name with enough information to easily trace the connection from postgres back to the node and pid that initiated the connection. * Set application_name in django settings so that application_name is set _before_ the first postgres connection is established. --- awx/main/management/commands/check_migrations.py | 2 ++ awx/settings/development.py | 3 +++ awx/settings/production.py | 3 +++ 3 files changed, 8 insertions(+) diff --git a/awx/main/management/commands/check_migrations.py b/awx/main/management/commands/check_migrations.py index 50ea354960..6f9cfc7727 100644 --- a/awx/main/management/commands/check_migrations.py +++ b/awx/main/management/commands/check_migrations.py @@ -8,5 +8,7 @@ class Command(MakeMigrations): def execute(self, *args, **options): settings = connections['default'].settings_dict.copy() settings['ENGINE'] = 'sqlite3' + if 'application_name' in settings['OPTIONS']: + del settings['OPTIONS']['application_name'] connections['default'] = DatabaseWrapper(settings) return MakeMigrations().execute(*args, **options) diff --git a/awx/settings/development.py b/awx/settings/development.py index 3a4e008488..108767b98c 100644 --- a/awx/settings/development.py +++ b/awx/settings/development.py @@ -184,3 +184,6 @@ else: pass AWX_CALLBACK_PROFILE = True + +if 'sqlite3' not in DATABASES['default']['ENGINE']: # noqa + DATABASES['default'].setdefault('OPTIONS', dict()).setdefault('application_name', f'{CLUSTER_HOST_ID}-{os.getpid()}-{" ".join(sys.argv)}'[:63]) # noqa diff --git a/awx/settings/production.py b/awx/settings/production.py index c2cde28c0f..fb24b7087f 100644 --- a/awx/settings/production.py +++ b/awx/settings/production.py @@ -102,6 +102,7 @@ except IOError: else: raise +# The below runs AFTER all of the custom settings are imported. CELERYBEAT_SCHEDULE.update({ # noqa 'isolated_heartbeat': { @@ -110,3 +111,5 @@ CELERYBEAT_SCHEDULE.update({ # noqa 'options': {'expires': AWX_ISOLATED_PERIODIC_CHECK * 2}, # noqa } }) + +DATABASES['default'].setdefault('OPTIONS', dict()).setdefault('application_name', f'{CLUSTER_HOST_ID}-{os.getpid()}-{" ".join(sys.argv)}'[:63]) # noqa From 67a5ad7dd68461a0f99c9606c2f1cbe464ccc0dd Mon Sep 17 00:00:00 2001 From: Ryan Petrello Date: Mon, 19 Oct 2020 13:53:29 -0400 Subject: [PATCH 043/242] Bump version to 15.0.1 --- CHANGELOG.md | 16 ++++++++++++++++ VERSION | 2 +- awxkit/VERSION | 2 +- 3 files changed, 18 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9f04a26ae3..c50243b1e8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,22 @@ This is a list of high-level changes for each release of AWX. A full list of commits can be found at `https://github.com/ansible/awx/releases/tag/`. +## 15.0.1 (October 20, 2020) +- Added several optimizations to improve performance for a variety of high-load simultaneous job launch use cases https://github.com/ansible/awx/pull/8403 +- Added the ability to source roles and collections from requirements.yaml files (not just requirements.yml) - https://github.com/ansible/awx/issues/4540 +- awx.awx collection modules now provide a clearer error message for incompatible versions of awxkit - https://github.com/ansible/awx/issues/8127 +- Fixed a bug in notification messages that contain certain unicode characters - https://github.com/ansible/awx/issues/7400 +- Fixed a bug that prevents the deletion of Workflow Approval records - https://github.com/ansible/awx/issues/8305 +- Fixed a bug that broke the selection of webhook credentials - https://github.com/ansible/awx/issues/7892 +- Fixed a bug which can cause confusing behavior for social auth logins across distinct browser tabs - https://github.com/ansible/awx/issues/8154 +- Fixed several bugs in the output of Workflow Job Templates using the `awx export` tool - https://github.com/ansible/awx/issues/7798 https://github.com/ansible/awx/pull/7847 +- Fixed a race condition that can lead to missing hosts when running parallel inventory syncs - https://github.com/ansible/awx/issues/5571 +- Fixed an HTTP 500 error when certain LDAP group parameters aren't properly set - https://github.com/ansible/awx/issues/7622 +- Updated a few dependencies in response to several CVEs: + * CVE-2020-7720 + * CVE-2020-7743 + * CVE-2020-7676 + ## 15.0.0 (September 30, 2020) - Added improved support for fetching Ansible collections from private Galaxy content sources (such as https://github.com/ansible/galaxy_ng) - https://github.com/ansible/awx/issues/7813 **Note:** as part of this change, new Organizations created in the AWX API will _no longer_ automatically synchronize roles and collections from galaxy.ansible.com by default. More details on this change can be found at: https://github.com/ansible/awx/issues/8341#issuecomment-707310633 diff --git a/VERSION b/VERSION index 94188a7483..2bbd2b4b42 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -15.0.0 +15.0.1 diff --git a/awxkit/VERSION b/awxkit/VERSION index 94188a7483..2bbd2b4b42 100644 --- a/awxkit/VERSION +++ b/awxkit/VERSION @@ -1 +1 @@ -15.0.0 +15.0.1 From 7a9b55c21b52b33677bbbabaa725a10e9053c884 Mon Sep 17 00:00:00 2001 From: mabashian Date: Tue, 20 Oct 2020 10:31:30 -0400 Subject: [PATCH 044/242] Replace SETTINGS > SYSTEM with SETTINGS > SYSTEM to get around issue with translating this string --- awx/ui/client/src/license/license.partial.html | 2 +- awx/ui/po/ansible-tower-ui.pot | 2 +- awx/ui/po/es.po | 4 ++-- awx/ui/po/fr.po | 4 ++-- awx/ui/po/ja.po | 4 ++-- awx/ui/po/nl.po | 4 ++-- awx/ui/po/zh.po | 4 ++-- 7 files changed, 12 insertions(+), 12 deletions(-) diff --git a/awx/ui/client/src/license/license.partial.html b/awx/ui/client/src/license/license.partial.html index 9a4a9a80f7..2dc7beeb9c 100644 --- a/awx/ui/client/src/license/license.partial.html +++ b/awx/ui/client/src/license/license.partial.html @@ -125,7 +125,7 @@
- Provide your Red Hat customer credentials and you can choose from a list of your available licenses. The credentials you use will be stored for future use in retrieving renewal or expanded licenses. You can update or remove them in SETTINGS > SYSTEM. + Provide your Red Hat customer credentials and you can choose from a list of your available licenses. The credentials you use will be stored for future use in retrieving renewal or expanded licenses. You can update or remove them in SETTINGS > SYSTEM.
diff --git a/awx/ui/po/ansible-tower-ui.pot b/awx/ui/po/ansible-tower-ui.pot index f3fb4fe286..5da8733512 100644 --- a/awx/ui/po/ansible-tower-ui.pot +++ b/awx/ui/po/ansible-tower-ui.pot @@ -5070,7 +5070,7 @@ msgid "Provide environment variables to pass to the custom inventory script." msgstr "" #: client/src/license/license.partial.html:128 -msgid "Provide your Red Hat customer credentials and you can choose from a list of your available licenses. The credentials you use will be stored for future use in retrieving renewal or expanded licenses. You can update or remove them in SETTINGS > SYSTEM." +msgid "Provide your Red Hat customer credentials and you can choose from a list of your available licenses. The credentials you use will be stored for future use in retrieving renewal or expanded licenses. You can update or remove them in SETTINGS > SYSTEM." msgstr "" #: client/src/templates/job_templates/job-template.form.js:374 diff --git a/awx/ui/po/es.po b/awx/ui/po/es.po index c70c66ee02..fdc1229418 100644 --- a/awx/ui/po/es.po +++ b/awx/ui/po/es.po @@ -5179,8 +5179,8 @@ msgid "Provide the named URL encoded name or id of the remote Tower inventory to msgstr "Indique la URL, el nombre cifrado o id del inventario remoto de Tower para importarlos." #: client/src/license/license.partial.html:128 -msgid "Provide your Red Hat customer credentials and you can choose from a list of your available licenses. The credentials you use will be stored for future use in retrieving renewal or expanded licenses. You can update or remove them in SETTINGS > SYSTEM." -msgstr "Proporcione sus credenciales de cliente de Red Hat y podrá elegir de una lista de sus licencias disponibles. Las credenciales que utilice se almacenarán para su uso futuro en la recuperación de las licencias de renovación o ampliadas. Puede actualizarlas o eliminarlas en CONFIGURACIÓN > SISTEMA." +msgid "Provide your Red Hat customer credentials and you can choose from a list of your available licenses. The credentials you use will be stored for future use in retrieving renewal or expanded licenses. You can update or remove them in SETTINGS > SYSTEM." +msgstr "Proporcione sus credenciales de cliente de Red Hat y podrá elegir de una lista de sus licencias disponibles. Las credenciales que utilice se almacenarán para su uso futuro en la recuperación de las licencias de renovación o ampliadas. Puede actualizarlas o eliminarlas en CONFIGURACIÓN > SISTEMA." #: client/src/templates/job_templates/job-template.form.js:374 #: client/src/templates/job_templates/job-template.form.js:382 diff --git a/awx/ui/po/fr.po b/awx/ui/po/fr.po index 59ed681256..513c1718aa 100644 --- a/awx/ui/po/fr.po +++ b/awx/ui/po/fr.po @@ -5185,8 +5185,8 @@ msgid "Provide the named URL encoded name or id of the remote Tower inventory to msgstr "Fournir le nom encodé de l'URL ou d'id de l'inventaire distant de Tower à importer." #: client/src/license/license.partial.html:128 -msgid "Provide your Red Hat customer credentials and you can choose from a list of your available licenses. The credentials you use will be stored for future use in retrieving renewal or expanded licenses. You can update or remove them in SETTINGS > SYSTEM." -msgstr "Fournissez vos informations d’identification client Red Hat et choisissez parmi une liste de licences disponibles pour vous. Les informations d'identification que vous utilisez seront stockées pour une utilisation ultérieure lors de la récupération des licences renouvelées ou étendues. Vous pouvez les mettre à jour ou les supprimer dans PARAMÈTRES > SYSTÈME." +msgid "Provide your Red Hat customer credentials and you can choose from a list of your available licenses. The credentials you use will be stored for future use in retrieving renewal or expanded licenses. You can update or remove them in SETTINGS > SYSTEM." +msgstr "Fournissez vos informations d’identification client Red Hat et choisissez parmi une liste de licences disponibles pour vous. Les informations d'identification que vous utilisez seront stockées pour une utilisation ultérieure lors de la récupération des licences renouvelées ou étendues. Vous pouvez les mettre à jour ou les supprimer dans PARAMÈTRES > SYSTÈME." #: client/src/templates/job_templates/job-template.form.js:374 #: client/src/templates/job_templates/job-template.form.js:382 diff --git a/awx/ui/po/ja.po b/awx/ui/po/ja.po index 617c5c66ca..9c04b6160a 100644 --- a/awx/ui/po/ja.po +++ b/awx/ui/po/ja.po @@ -5102,8 +5102,8 @@ msgid "Provide environment variables to pass to the custom inventory script." msgstr "カスタムインベントリースクリプトに渡す環境変数を指定します。" #: client/src/license/license.partial.html:128 -msgid "Provide your Red Hat customer credentials and you can choose from a list of your available licenses. The credentials you use will be stored for future use in retrieving renewal or expanded licenses. You can update or remove them in SETTINGS > SYSTEM." -msgstr "Red Hat の顧客認証情報を指定して、利用可能なライセンス一覧から選択してください。使用した認証情報は、今後、ライセンスの更新や延長情報を取得する時に利用できるように保存されます。設定 > システムでこの情報は更新または削除できます。" +msgid "Provide your Red Hat customer credentials and you can choose from a list of your available licenses. The credentials you use will be stored for future use in retrieving renewal or expanded licenses. You can update or remove them in SETTINGS > SYSTEM." +msgstr "Red Hat の顧客認証情報を指定して、利用可能なライセンス一覧から選択してください。使用した認証情報は、今後、ライセンスの更新や延長情報を取得する時に利用できるように保存されます。設定 > システムでこの情報は更新または削除できます。" #: client/src/templates/job_templates/job-template.form.js:374 #: client/src/templates/job_templates/job-template.form.js:382 diff --git a/awx/ui/po/nl.po b/awx/ui/po/nl.po index 721a28368c..c311590856 100644 --- a/awx/ui/po/nl.po +++ b/awx/ui/po/nl.po @@ -5183,8 +5183,8 @@ msgid "Provide the named URL encoded name or id of the remote Tower inventory to msgstr "Voer de URL, versleutelde naam of ID of de externe inventaris in die geïmporteerd moet worden." #: client/src/license/license.partial.html:128 -msgid "Provide your Red Hat customer credentials and you can choose from a list of your available licenses. The credentials you use will be stored for future use in retrieving renewal or expanded licenses. You can update or remove them in SETTINGS > SYSTEM." -msgstr "Geef uw Red Hat-klantengegevens door en u kunt kiezen uit een lijst met beschikbare licenties. De toegangsgegevens die u gebruikt, worden opgeslagen voor toekomstig gebruik bij het ophalen van verlengingen of uitbreidingen van licenties. U kunt deze updaten of verwijderen in INSTELLINGEN > SYSTEEM." +msgid "Provide your Red Hat customer credentials and you can choose from a list of your available licenses. The credentials you use will be stored for future use in retrieving renewal or expanded licenses. You can update or remove them in SETTINGS > SYSTEM." +msgstr "Geef uw Red Hat-klantengegevens door en u kunt kiezen uit een lijst met beschikbare licenties. De toegangsgegevens die u gebruikt, worden opgeslagen voor toekomstig gebruik bij het ophalen van verlengingen of uitbreidingen van licenties. U kunt deze updaten of verwijderen in INSTELLINGEN > SYSTEEM." #: client/src/templates/job_templates/job-template.form.js:374 #: client/src/templates/job_templates/job-template.form.js:382 diff --git a/awx/ui/po/zh.po b/awx/ui/po/zh.po index 92348c9c6e..957b5132f1 100644 --- a/awx/ui/po/zh.po +++ b/awx/ui/po/zh.po @@ -5183,8 +5183,8 @@ msgid "Provide the named URL encoded name or id of the remote Tower inventory to msgstr "提供要导入的远程 Tower 清单的命名 URL 编码名称或 ID。" #: client/src/license/license.partial.html:128 -msgid "Provide your Red Hat customer credentials and you can choose from a list of your available licenses. The credentials you use will be stored for future use in retrieving renewal or expanded licenses. You can update or remove them in SETTINGS > SYSTEM." -msgstr "提供您的红帽客户凭证,您可以从可用许可证列表中进行选择。您使用的凭证将存储以供将来用于检索续订或扩展许可证。您可以在“设置”>“系统”中更新或删除它们。" +msgid "Provide your Red Hat customer credentials and you can choose from a list of your available licenses. The credentials you use will be stored for future use in retrieving renewal or expanded licenses. You can update or remove them in SETTINGS > SYSTEM." +msgstr "提供您的红帽客户凭证,您可以从可用许可证列表中进行选择。您使用的凭证将存储以供将来用于检索续订或扩展许可证。您可以在“设置”>“系统”中更新或删除它们。" #: client/src/templates/job_templates/job-template.form.js:374 #: client/src/templates/job_templates/job-template.form.js:382 From 03c7504d2bc0f01cab68ca85d7b661c74f904448 Mon Sep 17 00:00:00 2001 From: sean-m-sullivan Date: Tue, 20 Oct 2020 09:40:45 -0500 Subject: [PATCH 045/242] fix existing item error --- .../plugins/modules/tower_workflow_job_template_node.py | 1 + awx_collection/test/awx/test_workflow_job_template_node.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/awx_collection/plugins/modules/tower_workflow_job_template_node.py b/awx_collection/plugins/modules/tower_workflow_job_template_node.py index 5fb6b03e53..14e02d6f70 100644 --- a/awx_collection/plugins/modules/tower_workflow_job_template_node.py +++ b/awx_collection/plugins/modules/tower_workflow_job_template_node.py @@ -316,6 +316,7 @@ def main(): workflow_job_template_node = module.get_one('workflow_job_template_nodes', **{'data': search_fields}) workflow_job_template_node_id = workflow_job_template_node['id'] module.json_output['workflow_node_id'] = workflow_job_template_node_id + existing_item = None # Due to not able to lookup workflow_approval_templates, find the existing item in another place if workflow_job_template_node['related'].get('unified_job_template') is not None: existing_item = module.get_endpoint(workflow_job_template_node['related']['unified_job_template'])['json'] diff --git a/awx_collection/test/awx/test_workflow_job_template_node.py b/awx_collection/test/awx/test_workflow_job_template_node.py index 1641aaf153..6127fde27e 100644 --- a/awx_collection/test/awx/test_workflow_job_template_node.py +++ b/awx_collection/test/awx/test_workflow_job_template_node.py @@ -53,7 +53,7 @@ def test_create_workflow_job_template_node(run_module, admin_user, wfjt, job_tem @pytest.mark.django_db -def test_create_workflow_job_template_node_no_template(run_module, admin_user, wfjt, job_template): +def test_create_workflow_job_template_node_approval_node(run_module, admin_user, wfjt, job_template): """This is a part of the API contract for creating approval nodes""" this_identifier = '42🐉' result = run_module('tower_workflow_job_template_node', { From 485cee56bc2995c6c9219c2f275f6ebe258a80ea Mon Sep 17 00:00:00 2001 From: mabashian Date: Thu, 15 Oct 2020 16:24:40 -0400 Subject: [PATCH 046/242] Fix issues with prop types that were causing errors to be logged during tests --- .../src/components/AdHocCommands/AdHocCommandsWizard.jsx | 3 ++- awx/ui_next/src/components/AdHocCommands/AdHocDetailsStep.jsx | 3 ++- .../src/screens/Inventory/InventoryHosts/InventoryHostList.jsx | 2 +- awx/ui_next/src/screens/Template/shared/JobTemplateForm.jsx | 1 + 4 files changed, 6 insertions(+), 3 deletions(-) diff --git a/awx/ui_next/src/components/AdHocCommands/AdHocCommandsWizard.jsx b/awx/ui_next/src/components/AdHocCommands/AdHocCommandsWizard.jsx index 83eee59d88..45d7426d30 100644 --- a/awx/ui_next/src/components/AdHocCommands/AdHocCommandsWizard.jsx +++ b/awx/ui_next/src/components/AdHocCommands/AdHocCommandsWizard.jsx @@ -134,7 +134,8 @@ const FormikApp = withFormik({ FormikApp.propTypes = { onLaunch: PropTypes.func.isRequired, - moduleOptions: PropTypes.arrayOf(PropTypes.object).isRequired, + moduleOptions: PropTypes.arrayOf(PropTypes.arrayOf(PropTypes.string)) + .isRequired, verbosityOptions: PropTypes.arrayOf(PropTypes.object).isRequired, onCloseWizard: PropTypes.func.isRequired, credentialTypeId: PropTypes.number.isRequired, diff --git a/awx/ui_next/src/components/AdHocCommands/AdHocDetailsStep.jsx b/awx/ui_next/src/components/AdHocCommands/AdHocDetailsStep.jsx index bd5fdab92c..47bc583bf3 100644 --- a/awx/ui_next/src/components/AdHocCommands/AdHocDetailsStep.jsx +++ b/awx/ui_next/src/components/AdHocCommands/AdHocDetailsStep.jsx @@ -316,7 +316,8 @@ function AdHocDetailsStep({ i18n, verbosityOptions, moduleOptions }) { } AdHocDetailsStep.propTypes = { - moduleOptions: PropTypes.arrayOf(PropTypes.object).isRequired, + moduleOptions: PropTypes.arrayOf(PropTypes.arrayOf(PropTypes.string)) + .isRequired, verbosityOptions: PropTypes.arrayOf(PropTypes.object).isRequired, }; diff --git a/awx/ui_next/src/screens/Inventory/InventoryHosts/InventoryHostList.jsx b/awx/ui_next/src/screens/Inventory/InventoryHosts/InventoryHostList.jsx index bf25eca01f..73bd3516b0 100644 --- a/awx/ui_next/src/screens/Inventory/InventoryHosts/InventoryHostList.jsx +++ b/awx/ui_next/src/screens/Inventory/InventoryHosts/InventoryHostList.jsx @@ -231,7 +231,7 @@ function InventoryHostList({ i18n }) { onClose={() => setIsAdHocCommandsOpen(false)} credentialTypeId={credentialTypeId} moduleOptions={moduleOptions} - itemId={id} + itemId={parseInt(id, 10)} /> )} {deletionError && ( diff --git a/awx/ui_next/src/screens/Template/shared/JobTemplateForm.jsx b/awx/ui_next/src/screens/Template/shared/JobTemplateForm.jsx index f89ba0636b..9afae3ca1d 100644 --- a/awx/ui_next/src/screens/Template/shared/JobTemplateForm.jsx +++ b/awx/ui_next/src/screens/Template/shared/JobTemplateForm.jsx @@ -239,6 +239,7 @@ function JobTemplateForm({ isRequired={!askInventoryOnLaunchField.value} > Date: Mon, 31 Aug 2020 15:23:32 -0400 Subject: [PATCH 047/242] Add galaxy credentials field to organizations form --- awx/ui_next/src/api/models/Organizations.js | 19 + .../components/Lookup/CredentialLookup.jsx | 17 +- .../src/screens/Organization/Organization.jsx | 383 +++++++++--------- .../Organization/Organization.test.jsx | 72 ++-- .../OrganizationAdd/OrganizationAdd.jsx | 10 +- .../OrganizationAdd/OrganizationAdd.test.jsx | 41 +- .../OrganizationDetail/OrganizationDetail.jsx | 19 + .../OrganizationEdit/OrganizationEdit.jsx | 37 +- .../screens/Organization/Organizations.jsx | 4 +- .../Organization/shared/OrganizationForm.jsx | 30 +- .../shared/OrganizationForm.test.jsx | 3 + 11 files changed, 400 insertions(+), 235 deletions(-) diff --git a/awx/ui_next/src/api/models/Organizations.js b/awx/ui_next/src/api/models/Organizations.js index 76c15504dc..ce236067b4 100644 --- a/awx/ui_next/src/api/models/Organizations.js +++ b/awx/ui_next/src/api/models/Organizations.js @@ -24,6 +24,12 @@ class Organizations extends InstanceGroupsMixin(NotificationsMixin(Base)) { return this.http.options(`${this.baseUrl}${id}/teams/`); } + readGalaxyCredentials(id, params) { + return this.http.get(`${this.baseUrl}${id}/galaxy_credentials/`, { + params, + }); + } + createUser(id, data) { return this.http.post(`${this.baseUrl}${id}/users/`, data); } @@ -48,6 +54,19 @@ class Organizations extends InstanceGroupsMixin(NotificationsMixin(Base)) { { id: notificationId, disassociate: true } ); } + + associateGalaxyCredential(resourceId, credentialId) { + return this.http.post(`${this.baseUrl}${resourceId}/galaxy_credentials/`, { + id: credentialId, + }); + } + + disassociateGalaxyCredential(resourceId, credentialId) { + return this.http.post(`${this.baseUrl}${resourceId}/galaxy_credentials/`, { + id: credentialId, + disassociate: true, + }); + } } export default Organizations; diff --git a/awx/ui_next/src/components/Lookup/CredentialLookup.jsx b/awx/ui_next/src/components/Lookup/CredentialLookup.jsx index 43440d0d22..019fa0e573 100644 --- a/awx/ui_next/src/components/Lookup/CredentialLookup.jsx +++ b/awx/ui_next/src/components/Lookup/CredentialLookup.jsx @@ -1,5 +1,13 @@ import React, { useCallback, useEffect } from 'react'; -import { bool, func, node, number, string, oneOfType } from 'prop-types'; +import { + arrayOf, + bool, + func, + node, + number, + string, + oneOfType, +} from 'prop-types'; import { withRouter } from 'react-router-dom'; import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; @@ -36,6 +44,7 @@ function CredentialLookup({ tooltip, isDisabled, autoPopulate, + multiple, }) { const autoPopulateLookup = useAutoPopulateLookup(onChange); const { @@ -120,6 +129,7 @@ function CredentialLookup({ required={required} qsConfig={QS_CONFIG} isDisabled={isDisabled} + multiple={multiple} renderOptionsList={({ state, dispatch, canDelete }) => ( dispatch({ type: 'SELECT_ITEM', item })} deselectItem={item => dispatch({ type: 'DESELECT_ITEM', item })} + multiple={multiple} /> )} /> @@ -188,10 +199,11 @@ CredentialLookup.propTypes = { helperTextInvalid: node, isValid: bool, label: string.isRequired, + multiple: bool, onBlur: func, onChange: func.isRequired, required: bool, - value: Credential, + value: oneOfType([Credential, arrayOf(Credential)]), isDisabled: bool, autoPopulate: bool, }; @@ -201,6 +213,7 @@ CredentialLookup.defaultProps = { credentialTypeKind: '', helperTextInvalid: '', isValid: true, + multiple: false, onBlur: () => {}, required: false, value: null, diff --git a/awx/ui_next/src/screens/Organization/Organization.jsx b/awx/ui_next/src/screens/Organization/Organization.jsx index d16113d8dc..1a2ee641c9 100644 --- a/awx/ui_next/src/screens/Organization/Organization.jsx +++ b/awx/ui_next/src/screens/Organization/Organization.jsx @@ -1,9 +1,19 @@ -import React, { Component } from 'react'; +import React, { useCallback, useEffect, useRef } from 'react'; import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; -import { Switch, Route, withRouter, Redirect, Link } from 'react-router-dom'; +import { + Switch, + Route, + withRouter, + Redirect, + Link, + useLocation, + useParams, + useRouteMatch, +} from 'react-router-dom'; import { CaretLeftIcon } from '@patternfly/react-icons'; import { Card, PageSection } from '@patternfly/react-core'; +import useRequest from '../../util/useRequest'; import RoutedTabs from '../../components/RoutedTabs'; import ContentError from '../../components/ContentError'; import NotificationList from '../../components/NotificationList/NotificationList'; @@ -13,214 +23,207 @@ import OrganizationEdit from './OrganizationEdit'; import OrganizationTeams from './OrganizationTeams'; import { OrganizationsAPI } from '../../api'; -class Organization extends Component { - constructor(props) { - super(props); +function Organization({ i18n, setBreadcrumb, me }) { + const location = useLocation(); + const { id: organizationId } = useParams(); + const match = useRouteMatch(); + const initialUpdate = useRef(true); - this.state = { - organization: null, - hasContentLoading: true, - contentError: null, - isInitialized: false, - isNotifAdmin: false, - isAuditorOfThisOrg: false, - isAdminOfThisOrg: false, - }; - this.loadOrganization = this.loadOrganization.bind(this); - this.loadOrganizationAndRoles = this.loadOrganizationAndRoles.bind(this); - } - - async componentDidMount() { - await this.loadOrganizationAndRoles(); - this.setState({ isInitialized: true }); - } - - async componentDidUpdate(prevProps) { - const { location, match } = this.props; - const url = `/organizations/${match.params.id}/`; - - if ( - prevProps.location.pathname.startsWith(url) && - prevProps.location !== location && - location.pathname === `${url}details` - ) { - await this.loadOrganization(); - } - } - - async loadOrganizationAndRoles() { - const { match, setBreadcrumb } = this.props; - const id = parseInt(match.params.id, 10); - - this.setState({ contentError: null, hasContentLoading: true }); - try { - const [{ data }, notifAdminRes, auditorRes, adminRes] = await Promise.all( - [ - OrganizationsAPI.readDetail(id), - OrganizationsAPI.read({ - page_size: 1, - role_level: 'notification_admin_role', - }), - OrganizationsAPI.read({ id, role_level: 'auditor_role' }), - OrganizationsAPI.read({ id, role_level: 'admin_role' }), - ] - ); + const { + result: { organization }, + isLoading: organizationLoading, + error: organizationError, + request: loadOrganization, + } = useRequest( + useCallback(async () => { + const [{ data }, credentialsRes] = await Promise.all([ + OrganizationsAPI.readDetail(organizationId), + OrganizationsAPI.readGalaxyCredentials(organizationId), + ]); + data.galaxy_credentials = credentialsRes.data.results; setBreadcrumb(data); - this.setState({ + + return { organization: data, + }; + }, [setBreadcrumb, organizationId]), + { + organization: null, + } + ); + + const { + result: { isNotifAdmin, isAuditorOfThisOrg, isAdminOfThisOrg }, + isLoading: rolesLoading, + error: rolesError, + request: loadRoles, + } = useRequest( + useCallback(async () => { + const [notifAdminRes, auditorRes, adminRes] = await Promise.all([ + OrganizationsAPI.read({ + page_size: 1, + role_level: 'notification_admin_role', + }), + OrganizationsAPI.read({ + id: organizationId, + role_level: 'auditor_role', + }), + OrganizationsAPI.read({ + id: organizationId, + role_level: 'admin_role', + }), + ]); + + return { isNotifAdmin: notifAdminRes.data.results.length > 0, isAuditorOfThisOrg: auditorRes.data.results.length > 0, isAdminOfThisOrg: adminRes.data.results.length > 0, - }); - } catch (err) { - this.setState({ contentError: err }); - } finally { - this.setState({ hasContentLoading: false }); + }; + }, [organizationId]), + { + isNotifAdmin: false, + isAuditorOfThisOrg: false, + isAdminOfThisOrg: false, } + ); + useEffect(() => { + loadOrganization(); + loadRoles(); + }, [loadOrganization, loadRoles]); + + useEffect(() => { + if (initialUpdate.current) { + initialUpdate.current = false; + return; + } + + if (location.pathname === `/organizations/${organizationId}/details`) { + loadOrganization(); + } + }, [loadOrganization, organizationId, location.pathname]); + + const canSeeNotificationsTab = + me.is_system_auditor || isNotifAdmin || isAuditorOfThisOrg; + const canToggleNotifications = + isNotifAdmin && + (me.is_system_auditor || isAuditorOfThisOrg || isAdminOfThisOrg); + + const tabsArray = [ + { + name: ( + <> + + {i18n._(t`Back to Organizations`)} + + ), + link: `/organizations`, + id: 99, + }, + { name: i18n._(t`Details`), link: `${match.url}/details`, id: 0 }, + { name: i18n._(t`Access`), link: `${match.url}/access`, id: 1 }, + { name: i18n._(t`Teams`), link: `${match.url}/teams`, id: 2 }, + ]; + + if (canSeeNotificationsTab) { + tabsArray.push({ + name: i18n._(t`Notifications`), + link: `${match.url}/notifications`, + id: 3, + }); } - async loadOrganization() { - const { match, setBreadcrumb } = this.props; - const id = parseInt(match.params.id, 10); + let showCardHeader = true; - this.setState({ contentError: null, hasContentLoading: true }); - try { - const { data } = await OrganizationsAPI.readDetail(id); - setBreadcrumb(data); - this.setState({ organization: data }); - } catch (err) { - this.setState({ contentError: err }); - } finally { - this.setState({ hasContentLoading: false }); - } + if (location.pathname.endsWith('edit')) { + showCardHeader = false; } - render() { - const { location, match, me, i18n } = this.props; - - const { - organization, - contentError, - hasContentLoading, - isInitialized, - isNotifAdmin, - isAuditorOfThisOrg, - isAdminOfThisOrg, - } = this.state; - - const canSeeNotificationsTab = - me.is_system_auditor || isNotifAdmin || isAuditorOfThisOrg; - const canToggleNotifications = - isNotifAdmin && - (me.is_system_auditor || isAuditorOfThisOrg || isAdminOfThisOrg); - - const tabsArray = [ - { - name: ( - <> - - {i18n._(t`Back to Organizations`)} - - ), - link: `/organizations`, - id: 99, - }, - { name: i18n._(t`Details`), link: `${match.url}/details`, id: 0 }, - { name: i18n._(t`Access`), link: `${match.url}/access`, id: 1 }, - { name: i18n._(t`Teams`), link: `${match.url}/teams`, id: 2 }, - ]; - - if (canSeeNotificationsTab) { - tabsArray.push({ - name: i18n._(t`Notifications`), - link: `${match.url}/notifications`, - id: 3, - }); - } - - let showCardHeader = true; - - if (!isInitialized || location.pathname.endsWith('edit')) { - showCardHeader = false; - } - - if (!hasContentLoading && contentError) { - return ( - - - - {contentError.response.status === 404 && ( - - {i18n._(t`Organization not found.`)}{' '} - - {i18n._(t`View all Organizations.`)} - - - )} - - - - ); - } - + if (!organizationLoading && organizationError) { return ( - {showCardHeader && } - - - {organization && ( - - - + + {organizationError.response.status === 404 && ( + + {i18n._(t`Organization not found.`)}{' '} + + {i18n._(t`View all Organizations.`)} + + )} - {organization && ( - - - - )} - {organization && ( - - - - )} - - - - {canSeeNotificationsTab && ( - - - - )} - - {!hasContentLoading && ( - - {match.params.id && ( - - {i18n._(t`View Organization Details`)} - - )} - - )} - - , - + ); } + + if (!rolesLoading && rolesError) { + return ( + + + + + + ); + } + + return ( + + + {showCardHeader && } + + + {organization && ( + + + + )} + {organization && ( + + + + )} + {organization && ( + + + + )} + + + + {canSeeNotificationsTab && ( + + + + )} + + {!organizationLoading && !rolesLoading && ( + + {match.params.id && ( + + {i18n._(t`View Organization Details`)} + + )} + + )} + + , + + + + ); } export default withI18n()(withRouter(Organization)); diff --git a/awx/ui_next/src/screens/Organization/Organization.test.jsx b/awx/ui_next/src/screens/Organization/Organization.test.jsx index 298ae9188e..10982505d5 100644 --- a/awx/ui_next/src/screens/Organization/Organization.test.jsx +++ b/awx/ui_next/src/screens/Organization/Organization.test.jsx @@ -1,4 +1,5 @@ import React from 'react'; +import { act } from 'react-dom/test-utils'; import { createMemoryHistory } from 'history'; import { OrganizationsAPI } from '../../api'; import { @@ -37,30 +38,44 @@ async function getOrganizations(params) { } describe('', () => { - test('initially renders succesfully', () => { + let wrapper; + + beforeAll(() => { OrganizationsAPI.readDetail.mockResolvedValue({ data: mockOrganization }); + OrganizationsAPI.readGalaxyCredentials.mockResolvedValue({ + data: { + results: [], + }, + }); + }); + + test('initially renders succesfully', async () => { OrganizationsAPI.read.mockImplementation(getOrganizations); - mountWithContexts( {}} me={mockMe} />); + await act(async () => { + mountWithContexts( {}} me={mockMe} />); + }); }); test('notifications tab shown for admins', async done => { - OrganizationsAPI.readDetail.mockResolvedValue({ data: mockOrganization }); OrganizationsAPI.read.mockImplementation(getOrganizations); - const wrapper = mountWithContexts( - {}} me={mockMe} /> - ); + await act(async () => { + wrapper = mountWithContexts( + {}} me={mockMe} /> + ); + }); + const tabs = await waitForElement( wrapper, '.pf-c-tabs__item', el => el.length === 5 ); expect(tabs.last().text()).toEqual('Notifications'); + wrapper.unmount(); done(); }); test('notifications tab hidden with reduced permissions', async done => { - OrganizationsAPI.readDetail.mockResolvedValue({ data: mockOrganization }); OrganizationsAPI.read.mockResolvedValue({ count: 0, next: null, @@ -68,15 +83,19 @@ describe('', () => { data: { results: [] }, }); - const wrapper = mountWithContexts( - {}} me={mockMe} /> - ); + await act(async () => { + wrapper = mountWithContexts( + {}} me={mockMe} /> + ); + }); + const tabs = await waitForElement( wrapper, '.pf-c-tabs__item', el => el.length === 4 ); tabs.forEach(tab => expect(tab.text()).not.toEqual('Notifications')); + wrapper.unmount(); done(); }); @@ -84,24 +103,27 @@ describe('', () => { const history = createMemoryHistory({ initialEntries: ['/organizations/1/foobar'], }); - const wrapper = mountWithContexts( - {}} me={mockMe} />, - { - context: { - router: { - history, - route: { - location: history.location, - match: { - params: { id: 1 }, - url: '/organizations/1/foobar', - path: '/organizations/1/foobar', + await act(async () => { + wrapper = mountWithContexts( + {}} me={mockMe} />, + { + context: { + router: { + history, + route: { + location: history.location, + match: { + params: { id: 1 }, + url: '/organizations/1/foobar', + path: '/organizations/1/foobar', + }, }, }, }, - }, - } - ); + } + ); + }); await waitForElement(wrapper, 'ContentError', el => el.length === 1); + wrapper.unmount(); }); }); diff --git a/awx/ui_next/src/screens/Organization/OrganizationAdd/OrganizationAdd.jsx b/awx/ui_next/src/screens/Organization/OrganizationAdd/OrganizationAdd.jsx index b0831829b0..6cb8c08891 100644 --- a/awx/ui_next/src/screens/Organization/OrganizationAdd/OrganizationAdd.jsx +++ b/awx/ui_next/src/screens/Organization/OrganizationAdd/OrganizationAdd.jsx @@ -16,9 +16,13 @@ function OrganizationAdd() { try { const { data: response } = await OrganizationsAPI.create(values); await Promise.all( - groupsToAssociate.map(id => - OrganizationsAPI.associateInstanceGroup(response.id, id) - ) + groupsToAssociate + .map(id => OrganizationsAPI.associateInstanceGroup(response.id, id)) + .concat( + values.galaxy_credentials.map(({ id: credId }) => + OrganizationsAPI.associateGalaxyCredential(response.id, credId) + ) + ) ); history.push(`/organizations/${response.id}`); } catch (error) { diff --git a/awx/ui_next/src/screens/Organization/OrganizationAdd/OrganizationAdd.test.jsx b/awx/ui_next/src/screens/Organization/OrganizationAdd/OrganizationAdd.test.jsx index 1d579fb85d..ff969b86b5 100644 --- a/awx/ui_next/src/screens/Organization/OrganizationAdd/OrganizationAdd.test.jsx +++ b/awx/ui_next/src/screens/Organization/OrganizationAdd/OrganizationAdd.test.jsx @@ -16,11 +16,12 @@ describe('', () => { name: 'new name', description: 'new description', custom_virtualenv: 'Buzz', + galaxy_credentials: [], }; OrganizationsAPI.create.mockResolvedValueOnce({ data: {} }); await act(async () => { const wrapper = mountWithContexts(); - wrapper.find('OrganizationForm').prop('onSubmit')(updatedOrgData, [], []); + wrapper.find('OrganizationForm').prop('onSubmit')(updatedOrgData, []); }); expect(OrganizationsAPI.create).toHaveBeenCalledWith(updatedOrgData); }); @@ -46,6 +47,7 @@ describe('', () => { name: 'new name', description: 'new description', custom_virtualenv: 'Buzz', + galaxy_credentials: [], }; OrganizationsAPI.create.mockResolvedValueOnce({ data: { @@ -62,7 +64,7 @@ describe('', () => { context: { router: { history } }, }); await waitForElement(wrapper, 'button[aria-label="Save"]'); - await wrapper.find('OrganizationForm').prop('onSubmit')(orgData, [3], []); + await wrapper.find('OrganizationForm').prop('onSubmit')(orgData, [3]); }); expect(history.location.pathname).toEqual('/organizations/5'); }); @@ -72,6 +74,7 @@ describe('', () => { name: 'new name', description: 'new description', custom_virtualenv: 'Buzz', + galaxy_credentials: [], }; OrganizationsAPI.create.mockResolvedValueOnce({ data: { @@ -87,10 +90,42 @@ describe('', () => { wrapper = mountWithContexts(); }); await waitForElement(wrapper, 'button[aria-label="Save"]'); - await wrapper.find('OrganizationForm').prop('onSubmit')(orgData, [3], []); + await wrapper.find('OrganizationForm').prop('onSubmit')(orgData, [3]); expect(OrganizationsAPI.associateInstanceGroup).toHaveBeenCalledWith(5, 3); }); + test('onSubmit should post galaxy credentials', async () => { + const orgData = { + name: 'new name', + description: 'new description', + custom_virtualenv: 'Buzz', + galaxy_credentials: [ + { + id: 9000, + }, + ], + }; + OrganizationsAPI.create.mockResolvedValueOnce({ + data: { + id: 5, + related: { + instance_groups: '/api/v2/organizations/5/instance_groups', + }, + ...orgData, + }, + }); + let wrapper; + await act(async () => { + wrapper = mountWithContexts(); + }); + await waitForElement(wrapper, 'button[aria-label="Save"]'); + await wrapper.find('OrganizationForm').prop('onSubmit')(orgData, [3]); + expect(OrganizationsAPI.associateGalaxyCredential).toHaveBeenCalledWith( + 5, + 9000 + ); + }); + test('AnsibleSelect component renders if there are virtual environments', async () => { const mockInstanceGroups = [ { name: 'One', id: 1 }, diff --git a/awx/ui_next/src/screens/Organization/OrganizationDetail/OrganizationDetail.jsx b/awx/ui_next/src/screens/Organization/OrganizationDetail/OrganizationDetail.jsx index 522607e6d7..2054c18c52 100644 --- a/awx/ui_next/src/screens/Organization/OrganizationDetail/OrganizationDetail.jsx +++ b/awx/ui_next/src/screens/Organization/OrganizationDetail/OrganizationDetail.jsx @@ -12,6 +12,7 @@ import { import { CardBody, CardActionsRow } from '../../../components/Card'; import AlertModal from '../../../components/AlertModal'; import ChipGroup from '../../../components/ChipGroup'; +import CredentialChip from '../../../components/CredentialChip'; import ContentError from '../../../components/ContentError'; import ContentLoading from '../../../components/ContentLoading'; import DeleteButton from '../../../components/DeleteButton'; @@ -30,6 +31,7 @@ function OrganizationDetail({ i18n, organization }) { created, modified, summary_fields, + galaxy_credentials, } = organization; const [contentError, setContentError] = useState(null); const [hasContentLoading, setHasContentLoading] = useState(true); @@ -113,6 +115,23 @@ function OrganizationDetail({ i18n, organization }) { } /> )} + {galaxy_credentials && galaxy_credentials.length > 0 && ( + + {galaxy_credentials.map(credential => ( + + ))} + + } + /> + )} {summary_fields.user_capabilities.edit && ( diff --git a/awx/ui_next/src/screens/Organization/OrganizationEdit/OrganizationEdit.jsx b/awx/ui_next/src/screens/Organization/OrganizationEdit/OrganizationEdit.jsx index c95c47faf2..400d3758c8 100644 --- a/awx/ui_next/src/screens/Organization/OrganizationEdit/OrganizationEdit.jsx +++ b/awx/ui_next/src/screens/Organization/OrganizationEdit/OrganizationEdit.jsx @@ -4,7 +4,7 @@ import { useHistory } from 'react-router-dom'; import { CardBody } from '../../../components/Card'; import { OrganizationsAPI } from '../../../api'; import { Config } from '../../../contexts/Config'; - +import { getAddedAndRemoved } from '../../../util/lists'; import OrganizationForm from '../shared/OrganizationForm'; function OrganizationEdit({ organization }) { @@ -18,16 +18,39 @@ function OrganizationEdit({ organization }) { groupsToDisassociate ) => { try { + const { + added: addedCredentials, + removed: removedCredentials, + } = getAddedAndRemoved( + organization.galaxy_credentials, + values.galaxy_credentials + ); + + const addedCredentialIds = addedCredentials.map(({ id }) => id); + const removedCredentialIds = removedCredentials.map(({ id }) => id); + await OrganizationsAPI.update(organization.id, values); await Promise.all( - groupsToAssociate.map(id => - OrganizationsAPI.associateInstanceGroup(organization.id, id) - ) + groupsToAssociate + .map(id => + OrganizationsAPI.associateInstanceGroup(organization.id, id) + ) + .concat( + addedCredentialIds.map(id => + OrganizationsAPI.associateGalaxyCredential(organization.id, id) + ) + ) ); await Promise.all( - groupsToDisassociate.map(id => - OrganizationsAPI.disassociateInstanceGroup(organization.id, id) - ) + groupsToDisassociate + .map(id => + OrganizationsAPI.disassociateInstanceGroup(organization.id, id) + ) + .concat( + removedCredentialIds.map(id => + OrganizationsAPI.disassociateGalaxyCredential(organization.id, id) + ) + ) ); history.push(detailsUrl); } catch (error) { diff --git a/awx/ui_next/src/screens/Organization/Organizations.jsx b/awx/ui_next/src/screens/Organization/Organizations.jsx index 2a289b6be6..8ed5f21f1d 100644 --- a/awx/ui_next/src/screens/Organization/Organizations.jsx +++ b/awx/ui_next/src/screens/Organization/Organizations.jsx @@ -48,7 +48,7 @@ class Organizations extends Component { }; render() { - const { match, history, location } = this.props; + const { match } = this.props; const { breadcrumbConfig } = this.state; return ( @@ -62,8 +62,6 @@ class Organizations extends Component { {({ me }) => ( diff --git a/awx/ui_next/src/screens/Organization/shared/OrganizationForm.jsx b/awx/ui_next/src/screens/Organization/shared/OrganizationForm.jsx index f164b19a6e..cba0d62224 100644 --- a/awx/ui_next/src/screens/Organization/shared/OrganizationForm.jsx +++ b/awx/ui_next/src/screens/Organization/shared/OrganizationForm.jsx @@ -1,6 +1,6 @@ -import React, { useContext, useEffect, useState } from 'react'; +import React, { useCallback, useContext, useEffect, useState } from 'react'; import PropTypes from 'prop-types'; -import { Formik, useField } from 'formik'; +import { Formik, useField, useFormikContext } from 'formik'; import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; import { Form, FormGroup } from '@patternfly/react-core'; @@ -16,6 +16,7 @@ import { InstanceGroupsLookup } from '../../../components/Lookup'; import { getAddedAndRemoved } from '../../../util/lists'; import { required, minMaxValue } from '../../../util/validators'; import { FormColumnLayout } from '../../../components/FormLayout'; +import CredentialLookup from '../../../components/Lookup/CredentialLookup'; function OrganizationFormFields({ i18n, @@ -23,8 +24,15 @@ function OrganizationFormFields({ instanceGroups, setInstanceGroups, }) { + const { setFieldValue } = useFormikContext(); const [venvField] = useField('custom_virtualenv'); + const [ + galaxyCredentialsField, + galaxyCredentialsMeta, + galaxyCredentialsHelpers, + ] = useField('galaxy_credentials'); + const defaultVenv = { label: i18n._(t`Use Default Ansible Environment`), value: '/venv/ansible/', @@ -32,6 +40,13 @@ function OrganizationFormFields({ }; const { custom_virtualenvs } = useContext(ConfigContext); + const handleCredentialUpdate = useCallback( + value => { + setFieldValue('galaxy_credentials', value); + }, + [setFieldValue] + ); + return ( <> + galaxyCredentialsHelpers.setTouched()} + onChange={handleCredentialUpdate} + value={galaxyCredentialsField.value} + multiple + /> ); } @@ -160,6 +185,7 @@ function OrganizationForm({ description: organization.description, custom_virtualenv: organization.custom_virtualenv || '', max_hosts: organization.max_hosts || '0', + galaxy_credentials: organization.galaxy_credentials || [], }} onSubmit={handleSubmit} > diff --git a/awx/ui_next/src/screens/Organization/shared/OrganizationForm.test.jsx b/awx/ui_next/src/screens/Organization/shared/OrganizationForm.test.jsx index 0048a13b13..004c7d1577 100644 --- a/awx/ui_next/src/screens/Organization/shared/OrganizationForm.test.jsx +++ b/awx/ui_next/src/screens/Organization/shared/OrganizationForm.test.jsx @@ -163,6 +163,7 @@ describe('', () => { expect(onSubmit.mock.calls[0][0]).toEqual({ name: 'new foo', description: 'new bar', + galaxy_credentials: [], custom_virtualenv: 'Fizz', max_hosts: 134, }); @@ -211,6 +212,7 @@ describe('', () => { const mockDataForm = { name: 'Foo', description: 'Bar', + galaxy_credentials: [], max_hosts: 1, custom_virtualenv: 'Fizz', }; @@ -315,6 +317,7 @@ describe('', () => { { name: 'Foo', description: 'Bar', + galaxy_credentials: [], max_hosts: 0, custom_virtualenv: 'Fizz', }, From 908e583c69a0837ada0b01ae77171e1cc4606430 Mon Sep 17 00:00:00 2001 From: mabashian Date: Thu, 4 Jun 2020 17:29:07 -0400 Subject: [PATCH 048/242] Display prompt on launch passwords properly --- .../Credential/CredentialDetail/CredentialDetail.jsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/awx/ui_next/src/screens/Credential/CredentialDetail/CredentialDetail.jsx b/awx/ui_next/src/screens/Credential/CredentialDetail/CredentialDetail.jsx index 2e8809955f..b297c679ef 100644 --- a/awx/ui_next/src/screens/Credential/CredentialDetail/CredentialDetail.jsx +++ b/awx/ui_next/src/screens/Credential/CredentialDetail/CredentialDetail.jsx @@ -75,7 +75,7 @@ function CredentialDetail({ i18n, credential }) { const { error, dismissError } = useDismissableError(deleteError); - const renderDetail = ({ id, label, type }) => { + const renderDetail = ({ id, label, type, ask_at_runtime }) => { let detail; if (type === 'boolean') { @@ -96,6 +96,10 @@ function CredentialDetail({ i18n, credential }) { isEncrypted={isEncrypted} /> ); + } else if (ask_at_runtime && inputs[id] === 'ASK') { + detail = ( + + ); } else { detail = ; } From e7cd9bbb9805f17d94934422541382f338394dd4 Mon Sep 17 00:00:00 2001 From: mabashian Date: Mon, 8 Jun 2020 11:00:35 -0400 Subject: [PATCH 049/242] Display fields that have plugins configured. --- .../CredentialDetail/CredentialDetail.jsx | 134 +++- .../CredentialDetail.test.jsx | 44 ++ .../Credential/shared/data.credentials.json | 746 +++++++++--------- 3 files changed, 528 insertions(+), 396 deletions(-) diff --git a/awx/ui_next/src/screens/Credential/CredentialDetail/CredentialDetail.jsx b/awx/ui_next/src/screens/Credential/CredentialDetail/CredentialDetail.jsx index b297c679ef..f66389f3a1 100644 --- a/awx/ui_next/src/screens/Credential/CredentialDetail/CredentialDetail.jsx +++ b/awx/ui_next/src/screens/Credential/CredentialDetail/CredentialDetail.jsx @@ -1,9 +1,9 @@ -import React, { useState, useEffect, useCallback } from 'react'; +import React, { Fragment, useState, useEffect, useCallback } from 'react'; import { Link, useHistory } from 'react-router-dom'; import { withI18n } from '@lingui/react'; -import { t } from '@lingui/macro'; +import { t, Trans } from '@lingui/macro'; import { shape } from 'prop-types'; - +import styled from 'styled-components'; import { Button, List, ListItem } from '@patternfly/react-core'; import AlertModal from '../../../components/AlertModal'; import { CardBody, CardActionsRow } from '../../../components/Card'; @@ -11,15 +11,26 @@ import ContentError from '../../../components/ContentError'; import ContentLoading from '../../../components/ContentLoading'; import DeleteButton from '../../../components/DeleteButton'; import { - DetailList, Detail, + DetailList, UserDateDetail, } from '../../../components/DetailList'; +import ChipGroup from '../../../components/ChipGroup'; +import CodeMirrorInput from '../../../components/CodeMirrorInput'; +import CredentialChip from '../../../components/CredentialChip'; import ErrorDetail from '../../../components/ErrorDetail'; import { CredentialsAPI, CredentialTypesAPI } from '../../../api'; import { Credential } from '../../../types'; import useRequest, { useDismissableError } from '../../../util/useRequest'; +const PluginInputMetadata = styled(CodeMirrorInput)` + grid-column: 1 / -1; +`; + +const PluginFieldText = styled.p` + margin-top: 10px; +`; + function CredentialDetail({ i18n, credential }) { const { id: credentialId, @@ -38,6 +49,7 @@ function CredentialDetail({ i18n, credential }) { } = credential; const [fields, setFields] = useState([]); + const [inputSources, setInputSources] = useState({}); const [managedByTower, setManagedByTower] = useState([]); const [contentError, setContentError] = useState(null); const [hasContentLoading, setHasContentLoading] = useState(true); @@ -48,19 +60,33 @@ function CredentialDetail({ i18n, credential }) { setContentError(null); setHasContentLoading(true); try { - const { - data: { inputs: credentialTypeInputs, managed_by_tower }, - } = await CredentialTypesAPI.readDetail(credential_type.id); + const [ + { + data: { inputs: credentialTypeInputs, managed_by_tower }, + }, + { + data: { results: loadedInputSources }, + }, + ] = await Promise.all([ + CredentialTypesAPI.readDetail(credential_type.id), + CredentialsAPI.readInputSources(credentialId, { page_size: 200 }), + ]); setFields(credentialTypeInputs.fields || []); setManagedByTower(managed_by_tower); + setInputSources( + loadedInputSources.reduce((inputSourcesMap, inputSource) => { + inputSourcesMap[inputSource.input_field_name] = inputSource; + return inputSourcesMap; + }, {}) + ); } catch (error) { setContentError(error); } finally { setHasContentLoading(false); } })(); - }, [credential_type]); + }, [credential_type, credentialId]); const { request: deleteCredential, @@ -76,35 +102,77 @@ function CredentialDetail({ i18n, credential }) { const { error, dismissError } = useDismissableError(deleteError); const renderDetail = ({ id, label, type, ask_at_runtime }) => { - let detail; + if (inputSources[id]) { + return ( + + {label} *} + value={ + + + + } + /> + {}} + rows={5} + hasErrors={false} + /> + + ); + } if (type === 'boolean') { - detail = ( + return ( {inputs[id] && {label}}} /> ); - } else if (inputs[id] === '$encrypted$') { - const isEncrypted = true; - detail = ( + } + + if (inputs[id] === '$encrypted$') { + return ( ); - } else if (ask_at_runtime && inputs[id] === 'ASK') { - detail = ( - - ); - } else { - detail = ; } - return detail; + if (ask_at_runtime && inputs[id] === 'ASK') { + return ( + + ); + } + + return ( + + ); }; if (hasContentLoading) { @@ -118,10 +186,19 @@ function CredentialDetail({ i18n, credential }) { return ( - - + + {organization && ( @@ -131,6 +208,7 @@ function CredentialDetail({ i18n, credential }) { /> )} renderDetail(field))} + {Object.keys(inputSources).length > 0 && ( + + + * This field will be retrieved from an external secret management + system using the specified credential. + + + )} {user_capabilities.edit && (
@@ -122,9 +122,13 @@ VariablesDetail.propTypes = { value: oneOfType([shape({}), arrayOf(string), string]).isRequired, label: node.isRequired, rows: number, + dataCy: string, + helpText: string, }; VariablesDetail.defaultProps = { rows: null, + dataCy: '', + helpText: '', }; export default VariablesDetail; diff --git a/awx/ui_next/src/components/CodeMirrorInput/VariablesField.jsx b/awx/ui_next/src/components/CodeMirrorInput/VariablesField.jsx index 9268771e0f..1db21fdf04 100644 --- a/awx/ui_next/src/components/CodeMirrorInput/VariablesField.jsx +++ b/awx/ui_next/src/components/CodeMirrorInput/VariablesField.jsx @@ -5,10 +5,11 @@ import { t } from '@lingui/macro'; import { useField } from 'formik'; import styled from 'styled-components'; import { Split, SplitItem } from '@patternfly/react-core'; -import { CheckboxField, FieldTooltip } from '../FormField'; +import { CheckboxField } from '../FormField'; import MultiButtonToggle from '../MultiButtonToggle'; import { yamlToJson, jsonToYaml, isJsonString } from '../../util/yaml'; import CodeMirrorInput from './CodeMirrorInput'; +import Popover from '../Popover'; import { JSON_MODE, YAML_MODE } from './constants'; const FieldHeader = styled.div` @@ -43,7 +44,7 @@ function VariablesField({ - {tooltip && } + {tooltip && } { )} ); - expect(wrapper.find('Popover').length).toBe(1); + expect(wrapper.find('Popover[data-cy="the-field"]').length).toBe(1); }); it('should submit value through Formik', async () => { diff --git a/awx/ui_next/src/components/DetailList/CodeDetail.jsx b/awx/ui_next/src/components/DetailList/CodeDetail.jsx index 9b9a2f0568..a3a4918605 100644 --- a/awx/ui_next/src/components/DetailList/CodeDetail.jsx +++ b/awx/ui_next/src/components/DetailList/CodeDetail.jsx @@ -1,17 +1,30 @@ import 'styled-components/macro'; import React from 'react'; -import { shape, node, number, oneOf } from 'prop-types'; +import { shape, node, number, oneOf, string } from 'prop-types'; import { TextListItemVariants } from '@patternfly/react-core'; import { DetailName, DetailValue } from './Detail'; import CodeMirrorInput from '../CodeMirrorInput'; +import Popover from '../Popover'; + +function CodeDetail({ + value, + label, + mode, + rows, + fullHeight, + helpText, + dataCy, +}) { + const labelCy = dataCy ? `${dataCy}-label` : null; + const valueCy = dataCy ? `${dataCy}-value` : null; -function CodeDetail({ value, label, mode, rows, fullHeight }) { return ( <>
{label} + {helpText && ( + + )}
( @@ -61,9 +61,7 @@ const Detail = ({ id={dataCy} > {label} - {helpText && ( - - )} + {helpText && } setShowPopover(false)} - > - - - ); -} - -DetailPopover.propTypes = { - content: node, - header: node, - id: string, -}; -DetailPopover.defaultProps = { - content: null, - header: null, - id: 'detail-popover', -}; - -export default DetailPopover; diff --git a/awx/ui_next/src/components/DetailPopover/index.js b/awx/ui_next/src/components/DetailPopover/index.js deleted file mode 100644 index 02b42518c5..0000000000 --- a/awx/ui_next/src/components/DetailPopover/index.js +++ /dev/null @@ -1 +0,0 @@ -export { default } from './DetailPopover'; diff --git a/awx/ui_next/src/components/FieldWithPrompt/FieldWithPrompt.jsx b/awx/ui_next/src/components/FieldWithPrompt/FieldWithPrompt.jsx index fd82ae9c7e..8c9051e67e 100644 --- a/awx/ui_next/src/components/FieldWithPrompt/FieldWithPrompt.jsx +++ b/awx/ui_next/src/components/FieldWithPrompt/FieldWithPrompt.jsx @@ -3,7 +3,8 @@ import { bool, node, string } from 'prop-types'; import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; import styled from 'styled-components'; -import { CheckboxField, FieldTooltip } from '../FormField'; +import { CheckboxField } from '../FormField'; +import Popover from '../Popover'; const FieldHeader = styled.div` display: flex; @@ -38,7 +39,7 @@ function FieldWithPrompt({ )} - {tooltip && } + {tooltip && }
{ wrapper.unmount(); }); - test('Required asterisk and Tooltip hidden when not required and tooltip not provided', () => { + test('Required asterisk and Popover hidden when not required and tooltip not provided', () => { wrapper = mountWithContexts( { ); expect(wrapper.find('.pf-c-form__label-required')).toHaveLength(0); - expect(wrapper.find('Tooltip')).toHaveLength(0); + expect(wrapper.find('Popover')).toHaveLength(0); }); - test('Required asterisk and Tooltip shown when required and tooltip provided', () => { + test('Required asterisk and Popover shown when required and tooltip provided', () => { wrapper = mountWithContexts( { ); expect(wrapper.find('.pf-c-form__label-required')).toHaveLength(1); - expect(wrapper.find('Popover')).toHaveLength(1); + expect(wrapper.find('Popover[data-cy="job-template-limit"]').length).toBe( + 1 + ); }); }); diff --git a/awx/ui_next/src/components/FormField/ArrayTextField.jsx b/awx/ui_next/src/components/FormField/ArrayTextField.jsx index 862d48901b..d32493b41b 100644 --- a/awx/ui_next/src/components/FormField/ArrayTextField.jsx +++ b/awx/ui_next/src/components/FormField/ArrayTextField.jsx @@ -2,7 +2,7 @@ import React from 'react'; import PropTypes from 'prop-types'; import { useField } from 'formik'; import { FormGroup, TextArea } from '@patternfly/react-core'; -import FieldTooltip from './FieldTooltip'; +import Popover from '../Popover'; function ArrayTextField(props) { const { @@ -30,7 +30,7 @@ function ArrayTextField(props) { isRequired={isRequired} validated={isValid ? 'default' : 'error'} label={label} - labelIcon={} + labelIcon={} >