add api for testing credential plugins

This commit is contained in:
Jake McDermott
2019-02-25 09:33:43 -05:00
parent 7a43f00a5d
commit 0ee223f799
14 changed files with 224 additions and 35 deletions

View File

@@ -13,6 +13,7 @@ from awx.api.views import (
CredentialOwnerTeamsList, CredentialOwnerTeamsList,
CredentialCopy, CredentialCopy,
CredentialInputSourceSubList, CredentialInputSourceSubList,
CredentialExternalTest,
) )
@@ -26,6 +27,7 @@ urls = [
url(r'^(?P<pk>[0-9]+)/owner_teams/$', CredentialOwnerTeamsList.as_view(), name='credential_owner_teams_list'), url(r'^(?P<pk>[0-9]+)/owner_teams/$', CredentialOwnerTeamsList.as_view(), name='credential_owner_teams_list'),
url(r'^(?P<pk>[0-9]+)/copy/$', CredentialCopy.as_view(), name='credential_copy'), url(r'^(?P<pk>[0-9]+)/copy/$', CredentialCopy.as_view(), name='credential_copy'),
url(r'^(?P<pk>[0-9]+)/input_sources/$', CredentialInputSourceSubList.as_view(), name='credential_input_source_sublist'), url(r'^(?P<pk>[0-9]+)/input_sources/$', CredentialInputSourceSubList.as_view(), name='credential_input_source_sublist'),
url(r'^(?P<pk>[0-9]+)/test/$', CredentialExternalTest.as_view(), name='credential_external_test'),
] ]
__all__ = ['urls'] __all__ = ['urls']

View File

@@ -8,6 +8,7 @@ from awx.api.views import (
CredentialTypeDetail, CredentialTypeDetail,
CredentialTypeCredentialList, CredentialTypeCredentialList,
CredentialTypeActivityStreamList, CredentialTypeActivityStreamList,
CredentialTypeExternalTest,
) )
@@ -16,6 +17,7 @@ urls = [
url(r'^(?P<pk>[0-9]+)/$', CredentialTypeDetail.as_view(), name='credential_type_detail'), url(r'^(?P<pk>[0-9]+)/$', CredentialTypeDetail.as_view(), name='credential_type_detail'),
url(r'^(?P<pk>[0-9]+)/credentials/$', CredentialTypeCredentialList.as_view(), name='credential_type_credential_list'), url(r'^(?P<pk>[0-9]+)/credentials/$', CredentialTypeCredentialList.as_view(), name='credential_type_credential_list'),
url(r'^(?P<pk>[0-9]+)/activity_stream/$', CredentialTypeActivityStreamList.as_view(), name='credential_type_activity_stream_list'), url(r'^(?P<pk>[0-9]+)/activity_stream/$', CredentialTypeActivityStreamList.as_view(), name='credential_type_activity_stream_list'),
url(r'^(?P<pk>[0-9]+)/test/$', CredentialTypeExternalTest.as_view(), name='credential_type_external_test'),
] ]
__all__ = ['urls'] __all__ = ['urls']

View File

@@ -1419,6 +1419,32 @@ class CredentialCopy(CopyAPIView):
copy_return_serializer_class = serializers.CredentialSerializer 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): class CredentialInputSourceDetail(RetrieveUpdateDestroyAPIView):
view_name = _("Credential Input Source Detail") view_name = _("Credential Input Source Detail")
@@ -1446,6 +1472,27 @@ class CredentialInputSourceSubList(SubListCreateAttachDetachAPIView):
parent_key = 'target_credential' 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): class HostRelatedSearchMixin(object):
@property @property

View File

@@ -577,6 +577,16 @@ class CredentialType(CommonModelNameNotUnique):
if field.get('ask_at_runtime', False) is True 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): def default_for_field(self, field_id):
for field in self.inputs.get('fields', []): for field in self.inputs.get('fields', []):
if field['id'] == field_id: if field['id'] == field_id:
@@ -1336,11 +1346,7 @@ class CredentialInputSource(PrimordialModel):
super(CredentialInputSource, self).save(*args, **kwargs) super(CredentialInputSource, self).save(*args, **kwargs)
def get_input_value(self): def get_input_value(self):
[backend] = [ backend = self.source_credential.credential_type.plugin.backend
plugin.backend for ns, plugin in credential_plugins.items()
if ns == self.source_credential.credential_type.namespace
]
backend_kwargs = {} backend_kwargs = {}
for field_name, value in self.source_credential.inputs.items(): for field_name, value in self.source_credential.inputs.items():
if field_name in self.source_credential.credential_type.secret_fields: if field_name in self.source_credential.credential_type.secret_fields:

View File

