From cf91124d0c97ae80c565ba0b03a41aa2579b998c Mon Sep 17 00:00:00 2001 From: Brad Pokorny Date: Thu, 23 Jun 2016 13:24:48 -0700 Subject: [PATCH] Choose a server group when booting a VM with NG launch instance Allow users to choose a server group when booting a VM. Adds an optional workflow step to the launch instance workflow that shows the available server groups and details about each group. The ability to choose a server groups already exists for the legacy launch instance workflow as a dropdown list, but having it as a separate step in the angular workflow provides the added capability of seeing group details. To test this patch, create a server group via the nova CLI. Example: nova server-group-create group1 affinity And use the angular launch instance workflow to select a server group. To validate the new instance was added to the server group, use the nova CLI: nova server-group-get [ID of server group] Change-Id: I651817850ef8a5afec047a9a481843a5eddbf5a9 Implements: blueprint nova-server-groups --- openstack_dashboard/api/rest/nova.py | 16 ++++ .../launch-instance-model.service.js | 28 ++++++- .../launch-instance-model.service.spec.js | 49 ++++++++++-- .../launch-instance-workflow.service.js | 8 ++ .../launch-instance-workflow.service.spec.js | 11 ++- .../launch-instance/launch-instance.module.js | 4 +- .../server-groups/server-group-details.html | 13 ++++ .../server-groups/server-groups.controller.js | 68 +++++++++++++++++ .../server-groups/server-groups.help.html | 11 +++ .../server-groups/server-groups.html | 73 ++++++++++++++++++ .../server-groups/server-groups.spec.js | 74 +++++++++++++++++++ .../openstack-service-api/nova.service.js | 17 +++++ .../test/api_tests/nova_rest_tests.py | 17 +++++ 13 files changed, 376 insertions(+), 13 deletions(-) create mode 100644 openstack_dashboard/dashboards/project/static/dashboard/project/workflow/launch-instance/server-groups/server-group-details.html create mode 100644 openstack_dashboard/dashboards/project/static/dashboard/project/workflow/launch-instance/server-groups/server-groups.controller.js create mode 100644 openstack_dashboard/dashboards/project/static/dashboard/project/workflow/launch-instance/server-groups/server-groups.help.html create mode 100644 openstack_dashboard/dashboards/project/static/dashboard/project/workflow/launch-instance/server-groups/server-groups.html create mode 100644 openstack_dashboard/dashboards/project/static/dashboard/project/workflow/launch-instance/server-groups/server-groups.spec.js diff --git a/openstack_dashboard/api/rest/nova.py b/openstack_dashboard/api/rest/nova.py index 67ea2dab9b..aae5959394 100644 --- a/openstack_dashboard/api/rest/nova.py +++ b/openstack_dashboard/api/rest/nova.py @@ -268,6 +268,22 @@ class Server(generic.View): return api.nova.server_get(request, server_id).to_dict() +@urls.register +class ServerGroups(generic.View): + """API for nova server groups. + """ + url_regex = r'nova/servergroups/$' + + @rest_utils.ajax() + def get(self, request): + """Get a list of server groups. + + The listing result is an object with property "items". + """ + result = api.nova.server_group_list(request) + return {'items': [u.to_dict() for u in result]} + + @urls.register class ServerMetadata(generic.View): """API for server metadata. diff --git a/openstack_dashboard/dashboards/project/static/dashboard/project/workflow/launch-instance/launch-instance-model.service.js b/openstack_dashboard/dashboards/project/static/dashboard/project/workflow/launch-instance/launch-instance-model.service.js index dd3b348ef2..1b98f75976 100644 --- a/openstack_dashboard/dashboards/project/static/dashboard/project/workflow/launch-instance/launch-instance-model.service.js +++ b/openstack_dashboard/dashboards/project/static/dashboard/project/workflow/launch-instance/launch-instance-model.service.js @@ -138,6 +138,7 @@ novaLimits: {}, profiles: [], securityGroups: [], + serverGroups: [], volumeBootable: false, volumes: [], volumeSnapshots: [], @@ -172,8 +173,10 @@ networks: [], ports: [], profile: {}, + scheduler_hints: {}, // REQUIRED Server Key. May be empty. security_groups: [], + server_groups: [], // REQUIRED for JS logic (image | snapshot | volume | volume_snapshot) source_type: null, source: [], @@ -239,6 +242,7 @@ // This provides supplemental data non-critical to launching // an instance. Therefore we load it only if the critical data // all loads successfully. + getServerGroups(); getMetadataDefinitions(); } @@ -276,6 +280,7 @@ setFinalSpecPorts(finalSpec); setFinalSpecKeyPairs(finalSpec); setFinalSpecSecurityGroups(finalSpec); + setFinalSpecServerGroup(finalSpec); setFinalSpecSchedulerHints(finalSpec); setFinalSpecMetadata(finalSpec); @@ -389,6 +394,26 @@ finalSpec.security_groups = securityGroupIds; } + // Server Groups + + function getServerGroups() { + if (policy.check(stepPolicy.serverGroups)) { + return novaAPI.getServerGroups().then(onGetServerGroups, noop); + } + } + + function onGetServerGroups(data) { + model.serverGroups.length = 0; + push.apply(model.serverGroups, data.data.items); + } + + function setFinalSpecServerGroup(finalSpec) { + if (finalSpec.server_groups.length > 0) { + finalSpec.scheduler_hints.group = finalSpec.server_groups[0].id; + } + delete finalSpec.server_groups; + } + // Networks function getNetworks() { @@ -624,9 +649,8 @@ var hints = model.hintsTree.getExisting(); if (!angular.equals({}, hints)) { angular.forEach(hints, function(value, key) { - hints[key] = value + ''; + finalSpec.scheduler_hints[key] = value + ''; }); - finalSpec.scheduler_hints = hints; } } } diff --git a/openstack_dashboard/dashboards/project/static/dashboard/project/workflow/launch-instance/launch-instance-model.service.spec.js b/openstack_dashboard/dashboards/project/static/dashboard/project/workflow/launch-instance/launch-instance-model.service.spec.js index 3de01e533c..b1238bb2ef 100644 --- a/openstack_dashboard/dashboards/project/static/dashboard/project/workflow/launch-instance/launch-instance-model.service.spec.js +++ b/openstack_dashboard/dashboards/project/static/dashboard/project/workflow/launch-instance/launch-instance-model.service.spec.js @@ -67,6 +67,14 @@ var deferred = $q.defer(); deferred.resolve({ data: limits }); + return deferred.promise; + }, + getServerGroups: function() { + var serverGroups = [ {'id': 'group-1'}, {'id': 'group-2'} ]; + + var deferred = $q.defer(); + deferred.resolve({ data: { items: serverGroups } }); + return deferred.promise; } }; @@ -201,6 +209,13 @@ deferred.resolve(); + return deferred.promise; + }, + check: function() { + var deferred = $q.defer(); + + deferred.resolve(); + return deferred.promise; } }); @@ -281,7 +296,8 @@ it('has empty arrays for all data', function() { var datasets = ['availabilityZones', 'flavors', 'allowedBootSources', 'images', 'imageSnapshots', 'keypairs', 'networks', - 'profiles', 'securityGroups', 'volumes', 'volumeSnapshots']; + 'profiles', 'securityGroups', 'serverGroups', 'volumes', + 'volumeSnapshots']; datasets.forEach(function(name) { expect(model[name]).toEqual([]); @@ -499,7 +515,7 @@ // This is here to ensure that as people add/change items, they // don't forget to implement tests for them. it('has the right number of properties', function() { - expect(Object.keys(model.newInstanceSpec).length).toBe(19); + expect(Object.keys(model.newInstanceSpec).length).toBe(21); }); it('sets availability zone to null', function() { @@ -554,6 +570,10 @@ expect(model.newInstanceSpec.security_groups).toEqual([]); }); + it('sets scheduler hints to an empty object', function() { + expect(model.newInstanceSpec.scheduler_hints).toEqual({}); + }); + it('sets source type to null', function() { expect(model.newInstanceSpec.source_type).toBeNull(); }); @@ -584,10 +604,12 @@ model.newInstanceSpec.key_pair = [ { name: 'keypair1' } ]; model.newInstanceSpec.security_groups = [ { id: 'adminId', name: 'admin' }, { id: 'demoId', name: 'demo' } ]; + model.newInstanceSpec.scheduler_hints = {}; model.newInstanceSpec.vol_create = true; model.newInstanceSpec.vol_delete_on_instance_delete = true; model.newInstanceSpec.vol_device_name = "volTestName"; model.newInstanceSpec.vol_size = 10; + model.newInstanceSpec.server_groups = []; metadata = {'foo': 'bar'}; model.metadataTree = { @@ -596,7 +618,7 @@ } }; - hints = {'group': 'group1'}; + hints = {'hint1': 'val1'}; model.hintsTree = { getExisting: function() { return hints; @@ -756,21 +778,34 @@ expect(finalSpec.meta).toBe(metadata); }); - it('should not have scheduler_hints property if no scheduler hints specified', function() { + it('should have only group for scheduler_hints if no other hints specified', function() { hints = {}; + model.newInstanceSpec.server_groups = [{'id': 'group1'}]; + var finalHints = {'group': model.newInstanceSpec.server_groups[0].id}; var finalSpec = model.createInstance(); - expect(finalSpec.scheduler_hints).toBeUndefined(); + expect(finalSpec.scheduler_hints).toEqual(finalHints); model.hintsTree = null; finalSpec = model.createInstance(); - expect(finalSpec.scheduler_hints).toBeUndefined(); + expect(finalSpec.scheduler_hints).toEqual(finalHints); }); it('should have scheduler_hints property if scheduler hints specified', function() { + var finalHints = hints; + finalHints.group = 'group1'; + var finalSpec = model.createInstance(); - expect(finalSpec.scheduler_hints).toBe(hints); + expect(finalSpec.scheduler_hints).toEqual(finalHints); + }); + + it('should have no scheduler_hints if no scheduler hints specified', function() { + hints = {}; + model.newInstanceSpec.server_groups = []; + + var finalSpec = model.createInstance(); + expect(finalSpec.scheduler_hints).toEqual({}); }); }); diff --git a/openstack_dashboard/dashboards/project/static/dashboard/project/workflow/launch-instance/launch-instance-workflow.service.js b/openstack_dashboard/dashboards/project/static/dashboard/project/workflow/launch-instance/launch-instance-workflow.service.js index 3f88d7e6d0..c4d5ab7288 100644 --- a/openstack_dashboard/dashboards/project/static/dashboard/project/workflow/launch-instance/launch-instance-workflow.service.js +++ b/openstack_dashboard/dashboards/project/static/dashboard/project/workflow/launch-instance/launch-instance-workflow.service.js @@ -89,6 +89,14 @@ helpUrl: basePath + 'configuration/configuration.help.html', formName: 'launchInstanceConfigurationForm' }, + { + id: 'servergroups', + title: gettext('Server Groups'), + templateUrl: basePath + 'server-groups/server-groups.html', + helpUrl: basePath + 'server-groups/server-groups.help.html', + formName: 'launchInstanceServerGroupsForm', + policy: stepPolicy.serverGroups + }, { id: 'hints', title: gettext('Scheduler Hints'), diff --git a/openstack_dashboard/dashboards/project/static/dashboard/project/workflow/launch-instance/launch-instance-workflow.service.spec.js b/openstack_dashboard/dashboards/project/static/dashboard/project/workflow/launch-instance/launch-instance-workflow.service.spec.js index 536a357921..b7bd1810c5 100644 --- a/openstack_dashboard/dashboards/project/static/dashboard/project/workflow/launch-instance/launch-instance-workflow.service.spec.js +++ b/openstack_dashboard/dashboards/project/static/dashboard/project/workflow/launch-instance/launch-instance-workflow.service.spec.js @@ -40,9 +40,9 @@ expect(launchInstanceWorkflow.title).toBeDefined(); }); - it('should have 10 steps defined', function () { + it('should have 11 steps defined', function () { expect(launchInstanceWorkflow.steps).toBeDefined(); - expect(launchInstanceWorkflow.steps.length).toBe(10); + expect(launchInstanceWorkflow.steps.length).toBe(11); var forms = [ 'launchInstanceDetailsForm', @@ -53,6 +53,7 @@ 'launchInstanceAccessAndSecurityForm', 'launchInstanceKeypairForm', 'launchInstanceConfigurationForm', + 'launchInstanceServerGroupsForm', 'launchInstanceSchedulerHintsForm', 'launchInstanceMetadataForm' ]; @@ -70,8 +71,12 @@ expect(launchInstanceWorkflow.steps[4].requiredServiceTypes).toEqual(['network']); }); + it('has a policy rule for the server groups step', function() { + expect(launchInstanceWorkflow.steps[8].policy).toEqual(stepPolicy.serverGroups); + }); + it('has a policy rule for the scheduler hints step', function() { - expect(launchInstanceWorkflow.steps[8].policy).toEqual(stepPolicy.schedulerHints); + expect(launchInstanceWorkflow.steps[9].policy).toEqual(stepPolicy.schedulerHints); }); }); diff --git a/openstack_dashboard/dashboards/project/static/dashboard/project/workflow/launch-instance/launch-instance.module.js b/openstack_dashboard/dashboards/project/static/dashboard/project/workflow/launch-instance/launch-instance.module.js index 2f4909e2c8..08b5f4cdad 100644 --- a/openstack_dashboard/dashboards/project/static/dashboard/project/workflow/launch-instance/launch-instance.module.js +++ b/openstack_dashboard/dashboards/project/static/dashboard/project/workflow/launch-instance/launch-instance.module.js @@ -46,7 +46,9 @@ .constant('horizon.dashboard.project.workflow.launch-instance.step-policy', { // This policy determines if the scheduler hints extension is discoverable when listing // available extensions. It's possible the extension is installed but not discoverable. - schedulerHints: { rules: [['compute', 'os_compute_api:os-scheduler-hints:discoverable']] } + schedulerHints: { rules: [['compute', 'os_compute_api:os-scheduler-hints:discoverable']] }, + // Determine if the server groups extension is discoverable. + serverGroups: { rules: [['compute', 'os_compute_api:os-server-groups:discoverable']] } }) .filter('diskFormat', diskFormat); diff --git a/openstack_dashboard/dashboards/project/static/dashboard/project/workflow/launch-instance/server-groups/server-group-details.html b/openstack_dashboard/dashboards/project/static/dashboard/project/workflow/launch-instance/server-groups/server-group-details.html new file mode 100644 index 0000000000..d61c115995 --- /dev/null +++ b/openstack_dashboard/dashboards/project/static/dashboard/project/workflow/launch-instance/server-groups/server-group-details.html @@ -0,0 +1,13 @@ + + + + + + + + + + + +
Policy
{$ policy | noValue $}
diff --git a/openstack_dashboard/dashboards/project/static/dashboard/project/workflow/launch-instance/server-groups/server-groups.controller.js b/openstack_dashboard/dashboards/project/static/dashboard/project/workflow/launch-instance/server-groups/server-groups.controller.js new file mode 100644 index 0000000000..7603c913fb --- /dev/null +++ b/openstack_dashboard/dashboards/project/static/dashboard/project/workflow/launch-instance/server-groups/server-groups.controller.js @@ -0,0 +1,68 @@ +/* + * Copyright 2016 Symantec Corp. + * + * 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.dashboard.project.workflow.launch-instance') + .controller('LaunchInstanceServerGroupsController', LaunchInstanceServerGroupsController); + + LaunchInstanceServerGroupsController.$inject = [ + 'launchInstanceModel', + 'horizon.dashboard.project.workflow.launch-instance.basePath' + ]; + + /** + * @ngdoc controller + * @name LaunchInstanceServerGroupsController + * @param {Object} launchInstanceModel + * @param {string} basePath + * @description + * Allows selection of server groups. + * @returns {undefined} No return value + */ + function LaunchInstanceServerGroupsController(launchInstanceModel, basePath) { + var ctrl = this; + + ctrl.tableData = { + available: launchInstanceModel.serverGroups, + allocated: launchInstanceModel.newInstanceSpec.server_groups, + displayedAvailable: [], + displayedAllocated: [] + }; + + ctrl.tableDetails = basePath + 'server-groups/server-group-details.html'; + + ctrl.tableHelp = { + /*eslint-disable max-len */ + noneAllocText: gettext('Select a server group from the available groups below.'), + /*eslint-enable max-len */ + availHelpText: gettext('Select one') + }; + + ctrl.tableLimits = { + maxAllocation: 1 + }; + + ctrl.filterFacets = [ + { + label: gettext('Name'), + name: 'name', + singleton: true + } + ]; + } +})(); diff --git a/openstack_dashboard/dashboards/project/static/dashboard/project/workflow/launch-instance/server-groups/server-groups.help.html b/openstack_dashboard/dashboards/project/static/dashboard/project/workflow/launch-instance/server-groups/server-groups.help.html new file mode 100644 index 0000000000..c0f5d2389f --- /dev/null +++ b/openstack_dashboard/dashboards/project/static/dashboard/project/workflow/launch-instance/server-groups/server-groups.help.html @@ -0,0 +1,11 @@ +
+

+ Server groups define collections of VM's so that the entire + collection can be given specific properties. For example, the policy of a + server group may specify that VM's in this group should not be placed on + the same physical hardware due to availability requirements. +

+

+ Server groups are project-specific and cannot be shared across projects. +

+
diff --git a/openstack_dashboard/dashboards/project/static/dashboard/project/workflow/launch-instance/server-groups/server-groups.html b/openstack_dashboard/dashboards/project/static/dashboard/project/workflow/launch-instance/server-groups/server-groups.html new file mode 100644 index 0000000000..67a30de9c9 --- /dev/null +++ b/openstack_dashboard/dashboards/project/static/dashboard/project/workflow/launch-instance/server-groups/server-groups.html @@ -0,0 +1,73 @@ +
+

+ Select the server group to launch the instance in. +

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + +
Name
+
+ {$ ::trCtrl.helpText.noneAllocText $} +
+
+
+ {$ ::trCtrl.helpText.noneAvailText $} +
+
+ + {$ row.name $} + + + + + + + + +
+
+
+ +
diff --git a/openstack_dashboard/dashboards/project/static/dashboard/project/workflow/launch-instance/server-groups/server-groups.spec.js b/openstack_dashboard/dashboards/project/static/dashboard/project/workflow/launch-instance/server-groups/server-groups.spec.js new file mode 100644 index 0000000000..7e3e9736cb --- /dev/null +++ b/openstack_dashboard/dashboards/project/static/dashboard/project/workflow/launch-instance/server-groups/server-groups.spec.js @@ -0,0 +1,74 @@ +/* + * Copyright 2016 Symantec Corp. + * + * 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('Launch Instance Server Groups Step', function() { + + describe('LaunchInstanceServerGroupsController', function() { + var ctrl; + + beforeEach(module('horizon.dashboard.project')); + + beforeEach(inject(function($controller) { + var model = { + newInstanceSpec: { + server_groups: [ 'server group 1' ] + }, + serverGroups: [ 'server group 1', 'server group 2' ] + }; + ctrl = $controller( + 'LaunchInstanceServerGroupsController', + { + launchInstanceModel: model, + 'horizon.dashboard.project.workflow.launch-instance.basePath': '' + }); + })); + + it('contains its table labels', function() { + expect(ctrl.tableData).toBeDefined(); + expect(Object.keys(ctrl.tableData).length).toBeGreaterThan(0); + }); + + it('sets table data to appropriate scoped items', function() { + expect(ctrl.tableData).toBeDefined(); + expect(Object.keys(ctrl.tableData).length).toBe(4); + expect(ctrl.tableData.available).toEqual([ 'server group 1', 'server group 2' ]); + expect(ctrl.tableData.allocated).toEqual([ 'server group 1' ]); + expect(ctrl.tableData.displayedAvailable).toEqual([]); + expect(ctrl.tableData.displayedAllocated).toEqual([]); + }); + + it('defines table details template', function() { + expect(ctrl.tableDetails).toBeDefined(); + }); + + it('defines table help', function() { + expect(ctrl.tableHelp).toBeDefined(); + expect(Object.keys(ctrl.tableHelp).length).toBe(2); + expect(ctrl.tableHelp.noneAllocText).toBeDefined(); + expect(ctrl.tableHelp.availHelpText).toBeDefined(); + }); + + it('allows only one allocation', function() { + expect(ctrl.tableLimits).toBeDefined(); + expect(Object.keys(ctrl.tableLimits).length).toBe(1); + expect(ctrl.tableLimits.maxAllocation).toBe(1); + }); + }); + + }); +})(); diff --git a/openstack_dashboard/static/app/core/openstack-service-api/nova.service.js b/openstack_dashboard/static/app/core/openstack-service-api/nova.service.js index 1340367d01..3f4e935aff 100644 --- a/openstack_dashboard/static/app/core/openstack-service-api/nova.service.js +++ b/openstack_dashboard/static/app/core/openstack-service-api/nova.service.js @@ -46,6 +46,7 @@ createServer: createServer, getServer: getServer, getServers: getServers, + getServerGroups: getServerGroups, getExtensions: getExtensions, getFlavors: getFlavors, getFlavor: getFlavor, @@ -246,6 +247,22 @@ }); } + /** + * @name getServerGroups + * @description + * Get a list of server groups. + * + * The listing result is an object with property "items". Each item is + * a server group. + * @returns {Object} The result of the API call + */ + function getServerGroups() { + return apiService.get('/api/nova/servergroups/') + .error(function () { + toastService.add('error', gettext('Unable to retrieve server groups.')); + }); + } + /** * @name getExtensions * @param {Object} config - A configuration object diff --git a/openstack_dashboard/test/api_tests/nova_rest_tests.py b/openstack_dashboard/test/api_tests/nova_rest_tests.py index 35e40187aa..dd32098e37 100644 --- a/openstack_dashboard/test/api_tests/nova_rest_tests.py +++ b/openstack_dashboard/test/api_tests/nova_rest_tests.py @@ -233,6 +233,23 @@ class NovaRestTestCase(test.TestCase): self.assertStatusCode(response, 200) nc.server_get.assert_called_once_with(request, "1") + # + # Server Groups + # + @mock.patch.object(nova.api, 'nova') + def test_server_group_list(self, nc): + request = self.mock_rest_request() + nc.server_group_list.return_value = [ + mock.Mock(**{'to_dict.return_value': {'id': '1'}}), + mock.Mock(**{'to_dict.return_value': {'id': '2'}}), + ] + + response = nova.ServerGroups().get(request) + self.assertStatusCode(response, 200) + self.assertEqual(response.json, + {'items': [{'id': '1'}, {'id': '2'}]}) + nc.server_group_list.assert_called_once_with(request) + # # Server Metadata #