Add support for TERMINATED_HTTPS protocol

This adds support for the TERMINATED_HTTPS listener protocol when
creating a new listener. When this option is selected the SSL
Certificates tab is displayed after the Listener Details tab and
allows selecting one or more available certificates. The user must
have barbican available and authority to list certificates and
secrets. Certificate containers must be created in barbican before
they will be available when creating a listener.

Partially-Implements: blueprint horizon-lbaas-v2-ui
Change-Id: Ia9312fa865d85ca977c1daea347d97bd69e9c5ba
This commit is contained in:
Justin Pomeroy 2016-01-12 17:37:40 -06:00
parent 9a88246fe6
commit c3ec347a54
21 changed files with 885 additions and 20 deletions

View File

@ -22,4 +22,5 @@ in https://wiki.openstack.org/wiki/APIChangeGuidelines.
"""
# import REST API modules here
from neutron_lbaas_dashboard.api.rest import barbican # noqa
from neutron_lbaas_dashboard.api.rest import lbaasv2 # noqa

View File

@ -0,0 +1,84 @@
# Copyright 2016 IBM Corp.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""API over the barbican service.
"""
from barbicanclient import client as barbican_client
from django.conf import settings
from django.views import generic
from keystoneclient.auth.identity import v2 as auth_v2
from keystoneclient.auth.identity import v3 as auth_v3
from keystoneclient import session
from horizon.utils.memoized import memoized # noqa
from openstack_dashboard.api import keystone
from openstack_dashboard.api.rest import urls
from openstack_dashboard.api.rest import utils as rest_utils
@memoized
def barbicanclient(request):
project_id = request.user.project_id
if keystone.get_version() < 3:
auth = auth_v2.Token(settings.OPENSTACK_KEYSTONE_URL,
request.user.token.id,
tenant_id=project_id)
else:
domain_id = request.session.get('domain_context')
auth = auth_v3.Token(settings.OPENSTACK_KEYSTONE_URL,
request.user.token.id,
project_id=project_id,
project_domain_id=domain_id)
return barbican_client.Client(session=session.Session(auth=auth))
@urls.register
class SSLCertificates(generic.View):
"""API for working with SSL certificate containers.
"""
url_regex = r'barbican/certificates/$'
@rest_utils.ajax()
def get(self, request):
"""List certificate containers.
The listing result is an object with property "items".
"""
limit = getattr(settings, 'API_RESULT_LIMIT', 1000)
containers = barbicanclient(request).containers
params = {'limit': limit, 'type': 'certificate'}
result = containers._api.get('containers', params=params)
return {'items': result.get('containers')}
@urls.register
class Secrets(generic.View):
"""API for working with secrets.
"""
url_regex = r'barbican/secrets/$'
@rest_utils.ajax()
def get(self, request):
"""List secrets.
The listing result is an object with property "items".
"""
limit = getattr(settings, 'API_RESULT_LIMIT', 1000)
secrets = barbicanclient(request).secrets
params = {'limit': limit}
result = secrets._api.get('secrets', params=params)
return {'items': result.get('secrets')}

View File

@ -72,6 +72,10 @@ def create_listener(request, **kwargs):
listenerSpec['name'] = data['listener']['name']
if data['listener'].get('description'):
listenerSpec['description'] = data['listener']['description']
if data.get('certificates'):
listenerSpec['default_tls_container_ref'] = data['certificates'][0]
listenerSpec['sni_container_refs'] = data['certificates']
listener = neutronclient(request).create_listener(
{'listener': listenerSpec}).get('listener')

View File

@ -0,0 +1,86 @@
/*
* Copyright 2016 IBM Corp.
*
* Licensed under the Apache License, Version 2.0 (the 'License');
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an 'AS IS' BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
(function () {
'use strict';
angular
.module('horizon.app.core.openstack-service-api')
.factory('horizon.app.core.openstack-service-api.barbican', barbicanAPI);
barbicanAPI.$inject = [
'horizon.framework.util.http.service',
'horizon.framework.widgets.toast.service'
];
/**
* @ngdoc service
* @name horizon.app.core.openstack-service-api.barbican
* @description Provides direct pass through to barbican with NO abstraction.
* @param apiService The horizon core API service.
* @param toastService The horizon toast service.
* @returns The barbican service API.
*/
function barbicanAPI(apiService, toastService) {
var service = {
getCertificates: getCertificates,
getSecrets: getSecrets
};
return service;
///////////////
// SSL Certificate Containers
/**
* @name horizon.app.core.openstack-service-api.barbican.getCertificates
* @description
* Get a list of SSL certificate containers.
*
* @param {boolean} quiet
* The listing result is an object with property "items". Each item is
* a certificate container.
*/
function getCertificates(quiet) {
var promise = apiService.get('/api/barbican/certificates/');
return quiet ? promise : promise.error(function handleError() {
toastService.add('error', gettext('Unable to retrieve SSL certificates.'));
});
}
// Secrets
/**
* @name horizon.app.core.openstack-service-api.barbican.getSecrets
* @description
* Get a list of secrets.
*
* @param {boolean} quiet
* The listing result is an object with property "items". Each item is
* a secret.
*/
function getSecrets(quiet) {
var promise = apiService.get('/api/barbican/secrets/');
return quiet ? promise : promise.error(function handleError() {
toastService.add('error', gettext('Unable to retrieve secrets.'));
});
}
}
}());

