Commit c3ec347a authored by Justin Pomeroy's avatar Justin Pomeroy

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
parent 9a88246f
......@@ -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
# 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')}
......@@ -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')
......
/*
* 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.'));
});
}
}
}());
/*
* 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");
});
});
})();
......@@ -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 {
......
......@@ -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];
}
......
......@@ -31,6 +31,7 @@
var workflow = {
steps: [{id: 'listener'}],
allSteps: [{id: 'listener'}, {id: 'pool'}, {id: 'monitor'}],
certificatesStep: {id: 'certificates'},
append: angular.noop
};
......
/*
* 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')
};
}
})();
/*
* 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);
});
});
});
})();
<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>
<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>
......@@ -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');
}
}
}
})();
......@@ -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');
});
});
......
<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>
\ No newline at end of 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>
......
......@@ -27,6 +27,8 @@
'horizon.app.core.openstack-service-api.neutron',