Merge "Use POST not GET for keypair generation"

This commit is contained in:
Jenkins 2017-06-16 09:00:05 +00:00 committed by Gerrit Code Review
commit f5b2561a09
23 changed files with 299 additions and 784 deletions

View File

@ -14,8 +14,6 @@
"""API over the nova service.""" """API over the nova service."""
from collections import OrderedDict from collections import OrderedDict
from django.http import HttpResponse
from django.template.defaultfilters import slugify
from django.utils import http as utils_http from django.utils import http as utils_http
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from django.views import generic from django.views import generic
@ -83,47 +81,6 @@ class Keypairs(generic.View):
) )
@urls.register
class Keypair(generic.View):
url_regex = r'nova/keypairs/(?P<keypair_name>.+)/$'
def get(self, request, keypair_name):
"""Creates a new keypair and associates it to the current project.
* Since the response for this endpoint creates a new keypair and
is not idempotent, it normally would be represented by a POST HTTP
request. However, this solution was adopted as it
would support automatic file download across browsers.
:param keypair_name: the name to associate the keypair to
:param regenerate: (optional) if set to the string 'true',
replaces the existing keypair with a new keypair
This returns the new keypair object on success.
"""
try:
regenerate = request.GET.get('regenerate') == 'true'
if regenerate:
api.nova.keypair_delete(request, keypair_name)
keypair = api.nova.keypair_create(request, keypair_name)
except exceptions.Conflict:
return HttpResponse(status=409)
except Exception:
return HttpResponse(status=500)
else:
response = HttpResponse(content_type='application/binary')
response['Content-Disposition'] = ('attachment; filename=%s.pem'
% slugify(keypair_name))
response.write(keypair.private_key)
response['Content-Length'] = str(len(response.content))
return response
@urls.register @urls.register
class Services(generic.View): class Services(generic.View):
"""API for nova services.""" """API for nova services."""

View File