View File

@ -0,0 +1,73 @@
/*
* Copyright 2016 IBM Corp.
*
* Licensed under the Apache License, Version 2.0 (the 'License');
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an 'AS IS' BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
(function() {
'use strict';
describe('Barbican API', function() {
var testCall, service;
var apiService = {};
var toastService = {};
beforeEach(module('horizon.mock.openstack-service-api', function($provide, initServices) {
testCall = initServices($provide, apiService, toastService);
}));
beforeEach(module('horizon.app.core.openstack-service-api'));
beforeEach(inject(['horizon.app.core.openstack-service-api.barbican', function(barbicanAPI) {
service = barbicanAPI;
}]));
it('defines the service', function() {
expect(service).toBeDefined();
});
var tests = [
{
"func": "getCertificates",
"method": "get",
"path": "/api/barbican/certificates/",
"error": "Unable to retrieve SSL certificates."
},
{
"func": "getSecrets",
"method": "get",
"path": "/api/barbican/secrets/",
"error": "Unable to retrieve secrets."
}
];
// Iterate through the defined tests and apply as Jasmine specs.
angular.forEach(tests, function(params) {
it('defines the ' + params.func + ' call properly', function() {
var callParams = [apiService, service, toastService, params];
testCall.apply(this, callParams);
});
});
it('supresses the error if instructed for getCertificates', function() {
spyOn(apiService, 'get').and.returnValue("promise");
expect(service.getCertificates(true)).toBe("promise");
});
it('supresses the error if instructed for getSecrets', function() {
spyOn(apiService, 'get').and.returnValue("promise");
expect(service.getSecrets(true)).toBe("promise");
});
});
})();

View File

@ -76,6 +76,13 @@
}
}
/* Listeners tab */
[ng-form="listenerDetailsForm"] {
div.listener-protocol > span.fa-exclamation-triangle {
color: #aaaaaa;
}
}
/* Pool Members tab */
[ng-form="memberDetailsForm"] {
.transfer-section:first-child {

View File

@ -52,6 +52,7 @@
gettext('Update Listener'),
'fa fa-pencil', ['listener'],
defer.promise);
var allSteps = scope.workflow.allSteps.concat([scope.workflow.certificatesStep]);
scope.model.initialize('listener', scope.launchContext.id).then(addSteps).then(ready);
function addSteps() {
@ -64,7 +65,7 @@
}
function getStep(id) {
return scope.workflow.allSteps.filter(function findStep(step) {
return allSteps.filter(function findStep(step) {
return step.id === id;
})[0];
}

View File

@ -31,6 +31,7 @@
var workflow = {
steps: [{id: 'listener'}],
allSteps: [{id: 'listener'}, {id: 'pool'}, {id: 'monitor'}],
certificatesStep: {id: 'certificates'},
append: angular.noop
};

View File

@ -0,0 +1,60 @@
/*
* Copyright 2016 IBM Corp.
*
* Licensed under the Apache License, Version 2.0 (the 'License');
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an 'AS IS' BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
(function () {
'use strict';
angular
.module('horizon.dashboard.project.lbaasv2')
.controller('CertificatesController', CertificatesController);
CertificatesController.$inject = [
'$scope',
'horizon.framework.util.i18n.gettext'
];
/**
* @ngdoc controller
* @name CertificatesController
* @description
* The `CertificatesController` controller provides functions for adding certificates to a
* listener.
* @param $scope The angular scope object.
* @param gettext The horizon gettext function for translation.
* @returns undefined
*/
function CertificatesController($scope, gettext) {
var ctrl = this;
ctrl.tableData = {
available: $scope.model.certificates,
allocated: $scope.model.spec.certificates,
displayedAvailable: [],
displayedAllocated: []
};
ctrl.tableLimits = {
maxAllocation: -1
};
ctrl.tableHelp = {
availHelpText: '',
noneAllocText: gettext('Select certificates from the available certificates below'),
noneAvailText: gettext('No available certificates')
};
}
})();

View File

@ -0,0 +1,67 @@
/*
* Copyright 2016 IBM Corp.
*
* Licensed under the Apache License, Version 2.0 (the 'License');
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an 'AS IS' BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
(function() {
'use strict';
describe('SSL Certificates Step', function() {
var certs = [{
id: '1',
name: 'foo',
expiration: '2015-03-26T21:10:45.417835'
}];
beforeEach(module('horizon.framework.util.i18n'));
beforeEach(module('horizon.dashboard.project.lbaasv2'));
describe('CertificatesController', function() {
var ctrl, scope;
beforeEach(inject(function($controller) {
scope = {
model: {
spec: {
certificates: []
},
certificates: certs
}
};
ctrl = $controller('CertificatesController', { $scope: scope });
}));
it('should define transfer table properties', function() {
expect(ctrl.tableData).toBeDefined();
expect(ctrl.tableLimits).toBeDefined();
expect(ctrl.tableHelp).toBeDefined();
});
it('should have available certificates', function() {
expect(ctrl.tableData.available).toBeDefined();
expect(ctrl.tableData.available.length).toBe(1);
expect(ctrl.tableData.available[0].id).toBe('1');
});
it('should not have allocated members', function() {
expect(ctrl.tableData.allocated).toEqual([]);
});
it('should allow adding multiple certificates', function() {
expect(ctrl.tableLimits.maxAllocation).toBe(-1);
});
});
});
})();

View File

@ -0,0 +1,3 @@
<h1 translate>SSL Certificates Help</h1>
<p translate>If the listener uses the TERMINATED_HTTPS protocol, then one or more SSL certificates must be selected. The first certificate will be the default. Use the key-manager service to create any certificate containers before creating the listener.</p>

View File

@ -0,0 +1,94 @@
<div ng-controller="CertificatesController as ctrl">
<h1 translate>SSL Certificates</h1>
<!--content-->
<div class="content">
<div translate class="subtitle">Select one or more SSL certificates for the listener.</div>
<transfer-table tr-model="ctrl.tableData"
limits="::ctrl.tableLimits"
help-text="::ctrl.tableHelp">
<!-- Allocated-->
<allocated validate-number-min="model.context.id ? 1 : 0" ng-model="ctrl.tableData.allocated.length">
<table st-table="ctrl.tableData.displayedAllocated"
st-safe-src="ctrl.tableData.allocated" hz-table
class="table-striped table-rsp table-detail modern form-group">
<thead>
<tr>
<th class="rsp-p1" translate>Certificate Name</th>
<th class="rsp-p1" translate>Expiration Date</th>
<th></th>
</tr>
</thead>
<tbody>
<tr ng-if="ctrl.tableData.allocated.length === 0">
<td colspan="100">
<div class="no-rows-help">
{$ ::trCtrl.helpText.noneAllocText $}
</div>
</td>
</tr>
<tr ng-repeat="row in ctrl.tableData.displayedAllocated track by row.id">
<td class="rsp-p1">{$ ::row.name $}</td>
<td class="rsp-p1">{$ ::row.expiration | date | noValue $}</td>
<td class="action-col">
<action-list>
<action action-classes="'btn btn-sm btn-default'"
callback="trCtrl.deallocate" item="row">
<span class="fa fa-minus"></span>
</action>
</action-list>
</td>
</tr>
</tbody>
</table>
</allocated>
<!-- Available -->
<available>
<table st-table="ctrl.tableData.displayedAvailable"
st-safe-src="ctrl.tableData.available"
hz-table class="table-striped table-rsp table-detail modern">
<thead>
<tr>
<th class="search-header" colspan="100">
<hz-search-bar group-classes="input-group-sm" icon-classes="fa-search">
</hz-search-bar>
</th>
</tr>
<tr>
<th st-sort="name" st-sort-default class="rsp-p1" translate>Certificate Name</th>
<th class="rsp-p1" translate>Expiration Date</th>
<th></th>
</tr>
</thead>
<tbody>
<tr ng-if="trCtrl.numAvailable() === 0">
<td colspan="100">
<div class="no-rows-help">
{$ ::trCtrl.helpText.noneAvailText $}
</div>
</td>
</tr>
<tr ng-repeat="row in ctrl.tableData.displayedAvailable track by row.id"
ng-if="!trCtrl.allocatedIds[row.id]">
<td class="rsp-p1">{$ ::row.name $}</td>
<td class="rsp-p1">{$ ::row.expiration | date | noValue $}</td>
<td class="action-col">
<action-list>
<action action-classes="'btn btn-sm btn-default'"
callback="trCtrl.allocate" item="row">
<span class="fa fa-plus"></span>
</action>
</action-list>
</td>
</tr>
</tbody>
</table>
</available>
</transfer-table> <!-- End Transfer Table -->
</div> <!-- end content -->
</div>

View File

@ -21,6 +21,7 @@
.controller('ListenerDetailsController', ListenerDetailsController);
ListenerDetailsController.$inject = [
'$scope',
'horizon.framework.util.i18n.gettext'
];
@ -34,11 +35,32 @@
* @returns undefined
*/
function ListenerDetailsController(gettext) {
function ListenerDetailsController($scope, gettext) {
var ctrl = this;
// Error text for invalid fields
ctrl.portError = gettext('The port must be a number between 1 and 65535.');
ctrl.certificatesError = gettext('There was an error obtaining certificates from the ' +
'key-manager service. The TERMINATED_HTTPS protocol is unavailable.');
ctrl.protocolChange = protocolChange;
//////////
// Called when the listener protocol is changed. Shows the SSL Certificates step if
// TERMINATED_HTTPS is selected.
function protocolChange() {
var protocol = $scope.model.spec.listener.protocol;
var workflow = $scope.workflow;
var certificates = workflow.steps.some(function checkCertificatesStep(step) {
return step.id === 'certificates';
});
if (protocol === 'TERMINATED_HTTPS' && !certificates) {
workflow.after('listener', workflow.certificatesStep);
} else if (protocol !== 'TERMINATED_HTTPS' && certificates) {
workflow.remove('certificates');
}
}
}
})();

