Add delete load balancer actions

This adds the single and multiple load balancer delete actions. A
load balancer must be in ACTIVE or ERROR state in order for the
delete action to be available. If a load balancer has a listener the
delete request will currently fail, but future work should allow for
the listener and any related resources to also be deleted.

Partially-Implements: blueprint horizon-lbaas-v2-ui
Change-Id: I3902587089963318c49a969f343e5043d8bdaf50
This commit is contained in:
Justin Pomeroy 2016-02-11 20:40:26 -06:00
parent 087d73cecc
commit 8c7979fa3e
16 changed files with 402 additions and 12 deletions

View File

@ -245,6 +245,14 @@ class LoadBalancer(generic.View):
return neutronclient(request).update_loadbalancer(
loadbalancer_id, {'loadbalancer': spec}).get('loadbalancer')
@rest_utils.ajax()
def delete(self, request, loadbalancer_id):
"""Delete a specific load balancer.
http://localhost/api/lbaas/loadbalancers/cc758c90-3d98-4ea1-af44-aab405c9c915
"""
neutronclient(request).delete_loadbalancer(loadbalancer_id)
@urls.register
class Listeners(generic.View):

View File

@ -38,6 +38,7 @@
var service = {
getLoadBalancers: getLoadBalancers,
getLoadBalancer: getLoadBalancer,
deleteLoadBalancer: deleteLoadBalancer,
createLoadBalancer: createLoadBalancer,
editLoadBalancer: editLoadBalancer,
getListeners: getListeners,
@ -85,6 +86,22 @@
});
}
/**
* @name horizon.app.core.openstack-service-api.lbaasv2.deleteLoadBalancer
* @description
* Delete a single load balancer by ID
* @param {string} id
* @param {boolean} quiet
* Specifies the id of the load balancer to delete.
*/
function deleteLoadBalancer(id, quiet) {
var promise = apiService.delete('/api/lbaas/loadbalancers/' + id);
return quiet ? promise : promise.error(function () {
toastService.add('error', gettext('Unable to delete load balancer.'));
});
}
/**
* @name horizon.app.core.openstack-service-api.lbaasv2.createLoadBalancer
* @description

View File

@ -51,6 +51,15 @@
'1234'
]
},
{
"func": "deleteLoadBalancer",
"method": "delete",
"path": "/api/lbaas/loadbalancers/1234",
"error": "Unable to delete load balancer.",
"testInput": [
'1234'
]
},
{
"func": "getListeners",
"method": "get",
@ -149,6 +158,11 @@
});
});
it('supresses the error if instructed for deleteLoadBalancer', function() {
spyOn(apiService, 'delete').and.returnValue("promise");
expect(service.deleteLoadBalancer("whatever", true)).toBe("promise");
});
});
})();

View File

@ -95,3 +95,12 @@
}
}
}
/*
TODO(jpomero): The float property is being set to "none" in _debt.scss apparently
to work around a known bootstrap bug, but this appears to break the actions menu
only in angular tables. Setting back to the original value ("left") here for now.
*/
td .btn-group.ng-scope > .btn {
float: left;
}

View File

@ -30,7 +30,7 @@
///////////////////////
beforeEach(module('horizon.framework.widgets.toast'));
beforeEach(module('horizon.framework.widgets'));
beforeEach(module('horizon.framework.conf'));
beforeEach(module('horizon.framework.util'));
beforeEach(module('horizon.app.core.openstack-service-api'));

View File

@ -59,7 +59,7 @@
<td class="select-col">
<input type="checkbox"
ng-model="selected[item.id].checked"
ng-model="tCtrl.selections[item.id].checked"
hz-select="item">
</td>
<td class="expander">

View File

@ -25,6 +25,7 @@
'$location',
'horizon.dashboard.project.lbaasv2.workflow.modal',
'horizon.dashboard.project.lbaasv2.basePath',
'horizon.dashboard.project.lbaasv2.loadbalancers.actions.delete',
'horizon.app.core.openstack-service-api.policy',
'horizon.framework.util.i18n.gettext'
];
@ -39,12 +40,13 @@
* @param $location The angular $location service.
* @param workflowModal The LBaaS workflow modal service.
* @param basePath The lbaasv2 module base path.
* @param deleteService The load balancer delete service.
* @param policy The horizon policy service.
* @param gettext The horizon gettext function for translation.
* @returns Load balancers table batch actions service object.
*/
function tableBatchActions($location, workflowModal, basePath, policy, gettext) {
function tableBatchActions($location, workflowModal, basePath, deleteService, policy, gettext) {
var create = workflowModal.init({
controller: 'CreateLoadBalancerWizardController',
@ -68,6 +70,12 @@
type: 'create',
text: gettext('Create Load Balancer')
}
}, {
service: deleteService,
template: {
type: 'delete-selected',
text: gettext('Delete Load Balancers')
}
}];
}

