Browse Source

Merge "Add ui for resizing clusters" into stable/train

tags/5.3.0
Zuul 5 months ago
committed by Gerrit Code Review
parent
commit
8bcf1d493c
12 changed files with 518 additions and 4 deletions
  1. +2
    -1
      lower-constraints.txt
  2. +62
    -0
      magnum_ui/api/heat.py
  3. +14
    -1
      magnum_ui/api/magnum.py
  4. +48
    -0
      magnum_ui/api/rest/magnum.py
  5. +9
    -0
      magnum_ui/static/dashboard/container-infra/clusters/actions.module.js
  6. +10
    -0
      magnum_ui/static/dashboard/container-infra/clusters/actions.module.spec.js
  7. +203
    -0
      magnum_ui/static/dashboard/container-infra/clusters/resize/resize.service.js
  8. +114
    -0
      magnum_ui/static/dashboard/container-infra/clusters/resize/resize.service.spec.js
  9. +17
    -0
      magnum_ui/static/dashboard/container-infra/magnum.service.js
  10. +26
    -0
      magnum_ui/static/dashboard/container-infra/magnum.service.spec.js
  11. +10
    -0
      releasenotes/notes/resize-actions-1436a2a0dccbd13b.yaml
  12. +3
    -2
      requirements.txt

+ 2
- 1
lower-constraints.txt 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
- 0
magnum_ui/api/heat.py 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)

+ 14
- 1
magnum_ui/api/magnum.py 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():


+ 48
- 0
magnum_ui/api/rest/magnum.py 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"""


+ 9
- 0
magnum_ui/static/dashboard/container-infra/clusters/actions.module.js 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,


+ 10
- 0
magnum_ui/static/dashboard/container-infra/clusters/actions.module.spec.js 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);


+ 203
- 0
magnum_ui/static/dashboard/container-infra/clusters/resize/resize.service.js 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;
}
}
})();

+ 114
- 0
magnum_ui/static/dashboard/container-infra/clusters/resize/resize.service.spec.js 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();
}));
});
})();

+ 17
- 0
magnum_ui/static/dashboard/container-infra/magnum.service.js 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() {


+ 26
- 0
magnum_ui/static/dashboard/container-infra/magnum.service.spec.js 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",


+ 10
- 0
releasenotes/notes/resize-actions-1436a2a0dccbd13b.yaml 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

+ 3
- 2
requirements.txt 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

Loading…
Cancel
Save