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
No known key found for this signature in database
GPG Key ID: 9A6F084352C3A0B7
14 changed files with 224 additions and 35 deletions

View File

@ -13,6 +13,7 @@ from awx.api.views import (
CredentialOwnerTeamsList,
CredentialCopy,
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]+)/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]+)/test/$', CredentialExternalTest.as_view(), name='credential_external_test'),
]
__all__ = ['urls']

View File

@ -8,6 +8,7 @@ from awx.api.views import (
CredentialTypeDetail,
CredentialTypeCredentialList,
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]+)/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]+)/test/$', CredentialTypeExternalTest.as_view(), name='credential_type_external_test'),
]
__all__ = ['urls']

View File

@ -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

View File

@ -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:

View File

@ -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: `
<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 => {
data.user = me.get('id');
@ -149,7 +193,9 @@ AddCredentialsController.$inject = [
'$scope',
'CredentialsStrings',
'ComponentsStrings',
'ConfigService'
'ConfigService',
'ngToast',
'$filter'
];
export default AddCredentialsController;

View File

@ -23,6 +23,7 @@
</at-input-group>
<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="save"></at-form-action>
</at-action-group>

View File

@ -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')
};

View File

@ -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: `
<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
* 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;

View File

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

View File

@ -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'];

View File

@ -1,5 +1,7 @@
<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()">
{{::text}}
</button>

View File

@ -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)

View File

@ -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;
});
}

View File

@ -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 = {