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
This commit is contained in:
Brad Pokorny 2016-06-23 13:24:48 -07:00
parent e1c3d6ec73
commit cf91124d0c
13 changed files with 376 additions and 13 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,13 @@
<table st-table="row.policies"
class="table table-condensed table-rsp server-group-details">
<thead>
<tr>
<th st-sort="policy" st-sort-default translate>Policy</th>
</tr>
</thead>
<tbody>
<tr ng-repeat="policy in row.policies">
<td>{$ policy | noValue $}</td>
</tr>
</tbody>
</table>

View File

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

View File

@ -0,0 +1,11 @@
<div>
<p translate>
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.
</p>
<p translate>
Server groups are project-specific and cannot be shared across projects.
</p>
</div>

View File

@ -0,0 +1,73 @@
<div ng-controller="LaunchInstanceServerGroupsController as ctrl">
<p class="step-description" translate>
Select the server group to launch the instance in.
</p>
<transfer-table tr-model="ctrl.tableData"
help-text="ctrl.tableHelp"
limits="ctrl.tableLimits"
clone-content>
<table st-table="$displayedItems"
st-safe-src="$sourceItems"
hz-table class="table table-striped table-rsp table-detail">
<thead>
<tr ng-show="$isAvailableTable">
<th class="search-header" colspan="9">
<hz-search-bar group-classes="input-group-sm" icon-classes="fa-search">
</hz-search-bar>
</th>
</tr>
<tr>
<th class="expander"></th>
<th st-sort="name" st-sort-default class="rsp-p1" translate>Name</th>
<th></th>
</tr>
</thead>
<tbody>
<tr ng-if="$isAllocatedTable && ctrl.tableData.allocated.length === 0">
<td colspan="8">
<div class="no-rows-help">
{$ ::trCtrl.helpText.noneAllocText $}
</div>
</td>
</tr>
<tr ng-if="$isAvailableTable && trCtrl.numAvailable() === 0">
<td colspan="8">
<div class="no-rows-help">
{$ ::trCtrl.helpText.noneAvailText $}
</div>
</td>
</tr>
<tr ng-repeat-start="row in $displayedItems track by row.id"
ng-if="$isAllocatedTable || ($isAvailableTable && !trCtrl.allocatedIds[row.id])">
<td class="expander">
<span class="fa fa-chevron-right" hz-expand-detail
title="{$ ::trCtrl.helpText.expandDetailsText $}"></span>
</td>
<td class="rsp-p1">{$ row.name $}</td>
<td class="actions_column">
<action-list>
<action ng-if="$isAllocatedTable"
action-classes="'btn btn-default'"
callback="trCtrl.deallocate" item="row">
<span class="fa fa-minus"></span>
</action>
<action ng-if="$isAvailableTable"
action-classes="'btn btn-default'"
callback="trCtrl.allocate" item="row">
<span class="fa fa-plus"></span>
</action>
</action-list>
</td>
</tr>
<tr ng-repeat-end class="detail-row">
<td></td>
<td class="detail" colspan="3" ng-include="ctrl.tableDetails">
</td>
</tr>
</tbody>
</table>
</transfer-table> <!-- End Server Groups Transfer Table -->
</div> <!-- End Controller -->

View File

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

View File

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

View File

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