diff --git a/lower-constraints.txt b/lower-constraints.txt index c826905a..d550e365 100644 --- a/lower-constraints.txt +++ b/lower-constraints.txt @@ -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 diff --git a/magnum_ui/api/heat.py b/magnum_ui/api/heat.py new file mode 100644 index 00000000..28404580 --- /dev/null +++ b/magnum_ui/api/heat.py @@ -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) diff --git a/magnum_ui/api/magnum.py b/magnum_ui/api/magnum.py index 97de6ba8..39e17684 100644 --- a/magnum_ui/api/magnum.py +++ b/magnum_ui/api/magnum.py @@ -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(): diff --git a/magnum_ui/api/rest/magnum.py b/magnum_ui/api/rest/magnum.py index 5e6af047..37ef251d 100644 --- a/magnum_ui/api/rest/magnum.py +++ b/magnum_ui/api/rest/magnum.py @@ -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[^/]+)/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""" diff --git a/magnum_ui/static/dashboard/container-infra/clusters/actions.module.js b/magnum_ui/static/dashboard/container-infra/clusters/actions.module.js index fa3dfb12..9a27d07a 100644 --- a/magnum_ui/static/dashboard/container-infra/clusters/actions.module.js +++ b/magnum_ui/static/dashboard/container-infra/clusters/actions.module.js @@ -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, diff --git a/magnum_ui/static/dashboard/container-infra/clusters/actions.module.spec.js b/magnum_ui/static/dashboard/container-infra/clusters/actions.module.spec.js index 04c0d726..b3bee3b0 100644 --- a/magnum_ui/static/dashboard/container-infra/clusters/actions.module.spec.js +++ b/magnum_ui/static/dashboard/container-infra/clusters/actions.module.spec.js @@ -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); diff --git a/magnum_ui/static/dashboard/container-infra/clusters/resize/resize.service.js b/magnum_ui/static/dashboard/container-infra/clusters/resize/resize.service.js new file mode 100644 index 00000000..24856b21 --- /dev/null +++ b/magnum_ui/static/dashboard/container-infra/clusters/resize/resize.service.js @@ -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; + } + } +})(); diff --git a/magnum_ui/static/dashboard/container-infra/clusters/resize/resize.service.spec.js b/magnum_ui/static/dashboard/container-infra/clusters/resize/resize.service.spec.js new file mode 100644 index 00000000..842df87d --- /dev/null +++ b/magnum_ui/static/dashboard/container-infra/clusters/resize/resize.service.spec.js @@ -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(); + })); + }); +})(); diff --git a/magnum_ui/static/dashboard/container-infra/magnum.service.js b/magnum_ui/static/dashboard/container-infra/magnum.service.js index 756c2a50..2c03e3a7 100644 --- a/magnum_ui/static/dashboard/container-infra/magnum.service.js +++ b/magnum_ui/static/dashboard/container-infra/magnum.service.js @@ -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() { diff --git a/magnum_ui/static/dashboard/container-infra/magnum.service.spec.js b/magnum_ui/static/dashboard/container-infra/magnum.service.spec.js index ff6bd805..cb3f59dd 100644 --- a/magnum_ui/static/dashboard/container-infra/magnum.service.spec.js +++ b/magnum_ui/static/dashboard/container-infra/magnum.service.spec.js @@ -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", diff --git a/releasenotes/notes/resize-actions-1436a2a0dccbd13b.yaml b/releasenotes/notes/resize-actions-1436a2a0dccbd13b.yaml new file mode 100644 index 00000000..d90bffe1 --- /dev/null +++ b/releasenotes/notes/resize-actions-1436a2a0dccbd13b.yaml @@ -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 diff --git a/requirements.txt b/requirements.txt index 87377a96..b62bcb6d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -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 \ No newline at end of file