@@ -4,7 +4,9 @@ function AddCredentialsController (
$scope, $scope,
strings, strings,
componentsStrings, componentsStrings,
ConfigService ConfigService,
ngToast,
$filter
) { ) {
const vm = this || {}; const vm = this || {};
@@ -36,6 +38,7 @@ function AddCredentialsController (
vm.form.credential_type._route = 'credentials.add.credentialType'; vm.form.credential_type._route = 'credentials.add.credentialType';
vm.form.credential_type._model = credentialType; vm.form.credential_type._model = credentialType;
vm.form.credential_type._placeholder = strings.get('inputs.CREDENTIAL_TYPE_PLACEHOLDER'); vm.form.credential_type._placeholder = strings.get('inputs.CREDENTIAL_TYPE_PLACEHOLDER');
vm.isTestable = credentialType.get('kind') === 'external';
const gceFileInputSchema = { const gceFileInputSchema = {
id: 'gce_service_account_key', id: 'gce_service_account_key',
@@ -62,6 +65,8 @@ function AddCredentialsController (
become._choices = Array.from(apiConfig.become_methods, method => method[0]); become._choices = Array.from(apiConfig.become_methods, method => method[0]);
} }
vm.isTestable = credentialType.get('kind') === 'external';
return fields; return fields;
}, },
_source: vm.form.credential_type, _source: vm.form.credential_type,
@@ -69,6 +74,45 @@ function AddCredentialsController (
_key: 'inputs' _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: `
<div class="Toast-wrapper">
<div class="Toast-icon">
<i class="fa fa-check-circle Toast-successIcon"></i>
</div>
<div>
<b>${name}:</b> ${strings.get('edit.TEST_PASSED')}
</div>
</div>`,
dismissButton: false,
dismissOnTimeout: true
});
})
.catch(({ data }) => {
const msg = data.inputs ? `${$filter('sanitize')(data.inputs)}` : strings.get('edit.TEST_FAILED');
ngToast.danger({
content: `
<div class="Toast-wrapper">
<div class="Toast-icon">
<i class="fa fa-exclamation-triangle Toast-successIcon"></i>
</div>
<div>
<b>${name}:</b> ${msg}
</div>
</div>`,
dismissButton: false,
dismissOnTimeout: true
});
});
};
vm.form.save = data => { vm.form.save = data => {
data.user = me.get('id'); data.user = me.get('id');
@@ -149,7 +193,9 @@ AddCredentialsController.$inject = [
'$scope', '$scope',
'CredentialsStrings', 'CredentialsStrings',
'ComponentsStrings', 'ComponentsStrings',
'ConfigService' 'ConfigService',
'ngToast',
'$filter'
]; ];
export default AddCredentialsController; export default AddCredentialsController;

View File

@@ -23,6 +23,7 @@
</at-input-group> </at-input-group>
<at-action-group col="12" pos="right"> <at-action-group col="12" pos="right">
<at-form-action type="secondary" ng-if="vm.isTestable"></at-form-action>
<at-form-action type="cancel" to="credentials"></at-form-action> <at-form-action type="cancel" to="credentials"></at-form-action>
<at-form-action type="save"></at-form-action> <at-form-action type="save"></at-form-action>
</at-action-group> </at-action-group>

View File

@@ -26,6 +26,11 @@ function CredentialsStrings (BaseString) {
PANEL_TITLE: t.s('NEW CREDENTIAL') PANEL_TITLE: t.s('NEW CREDENTIAL')
}; };
ns.edit = {
TEST_PASSED: t.s('Test passed.'),
TEST_FAILED: t.s('Test failed.')
};
ns.permissions = { ns.permissions = {
TITLE: t.s('CREDENTIALS PERMISSIONS') TITLE: t.s('CREDENTIALS PERMISSIONS')
}; };

View File