View File

@ -21,7 +21,7 @@
beforeEach(module('horizon.framework.util'));
beforeEach(module('horizon.framework.conf'));
beforeEach(module('horizon.framework.widgets.toast'));
beforeEach(module('horizon.framework.widgets'));
beforeEach(module('horizon.app.core.openstack-service-api'));
beforeEach(module('horizon.dashboard.project.lbaasv2'));
@ -54,8 +54,9 @@
}));
it('should define correct table batch actions', function() {
expect(actions.length).toBe(1);
expect(actions.length).toBe(2);
expect(actions[0].template.text).toBe('Create Load Balancer');
expect(actions[1].template.text).toBe('Delete Load Balancers');
});
it('should have the "allowed" and "perform" functions', function() {

View File

@ -0,0 +1,147 @@
/*
* 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.loadbalancers')
.factory('horizon.dashboard.project.lbaasv2.loadbalancers.actions.delete', deleteService);
deleteService.$inject = [
'$q',
'$location',
'$route',
'horizon.framework.widgets.modal.deleteModalService',
'horizon.app.core.openstack-service-api.lbaasv2',
'horizon.app.core.openstack-service-api.policy',
'horizon.framework.widgets.toast.service',
'horizon.framework.util.q.extensions',
'horizon.framework.util.i18n.gettext'
];
/**
* @ngDoc factory
* @name horizon.dashboard.project.lbaasv2.loadbalancers.actions.deleteService
* @description
* Brings up the delete load balancers confirmation modal dialog.
* On submit, deletes selected load balancers.
* On cancel, does nothing.
* @param $q The angular service for promises.
* @param $location The angular $location service.
* @param $route The angular $route service.
* @param deleteModal The horizon delete modal service.
* @param api The LBaaS v2 API service.
* @param policy The horizon policy service.
* @param toast The horizon message service.
* @param qExtensions Horizon extensions to the $q service.
* @param gettext The horizon gettext function for translation.
* @returns The load balancers table delete service.
*/
function deleteService(
$q, $location, $route, deleteModal, api, policy, toast, qExtensions, gettext
) {
// If a batch delete, then this message is displayed for any selected load balancers not in
// ACTIVE or ERROR state.
var notAllowedMessage = gettext('The following load balancers are pending and cannot be ' +
'deleted: %s.');
var context = {
labels: {
title: gettext('Confirm Delete Load Balancers'),
message: gettext('You have selected "%s". Please confirm your selection. Deleted load ' +
'balancers are not recoverable.'),
submit: gettext('Delete Load Balancers'),
success: gettext('Deleted load balancers: %s.'),
error: gettext('The following load balancers could not be deleted, possibly due to ' +
'existing listeners: %s.')
},
deleteEntity: deleteItem
};
var service = {
perform: perform,
allowed: allowed
};
return service;
//////////////
function perform(items) {
if (angular.isArray(items)) {
qExtensions.allSettled(items.map(checkPermission)).then(afterCheck);
} else {
deleteModal.open({ $emit: actionComplete }, [items], context);
}
}
function allowed(item) {
// This rule is made up and should therefore always pass. I assume at some point there
// will be a valid rule similar to this that we will want to use.
var promises = [policy.ifAllowed({ rules: [['neutron', 'delete_loadbalancer']] })];
if (item) {
var status = item.provisioning_status;
promises.push(qExtensions.booleanAsPromise(status === 'ACTIVE' || status === 'ERROR'));
}
return $q.all(promises);
}
function canBeDeleted(item) {
var status = item.provisioning_status;
return qExtensions.booleanAsPromise(status === 'ACTIVE' || status === 'ERROR');
}
function checkPermission(item) {
return { promise: canBeDeleted(item), context: item };
}
function afterCheck(result) {
if (result.fail.length > 0) {
toast.add('error', getMessage(notAllowedMessage, result.fail));
}
if (result.pass.length > 0) {
deleteModal.open({ $emit: actionComplete }, result.pass.map(getEntity), context);
}
}
function deleteItem(id) {
return api.deleteLoadBalancer(id, true);
}
function getMessage(message, entities) {
return interpolate(message, [entities.map(getName).join(", ")]);
}
function getName(result) {
return getEntity(result).name;
}
function getEntity(result) {
return result.context;
}
function actionComplete() {
// If the user is on the load balancers table then just reload the page, otherwise they
// are on the details page and we return to the table.
if (/\/ngloadbalancersv2(\/)?$/.test($location.path())) {
$route.reload();
} else {
$location.path('project/ngloadbalancersv2');
}
}
}
})();

