From 108c693991b03238d68fdd882f2f95ab8c6305ea Mon Sep 17 00:00:00 2001 From: Simon Merrick Date: Thu, 12 Sep 2019 17:05:16 +1200 Subject: [PATCH] Improve cluster launch workflow + Improve the launch work flow form. + Add REST endpoint for available ingress controllers + Add REST endpint for available addons Change-Id: Ic76d4d853bce0b1bfd107ca1bd6a7231939845df Depends-On: https://review.opendev.org/#/c/697000/ --- doc/source/configuration/index.rst | 85 ++- magnum_ui/api/rest/magnum.py | 67 +- .../clusters/actions.module.js | 9 - .../clusters/actions.module.spec.js | 5 - .../clusters/create/create.service.js | 138 +++- .../clusters/create/create.service.spec.js | 64 +- .../clusters/update/update.service.js | 132 ---- .../clusters/update/update.service.spec.js | 90 --- .../clusters/workflow/advanced.help.html | 5 + .../workflow/cluster-template.controller.js | 63 +- .../cluster-template.controller.spec.js | 219 +++++- .../clusters/workflow/details.help.html | 2 + .../clusters/workflow/info.help.html | 1 - .../clusters/workflow/labels.help.html | 1 - .../clusters/workflow/management.help.html | 2 + .../clusters/workflow/misc.help.html | 9 - .../clusters/workflow/network.help.html | 4 + .../clusters/workflow/size.help.html | 7 +- .../clusters/workflow/workflow.service.js | 638 +++++++++++++----- .../workflow/workflow.service.spec.js | 56 +- .../container-infra/magnum.service.js | 27 + .../container-infra/magnum.service.spec.js | 12 + ...prove-cluster-launch-dfc514b51cfa7f0e.yaml | 10 + 23 files changed, 1172 insertions(+), 474 deletions(-) delete mode 100644 magnum_ui/static/dashboard/container-infra/clusters/update/update.service.js delete mode 100644 magnum_ui/static/dashboard/container-infra/clusters/update/update.service.spec.js create mode 100644 magnum_ui/static/dashboard/container-infra/clusters/workflow/advanced.help.html create mode 100644 magnum_ui/static/dashboard/container-infra/clusters/workflow/details.help.html delete mode 100644 magnum_ui/static/dashboard/container-infra/clusters/workflow/info.help.html delete mode 100644 magnum_ui/static/dashboard/container-infra/clusters/workflow/labels.help.html create mode 100644 magnum_ui/static/dashboard/container-infra/clusters/workflow/management.help.html delete mode 100644 magnum_ui/static/dashboard/container-infra/clusters/workflow/misc.help.html create mode 100644 magnum_ui/static/dashboard/container-infra/clusters/workflow/network.help.html create mode 100644 releasenotes/notes/improve-cluster-launch-dfc514b51cfa7f0e.yaml diff --git a/doc/source/configuration/index.rst b/doc/source/configuration/index.rst index 67d551fe..7657ce64 100644 --- a/doc/source/configuration/index.rst +++ b/doc/source/configuration/index.rst @@ -31,6 +31,90 @@ 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. + +MAGNUM_INGRESS_CONTROLLERS +-------------------------- + +.. versionadded:: 5.3.0 (Ussuri) + +Default: ``None`` + +Examples: + +.. code-block:: python + + MAGNUM_INGRESS_CONTROLLERS = [ + { + "name": "NGINX", + "labels": { + "ingress_controller": "nginx" + } + }, + { + "name": "Traefik", + "labels": { + "ingress_controller": "traefik" + } + }, + { + "name": "Octavia", + "labels": { + "ingress_controller": "octavia" + } + } + ] + +This setting specifies which `Kubernetes Ingress Controllers `__ +are supported by the deployed version of magnum and map directly to the +response returned by the magnum-ui `api/container-infra/ingress_controllers` endpoint. + +MAGNUM_AVAILABLE_ADDONS +----------------------- + +.. versionadded:: 5.3.0 (Ussuri) + +Default: ``None`` + +Examples: + +.. code-block:: python + + MAGNUM_AVAILABLE_ADDONS = [ + { + "name": "Kubernetes Dashboard", + "selected": True, + "labels": { + "kube_dashboard_enabled": True + }, + "labels_unselected": { + "kube_dashboard_enabled": False + } + }, + { + "name": "Influx Grafana Dashboard", + "selected": False, + "labels": { + "influx_grafana_dashboard_enabled": True + }, + "labels_unselected": { + "influx_grafana_dashboard_enabled": False + } + } + ] + +Specifies which 'Addon Software' is available or supported in the deployed version +of magnum and specifies which labels need to be included in order to enable or +disable the Software Addon. + +Examples of `Addon Software` include but are not limited to: + +* `Kubernetes Dashboard `__ +* `Influx Grafana Dashboard `__ + +Values specified in the ``MAGNUM_AVAILABLE_ADDONS`` setting map directly to the +values returned in the response of the `api/container-infra/available_addons` +endpoint. + Horizon Settings ================ @@ -38,4 +122,3 @@ For more configurations, see `Configuration Guide `__ in the Horizon documentation. - diff --git a/magnum_ui/api/rest/magnum.py b/magnum_ui/api/rest/magnum.py index 3339c1f4..e5e61b80 100644 --- a/magnum_ui/api/rest/magnum.py +++ b/magnum_ui/api/rest/magnum.py @@ -12,9 +12,13 @@ # License for the specific language governing permissions and limitations # under the License. +import logging +import re + from collections import defaultdict from django.conf import settings +from django.http import HttpResponse from django.http import HttpResponseNotFound from django.views import generic @@ -26,7 +30,7 @@ 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 +LOG = logging.getLogger(__name__) def change_to_id(obj): @@ -39,6 +43,59 @@ def change_to_id(obj): return obj +@urls.register +class IngressControllers(generic.View): + url_regex = r'container_infra/ingress_controllers/' + + @rest_utils.ajax() + def get(self, request): + configured_controllers = getattr( + settings, "MAGNUM_INGRESS_CONTROLLERS", []) + available_controllers = [] + + for controller in configured_controllers: + try: + parsed = {} + parsed["name"] = controller["name"] + parsed["labels"] = controller["labels"] + assert type(parsed["labels"]) is dict + available_controllers.append(parsed) + except KeyError as e: + LOG.exception(e) + except AssertionError as e: + LOG.exception(e) + + return {"controllers": available_controllers} + + +@urls.register +class Addons(generic.View): + url_regex = r'container_infra/available_addons/' + + @rest_utils.ajax() + def get(self, request): + available_addons = [] + + configured_addons = getattr( + settings, "MAGNUM_AVAILABLE_ADDONS", []) + + for configured_addon in configured_addons: + addon = {} + try: + addon["name"] = configured_addon["name"] + addon["selected"] = configured_addon["selected"] + assert type(addon["selected"]) is bool + addon["labels"] = configured_addon["labels"] + assert type(addon["labels"]) is dict + available_addons.append(addon) + except KeyError as e: + LOG.exception(e) + except AssertionError as e: + LOG.exception(e) + + return {"addons": available_addons} + + @urls.register class ClusterTemplate(generic.View): """API for retrieving a single cluster template""" @@ -317,7 +374,13 @@ class Quota(generic.View): @rest_utils.ajax() def get(self, request, project_id, resource): """Get a specific quota""" - return magnum.quotas_show(request, project_id, resource).to_dict() + try: + return magnum.quotas_show(request, project_id, resource).to_dict() + except AttributeError as e: + LOG.exception(e) + message = ("Quota could not be found: " + "project_id %s resource %s" % (project_id, resource)) + return HttpResponse(message, status=404) @rest_utils.ajax(data_required=True) def patch(self, request, project_id, resource): 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 f30c720a..eaa1d130 100644 --- a/magnum_ui/static/dashboard/container-infra/clusters/actions.module.js +++ b/magnum_ui/static/dashboard/container-infra/clusters/actions.module.js @@ -35,7 +35,6 @@ '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.rolling-upgrade.service', 'horizon.dashboard.container-infra.clusters.show-certificate.service', 'horizon.dashboard.container-infra.clusters.sign-certificate.service', @@ -49,7 +48,6 @@ createClusterService, deleteClusterService, resizeClusterService, - updateClusterService, rollingUpgradeClusterService, showCertificateService, signCertificateService, @@ -106,13 +104,6 @@ text: gettext('Resize Cluster') } }) - .append({ - id: 'updateClusterAction', - service: updateClusterService, - template: { - text: gettext('Update Cluster') - } - }) .append({ id: 'rollingUpgradeClusterAction', service: rollingUpgradeClusterService, 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 b3bee3b0..59174ec0 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 @@ -55,11 +55,6 @@ 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/create/create.service.js b/magnum_ui/static/dashboard/container-infra/clusters/create/create.service.js index 8d06468c..044a41b7 100644 --- a/magnum_ui/static/dashboard/container-infra/clusters/create/create.service.js +++ b/magnum_ui/static/dashboard/container-infra/clusters/create/create.service.js @@ -18,7 +18,8 @@ /** * @ngdoc overview * @name horizon.dashboard.container-infra.clusters.create.service - * @description Service for the container-infra cluster create modal + * @description Service for the container-infra 'Create New Cluster' dialog. + * Also responsible for processing the user submission. */ angular .module('horizon.dashboard.container-infra.clusters') @@ -27,22 +28,22 @@ createService.$inject = [ '$location', 'horizon.app.core.openstack-service-api.magnum', - 'horizon.app.core.openstack-service-api.policy', '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.clusters.workflow' ]; function createService( - $location, magnum, policy, actionResult, gettext, $qExtensions, modal, toast, + $location, magnum, actionResult, gettext, $qExtensions, modal, toast, spinnerModal, resourceType, workflow ) { - var config; + var modalConfig; var message = { success: gettext('Cluster %s was successfully created.') }; @@ -57,44 +58,133 @@ ////////////// function perform(selected, $scope) { - config = workflow.init('create', gettext('Create'), $scope); - if (typeof selected !== 'undefined') { - config.model.cluster_template_id = selected.id; + spinnerModal.showModalSpinner(gettext('Loading')); + + function onCreateWorkflowConfig(config) { + modalConfig = config; + spinnerModal.hideModalSpinner(); + return modal.open(modalConfig).then(onModalSubmit); } - return modal.open(config).then(submit); + + return workflow.init(gettext('Create New Cluster'), $scope) + .then(onCreateWorkflowConfig) + .catch(hideSpinnerOnError); + } + + function hideSpinnerOnError(error) { + spinnerModal.hideModalSpinner(); + return error; } function allowed() { return $qExtensions.booleanAsPromise(true); } - function submit(context) { - context.model = cleanNullProperties(context.model); - return magnum.createCluster(context.model, false).then(success, true); + function onModalSubmit(context) { + return magnum.createCluster(buildRequestObject(context.model), false) + .then(onRequestSuccess, true); } - function cleanNullProperties(model) { - // Initially clean fields that don't have any value. - // Not only "null", blank too. - for (var key in model) { - if (model.hasOwnProperty(key) && model[key] === null || model[key] === "" || - key === "tabs") { - delete model[key]; + function buildRequestObject(model) { + var MODEL_DEFAULTS = model.DEFAULTS; + var requestLabels = {}; + + var requestObject = { + // Defaults required by the endpoint + discovery_url: null, + create_timeout: 60, + rollback: false, + + // Form fields + name: model.name, + cluster_template_id: model.cluster_template_id, + keypair: model.keypair, + floating_ip_enabled: model.floating_ip_enabled, + labels: requestLabels + }; + + // Optional request fields + addFieldToRequestObjectIfSet('master_count','master_count'); + addFieldToRequestObjectIfSet('master_flavor_id','master_flavor_id'); + addFieldToRequestObjectIfSet('node_count','node_count'); + addFieldToRequestObjectIfSet('flavor_id','flavor_id'); + + if (!model.create_network) { + addFieldToRequestObjectIfSet('fixed_network','fixed_network'); + } + // Labels processing order (the following overrides previous): + // Cluster Templates -> Create Form -> User-defined in 'labels' textarea + + // 1) Cluster Templates labels + if (model.templateLabels) { + angular.extend(requestLabels, model.templateLabels); + } + + // 2) Create Workflow Form labels + requestLabels.availability_zone = model.availability_zone; + requestLabels.auto_scaling_enabled = model.auto_scaling_enabled; + requestLabels.auto_healing_enabled = model.auto_healing_enabled; + + if (model.auto_scaling_enabled) { + requestLabels.min_node_count = model.min_node_count; + requestLabels.max_node_count = model.max_node_count; + } + + // 2A) Labels from user-selected addons + angular.forEach(model.addons, function(addon) { + angular.extend(requestLabels, addon.labels); + }); + // 2B) Labels from user-selected ingress controller + if (model.ingress_controller && model.ingress_controller.labels) { + angular.extend(requestLabels, model.ingress_controller.labels); + } + + // 3) User-defined Custom labels + // Parse all labels comma-separated key=value pairs and inject them into request object + if (model.labels !== MODEL_DEFAULTS.labels) { + try { + model.labels.split(',').forEach(function(kvPair) { + var pairsList = kvPair.split('='); + + // Remove leading and trailing whitespaces & convert to l-case + var labelKey = pairsList[0].trim().toLowerCase(); + var labelValue = pairsList[1].trim().toLowerCase(); + + if (labelValue) { + // Only override existing label values if user override flag is true + if (!requestLabels.hasOwnProperty(labelKey) || model.override_labels) { + requestLabels[labelKey] = labelValue; + } + } + }); + } catch (err) { + toast.add('error', gettext('Unable to process `Additional Labels`. ' + + 'Not all labels will be applied.')); } } - return model; + + // Only add to the request Object if set (= not default) + function addFieldToRequestObjectIfSet(requestFieldName, modelFieldName) { + if (model[modelFieldName] !== MODEL_DEFAULTS[modelFieldName]) { + requestObject[requestFieldName] = model[modelFieldName]; + } + } + + return requestObject; } - function success(response) { + function onRequestSuccess(response) { response.data.id = response.data.uuid; toast.add('success', interpolate(message.success, [response.data.id])); + var result = actionResult.getActionResult() - .created(resourceType, response.data.id); + .created(resourceType, response.data.id); + if (result.result.failed.length === 0 && result.result.created.length > 0) { - $location.path("/project/clusters"); - } else { - return result.result; + $location.path('/project/clusters'); } + + return result.result; } } })(); diff --git a/magnum_ui/static/dashboard/container-infra/clusters/create/create.service.spec.js b/magnum_ui/static/dashboard/container-infra/clusters/create/create.service.spec.js index 239703c8..2f876b68 100644 --- a/magnum_ui/static/dashboard/container-infra/clusters/create/create.service.spec.js +++ b/magnum_ui/static/dashboard/container-infra/clusters/create/create.service.spec.js @@ -19,15 +19,26 @@ describe('horizon.dashboard.container-infra.clusters.create.service', function() { - var service, $scope, $q, deferred, magnum, workflow; + var service, $scope, $q, deferred, magnum, workflow, spinnerModal, modalConfig, configDeferred; + var model = { - id: 1 + id: 1, + labels: 'key1=value1,key2=value2', + auto_scaling_enabled: true, + templateLabels: {key1:'default value'}, + override_labels: true, + master_count: 1, + create_network: true, + addons: [{labels:{}},{labels:{}}], + ingress_controller: {labels:{ingress_controller:''}}, + DEFAULTS: {labels:''} }; var modal = { open: function(config) { - config.model = model; deferred = $q.defer(); deferred.resolve(config); + modalConfig = config; + return deferred.promise; } }; @@ -48,11 +59,25 @@ service = $injector.get('horizon.dashboard.container-infra.clusters.create.service'); magnum = $injector.get('horizon.app.core.openstack-service-api.magnum'); workflow = $injector.get('horizon.dashboard.container-infra.clusters.workflow'); + + 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}}); + + configDeferred = $q.defer(); + configDeferred.resolve({ + title: 'Create New Cluster', + schema: {}, + form: {}, + model: model + }); + spyOn(magnum, 'createCluster').and.returnValue(deferred.promise); + spyOn(workflow, 'init').and.returnValue(configDeferred.promise); spyOn(modal, 'open').and.callThrough(); - spyOn(workflow, 'init').and.returnValue({model: model}); })); it('should check the policy if the user is allowed to create cluster', function() { @@ -60,15 +85,36 @@ expect(allowed).toBeTruthy(); }); - it('open the modal', inject(function($timeout) { - service.perform(model, $scope); + it('should open the modal, hide the loading spinner and have valid ' + + 'form model', inject(function($timeout) { + service.perform(null, $scope); - expect(modal.open).toHaveBeenCalled(); + $timeout(function() { + expect(modal.open).toHaveBeenCalled(); + expect(magnum.createCluster).toHaveBeenCalled(); + // Check if the form's model skeleton is correct + expect(modalConfig.model).toBeDefined(); + expect(modalConfig.schema).toBeDefined(); + expect(modalConfig.form).toBeDefined(); + expect(modalConfig.title).toEqual('Create New Cluster'); + }, 0); + + $timeout.flush(); + $scope.$apply(); + })); + + it('should not crash unexpectedly with empty form model', inject(function($timeout) { + model.auto_scaling_enabled = null; + model.templateLabels = null; + model.override_labels = null; + model.create_network = null; + model.addons = null; + model.labels = 'invalid label'; + + service.perform(null, $scope); $timeout.flush(); $scope.$apply(); - - expect(magnum.createCluster).toHaveBeenCalled(); })); }); })(); diff --git a/magnum_ui/static/dashboard/container-infra/clusters/update/update.service.js b/magnum_ui/static/dashboard/container-infra/clusters/update/update.service.js deleted file mode 100644 index eb623b71..00000000 --- a/magnum_ui/static/dashboard/container-infra/clusters/update/update.service.js +++ /dev/null @@ -1,132 +0,0 @@ -/** - * 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.update.service - * @description Service for the container-infra cluster update modal - */ - angular - .module('horizon.dashboard.container-infra.clusters') - .factory('horizon.dashboard.container-infra.clusters.update.service', updateService); - - updateService.$inject = [ - 'horizon.app.core.openstack-service-api.magnum', - 'horizon.app.core.openstack-service-api.policy', - '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.dashboard.container-infra.clusters.resourceType', - 'horizon.dashboard.container-infra.clusters.workflow' - ]; - - function updateService( - magnum, policy, actionResult, gettext, $qExtensions, modal, toast, resourceType, workflow - ) { - - var config; - var message = { - success: gettext('Cluster %s was successfully updated.') - }; - - var service = { - perform: perform, - allowed: allowed - }; - - return service; - - ////////////// - - function perform(selected, $scope) { - config = workflow.init('update', gettext('Update Cluster'), $scope); - config.model.id = selected.id; - - // load current data - magnum.getCluster(selected.id).then(onLoad); - function onLoad(response) { - config.model.name = response.data.name - ? response.data.name : ""; - config.model.cluster_template_id = response.data.cluster_template_id - ? response.data.cluster_template_id : ""; - config.model.master_count = response.data.master_count - ? response.data.master_count : null; - config.model.node_count = response.data.node_count - ? response.data.node_count : null; - config.model.discovery_url = response.data.discovery_url - ? response.data.discovery_url : ""; - config.model.create_timeout = response.data.create_timeout - ? response.data.create_timeout : null; - config.model.keypair = response.data.keypair - ? response.data.keypair : ""; - config.model.docker_volume_size = response.data.docker_volume_size - ? response.data.docker_volume_size : ""; - config.model.master_flavor_id = response.data.master_flavor_id - ? response.data.master_flavor_id : ""; - config.model.flavor_id = response.data.flavor_id - ? response.data.flavor_id : ""; - var labels = ""; - for (var key in response.data.labels) { - if (response.data.labels.hasOwnProperty(key)) { - if (labels !== "") { - labels += ","; - } - labels += key + "=" + response.data.labels[key]; - } - } - config.model.labels = labels; - } - - return modal.open(config).then(submit); - } - - function allowed() { - return $qExtensions.booleanAsPromise(true); - } - - function submit(context) { - var id = context.model.id; - context.model = cleanNullProperties(context.model); - return magnum.updateCluster(id, context.model, true) - .then(success, true); - } - - function cleanNullProperties(model) { - // Initially clean fields that don't have any value. - // Not only "null", blank too. - for (var key in model) { - if (model.hasOwnProperty(key) && model[key] === null || model[key] === "" || - key === "tabs" || key === "id") { - delete model[key]; - } - } - return model; - } - - function success(response) { - response.data.id = response.data.uuid; - toast.add('success', interpolate(message.success, [response.data.id])); - return actionResult.getActionResult() - .updated(resourceType, response.data.id) - .result; - } - } -})(); diff --git a/magnum_ui/static/dashboard/container-infra/clusters/update/update.service.spec.js b/magnum_ui/static/dashboard/container-infra/clusters/update/update.service.spec.js deleted file mode 100644 index 3a4ef471..00000000 --- a/magnum_ui/static/dashboard/container-infra/clusters/update/update.service.spec.js +++ /dev/null @@ -1,90 +0,0 @@ -/** - * 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.update.service', function() { - - var service, $scope, $q, deferred, magnum; - var selected = { - id: 1 - }; - var model = { - id: 1, - tabs: "", - keypair_id: "", - coe: null - }; - var modal = { - open: function(config) { - config.model = model; - deferred = $q.defer(); - deferred.resolve(config); - return deferred.promise; - } - }; - var workflow = { - init: function (action, title) { - action = title; - return {model: model}; - } - }; - - /////////////////// - - beforeEach(module('horizon.app.core')); - beforeEach(module('horizon.framework')); - beforeEach(module('horizon.dashboard.container-infra.clusters')); - - beforeEach(module(function($provide) { - $provide.value('horizon.dashboard.container-infra.clusters.workflow', workflow); - $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.update.service'); - magnum = $injector.get('horizon.app.core.openstack-service-api.magnum'); - deferred = $q.defer(); - deferred.resolve({data: {uuid: 1, labels: "key1:val1,key2:val2"}}); - spyOn(magnum, 'getCluster').and.returnValue(deferred.promise); - spyOn(magnum, 'updateCluster').and.returnValue(deferred.promise); - spyOn(workflow, 'init').and.returnValue({model: model}); - 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('open the modal', inject(function($timeout) { - service.perform(selected, $scope); - - expect(workflow.init).toHaveBeenCalled(); - - expect(modal.open).toHaveBeenCalledWith({model: model}); - - $timeout.flush(); - $scope.$apply(); - - expect(magnum.updateCluster).toHaveBeenCalled(); - })); - }); -})(); diff --git a/magnum_ui/static/dashboard/container-infra/clusters/workflow/advanced.help.html b/magnum_ui/static/dashboard/container-infra/clusters/workflow/advanced.help.html new file mode 100644 index 00000000..9de628ee --- /dev/null +++ b/magnum_ui/static/dashboard/container-infra/clusters/workflow/advanced.help.html @@ -0,0 +1,5 @@ +

