Add ui for resizing clusters

Create new row action on clusters panel
Create new modal form for resizing cluster:wq
Create REST endpoint for resizing cluster
Bump python-magnumclient lower constraint
Add heatclient lower constraint

Change-Id: I591d4e6ebe85adac0bcefb3f95b1a7d2abf0ba88
(cherry picked from commit 0bfd85d4c1)
This commit is contained in:
Simon Merrick 2019-12-02 17:38:55 +13:00 committed by Bharat Kunwar
parent 5763f3b40a
commit 7bc8797bde
12 changed files with 518 additions and 4 deletions

View File

@ -79,8 +79,9 @@ pyScss==1.3.4
python-cinderclient==3.5.0
python-dateutil==2.7.0
python-glanceclient==2.9.1
python-heatclient==1.18.0
python-keystoneclient==3.15.0
python-magnumclient==2.11.0
python-magnumclient==2.15.0 # Apache-2.0
python-mimeparse==1.6.0
python-neutronclient==6.7.0
python-novaclient==10.1.0

62
magnum_ui/api/heat.py Normal file
View File

@ -0,0 +1,62 @@
# Copyright 2015 Cisco Systems.
#
# 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.
from __future__ import absolute_import
import logging
from django.conf import settings
from horizon.utils.memoized import memoized
from openstack_dashboard.api import base
from heatclient import client as heat_client
LOG = logging.getLogger(__name__)
@memoized
def heatclient(request, password=None):
service_type = 'orchestration'
openstack_api_versions = getattr(settings, 'OPENSTACK_API_VERSIONS', {})
api_version = openstack_api_versions.get(service_type, 1)
insecure = getattr(settings, 'OPENSTACK_SSL_NO_VERIFY', False)
cacert = getattr(settings, 'OPENSTACK_SSL_CACERT', None)
endpoint = base.url_for(request, 'orchestration')
kwargs = {
'token': request.user.token.id,
'insecure': insecure,
'ca_file': cacert,
'username': request.user.username,
'password': password
}
client = heat_client.Client(api_version, endpoint, **kwargs)
client.format_parameters = format_parameters
return client
def format_parameters(params):
parameters = {}
for count, p in enumerate(params, 1):
parameters['Parameters.member.%d.ParameterKey' % count] = p
parameters['Parameters.member.%d.ParameterValue' % count] = params[p]
return parameters
def stack_get(request, stack_id):
return heatclient(request).stacks.get(stack_id)

View File

