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 <lajos.katona@ericsson.com>
Co-Authored-By: Elod Illes <elod.illes@ericsson.com>
Change-Id: I663a7e0158335155fe11f0fc40d9fa86bf984ae0
Partially-Implements: blueprint neutron-trunk-ui
This commit is contained in:
Bence Romsics 2017-04-03 16:37:29 +02:00
parent d7f29a54b2
commit 1ab4b498f2
31 changed files with 1989 additions and 38 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,4 +1,4 @@
/**
/*
* Copyright 2017 Ericsson
*
* Licensed under the Apache License, Version 2.0 (the "License"); you may

View File

@ -1,4 +1,4 @@
/**
/*
* Copyright 2017 Ericsson
*
* Licensed under the Apache License, Version 2.0 (the "License"); you may

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,17 @@
<dl>
<dt translate>Name</dt>
<dd>
<p translate>An arbitrary name for the trunk. May not be unique.</p>
<p translate>Default: Empty string</p>
</dd>
<dt translate>Description</dt>
<dd>
<p translate>An arbitrary description for the trunk.</p>
<p translate>Default: Empty string</p>
</dd>
<dt translate>Admin State</dt>
<dd>
<p translate>Enable/Disable subport addition, removal and trunk delete.</p>
<p translate>Default: Enabled</p>
</dd>
</dl>

View File

@ -0,0 +1,53 @@
<div ng-controller="TrunkDetailsController as ctrl">
<h4 translate>Details</h4>
<p class="step-description" translate>
Provide basic properties of the trunk to be created. All optional.
</p>
<div class="content">
<div class="selected-source">
<div class="row form-group">
<div class="col-xs-8 col-sm-8">
<div class="form-group">
<label class="control-label" for="trunkForm-name">
<translate>Name</translate>
</label>
<input id="trunkForm-name" name="name"
type="text" class="form-control"
ng-model="ctrl.trunk.name">
</div>
</div>
<div class="col-xs-4 col-sm-4">
<div class="form-group">
<label class="control-label">
<translate>Admin State</translate>
</label>
<div class="form-field">
<div class="btn-group">
<label class="btn btn-default"
ng-repeat="option in ctrl.trunkAdminStateOptions"
ng-model="ctrl.trunk.admin_state_up"
uib-btn-radio="option.value">{$ ::option.label $}</label>
</div>
</div>
</div>
</div>
</div>
<div class="row form-group">
<div class="col-xs-12 col-sm-12">
<div class="form-group">
<label class="control-label" for="trunkForm-description">
<translate>Description</translate>
</label>
<input id="trunkForm-description" name="description"
type="text" class="form-control"
ng-model="ctrl.trunk.description">
</div>
</div>
</div>
</div>
</div>
</div>

View File

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

View File

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

View File

@ -0,0 +1,25 @@
<dl>
<dt translate>Parent Port</dt>
<dd>
<p translate>Exactly one regular Neutron port. Has to be provided when
the trunk is created. Cannot be changed during the trunk's life.</p>
<p translate>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.</p>
<p translate>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.</p>
<p translate>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.</p>
<p translate>Must not be the parent or a subport of any other
trunks.</p>
</dd>
</dl>

View File

@ -0,0 +1,159 @@
<div ng-controller="TrunkParentPortController as ctrl">
<h4 translate>Parent port</h4>
<p class="step-description" translate>
Select <em>exactly one port</em> as the parent port of the trunk to
be created. Mandatory.
</p>
<transfer-table tr-model="ctrl.parentTables" help-text="ctrl.tableHelpText" limits="ctrl.tableLimits">
<allocated ng-model="ctrl.parentTables.allocated.length" validate-number-min="1">
<table st-table="ctrl.parentTables.displayedAllocated" st-safe-src="ctrl.parentTables.allocated"
hz-table class="table table-striped table-rsp table-detail">
<thead>
<tr>
<th class="expander"></th>
<th st-sort="name" st-sort-default class="rsp-p1" translate>Name</th>
<th class="rsp-p2" translate>IP</th>
<th st-sort="admin_state" class="rsp-p1" translate>Admin State</th>
<th st-sort="status" class="rsp-p1" translate>Status</th>
<th class="actions_column"></th>
</tr>
</thead>
<tbody>
<tr ng-if="ctrl.parentTables.allocated.length === 0">
<td colspan="7">
<div class="no-rows-help" translate>
Select an item from Available items below
</div>
</td>
</tr>
<tr ng-repeat-start="item in ctrl.parentTables.displayedAllocated track by item.id"
lr-drag-data="ctrl.parentTables.displayedAllocated"
lr-drop-success="trCtrl.updateAllocated(e, item, collection)">
<td class="expander">
<span class="fa fa-chevron-right" hz-expand-detail
title="{$ 'Click to see more details'|translate $}"></span>
</td>
<td class="rsp-p1 word-break">{$ ctrl.nameOrID(item) $}</td>
<td class="rsp-p2">
<div ng-repeat="ip in item.fixed_ips">
{$ ip.ip_address $} <span translate>on subnet</span>: {$ item.subnet_names[ip.ip_address] $}
</div>
</td>
<td class="rsp-p1">{$ item.admin_state | decode:ctrl.portAdminStates $}</td>
<td class="rsp-p1">{$ item.status | decode:ctrl.portStatuses $}</td>
<td class="actions_column">
<action-list>
<action action-classes="'btn btn-default'"
callback="trCtrl.deallocate" item="item">
<span class="fa fa-arrow-down"></span>
</action>
</action-list>
</td>
</tr>
<tr ng-repeat-end class="detail-row">
<td colspan="7" class="detail">
<dl class="dl-horizontal">
<dt translate>ID</dt>
<dd>{$ item.id $}</dd>
<dt translate>Project ID</dt>
<dd>{$ item.tenant_id $}</dd>
<dt translate>Network ID</dt>
<dd>{$ item.network_id $}</dd>
<dt translate>Network</dt>
<dd>{$ item.network_name $}</dd>
<dt translate>Device Owner</dt>
<dd>{$ item.device_owner $}</dd>
<dt translate>Device ID</dt>
<dd>{$ item.device_id $}</dd>
<dt translate>VNIC type</dt>
<dd>{$ item['binding:vnic_type'] | decode:ctrl.vnicTypes $}</dd>
<div ng-if="item['binding:host_id']">
<dt translate>Host ID</dt>
<dd>{$ item['binding:host_id'] $}</dd>
</div>
</dl>
</td>
</tr>
</tbody>
</table>
</allocated>
<available>
<table st-table="ctrl.parentTables.displayedAvailable" st-safe-src="ctrl.parentTables.available"
hz-table class="table table-striped table-rsp table-detail">
<thead>
<tr>
<th class="search-header" colspan="6">
<hz-search-bar icon-classes="fa-search"></hz-search-bar>
</th>
</tr>
<tr>
<th class="expander"></th>
<th st-sort="name" st-sort-default class="rsp-p1" translate>Name</th>
<th class="rsp-p2" translate>IP</th>
<th st-sort="admin_state" class="rsp-p1" translate>Admin State</th>
<th st-sort="status" class="rsp-p1" translate>Status</th>
<th class="actions_column"></th>
</tr>
</thead>
<tbody>
<tr ng-if="trCtrl.numAvailable() === 0">
<td colspan="6">
<div class="no-rows-help" translate>
No available items
</div>
</td>
</tr>
<tr ng-repeat-start="item in ctrl.parentTables.displayedAvailable track by item.id"
ng-if="!trCtrl.allocatedIds[item.id]">
<td class="expander">
<span class="fa fa-chevron-right" hz-expand-detail
title="{$ 'Click to see more details'|translate $}"></span>
</td>
<td class="rsp-p1 word-break">{$ ctrl.nameOrID(item) $}</td>
<td class="rsp-p2">
<div ng-repeat="ip in item.fixed_ips">
{$ ip.ip_address $} <span translate>on subnet</span>: {$ item.subnet_names[ip.ip_address] $}
</div>
</td>
<td class="rsp-p1">{$ item.admin_state | decode:ctrl.portAdminStates $}</td>
<td class="rsp-p1">{$ item.status | decode:ctrl.portStatuses $}</td>
<td class="actions_column">
<action-list>
<action action-classes="'btn btn-default'"
callback="trCtrl.allocate" item="item">
<span class="fa fa-arrow-up"></span>
</action>
</action-list>
</td>
</tr>
<tr ng-repeat-end class="detail-row">
<td colspan="6" class="detail">
<dl class="dl-horizontal">
<dt translate>ID</dt>
<dd>{$ item.id $}</dd>
<dt translate>Project ID</dt>
<dd>{$ item.tenant_id $}</dd>
<dt translate>Network ID</dt>
<dd>{$ item.network_id $}</dd>
<dt translate>Network</dt>
<dd>{$ item.network_name $}</dd>
<dt translate>Device ID</dt>
<dd>{$ item.device_id $}</dd>
<dt translate>VNIC type</dt>
<dd>{$ item['binding:vnic_type'] | decode:ctrl.vnicTypes $}</dd>
<div ng-if="item['binding:host_id']">
<dt translate>Host ID</dt>
<dd>{$ item['binding:host_id'] $}</dd>
</div>
</dl>
</td>
</tr>
</tbody>
</table>
</available>
</transfer-table>
</div>

View File

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

View File

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

View File

@ -0,0 +1,65 @@
<dl>
<dt translate>Subports</dt>
<dd>
<p translate>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.
<p translate>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 <em>segmentation type</em> and <em>segmentation ID</em>
of the subport.</p>
<p translate>Networks of subports may become available later than the
moment of bootup. But they will be available after the trunk reached
the <em>ACTIVE</em> status.</p>
<p translate>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.</p>
<p translate>For ports on provider networks you may choose the special
segmentation type <em>inherit</em>. 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.</p>
<p translate>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.</p>
<p translate>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:</p>
<pre>
# 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</pre>
<p translate>This can be simplified by reusing the parent's MAC address
for all subports of the trunk earlier at port creation. Eg.:</p>
<pre>
sudo ip link add link eth0 \
name eth0.101 \
type vlan id 101
sudo dhclient eth0.101</pre>
<p translate>Also note that segmentation details may be mandatory
or optional depending on the backend. Notably Ironic may provide
segmentation details instead of the user.</p>
<p translate>No subport can be the parent or a subport of any other
trunks.</p>
</dd>
</dl>

View File

@ -0,0 +1,176 @@
<div ng-controller="TrunkSubPortsController as ctrl">
<h4 translate>Subports</h4>
<p class="step-description" translate>
Select an <em>arbitrary amount of ports (0, 1, 2, ...)</em> and their
segmentation details as the subports of the trunk to be created.
Optional.
</p>
<transfer-table tr-model="ctrl.subportsTables" help-text="ctrl.tableHelpText" limits="ctrl.tableLimits">
<allocated>
<table st-table="ctrl.subportsTables.displayedAllocated" st-safe-src="ctrl.subportsTables.allocated"
hz-table class="table table-striped table-rsp table-detail">
<thead>
<tr>
<th class="expander"></th>
<th st-sort="name" st-sort-default class="rsp-p1" translate>Name</th>
<th class="rsp-p2" translate>IP</th>
<th st-sort="admin_state" class="rsp-p1" translate>Admin State</th>
<th st-sort="status" class="rsp-p1" translate>Status</th>
<th class="rsp-p1" translate>Segmentation Type</th>
<th class="rsp-p1" translate>Segmentation Id</th>
<th class="actions_column"></th>
</tr>
</thead>
<tbody>
<tr ng-if="ctrl.subportsTables.allocated.length === 0">
<td colspan="7">
<div class="no-rows-help" translate>
Select items from Available items below
</div>
</td>
</tr>
<tr ng-repeat-start="item in ctrl.subportsTables.displayedAllocated track by item.id"
lr-drag-data="ctrl.subportsTables.displayedAllocated"
lr-drop-success="trCtrl.updateAllocated(e, item, collection)">
<td class="expander">
<span class="fa fa-chevron-right" hz-expand-detail
title="{$ 'Click to see more details'|translate $}"></span>
</td>
<td class="rsp-p1 word-break">{$ ctrl.nameOrID(item) $}</td>
<td class="rsp-p2">
<div ng-repeat="ip in item.fixed_ips">
{$ ip.ip_address $} <span translate>on subnet</span>: {$ item.subnet_names[ip.ip_address] $}
</div>
</td>
<td class="rsp-p1">{$ item.admin_state | decode:ctrl.portAdminStates $}</td>
<td class="rsp-p1">{$ item.status | decode:ctrl.portStatuses $}</td>
<td class="rsp-p1">
<select id="segmentation_type_{$ $index $}"
ng-init="ctrl.subportsDetails[item.id]['segmentation_type'] = ctrl.segmentationTypes[0]"
ng-options="type for type in ctrl.segmentationTypes"
ng-model="ctrl.subportsDetails[item.id]['segmentation_type']">
</select>
</td>
<td class="rsp-p1" ng-if="ctrl.subportsDetails[item.id]['segmentation_type'] === 'inherit'" translate>
inherit
</td>
<td class="rsp-p1" ng-if="ctrl.subportsDetails[item.id]['segmentation_type'] !== 'inherit'">
<!-- NOTE(bence romsics): We could but do not reject non-integer
segmentation IDs. It does not seem worth to add one more
directive for that effect. -->
<input type="number"
id="segmentation_id_{$ $index $}"
min="{$ ctrl.segmentationTypesDict[ctrl.subportsDetails[item.id]['segmentation_type']][0] $}"
max="{$ ctrl.segmentationTypesDict[ctrl.subportsDetails[item.id]['segmentation_type']][1] $}"
ng-model="ctrl.subportsDetails[item.id]['segmentation_id']" required>
</td>
<td class="actions_column">
<action-list>
<action action-classes="'btn btn-default'"
callback="trCtrl.deallocate" item="item">
<span class="fa fa-arrow-down"></span>
</action>
</action-list>
</td>
</tr>
<tr ng-repeat-end class="detail-row">
<td colspan="7" class="detail">
<dl class="dl-horizontal">
<dt translate>ID</dt>
<dd>{$ item.id $}</dd>
<dt translate>Project ID</dt>
<dd>{$ item.tenant_id $}</dd>
<dt translate>Network ID</dt>
<dd>{$ item.network_id $}</dd>
<dt translate>Network</dt>
<dd>{$ item.network_name $}</dd>
<dt translate>VNIC type</dt>
<dd>{$ item['binding:vnic_type'] | decode:ctrl.vnicTypes $}</dd>
<div ng-if="item['binding:host_id']">
<dt translate>Host ID</dt>
<dd>{$ item['binding:host_id'] $}</dd>
</div>
</dl>
</td>
</tr>
</tbody>
</table>
</allocated>
<available>
<table st-table="ctrl.subportsTables.displayedAvailable" st-safe-src="ctrl.subportsTables.available"
hz-table class="table table-striped table-rsp table-detail">
<thead>
<tr>
<th class="search-header" colspan="6">
<hz-search-bar icon-classes="fa-search"></hz-search-bar>
</th>
</tr>
<tr>
<th class="expander"></th>
<th st-sort="name" st-sort-default class="rsp-p1" translate>Name</th>
<th class="rsp-p2" translate>IP</th>
<th st-sort="admin_state" class="rsp-p1" translate>Admin State</th>
<th st-sort="status" class="rsp-p1" translate>Status</th>
<th class="actions_column"></th>
</tr>
</thead>
<tbody>
<tr ng-if="trCtrl.numAvailable() === 0">
<td colspan="6">
<div class="no-rows-help" translate>
No available items
</div>
</td>
</tr>
<tr ng-repeat-start="item in ctrl.subportsTables.displayedAvailable track by item.id"
ng-if="!trCtrl.allocatedIds[item.id]">
<td class="expander">
<span class="fa fa-chevron-right" hz-expand-detail
title="{$ 'Click to see more details'|translate $}"></span>
</td>
<td class="rsp-p1 word-break">{$ ctrl.nameOrID(item) $}</td>
<td class="rsp-p2">
<div ng-repeat="ip in item.fixed_ips">
{$ ip.ip_address $} <span translate>on subnet</span>: {$ item.subnet_names[ip.ip_address] $}
</div>
</td>
<td class="rsp-p1">{$ item.admin_state | decode:ctrl.portAdminStates $}</td>
<td class="rsp-p1">{$ item.status | decode:ctrl.portStatuses $}</td>
<td class="actions_column">
<action-list>
<action action-classes="'btn btn-default'"
callback="trCtrl.allocate" item="item">
<span class="fa fa-arrow-up"></span>
</action>
</action-list>
</td>
</tr>
<tr ng-repeat-end class="detail-row">
<td colspan="6" class="detail">
<dl class="dl-horizontal">
<dt translate>ID</dt>
<dd>{$ item.id $}</dd>
<dt translate>Project ID</dt>
<dd>{$ item.tenant_id $}</dd>
<dt translate>Network ID</dt>
<dd>{$ item.network_id $}</dd>
<dt translate>Network</dt>
<dd>{$ item.network_name $}</dd>
<dt translate>VNIC type</dt>
<dd>{$ item['binding:vnic_type'] | decode:ctrl.vnicTypes $}</dd>
<div ng-if="item['binding:host_id']">
<dt translate>Host ID</dt>
<dd>{$ item['binding:host_id'] $}</dd>
</div>
</dl>
</td>
</tr>
</tbody>
</table>
</available>
</transfer-table>
</div>

View File

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

View File

@ -1,4 +1,4 @@
/**
/*
* Copyright 2017 Ericsson
*
* Licensed under the Apache License, Version 2.0 (the "License"); you may

View File

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

View File

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

View File

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

View File

@ -0,0 +1,9 @@
---
features:
- |
[`blueprint neutron-trunk-ui <https://blueprints.launchpad.net/horizon/+spec/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.