diff --git a/openstack_dashboard/api/neutron.py b/openstack_dashboard/api/neutron.py index e526c8210d..281f670eca 100644 --- a/openstack_dashboard/api/neutron.py +++ b/openstack_dashboard/api/neutron.py @@ -831,6 +831,16 @@ def trunk_list(request, **params): return [Trunk(t) for t in trunks] +@profiler.trace +def trunk_create(request, **params): + LOG.debug("trunk_create(): params=%s", params) + if 'project_id' not in params: + params['project_id'] = request.user.project_id + body = {'trunk': params} + trunk = neutronclient(request).create_trunk(body=body).get('trunk') + return Trunk(trunk) + + @profiler.trace def trunk_delete(request, trunk_id): LOG.debug("trunk_delete(): trunk_id=%s", trunk_id) diff --git a/openstack_dashboard/api/rest/neutron.py b/openstack_dashboard/api/rest/neutron.py index 004a61849e..3c7df60570 100644 --- a/openstack_dashboard/api/rest/neutron.py +++ b/openstack_dashboard/api/rest/neutron.py @@ -129,7 +129,7 @@ class Ports(generic.View): """Get a list of ports for a network The listing result is an object with property "items". Each item is - a subnet. + a port. """ # see # https://github.com/openstack/neutron/blob/master/neutron/api/v2/attributes.py @@ -169,6 +169,14 @@ class Trunks(generic.View): result = api.neutron.trunk_list(request, **request.GET.dict()) return {'items': [n.to_dict() for n in result]} + @rest_utils.ajax(data_required=True) + def post(self, request): + new_trunk = api.neutron.trunk_create(request, **request.DATA) + return rest_utils.CreatedResponse( + '/api/neutron/trunks/%s' % new_trunk.id, + new_trunk.to_dict() + ) + @urls.register class Services(generic.View): diff --git a/openstack_dashboard/static/app/core/openstack-service-api/neutron.service.js b/openstack_dashboard/static/app/core/openstack-service-api/neutron.service.js index e613590278..72fc049502 100644 --- a/openstack_dashboard/static/app/core/openstack-service-api/neutron.service.js +++ b/openstack_dashboard/static/app/core/openstack-service-api/neutron.service.js @@ -37,6 +37,7 @@ var service = { createNetwork: createNetwork, createSubnet: createSubnet, + createTrunk: createTrunk, deleteTrunk: deleteTrunk, getAgents: getAgents, getDefaultQuotaSets: getDefaultQuotaSets, @@ -387,17 +388,21 @@ * @param {string} id * Specifies the id of the trunk to request. * + * @param {boolean} suppressError (optional) + * Suppress the error toast. Default to showing it. + * * @returns {Object} The result of the API call */ - function getTrunk(id) { - return apiService.get('/api/neutron/trunks/' + id + '/') + function getTrunk(id, suppressError) { + var promise = apiService.get('/api/neutron/trunks/' + id + '/') .success(function(trunk) { convertDatesHumanReadable(trunk); - }) - .error(function () { - var msg = gettext('Unable to retrieve the trunk with id: %(id)s'); - toastService.add('error', interpolate(msg, { id : id }, true)); }); + promise = suppressError ? promise : promise.error(function () { + var msg = gettext('Unable to retrieve the trunk with id: %(id)s'); + toastService.add('error', interpolate(msg, {id: id}, true)); + }); + return promise; } /** @@ -420,20 +425,36 @@ }); } + /** + * @name createTrunk + * @description + * Create a neutron trunk. + */ + function createTrunk(newTrunk) { + return apiService.post('/api/neutron/trunks/', newTrunk) + .error(function () { + toastService.add('error', gettext('Unable to create the trunk.')); + }); + } + /** * @name deleteTrunk * @description * Delete a single neutron trunk. + * * @param {string} trunkId * UUID of a trunk to be deleted. + * + * @param {boolean} suppressError (optional) + * Suppress the error toast. Default to showing it. */ - function deleteTrunk(trunkId) { + function deleteTrunk(trunkId, suppressError) { var promise = apiService.delete('/api/neutron/trunks/' + trunkId + '/'); - - return promise.error(function() { + promise = suppressError ? promise : promise.error(function() { var msg = gettext('Unable to delete trunk: %(id)s'); toastService.add('error', interpolate(msg, { id: trunkId }, true)); }); + return promise; } } }()); diff --git a/openstack_dashboard/static/app/core/openstack-service-api/neutron.service.spec.js b/openstack_dashboard/static/app/core/openstack-service-api/neutron.service.spec.js index feeed9849c..37f1e5100e 100644 --- a/openstack_dashboard/static/app/core/openstack-service-api/neutron.service.spec.js +++ b/openstack_dashboard/static/app/core/openstack-service-api/neutron.service.spec.js @@ -162,6 +162,16 @@ ], "error": "Unable to retrieve the trunks." }, + { + "func": "createTrunk", + "method": "post", + "path": "/api/neutron/trunks/", + "data": "new trunk", + "error": "Unable to create the trunk.", + "testInput": [ + "new trunk" + ] + }, { "func": "deleteTrunk", "method": "delete", diff --git a/openstack_dashboard/static/app/core/trunks/actions/actions.module.js b/openstack_dashboard/static/app/core/trunks/actions/actions.module.js index 129532e5eb..8465923b11 100644 --- a/openstack_dashboard/static/app/core/trunks/actions/actions.module.js +++ b/openstack_dashboard/static/app/core/trunks/actions/actions.module.js @@ -1,17 +1,17 @@ -/** +/* * Copyright 2017 Ericsson * - * 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 + * 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. + * 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() { @@ -33,17 +33,29 @@ registerTrunkActions.$inject = [ 'horizon.framework.conf.resource-type-registry.service', + 'horizon.app.core.trunks.actions.create.service', 'horizon.app.core.trunks.actions.delete.service', 'horizon.app.core.trunks.resourceType' ]; function registerTrunkActions( registry, + createService, deleteService, trunkResourceTypeCode ) { var trunkResourceType = registry.getResourceType(trunkResourceTypeCode); + trunkResourceType.globalActions + .append({ + id: 'createTrunkAction', + service: createService, + template: { + text: gettext('Create Trunk'), + type: 'create' + } + }); + trunkResourceType.itemActions .append({ id: 'deleteTrunkAction', diff --git a/openstack_dashboard/static/app/core/trunks/actions/actions.module.spec.js b/openstack_dashboard/static/app/core/trunks/actions/actions.module.spec.js index 84b5406bd0..e82d3d10dc 100644 --- a/openstack_dashboard/static/app/core/trunks/actions/actions.module.spec.js +++ b/openstack_dashboard/static/app/core/trunks/actions/actions.module.spec.js @@ -1,17 +1,17 @@ -/** +/* * Copyright 2017 Ericsson * - * 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 + * 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. + * 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() { @@ -35,13 +35,16 @@ expect(actionHasId(actions, 'batchDeleteTrunkAction')).toBe(true); }); + it('registers Create Trunk as global action', function() { + var actions = registry.getResourceType('OS::Neutron::Trunk').globalActions; + expect(actionHasId(actions, 'createTrunkAction')).toBe(true); + }); + function actionHasId(list, value) { return list.filter(matchesId).length === 1; function matchesId(action) { - if (action.id === value) { - return true; - } + return action.id === value; } } diff --git a/openstack_dashboard/static/app/core/trunks/actions/create.action.service.js b/openstack_dashboard/static/app/core/trunks/actions/create.action.service.js new file mode 100644 index 0000000000..91adbb34e7 --- /dev/null +++ b/openstack_dashboard/static/app/core/trunks/actions/create.action.service.js @@ -0,0 +1,169 @@ +/* + * Copyright 2017 Ericsson + * + * 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.trunks') + .factory('horizon.app.core.trunks.actions.create.service', createService); + + createService.$inject = [ + '$q', + 'horizon.app.core.openstack-service-api.neutron', + 'horizon.app.core.openstack-service-api.policy', + 'horizon.app.core.openstack-service-api.userSession', + 'horizon.app.core.trunks.actions.createWorkflow', + 'horizon.app.core.trunks.actions.ports-extra.service', + 'horizon.app.core.trunks.resourceType', + 'horizon.framework.util.actions.action-result.service', + 'horizon.framework.widgets.modal-wait-spinner.service', + // Using horizon.framework.widgets.form.ModalFormService and + // angular-schema-form would have made many things easier, but it wasn't + // really an option because it does not have a transfer-table widget. + 'horizon.framework.widgets.modal.wizard-modal.service', + 'horizon.framework.widgets.toast.service' + ]; + + /** + * @ngDoc factory + * @name horizon.app.core.trunks.actions.createService + * @Description A service to handle the Create Trunk modal. + */ + function createService( + $q, + neutron, + policy, + userSession, + createWorkflow, + portsExtra, + resourceType, + actionResultService, + spinnerService, + wizardModalService, + toast + ) { + var service = { + perform: perform, + allowed: allowed + }; + + return service; + + //////////// + + function allowed() { + return policy.ifAllowed( + {rules: [ + ['network', 'create_trunk'] + ]} + ); + } + + function perform() { + // NOTE(bence romsics): Suboptimal UX. We delay opening the modal until + // neutron objects are loaded. But ideally the steps independent of + // already existing neutron objects (ie. the trunk details step) could + // already work while loading neutron stuff in the background. + spinnerService.showModalSpinner(gettext('Please Wait')); + + return $q.all({ + // TODO(bence romsics): Query filters of port and network listings + // should be aligned. While here it looks like we query all + // possible ports and all possible networks this is not really the + // case. A few calls down in openstack_dashboard/api/neutron.py + // we have a filterless port listing, but networks are listed + // by network_list_for_tenant() which includes some hardcoded + // tenant-specific filtering. Therefore here we may get back some + // ports whose networks we don't have. This is only a problem for + // admin and even then it means just missing network and subnet + // metadata on some ports. But anyway when we want this panel to + // work for admin too, we should fix this. + getNetworks: neutron.getNetworks(), + getPorts: userSession.get().then(function(session) { + return neutron.getPorts({project_id: session.project_id}); + }) + }).then(function(responses) { + var networks = responses.getNetworks.data.items; + var ports = responses.getPorts.data.items; + return { + parentPortCandidates: portsExtra.addNetworkAndSubnetInfo( + ports.filter(portsExtra.isParentPortCandidate), + networks), + subportCandidates: portsExtra.addNetworkAndSubnetInfo( + ports.filter(portsExtra.isSubportCandidate), + networks) + }; + }).then(openModal); + + function openModal(params) { + spinnerService.hideModalSpinner(); + + return wizardModalService.modal({ + workflow: createWorkflow, + submit: submit, + data: { + initTrunk: { + admin_state_up: true, + description: '', + name: '', + port_id: undefined, + sub_ports: [] + }, + ports: { + parentPortCandidates: params.parentPortCandidates.sort( + portsExtra.cmpPortsByNameAndId), + subportCandidates: params.subportCandidates.sort( + portsExtra.cmpPortsByNameAndId), + subportsOfInitTrunk: [] + }, + // When both the parent port and subports steps show mostly the + // same ports available, then a port allocated in one step should + // become unavailable in the other. + crossHide: true + } + }).result; + } + } + + function submit(stepModels) { + // NOTE(bence romsics): The action should not know about the steps. How + // many steps we have, or their names. All we have to know is that each + // has a closure returning a trunk slice and these slices can be merged + // by extend() to a full trunk model. + var trunk = {}; + var stepName, getTrunkSlice; + + for (stepName in stepModels.trunkSlices) { + if (stepModels.trunkSlices.hasOwnProperty(stepName)) { + getTrunkSlice = stepModels.trunkSlices[stepName]; + angular.extend(trunk, getTrunkSlice()); + } + } + return neutron.createTrunk(trunk).then(onSuccess); + + function onSuccess(response) { + var trunk = response.data; + toast.add('success', interpolate( + gettext('Trunk %s was successfully created.'), [trunk.name])); + return actionResultService.getActionResult() + .created(resourceType, trunk.id) + .result; + } + } + + } +})(); diff --git a/openstack_dashboard/static/app/core/trunks/actions/create.action.service.spec.js b/openstack_dashboard/static/app/core/trunks/actions/create.action.service.spec.js new file mode 100644 index 0000000000..2f1a2cbb26 --- /dev/null +++ b/openstack_dashboard/static/app/core/trunks/actions/create.action.service.spec.js @@ -0,0 +1,170 @@ +/* + * Copyright 2017 Ericsson + * + * 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.trunks.actions.create.service', function() { + + var $q, $scope, service, modalWaitSpinnerService, deferred, $timeout; + + var policyAPI = { + ifAllowed: function() { + return { + success: function(callback) { + callback({allowed: true}); + } + }; + } + }; + + var wizardModalService = { + modal: function () { + return { + result: undefined + }; + } + }; + + var neutronAPI = { + getNetworks: function() { + return $q.when( + {data: {items: []}} + ); + }, + getPorts: function() { + return $q.when( + {data: {items: []}} + ); + }, + createTrunk: function(trunk) { + return $q.when( + {data: trunk} + ); + } + }; + + var userSession = { + isCurrentProject: function() { + deferred.resolve(); + return deferred.promise; + }, + get: function() { + return $q.when({'project_id': '1'}); + } + }; + + //////////// + + beforeEach(module('horizon.app.core')); + + beforeEach(module(function($provide) { + $provide.value('horizon.framework.widgets.modal.wizard-modal.service', + wizardModalService); + $provide.value('horizon.app.core.openstack-service-api.policy', + policyAPI); + $provide.value('horizon.app.core.openstack-service-api.neutron', + neutronAPI); + $provide.value('horizon.app.core.openstack-service-api.userSession', + userSession); + })); + + beforeEach(inject(function($injector, $rootScope, _$q_, _$timeout_) { + $q = _$q_; + $timeout = _$timeout_; + $scope = $rootScope.$new(); + deferred = $q.defer(); + service = $injector.get('horizon.app.core.trunks.actions.create.service'); + modalWaitSpinnerService = $injector.get( + 'horizon.framework.widgets.modal-wait-spinner.service' + ); + })); + + it('should check the policy if the user is allowed to create trunks', function() { + spyOn(policyAPI, 'ifAllowed').and.callThrough(); + var allowed = service.allowed(); + expect(allowed).toBeTruthy(); + expect(policyAPI.ifAllowed).toHaveBeenCalledWith( + { rules: [['network', 'create_trunk']] } + ); + }); + + it('open the modal with the correct parameters', function() { + spyOn(wizardModalService, 'modal').and.callThrough(); + spyOn(modalWaitSpinnerService, 'showModalSpinner'); + spyOn(modalWaitSpinnerService, 'hideModalSpinner'); + + service.perform(); + expect(modalWaitSpinnerService.showModalSpinner).toHaveBeenCalledWith('Please Wait'); + $timeout.flush(); + + expect(wizardModalService.modal).toHaveBeenCalled(); + expect(modalWaitSpinnerService.hideModalSpinner).toHaveBeenCalled(); + + var modalArgs = wizardModalService.modal.calls.argsFor(0)[0]; + expect(modalArgs.scope).toBeUndefined(); + expect(modalArgs.workflow).toBeDefined(); + expect(modalArgs.submit).toBeDefined(); + expect(modalArgs.data.initTrunk).toBeDefined(); + expect(modalArgs.data.ports).toBeDefined(); + }); + + it('should submit create trunk request to neutron', function() { + spyOn(neutronAPI, 'createTrunk').and.callThrough(); + spyOn(wizardModalService, 'modal').and.callThrough(); + spyOn(modalWaitSpinnerService, 'showModalSpinner'); + spyOn(modalWaitSpinnerService, 'hideModalSpinner'); + + service.perform(); + $timeout.flush(); + + var modalArgs = wizardModalService.modal.calls.argsFor(0)[0]; + modalArgs.submit( + {trunkSlices: { + step1: function () { + return { + name: 'trunk name' + }; + }, + step2: function () { + return { + port_id: 'parent port uuid' + }; + }, + step3: function () { + return { + sub_ports: [ + {port_id: 'subport uuid', segmentation_type: 'vlan', segmentation_id: 100} + ] + }; + } + }} + ); + $scope.$apply(); + + expect(neutronAPI.createTrunk).toHaveBeenCalled(); + expect(neutronAPI.createTrunk.calls.argsFor(0)[0]).toEqual({ + name: 'trunk name', + port_id: 'parent port uuid', + sub_ports: [ + {port_id: 'subport uuid', segmentation_type: 'vlan', segmentation_id: 100} + ] + }); + }); + + }); + +})(); diff --git a/openstack_dashboard/static/app/core/trunks/actions/create.workflow.service.js b/openstack_dashboard/static/app/core/trunks/actions/create.workflow.service.js new file mode 100644 index 0000000000..d59e3a05d4 --- /dev/null +++ b/openstack_dashboard/static/app/core/trunks/actions/create.workflow.service.js @@ -0,0 +1,68 @@ +/* + * Copyright 2017 Ericsson + * + * 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.trunks') + .factory('horizon.app.core.trunks.actions.createWorkflow', createWorkflow); + + createWorkflow.$inject = [ + 'horizon.app.core.trunks.basePath', + 'horizon.app.core.workflow.factory', + 'horizon.framework.util.i18n.gettext' + ]; + + /** + * @ngdoc factory + * @name horizon.app.core.trunks.createWorkflow + * @description A workflow for the create trunk action. + */ + function createWorkflow( + basePath, + workflowService, + gettext + ) { + var workflow = workflowService({ + title: gettext('Create Trunk'), + btnText: {finish: gettext('Create Trunk')}, + steps: [ + { + title: gettext('Details'), + templateUrl: basePath + 'steps/trunk-details.html', + helpUrl: basePath + 'steps/trunk-details.help.html', + formName: 'detailsForm' + }, + { + title: gettext('Parent Port'), + templateUrl: basePath + 'steps/trunk-parent-port.html', + helpUrl: basePath + 'steps/trunk-parent-port.help.html', + formName: 'parentPortForm' + }, + { + title: gettext('Subports'), + templateUrl: basePath + 'steps/trunk-subports.html', + helpUrl: basePath + 'steps/trunk-subports.help.html', + formName: 'subportsForm' + } + ] + }); + + return workflow; + } + +})(); diff --git a/openstack_dashboard/static/app/core/trunks/actions/delete.action.service.js b/openstack_dashboard/static/app/core/trunks/actions/delete.action.service.js index 980bfed7a4..e5d39dfa10 100644 --- a/openstack_dashboard/static/app/core/trunks/actions/delete.action.service.js +++ b/openstack_dashboard/static/app/core/trunks/actions/delete.action.service.js @@ -1,4 +1,4 @@ -/** +/* * Copyright 2017 Ericsson * * Licensed under the Apache License, Version 2.0 (the "License"); you may diff --git a/openstack_dashboard/static/app/core/trunks/actions/delete.action.service.spec.js b/openstack_dashboard/static/app/core/trunks/actions/delete.action.service.spec.js index 0d8132c80e..2e71e9ed00 100644 --- a/openstack_dashboard/static/app/core/trunks/actions/delete.action.service.spec.js +++ b/openstack_dashboard/static/app/core/trunks/actions/delete.action.service.spec.js @@ -1,4 +1,4 @@ -/** +/* * Copyright 2017 Ericsson * * Licensed under the Apache License, Version 2.0 (the "License"); you may diff --git a/openstack_dashboard/static/app/core/trunks/actions/ports-extra.service.js b/openstack_dashboard/static/app/core/trunks/actions/ports-extra.service.js new file mode 100644 index 0000000000..4b085bcc2a --- /dev/null +++ b/openstack_dashboard/static/app/core/trunks/actions/ports-extra.service.js @@ -0,0 +1,132 @@ +/* + * Copyright 2017 Ericsson + * + * 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.trunks') + .factory('horizon.app.core.trunks.actions.ports-extra.service', portsExtra); + + /** + * @ngdoc factory + * @name horizon.app.core.trunks.actions.ports-extra.service + * @description Ports-related utility functions including: + * - filters for various port subtypes + * - comparison functions to sort port lists + * - etc. + */ + function portsExtra() { + var service = { + addNetworkAndSubnetInfo: addNetworkAndSubnetInfo, + cmpPortsByNameAndId: cmpPortsByNameAndId, + isParentPortCandidate: isParentPortCandidate, + isSubportCandidate: isSubportCandidate, + isSubportOfTrunk: isSubportOfTrunk + }; + + return service; + + //////////// + + function isParentPortCandidate(port) { + return ( + port.admin_state_up && + // NOTE(bence romsics): A port already booted on may or may not be used + // as the parent port of a trunk depending on the neutron driver. But + // we do not (and should not) know anything about neutron driver config + // so here we cannot filter on device_owner being unset. However the ovs + // driver is going to throw errors if the user tries to trunk a port + // already booted. + (port.device_owner === '' || + port.device_owner.indexOf('compute:') === 0) && + // port is not a trunk parent already + !port.trunk_details && + // port is not a trunk subport already + !port.trunk_id + ); + } + + function isSubportCandidate(port) { + return ( + port.admin_state_up && + // port already booted on must never be a subport + port.device_owner === '' && + // port is not a trunk parent already + !port.trunk_details && + // port is not a trunk subport already + !port.trunk_id + ); + } + + function isSubportOfTrunk(trunkId, port) { + return ( + // port is a trunk subport... + port.trunk_id && + // ...of this trunk + port.trunk_id === trunkId + ); + } + + function cmpPortsByNameAndId(a, b) { + return ( + // primary key: ports with a name sort earlier than ports without + (a.name === '') - (b.name === '') || + // secondary key: name + a.name.localeCompare(b.name) || + // tertiary key: id + a.id.localeCompare(b.id) + ); + } + + function addNetworkAndSubnetInfo(inPorts, networks) { + var networksDict = {}; + networks.forEach(function(network) { + networksDict[network.id] = network; + }); + var outPorts = []; + + inPorts.forEach(function(inPort) { + var network, outPort; + outPort = angular.copy(inPort); + // NOTE(bence romsics): Ports and networks may not be in sync, + // therefore some ports may not get their network and subnet + // info. But we return (and so display) all ports anyway. + if (inPort.network_id in networksDict) { + network = networksDict[inPort.network_id]; + outPort.network_name = network.name; + outPort.subnet_names = getSubnetsForPort(inPort, network.subnets); + } + outPorts.push(outPort); + }); + + return outPorts; + } + + function getSubnetsForPort(port, subnets) { + var subnetNames = {}; + port.fixed_ips.forEach(function(ip) { + subnets.forEach(function(subnet) { + if (ip.subnet_id === subnet.id) { + subnetNames[ip.ip_address] = subnet.name; + } + }); + }); + return subnetNames; + } + + } +})(); diff --git a/openstack_dashboard/static/app/core/trunks/actions/ports-extra.service.spec.js b/openstack_dashboard/static/app/core/trunks/actions/ports-extra.service.spec.js new file mode 100644 index 0000000000..1cb4d0bb7b --- /dev/null +++ b/openstack_dashboard/static/app/core/trunks/actions/ports-extra.service.spec.js @@ -0,0 +1,100 @@ +/* + * Copyright 2017 Ericsson + * + * 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.trunks.actions.ports-extra.service', function() { + + var service; + + beforeEach(module('horizon.app.core')); + beforeEach(inject(function($injector) { + service = $injector.get('horizon.app.core.trunks.actions.ports-extra.service'); + })); + + it('should add network and subnet info', function() { + var inPorts = [{network_id: 1, fixed_ips: [{subnet_id: 1, ip_address: '1.1.1.1'}]}]; + var networks = [{id : 1, name: 'net1', subnets: [{id: 1, name: 'subnet1'}]}]; + + var outPorts = service.addNetworkAndSubnetInfo(inPorts, networks); + expect(outPorts[0].network_name).toEqual('net1'); + expect(outPorts[0].subnet_names).toEqual({'1.1.1.1': 'subnet1'}); + }); + + it('should return the same port object if no network match', function() { + var inPorts = [{network_id: 1, fixed_ips: [{subnet_id: 1, ip_address: '1.1.1.1'}]}]; + var networks = [{id : 2, name: 'net1'}]; + + var outPorts = service.addNetworkAndSubnetInfo(inPorts, networks); + expect(outPorts[0].network_name).toBeUndefined(); + expect(outPorts[0].subnet_names).toBeUndefined(); + }); + + it('should return only network name if no match in subnet', function() { + var inPorts = [{network_id: 1, fixed_ips: [{subnet_id: 1, ip_address: '1.1.1.1'}]}]; + var networks = [{id : 1, name: 'net1', subnets: [{id: 2, name: 'subnet1'}]}]; + + var outPorts = service.addNetworkAndSubnetInfo(inPorts, networks); + expect(outPorts[0].network_name).toEqual('net1'); + expect(outPorts[0].subnet_names).toEqual({}); + }); + + it('should compare port1 and port2 and return -1 if port1 is first', function() { + var port1 = {name: 'port1', id: '1234'}; + var port2 = {name: 'port2', id: '5678'}; + expect(service.cmpPortsByNameAndId(port1, port2)).toEqual(-1); + }); + + it ('should compare port1 and port2 and return 1 if port2 is first', function() { + var port1 = {name: 'xxxx1', id: '1234'}; + var port2 = {name: 'port2', id: '5678'}; + expect(service.cmpPortsByNameAndId(port1, port2)).toEqual(1); + }); + + it('should return true if port has trunk_id and it equals with trunkId', function() { + var trunkId = '1234'; + var port = {trunk_id: '1234'}; + expect(service.isSubportOfTrunk(trunkId, port)).toBe(true); + }); + + it('should return true if port is subport candidate', function() { + var port = {admin_state_up: true, device_owner: ''}; + expect(service.isSubportCandidate(port)).toBe(true); + }); + + it('should return false if port is not subport candidate', function() { + var port = {admin_state_up: true, device_owner: '', trunk_id: '1234', trunk_details: {}}; + expect(service.isSubportCandidate(port)).toBe(false); + }); + + it('should return true if port is parent port candidate', function() { + var port1 = {admin_state_up: true, device_owner: ''}; + var port2 = {admin_state_up: true, device_owner: 'compute:1'}; + expect(service.isParentPortCandidate(port1)).toBe(true); + expect(service.isParentPortCandidate(port2)).toBe(true); + }); + + it('should return false if port is not parent port candidate', function() { + var port1 = {admin_state_up: true, device_owner: '', trunk_id: '1234', trunk_details: {}}; + var port2 = {admin_state_up: true, device_owner: 'network:dhcp'}; + expect(service.isParentPortCandidate(port1)).toBe(false); + expect(service.isParentPortCandidate(port2)).toBe(false); + }); + + }); + +})(); diff --git a/openstack_dashboard/static/app/core/trunks/steps/trunk-details.controller.js b/openstack_dashboard/static/app/core/trunks/steps/trunk-details.controller.js new file mode 100644 index 0000000000..66de51677b --- /dev/null +++ b/openstack_dashboard/static/app/core/trunks/steps/trunk-details.controller.js @@ -0,0 +1,89 @@ +/* + * Copyright 2017 Ericsson + * + * 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 controller + * @name horizon.app.core.trunks.steps.TrunkDetailsController + * @description + * Controller responsible for trunk attribute(s): + * admin_state_up + * description + * name + * This step has to work with both create and edit actions. + */ + angular + .module('horizon.app.core.trunks.actions') + .controller('TrunkDetailsController', TrunkDetailsController); + + TrunkDetailsController.$inject = [ + '$scope' + ]; + + function TrunkDetailsController($scope) { + var ctrl = this; + + ctrl.trunkAdminStateOptions = [ + { label: gettext('Enabled'), value: true }, + { label: gettext('Disabled'), value: false } + ]; + + ctrl.trunk = { + admin_state_up: $scope.initTrunk.admin_state_up, + description: $scope.initTrunk.description, + name: $scope.initTrunk.name + }; + + // NOTE(bence romsics): The step controllers are naturally stateful, + // but the actions should be stateless. However we have to + // get back the captured user input from the step controller to the + // action, because the action makes the neutron call. WizardController + // helps us and passes $scope.stepModels to the actions' submit(). + // Also note that $scope.stepModels is shared between all workflow + // steps. + // + // We roughly follow the example discussed and presented here: + // http://lists.openstack.org/pipermail/openstack-dev/2016-July/099368.html + // https://review.openstack.org/345145 + // + // Though we deviate a bit in the use of stepModels. The example + // has one model object per step, named after the workflow step's + // form. Instead we treat stepModels as a generic state variable. See + // the details below. + // + // The trunkSlices closures return a slice of the trunk model which + // can be then merged by the action to get the whole trunk model. By + // using closures we can spare the use of watchers and the constant + // recomputation of the trunk slices even in the more complicated + // other steps. + $scope.stepModels.trunkSlices = $scope.stepModels.trunkSlices || {}; + $scope.stepModels.trunkSlices.getDetails = function() { + return ctrl.trunk; + }; + + // In order to keep the update action stateless, we pass the old + // state of the trunk down to the step controllers, then back up + // to the update action's submit(). An alternative would be to + // eliminate the need for the old state of the trunk at update, + // at the price of moving the trunk diffing logic from python to + // javascript (ie. the subports step controller). + $scope.stepModels.initTrunk = $scope.initTrunk; + + } + +})(); diff --git a/openstack_dashboard/static/app/core/trunks/steps/trunk-details.controller.spec.js b/openstack_dashboard/static/app/core/trunks/steps/trunk-details.controller.spec.js new file mode 100644 index 0000000000..b7b3d9de39 --- /dev/null +++ b/openstack_dashboard/static/app/core/trunks/steps/trunk-details.controller.spec.js @@ -0,0 +1,64 @@ +/* + * Copyright 2017 Ericsson + * + * 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('Create Trunks Details Step', function() { + beforeEach(module('horizon.app.core.trunks')); + + describe('TrunkDetailsController', function() { + var scope, ctrl; + + //beforeEach(module('horizon.app.core.trunks.actions')); + beforeEach(inject(function($rootScope, $injector, $controller) { + scope = $rootScope.$new(); + scope.stepModels = {}; + scope.initTrunk = { + admin_state_up: true, + description: '', + name: 'trunk1' + }; + + ctrl = $controller('TrunkDetailsController', { + $scope: scope + }); + + })); + + it('has adminstate options', function() { + expect(ctrl.trunkAdminStateOptions).toBeDefined(); + expect(ctrl.trunkAdminStateOptions.length).toEqual(2); + }); + + it('has trunk property', function() { + expect(ctrl.trunk).toBeDefined(); + expect(ctrl.trunk.admin_state_up).toBeDefined(); + expect(ctrl.trunk.admin_state_up).toEqual(true); + expect(ctrl.trunk.description).toBeDefined(); + expect(ctrl.trunk.name).toBeDefined(); + }); + + it('should return with trunk', function() { + var trunk = scope.stepModels.trunkSlices.getDetails(); + expect(trunk.name).toEqual('trunk1'); + expect(trunk.admin_state_up).toBe(true); + }); + + }); + + }); +})(); diff --git a/openstack_dashboard/static/app/core/trunks/steps/trunk-details.help.html b/openstack_dashboard/static/app/core/trunks/steps/trunk-details.help.html new file mode 100644 index 0000000000..8e0b93506f --- /dev/null +++ b/openstack_dashboard/static/app/core/trunks/steps/trunk-details.help.html @@ -0,0 +1,17 @@ +
An arbitrary name for the trunk. May not be unique.
+Default: Empty string
+An arbitrary description for the trunk.
+Default: Empty string
+Enable/Disable subport addition, removal and trunk delete.
+Default: Enabled
++ Provide basic properties of the trunk to be created. All optional. +
+ +Exactly one regular Neutron port. Has to be provided when + the trunk is created. Cannot be changed during the trunk's life.
+ +The parent port is the port you have to add to the instance + at launch. Do not try to add the trunk or any of the subports to the + instance directly.
+ +Inside the instance the parent port's network will always + be presented as the untagged network. It will be available early from + the moment of bootup.
+ +Note that some Neutron backends (notably the Open vSwitch + based backend) only allow trunk creation before an instance is launched + on the parent port. Other backends may allow trunk creation at any + time during the life of a port.
+ +Must not be the parent or a subport of any other + trunks.
+ ++ Select exactly one port as the parent port of the trunk to + be created. Mandatory. +
+ ++ | Name | +IP | +Admin State | +Status | ++ | |
---|---|---|---|---|---|---|
+
+ Select an item from Available items below
+
+ |
+ ||||||
+ + | +{$ ctrl.nameOrID(item) $} | +
+
+ {$ ip.ip_address $} on subnet: {$ item.subnet_names[ip.ip_address] $}
+
+ |
+ {$ item.admin_state | decode:ctrl.portAdminStates $} | +{$ item.status | decode:ctrl.portStatuses $} | +
+ |
+ |
+
+
+ |
+
+ |
+ |||||
---|---|---|---|---|---|
+ | Name | +IP | +Admin State | +Status | ++ |
+
+ No available items
+
+ |
+ |||||
+ + | +{$ ctrl.nameOrID(item) $} | +
+
+ {$ ip.ip_address $} on subnet: {$ item.subnet_names[ip.ip_address] $}
+
+ |
+ {$ item.admin_state | decode:ctrl.portAdminStates $} | +{$ item.status | decode:ctrl.portStatuses $} | +
+ |
+
+
+
+ |
+
An arbitrary amount (0, 1, 2, ...) of regular Neutron + ports with segmentation details (ie. type and ID). May be provided + when the trunk is created. Also may be attached or detached later + during the trunk's (and the instance's) life. + +
Inside the instance a particular subport's network will + be presented as tagged frames transmitted and received on the vNIC + belonging to the parent port. The cloud user may control the tagging + by setting the segmentation type and segmentation ID + of the subport.
+ +Networks of subports may become available later than the + moment of bootup. But they will be available after the trunk reached + the ACTIVE status.
+ +The segmentation type and ID are decoupled (and + therefore independent) from Neutron's network virtualization + implementation. Different segmentation types will be remapped as frames + leave/enter the instance.
+ +For ports on provider networks you may choose the special + segmentation type inherit. Then the subport's segmentation + type and ID will be automagically inherited from the provider network's + segmentation type and ID. This is useful when the switch is incapable of + remapping (tag pop-push) for example as usual for Ironic instances.
+ +The segmentation type, ID tuples of subports must be unique + (in the scope of a trunk), otherwise networks of subports could not + be distinguished inside the instance.
+ +Note that most guest operating systems will not + automatically configure and bring up the VLAN subinterfaces belonging + to subports. You may have to do that yourself. For example:
+ ++ # eth0 belongs to the parent port + # VLAN 101 was chosen for the subport + sudo ip link add link eth0 \ + name eth0.101 \ + address "$subport_mac" \ + type vlan id 101 + sudo dhclient eth0.101+ +
This can be simplified by reusing the parent's MAC address + for all subports of the trunk earlier at port creation. Eg.:
+ ++ sudo ip link add link eth0 \ + name eth0.101 \ + type vlan id 101 + sudo dhclient eth0.101+ +
Also note that segmentation details may be mandatory + or optional depending on the backend. Notably Ironic may provide + segmentation details instead of the user.
+ +No subport can be the parent or a subport of any other + trunks.
+ ++ Select an arbitrary amount of ports (0, 1, 2, ...) and their + segmentation details as the subports of the trunk to be created. + Optional. +
+ ++ | Name | +IP | +Admin State | +Status | +Segmentation Type | +Segmentation Id | ++ | |
---|---|---|---|---|---|---|---|---|
+
+ Select items from Available items below
+
+ |
+ ||||||||
+ + | +{$ ctrl.nameOrID(item) $} | +
+
+ {$ ip.ip_address $} on subnet: {$ item.subnet_names[ip.ip_address] $}
+
+ |
+ {$ item.admin_state | decode:ctrl.portAdminStates $} | +{$ item.status | decode:ctrl.portStatuses $} | ++ + | ++ inherit + | ++ + + | +
+ |
+
+
+
+ |
+
+ |
+ |||||
---|---|---|---|---|---|
+ | Name | +IP | +Admin State | +Status | ++ |
+
+ No available items
+
+ |
+ |||||
+ + | +{$ ctrl.nameOrID(item) $} | +
+
+ {$ ip.ip_address $} on subnet: {$ item.subnet_names[ip.ip_address] $}
+
+ |
+ {$ item.admin_state | decode:ctrl.portAdminStates $} | +{$ item.status | decode:ctrl.portStatuses $} | +
+ |
+
+
+
+ |
+