@ -84,7 +84,7 @@ def _create_patches(old, new):
for key in new:
path = '/' + key
if key in old and old[key] != new[key]:
if new[key] is None or new[key] is '':
if new[key] is None or new[key] == '':
patch.append({'op': 'remove', 'path': path})
else:
patch.append({'op': 'replace', 'path': path,
@ -196,6 +196,19 @@ def cluster_show(request, id):
return magnumclient(request).clusters.get(id)
def cluster_resize(request, cluster_id, node_count,
nodes_to_remove=None, nodegroup=None):
if nodes_to_remove is None:
nodes_to_remove = []
# Note: Magnum client does not use any return statement so result will
# be None unless an exception is raised.
return magnumclient(request).clusters.resize(
cluster_id, node_count,
nodes_to_remove=nodes_to_remove, nodegroup=nodegroup)
def certificate_create(request, **kwargs):
args = {}
for (key, value) in kwargs.items():

View File

@ -12,10 +12,13 @@
# License for the specific language governing permissions and limitations
# under the License.
from django.http import HttpResponseNotFound
from django.views import generic
from magnum_ui.api import heat
from magnum_ui.api import magnum
from openstack_dashboard import api
from openstack_dashboard.api import neutron
from openstack_dashboard.api.rest import urls
from openstack_dashboard.api.rest import utils as rest_utils
@ -116,6 +119,51 @@ class Cluster(generic.View):
updated_cluster.to_dict())
@urls.register
class ClusterResize(generic.View):
url_regex = r'container_infra/clusters/(?P<cluster_id>[^/]+)/resize$'
@rest_utils.ajax()
def get(self, request, cluster_id):
"""Get cluster details for resize"""
try:
cluster = magnum.cluster_show(request, cluster_id).to_dict()
except AttributeError as e:
print(e)
return HttpResponseNotFound()
stack = heat.stack_get(request, cluster["stack_id"])
search_opts = {"name": "%s-minion" % stack.stack_name}
servers = api.nova.server_list(request, search_opts=search_opts)[0]
worker_nodes = []
for server in servers:
worker_nodes.append({"name": server.name, "id": server.id})
return {"cluster": change_to_id(cluster),
"worker_nodes": worker_nodes}
@rest_utils.ajax(data_required=True)
def post(self, request, cluster_id):
"""Resize a cluster"""
nodes_to_remove = request.DATA.get("nodes_to_remove", None)
nodegroup = request.DATA.get("nodegroup", None)
node_count = request.DATA.get("node_count")
# Result will be 'None' unless error is raised response will be '204'
try:
return magnum.cluster_resize(
request, cluster_id, node_count,
nodes_to_remove=nodes_to_remove, nodegroup=nodegroup)
except AttributeError as e:
# If cluster is not found magnum-client throws Attribute error
# catch and respond with 404
print(e)
return HttpResponseNotFound()
@urls.register
class Clusters(generic.View):
"""API for Magnum Clusters"""

View File

@ -34,6 +34,7 @@
'horizon.framework.util.i18n.gettext',
'horizon.dashboard.container-infra.clusters.create.service',
'horizon.dashboard.container-infra.clusters.delete.service',
'horizon.dashboard.container-infra.clusters.resize.service',
'horizon.dashboard.container-infra.clusters.update.service',
'horizon.dashboard.container-infra.clusters.show-certificate.service',
'horizon.dashboard.container-infra.clusters.sign-certificate.service',
@ -46,6 +47,7 @@
gettext,
createClusterService,
deleteClusterService,
resizeClusterService,
updateClusterService,
showCertificateService,
signCertificateService,
@ -95,6 +97,13 @@
text: gettext('Rotate Certificate')
}
})
.append({
id: 'resizeClusterAction',
service: resizeClusterService,
template: {
text: gettext('Resize Cluster')
}
})
.append({
id: 'updateClusterAction',
service: updateClusterService,

View File

@ -50,6 +50,16 @@
expect(actionHasId(actions, 'rotateCertificateAction')).toBe(true);
});
it('registers Resize Cluster as an item action', function() {
var actions = registry.getResourceType('OS::Magnum::Cluster').itemActions;
expect(actionHasId(actions, 'resizeClusterAction')).toBe(true);
});
it('registers Update Cluster as an item action', function() {
var actions = registry.getResourceType('OS::Magnum::Cluster').itemActions;
expect(actionHasId(actions, 'updateClusterAction')).toBe(true);
});
it('registers Delete Cluster as an item action', function() {
var actions = registry.getResourceType('OS::Magnum::Cluster').itemActions;
expect(actionHasId(actions, 'deleteClusterAction')).toBe(true);

View File

@ -0,0 +1,203 @@
/**
* Copyright 2017 NEC Corporation
*
* 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 overview
* @name horizon.dashboard.container-infra.clusters.resize.service
* @description Service for the container-infra cluster resize modal.
* Allows user to select new number of worker nodes and if the number
* is reduced, nodes to be removed can be selected from the list.
*/
angular
.module('horizon.dashboard.container-infra.clusters')
.factory('horizon.dashboard.container-infra.clusters.resize.service',
resizeService);
resizeService.$inject = [
'$rootScope',
'$q',
'horizon.app.core.openstack-service-api.magnum',
'horizon.framework.util.actions.action-result.service',
'horizon.framework.util.i18n.gettext',
'horizon.framework.util.q.extensions',
'horizon.framework.widgets.form.ModalFormService',
'horizon.framework.widgets.toast.service',
'horizon.framework.widgets.modal-wait-spinner.service',
'horizon.dashboard.container-infra.clusters.resourceType'
];
function resizeService(
$rootScope, $q, magnum, actionResult, gettext, $qExtensions, modal, toast, spinnerModal,
resourceType
) {
var modalConfig, formModel;
var service = {
perform: perform,
allowed: allowed
};
return service;
//////////////
function perform(selected, $scope) {
var deferred = $q.defer();
spinnerModal.showModalSpinner(gettext('Loading'));
magnum.getClusterNodes(selected.id)
.then(onLoad)
.catch(hideSpinnerOnError);
function onLoad(response) {
formModel = getFormModelDefaults();
formModel.id = selected.id;
modalConfig = constructModalConfig(response.data.worker_nodes);
deferred.resolve(modal.open(modalConfig).then(onModalSubmit));
$scope.model = formModel;
spinnerModal.hideModalSpinner();
}
function hideSpinnerOnError(error) {
spinnerModal.hideModalSpinner();
return deferred.reject(error);
}
return deferred.promise;
}
function allowed() {
return $qExtensions.booleanAsPromise(true);
}
function constructModalConfig(workerNodesList) {
formModel.original_node_count = workerNodesList.length;
formModel.node_count = workerNodesList.length;
return {
title: gettext('Resize Cluster'),
schema: {
type: 'object',
properties: {
'node_count': {
type: 'number',
minimum: 1
},
'nodes_to_remove': {
type: 'array',
items: {
type: 'string'
},
minItems: 0 // Must be specified to avoid obsolete validation errors
}
}
},
form: [
{
key: 'node_count',
title: gettext('Node Count'),
placeholder: gettext('The cluster node count.'),
required: true,
validationMessage: {
101: gettext('You cannot resize to less than a single Worker Node.')
},
onChange: validateNodeRemovalCount
},
{
key: 'nodes_to_remove',
type: 'checkboxes',
title: gettext('Choose nodes to remove (Optional)'),
titleMap: generateNodesTitleMap(workerNodesList),
condition: 'model.node_count < model.original_node_count',
onChange: validateNodeRemovalCount,
validationMessage: {
nodeRemovalCountExceeded: gettext('You may only select as many nodes ' +
'as you are reducing the original node count by.')
}
}
],
model: formModel
};
}
// Invalid when user selects more Worker Nodes (checkboxes) than is allowed to be removed
function validateNodeRemovalCount() {
var selectedNodesCount = formModel.nodes_to_remove ? formModel.nodes_to_remove.length : 0;
var maximumNodesCount = formModel.original_node_count - formModel.node_count;
if (selectedNodesCount <= maximumNodesCount) {
broadcastNodeRemovalValid();
} else {
broadcastNodeRemovalInvalid();
}
function broadcastNodeRemovalInvalid() {
$rootScope.$broadcast('schemaForm.error.nodes_to_remove',
'nodeRemovalCountExceeded', false);
}
function broadcastNodeRemovalValid() {
$rootScope.$broadcast('schemaForm.error.nodes_to_remove',
'nodeRemovalCountExceeded', true);
}
}
function getFormModelDefaults() {
return {
original_node_count: null,
node_count: null,
nodes_to_remove: []
};
}
function generateNodesTitleMap(nodesList) {
return nodesList.map(function(node) {
return {
value: node.id,
name: node.name
};
});
}
function onModalSubmit() {
var postRequestObject = {
node_count: formModel.node_count,
nodegroup: 'production_group'
};
if (formModel.node_count < formModel.original_node_count &&
formModel.nodes_to_remove && formModel.nodes_to_remove.length > 0) {
postRequestObject.nodes_to_remove = formModel.nodes_to_remove;
}
return magnum.resizeCluster(formModel.id, postRequestObject)
.then(onRequestSuccess);
}
function onRequestSuccess() {
toast.add('success', gettext('Cluster is being resized.'));
return actionResult.getActionResult()
.updated(resourceType, formModel.id)
.result;
}
}
})();