@@ -4,7 +4,10 @@ function EditCredentialsController (
$scope, $scope,
strings, strings,
componentsStrings, componentsStrings,
ConfigService ConfigService,
ngToast,
Wait,
$filter,
) { ) {
const vm = this || {}; const vm = this || {};
@@ -83,6 +86,7 @@ function EditCredentialsController (
vm.form.credential_type._value = credentialType.get('id'); vm.form.credential_type._value = credentialType.get('id');
vm.form.credential_type._displayValue = credentialType.get('name'); vm.form.credential_type._displayValue = credentialType.get('name');
vm.form.credential_type._placeholder = strings.get('inputs.CREDENTIAL_TYPE_PLACEHOLDER'); vm.form.credential_type._placeholder = strings.get('inputs.CREDENTIAL_TYPE_PLACEHOLDER');
vm.isTestable = (isEditable && credentialType.get('kind') === 'external');
const gceFileInputSchema = { const gceFileInputSchema = {
id: 'gce_service_account_key', id: 'gce_service_account_key',
@@ -124,6 +128,7 @@ function EditCredentialsController (
} }
} }
} }
vm.isTestable = (isEditable && credentialType.get('kind') === 'external');
return fields; return fields;
}, },
@@ -132,6 +137,45 @@ function EditCredentialsController (
_key: 'inputs' _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: `
<div class="Toast-wrapper">
<div class="Toast-icon">
<i class="fa fa-check-circle Toast-successIcon"></i>
</div>
<div>
<b>${name}:</b> ${strings.get('edit.TEST_PASSED')}
</div>
</div>`,
dismissButton: false,
dismissOnTimeout: true
});
})
.catch(({ data }) => {
const msg = data.inputs ? `${$filter('sanitize')(data.inputs)}` : strings.get('edit.TEST_FAILED');
ngToast.danger({
content: `
<div class="Toast-wrapper">
<div class="Toast-icon">
<i class="fa fa-exclamation-triangle Toast-successIcon"></i>
</div>
<div>
<b>${name}:</b> ${msg}
</div>
</div>`,
dismissButton: false,
dismissOnTimeout: true
});
});
};
/** /**
* If a credential's `credential_type` is changed while editing, the inputs associated with * 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. * the old type need to be cleared before saving the inputs associated with the new type.
@@ -210,7 +254,10 @@ EditCredentialsController.$inject = [
'$scope', '$scope',
'CredentialsStrings', 'CredentialsStrings',
'ComponentsStrings', 'ComponentsStrings',
'ConfigService' 'ConfigService',
'ngToast',
'Wait',
'$filter',
]; ];
export default EditCredentialsController; export default EditCredentialsController;

View File

@@ -1,7 +1,7 @@
.at-ActionGroup { .at-ActionGroup {
margin-top: @at-margin-panel; margin-top: @at-margin-panel;
button:last-child { button {
margin-left: @at-margin-panel-inset; margin-left: 15px;
} }
} }

View File

@@ -23,6 +23,9 @@ function atFormActionController ($state, strings) {
case 'save': case 'save':
vm.setSaveDefaults(); vm.setSaveDefaults();
break; break;
case 'secondary':
vm.setSecondaryDefaults();
break;
default: default:
vm.setCustomDefaults(); vm.setCustomDefaults();
} }
@@ -43,6 +46,13 @@ function atFormActionController ($state, strings) {
scope.color = 'success'; scope.color = 'success';
scope.action = () => { form.submit(); }; scope.action = () => { form.submit(); };
}; };
vm.setSecondaryDefaults = () => {
scope.text = strings.get('TEST');
scope.fill = '';
scope.color = 'info';
scope.action = () => { form.submitSecondary(); };
};
} }
atFormActionController.$inject = ['$state', 'ComponentsStrings']; atFormActionController.$inject = ['$state', 'ComponentsStrings'];

View File

@@ -1,5 +1,7 @@
<button class="btn at-Button{{ fill }}--{{ color }}" <button class="btn at-Button{{ fill }}--{{ color }}"
ng-disabled="type !== 'cancel' && (form.disabled || (type === 'save' && !form.isValid))" ng-disabled="type !== 'cancel' && (form.disabled
|| (type === 'save' && !form.isValid))
|| (type === 'secondary' && !form.isValid)"
ng-click="action()"> ng-click="action()">
{{::text}} {{::text}}
</button> </button>

View File

@@ -62,6 +62,40 @@ function AtFormController (eventService, strings) {
scope.$apply(vm.submit); 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 = () => { vm.submit = () => {
if (!vm.state.isValid) { if (!vm.state.isValid) {
return; return;
@@ -69,26 +103,7 @@ function AtFormController (eventService, strings) {
vm.state.disabled = true; vm.state.disabled = true;
const data = vm.components const data = vm.getSubmitData();
.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;
}, {});
scope.state.save(data) scope.state.save(data)
.then(scope.state.onSaveSuccess) .then(scope.state.onSaveSuccess)

View File

@@ -153,17 +153,22 @@ function httpPost (config = {}) {
const req = { const req = {
method: 'POST', method: 'POST',
url: this.path, url: this.path,
data: config.data data: config.data,
}; };
if (config.url) { if (config.url) {
req.url = `${this.path}${config.url}`; req.url = `${this.path}${config.url}`;
} }
if (!('replace' in config)) {
config.replace = true;
}
return $http(req) return $http(req)
.then(res => { .then(res => {
this.model.GET = res.data; if (config.replace) {
this.model.GET = res.data;
}
return res; return res;
}); });
} }

View File

@@ -73,6 +73,7 @@ function BaseStringService (namespace) {
this.COPY = t.s('COPY'); this.COPY = t.s('COPY');
this.YES = t.s('YES'); this.YES = t.s('YES');
this.CLOSE = t.s('CLOSE'); this.CLOSE = t.s('CLOSE');
this.TEST = t.s('TEST');
this.SUCCESSFUL_CREATION = resource => t.s('{{ resource }} successfully created', { resource: $filter('sanitize')(resource) }); this.SUCCESSFUL_CREATION = resource => t.s('{{ resource }} successfully created', { resource: $filter('sanitize')(resource) });
this.deleteResource = { this.deleteResource = {