From 1ab4b498f2df19a4541c8991138907bef829376f Mon Sep 17 00:00:00 2001 From: Bence Romsics Date: Mon, 3 Apr 2017 16:37:29 +0200 Subject: [PATCH] Trunks panel: create button Trunk creation is a 3-step workflow: * Basic trunk attributes * Parent port selector transfertable: Selects a single port (mandatory) * Subports selector transfertable: Selects many ports with segmentation details (optional) In the port selector steps reused and built on port allocator transfertable from launch instance. The easiest way to test is to take the whole change series by taking the last change in it, then build devstack with neutron trunk support. Eg: local.conf: enable_plugin neutron https://git.openstack.org/openstack/neutron enable_service q-trunk If you want to test this change in isolation you also need the following Horizon config: openstack_dashboard/enabled/_1500_project_trunks_panel.py: DISABLED = False # or just remove this line As long as the 'trunk' API extension is available (openstack extension show trunk) the panel should automatically appear under Project/Network/Trunks. To try the 'inherit' segmentation type the subports must be ports of vlan type provider networks. Co-Authored-By: Lajos Katona Co-Authored-By: Elod Illes Change-Id: I663a7e0158335155fe11f0fc40d9fa86bf984ae0 Partially-Implements: blueprint neutron-trunk-ui --- openstack_dashboard/api/neutron.py | 10 + openstack_dashboard/api/rest/neutron.py | 10 +- .../openstack-service-api/neutron.service.js | 39 +++- .../neutron.service.spec.js | 10 + .../app/core/trunks/actions/actions.module.js | 28 ++- .../trunks/actions/actions.module.spec.js | 25 +-- .../trunks/actions/create.action.service.js | 169 +++++++++++++++++ .../actions/create.action.service.spec.js | 170 +++++++++++++++++ .../trunks/actions/create.workflow.service.js | 68 +++++++ .../trunks/actions/delete.action.service.js | 2 +- .../actions/delete.action.service.spec.js | 2 +- .../trunks/actions/ports-extra.service.js | 132 +++++++++++++ .../actions/ports-extra.service.spec.js | 100 ++++++++++ .../trunks/steps/trunk-details.controller.js | 89 +++++++++ .../steps/trunk-details.controller.spec.js | 64 +++++++ .../core/trunks/steps/trunk-details.help.html | 17 ++ .../app/core/trunks/steps/trunk-details.html | 53 ++++++ .../steps/trunk-parent-port.controller.js | 125 +++++++++++++ .../trunk-parent-port.controller.spec.js | 119 ++++++++++++ .../trunks/steps/trunk-parent-port.help.html | 25 +++ .../core/trunks/steps/trunk-parent-port.html | 159 ++++++++++++++++ .../trunks/steps/trunk-subports.controller.js | 152 +++++++++++++++ .../steps/trunk-subports.controller.spec.js | 137 ++++++++++++++ .../trunks/steps/trunk-subports.help.html | 65 +++++++ .../app/core/trunks/steps/trunk-subports.html | 176 ++++++++++++++++++ .../static/app/core/trunks/trunks.module.js | 23 ++- .../app/core/trunks/trunks.module.spec.js | 2 +- .../static/app/core/trunks/trunks.service.js | 18 +- .../test/api_tests/neutron_rest_tests.py | 15 ++ .../test/api_tests/neutron_tests.py | 14 ++ ...tron-trunk-ui-queens-1d59df887b9a079a.yaml | 9 + 31 files changed, 1989 insertions(+), 38 deletions(-) create mode 100644 openstack_dashboard/static/app/core/trunks/actions/create.action.service.js create mode 100644 openstack_dashboard/static/app/core/trunks/actions/create.action.service.spec.js create mode 100644 openstack_dashboard/static/app/core/trunks/actions/create.workflow.service.js create mode 100644 openstack_dashboard/static/app/core/trunks/actions/ports-extra.service.js create mode 100644 openstack_dashboard/static/app/core/trunks/actions/ports-extra.service.spec.js create mode 100644 openstack_dashboard/static/app/core/trunks/steps/trunk-details.controller.js create mode 100644 openstack_dashboard/static/app/core/trunks/steps/trunk-details.controller.spec.js create mode 100644 openstack_dashboard/static/app/core/trunks/steps/trunk-details.help.html create mode 100644 openstack_dashboard/static/app/core/trunks/steps/trunk-details.html create mode 100644 openstack_dashboard/static/app/core/trunks/steps/trunk-parent-port.controller.js create mode 100644 openstack_dashboard/static/app/core/trunks/steps/trunk-parent-port.controller.spec.js create mode 100644 openstack_dashboard/static/app/core/trunks/steps/trunk-parent-port.help.html create mode 100644 openstack_dashboard/static/app/core/trunks/steps/trunk-parent-port.html create mode 100644 openstack_dashboard/static/app/core/trunks/steps/trunk-subports.controller.js create mode 100644 openstack_dashboard/static/app/core/trunks/steps/trunk-subports.controller.spec.js create mode 100644 openstack_dashboard/static/app/core/trunks/steps/trunk-subports.help.html create mode 100644 openstack_dashboard/static/app/core/trunks/steps/trunk-subports.html create mode 100644 releasenotes/notes/bp-neutron-trunk-ui-queens-1d59df887b9a079a.yaml 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.