View File

@ -0,0 +1,114 @@
/**
* Copyright 2017 NEC Corporation
*
* 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.dashboard.container-infra.clusters.resize.service', function() {
var service, $scope, $q, deferred, magnum, spinnerModal, modalConfig;
var selected = {
id: 1
};
var modal = {
open: function(config) {
deferred = $q.defer();
deferred.resolve(config);
modalConfig = config;
return deferred.promise;
}
};
///////////////////
beforeEach(module('horizon.app.core'));
beforeEach(module('horizon.framework'));
beforeEach(module('horizon.dashboard.container-infra.clusters'));
beforeEach(module(function($provide) {
$provide.value('horizon.framework.widgets.form.ModalFormService', modal);
}));
beforeEach(inject(function($injector, _$rootScope_, _$q_) {
$q = _$q_;
$scope = _$rootScope_.$new();
service = $injector.get(
'horizon.dashboard.container-infra.clusters.resize.service');
magnum = $injector.get('horizon.app.core.openstack-service-api.magnum');
spinnerModal = $injector.get('horizon.framework.widgets.modal-wait-spinner.service');
spyOn(spinnerModal, 'showModalSpinner').and.callFake(function() {});
spyOn(spinnerModal, 'hideModalSpinner').and.callFake(function() {});
deferred = $q.defer();
deferred.resolve({data: {uuid: '1'}});
spyOn(magnum, 'resizeCluster').and.returnValue(deferred.promise);
spyOn(modal, 'open').and.callThrough();
}));
it('should check the policy if the user is allowed to update cluster', function() {
var allowed = service.allowed();
expect(allowed).toBeTruthy();
});
it('should open the modal, hide the loading spinner and check the form model',
inject(function($timeout) {
var mockWorkerNodes = [{id: "456", name: "Worker Node 1"}];
deferred = $q.defer();
deferred.resolve({data: {cluster: {}, worker_nodes: mockWorkerNodes}});
spyOn(magnum, 'getClusterNodes').and.returnValue(deferred.promise);
service.perform(selected, $scope);
$timeout(function() {
expect(modal.open).toHaveBeenCalled();
expect(spinnerModal.showModalSpinner).toHaveBeenCalled();
expect(spinnerModal.hideModalSpinner).toHaveBeenCalled();
// Check if the form's model skeleton is correct
expect(modalConfig.model.id).toBe(selected.id);
expect(modalConfig.model.original_node_count).toBe(mockWorkerNodes.length);
expect(modalConfig.model.node_count).toBe(mockWorkerNodes.length);
expect(modalConfig.title).toBeDefined();
expect(modalConfig.schema).toBeDefined();
expect(modalConfig.form).toBeDefined();
}, 0);
$timeout.flush();
$scope.$apply();
}));
it('should not open the modal due to a request error and should hide the loading spinner',
inject(function($timeout) {
deferred = $q.defer();
deferred.reject();
spyOn(magnum, 'getClusterNodes').and.returnValue(deferred.promise);
service.perform(selected, $scope);
$timeout(function() {
expect(modal.open).not.toHaveBeenCalled();
expect(spinnerModal.showModalSpinner).toHaveBeenCalled();
expect(spinnerModal.hideModalSpinner).toHaveBeenCalled();
}, 0);
$timeout.flush();
$scope.$apply();
}));
});
})();

View File

@ -33,6 +33,8 @@
updateCluster: updateCluster,
getCluster: getCluster,
getClusters: getClusters,
getClusterNodes: getClusterNodes,
resizeCluster: resizeCluster,
deleteCluster: deleteCluster,
deleteClusters: deleteClusters,
createClusterTemplate: createClusterTemplate,
@ -87,6 +89,21 @@
});
}
function getClusterNodes(id) {
return apiService.get('/api/container_infra/clusters/' + id + '/resize')
.error(function() {
toastService.add('error', gettext('Unable to get cluster\'s working nodes.'));
});
}
function resizeCluster(id, params) {
return apiService.post('/api/container_infra/clusters/' + id + '/resize', params)
.error(function() {
var msg = gettext('Unable to resize given cluster id: %(id)s.');
toastService.add('error', interpolate(msg, { id: id }, true));
});
}
function deleteCluster(id, suppressError) {
var promise = apiService.delete('/api/container_infra/clusters/', [id]);
return suppressError ? promise : promise.error(function() {

View File

@ -68,6 +68,32 @@
"path": "/api/container_infra/clusters/",
"error": "Unable to retrieve the clusters."
},
{
"func": "getClusterNodes",
"method": "get",
"path": "/api/container_infra/clusters/123/resize",
"error": "Unable to get cluster\'s working nodes.",
"testInput": ["123"]
},
{
"func": "resizeCluster",
"method": "post",
"path": "/api/container_infra/clusters/123/resize",
"data": {
"node_count": 2,
"nodes_to_remove": ["456"],
"nodegroup": "production_group"
},
"error": "Unable to resize given cluster id: 123.",
"testInput": [
"123",
{
"node_count": 2,
"nodes_to_remove": ["456"],
"nodegroup": "production_group"
}
]
},
{
"func": "deleteCluster",
"method": "delete",

View File

@ -0,0 +1,10 @@
---
features:
- >
REST Api and Angular service for resizing clusters is addedd. Angular view
supports resizing number of worker nodes only.
other:
- >
Bump python-magnumclient lowerconstraint to >= 2.15.0
- >
Adds python-heatclient >= 1.18.0 dependency

View File

@ -8,6 +8,7 @@
#
# PBR should always appear first
pbr!=2.1.0,>=2.0.0 # Apache-2.0
python-magnumclient>=2.11.0 # Apache-2.0
python-magnumclient>=2.15.0 # Apache-2.0
python-heatclient>=1.18.0
horizon>=15.0.0.0b1 # Apache-2.0
horizon>=15.0.0.0b1 # Apache-2.0