View File

@ -22,14 +22,57 @@
beforeEach(module('horizon.dashboard.project.lbaasv2'));
describe('ListenerDetailsController', function() {
var ctrl;
var ctrl, workflow, listener;
beforeEach(inject(function($controller) {
ctrl = $controller('ListenerDetailsController');
workflow = {
steps: [{ id: 'listener' }],
certificatesStep: { id: 'certificates' },
after: angular.noop,
remove: angular.noop
};
listener = {
protocol: null
};
var scope = {
model: {
spec: {
listener: listener
}
},
workflow: workflow
};
ctrl = $controller('ListenerDetailsController', { $scope: scope });
}));
it('should define error messages for invalid fields', function() {
expect(ctrl.portError).toBeDefined();
expect(ctrl.certificatesError).toBeDefined();
});
it('should show certificates step if selecting TERMINATED_HTTPS', function() {
listener.protocol = 'TERMINATED_HTTPS';
workflow.steps.push(workflow.certificatesStep);
spyOn(workflow, 'after');
ctrl.protocolChange();
expect(workflow.after).not.toHaveBeenCalled();
workflow.steps.splice(1, 1);
ctrl.protocolChange();
expect(workflow.after).toHaveBeenCalledWith('listener', workflow.certificatesStep);
});
it('should hide certificates step if not selecting TERMINATED_HTTPS', function() {
listener.protocol = 'HTTP';
spyOn(workflow, 'remove');
ctrl.protocolChange();
expect(workflow.remove).not.toHaveBeenCalled();
workflow.steps.push(workflow.certificatesStep);
ctrl.protocolChange();
expect(workflow.remove).toHaveBeenCalledWith('certificates');
});
});