Additional Labels

+ +

Specify additional kube_labels to apply to the cluster or override labels set by the cluster template. Overriding labels set by the cluster template may result in your cluster being misconfigured, unstable or unable to be created.

+ +

The key=value pair string is case insensitive and will be converted to lower case.

\ No newline at end of file diff --git a/magnum_ui/static/dashboard/container-infra/clusters/workflow/cluster-template.controller.js b/magnum_ui/static/dashboard/container-infra/clusters/workflow/cluster-template.controller.js index aba86aba..674c4f87 100644 --- a/magnum_ui/static/dashboard/container-infra/clusters/workflow/cluster-template.controller.js +++ b/magnum_ui/static/dashboard/container-infra/clusters/workflow/cluster-template.controller.js @@ -67,19 +67,60 @@ } function onGetClusterTemplate(response) { - ctrl.clusterTemplate = response.data; - if ($scope.model.keypair === "") { - if (response.data.keypair_id === null) { - $scope.model.keypair = ""; - } else { - $scope.model.keypair = response.data.keypair_id; + var MODEL_DEFAULTS = $scope.model.DEFAULTS; + var template = response.data; + + ctrl.clusterTemplate = template; + + // master_lb_enabled=false? Only allow a single Master Node + $scope.model.isSingleMasterNode = template.hasOwnProperty('master_lb_enabled') && + template.master_lb_enabled === false; + $scope.model.master_count = $scope.model.isSingleMasterNode ? 1 : $scope.model.master_count; + + // Only alter the model if the value is default and exists in the response + // Warning: This is loosely coupled with default states. + // Sets response.key -> model.key + setResponseAsDefaultIfUnset('keypair_id', 'keypair'); + setResponseAsDefaultIfUnset('master_count', 'master_count'); + setResponseAsDefaultIfUnset('master_flavor_id', 'master_flavor_id'); + setResponseAsDefaultIfUnset('node_count', 'node_count'); + setResponseAsDefaultIfUnset('flavor_id', 'flavor_id'); + + if (template.floating_ip_enabled !== null) { + $scope.model.floating_ip_enabled = template.floating_ip_enabled; + } + + if (!template.labels) { return; } + + $scope.model.templateLabels = template.labels; + + // If a template label exists as a field on the form -> Set it as a default + setLabelResponseAsDefault('auto_scaling_enabled', 'auto_scaling_enabled', true); + setLabelResponseAsDefault('auto_healing_enabled', 'auto_healing_enabled', true); + + // Set default `ingress_controller` based on its label + if (template.labels.ingress_controller !== null && + $scope.model.ingressControllers && $scope.model.ingressControllers.length > 0) { + $scope.model.ingress_controller = MODEL_DEFAULTS.ingress_controller; + $scope.model.ingressControllers.forEach(function(controller) { + if (controller.labels && controller.labels.ingress_controller && + controller.labels.ingress_controller === template.labels.ingress_controller) { + $scope.model.ingress_controller = controller; + } + }); + } + + function setResponseAsDefaultIfUnset(responseKey, modelKey) { + if ($scope.model[modelKey] === MODEL_DEFAULTS[modelKey] && + template[responseKey] !== null) { + $scope.model[modelKey] = template[responseKey]; } } - if ($scope.model.docker_volume_size === "") { - if (response.data.docker_volume_size === null) { - $scope.model.docker_volume_size = ""; - } else { - $scope.model.docker_volume_size = response.data.docker_volume_size; + function setLabelResponseAsDefault(labelKey, modelKey, isValueBoolean) { + if (template.labels[labelKey] !== null) { + $scope.model[modelKey] = isValueBoolean + ? template.labels[labelKey] === 'true' + : template.labels[labelKey]; } } } diff --git a/magnum_ui/static/dashboard/container-infra/clusters/workflow/cluster-template.controller.spec.js b/magnum_ui/static/dashboard/container-infra/clusters/workflow/cluster-template.controller.spec.js index f8fb91c6..fcfb15c4 100644 --- a/magnum_ui/static/dashboard/container-infra/clusters/workflow/cluster-template.controller.spec.js +++ b/magnum_ui/static/dashboard/container-infra/clusters/workflow/cluster-template.controller.spec.js @@ -15,7 +15,42 @@ (function() { 'use strict'; describe('horizon.dashboard.container-infra.clusters', function() { - var magnum, controller, $scope, $q, deferred; + var magnum, controller, $scope, $q, deferred, templateResponse, MODEL_DEFAULTS; + + function getModelDefaults() { + return { + // Props used by the form + name: '', + cluster_template_id: '', + availability_zone: '', + keypair: '', + addons: [], + + master_count: null, + master_flavor_id: '', + node_count: null, + flavor_id: '', + auto_scaling_enabled: false, + min_node_count: null, + max_node_count: null, + + create_network: false, + fixed_network: '', + floating_ip_enabled: false, + ingress_controller: '', + + auto_healing_enabled: true, + labels: '', + override_labels: false, + + // Utility properties (not actively used in the form, + // populated dynamically) + id: null, + templateLabels: null, + ingressControllers: null, + isSingleMasterNode: false + }; + } beforeEach(module('horizon.framework')); beforeEach(module('horizon.app.core.openstack-service-api')); @@ -24,14 +59,63 @@ beforeEach(inject(function ($injector, _$rootScope_, _$q_) { $q = _$q_; $scope = _$rootScope_.$new(); - $scope.model = { - cluster_template_id: '1', - keypair: '' + $scope.model = getModelDefaults(); + + MODEL_DEFAULTS = getModelDefaults(); + // Trigger the controller's business logic + $scope.model.cluster_template_id = '1'; + $scope.model.DEFAULTS = MODEL_DEFAULTS; + + templateResponse = { + "coe": "kubernetes", + "docker_storage_driver": "overlay2", + "docker_volume_size": 20, + "external_network_id": "f10ad6de-a26d-4c29-8c64-2a7418d47f8f", + "fixed_network": null, + "fixed_subnet": null, + "flavor_id": "c1.c4r8", + "floating_ip_enabled": false, + "id": "6f3869a2-4cff-4e59-9e8e-ee03efa26688", + "image_id": "2beb7301-e8c8-4ac1-a321-c63e919094a9", + "insecure_registry": null, + "keypair_id": null, + "labels": { + "auto_healing_controller": "magnum-auto-healer", + "auto_healing_enabled": "true", + "auto_scaling_enabled": "false", + "cloud_provider_enabled": "true", + "cloud_provider_tag": "1.14.0-catalyst", + "container_infra_prefix": "docker.io/catalystcloud/", + "etcd_volume_size": "20", + "heat_container_agent_tag": "stein-dev", + "ingress_controller": "octavia", + "k8s_keystone_auth_tag": "v1.15.0", + "keystone_auth_enabled": "true", + "kube_dashboard_enabled": "true", + "kube_tag": "v1.15.6", + "magnum_auto_healer_tag": "v1.15.0-catalyst.0", + "master_lb_floating_ip_enabled": "false", + "octavia_ingress_controller_tag": "1.14.0-catalyst", + "prometheus_monitoring": "true" + }, + "master_flavor_id": "c1.c2r4", + "master_lb_enabled": true, + "name": "kubernetes-v1.15.6-prod-20191129", + "network_driver": "calico", + "no_proxy": null, + "project_id": "94b566de52f9423fab80ceee8c0a4a23", + "public": true, + "registry_enabled": false, + "server_type": "vm", + "tls_disabled": false, + "user_id": "098b4de3d94649f8b9ae5bf5ee59451c", + "volume_driver": "cinder" }; + magnum = $injector.get('horizon.app.core.openstack-service-api.magnum'); controller = $injector.get('$controller'); deferred = $q.defer(); - deferred.resolve({data: {keypair_id: '1'}}); + deferred.resolve({data: templateResponse}); spyOn(magnum, 'getClusterTemplate').and.returnValue(deferred.promise); createController($scope); })); @@ -49,14 +133,127 @@ expect(magnum.getClusterTemplate).toHaveBeenCalled(); }); - it('should keypair is changed by cluster template\'s keypair', function() { - $scope.model.cluster_template_id = '1'; - $scope.$apply(); - expect($scope.model.keypair).toBe('1'); + it('should override some model default properties by values from ' + + 'retrieved cluster template', function() { + templateResponse.keypair_id = 1; + templateResponse.master_count = 1; + templateResponse.master_flavor_id = 'ABC'; + templateResponse.node_count = 1; + templateResponse.flavor_id = 'ABC'; - $scope.model.cluster_template_id = ''; + var model = $scope.model; + model.cluster_template_id = '99'; // Triggers bussines logic revalidation $scope.$apply(); - expect($scope.model.keypair).toBe(''); + + expect(model.keypair).toBe(1); + expect(model.master_count).toBe(1); + expect(model.master_flavor_id).toEqual('ABC'); + expect(model.node_count).toBe(1); + expect(model.flavor_id).toEqual('ABC'); + }); + + it('should not override some non-default model properties by values ' + + 'from retrieved cluster template', function() { + var model = $scope.model; + + model.keypair = 99; + model.master_count = 99; + model.master_flavor_id = 'XYZ'; + model.node_count = 99; + model.flavor_id = 'XYZ'; + + templateResponse.keypair_id = 1; + templateResponse.master_count = 1; + templateResponse.master_flavor_id = 'ABC'; + templateResponse.node_count = 1; + templateResponse.flavor_id = 'ABC'; + + model.cluster_template_id = '99'; // Triggers bussines logic revalidation + $scope.$apply(); + + expect(model.keypair).toBe(99); + expect(model.master_count).toBe(99); + expect(model.master_flavor_id).toEqual('XYZ'); + expect(model.node_count).toBe(99); + expect(model.flavor_id).toEqual('XYZ'); + }); + + it('should set number of Master Nodes to 1 if the cluster template ' + + 'response contains negative `master_lb_enabled` flag', function() { + $scope.model.master_count = 99; + templateResponse.master_lb_enabled = false; + $scope.model.cluster_template_id = '99'; // Triggers bussines logic revalidation + $scope.$apply(); + expect($scope.model.master_count).toBe(1); + + $scope.model.master_count = MODEL_DEFAULTS.master_count; + $scope.model.cluster_template_id = '999'; // Triggers bussines logic revalidation + $scope.$apply(); + expect($scope.model.master_count).toBe(1); + }); + + it('should not process labels if they are not available in the cluster ' + + 'template response', function() { + templateResponse.labels = null; + $scope.model.labels = MODEL_DEFAULTS.labels; + $scope.model.cluster_template_id = '99'; // Triggers bussines logic revalidation + $scope.$apply(); + + expect($scope.model.labels).toEqual(MODEL_DEFAULTS.labels); + }); + + it('should always override some model properties by values from ' + + 'retrieved cluster template', function() { + $scope.model.floating_ip_enabled = !MODEL_DEFAULTS.floating_ip_enabled; + templateResponse.floating_ip_enabled = !$scope.model.floating_ip_enabled; + $scope.model.cluster_template_id = '99'; // Triggers bussines logic revalidation + $scope.$apply(); + + expect($scope.model.floating_ip_enabled).toBe(templateResponse.floating_ip_enabled); + }); + + it('should always override some model\'s properties by values from ' + + 'retrieved cluster template\'s labels', function() { + var model = $scope.model; + + model.auto_scaling_enabled = true; + templateResponse.labels.auto_scaling_enabled = 'true'; + model.auto_healing_enabled = true; + templateResponse.labels.auto_healing_enabled = 'false'; + model.cluster_template_id = '99'; // Triggers bussines logic revalidation + $scope.$apply(); + + expect(model.auto_scaling_enabled).toBe(true); + expect(model.auto_healing_enabled).toBe(false); + }); + + it('should not fail if the cluster template response is empty', function() { + templateResponse = {}; + $scope.model.cluster_template_id = '99'; // Triggers bussines logic revalidation + $scope.$apply(); + }); + + it('should not fail if the cluster template\'s labels are empty', function() { + templateResponse = {labels:{}}; + $scope.model.cluster_template_id = '99'; // Triggers bussines logic revalidation + $scope.$apply(); + }); + + it('should set the correct Ingress Controller on the model based on the ' + + 'label in cluster template response', function() { + // Controllers retrieved from the API + $scope.model.ingressControllers = [ + { name: 'Controller1', labels: { ingress_controller: 'c1'}}, + { name: 'Controller2', labels: { ingress_controller: 'c2'}}, + { name: 'Controller3', labels: { ingress_controller: 'c3'}}, + ]; + + templateResponse.labels.ingress_controller = 'c2'; + + $scope.model.cluster_template_id = '99'; // Triggers bussines logic revalidation + $scope.$apply(); + + expect($scope.model.ingress_controller.labels.ingress_controller).toBe('c2'); }); }); })(); diff --git a/magnum_ui/static/dashboard/container-infra/clusters/workflow/details.help.html b/magnum_ui/static/dashboard/container-infra/clusters/workflow/details.help.html new file mode 100644 index 00000000..8a75f2ad --- /dev/null +++ b/magnum_ui/static/dashboard/container-infra/clusters/workflow/details.help.html @@ -0,0 +1,2 @@ +

Addon software

+

Any addon software selected will be installed at the latest supported version when the cluster is deployed, but will not be upgraded automatically by rolling upgrades.

\ No newline at end of file diff --git a/magnum_ui/static/dashboard/container-infra/clusters/workflow/info.help.html b/magnum_ui/static/dashboard/container-infra/clusters/workflow/info.help.html deleted file mode 100644 index e3490a3a..00000000 --- a/magnum_ui/static/dashboard/container-infra/clusters/workflow/info.help.html +++ /dev/null @@ -1 +0,0 @@ -

Specify cluster name and choose cluster template

diff --git a/magnum_ui/static/dashboard/container-infra/clusters/workflow/labels.help.html b/magnum_ui/static/dashboard/container-infra/clusters/workflow/labels.help.html deleted file mode 100644 index 03fc98b9..00000000 --- a/magnum_ui/static/dashboard/container-infra/clusters/workflow/labels.help.html +++ /dev/null @@ -1 +0,0 @@ -

Arbitrary labels in the form of key=value pairs to associate with a clusters. May be used multiple times.

\ No newline at end of file diff --git a/magnum_ui/static/dashboard/container-infra/clusters/workflow/management.help.html b/magnum_ui/static/dashboard/container-infra/clusters/workflow/management.help.html new file mode 100644 index 00000000..09e889c5 --- /dev/null +++ b/magnum_ui/static/dashboard/container-infra/clusters/workflow/management.help.html @@ -0,0 +1,2 @@ +

Security Updates

+

Please note that updates may cause application downtime if workloads deployed to Kubernetes are not following the best practices outlined in the documentation (for example, not using multiple replicas).

\ No newline at end of file diff --git a/magnum_ui/static/dashboard/container-infra/clusters/workflow/misc.help.html b/magnum_ui/static/dashboard/container-infra/clusters/workflow/misc.help.html deleted file mode 100644 index 15ba05e4..00000000 --- a/magnum_ui/static/dashboard/container-infra/clusters/workflow/misc.help.html +++ /dev/null @@ -1,9 +0,0 @@ -

Specify conditions for cluster creation.

-
-
Keypair
-
- When the selected cluster template contains keypair, user can either provide a new keypair - for the cluster or inherit one from the cluster template. When the selected cluster template - has no keypair attached, user has to provide a keypair for the cluster. -
-
\ No newline at end of file diff --git a/magnum_ui/static/dashboard/container-infra/clusters/workflow/network.help.html b/magnum_ui/static/dashboard/container-infra/clusters/workflow/network.help.html new file mode 100644 index 00000000..e73ea502 --- /dev/null +++ b/magnum_ui/static/dashboard/container-infra/clusters/workflow/network.help.html @@ -0,0 +1,4 @@ +

Cluster API

+ +

Making the Kubernetes API accessible from the private network only is the most secure option (the default), but access will be limited to compute instances on the same private network or a VPN to that network.

+

Making the Kubernetes API accessible from anywhere on the public internet is convenient, but may represent a security risk. [When selecting this option, it is recommended to limit access to a trusted IP address range.]

\ No newline at end of file diff --git a/magnum_ui/static/dashboard/container-infra/clusters/workflow/size.help.html b/magnum_ui/static/dashboard/container-infra/clusters/workflow/size.help.html index cd4c6fe8..52a4968a 100644 --- a/magnum_ui/static/dashboard/container-infra/clusters/workflow/size.help.html +++ b/magnum_ui/static/dashboard/container-infra/clusters/workflow/size.help.html @@ -1 +1,6 @@ -

Specify the number of master nodes, cluster nodes and docker volume size for the cluster.

+

Auto Scaling

+ +

If enabled, the minimum and maximum number of worker nodes must be specified.

+

Auto scaling requires the use of CPU and memory limits on the resource definition of Pods.

+

If Kubernetes is unable to schedule a Pod due to insuficient CPU or memory in the cluster, a worker node will be added, as long as the maximum number of worker nodes has not been reached.

+

If the aggregate resource limits of all existing Pods is lower than 50% of the cluster capacity, a worker node will be removed, as long as the minimum number of worker nodes has not been reached.

diff --git a/magnum_ui/static/dashboard/container-infra/clusters/workflow/workflow.service.js b/magnum_ui/static/dashboard/container-infra/clusters/workflow/workflow.service.js index f1355226..7a384df2 100644 --- a/magnum_ui/static/dashboard/container-infra/clusters/workflow/workflow.service.js +++ b/magnum_ui/static/dashboard/container-infra/clusters/workflow/workflow.service.js @@ -17,6 +17,15 @@ (function() { 'use strict'; + /** + * @ngdoc overview + * @name horizon.dashboard.container-infra.clusters.workflow + * @ngModule + * + * @description + * Provides business logic for Cluster creation workflow, including data model, + * UI form schema and configuration, fetching and processing of required data. + */ angular .module('horizon.dashboard.container-infra.clusters') .factory( @@ -24,225 +33,424 @@ ClusterWorkflow); ClusterWorkflow.$inject = [ + '$q', 'horizon.dashboard.container-infra.basePath', - 'horizon.app.core.workflow.factory', 'horizon.framework.util.i18n.gettext', 'horizon.app.core.openstack-service-api.magnum', + 'horizon.app.core.openstack-service-api.neutron', 'horizon.app.core.openstack-service-api.nova' ]; - function ClusterWorkflow(basePath, workflowService, gettext, magnum, nova) { + // comma-separated key=value with optional space after comma + var REGEXP_KEY_VALUE = /^(\w+=[^,]+,?\s?)+$/; + + function ClusterWorkflow($q, basePath, gettext, magnum, neutron, nova) { var workflow = { init: init }; - function init(action, title, $scope) { - var schema, form, model, nflavors, mflavors; - var clusterTemplates = [{value:"", name: gettext("Choose a Cluster Template")}]; - var keypairs = [{value:"", name: gettext("Choose a Keypair")}]; - var dockerVolumeSizeDescription = gettext( - "If not specified, the value specified in Cluster Template will be used."); + function init(title, $scope) { + var schema, form; + + // Default