@ -36,29 +36,6 @@ KEYPAIR_ERROR_MESSAGES = {
'and may not be white space.')} 'and may not be white space.')}
class CreateKeypair(forms.SelfHandlingForm):
name = forms.RegexField(max_length=255,
label=_("Key Pair Name"),
regex=KEYPAIR_NAME_REGEX,
error_messages=KEYPAIR_ERROR_MESSAGES)
def handle(self, request, data):
return True # We just redirect to the download view.
def clean(self):
cleaned_data = super(CreateKeypair, self).clean()
name = cleaned_data.get('name')
try:
keypairs = api.nova.keypair_list(self.request)
except Exception:
exceptions.handle(self.request, ignore=True)
keypairs = []
if name in [keypair.name for keypair in keypairs]:
error_msg = _("The name is already in use.")
self._errors['name'] = self.error_class([error_msg])
return cleaned_data
class ImportKeypair(forms.SelfHandlingForm): class ImportKeypair(forms.SelfHandlingForm):
name = forms.RegexField(max_length=255, name = forms.RegexField(max_length=255,
label=_("Key Pair Name"), label=_("Key Pair Name"),

View File

@ -12,6 +12,7 @@
# License for the specific language governing permissions and limitations # License for the specific language governing permissions and limitations
# under the License. # under the License.
from django.core import urlresolvers
from django.utils.translation import string_concat from django.utils.translation import string_concat
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from django.utils.translation import ungettext_lazy from django.utils.translation import ungettext_lazy
@ -78,16 +79,28 @@ class ImportKeyPair(QuotaKeypairMixin, tables.LinkAction):
return True return True
class CreateKeyPair(QuotaKeypairMixin, tables.LinkAction): class CreateLinkNG(QuotaKeypairMixin, tables.LinkAction):
name = "create" name = "create-keypair-ng"
verbose_name = _("Create Key Pair") verbose_name = _("Create Key Pair")
url = "horizon:project:key_pairs:create" url = "horizon:project:key_pairs:index"
classes = ("ajax-modal",) classes = ("btn-launch",)
icon = "plus" icon = "plus"
policy_rules = (("compute", "os_compute_api:os-keypairs:create"),) policy_rules = (("compute", "os_compute_api:os-keypairs:create"),)
def get_default_attrs(self):
url = urlresolvers.reverse(self.url)
ngclick = "modal.createKeyPair({ successUrl: '%s' })" % url
self.attrs.update({
'ng-controller': 'KeypairController as modal',
'ng-click': ngclick
})
return super(CreateLinkNG, self).get_default_attrs()
def get_link_url(self, datum=None):
return "javascript:void(0);"
def allowed(self, request, keypair=None): def allowed(self, request, keypair=None):
if super(CreateKeyPair, self).allowed(request, keypair): if super(CreateLinkNG, self).allowed(request, keypair):
self.verbose_name = _("Create Key Pair") self.verbose_name = _("Create Key Pair")
return True return True
@ -113,6 +126,6 @@ class KeyPairsTable(tables.DataTable):
class Meta(object): class Meta(object):
name = "keypairs" name = "keypairs"
verbose_name = _("Key Pairs") verbose_name = _("Key Pairs")
table_actions = (CreateKeyPair, ImportKeyPair, DeleteKeyPairs, table_actions = (CreateLinkNG, ImportKeyPair, DeleteKeyPairs,
KeypairsFilterAction,) KeypairsFilterAction,)
row_actions = (DeleteKeyPairs,) row_actions = (DeleteKeyPairs,)

View File

@ -1,7 +0,0 @@
{% extends "horizon/common/_modal_form.html" %}
{% load i18n %}
{% block modal-body-right %}
<p>{% trans "Key pairs are SSH credentials which are injected into images when they are launched. Creating a new key pair registers the public key and downloads the private key (a .pem file)." %}</p>
<p>{% trans "Protect and use the key as you would any normal SSH private key." %}</p>
{% endblock %}

View File

@ -1,8 +0,0 @@
{% extends 'base.html' %}
{% load i18n %}
{% block title %}{{ page_title }}{% endblock %}
{% block main %}
{% include 'project/key_pairs/_create.html' %}
{% endblock %}

View File

@ -22,8 +22,6 @@ from mox3.mox import IsA
import six import six
from openstack_dashboard import api from openstack_dashboard import api
from openstack_dashboard.dashboards.project.key_pairs.forms \
import CreateKeypair
from openstack_dashboard.dashboards.project.key_pairs.forms \ from openstack_dashboard.dashboards.project.key_pairs.forms \
import KEYPAIR_ERROR_MESSAGES import KEYPAIR_ERROR_MESSAGES
from openstack_dashboard.test import helpers as test from openstack_dashboard.test import helpers as test
@ -81,37 +79,6 @@ class KeyPairTests(test.TestCase):
res = self.client.post(INDEX_URL, formData) res = self.client.post(INDEX_URL, formData)
self.assertRedirectsNoFollow(res, INDEX_URL) self.assertRedirectsNoFollow(res, INDEX_URL)
def test_create_keypair_get(self):
res = self.client.get(
reverse('horizon:project:key_pairs:create'))
self.assertTemplateUsed(
res, 'project/key_pairs/create.html')
def test_download_keypair_get(self):
keypair_name = "keypair"
context = {'keypair_name': keypair_name}
url = reverse('horizon:project:key_pairs:download',
kwargs={'keypair_name': keypair_name})
res = self.client.get(url, context)
self.assertTemplateUsed(
res, 'project/key_pairs/download.html')
@test.create_stubs({api.nova: ('keypair_create',)})
def test_generate_keypair_get(self):
keypair = self.keypairs.first()
keypair.private_key = "secret"
api.nova.keypair_create(IsA(http.HttpRequest),
keypair.name).AndReturn(keypair)
self.mox.ReplayAll()
context = {'keypair_name': keypair.name}
url = reverse('horizon:project:key_pairs:generate',
kwargs={'keypair_name': keypair.name})
res = self.client.get(url, context)
self.assertTrue(res.has_header('content-disposition'))
@test.create_stubs({api.nova: ('keypair_get',)}) @test.create_stubs({api.nova: ('keypair_get',)})
def test_keypair_detail_get(self): def test_keypair_detail_get(self):
keypair = self.keypairs.first() keypair = self.keypairs.first()
@ -127,22 +94,6 @@ class KeyPairTests(test.TestCase):
res = self.client.get(url, context) res = self.client.get(url, context)
self.assertContains(res, "<dd>%s</dd>" % keypair.name, 1, 200) self.assertContains(res, "<dd>%s</dd>" % keypair.name, 1, 200)
@test.create_stubs({api.nova: ("keypair_create", "keypair_delete",)})
def test_regenerate_keypair_get(self):
keypair = self.keypairs.first()
keypair.private_key = "secret"
optional_param = "regenerate"
api.nova.keypair_delete(IsA(http.HttpRequest), keypair.name)
api.nova.keypair_create(IsA(http.HttpRequest),
keypair.name).AndReturn(keypair)
self.mox.ReplayAll()
url = reverse('horizon:project:key_pairs:generate',
kwargs={'keypair_name': keypair.name,
'optional': optional_param})
res = self.client.get(url)
self.assertTrue(res.has_header('content-disposition'))
@test.create_stubs({api.nova: ("keypair_import",)}) @test.create_stubs({api.nova: ("keypair_import",)})
def test_import_keypair(self): def test_import_keypair(self):
key1_name = "new_key_pair" key1_name = "new_key_pair"
@ -204,22 +155,6 @@ class KeyPairTests(test.TestCase):
msg = six.text_type(KEYPAIR_ERROR_MESSAGES['invalid']) msg = six.text_type(KEYPAIR_ERROR_MESSAGES['invalid'])
self.assertFormErrors(res, count=1, message=msg) self.assertFormErrors(res, count=1, message=msg)
@test.create_stubs({api.nova: ("keypair_create",)})
def test_generate_keypair_exception(self):
keypair = self.keypairs.first()
api.nova.keypair_create(IsA(http.HttpRequest), keypair.name) \
.AndRaise(self.exceptions.nova)
self.mox.ReplayAll()
context = {'keypair_name': keypair.name}
url = reverse('horizon:project:key_pairs:generate',
kwargs={'keypair_name': keypair.name})
res = self.client.get(url, context)
self.assertRedirectsNoFollow(
res, reverse('horizon:project:key_pairs:index'))
@test.create_stubs({api.nova: ("keypair_import",)}) @test.create_stubs({api.nova: ("keypair_import",)})
def test_import_keypair_with_regex_defined_name(self): def test_import_keypair_with_regex_defined_name(self):
key1_name = "new-key-pair with_regex" key1_name = "new-key-pair with_regex"
@ -236,42 +171,3 @@ class KeyPairTests(test.TestCase):
url = reverse('horizon:project:key_pairs:import') url = reverse('horizon:project:key_pairs:import')
res = self.client.post(url, formData) res = self.client.post(url, formData)
self.assertMessageCount(res, success=1) self.assertMessageCount(res, success=1)
@test.create_stubs({api.nova: ("keypair_create",)})
def test_create_keypair_with_regex_name_get(self):
keypair = self.keypairs.first()
keypair.name = "key-space pair-regex_name-0123456789"
keypair.private_key = "secret"
api.nova.keypair_create(IsA(http.HttpRequest),
keypair.name).AndReturn(keypair)
self.mox.ReplayAll()
context = {'keypair_name': keypair.name}
url = reverse('horizon:project:key_pairs:generate',
kwargs={'keypair_name': keypair.name})
res = self.client.get(url, context)
self.assertTrue(res.has_header('content-disposition'))
def test_download_with_regex_name_get(self):
keypair_name = "key pair-regex_name-0123456789"
context = {'keypair_name': keypair_name}
url = reverse('horizon:project:key_pairs:download',
kwargs={'keypair_name': keypair_name})
res = self.client.get(url, context)
self.assertTemplateUsed(
res, 'project/key_pairs/download.html')
@test.create_stubs({api.nova: ('keypair_list',)})
def test_create_duplicate_keypair(self):
keypair_name = self.keypairs.first().name
api.nova.keypair_list(IsA(http.HttpRequest)) \
.AndReturn(self.keypairs.list())
self.mox.ReplayAll()
form = CreateKeypair(self.request, data={'name': keypair_name})
self.assertFalse(form.is_valid())
self.assertIn('The name is already in use.',
form.errors['name'][0])

View File

@ -21,14 +21,7 @@ from openstack_dashboard.dashboards.project.key_pairs import views
urlpatterns = [ urlpatterns = [
url(r'^$', views.IndexView.as_view(), name='index'), url(r'^$', views.IndexView.as_view(), name='index'),
url(r'^create/$', views.CreateView.as_view(), name='create'),
url(r'^import/$', views.ImportView.as_view(), name='import'), url(r'^import/$', views.ImportView.as_view(), name='import'),
url(r'^(?P<keypair_name>[^/]+)/download/$', views.DownloadView.as_view(),
name='download'),
url(r'^(?P<keypair_name>[^/]+)/generate/$', views.GenerateView.as_view(),
name='generate'),
url(r'^(?P<keypair_name>[^/]+)/(?P<optional>[^/]+)/generate/$',
views.GenerateView.as_view(), name='generate'),
url(r'^(?P<keypair_name>[^/]+)/$', views.DetailView.as_view(), url(r'^(?P<keypair_name>[^/]+)/$', views.DetailView.as_view(),
name='detail'), name='detail'),
] ]

View File

@ -14,12 +14,7 @@
from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse
from django.core.urlresolvers import reverse_lazy from django.core.urlresolvers import reverse_lazy
from django import http
from django.template.defaultfilters import slugify
from django.utils.decorators import method_decorator
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from django.views.decorators.cache import cache_control
from django.views.decorators.cache import never_cache
from horizon import exceptions from horizon import exceptions
from horizon import forms from horizon import forms
@ -55,20 +50,6 @@ class IndexView(tables.DataTableView):
return keypairs return keypairs
class CreateView(forms.ModalFormView):
form_class = key_pairs_forms.CreateKeypair
template_name = 'project/key_pairs/create.html'
submit_url = reverse_lazy(
"horizon:project:key_pairs:create")
success_url = 'horizon:project:key_pairs:download'
submit_label = page_title = _("Create Key Pair")
cancel_url = reverse_lazy("horizon:project:key_pairs:index")
def get_success_url(self):
return reverse(self.success_url,
kwargs={"keypair_name": self.request.POST['name']})
class ImportView(forms.ModalFormView): class ImportView(forms.ModalFormView):
form_class = key_pairs_forms.ImportKeypair form_class = key_pairs_forms.ImportKeypair
template_name = 'project/key_pairs/import.html' template_name = 'project/key_pairs/import.html'
@ -103,37 +84,3 @@ class DetailView(views.HorizonTemplateView):
context = super(DetailView, self).get_context_data(**kwargs) context = super(DetailView, self).get_context_data(**kwargs)
context['keypair'] = self._get_data() context['keypair'] = self._get_data()
return context return context
class DownloadView(views.HorizonTemplateView):
template_name = 'project/key_pairs/download.html'
page_title = _("Download Key Pair")
def get_context_data(self, keypair_name=None):
return {'keypair_name': keypair_name}
class GenerateView(views.HorizonTemplateView):
# TODO(Itxaka): Remove cache_control in django >= 1.9
# https://code.djangoproject.com/ticket/13008
@method_decorator(cache_control(max_age=0, no_cache=True,
no_store=True, must_revalidate=True))
@method_decorator(never_cache)
def get(self, request, keypair_name=None, optional=None):
try:
if optional == "regenerate":
nova.keypair_delete(request, keypair_name)
keypair = nova.keypair_create(request, keypair_name)
except Exception:
redirect = reverse('horizon:project:key_pairs:index')
exceptions.handle(self.request,
_('Unable to create key pair: %(exc)s'),
redirect=redirect)
response = http.HttpResponse(content_type='application/binary')
response['Content-Disposition'] = ('attachment; filename=%s.pem'
% slugify(keypair.name))
response.write(keypair.private_key)
response['Content-Length'] = str(len(response.content))
return response

View File

@ -24,9 +24,7 @@
LaunchInstanceCreateKeyPairController.$inject = [ LaunchInstanceCreateKeyPairController.$inject = [
'$uibModalInstance', '$uibModalInstance',
'existingKeypairs', 'existingKeypairs',
'horizon.app.core.openstack-service-api.nova', 'horizon.app.core.openstack-service-api.nova'
'horizon.framework.widgets.toast.service',
'horizon.app.core.openstack-service-api.keypair-download-service'
]; ];
/** /**
@ -35,22 +33,21 @@
* @param {Object} $uibModalInstance * @param {Object} $uibModalInstance
* @param {Object} existingKeypairs * @param {Object} existingKeypairs
* @param {Object} nova * @param {Object} nova
* @param {Object} toastService
* @param {Object} keypairDownloadService
* @description * @description
* Provide a dialog for creation of a new key pair. * Provide a dialog for creation of a new key pair.
* @returns {undefined} Returns nothing * @returns {undefined} Returns nothing
*/ */
function LaunchInstanceCreateKeyPairController($uibModalInstance, existingKeypairs, nova, function LaunchInstanceCreateKeyPairController($uibModalInstance, existingKeypairs, nova) {
toastService, keypairDownloadService) {
var ctrl = this; var ctrl = this;
ctrl.submit = submit; ctrl.submit = submit;
ctrl.cancel = cancel; ctrl.cancel = cancel;
ctrl.doesKeypairExist = doesKeypairExist; ctrl.doesKeypairExist = doesKeypairExist;
ctrl.generate = generate;
ctrl.keypair = ''; ctrl.keypair = '';
ctrl.keypairExistsError = gettext('Keypair already exists or name contains bad characters.'); ctrl.keypairExistsError = gettext('Keypair already exists or name contains bad characters.');
ctrl.copyPrivateKey = copyPrivateKey;
/* /*
* @ngdoc function * @ngdoc function
@ -62,6 +59,21 @@
return exists(ctrl.keypair); return exists(ctrl.keypair);
} }
function generate() {
nova.createKeypair({name: ctrl.keypair}).then(onKeypairCreated);
function onKeypairCreated(data) {
ctrl.createdKeypair = data.data;
ctrl.privateKey = ctrl.createdKeypair.private_key;
ctrl.publicKey = ctrl.createdKeypair.public_key;
}
}
function copyPrivateKey() {
angular.element('textarea').select();
document.execCommand('copy');
}
/* /*
* @ngdoc function * @ngdoc function
* @name exists * @name exists
@ -84,17 +96,7 @@
* notified of the problem and given the opportunity to try again. * notified of the problem and given the opportunity to try again.
*/ */
function submit() { function submit() {
keypairDownloadService.createAndDownloadKeypair(ctrl.keypair).then( $uibModalInstance.close(ctrl.createdKeypair);
function success(createdKeypair) {
createdKeypair.regenerateUrl = nova.getRegenerateKeypairUrl(createdKeypair.name);
$uibModalInstance.close(createdKeypair);
},
function error() {
var errorMessage = interpolate(gettext('Unable to generate "%s". Please try again.'),
[ctrl.keypair]);
toastService.add('error', errorMessage);
}
);
} }
/* /*

View File

@ -77,36 +77,16 @@
}); });
it('should close the modal and return the created keypair', function() { it('should close the modal and return the created keypair', function() {
mockCreationSuccess = true;
mockKeypair = {
name: "newKeypair"
};
spyOn(createKeypairServiceMock, 'getRegenerateKeypairUrl').and.returnValue(
"a url"
);
spyOn(modalInstanceMock, 'close'); spyOn(modalInstanceMock, 'close');
ctrl.createdKeypair = {name: 'newKeypair'};
ctrl.submit(); ctrl.submit();
expect(modalInstanceMock.close).toHaveBeenCalledWith({ expect(modalInstanceMock.close).toHaveBeenCalledWith({
name: "newKeypair", name: "newKeypair"
regenerateUrl: "a url"
}); });
}); });
it('should raise a toast error message when create is unsuccessful', function() {
mockCreationSuccess = false;
spyOn(toastServiceMock, 'add');
ctrl.keypair = "aKeypair";
ctrl.submit();
expect(toastServiceMock.add).toHaveBeenCalledWith(
'error',
'Unable to generate "aKeypair". Please try again.'
);
});
it('defines a submit function', function() { it('defines a submit function', function() {
expect(ctrl.submit).toBeDefined(); expect(ctrl.submit).toBeDefined();
}); });

View File

@ -29,17 +29,30 @@
{$ ctrl.keypairExistsError $} {$ ctrl.keypairExistsError $}
</span> </span>
</div> </div>
<div class="form-group" ng-if="ctrl.privateKey">
<label for="private-key" translate>
Private Key
<span class="hz-icon-required fa"></span>
</label>
<!-- Note: textarea is used here (instead of pre) due to the fact that ctrl.copyPrivateKey() uses
the HTMLInputElement.select() function which is only present on input elements -->
<textarea class="form-control" id="private-key" rows="15"
ng-model="ctrl.privateKey" readonly></textarea>
</div>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<button class="btn btn-default pull-left" ng-click="ctrl.cancel()">
<span class="fa fa-close"></span>
<translate>Cancel</translate>
</button>
<button class="btn btn-primary" <button class="btn btn-primary"
ng-click="ctrl.submit()" ng-disabled="wizardForm.$invalid || ctrl.doesKeypairExist()"> ng-click="ctrl.generate()" ng-disabled="wizardForm.$invalid || ctrl.doesKeypairExist() || ctrl.privateKey">
<translate>Create Keypair</translate> <translate>Create Keypair</translate>
</button> </button>
<button class="btn btn-primary"
ng-click="ctrl.copyPrivateKey()" ng-disabled="!ctrl.privateKey">
<translate>Copy Private Key to Clipboard</translate>
</button>
<button class="btn btn-primary"
ng-click="ctrl.submit()" ng-disabled="!ctrl.privateKey">
<translate>Done</translate>
</button>
</div> </div>
</div> </div>

View File

@ -36,6 +36,7 @@
* @param {Object} launchInstanceModel * @param {Object} launchInstanceModel
* @param {Object} $uibModal * @param {Object} $uibModal
* @param {Object} toastService * @param {Object} toastService
* @param {Object} settingsService
* @description * @description
* Allows selection of key pairs. * Allows selection of key pairs.
* @returns {undefined} No return value * @returns {undefined} No return value

View File

@ -4,18 +4,6 @@
You may select an existing key pair, import a key pair, or generate a new key pair. You may select an existing key pair, import a key pair, or generate a new key pair.
</p> </p>
<div ng-if="ctrl.isKeypairCreated" class="alert alert-info" role="alert">
<p translate>A key pair named '{$ctrl.createdKeypair.name$}' was successfully created. This key pair should automatically download.</p>
<p translate>If not, you can manually download this keypair at the following link:</p>
<a class="btn btn-default" role="button" href="{$ ctrl.createdKeypair.regenerateUrl $}">
<span class="fa fa-download"></span>
{$ctrl.createdKeypair.name$}
</a>
<p translate>
Note: you will not be able to download this later.
</p>
</div>
<button type="button" class="btn btn-default" <button type="button" class="btn btn-default"
ng-click="ctrl.createKeyPair()"> ng-click="ctrl.createKeyPair()">
<span class="fa fa-plus"></span> <span class="fa fa-plus"></span>

View File

@ -36,6 +36,7 @@
'horizon.app.core.cloud-services', 'horizon.app.core.cloud-services',
'horizon.app.core.flavors', 'horizon.app.core.flavors',
'horizon.app.core.images', 'horizon.app.core.images',
'horizon.app.core.keypairs',
'horizon.app.core.metadata', 'horizon.app.core.metadata',
'horizon.app.core.network_qos', 'horizon.app.core.network_qos',
'horizon.app.core.openstack-service-api', 'horizon.app.core.openstack-service-api',

View File

@ -0,0 +1,97 @@
/*
* (c) Copyright 2015 Hewlett-Packard Development Company, L.P.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
(function () {
'use strict';
angular
.module('horizon.app.core.keypairs')
.controller('KeypairController', KeypairController);
KeypairController.$inject = [
'horizon.dashboard.project.workflow.launch-instance.basePath',
'horizon.app.core.openstack-service-api.nova',
'horizon.framework.widgets.modal-wait-spinner.service',
'$window',
'$uibModal'
];
/**
* @ngdoc controller
* @name KeypairController
* @param {string} basePath
* @param {Object} $uibModal
* @description
* Allows creation of key pairs.
* @returns {undefined} No return value
*/
function KeypairController(
basePath,
nova,
spinnerService,
$window,
$uibModal
) {
var ctrl = this;
ctrl.createKeyPair = createKeyPair;
//////////
function setKeyPairs(config) {
return function(response) {
var keyPairs = response.data.items.map(getName);
$uibModal.open({
templateUrl: basePath + 'keypair/create-keypair.html',
controller: 'LaunchInstanceCreateKeyPairController as ctrl',
windowClass: 'modal-dialog-wizard',
resolve: {
existingKeypairs: getKeypairs
}
}).result.then(go(config.successUrl));
function getName(item) {
return item.keypair.name;
}
function getKeypairs() {
return keyPairs;
}
};
}
/**
* @ngdoc function
* @name createKeyPair
* @description
* Launches the modal to create a key pair.
* @returns {undefined} No return value
*/
function createKeyPair(config) {
nova.getKeypairs().then(setKeyPairs(config));
}
function go(url) {
return function changeUrl() {
spinnerService.showModalSpinner(gettext('Please Wait'));
$window.location.href = url;
};
}
}
})();

View File

@ -0,0 +1,80 @@
/*
* (c) Copyright 2015 Hewlett-Packard Development Company, L.P.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
(function() {
'use strict';
describe('KeypairController', function() {
var ctrl, keyPairCall, $timeout;
var nova = {
getKeypairs: function() {
var kps = {data: {items: [{keypair: {name: 'one'}},{keypair: {name: 'two'}} ]}};
keyPairCall.resolve(kps);
return keyPairCall.promise;
}
};
var spinner = {
showModalSpinner: angular.noop
};
var config = {successUrl: '/some/where'};
var $uibModal = {
open: angular.noop
};
var $window = {location: {}};
beforeEach(module('horizon.app.core.keypairs'));
beforeEach(
inject(
function($controller, $rootScope, $q, _$timeout_) {
$timeout = _$timeout_;
ctrl = $controller('KeypairController', {
'horizon.dashboard.project.workflow.launch-instance.basePath': '/here/',
'horizon.app.core.openstack-service-api.nova': nova,
'horizon.framework.widgets.modal-wait-spinner.service': spinner,
'$uibModal': $uibModal,
'$window': $window
});
keyPairCall = $q.defer();
spyOn($uibModal, 'open').and.returnValue({result: $q.resolve({})});
}
)
);
describe('createKeyPair', function() {
it('opens the modal', function() {
ctrl.createKeyPair(config);
$timeout.flush();
expect($uibModal.open).toHaveBeenCalled();
});
it('provides a function to existingKeypairs that returns keypair names', function() {
ctrl.createKeyPair(config);
$timeout.flush();
var func = $uibModal.open.calls.argsFor(0)[0].resolve.existingKeypairs;
expect(func()).toEqual(['one','two']);
});
it('relocates to the config successUrl', function() {
ctrl.createKeyPair(config);
$timeout.flush();
expect($window.location.href).toBe('/some/where');
});
});
});
})();

View File

@ -0,0 +1,33 @@
/**
* (c) Copyright 2015 Hewlett-Packard Development Company, L.P.
*
* Licensed under the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License. You may obtain
* a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
(function() {
'use strict';
/**
* @ngdoc overview
* @ngname horizon.app.core.keypairs
*
* @description
* Provides all of the services and widgets required
* to support and display keypairs related content.
*/
angular
.module('horizon.app.core.keypairs', [
])
;
})();