View File

@ -0,0 +1,177 @@
/*
* 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('LBaaS v2 Load Balancers Table Row Delete Service', function() {
var service, policy, modal, lbaasv2Api, $scope, $route, $location, $q, toast, items, path;
function allowed(item) {
spyOn(policy, 'ifAllowed').and.returnValue(true);
var promise = service.allowed(item);
var allowed;
promise.then(function() {
allowed = true;
}, function() {
allowed = false;
});
$scope.$apply();
expect(policy.ifAllowed).toHaveBeenCalledWith({rules: [['neutron', 'delete_loadbalancer']]});
return allowed;
}
function makePromise(reject) {
var def = $q.defer();
def[reject ? 'reject' : 'resolve']();
return def.promise;
}
beforeEach(module('horizon.framework.util'));
beforeEach(module('horizon.framework.conf'));
beforeEach(module('horizon.framework.widgets'));
beforeEach(module('horizon.app.core.openstack-service-api'));
beforeEach(module('horizon.dashboard.project.lbaasv2'));
beforeEach(function() {
items = [{ id: '1', name: 'First', provisioning_status: 'ACTIVE' },
{ id: '2', name: 'Second', provisioning_status: 'ACTIVE' }];
});
beforeEach(module(function($provide) {
$provide.value('$modal', {
open: function() {
return {
result: makePromise()
};
}
});
$provide.value('horizon.app.core.openstack-service-api.lbaasv2', {
deleteLoadBalancer: function() {
return makePromise();
}
});
$provide.value('$location', {
path: function() {
return path;
}
});
}));
beforeEach(inject(function ($injector) {
policy = $injector.get('horizon.app.core.openstack-service-api.policy');
lbaasv2Api = $injector.get('horizon.app.core.openstack-service-api.lbaasv2');
modal = $injector.get('horizon.framework.widgets.modal.deleteModalService');
$scope = $injector.get('$rootScope').$new();
$route = $injector.get('$route');
$location = $injector.get('$location');
$q = $injector.get('$q');
toast = $injector.get('horizon.framework.widgets.toast.service');
service = $injector.get('horizon.dashboard.project.lbaasv2.loadbalancers.actions.delete');
}));
it('should have the "allowed" and "perform" functions', function() {
expect(service.allowed).toBeDefined();
expect(service.perform).toBeDefined();
});
it('should check policy to allow deleting a load balancer (single)', function() {
expect(allowed(items[0])).toBe(true);
});
it('should check policy to allow deleting a load balancer (batch)', function() {
expect(allowed()).toBe(true);
});
it('should not allow deleting load balancers if state check fails (single)', function() {
items[0].provisioning_status = 'PENDING_UPDATE';
expect(allowed(items[0])).toBe(false);
});
it('should allow batch delete even if state check fails (batch)', function() {
items[0].provisioning_status = 'PENDING_UPDATE';
expect(allowed()).toBe(true);
});
it('should open the delete modal', function() {
spyOn(modal, 'open');
service.perform(items[0]);
$scope.$apply();
expect(modal.open.calls.count()).toBe(1);
var args = modal.open.calls.argsFor(0);
expect(args.length).toBe(3);
expect(args[0]).toEqual({ $emit: jasmine.any(Function) });
expect(args[1]).toEqual([jasmine.objectContaining({ id: '1' })]);
expect(args[2]).toEqual(jasmine.objectContaining({
labels: jasmine.any(Object),
deleteEntity: jasmine.any(Function)
}));
expect(args[2].labels.title).toBe('Confirm Delete Load Balancers');
});
it('should pass function to modal that deletes load balancers', function() {
spyOn(modal, 'open').and.callThrough();
spyOn(lbaasv2Api, 'deleteLoadBalancer').and.callThrough();
service.perform(items[0]);
$scope.$apply();
expect(lbaasv2Api.deleteLoadBalancer.calls.count()).toBe(1);
expect(lbaasv2Api.deleteLoadBalancer).toHaveBeenCalledWith('1', true);
});
it('should show message if any selected items do not allow for delete (batch)', function() {
spyOn(modal, 'open');
spyOn(toast, 'add');
items[0].provisioning_status = 'PENDING_UPDATE';
items[1].provisioning_status = 'PENDING_DELETE';
service.perform(items);
$scope.$apply();
expect(modal.open).not.toHaveBeenCalled();
expect(toast.add).toHaveBeenCalledWith('error',
'The following load balancers are pending and cannot be deleted: First, Second.');
});
it('should show message if any items fail to be deleted', function() {
spyOn(modal, 'open').and.callThrough();
spyOn(lbaasv2Api, 'deleteLoadBalancer').and.returnValue(makePromise(true));
spyOn(toast, 'add');
items.splice(1, 1);
service.perform(items);
$scope.$apply();
expect(modal.open).toHaveBeenCalled();
expect(lbaasv2Api.deleteLoadBalancer.calls.count()).toBe(1);
expect(toast.add).toHaveBeenCalledWith('error', 'The following load balancers could not ' +
'be deleted, possibly due to existing listeners: First.');
});
it('should reload table after delete', function() {
path = 'project/ngloadbalancersv2';
spyOn($route, 'reload');
service.perform(items);
$scope.$apply();
expect($route.reload).toHaveBeenCalled();
});
it('should return to table after delete if on detail page', function() {
path = 'project/ngloadbalancersv2/1';
spyOn($location, 'path');
spyOn(toast, 'add');
service.perform(items[0]);
$scope.$apply();
expect($location.path).toHaveBeenCalledWith('project/ngloadbalancersv2');
expect(toast.add).toHaveBeenCalledWith('success', 'Deleted load balancers: First.');
});
});
})();

View File

@ -25,6 +25,7 @@
'$q',
'$route',
'horizon.dashboard.project.lbaasv2.workflow.modal',
'horizon.dashboard.project.lbaasv2.loadbalancers.actions.delete',
'horizon.app.core.openstack-service-api.policy',
'horizon.framework.util.q.extensions',
'horizon.framework.util.i18n.gettext'
@ -40,13 +41,14 @@
* @param $q The angular service for promises.
* @param $route The angular $route service.
* @param workflowModal The LBaaS workflow modal service.
* @param deleteService The load balancer delete service.
* @param policy The horizon policy service.
* @param qExtensions Horizon extensions to the $q service.
* @param gettext The horizon gettext function for translation.
* @returns Load balancers table batch actions service object.
*/
function tableRowActions($q, $route, workflowModal, policy, qExtensions, gettext) {
function tableRowActions($q, $route, workflowModal, deleteService, policy, qExtensions, gettext) {
var edit = workflowModal.init({
controller: 'EditLoadBalancerWizardController',
@ -69,6 +71,12 @@
template: {
text: gettext('Edit')
}
}, {
service: deleteService,
template: {
text: gettext('Delete Load Balancer'),
type: 'delete'
}
}];
}

View File

@ -35,7 +35,7 @@
beforeEach(module('horizon.framework.util'));
beforeEach(module('horizon.framework.conf'));
beforeEach(module('horizon.framework.widgets.toast'));
beforeEach(module('horizon.framework.widgets'));
beforeEach(module('horizon.app.core.openstack-service-api'));
beforeEach(module('horizon.dashboard.project.lbaasv2'));
@ -69,8 +69,9 @@
}));
it('should define correct table row actions', function() {
expect(actions.length).toBe(1);
expect(actions.length).toBe(2);
expect(actions[0].template.text).toBe('Edit');
expect(actions[1].template.text).toBe('Delete Load Balancer');
});
it('should allow editing an ACTIVE load balancer', function() {

View File

@ -29,7 +29,7 @@
///////////////////////
beforeEach(module('horizon.framework.widgets.toast'));
beforeEach(module('horizon.framework.widgets'));
beforeEach(module('horizon.framework.conf'));
beforeEach(module('horizon.framework.util'));
beforeEach(module('horizon.app.core.openstack-service-api'));

View File

@ -30,7 +30,7 @@
///////////////////////
beforeEach(module('horizon.framework.widgets.toast'));
beforeEach(module('horizon.framework.widgets'));
beforeEach(module('horizon.framework.conf'));
beforeEach(module('horizon.framework.util'));
beforeEach(module('horizon.app.core.openstack-service-api'));

View File

@ -62,7 +62,7 @@
<td class="select-col">
<input type="checkbox"
ng-model="selected[item.id].checked"
ng-model="tCtrl.selections[item.id].checked"
hz-select="item">
</td>
<td class="expander">

View File

@ -57,7 +57,7 @@
<td class="select-col">
<input type="checkbox"
ng-model="selected[item.id].checked"
ng-model="tCtrl.selections[item.id].checked"
hz-select="item">
</td>
<td class="rsp-p1"><a ng-href="project/ngloadbalancersv2/{$ ::table.loadbalancerId $}/listeners/{$ ::table.listenerId $}/pools/{$ ::table.poolId $}/members/{$ ::item.id $}">{$ ::item.id $}</a></td>