diff --git a/awx/api/urls/credential.py b/awx/api/urls/credential.py index 3143b91c9e..e041e08477 100644 --- a/awx/api/urls/credential.py +++ b/awx/api/urls/credential.py @@ -13,6 +13,7 @@ from awx.api.views import ( CredentialOwnerTeamsList, CredentialCopy, CredentialInputSourceSubList, + CredentialExternalTest, ) @@ -26,6 +27,7 @@ urls = [ url(r'^(?P[0-9]+)/owner_teams/$', CredentialOwnerTeamsList.as_view(), name='credential_owner_teams_list'), url(r'^(?P[0-9]+)/copy/$', CredentialCopy.as_view(), name='credential_copy'), url(r'^(?P[0-9]+)/input_sources/$', CredentialInputSourceSubList.as_view(), name='credential_input_source_sublist'), + url(r'^(?P[0-9]+)/test/$', CredentialExternalTest.as_view(), name='credential_external_test'), ] __all__ = ['urls'] diff --git a/awx/api/urls/credential_type.py b/awx/api/urls/credential_type.py index 22c097523b..5fa033fd33 100644 --- a/awx/api/urls/credential_type.py +++ b/awx/api/urls/credential_type.py @@ -8,6 +8,7 @@ from awx.api.views import ( CredentialTypeDetail, CredentialTypeCredentialList, CredentialTypeActivityStreamList, + CredentialTypeExternalTest, ) @@ -16,6 +17,7 @@ urls = [ url(r'^(?P[0-9]+)/$', CredentialTypeDetail.as_view(), name='credential_type_detail'), url(r'^(?P[0-9]+)/credentials/$', CredentialTypeCredentialList.as_view(), name='credential_type_credential_list'), url(r'^(?P[0-9]+)/activity_stream/$', CredentialTypeActivityStreamList.as_view(), name='credential_type_activity_stream_list'), + url(r'^(?P[0-9]+)/test/$', CredentialTypeExternalTest.as_view(), name='credential_type_external_test'), ] __all__ = ['urls'] diff --git a/awx/api/views/__init__.py b/awx/api/views/__init__.py index 3d158315d3..70e56fcb03 100644 --- a/awx/api/views/__init__.py +++ b/awx/api/views/__init__.py @@ -1419,6 +1419,32 @@ class CredentialCopy(CopyAPIView): copy_return_serializer_class = serializers.CredentialSerializer +class CredentialExternalTest(SubDetailAPIView): + """ + Test updates to the input values of an external credential before + saving them. + """ + + view_name = _('External Credential Test') + + model = models.Credential + serializer_class = serializers.EmptySerializer + + def post(self, request, *args, **kwargs): + obj = self.get_object() + test_inputs = {} + for field_name, value in request.data.get('inputs', {}).items(): + if value == '$encrypted$': + test_inputs[field_name] = obj.get_input(field_name) + else: + test_inputs[field_name] = value + try: + obj.credential_type.plugin.backend(None, **test_inputs) + return Response({}, status=status.HTTP_202_ACCEPTED) + except Exception as exc: + return Response({'inputs': str(exc)}, status=status.HTTP_400_BAD_REQUEST) + + class CredentialInputSourceDetail(RetrieveUpdateDestroyAPIView): view_name = _("Credential Input Source Detail") @@ -1446,6 +1472,27 @@ class CredentialInputSourceSubList(SubListCreateAttachDetachAPIView): parent_key = 'target_credential' +class CredentialTypeExternalTest(SubDetailAPIView): + """ + Test a complete set of input values for an external credential before + saving it. + """ + + view_name = _('External Credential Type Test') + + model = models.CredentialType + serializer_class = serializers.EmptySerializer + + def post(self, request, *args, **kwargs): + obj = self.get_object() + test_inputs = request.data.get('inputs', {}) + try: + obj.plugin.backend(None, **test_inputs) + return Response({}, status=status.HTTP_202_ACCEPTED) + except Exception as exc: + return Response({'inputs': str(exc)}, status=status.HTTP_400_BAD_REQUEST) + + class HostRelatedSearchMixin(object): @property diff --git a/awx/main/models/credential/__init__.py b/awx/main/models/credential/__init__.py index 03c86f6800..eac6fd4d71 100644 --- a/awx/main/models/credential/__init__.py +++ b/awx/main/models/credential/__init__.py @@ -577,6 +577,16 @@ class CredentialType(CommonModelNameNotUnique): if field.get('ask_at_runtime', False) is True ] + @property + def plugin(self): + if self.kind != 'external': + raise AttributeError('plugin') + [plugin] = [ + plugin for ns, plugin in credential_plugins.items() + if ns == self.namespace + ] + return plugin + def default_for_field(self, field_id): for field in self.inputs.get('fields', []): if field['id'] == field_id: @@ -1336,11 +1346,7 @@ class CredentialInputSource(PrimordialModel): super(CredentialInputSource, self).save(*args, **kwargs) def get_input_value(self): - [backend] = [ - plugin.backend for ns, plugin in credential_plugins.items() - if ns == self.source_credential.credential_type.namespace - ] - + backend = self.source_credential.credential_type.plugin.backend backend_kwargs = {} for field_name, value in self.source_credential.inputs.items(): if field_name in self.source_credential.credential_type.secret_fields: diff --git a/awx/ui/client/features/credentials/add-credentials.controller.js b/awx/ui/client/features/credentials/add-credentials.controller.js index 36428d50a8..9193712f80 100644 --- a/awx/ui/client/features/credentials/add-credentials.controller.js +++ b/awx/ui/client/features/credentials/add-credentials.controller.js @@ -4,7 +4,9 @@ function AddCredentialsController ( $scope, strings, componentsStrings, - ConfigService + ConfigService, + ngToast, + $filter ) { const vm = this || {}; @@ -36,6 +38,7 @@ function AddCredentialsController ( vm.form.credential_type._route = 'credentials.add.credentialType'; vm.form.credential_type._model = credentialType; vm.form.credential_type._placeholder = strings.get('inputs.CREDENTIAL_TYPE_PLACEHOLDER'); + vm.isTestable = credentialType.get('kind') === 'external'; const gceFileInputSchema = { id: 'gce_service_account_key', @@ -62,6 +65,8 @@ function AddCredentialsController ( become._choices = Array.from(apiConfig.become_methods, method => method[0]); } + vm.isTestable = credentialType.get('kind') === 'external'; + return fields; }, _source: vm.form.credential_type, @@ -69,6 +74,45 @@ function AddCredentialsController ( _key: 'inputs' }; + vm.form.secondary = ({ inputs }) => { + const name = $filter('sanitize')(credentialType.get('name')); + const endpoint = `${credentialType.get('id')}/test/`; + + return credentialType.http.post({ url: endpoint, data: { inputs }, replace: false }) + .then(() => { + ngToast.success({ + content: ` +
+
+ +
+
+ ${name}: ${strings.get('edit.TEST_PASSED')} +
+
`, + dismissButton: false, + dismissOnTimeout: true + }); + }) + .catch(({ data }) => { + const msg = data.inputs ? `${$filter('sanitize')(data.inputs)}` : strings.get('edit.TEST_FAILED'); + + ngToast.danger({ + content: ` +
+
+ +
+
+ ${name}: ${msg} +
+
`, + dismissButton: false, + dismissOnTimeout: true + }); + }); + }; + vm.form.save = data => { data.user = me.get('id'); @@ -149,7 +193,9 @@ AddCredentialsController.$inject = [ '$scope', 'CredentialsStrings', 'ComponentsStrings', - 'ConfigService' + 'ConfigService', + 'ngToast', + '$filter' ]; export default AddCredentialsController; diff --git a/awx/ui/client/features/credentials/add-edit-credentials.view.html b/awx/ui/client/features/credentials/add-edit-credentials.view.html index dcc9d47e8b..fae203e152 100644 --- a/awx/ui/client/features/credentials/add-edit-credentials.view.html +++ b/awx/ui/client/features/credentials/add-edit-credentials.view.html @@ -23,6 +23,7 @@ + diff --git a/awx/ui/client/features/credentials/credentials.strings.js b/awx/ui/client/features/credentials/credentials.strings.js index cb6260ce55..b198d7f13f 100644 --- a/awx/ui/client/features/credentials/credentials.strings.js +++ b/awx/ui/client/features/credentials/credentials.strings.js @@ -26,6 +26,11 @@ function CredentialsStrings (BaseString) { PANEL_TITLE: t.s('NEW CREDENTIAL') }; + ns.edit = { + TEST_PASSED: t.s('Test passed.'), + TEST_FAILED: t.s('Test failed.') + }; + ns.permissions = { TITLE: t.s('CREDENTIALS PERMISSIONS') }; diff --git a/awx/ui/client/features/credentials/edit-credentials.controller.js b/awx/ui/client/features/credentials/edit-credentials.controller.js index 67f8590f95..1516b71d96 100644 --- a/awx/ui/client/features/credentials/edit-credentials.controller.js +++ b/awx/ui/client/features/credentials/edit-credentials.controller.js @@ -4,7 +4,10 @@ function EditCredentialsController ( $scope, strings, componentsStrings, - ConfigService + ConfigService, + ngToast, + Wait, + $filter, ) { const vm = this || {}; @@ -83,6 +86,7 @@ function EditCredentialsController ( vm.form.credential_type._value = credentialType.get('id'); vm.form.credential_type._displayValue = credentialType.get('name'); vm.form.credential_type._placeholder = strings.get('inputs.CREDENTIAL_TYPE_PLACEHOLDER'); + vm.isTestable = (isEditable && credentialType.get('kind') === 'external'); const gceFileInputSchema = { id: 'gce_service_account_key', @@ -124,6 +128,7 @@ function EditCredentialsController ( } } } + vm.isTestable = (isEditable && credentialType.get('kind') === 'external'); return fields; }, @@ -132,6 +137,45 @@ function EditCredentialsController ( _key: 'inputs' }; + vm.form.secondary = ({ inputs }) => { + const name = $filter('sanitize')(credentialType.get('name')); + const endpoint = `${credential.get('id')}/test/`; + + return credential.http.post({ url: endpoint, data: { inputs }, replace: false }) + .then(() => { + ngToast.success({ + content: ` +
+
+ +
+
+ ${name}: ${strings.get('edit.TEST_PASSED')} +
+
`, + dismissButton: false, + dismissOnTimeout: true + }); + }) + .catch(({ data }) => { + const msg = data.inputs ? `${$filter('sanitize')(data.inputs)}` : strings.get('edit.TEST_FAILED'); + + ngToast.danger({ + content: ` +
+
+ +
+
+ ${name}: ${msg} +
+
`, + dismissButton: false, + dismissOnTimeout: true + }); + }); + }; + /** * If a credential's `credential_type` is changed while editing, the inputs associated with * the old type need to be cleared before saving the inputs associated with the new type. @@ -210,7 +254,10 @@ EditCredentialsController.$inject = [ '$scope', 'CredentialsStrings', 'ComponentsStrings', - 'ConfigService' + 'ConfigService', + 'ngToast', + 'Wait', + '$filter', ]; export default EditCredentialsController; diff --git a/awx/ui/client/lib/components/action/_index.less b/awx/ui/client/lib/components/action/_index.less index 6208e2a41d..c5be9803a3 100644 --- a/awx/ui/client/lib/components/action/_index.less +++ b/awx/ui/client/lib/components/action/_index.less @@ -1,7 +1,7 @@ .at-ActionGroup { margin-top: @at-margin-panel; - button:last-child { - margin-left: @at-margin-panel-inset; + button { + margin-left: 15px; } } diff --git a/awx/ui/client/lib/components/form/action.directive.js b/awx/ui/client/lib/components/form/action.directive.js index 4ba3c7d67a..ab8d6e72a3 100644 --- a/awx/ui/client/lib/components/form/action.directive.js +++ b/awx/ui/client/lib/components/form/action.directive.js @@ -23,6 +23,9 @@ function atFormActionController ($state, strings) { case 'save': vm.setSaveDefaults(); break; + case 'secondary': + vm.setSecondaryDefaults(); + break; default: vm.setCustomDefaults(); } @@ -43,6 +46,13 @@ function atFormActionController ($state, strings) { scope.color = 'success'; scope.action = () => { form.submit(); }; }; + + vm.setSecondaryDefaults = () => { + scope.text = strings.get('TEST'); + scope.fill = ''; + scope.color = 'info'; + scope.action = () => { form.submitSecondary(); }; + }; } atFormActionController.$inject = ['$state', 'ComponentsStrings']; diff --git a/awx/ui/client/lib/components/form/action.partial.html b/awx/ui/client/lib/components/form/action.partial.html index 245c649de1..78c89d0f91 100644 --- a/awx/ui/client/lib/components/form/action.partial.html +++ b/awx/ui/client/lib/components/form/action.partial.html @@ -1,5 +1,7 @@ diff --git a/awx/ui/client/lib/components/form/form.directive.js b/awx/ui/client/lib/components/form/form.directive.js index 97a30911d0..9c008b19e6 100644 --- a/awx/ui/client/lib/components/form/form.directive.js +++ b/awx/ui/client/lib/components/form/form.directive.js @@ -62,6 +62,40 @@ function AtFormController (eventService, strings) { scope.$apply(vm.submit); }; + vm.getSubmitData = () => vm.components + .filter(component => component.category === 'input') + .reduce((values, component) => { + if (component.state._value === undefined) { + return values; + } + + if (component.state._format === 'selectFromOptions') { + values[component.state.id] = component.state._value[0]; + } else if (component.state._key && typeof component.state._value === 'object') { + values[component.state.id] = component.state._value[component.state._key]; + } else if (component.state._group) { + values[component.state._key] = values[component.state._key] || {}; + values[component.state._key][component.state.id] = component.state._value; + } else { + values[component.state.id] = component.state._value; + } + + return values; + }, {}); + + vm.submitSecondary = () => { + if (!vm.state.isValid) { + return; + } + + vm.state.disabled = true; + + const data = vm.getSubmitData(); + + scope.state.secondary(data) + .finally(() => { vm.state.disabled = false; }); + }; + vm.submit = () => { if (!vm.state.isValid) { return; @@ -69,26 +103,7 @@ function AtFormController (eventService, strings) { vm.state.disabled = true; - const data = vm.components - .filter(component => component.category === 'input') - .reduce((values, component) => { - if (component.state._value === undefined) { - return values; - } - - if (component.state._format === 'selectFromOptions') { - values[component.state.id] = component.state._value[0]; - } else if (component.state._key && typeof component.state._value === 'object') { - values[component.state.id] = component.state._value[component.state._key]; - } else if (component.state._group) { - values[component.state._key] = values[component.state._key] || {}; - values[component.state._key][component.state.id] = component.state._value; - } else { - values[component.state.id] = component.state._value; - } - - return values; - }, {}); + const data = vm.getSubmitData(); scope.state.save(data) .then(scope.state.onSaveSuccess) diff --git a/awx/ui/client/lib/models/Base.js b/awx/ui/client/lib/models/Base.js index 1eaa60de87..e5425a3335 100644 --- a/awx/ui/client/lib/models/Base.js +++ b/awx/ui/client/lib/models/Base.js @@ -153,17 +153,22 @@ function httpPost (config = {}) { const req = { method: 'POST', url: this.path, - data: config.data + data: config.data, }; if (config.url) { req.url = `${this.path}${config.url}`; } + if (!('replace' in config)) { + config.replace = true; + } + return $http(req) .then(res => { - this.model.GET = res.data; - + if (config.replace) { + this.model.GET = res.data; + } return res; }); } diff --git a/awx/ui/client/lib/services/base-string.service.js b/awx/ui/client/lib/services/base-string.service.js index 0956c7c5a1..83a860eeb1 100644 --- a/awx/ui/client/lib/services/base-string.service.js +++ b/awx/ui/client/lib/services/base-string.service.js @@ -73,6 +73,7 @@ function BaseStringService (namespace) { this.COPY = t.s('COPY'); this.YES = t.s('YES'); this.CLOSE = t.s('CLOSE'); + this.TEST = t.s('TEST'); this.SUCCESSFUL_CREATION = resource => t.s('{{ resource }} successfully created', { resource: $filter('sanitize')(resource) }); this.deleteResource = {