View File

@ -1,3 +1,4 @@
<h1 translate>Listener Details Help</h1>
<p translate>To create a listener, the port and protocol must be provided. If either of these properties are not provided, only the load balancer will be created.</p>
<p translate><strong>NOTE:</strong> The TERMINATED_HTTPS protocol is only available if the key-manager service is enabled and you have authority to list certificate containers and secrets.</p>

View File

@ -32,11 +32,16 @@
<div class="col-sm-6 col-md-3">
<div class="form-field required listener-protocol">
<label translate class="on-top" for="listener-protocol">Protocol</label>
<select class="form-control input-sm" name="listener-protocol"
id="listener-protocol"
ng-options="protocol for protocol in model.listenerProtocols"
<span class="fa fa-exclamation-triangle invalid"
ng-show="model.certificatesError"
popover="{$ ::ctrl.certificatesError $}"
popover-placement="top" popover-append-to-body="true"
popover-trigger="hover"></span>
<select class="form-control input-sm" name="listener-protocol" id="listener-protocol"
ng-model="model.spec.listener.protocol" ng-required="model.context.resource === 'listener'"
ng-disabled="model.context.id">
ng-change="ctrl.protocolChange()" ng-disabled="model.context.id">
<option ng-repeat="protocol in model.listenerProtocols" value="{$ protocol $}"
ng-disabled="protocol==='TERMINATED_HTTPS' && model.certificatesError">{$ protocol $}</option>
</select>
</div>
</div>