View File

@ -0,0 +1,25 @@
/*
* Copyright 2017 SUSE LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
(function () {
'use strict';
describe('horizon.app.core.keypairs', function () {
it('should be defined', function () {
expect(angular.module('horizon.app.core.keypairs')).toBeDefined();
});
});
})();

View File

@ -1,153 +0,0 @@
/*
* (c) Copyright 2015 Hewlett-Packard Development Company, L.P.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
(function () {
'use strict';
/**
* @ngdoc overview
* @name horizon.dashboard.project.workflow.keypair.create-keypair-service
*
* @description
* Service to handle creating keypairs and downloading their private keys.
* Please note, the implementation has quirks due to what features are
* available in which browsers. As a result, the implementation involves
* using iframes to specify downloads. Since the API does not allow the
* full retrieval of the key pair after creation, and the URL retrieved in
* an iframe must be a GET method, and the fact that we shouldn't pass
* data as part of the URL (because it is potentially logged and contains
* private key data), we have to make the actual API call when performing
* the GET. This also means that if the user misses the download for some
* reason, we need to provide the ability to regenerate the key pair,
* although that is not a feature of this service.
*/
angular
.module('horizon.app.core.openstack-service-api')
.factory('horizon.app.core.openstack-service-api.keypair-download-service',
keypairDownloadService);
keypairDownloadService.$inject = [
'$document',
'horizon.app.core.openstack-service-api.nova',
'$q',
'$timeout'
];
function keypairDownloadService($document, novaAPI, $q, $timeout) {
var service = {
createAndDownloadKeypair: createAndDownloadKeypair
};
return service;
/**
* @ngdoc function
* @name createAndDownloadKeypair
*
* @description
* This function performs the actions necessary to begin a download
* of a newly created key pair. The given name will be used as the
* logical name of the key pair and will be used to make a file-system-
* friendly filename.
* In this implementation, for browser compatibility reasons, the
* download is achieved by creating an iframe with the given path for
* the create API call given, so the results are streamed directly to the
* client. This is not ideal but is due to lack of support in IE for
* features like the data: protocol. The iframes require that an element
* with the class of 'download-iframes' is present.
*
* @param {string} name The desired name for the key pair
* @returns {promise} A promise resolving if true, rejecting with error
*/
function createAndDownloadKeypair(name) {
addDOMResource(name);
return verifyCreatedPromise(name);
}
/**
* @ngdoc function
* @name addDOMResource
*
* @description
* This adds an iframe to the body of the current document, using
* the appropriate URL for the API to create/download the new key pair.
*
* @param {string} keypairName The desired name for the key pair
* @returns {undefined} Returns nothing
*/
function addDOMResource(keypairName) {
var url = novaAPI.getCreateKeypairUrl(keypairName);
var iframe = angular.element("<iframe></iframe>");
iframe.attr('id', keypairName);
iframe.attr('src', url);
iframe.attr('style', 'display: none;');
if ($document.find('.download-iframes').size() === 0) {
var iframeContainer = angular.element('<div class="download-iframes"></div>');
$document.find('body').append(iframeContainer);
}
$document.find('.download-iframes').append(iframe);
}
/**
* @ngdoc function
* @name verifyCreatedPromise
*
* @description
* This function returns a promise that tries ten times to see if a
* key pair of the given name exists in the key pair listing. These
* tries are one second apart. Once it has been found, the promise
* is resolved with the key pair data. If it is not found within the
* period, the promise is rejected.
*
* @param {string} name The name for the key pair
* @returns {promise} A promise resolving if true, rejecting with error
*/
function verifyCreatedPromise(name) {
return $q(function doesKeypairExistPromise(resolve, reject) {
doesKeypairExist(10);
function doesKeypairExist(timesToCheck) {
$timeout(function doesKeypairExistTimeout() {
novaAPI.getKeypairs().then(function isKeypairInResponse(response) {
var foundKeypairs = response.data.items.filter(function sameName(item) {
return item.keypair.name === name;
});
if (foundKeypairs.length === 1) {
resolve(foundKeypairs[0].keypair);
angular.element('.download-iframes #' + name).remove();
} else if (timesToCheck > 1) {
doesKeypairExist(timesToCheck - 1);
} else {
reject();
angular.element('.download-iframes #' + name).remove();
}
});
},
1000);
}
});
}
}
})();

