Add rolling upgrade ui

+ Create new row action on clusters panel
 + Create new modal form for upgrading cluster
 + Create REST endpoint for upgrading cluster
 + Bump python-magnumclient lower constraint

(cherry picked from commit cd0817a13b)

Register the Cluster Upgrade view

 + Fixes missing url registraion for upgrade cluster
   in prior patch (https://review.opendev.org/#/c/697000/4).

(cherry picked from commit d96f4d16c2)

Change-Id: Id3fd3ee80fb27b08673933800aea6e7ee7ac7cd0
This commit is contained in:
Simon Merrick 2019-09-18 18:06:43 +12:00 committed by Bharat Kunwar
parent 7bc8797bde
commit b0fdeed636
11 changed files with 626 additions and 5 deletions

View File

@ -2,7 +2,37 @@
Configuration
=============
Magnum UI has no configuration option.
Magnum UI specific settings
===========================
CLUSTER_TEMPLATE_GROUP_FILTERS
------------------------------
.. versionadded:: 5.3.0 (Ussuri)
Default: ``None``
Examples:
.. code-block:: python
CLUSTER_TEMPLATE_GROUP_FILTERS = {
"dev": ".*-dev-.*",
"prod": ".*-prod-.*"
}
The settings expects a dictionary of group name, to group regex.
When set allows a cloud provider to specify template groups
for their cluster templates based on their naming convention.
This helps limit users from upgrading their cluster to an invalid
template that will not work based on their current template type.
This filtering is only relevant when choosing a new template for
upgrading a cluster.
Horizon Settings
================
For more configurations, see
`Configuration Guide

View File

@ -209,6 +209,13 @@ def cluster_resize(request, cluster_id, node_count,
nodes_to_remove=nodes_to_remove, nodegroup=nodegroup)
def cluster_upgrade(request, cluster_uuid, cluster_template,
max_batch_size=1, nodegroup=None):
return magnumclient(request).clusters.upgrade(
cluster_uuid, cluster_template,
max_batch_size=max_batch_size, nodegroup=None)
def certificate_create(request, **kwargs):
args = {}
for (key, value) in kwargs.items():

View File

@ -12,6 +12,9 @@
# License for the specific language governing permissions and limitations
# under the License.
from collections import defaultdict
from django.conf import settings
from django.http import HttpResponseNotFound
from django.views import generic
@ -23,6 +26,8 @@ from openstack_dashboard.api import neutron
from openstack_dashboard.api.rest import urls
from openstack_dashboard.api.rest import utils as rest_utils
import re
def change_to_id(obj):
"""Change key named 'uuid' to 'id'
@ -70,9 +75,40 @@ class ClusterTemplates(generic.View):
The returned result is an object with property 'items' and each
item under this is a Cluster Template.
If a GET query param for 'related_to' is specified, and
the setting for template filtering is set, then Horizon will
only return template groups which the given template
falls into, or all if none match.
"""
result = magnum.cluster_template_list(request)
return {'items': [change_to_id(n.to_dict()) for n in result]}
templates = magnum.cluster_template_list(request)
template_filters = getattr(
settings, "CLUSTER_TEMPLATE_GROUP_FILTERS", None)
related_to_id = request.GET.get("related_to")
if template_filters and related_to_id:
templates_by_id = {t.uuid: t for t in templates}
related_to = templates_by_id.get(related_to_id)
if related_to:
matched_groups = []
groups = defaultdict(list)
for group, regex in template_filters.items():
pattern = re.compile(regex)
if pattern.match(related_to.name):
matched_groups.append(group)
for template in templates:
if pattern.match(template.name):
groups[group].append(template)
if matched_groups:
new_templates = []
for group in matched_groups:
new_templates += groups[group]
templates = set(new_templates)
return {'items': [change_to_id(n.to_dict()) for n in templates]}
@rest_utils.ajax(data_required=True)
def delete(self, request):
@ -164,6 +200,23 @@ class ClusterResize(generic.View):
return HttpResponseNotFound()
@urls.register
class ClusterUpgrade(generic.View):
url_regex = r'container_infra/clusters/(?P<cluster_id>[^/]+)/upgrade$'
@rest_utils.ajax(data_required=True)
def post(self, request, cluster_id):
"""Upgrade a cluster"""
cluster_template = request.DATA.get("cluster_template")
max_batch_size = request.DATA.get("max_batch_size", 1)
nodegroup = request.DATA.get("nodegroup", None)
return magnum.cluster_upgrade(
request, cluster_id, cluster_template,
max_batch_size=max_batch_size, nodegroup=nodegroup)
@urls.register
class Clusters(generic.View):
"""API for Magnum Clusters"""

View File

@ -36,6 +36,7 @@
'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.rolling-upgrade.service',
'horizon.dashboard.container-infra.clusters.show-certificate.service',
'horizon.dashboard.container-infra.clusters.sign-certificate.service',
'horizon.dashboard.container-infra.clusters.rotate-certificate.service',
@ -49,6 +50,7 @@
deleteClusterService,
resizeClusterService,
updateClusterService,
rollingUpgradeClusterService,
showCertificateService,
signCertificateService,
rotateCertificateService,
@ -111,6 +113,13 @@
text: gettext('Update Cluster')
}
})
.append({
id: 'rollingUpgradeClusterAction',
service: rollingUpgradeClusterService,
template: {
text: gettext('Rolling Cluster Upgrade')
}
})
.append({
id: 'deleteClusterAction',
service: deleteClusterService,

View File

@ -0,0 +1,227 @@
/**
* 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.rolling-upgrade.service
* @description Service for the container-infra cluster rolling upgrade modal.
* Allows user to choose a Cluster template with higher version number the
* cluster should upgrade to. Optionally, the number of nodes in a single
* upgrade batch can be chosen.
*/
angular
.module('horizon.dashboard.container-infra.clusters')
.factory('horizon.dashboard.container-infra.clusters.rolling-upgrade.service', upgradeService);
upgradeService.$inject = [
'$q',
'$document',
'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',
'horizon.dashboard.container-infra.utils.service'
];
function upgradeService(
$q, $document, magnum, actionResult, gettext, $qExtensions, modal, toast, spinnerModal,
resourceType, utils
) {
var modalConfig, formModel, isLatestTemplate, clusterTemplatesTitleMap;
var service = {
perform: perform,
allowed: allowed
};
return service;
//////////////
function perform(selected, $scope) {
// Simulate a click to dismiss opened action dropdown, otherwise it could interfere with
// correct behaviour of other dropdowns.
$document[0].body.click();
var deferred = $q.defer();
spinnerModal.showModalSpinner(gettext('Loading'));
var activeTemplateVersion, activeTemplateId;
magnum.getCluster(selected.id).then(function(response) {
formModel = getFormModelDefaults();
formModel.id = selected.id;
clusterTemplatesTitleMap = [
// Default <select> placeholder
{
value:'',
name: gettext("Choose a Cluster Template to upgrade to")
}
];
processClusterResponse(response.data);
// Retrieve only cluster templates related to the current one.
return magnum.getClusterTemplates(activeTemplateId);
}).then(function(response) {
processClusterTemplatesResponse(response.data.items);
modalConfig = createModalConfig();
deferred.resolve(modal.open(modalConfig).then(onModalSubmit));
spinnerModal.hideModalSpinner();
$scope.model = formModel;
}).catch(onError);
function processClusterResponse(cluster) {
formModel.master_nodes = cluster.master_count;
formModel.worker_nodes = cluster.node_count;
activeTemplateVersion = cluster.labels.kube_tag;
activeTemplateId = cluster.cluster_template_id;
}
function processClusterTemplatesResponse(clusterTemplates) {
if (!clusterTemplates) { return; }
var startingTemplatesTitleMapLength = clusterTemplatesTitleMap.length;
// Only load templates that are greater than the current template (kube tag comparison)
clusterTemplates.forEach(function(template) {
if (isVersionGreater(activeTemplateVersion, template.labels.kube_tag)) {
clusterTemplatesTitleMap.push({
value: template.id,
name: template.name
});
}
});
// Order templates by name in descending order
clusterTemplatesTitleMap.sort(function(firstTemplate, secondTemplate) {
return firstTemplate.name < secondTemplate.name ? 1 : -1;
});
// If nothing has been added to the map => already on latest template
isLatestTemplate = startingTemplatesTitleMapLength === clusterTemplatesTitleMap.length;
}
function onError(err) {
spinnerModal.hideModalSpinner();
return deferred.reject(err);
}
return deferred.promise;
}
function createModalConfig() {
return {
title: gettext('Rolling Cluster Upgrade'),
schema: {
type: 'object',
properties: {
'cluster_template_id': {
title: gettext('New Cluster Template'),
type: 'string'
},
'max_batch_size': {
title: gettext('Maximum Batch Size'),
type: 'number',
minimum: 1
}
}
},
form: [
{
key: 'cluster_template_id',
type: 'select',
titleMap: clusterTemplatesTitleMap,
required: true,
readonly: isLatestTemplate,
description: isLatestTemplate
? gettext('<em>This cluster is already on the latest compatible template</em>') : null
},
{
key: 'max_batch_size',
placeholder: gettext('The cluster node count.'),
// Disable if there's nothing to upgrade or if the the default value incrementation
// would fail the validation.
readonly: isLatestTemplate ||
!isBatchSizeValid(getFormModelDefaults().max_batch_size + 1),
validationMessage: {
sizeExceeded: gettext('The maximum number of nodes in the batch has been exceeded.'),
101: gettext('A batch cannot have less than one node.')
},
$validators: {
sizeExceeded: isBatchSizeValid
}
}
],
model: formModel
};
}
function getFormModelDefaults() {
return {
cluster_template_id: '',
max_batch_size: 1
};
}
function allowed() {
return $qExtensions.booleanAsPromise(true);
}
function isBatchSizeValid(batchSize) {
return batchSize &&
(batchSize === 1 ||
batchSize <= formModel.master_nodes / 3 && batchSize <= formModel.worker_nodes / 5);
}
function onModalSubmit() {
return magnum.upgradeCluster(formModel.id, {
cluster_template: formModel.cluster_template_id,
max_batch_size: formModel.max_batch_size,
nodegroup: 'production_group'
}).then(onRequestSuccess);
}
function onRequestSuccess() {
toast.add('success', gettext('Cluster is being upgraded to the new Cluster template'));
return actionResult.getActionResult()
.updated(resourceType, formModel.id)
.result;
}
function isVersionGreater(v1, v2) {
if (!v1 || !v2) { return null; }
// Strip the 'v' if prefixed in the version
if (v1[0] === 'v') { v1 = v1.substr(1); }
if (v2[0] === 'v') { v2 = v2.substr(1); }
return utils.versionCompare(v1, v2) < 0;
}
}
})();

View File

@ -0,0 +1,139 @@
/**
* 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.rolling-upgrade.service', function() {
var service, $scope, $q, deferred, magnum, spinnerModal, modalConfig;
var selected = {
id: 1
};
var modal = {
open: function(config) {
modalConfig = config;
deferred = $q.defer();
deferred.resolve(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.rolling-upgrade.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, labels: "key1:val1,key2:val2"}});
spyOn(magnum, 'upgradeCluster').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 mockClusterDetails = {
data: {
master_count: 1,
node_count: 2,
labels: { kube_tag: 'v1.3.4' }
}
};
var mockClusterTemplates = {
data: {
items: [
{ labels: { kube_tag: 'v1.4.1' } },
{ labels: { kube_tag: 'v1.3.4' } }
]
}
};
deferred = $q.defer();
deferred.resolve(mockClusterDetails);
spyOn(magnum, 'getCluster').and.returnValue(deferred.promise);
deferred = $q.defer();
deferred.resolve(mockClusterTemplates);
spyOn(magnum, 'getClusterTemplates').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.master_nodes).toBe(mockClusterDetails.data.master_count);
expect(modalConfig.model.worker_nodes).toBe(mockClusterDetails.data.node_count);
expect(modalConfig.title).toBeDefined();
expect(modalConfig.schema).toBeDefined();
expect(modalConfig.form).toBeDefined();
// Only one version is greater than `v1.3.4`, so the
// form <select> should have 2 optiosn (1+1 the default)
expect(modalConfig.form[0].titleMap.length).toBe(2);
}, 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, 'getCluster').and.returnValue(deferred.promise);
spyOn(magnum, 'getClusterTemplates').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

@ -31,6 +31,7 @@
var service = {
createCluster: createCluster,
updateCluster: updateCluster,
upgradeCluster: upgradeCluster,
getCluster: getCluster,
getClusters: getClusters,
getClusterNodes: getClusterNodes,
@ -75,6 +76,13 @@
});
}
function upgradeCluster(id, params) {
return apiService.post('/api/container_infra/clusters/' + id + '/upgrade', params)
.error(function() {
toastService.add('error', gettext('Unable to perform rolling upgrade.'));
});
}
function getCluster(id) {
return apiService.get('/api/container_infra/clusters/' + id)
.error(function() {
@ -145,8 +153,9 @@
});
}
function getClusterTemplates() {
return apiService.get('/api/container_infra/cluster_templates/')
function getClusterTemplates(relatedTemplateId) {
var filterQuery = relatedTemplateId ? '?related_to=' + relatedTemplateId : '';
return apiService.get('/api/container_infra/cluster_templates/' + filterQuery)
.error(function() {
toastService.add('error', gettext('Unable to retrieve the cluster templates.'));
});

View File

@ -102,6 +102,25 @@
"error": "Unable to delete the cluster with id: 1",
"testInput": [1]
},
{
"func": "upgradeCluster",
"method": "post",
"path": "/api/container_infra/clusters/123/upgrade",
"data": {
"cluster_template": "ABC",
"max_batch_size": 1,
"node_group": "production_group"
},
"error": "Unable to perform rolling upgrade.",
"testInput": [
"123",
{
"cluster_template": "ABC",
"max_batch_size": 1,
"node_group": "production_group"
}
]
},
{
"func": "deleteClusters",
"method": "delete",
@ -139,6 +158,13 @@
"path": "/api/container_infra/cluster_templates/",
"error": "Unable to retrieve the cluster templates."
},
{
"func": "getClusterTemplates",
"method": "get",
"path": "/api/container_infra/cluster_templates/?related_to=123",
"error": "Unable to retrieve the cluster templates.",
"testInput": [123]
},
{
"func": "deleteClusterTemplate",
"method": "delete",

View File

@ -0,0 +1,75 @@
/**
* 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.container-infra')
.factory('horizon.dashboard.container-infra.utils.service',
utilsService);
/*
* @ngdoc factory
* @name horizon.dashboard.container-infra.utils.service
*
* @description
* A utility service providing helpers for the Magnum UI frontend.
*/
function utilsService() {
return {
versionCompare: versionCompare
};
function versionCompare(v1, v2, options) {
var lexicographical = options && options.lexicographical;
var zeroExtend = options && options.zeroExtend;
var v1parts = v1.split('.');
var v2parts = v2.split('.');
function isValidPart(x) {
return (lexicographical ? /^\d+[A-Za-z]*$/ : /^\d+$/).test(x);
}
if (!v1parts.every(isValidPart) || !v2parts.every(isValidPart)) {
return NaN;
}
if (zeroExtend) {
while (v1parts.length < v2parts.length) { v1parts.push("0"); }
while (v2parts.length < v1parts.length) { v2parts.push("0"); }
}
if (!lexicographical) {
v1parts = v1parts.map(Number);
v2parts = v2parts.map(Number);
}
for (var i = 0; i < v1parts.length; ++i) {
if (v2parts.length === i) { return 1; }
if (v1parts[i] === v2parts[i]) {
continue;
} else if (v1parts[i] > v2parts[i]) {
return 1;
} else {
return -1;
}
}
if (v1parts.length !== v2parts.length) { return -1; }
return 0;
}
}
})();

View File

@ -0,0 +1,42 @@
/**
* 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.utils.service', function() {
var service;
///////////////////
beforeEach(module('horizon.dashboard.container-infra'));
beforeEach(inject(function($injector) {
service = $injector.get(
'horizon.dashboard.container-infra.utils.service');
}));
it('should compare two semver-based versions strings', function() {
expect(service.versionCompare('1.2.2','1.2.2')).toBe(0);
expect(service.versionCompare('1.2.3','1.2.2')).toBe(1);
expect(service.versionCompare('1.2.2','1.2.3')).toBe(-1);
expect(service.versionCompare('1.12.2','1.2.2')).toBe(1);
expect(service.versionCompare('12.1.2','1.3.2')).toBe(1);
expect(service.versionCompare('1.3.2','1.3.11')).toBe(-1);
});
});
})();

View File

@ -0,0 +1,4 @@
---
features:
- |
Adds REST api and Angular service for rolling upgrade action on cluster.