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

An arbitrary name for the trunk. May not be unique.

+

Default: Empty string

+
+
Description
+
+

An arbitrary description for the trunk.

+

Default: Empty string

+
+
Admin State
+
+

Enable/Disable subport addition, removal and trunk delete.

+

Default: Enabled

+
+
diff --git a/openstack_dashboard/static/app/core/trunks/steps/trunk-details.html b/openstack_dashboard/static/app/core/trunks/steps/trunk-details.html new file mode 100644 index 0000000000..4afc169dbd --- /dev/null +++ b/openstack_dashboard/static/app/core/trunks/steps/trunk-details.html @@ -0,0 +1,53 @@ +
+ +

Details

+ +

+ Provide basic properties of the trunk to be created. All optional. +

+ +
+ +
+
+
+
+ + +
+
+
+
+ +
+
+ +
+
+
+
+
+
+
+
+ + +
+
+
+
+
+
diff --git a/openstack_dashboard/static/app/core/trunks/steps/trunk-parent-port.controller.js b/openstack_dashboard/static/app/core/trunks/steps/trunk-parent-port.controller.js new file mode 100644 index 0000000000..f32f9de2fc --- /dev/null +++ b/openstack_dashboard/static/app/core/trunks/steps/trunk-parent-port.controller.js @@ -0,0 +1,125 @@ +/* + * 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 TrunkParentPortController + * @description + * Controller responsible for trunk attribute(s): + * port_id (ie. the parent port) + * This step has to work with edit action only, since a trunk's parent port + * cannot be updated. + */ + angular + .module('horizon.app.core.trunks.actions') + .controller('TrunkParentPortController', TrunkParentPortController); + + TrunkParentPortController.$inject = [ + '$scope', + 'horizon.app.core.trunks.portConstants', + 'horizon.framework.widgets.action-list.button-tooltip.row-warning.service', + 'horizon.framework.widgets.transfer-table.events' + ]; + + function TrunkParentPortController( + $scope, + portConstants, + tooltipService, + ttevents + ) { + var ctrl = this; + + ctrl.portStatuses = portConstants.statuses; + ctrl.portAdminStates = portConstants.adminStates; + ctrl.vnicTypes = portConstants.vnicTypes; + + ctrl.tableHelpText = { + allocHelpText: gettext('Select from the list of available ports below.') + }; + + ctrl.tooltipModel = tooltipService; + + ctrl.nameOrID = function nameOrId(data) { + return angular.isDefined(data.name) && data.name !== '' ? data.name : data.id; + }; + + ctrl.tableLimits = { + maxAllocation: 1 + }; + + ctrl.parentTables = { + available: $scope.ports.parentPortCandidates, + allocated: [], + displayedAvailable: [], + displayedAllocated: [] + }; + + // See also in the details step controller. + $scope.stepModels.trunkSlices = $scope.stepModels.trunkSlices || {}; + $scope.stepModels.trunkSlices.getParentPort = function() { + var trunk = {port_id: $scope.initTrunk.port_id}; + + if (ctrl.parentTables.allocated.length in [0, 1]) { + trunk.port_id = ctrl.parentTables.allocated[0].id; + } else { + // maxAllocation is 1, so this should never happen. + throw new Error('Allocating multiple parent ports is meaningless.'); + } + + return trunk; + }; + + if ($scope.crossHide) { + // We expose the allocated table directly to the subports step + // controller, so it can set watchers on it and react accordingly... + $scope.stepModels.allocated = $scope.stepModels.allocated || {}; + $scope.stepModels.allocated.parentPort = ctrl.parentTables.allocated; + + // ...and vice versa. + var deregisterAllocatedWatcher = $scope.$watchCollection( + 'stepModels.allocated.subports', hideAllocated); + + $scope.$on('$destroy', function() { + deregisterAllocatedWatcher(); + }); + } + + function hideAllocated(allocatedList) { + var allocatedDict = {}; + var availableList; + + allocatedList.forEach(function(port) { + allocatedDict[port.id] = true; + }); + availableList = $scope.ports.parentPortCandidates.filter( + function(port) { + return !(port.id in allocatedDict); + } + ); + + ctrl.parentTables.available = availableList; + // Notify transfertable. + $scope.$broadcast( + ttevents.TABLES_CHANGED, + {data: {available: availableList}} + ); + } + + } +})(); diff --git a/openstack_dashboard/static/app/core/trunks/steps/trunk-parent-port.controller.spec.js b/openstack_dashboard/static/app/core/trunks/steps/trunk-parent-port.controller.spec.js new file mode 100644 index 0000000000..a178159d65 --- /dev/null +++ b/openstack_dashboard/static/app/core/trunks/steps/trunk-parent-port.controller.spec.js @@ -0,0 +1,119 @@ +/* + * 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 Parent Step', function() { + beforeEach(module('horizon.framework.widgets')); + beforeEach(module('horizon.framework.widgets.action-list')); + beforeEach(module('horizon.app.core.trunks')); + + describe('TrunkParentPortController', function() { + var scope, ctrl, ttevents; + + beforeEach(inject(function($rootScope, $controller, $injector) { + scope = $rootScope.$new(); + scope.crossHide = true; + scope.ports = { + parentPortCandidates: [{id: 1}, {id: 2}] + }; + scope.stepModels = {}; + scope.initTrunk = { + port_id: 1 + }; + + ttevents = $injector.get('horizon.framework.widgets.transfer-table.events'); + + ctrl = $controller('TrunkParentPortController', { + $scope: scope + }); + })); + + it('has correct ports statuses', function() { + expect(ctrl.portStatuses).toBeDefined(); + expect(ctrl.portStatuses.ACTIVE).toBeDefined(); + expect(ctrl.portStatuses.DOWN).toBeDefined(); + expect(Object.keys(ctrl.portStatuses).length).toBe(2); + }); + + it('has correct network admin states', function() { + expect(ctrl.portAdminStates).toBeDefined(); + expect(ctrl.portAdminStates.UP).toBeDefined(); + expect(ctrl.portAdminStates.DOWN).toBeDefined(); + expect(Object.keys(ctrl.portAdminStates).length).toBe(2); + }); + + it('defines a single-allocation table', function() { + expect(ctrl.tableLimits).toBeDefined(); + expect(ctrl.tableLimits.maxAllocation).toBe(1); + }); + + it('contains help text for the table', function() { + expect(ctrl.tableHelpText).toBeDefined(); + expect(ctrl.tableHelpText.allocHelpText).toBeDefined(); + }); + + it('nameOrId returns the name', function() { + var obj = {name: 'test_name', id: 'test_id'}; + expect(ctrl.nameOrID).toBeDefined(); + expect(ctrl.nameOrID(obj)).toBe('test_name'); + }); + + it('nameOrId returns the id if the name is missing', function() { + expect(ctrl.nameOrID).toBeDefined(); + expect(ctrl.nameOrID({'id': 'testid'})).toBe('testid'); + }); + + it('uses scope to set table data', function() { + expect(ctrl.parentTables).toBeDefined(); + expect(ctrl.parentTables.available).toEqual( + [{id: 1}, {id: 2}]); + expect(ctrl.parentTables.allocated).toEqual([]); + expect(ctrl.parentTables.displayedAllocated).toEqual([]); + expect(ctrl.parentTables.displayedAvailable).toEqual([]); + }); + + it('should return with parent port', function() { + ctrl.parentTables.allocated = [{id: 3}]; + var trunk = scope.stepModels.trunkSlices.getParentPort(); + expect(trunk.port_id).toEqual(3); + }); + + it('should throw exception if more than on port is allocated', function() { + ctrl.parentTables.allocated = [{id: 3}, {id: 4}]; + expect(scope.stepModels.trunkSlices.getParentPort).toThrow(); + }); + + it('should remove port from available list if subportstable changes', function() { + spyOn(scope, '$broadcast').and.callThrough(); + + ctrl.parentTables.available = [{id: 1}, {id: 2}, {id: 3}]; + scope.stepModels.allocated.subports = [{id: 3}]; + + scope.$digest(); + + expect(scope.$broadcast).toHaveBeenCalledWith( + ttevents.TABLES_CHANGED, + {data: {available: [{id: 1}, {id: 2}]}} + ); + expect(ctrl.parentTables.available).toEqual([{id: 1}, {id: 2}]); + }); + + }); + + }); +})(); diff --git a/openstack_dashboard/static/app/core/trunks/steps/trunk-parent-port.help.html b/openstack_dashboard/static/app/core/trunks/steps/trunk-parent-port.help.html new file mode 100644 index 0000000000..42b6985645 --- /dev/null +++ b/openstack_dashboard/static/app/core/trunks/steps/trunk-parent-port.help.html @@ -0,0 +1,25 @@ +
+
Parent Port
+
+ +

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.

+ +
+
diff --git a/openstack_dashboard/static/app/core/trunks/steps/trunk-parent-port.html b/openstack_dashboard/static/app/core/trunks/steps/trunk-parent-port.html new file mode 100644 index 0000000000..402667c9a9 --- /dev/null +++ b/openstack_dashboard/static/app/core/trunks/steps/trunk-parent-port.html @@ -0,0 +1,159 @@ +
+ +

Parent port

+ +

+ Select exactly one port as the parent port of the trunk to + be created. Mandatory. +

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameIPAdmin StateStatus
+
+ 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 $} + + + + + +
+
+
ID
+
{$ item.id $}
+
Project ID
+
{$ item.tenant_id $}
+
Network ID
+
{$ item.network_id $}
+
Network
+
{$ item.network_name $}
+
Device Owner
+
{$ item.device_owner $}
+
Device ID
+
{$ item.device_id $}
+
VNIC type
+
{$ item['binding:vnic_type'] | decode:ctrl.vnicTypes $}
+
+
Host ID
+
{$ item['binding:host_id'] $}
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
NameIPAdmin StateStatus
+
+ 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 $} + + + + + +
+
+
ID
+
{$ item.id $}
+
Project ID
+
{$ item.tenant_id $}
+
Network ID
+
{$ item.network_id $}
+
Network
+
{$ item.network_name $}
+
Device ID
+
{$ item.device_id $}
+
VNIC type
+
{$ item['binding:vnic_type'] | decode:ctrl.vnicTypes $}
+
+
Host ID
+
{$ item['binding:host_id'] $}
+
+
+
+
+
+
diff --git a/openstack_dashboard/static/app/core/trunks/steps/trunk-subports.controller.js b/openstack_dashboard/static/app/core/trunks/steps/trunk-subports.controller.js new file mode 100644 index 0000000000..2c953886fa --- /dev/null +++ b/openstack_dashboard/static/app/core/trunks/steps/trunk-subports.controller.js @@ -0,0 +1,152 @@ +/* + * 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 TrunkSubPortsController + * @description + * Controller responsible for trunk attribute(s): + * sub_ports + * This step has to work with both create and edit actions. + */ + angular + .module('horizon.app.core.trunks.actions') + .controller('TrunkSubPortsController', TrunkSubPortsController); + + TrunkSubPortsController.$inject = [ + '$scope', + 'horizon.app.core.trunks.portConstants', + 'horizon.framework.widgets.action-list.button-tooltip.row-warning.service', + 'horizon.framework.widgets.transfer-table.events' + ]; + + function TrunkSubPortsController( + $scope, + portConstants, + tooltipService, + ttevents + ) { + var ctrl = this; + + ctrl.portStatuses = portConstants.statuses; + ctrl.portAdminStates = portConstants.adminStates; + ctrl.vnicTypes = portConstants.vnicTypes; + + ctrl.tableHelpText = { + allocHelpText: gettext('Select from the list of available ports below.'), + availHelpText: gettext('Select many') + }; + + ctrl.tooltipModel = tooltipService; + + ctrl.nameOrID = function nameOrId(data) { + return angular.isDefined(data.name) && data.name !== '' ? data.name : data.id; + }; + + ctrl.tableLimits = { + maxAllocation: -1 + }; + + ctrl.segmentationTypesDict = { + // The first item will be the default type. + 'vlan': [1, 4094], + 'inherit': null + }; + ctrl.segmentationTypes = Object.keys(ctrl.segmentationTypesDict); + ctrl.subportsDetails = {}; + + $scope.initTrunk.sub_ports.forEach(function(subport) { + ctrl.subportsDetails[subport.port_id] = { + segmentation_type: subport.segmentation_type, + segmentation_id: subport.segmentation_id + }; + }); + + ctrl.subportsTables = { + available: [].concat( + $scope.ports.subportsOfInitTrunk, + $scope.ports.subportCandidates), + // NOTE(bence romsics): Trunk information merged into ports and trunk + // information in initTrunk may occasionally be out of sync. Theoratically + // there's a chance to get rid of this, but that refactor will go deep. + allocated: $scope.ports.subportsOfInitTrunk, + displayedAvailable: [], + displayedAllocated: [] + }; + + // See also in the details step controller. + $scope.stepModels.trunkSlices = $scope.stepModels.trunkSlices || {}; + $scope.stepModels.trunkSlices.getSubports = function() { + var trunk = {sub_ports: []}; + + ctrl.subportsTables.allocated.forEach(function(port) { + // Subport information comes from two sources, one handled by + // transfertable, the other from outside of transfertable. We + // may see the two data structures in an inconsistent state. We + // skip the inconsistent cases by the following condition. + if (port.id in ctrl.subportsDetails) { + trunk.sub_ports.push({ + port_id: port.id, + segmentation_id: ctrl.subportsDetails[port.id].segmentation_id, + segmentation_type: ctrl.subportsDetails[port.id].segmentation_type + }); + } + }); + + return trunk; + }; + + if ($scope.crossHide) { + // We expose the allocated table directly to the parent port step + // controller, so it can set watchers on it and react accordingly... + $scope.stepModels.allocated = $scope.stepModels.allocated || {}; + $scope.stepModels.allocated.subports = ctrl.subportsTables.allocated; + + // ...and vice versa. + var deregisterAllocatedWatcher = $scope.$watchCollection( + 'stepModels.allocated.parentPort', hideAllocated); + + $scope.$on('$destroy', function() { + deregisterAllocatedWatcher(); + }); + } + + function hideAllocated(allocatedList) { + var allocatedDict = {}; + var availableList; + + allocatedList.forEach(function(port) { + allocatedDict[port.id] = true; + }); + availableList = $scope.ports.subportCandidates.filter( + function(port) { + return !(port.id in allocatedDict); + } + ); + + ctrl.subportsTables.available = availableList; + // Notify transfertable. + $scope.$broadcast( + ttevents.TABLES_CHANGED, + {data: {available: availableList}} + ); + } + + } +})(); diff --git a/openstack_dashboard/static/app/core/trunks/steps/trunk-subports.controller.spec.js b/openstack_dashboard/static/app/core/trunks/steps/trunk-subports.controller.spec.js new file mode 100644 index 0000000000..49f160442a --- /dev/null +++ b/openstack_dashboard/static/app/core/trunks/steps/trunk-subports.controller.spec.js @@ -0,0 +1,137 @@ +/* + * 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 Subports Step', function() { + beforeEach(module('horizon.framework.widgets')); + beforeEach(module('horizon.framework.widgets.action-list')); + beforeEach(module('horizon.app.core.trunks')); + + describe('TrunkSubPortsController', function() { + var scope, ctrl, ttevents; + + beforeEach(inject(function($rootScope, $controller, $injector) { + scope = $rootScope.$new(); + scope.crossHide = true; + scope.ports = { + subportCandidates: [{id: 1}, {id: 2}], + subportsOfInitTrunk: [] + }; + scope.stepModels = {}; + scope.initTrunk = { + sub_ports: [] + }; + ttevents = $injector.get('horizon.framework.widgets.transfer-table.events'); + + ctrl = $controller('TrunkSubPortsController', { + $scope: scope + }); + })); + + it('has correct ports statuses', function() { + expect(ctrl.portStatuses).toBeDefined(); + expect(ctrl.portStatuses.ACTIVE).toBeDefined(); + expect(ctrl.portStatuses.DOWN).toBeDefined(); + expect(Object.keys(ctrl.portStatuses).length).toBe(2); + }); + + it('has correct network admin states', function() { + expect(ctrl.portAdminStates).toBeDefined(); + expect(ctrl.portAdminStates.UP).toBeDefined(); + expect(ctrl.portAdminStates.DOWN).toBeDefined(); + expect(Object.keys(ctrl.portAdminStates).length).toBe(2); + }); + + it('defines a multiple-allocation table', function() { + expect(ctrl.tableLimits).toBeDefined(); + expect(ctrl.tableLimits.maxAllocation).toBe(-1); + }); + + it('contains help text for the table', function() { + expect(ctrl.tableHelpText).toBeDefined(); + expect(ctrl.tableHelpText.allocHelpText).toBeDefined(); + }); + + it('nameOrId returns the name', function() { + var obj = {name: 'test_name', id: 'test_id'}; + expect(ctrl.nameOrID).toBeDefined(); + expect(ctrl.nameOrID(obj)).toBe('test_name'); + }); + + it('nameOrId returns the id if the name is missing', function() { + expect(ctrl.nameOrID).toBeDefined(); + expect(ctrl.nameOrID({'id': 'testid'})).toBe('testid'); + }); + + it('uses scope to set table data', function() { + expect(ctrl.subportsTables).toBeDefined(); + expect(ctrl.subportsTables.available).toEqual( + [{id: 1}, {id: 2}]); + expect(ctrl.subportsTables.allocated).toEqual([]); + expect(ctrl.subportsTables.displayedAllocated).toEqual([]); + expect(ctrl.subportsTables.displayedAvailable).toEqual([]); + }); + + it('has segmentation types dict', function() { + expect(ctrl.segmentationTypesDict).toBeDefined(); + expect(ctrl.segmentationTypesDict.vlan).toBeDefined(); + expect(ctrl.segmentationTypesDict.vlan.length).toEqual(2); + }); + + it('has subports detail dict', function() { + expect(ctrl.subportsDetails).toBeDefined(); + }); + + it('has segmentation types list', function() { + expect(ctrl.segmentationTypes).toBeDefined(); + }); + + it('should return with subports', function() { + ctrl.subportsTables.allocated = [{id: 3}, {id: 4}, {id: 5}]; + ctrl.subportsDetails = { + 3: {segmentation_type: 'VLAN', segmentation_id: 100}, + 4: {segmentation_type: 'VLAN', segmentation_id: 101} + }; + var subports = scope.stepModels.trunkSlices.getSubports(); + expect(subports).toEqual({ + sub_ports: [ + {port_id: 3, segmentation_id: 100, segmentation_type: 'VLAN'}, + {port_id: 4, segmentation_id: 101, segmentation_type: 'VLAN'} + ] + }); + }); + + it('should remove port from available list if parenttable changes', function() { + spyOn(scope, '$broadcast').and.callThrough(); + + ctrl.subportsTables.available = [{id: 1}, {id: 2}, {id: 3}]; + scope.stepModels.allocated.parentPort = [{id: 3}]; + + scope.$digest(); + + expect(scope.$broadcast).toHaveBeenCalledWith( + ttevents.TABLES_CHANGED, + {data: {available: [{id: 1}, {id: 2}]}} + ); + expect(ctrl.subportsTables.available).toEqual([{id: 1}, {id: 2}]); + }); + + }); + + }); +})(); diff --git a/openstack_dashboard/static/app/core/trunks/steps/trunk-subports.help.html b/openstack_dashboard/static/app/core/trunks/steps/trunk-subports.help.html new file mode 100644 index 0000000000..8ad75522ef --- /dev/null +++ b/openstack_dashboard/static/app/core/trunks/steps/trunk-subports.help.html @@ -0,0 +1,65 @@ +
+
Subports
+
+ +

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.

+ +
+
diff --git a/openstack_dashboard/static/app/core/trunks/steps/trunk-subports.html b/openstack_dashboard/static/app/core/trunks/steps/trunk-subports.html new file mode 100644 index 0000000000..313f4e9d6b --- /dev/null +++ b/openstack_dashboard/static/app/core/trunks/steps/trunk-subports.html @@ -0,0 +1,176 @@ +
+ +

Subports

+ +

+ Select an arbitrary amount of ports (0, 1, 2, ...) and their + segmentation details as the subports of the trunk to be created. + Optional. +

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameIPAdmin StateStatusSegmentation TypeSegmentation 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 + + + + + + + + + +
+
+
ID
+
{$ item.id $}
+
Project ID
+
{$ item.tenant_id $}
+
Network ID
+
{$ item.network_id $}
+
Network
+
{$ item.network_name $}
+
VNIC type
+
{$ item['binding:vnic_type'] | decode:ctrl.vnicTypes $}
+
+
Host ID
+
{$ item['binding:host_id'] $}
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
NameIPAdmin StateStatus
+
+ 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 $} + + + + + +
+
+
ID
+
{$ item.id $}
+
Project ID
+
{$ item.tenant_id $}
+
Network ID
+
{$ item.network_id $}
+
Network
+
{$ item.network_name $}
+
VNIC type
+
{$ item['binding:vnic_type'] | decode:ctrl.vnicTypes $}
+
+
Host ID
+
{$ item['binding:host_id'] $}
+
+
+
+
+
+
diff --git a/openstack_dashboard/static/app/core/trunks/trunks.module.js b/openstack_dashboard/static/app/core/trunks/trunks.module.js index cc0b99e6e4..d7c533aee7 100644 --- a/openstack_dashboard/static/app/core/trunks/trunks.module.js +++ b/openstack_dashboard/static/app/core/trunks/trunks.module.js @@ -34,6 +34,7 @@ 'horizon.app.core' ]) .constant('horizon.app.core.trunks.resourceType', 'OS::Neutron::Trunk') + .constant('horizon.app.core.trunks.portConstants', portConstants()) .run(run) .config(config); @@ -128,7 +129,7 @@ description: gettext('Description'), id: gettext('ID'), name: gettext('Name'), - name_or_id: gettext('Name'), + name_or_id: gettext('Name/ID'), port_id: gettext('Parent Port'), project_id: gettext('Project ID'), status: gettext('Status'), @@ -137,6 +138,26 @@ }; } + function portConstants() { + return { + statuses: { + 'ACTIVE': gettext('Active'), + 'DOWN': gettext('Down') + }, + adminStates: { + 'UP': gettext('Up'), + 'DOWN': gettext('Down') + }, + vnicTypes: { + 'normal': gettext('Normal'), + 'direct': gettext('Direct'), + 'direct-physical': gettext('Direct Physical'), + 'macvtap': gettext('MacVTap'), + 'baremetal': gettext('Bare Metal') + } + }; + } + config.$inject = [ '$provide', '$windowProvider', diff --git a/openstack_dashboard/static/app/core/trunks/trunks.module.spec.js b/openstack_dashboard/static/app/core/trunks/trunks.module.spec.js index 0e00141b3b..6e4dabaabb 100644 --- a/openstack_dashboard/static/app/core/trunks/trunks.module.spec.js +++ b/openstack_dashboard/static/app/core/trunks/trunks.module.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/trunks.service.js b/openstack_dashboard/static/app/core/trunks/trunks.service.js index 1e13f43255..a4719534d2 100644 --- a/openstack_dashboard/static/app/core/trunks/trunks.service.js +++ b/openstack_dashboard/static/app/core/trunks/trunks.service.js @@ -80,13 +80,21 @@ * Given an id, returns a promise for the trunk data. */ function getTrunkPromise(identifier) { - return neutron.getTrunk(identifier).then(getTrunkSuccess, getTrunkError); - - function getTrunkSuccess(trunk) { - return trunk; - } + // NOTE(bence romsics): This promise is called from multiple places + // where error handling should differ. When you edit a trunk from the + // detail view errors of re-reading the trunk should be shown. But + // when you delete a trunk from the detail view and the deleted + // trunk is re-read (that fails of course) you don't want to see an + // error because of that. Ideally we wouldn't even try to re-read (ie. + // show) after delete from detail (re-list should be enough). + return neutron.getTrunk(identifier).catch(getTrunkError); function getTrunkError(trunk) { + // TODO(bence romsics): When you delete a trunk from the details + // view then it cannot be re-read (of course) and we handle that + // by a hard-coded redirect to the project panel. This is okay + // for now. But when we want this panel to work for admin too, + // we should not hard-code this anymore. $location.url('project/trunks'); return trunk; } diff --git a/openstack_dashboard/test/api_tests/neutron_rest_tests.py b/openstack_dashboard/test/api_tests/neutron_rest_tests.py index 7b09a54aed..41c944a7b1 100644 --- a/openstack_dashboard/test/api_tests/neutron_rest_tests.py +++ b/openstack_dashboard/test/api_tests/neutron_rest_tests.py @@ -182,6 +182,10 @@ class NeutronTrunkTestCase(test.TestCase): class NeutronTrunksTestCase(test.TestCase): + def setUp(self): + super(NeutronTrunksTestCase, self).setUp() + self._trunks = [test.mock_factory(n) + for n in TEST.api_trunks.list()] @mock.patch.object(neutron.api, 'neutron') def test_trunks_get(self, client): @@ -193,6 +197,17 @@ class NeutronTrunksTestCase(test.TestCase): response, [t.to_dict() for t in self.trunks.list()]) + @mock.patch.object(neutron.api, 'neutron') + def test_trunks_create(self, client): + request = self.mock_rest_request(body=''' + {"name": "trunk1", "port_id": 1} + ''') + + client.trunk_create.return_value = self._trunks[0] + response = neutron.Trunks().post(request) + self.assertStatusCode(response, 201) + self.assertEqual(response.json, TEST.api_trunks.first()) + class NeutronExtensionsTestCase(test.TestCase): def setUp(self): diff --git a/openstack_dashboard/test/api_tests/neutron_tests.py b/openstack_dashboard/test/api_tests/neutron_tests.py index 95b79185ea..026c23bb38 100644 --- a/openstack_dashboard/test/api_tests/neutron_tests.py +++ b/openstack_dashboard/test/api_tests/neutron_tests.py @@ -534,6 +534,20 @@ class NeutronApiTests(test.APITestCase): self.assertEqual(obj.name_or_id, trunk_dict['name_or_id']) self.assertEqual(2, trunk_dict['subport_count']) + def test_trunk_create(self): + trunk = {'trunk': self.api_trunks.first()} + params = {'name': trunk['trunk']['name'], + 'port_id': trunk['trunk']['port_id'], + 'project_id': trunk['trunk']['project_id']} + + neutronclient = self.stub_neutronclient() + neutronclient.create_trunk(body={'trunk': params}).AndReturn(trunk) + self.mox.ReplayAll() + + ret_val = api.neutron.trunk_create(self.request, **params) + self.assertIsInstance(ret_val, api.neutron.Trunk) + self.assertEqual(api.neutron.Trunk(trunk['trunk']).id, ret_val.id) + def test_trunk_delete(self): trunk_id = self.api_trunks.first()['id'] diff --git a/releasenotes/notes/bp-neutron-trunk-ui-queens-1d59df887b9a079a.yaml b/releasenotes/notes/bp-neutron-trunk-ui-queens-1d59df887b9a079a.yaml new file mode 100644 index 0000000000..0bfe0394d6 --- /dev/null +++ b/releasenotes/notes/bp-neutron-trunk-ui-queens-1d59df887b9a079a.yaml @@ -0,0 +1,9 @@ +--- +features: + - | + [`blueprint neutron-trunk-ui `_] + Partial support for Neutron Trunks. The Project/Network/Trunks + panel turns on if Neutron API extension 'trunk' is available. It + displays information about trunks. The details page for each trunk + also shows information about subports of that trunk. + Supported actions: create, delete.