View File

@ -1,166 +0,0 @@
/*
* (c) Copyright 2015 Hewlett-Packard Development Company, L.P.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
(function() {
'use strict';
describe('Download Keypair Service', function() {
var service, $scope, existingKeypairs, $timeout;
var documentMock = {
find: function() {
return documentFindMock;
}
};
var documentFindMock = {
append : angular.noop,
load: function (callback) {
callback();
},
size: function() {
return 1;
}
};
var novaAPIMock = {
getKeypairs: function() {
return {
then: function(callback) {
callback(existingKeypairs);
}
};
},
getCreateKeypairUrl: function() {
return "/some/given/path";
}
};
beforeEach(module('horizon.app.core.openstack-service-api'));
beforeEach(module(function ($provide) {
$provide.value('horizon.app.core.openstack-service-api.nova', novaAPIMock);
$provide.value('$document', documentMock);
}));
beforeEach(inject(function (_$injector_, _$rootScope_, _$timeout_) {
service = _$injector_.get(
'horizon.app.core.openstack-service-api.keypair-download-service'
);
$scope = _$rootScope_.$new();
$timeout = _$timeout_;
}));
it('adds the download key pair endpoint as a resource to the DOM', function() {
spyOn(documentFindMock, 'append');
spyOn(documentFindMock, 'load').and.returnValue({});
service.createAndDownloadKeypair("newKeypair");
var passedObj = documentFindMock.append.calls.argsFor(0)[0];
expect(passedObj).toBeDefined();
expect(passedObj.attr('id')).toBe("newKeypair");
});
it('encodes the URI component given slashes, etc.', function() {
spyOn(documentFindMock, 'append');
spyOn(documentFindMock, 'load').and.returnValue({});
service.createAndDownloadKeypair("new/Keypair");
var passedObj = documentFindMock.append.calls.argsFor(0)[0];
expect(passedObj).toBeDefined();
expect(passedObj.attr('id')).toBe("new/Keypair");
});
it('creates a div with download-iframes if not present', function() {
spyOn(documentFindMock, 'append');
spyOn(documentFindMock, 'load').and.returnValue({});
spyOn(documentFindMock, 'size').and.returnValue(0);
service.createAndDownloadKeypair("new/Keypair");
expect(documentFindMock.append.calls.count()).toBe(2);
expect(documentFindMock.append.calls.allArgs().some(function(x) {
return x[0].attr('class') === 'download-iframes';
})).toBe(true);
});
it('checks that the keypair was added and returns a success promise result', function() {
existingKeypairs = {
data: {
items:[{
keypair: {
name: "newKeypair"
}
}]
}
};
var promiseSuccessful, keypair;
service.createAndDownloadKeypair("newKeypair").then(
function success(createdKeypair) {
promiseSuccessful = true;
keypair = createdKeypair;
}
);
$timeout.flush();
$scope.$apply();
expect(promiseSuccessful).toEqual(true);
expect(keypair).toEqual({name: "newKeypair"});
});
it('checks that the keypair was not added and returns a error promise result', function() {
existingKeypairs = {
data: {
items:[]
}
};
var promiseErrored;
service.createAndDownloadKeypair("newKeypair").then(
function success() {},
function error() {
promiseErrored = true;
}
);
// checks 10 times after one second
$timeout.flush();
$timeout.flush();
$timeout.flush();
$timeout.flush();
$timeout.flush();
$timeout.flush();
$timeout.flush();
$timeout.flush();
$timeout.flush();
$timeout.flush();
$scope.$apply();
expect(promiseErrored).toEqual(true);
});
});
})();

View File

@ -23,20 +23,18 @@
novaAPI.$inject = [ novaAPI.$inject = [
'horizon.framework.util.http.service', 'horizon.framework.util.http.service',
'horizon.framework.widgets.toast.service', 'horizon.framework.widgets.toast.service'
'$window'
]; ];
/** /**
* @ngdoc service * @ngdoc service
* @param {Object} apiService * @param {Object} apiService
* @param {Object} toastService * @param {Object} toastService
* @param {Object} $window
* @name novaApi * @name novaApi
* @description Provides access to Nova APIs. * @description Provides access to Nova APIs.
* @returns {Object} The service * @returns {Object} The service
*/ */
function novaAPI(apiService, toastService, $window) { function novaAPI(apiService, toastService) {
var service = { var service = {
getActionList: getActionList, getActionList: getActionList,
@ -71,8 +69,6 @@
getServices: getServices, getServices: getServices,
getInstanceMetadata: getInstanceMetadata, getInstanceMetadata: getInstanceMetadata,
editInstanceMetadata: editInstanceMetadata, editInstanceMetadata: editInstanceMetadata,
getCreateKeypairUrl: getCreateKeypairUrl,
getRegenerateKeypairUrl: getRegenerateKeypairUrl,
createFlavor: createFlavor, createFlavor: createFlavor,
updateFlavor: updateFlavor, updateFlavor: updateFlavor,
deleteFlavor: deleteFlavor, deleteFlavor: deleteFlavor,
@ -743,44 +739,6 @@
}); });
} }
/**
* @ngdoc function
* @name getCreateKeypairUrl
*
* @description
* Returns a URL, respecting WEBROOT, that if called as a REST call
* would create and return a new key pair with the given name. This
* function is provided because to perform a download of the key pair,
* an iframe must be given a URL to use (which is further explained in
* the key pair download service).
*
* @param {string} keyPairName
* @returns {Object} The result of the API call
*/
function getCreateKeypairUrl(keyPairName) {
// NOTE: WEBROOT by definition must end with a slash (local_settings.py).
return $window.WEBROOT + "api/nova/keypairs/" +
encodeURIComponent(keyPairName) + "/";
}
/**
* @ngdoc function
* @name getRegenerateKeypairUrl
*
* @description
* Returns a URL, respecting WEBROOT, that if called as a REST call
* would regenereate an existing key pair with the given name and return
* the new key pair data. This function is provided because to perform
* a download of the key pair, an iframe must be given a URL to use
* (which is further explained in the key pair download service).
*
* @param {string} keyPairName
* @returns {Object} The result of the API call
*/
function getRegenerateKeypairUrl(keyPairName) {
return getCreateKeypairUrl(keyPairName) + "?regenerate=true";
}
/** /**
* @name createServerSnapshot * @name createServerSnapshot
* @param {Object} newSnapshot - The new server snapshot * @param {Object} newSnapshot - The new server snapshot

View File

@ -560,53 +560,4 @@
}); });
//// This is separated due to differences in what is being tested.
describe('Keypair functions', function() {
var service, $window;
beforeEach(module('horizon.app.core.openstack-service-api'));
beforeEach(module(function ($provide) {
$provide.value('horizon.framework.util.http.service', {});
$provide.value('horizon.framework.widgets.toast.service', {});
}));
beforeEach(inject(function (_$injector_, _$rootScope_, _$timeout_, _$window_) {
service = _$injector_.get(
'horizon.app.core.openstack-service-api.nova'
);
$window = _$window_;
$window.WEBROOT = '/';
}));
afterEach(inject(function (_$window_) {
$window = _$window_;
$window.WEBROOT = '/';
}));
it('returns a link to download the private key for an existing keypair', function() {
var link = service.getCreateKeypairUrl("keypairName");
expect(link).toEqual('/api/nova/keypairs/keypairName/');
});
it('returns a WEBROOT link to download the private key for an existing keypair', function() {
$window.WEBROOT = '/myroot/';
var link = service.getCreateKeypairUrl("keypairName");
expect(link).toEqual('/myroot/api/nova/keypairs/keypairName/');
});
it('returns a link to redownload the private key for an existing keypair', function() {
var link = service.getRegenerateKeypairUrl("keypairName");
expect(link).toEqual('/api/nova/keypairs/keypairName/?regenerate=true');
});
it('returns a WEBROOT link to redownload the private key for an existing keypair', function() {
$window.WEBROOT = '/myroot/';
var link = service.getRegenerateKeypairUrl("keypairName");
expect(link).toEqual('/myroot/api/nova/keypairs/keypairName/?regenerate=true');
});
});
})(); })();

View File

@ -22,8 +22,6 @@ from openstack_dashboard.api.rest import nova
from openstack_dashboard.test import helpers as test from openstack_dashboard.test import helpers as test
from openstack_dashboard.usage import quotas from openstack_dashboard.usage import quotas
from novaclient import exceptions
class NovaRestTestCase(test.TestCase): class NovaRestTestCase(test.TestCase):
# #
@ -216,67 +214,6 @@ class NovaRestTestCase(test.TestCase):
self.assertEqual('/api/nova/keypairs/Ni%21', response['location']) self.assertEqual('/api/nova/keypairs/Ni%21', response['location'])
nc.keypair_import.assert_called_once_with(request, 'Ni!', 'hi') nc.keypair_import.assert_called_once_with(request, 'Ni!', 'hi')
def test_keypair_create_and_download(self):
self._test_keypair_create_and_download(False)
def test_keypair_recreate_and_download(self):
self._test_keypair_create_and_download(True)
@mock.patch.object(nova.api, 'nova')
def _test_keypair_create_and_download(self, recreate_keypair, nc):
params = {}
if recreate_keypair:
params = {'regenerate': 'true'}
request = self.mock_rest_request(GET=params)
keypair_create_response = mock.Mock()
keypair_create_response.private_key = "private key content"
nc.keypair_create.return_value = keypair_create_response
with mock.patch.object(settings, 'DEBUG', True):
response = nova.Keypair().get(request, "Ni!")
if recreate_keypair:
nc.keypair_delete.assert_called_once_with(request, 'Ni!')
else:
nc.keypair_delete.assert_not_called()
nc.keypair_create.assert_called_once_with(request, 'Ni!')
self.assertStatusCode(response, 200)
self.assertEqual(
'attachment; filename=ni.pem',
response['Content-Disposition'])
self.assertEqual(
"private key content",
response.content.decode('utf-8'))
self.assertEqual('19', response['Content-Length'])
@mock.patch.object(nova.api, 'nova')
def test_keypair_fail_to_create_because_already_exists(self, nc):
request = self.mock_rest_request(GET={})
conflict_exception = exceptions.Conflict(409, 'keypair exists!')
nc.keypair_create.side_effect = conflict_exception
with mock.patch.object(settings, 'DEBUG', True):
response = nova.Keypair().get(request, "Ni!")
self.assertEqual(409, response.status_code)
@mock.patch.object(nova.api, 'nova')
def test_keypair_fail_to_create(self, nc):
request = self.mock_rest_request(GET={})
surprise_exception = exceptions.ClientException(501, 'Boom!')
nc.keypair_create.side_effect = surprise_exception
with mock.patch.object(settings, 'DEBUG', True):
response = nova.Keypair().get(request, "Ni!")
self.assertEqual(500, response.status_code)
# #
# Availability Zones # Availability Zones
# #