View File

@ -27,6 +27,8 @@
'horizon.app.core.openstack-service-api.neutron',
'horizon.app.core.openstack-service-api.nova',
'horizon.app.core.openstack-service-api.lbaasv2',
'horizon.app.core.openstack-service-api.barbican',
'horizon.app.core.openstack-service-api.serviceCatalog',
'horizon.framework.util.i18n.gettext'
];
@ -43,12 +45,22 @@
* @param neutronAPI The neutron service API.
* @param novaAPI The nova service API.
* @param lbaasv2API The LBaaS V2 service API.
* @param barbicanAPI The barbican service API.
* @param serviceCatalog The keystone service catalog API.
* @param gettext The horizon gettext function for translation.
* @returns The model service for the create load balancer workflow.
*/
function workflowModel($q, neutronAPI, novaAPI, lbaasv2API, gettext) {
var ports;
function workflowModel(
$q,
neutronAPI,
novaAPI,
lbaasv2API,
barbicanAPI,
serviceCatalog,
gettext
) {
var ports, keymanagerPromise;
/**
* @ngdoc model api object
@ -73,11 +85,12 @@
visibleResources: [],
subnets: [],
members: [],
listenerProtocols: ['TCP', 'HTTP', 'HTTPS'],
listenerProtocols: ['TCP', 'HTTP', 'HTTPS', 'TERMINATED_HTTPS'],
poolProtocols: ['TCP', 'HTTP', 'HTTPS'],
methods: ['ROUND_ROBIN', 'LEAST_CONNECTIONS', 'SOURCE_IP'],
monitorTypes: ['HTTP', 'HTTPS', 'PING', 'TCP'],
monitorMethods: ['GET', 'HEAD'],
certificates: [],
/**
* api methods for UI controllers
@ -89,6 +102,8 @@
return model;
///////////////
/**
* @ngdoc method
* @name workflowModel.initialize
@ -105,6 +120,7 @@
function initialize(resource, id) {
var promise;
model.certificatesError = false;
model.context = {
resource: resource,
id: id,
@ -112,6 +128,7 @@
};
model.visibleResources = [];
model.certificates = [];
model.spec = {
loadbalancer_id: null,
@ -145,7 +162,8 @@
status: '200',
path: '/'
},
members: []
members: [],
certificates: []
};
if (model.initializing) {
@ -153,13 +171,21 @@
}
model.initializing = true;
var type = (id ? 'edit' : 'create') + resource;
keymanagerPromise = serviceCatalog.ifTypeEnabled('key-manager');
if (type === 'createloadbalancer' || resource === 'listener') {
keymanagerPromise.then(angular.noop, certificatesNotSupported);
}
switch ((id ? 'edit' : 'create') + resource) {
case 'createloadbalancer':
promise = $q.all([
lbaasv2API.getLoadBalancers().then(onGetLoadBalancers),
neutronAPI.getSubnets().then(onGetSubnets),
neutronAPI.getPorts().then(onGetPorts),
novaAPI.getServers().then(onGetServers)
novaAPI.getServers().then(onGetServers),
keymanagerPromise.then(prepareCertificates)
]).then(initMemberAddresses);
model.context.submit = createLoadBalancer;
break;
@ -247,10 +273,22 @@
}
function cleanFinalSpecListener(finalSpec) {
// Listener requires protocol and port
if (!finalSpec.listener.protocol || !finalSpec.listener.port) {
// Listener requires protocol and port
delete finalSpec.listener;
} else if (finalSpec.listener.protocol === 'TERMINATED_HTTPS' &&
finalSpec.certificates.length === 0) {
// TERMINATED_HTTPS requires certificates
delete finalSpec.listener;
} else if (finalSpec.listener.protocol !== 'TERMINATED_HTTPS') {
// Remove certificate containers if not using TERMINATED_HTTPS
delete finalSpec.certificates;
} else {
var containers = [];
angular.forEach(finalSpec.certificates, function(cert) {
containers.push(cert.id);
});
finalSpec.certificates = containers;
}
}
@ -405,6 +443,19 @@
model.visibleResources.push('listener');
model.spec.loadbalancer_id = resources.listener.loadbalancers[0].id;
if (resources.listener.protocol === 'TERMINATED_HTTPS') {
keymanagerPromise.then(prepareCertificates).then(function addAvailableCertificates() {
resources.listener.sni_container_refs.forEach(function addAvailableCertificate(ref) {
model.certificates.filter(function matchCertificate(cert) {
return cert.id === ref;
}).forEach(function addCertificate(cert) {
model.spec.certificates.push(cert);
});
});
});
model.visibleResources.push('certificates');
}
if (resources.pool) {
setPoolSpec(resources.pool);
model.visibleResources.push('pool');
@ -476,6 +527,45 @@
spec.path = monitor.url_path;
}
function prepareCertificates() {
return $q.all([
barbicanAPI.getCertificates(true),
barbicanAPI.getSecrets(true)
]).then(onGetCertificates, certificatesError);
}
function onGetCertificates(results) {
// To use the TERMINATED_HTTPS protocol with a listener, the LBaaS v2 API wants a default
// container ref and a list of containers that hold TLS secrets. In barbican the container
// object has a list of references to the secrets it holds. This assumes that each
// certificate container has exactly one certificate and private key. We fetch both the
// certificate containers and the secrets so that we can display the secret names and
// expirations dates.
model.certificates.length = 0;
var certificates = [];
// Only accept containers that have both a certificate and private_key ref
var containers = results[0].data.items.filter(function validateContainer(container) {
container.refs = {};
container.secret_refs.forEach(function(ref) {
container.refs[ref.name] = ref.secret_ref;
});
return 'certificate' in container.refs && 'private_key' in container.refs;
});
var secrets = {};
results[1].data.items.forEach(function addSecret(secret) {
secrets[secret.secret_ref] = secret;
});
containers.forEach(function addCertificateContainer(container) {
var secret = secrets[container.refs.certificate];
certificates.push({
id: container.container_ref,
name: secret.name || secret.secret_ref.split('/').reverse()[0],
expiration: secret.expiration
});
});
push.apply(model.certificates, certificates);
}
function initSubnet() {
var subnet = model.subnets.filter(function filterSubnetsByLoadBalancer(s) {
return s.id === model.spec.loadbalancer.subnet;
@ -490,6 +580,22 @@
return subnet[0];
}
function certificatesNotSupported() {
// This function is called if the key-manager service is not available. In that case we
// cannot support the TERMINATED_HTTPS listener protocol so we hide the option if creating
// a new load balancer or listener. However for editing we still need it.
if (!model.context.id) {
model.listenerProtocols.splice(3, 1);
}
}
function certificatesError() {
// This function is called if there is an error fetching the certificate containers or
// secrets. In that case we cannot support the TERMINATED_HTTPS listener protocol but we
// want to make the user aware of the error.
model.certificatesError = true;
}
}
})();

View File

@ -17,7 +17,7 @@
'use strict';
describe('LBaaS v2 Workflow Model Service', function() {
var model, $q, scope, listenerResources;
var model, $q, scope, listenerResources, barbicanEnabled, certificatesError;
beforeEach(module('horizon.framework.util.i18n'));
beforeEach(module('horizon.dashboard.project.lbaasv2'));
@ -30,7 +30,8 @@
description: 'listener description',
protocol: 'HTTP',
protocol_port: 80,
loadbalancers: [ { id: '1234' } ]
loadbalancers: [ { id: '1234' } ],
sni_container_refs: ['container2']
},
pool: {
id: '1234',
@ -66,6 +67,8 @@
url_path: '/test'
}
};
barbicanEnabled = true;
certificatesError = false;
});
beforeEach(module(function($provide) {
@ -164,6 +167,56 @@
}
});
$provide.value('horizon.app.core.openstack-service-api.barbican', {
getCertificates: function() {
var containers = [
{
container_ref: 'container1',
secret_refs: [{name: 'certificate', secret_ref: 'secret1'}]
}, {
container_ref: 'container2',
secret_refs: [{name: 'certificate', secret_ref: 'certificate1'},
{name: 'private_key', secret_ref: 'privatekey1'}]
},
{
container_ref: 'container3',
secret_refs: [{name: 'certificate', secret_ref: 'certificate2'},
{name: 'private_key', secret_ref: 'privatekey2'}]
}
];
var deferred = $q.defer();
if (certificatesError) {
deferred.reject();
} else {
deferred.resolve({ data: { items: containers } });
}
return deferred.promise;
},
getSecrets: function() {
var secrets = [
{
name: 'foo',
expiration: '2016-03-26T21:10:45.417835',
secret_ref: 'certificate1'
},{
expiration: '2016-03-28T21:10:45.417835',
secret_ref: 'certificate2'
},{
secret_ref: 'privatekey1'
},{
secret_ref: 'privatekey2'
}
];
var deferred = $q.defer();
deferred.resolve({ data: { items: secrets } });
return deferred.promise;
}
});
$provide.value('horizon.app.core.openstack-service-api.neutron', {
getSubnets: function() {
var subnets = [ { id: 'subnet-1', name: 'subnet-1' },
@ -200,6 +253,14 @@
return deferred.promise;
}
});
$provide.value('horizon.app.core.openstack-service-api.serviceCatalog', {
ifTypeEnabled: function() {
var deferred = $q.defer();
deferred[barbicanEnabled ? 'resolve' : 'reject']();
return deferred.promise;
}
});
}));
beforeEach(inject(function ($injector) {
@ -233,12 +294,16 @@
expect(model.members).toEqual([]);
});
it('has empty certificates array', function() {
expect(model.certificates).toEqual([]);
});
it('has array of pool protocols', function() {
expect(model.poolProtocols).toEqual(['TCP', 'HTTP', 'HTTPS']);
});
it('has array of listener protocols', function() {
expect(model.listenerProtocols).toEqual(['TCP', 'HTTP', 'HTTPS']);
expect(model.listenerProtocols).toEqual(['TCP', 'HTTP', 'HTTPS', 'TERMINATED_HTTPS']);
});
it('has array of pool methods', function() {
@ -278,13 +343,16 @@
expect(model.initialized).toBe(true);
expect(model.subnets.length).toBe(2);
expect(model.members.length).toBe(2);
expect(model.certificates.length).toBe(2);
expect(model.spec).toBeDefined();
expect(model.spec.loadbalancer_id).toBeNull();
expect(model.spec.loadbalancer).toBeDefined();
expect(model.spec.listener).toBeDefined();
expect(model.spec.pool).toBeDefined();
expect(model.spec.members).toEqual([]);
expect(model.spec.certificates).toEqual([]);
expect(model.spec.monitor).toBeDefined();
expect(model.certificatesError).toBe(false);
});
it('should initialize names', function() {
@ -363,6 +431,39 @@
expect(model.context.id).toBe('1234');
expect(model.context.submit).toBeDefined();
});
it('should initialize listener protocols', function() {
expect(model.listenerProtocols.length).toBe(4);
expect(model.listenerProtocols.indexOf('TERMINATED_HTTPS')).toBe(3);
});
});
describe('Post initialize model (without barbican)', function() {
beforeEach(function() {
barbicanEnabled = false;
model.initialize('loadbalancer');
scope.$apply();
});
it('should initialize listener protocols', function() {
expect(model.listenerProtocols.length).toBe(3);
expect(model.listenerProtocols.indexOf('TERMINATED_HTTPS')).toBe(-1);
});
});
describe('Post initialize model (certificates error)', function() {
beforeEach(function() {
certificatesError = true;
model.initialize('loadbalancer');
scope.$apply();
});
it('should initialize listener protocols', function() {
expect(model.certificates).toEqual([]);
expect(model.certificatesError).toBe(true);
});
});
describe('Post initialize model (edit listener)', function() {
@ -444,6 +545,37 @@
});
});
describe('Post initialize model (edit listener TERMINATED_HTTPS)', function() {
beforeEach(function() {
listenerResources.listener.protocol = 'TERMINATED_HTTPS';
model.initialize('listener', '1234');
scope.$apply();
});
it('should initialize certificates', function() {
expect(model.certificates.length).toBe(2);
expect(model.spec.certificates.length).toBe(1);
expect(model.spec.certificates[0].id).toBe('container2');
});
});
describe('Post initialize model (edit listener TERMINATED_HTTPS no barbican)', function() {
beforeEach(function() {
listenerResources.listener.protocol = 'TERMINATED_HTTPS';
barbicanEnabled = false;
model.initialize('listener', '1234');
scope.$apply();
});
it('should initialize certificates', function() {
expect(model.certificates.length).toBe(0);
expect(model.spec.certificates.length).toBe(0);
expect(model.spec.listener.protocol).toBe('TERMINATED_HTTPS');
});
});
describe('Post initialize model - Initializing', function() {
beforeEach(function() {
@ -455,7 +587,7 @@
// This is here to ensure that as people add/change spec properties, they don't forget
// to implement tests for them.
it('has the right number of properties', function() {
expect(Object.keys(model.spec).length).toBe(6);
expect(Object.keys(model.spec).length).toBe(7);
expect(Object.keys(model.spec.loadbalancer).length).toBe(4);
expect(Object.keys(model.spec.listener).length).toBe(5);
expect(Object.keys(model.spec.pool).length).toBe(5);
@ -688,6 +820,11 @@
model.spec.monitor.interval = 1;
model.spec.monitor.retry = 1;
model.spec.monitor.timeout = 1;
model.spec.certificates = [{
id: 'container1',
name: 'foo',
expiration: '2015-03-26T21:10:45.417835'
}];
var finalSpec = model.submit();
@ -732,6 +869,31 @@
expect(finalSpec.monitor.interval).toBe(1);
expect(finalSpec.monitor.retry).toBe(1);
expect(finalSpec.monitor.timeout).toBe(1);
expect(finalSpec.certificates).toBeUndefined();
});
it('should set final spec certificates', function() {
model.spec.loadbalancer.ip = '1.2.3.4';
model.spec.loadbalancer.subnet = model.subnets[0];
model.spec.listener.protocol = 'TERMINATED_HTTPS';
model.spec.listener.port = 443;
model.spec.certificates = [{
id: 'container1',
name: 'foo',
expiration: '2015-03-26T21:10:45.417835'
}];
var finalSpec = model.submit();
expect(finalSpec.loadbalancer.name).toBe('Load Balancer 3');
expect(finalSpec.loadbalancer.description).toBeUndefined();
expect(finalSpec.loadbalancer.ip).toBe('1.2.3.4');
expect(finalSpec.loadbalancer.subnet).toBe(model.subnets[0].id);
expect(finalSpec.listener.name).toBe('Listener 1');
expect(finalSpec.listener.description).toBeUndefined();
expect(finalSpec.listener.protocol).toBe('TERMINATED_HTTPS');
expect(finalSpec.listener.port).toBe(443);
expect(finalSpec.certificates).toEqual(['container1']);
});
it('should delete load balancer if any required property is not set', function() {
@ -754,6 +916,33 @@
expect(finalSpec.pool).toBeUndefined();
});
it('should delete listener if using TERMINATED_HTTPS but no certificates', function() {
model.spec.loadbalancer.ip = '1.2.3.4';
model.spec.loadbalancer.subnet = model.subnets[0];
model.spec.listener.protocol = 'TERMINATED_HTTPS';
model.spec.listener.port = 443;
model.spec.certificates = [];
var finalSpec = model.submit();
expect(finalSpec.loadbalancer).toBeDefined();
expect(finalSpec.listener).toBeUndefined();
});
it('should delete certificates if not using TERMINATED_HTTPS', function() {
model.spec.loadbalancer.ip = '1.2.3.4';
model.spec.loadbalancer.subnet = model.subnets[0];
model.spec.listener.protocol = 'HTTP';
model.spec.listener.port = 80;
model.spec.certificates = [{id: '1'}];
var finalSpec = model.submit();
expect(finalSpec.loadbalancer).toBeDefined();
expect(finalSpec.listener).toBeDefined();
expect(finalSpec.certificates).toBeUndefined();
});
it('should delete pool if any required property is not set', function() {
model.spec.loadbalancer.ip = '1.2.3.4';
model.spec.loadbalancer.subnet = model.subnets[0];

View File

@ -65,6 +65,16 @@
}
];
// This step is kept separate from the rest because it is only added to the workflow by
// the Listener Details step if the TERMINATED_HTTPS protocol is selected.
var certificatesStep = {
id: 'certificates',
title: gettext('SSL Certificates'),
templateUrl: basePath + 'workflow/certificates/certificates.html',
helpUrl: basePath + 'workflow/certificates/certificates.help.html',
formName: 'certificateDetailsForm'
};
return initWorkflow;
function initWorkflow(title, icon, steps, promise) {
@ -92,7 +102,8 @@
finish: icon
},
steps: filteredSteps,
allSteps: workflowSteps
allSteps: workflowSteps,
certificatesStep: certificatesStep
});
}
}

View File

@ -58,6 +58,12 @@
});
});
it('should have a step for ssl certificates', function () {
var workflow = workflowService('My Workflow');
expect(workflow.certificatesStep).toBeDefined();
expect(workflow.certificatesStep.title).toBe('SSL Certificates');
});
it('can filter steps', function () {
var workflow = workflowService('My Workflow', 'foo', ['listener', 'pool']);
expect(workflow.steps).toBeDefined();

View File

@ -4,3 +4,4 @@
pbr>=1.6 # Apache-2.0
Babel>=1.3 # BSD
python-barbicanclient>=3.3.0 # Apache-2.0