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/
This commit is contained in:
Simon Merrick 2019-09-12 17:05:16 +12:00
parent cd0817a13b
commit 108c693991
23 changed files with 1172 additions and 474 deletions

View File

@ -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 This filtering is only relevant when choosing a new template for
upgrading a cluster. 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 <https://docs.openstack.org/horizon/latest/configuration/index.html>`__
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 <https://docs.openstack.org/magnum/latest/user/index.html#kube-dashboard-enabled>`__
* `Influx Grafana Dashboard <https://docs.openstack.org/magnum/train/user/index.html#influx-grafana-dashboard-enabled>`__
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 Horizon Settings
================ ================
@ -38,4 +122,3 @@ For more configurations, see
`Configuration Guide `Configuration Guide
<https://docs.openstack.org/horizon/latest/configuration/index.html>`__ <https://docs.openstack.org/horizon/latest/configuration/index.html>`__
in the Horizon documentation. in the Horizon documentation.

View File

@ -12,9 +12,13 @@
# License for the specific language governing permissions and limitations # License for the specific language governing permissions and limitations
# under the License. # under the License.
import logging
import re
from collections import defaultdict from collections import defaultdict
from django.conf import settings from django.conf import settings
from django.http import HttpResponse
from django.http import HttpResponseNotFound from django.http import HttpResponseNotFound
from django.views import generic 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 urls
from openstack_dashboard.api.rest import utils as rest_utils from openstack_dashboard.api.rest import utils as rest_utils
import re LOG = logging.getLogger(__name__)
def change_to_id(obj): def change_to_id(obj):
@ -39,6 +43,59 @@ def change_to_id(obj):
return 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 @urls.register
class ClusterTemplate(generic.View): class ClusterTemplate(generic.View):
"""API for retrieving a single cluster template""" """API for retrieving a single cluster template"""
@ -317,7 +374,13 @@ class Quota(generic.View):
@rest_utils.ajax() @rest_utils.ajax()
def get(self, request, project_id, resource): def get(self, request, project_id, resource):
"""Get a specific quota""" """Get a specific quota"""
try:
return magnum.quotas_show(request, project_id, resource).to_dict() 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) @rest_utils.ajax(data_required=True)
def patch(self, request, project_id, resource): def patch(self, request, project_id, resource):

View File

@ -35,7 +35,6 @@
'horizon.dashboard.container-infra.clusters.create.service', 'horizon.dashboard.container-infra.clusters.create.service',
'horizon.dashboard.container-infra.clusters.delete.service', 'horizon.dashboard.container-infra.clusters.delete.service',
'horizon.dashboard.container-infra.clusters.resize.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.rolling-upgrade.service',
'horizon.dashboard.container-infra.clusters.show-certificate.service', 'horizon.dashboard.container-infra.clusters.show-certificate.service',
'horizon.dashboard.container-infra.clusters.sign-certificate.service', 'horizon.dashboard.container-infra.clusters.sign-certificate.service',
@ -49,7 +48,6 @@
createClusterService, createClusterService,
deleteClusterService, deleteClusterService,
resizeClusterService, resizeClusterService,
updateClusterService,
rollingUpgradeClusterService, rollingUpgradeClusterService,
showCertificateService, showCertificateService,
signCertificateService, signCertificateService,
@ -106,13 +104,6 @@
text: gettext('Resize Cluster') text: gettext('Resize Cluster')
} }
}) })
.append({
id: 'updateClusterAction',
service: updateClusterService,
template: {
text: gettext('Update Cluster')
}
})
.append({ .append({
id: 'rollingUpgradeClusterAction', id: 'rollingUpgradeClusterAction',
service: rollingUpgradeClusterService, service: rollingUpgradeClusterService,

View File

@ -55,11 +55,6 @@
expect(actionHasId(actions, 'resizeClusterAction')).toBe(true); 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() { it('registers Delete Cluster as an item action', function() {
var actions = registry.getResourceType('OS::Magnum::Cluster').itemActions; var actions = registry.getResourceType('OS::Magnum::Cluster').itemActions;
expect(actionHasId(actions, 'deleteClusterAction')).toBe(true); expect(actionHasId(actions, 'deleteClusterAction')).toBe(true);

View File

@ -18,7 +18,8 @@
/** /**
* @ngdoc overview * @ngdoc overview
* @name horizon.dashboard.container-infra.clusters.create.service * @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 angular
.module('horizon.dashboard.container-infra.clusters') .module('horizon.dashboard.container-infra.clusters')
@ -27,22 +28,22 @@
createService.$inject = [ createService.$inject = [
'$location', '$location',
'horizon.app.core.openstack-service-api.magnum', 'horizon.app.core.openstack-service-api.magnum',
'horizon.app.core.openstack-service-api.policy',
'horizon.framework.util.actions.action-result.service', 'horizon.framework.util.actions.action-result.service',
'horizon.framework.util.i18n.gettext', 'horizon.framework.util.i18n.gettext',
'horizon.framework.util.q.extensions', 'horizon.framework.util.q.extensions',
'horizon.framework.widgets.form.ModalFormService', 'horizon.framework.widgets.form.ModalFormService',
'horizon.framework.widgets.toast.service', 'horizon.framework.widgets.toast.service',
'horizon.framework.widgets.modal-wait-spinner.service',
'horizon.dashboard.container-infra.clusters.resourceType', 'horizon.dashboard.container-infra.clusters.resourceType',
'horizon.dashboard.container-infra.clusters.workflow' 'horizon.dashboard.container-infra.clusters.workflow'
]; ];
function createService( function createService(
$location, magnum, policy, actionResult, gettext, $qExtensions, modal, toast, $location, magnum, actionResult, gettext, $qExtensions, modal, toast, spinnerModal,
resourceType, workflow resourceType, workflow
) { ) {
var config; var modalConfig;
var message = { var message = {
success: gettext('Cluster %s was successfully created.') success: gettext('Cluster %s was successfully created.')
}; };
@ -57,44 +58,133 @@
////////////// //////////////
function perform(selected, $scope) { function perform(selected, $scope) {
config = workflow.init('create', gettext('Create'), $scope); spinnerModal.showModalSpinner(gettext('Loading'));
if (typeof selected !== 'undefined') {
config.model.cluster_template_id = selected.id; 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() { function allowed() {
return $qExtensions.booleanAsPromise(true); return $qExtensions.booleanAsPromise(true);
} }
function submit(context) { function onModalSubmit(context) {
context.model = cleanNullProperties(context.model); return magnum.createCluster(buildRequestObject(context.model), false)
return magnum.createCluster(context.model, false).then(success, true); .then(onRequestSuccess, true);
} }
function cleanNullProperties(model) { function buildRequestObject(model) {
// Initially clean fields that don't have any value. var MODEL_DEFAULTS = model.DEFAULTS;
// Not only "null", blank too. var requestLabels = {};
for (var key in model) {
if (model.hasOwnProperty(key) && model[key] === null || model[key] === "" || var requestObject = {
key === "tabs") { // Defaults required by the endpoint
delete model[key]; 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):
return model; // Cluster Templates -> Create Form -> User-defined in 'labels' textarea
// 1) Cluster Templates labels
if (model.templateLabels) {
angular.extend(requestLabels, model.templateLabels);
} }
function success(response) { // 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.'));
}
}
// 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 onRequestSuccess(response) {
response.data.id = response.data.uuid; response.data.id = response.data.uuid;
toast.add('success', interpolate(message.success, [response.data.id])); toast.add('success', interpolate(message.success, [response.data.id]));
var result = actionResult.getActionResult() 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) { if (result.result.failed.length === 0 && result.result.created.length > 0) {
$location.path("/project/clusters"); $location.path('/project/clusters');
} else { }
return result.result; return result.result;
} }
} }
}
})(); })();

View File

@ -19,15 +19,26 @@
describe('horizon.dashboard.container-infra.clusters.create.service', function() { 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 = { 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 = { var modal = {
open: function(config) { open: function(config) {
config.model = model;
deferred = $q.defer(); deferred = $q.defer();
deferred.resolve(config); deferred.resolve(config);
modalConfig = config;
return deferred.promise; return deferred.promise;
} }
}; };
@ -48,11 +59,25 @@
service = $injector.get('horizon.dashboard.container-infra.clusters.create.service'); service = $injector.get('horizon.dashboard.container-infra.clusters.create.service');
magnum = $injector.get('horizon.app.core.openstack-service-api.magnum'); magnum = $injector.get('horizon.app.core.openstack-service-api.magnum');
workflow = $injector.get('horizon.dashboard.container-infra.clusters.workflow'); 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 = $q.defer();
deferred.resolve({data: {uuid: 1}}); 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(magnum, 'createCluster').and.returnValue(deferred.promise);
spyOn(workflow, 'init').and.returnValue(configDeferred.promise);
spyOn(modal, 'open').and.callThrough(); 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() { it('should check the policy if the user is allowed to create cluster', function() {
@ -60,15 +85,36 @@
expect(allowed).toBeTruthy(); expect(allowed).toBeTruthy();
}); });
it('open the modal', inject(function($timeout) { it('should open the modal, hide the loading spinner and have valid ' +
service.perform(model, $scope); 'form model', inject(function($timeout) {
service.perform(null, $scope);
$timeout(function() {
expect(modal.open).toHaveBeenCalled(); 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(); $timeout.flush();
$scope.$apply(); $scope.$apply();
}));
expect(magnum.createCluster).toHaveBeenCalled(); 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();
})); }));
}); });
})(); })();

View File

@ -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;
}
}
})();

View File

@ -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();
}));
});
})();

View File

@ -0,0 +1,5 @@
<h1 class="h4" translate>Additional Labels</h1>
<p translate>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.</p>
<p translate>The key=value pair string is case insensitive and will be converted to lower case.</p>

View File

@ -67,19 +67,60 @@
} }
function onGetClusterTemplate(response) { function onGetClusterTemplate(response) {
ctrl.clusterTemplate = response.data; var MODEL_DEFAULTS = $scope.model.DEFAULTS;
if ($scope.model.keypair === "") { var template = response.data;
if (response.data.keypair_id === null) {
$scope.model.keypair = ""; ctrl.clusterTemplate = template;
} else {
$scope.model.keypair = response.data.keypair_id; // 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 === "") { function setLabelResponseAsDefault(labelKey, modelKey, isValueBoolean) {
if (response.data.docker_volume_size === null) { if (template.labels[labelKey] !== null) {
$scope.model.docker_volume_size = ""; $scope.model[modelKey] = isValueBoolean
} else { ? template.labels[labelKey] === 'true'
$scope.model.docker_volume_size = response.data.docker_volume_size; : template.labels[labelKey];
} }
} }
} }

View File

@ -15,7 +15,42 @@
(function() { (function() {
'use strict'; 'use strict';
describe('horizon.dashboard.container-infra.clusters', function() { 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.framework'));
beforeEach(module('horizon.app.core.openstack-service-api')); beforeEach(module('horizon.app.core.openstack-service-api'));
@ -24,14 +59,63 @@
beforeEach(inject(function ($injector, _$rootScope_, _$q_) { beforeEach(inject(function ($injector, _$rootScope_, _$q_) {
$q = _$q_; $q = _$q_;
$scope = _$rootScope_.$new(); $scope = _$rootScope_.$new();
$scope.model = { $scope.model = getModelDefaults();
cluster_template_id: '1',
keypair: '' 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'); magnum = $injector.get('horizon.app.core.openstack-service-api.magnum');
controller = $injector.get('$controller'); controller = $injector.get('$controller');
deferred = $q.defer(); deferred = $q.defer();
deferred.resolve({data: {keypair_id: '1'}}); deferred.resolve({data: templateResponse});
spyOn(magnum, 'getClusterTemplate').and.returnValue(deferred.promise); spyOn(magnum, 'getClusterTemplate').and.returnValue(deferred.promise);
createController($scope); createController($scope);
})); }));
@ -49,14 +133,127 @@
expect(magnum.getClusterTemplate).toHaveBeenCalled(); expect(magnum.getClusterTemplate).toHaveBeenCalled();
}); });
it('should keypair is changed by cluster template\'s keypair', function() { it('should override some model default properties by values from ' +
$scope.model.cluster_template_id = '1'; 'retrieved cluster template', function() {
$scope.$apply(); templateResponse.keypair_id = 1;
expect($scope.model.keypair).toBe('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(); $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');
}); });
}); });
})(); })();

View File

@ -0,0 +1,2 @@
<h1 class="h4" translate>Addon software</h1>
<p translate>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.</p>

View File

@ -1 +0,0 @@
<p translate>Specify cluster name and choose cluster template</p>

View File

@ -1 +0,0 @@
<p translate>Arbitrary labels in the form of key=value pairs to associate with a clusters. May be used multiple times.</p>

View File

@ -0,0 +1,2 @@
<h1 class="h4" translate>Security Updates</h1>
<p translate>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).</p>

View File

@ -1,9 +0,0 @@
<p translate>Specify conditions for cluster creation.</p>
<dl>
<dt>Keypair</dt>
<dd>
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.
</dd>
</dl>

View File

@ -0,0 +1,4 @@
<h1 class="h4" translate>Cluster API</h1>
<p translate>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.</p>
<p translate>Making the Kubernetes API accessible from anywhere on the public internet is convenient, but may represent a security risk. <em>[When selecting this option, it is recommended to limit access to a trusted IP address range.]</em></p>

View File

@ -1 +1,6 @@
<p translate>Specify the number of master nodes, cluster nodes and docker volume size for the cluster.</p> <h1 class="h4" translate>Auto Scaling</h1>
<p translate>If enabled, the minimum and maximum number of worker nodes must be specified.</p>
<p translate>Auto scaling requires the use of CPU and memory limits on the resource definition of Pods.</p>
<p translate>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.</p>
<p translate>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.</p>

View File

@ -17,6 +17,15 @@
(function() { (function() {
'use strict'; '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 angular
.module('horizon.dashboard.container-infra.clusters') .module('horizon.dashboard.container-infra.clusters')
.factory( .factory(
@ -24,225 +33,424 @@
ClusterWorkflow); ClusterWorkflow);
ClusterWorkflow.$inject = [ ClusterWorkflow.$inject = [
'$q',
'horizon.dashboard.container-infra.basePath', 'horizon.dashboard.container-infra.basePath',
'horizon.app.core.workflow.factory',
'horizon.framework.util.i18n.gettext', 'horizon.framework.util.i18n.gettext',
'horizon.app.core.openstack-service-api.magnum', 'horizon.app.core.openstack-service-api.magnum',
'horizon.app.core.openstack-service-api.neutron',
'horizon.app.core.openstack-service-api.nova' '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 = { var workflow = {
init: init init: init
}; };
function init(action, title, $scope) { function init(title, $scope) {
var schema, form, model, nflavors, mflavors; var schema, form;
var clusterTemplates = [{value:"", name: gettext("Choose a Cluster Template")}];
var keypairs = [{value:"", name: gettext("Choose a Keypair")}]; // Default <option>s; will be shown in selector as a placeholder
var dockerVolumeSizeDescription = gettext( var templateTitleMap = [{value: '', name: gettext('Choose a Cluster Template') }];
"If not specified, the value specified in Cluster Template will be used."); var availabilityZoneTitleMap = [{value: '',
name: gettext('Choose an Availability Zone')}];
var keypairsTitleMap = [{value: '', name: gettext('Choose a Keypair')}];
var masterFlavorTitleMap = [{value: '',
name: gettext('Choose a Flavor for the Master Node')}];
var workerFlavorTitleMap = [{value: '',
name: gettext('Choose a Flavor for the Worker Node')}];
var networkTitleMap = [{value: '', name: gettext('Choose an existing network')}];
var ingressTitleMap = [{value: '', name: gettext('Choose an ingress controller')}];
var addonsTitleMap = [];
var MODEL_DEFAULTS = getModelDefaults();
var model = getModelDefaults();
// schema
schema = { schema = {
type: 'object', type: 'object',
properties: { properties: {
'name': { 'name': { type: 'string' },
title: gettext('Cluster Name'), 'cluster_template_id': { type: 'string' },
type: 'string' 'availability_zone': { type: 'string' },
}, 'keypair': { type: 'string' },
'cluster_template_id': { 'addons': {
title: gettext('Cluster Template'), type: 'array',
type: 'string' items: { type: 'object' },
minItems: 0
}, },
'master_count': { 'master_count': {
title: gettext('Master Count'),
type: 'number', type: 'number',
minimum: 1 minimum: 1
}, },
'master_flavor_id': { type: 'string' },
'node_count': { 'node_count': {
title: gettext('Node Count'),
type: 'number', type: 'number',
minimum: 1 minimum: 1
}, },
'discovery_url': { 'flavor_id': { type: 'string' },
title: gettext('Discovery URL'), 'auto_scaling_enabled': { type: 'boolean' },
type: 'string' 'min_node_count': {
},
'create_timeout': {
title: gettext('Timeout'),
type: 'number', type: 'number',
minimum: 0 minimum: 1
}, },
'keypair': { 'max_node_count': { type: 'number' },
title: gettext('Keypair'),
type: 'string' 'create_network': { type: 'boolean' },
}, 'fixed_network': { type: 'string' },
'docker_volume_size': { 'floating_ip_enabled': { type: 'boolean' },
title: gettext('Docker Volume Size (GB)'), 'ingress_controller': { type: 'object' },
type: 'number'
}, 'auto_healing_enabled': { type: 'boolean' },
'master_flavor_id': {
title: gettext('Master Flavor ID'), 'labels': { type: 'string' },
type: 'string' 'override_labels': { type: 'boolean' }
},
'flavor_id': {
title: gettext('Node Flavor ID'),
type: 'string'
},
'rollback': {
title: gettext('Rollback cluster on update failure'),
type: 'boolean'
},
'labels': {
title: gettext('Labels'),
type: 'string'
}
} }
}; };
// form var formMasterCount = {
key: 'master_count',
title: gettext('Number of Master Nodes'),
placeholder: gettext('The number of master nodes for the cluster'),
required: true
};
// Disable the Master Count field, if only a single master is allowed
var isSingleMasterNodeWatcher = $scope.$watch(
function() { return model.isSingleMasterNode; },
function(isSingle) {
if (typeof isSingle !== 'undefined') {
formMasterCount.readonly = isSingle;
}
},
true);
form = [ form = [
{ {
type:'tabs', type:'tabs',
tabs: [ tabs: [
{ {
title: gettext('Info'), title: gettext('Details'),
help: basePath + 'clusters/workflow/info.help.html', help: basePath + 'clusters/workflow/details.help.html',
type: 'section', type: 'section',
htmlClass: 'row', htmlClass: 'row',
required: true,
items: [ items: [
{ {
type: 'section', type: 'section',
htmlClass: 'col-xs-12', htmlClass: 'col-md-8',
items: [ items: [
{ {
key: 'name', key: 'name',
placeholder: gettext('Name of the cluster.'), title: gettext('Cluster Name'),
readonly: action === 'update' placeholder: gettext('Name of the cluster'),
required: true
}, },
{ {
key: 'cluster_template_id', key: 'cluster_template_id',
type: 'select', type: 'select',
titleMap: clusterTemplates, title: gettext('Cluster Template'),
required: true, titleMap: templateTitleMap,
readonly: action === 'update' required: true
}, },
// Details of the chosen Cluster Template
{ {
type: 'template', type: 'template',
templateUrl: basePath + 'clusters/workflow/cluster-template.html' templateUrl: basePath + 'clusters/workflow/cluster-template.html'
},
{
key: 'availability_zone',
type: 'select',
title: gettext('Availability Zone'),
titleMap: availabilityZoneTitleMap,
required: true
},
{
key: 'keypair',
type: 'select',
title: gettext('Keypair'),
titleMap: keypairsTitleMap,
required: true,
},
{
key: 'addons',
type: 'checkboxes',
title: gettext('Addon Software'),
disableSuccessState: true,
titleMap: addonsTitleMap
} }
] ]
} }
], ]
required: true
}, },
{ {
title: gettext('Size'), title: gettext('Size'),
help: basePath + 'clusters/workflow/size.help.html', help: basePath + 'clusters/workflow/size.help.html',
type: 'section', type: 'section',
htmlClass: 'row', htmlClass: 'row',
items: [
{
type: 'section',
htmlClass: 'col-xs-12',
items: [
{
key: 'master_count',
placeholder: gettext('The number of master nodes for the cluster.'),
readonly: action === 'update'
},
{
key: 'node_count',
placeholder: gettext('The cluster node count.')
},
{
key: 'docker_volume_size',
placeholder: gettext('Specify the size in GB for the docker volume'),
description: dockerVolumeSizeDescription,
readonly: action === 'update'
},
{
key: 'rollback',
condition: action === 'create'
}
]
}
]
},
{
title: gettext('Misc'),
help: basePath + 'clusters/workflow/misc.help.html',
type: 'section',
htmlClass: 'row',
items: [
{
type: 'section',
htmlClass: 'col-xs-12',
items: [
{
key: 'discovery_url',
placeholder: gettext('Specifies custom discovery url for node discovery.'),
readonly: action === 'update'
},
{
key: 'create_timeout',
/* eslint-disable max-len */
placeholder: gettext('The timeout for cluster creation in minutes.'),
description: gettext('Set to 0 for no timeout. The default is no timeout.'),
readonly: action === 'update'
},
{
key: 'keypair',
type: 'select',
titleMap: keypairs,
required: true, required: true,
readonly: action === 'update' items: [
}
]
},
{ {
type: 'section', type: 'section',
htmlClass: 'col-xs-6', htmlClass: 'col-md-8',
items: [ items: [
{
type: 'fieldset',
title: gettext('Master Nodes'),
items: [
formMasterCount,
// Info message explaining why only single master node is enabled
{
type: 'template',
template: '<div class="alert alert-info">' +
'<span class="fa fa-info-circle"></span> ' +
gettext('The selected Cluster Template does not support ' +
'multiple master nodes.') +
'</div>',
condition: 'model.isSingleMasterNode == true'
},
{ {
key: 'master_flavor_id', key: 'master_flavor_id',
title: gettext('Flavor of Master Nodes'),
type: 'select', type: 'select',
titleMap: mflavors, titleMap: masterFlavorTitleMap,
readonly: action === 'update' required: true
} }
] ]
}, },
{ {
type: 'section', type: 'fieldset',
htmlClass: 'col-xs-6', title: gettext('Worker Nodes'),
items: [ items: [
{
key: 'node_count',
title: gettext('Number of Worker Nodes'),
placeholder: gettext('The number of worker nodes for the cluster'),
required: true,
onChange: autosetScalingModelValues
},
{ {
key: 'flavor_id', key: 'flavor_id',
title: gettext('Flavor of Worker Nodes'),
type: 'select', type: 'select',
titleMap: nflavors, titleMap: workerFlavorTitleMap,
readonly: action === 'update' required: true
} }
] ]
},
{
type: 'fieldset',
title: gettext('Auto Scaling'),
items: [
{
key: 'auto_scaling_enabled',
type: 'checkbox',
title: gettext('Auto-scale Worker Nodes'),
onChange: function(isAutoScaling) {
// Reset dependant model fields to defaults first
model.min_node_count = MODEL_DEFAULTS.min_node_count;
model.max_node_count = MODEL_DEFAULTS.max_node_count;
if (isAutoScaling) { autosetScalingModelValues(); }
} }
], },
{
key: 'min_node_count',
title: gettext('Minimum Number of Worker Nodes'),
placeholder: gettext('Minimum Number of Worker Nodes'),
validationMessage: {
101: gettext('You cannot auto-scale to less than ' +
'a single Worker Node.'),
103: gettext('The minimum number of Worker Nodes a ' +
'new cluster can auto scale to cannot exceed the ' +
'total amount of Worker Nodes.'),
maximumExceeded: gettext('A minimum number of Worker ' +
'Nodes cannot be higher than the default number of Worker Nodes.')
},
$validators: {
maximumExceeded: function(minNodeCount) {
return !model.node_count || minNodeCount <= model.node_count;
}
},
condition: 'model.auto_scaling_enabled === true',
required: true required: true
}, },
{ {
title: gettext('Labels'), key: 'max_node_count',
help: basePath + 'clusters/workflow/labels.help.html', title: gettext('Maximum number of Worker Nodes'),
placeholder: gettext('Maximum number of Worker Nodes'),
validationMessage: {
101: gettext('The maximum number of Worker Nodes a new cluster ' +
'can auto-scale to cannot be less than the total amount of ' +
'Worker Nodes.'),
minimumExceeded: gettext('The maximum number of Worker Nodes cannot ' +
'be less than the default number of Worker Nodes and 1.')
},
$validators: {
minimumExceeded: function(maxNodeCount) {
return maxNodeCount > 0 && (!model.node_count ||
maxNodeCount >= model.node_count);
}
},
condition: 'model.auto_scaling_enabled === true',
required: true
}
]
}
]
}
]
},
{
title: gettext('Network'),
help: basePath + 'clusters/workflow/network.help.html',
type: 'section',
htmlClass: 'row',
required: true,
items: [
{
type: 'section',
htmlClass: 'col-md-8',
items: [
{
type: 'fieldset',
title: gettext('Network'),
items: [
{
key: 'create_network',
title: gettext('Create New Network'),
onChange: function(isNewNetwork) {
if (isNewNetwork) {
model.fixed_network = MODEL_DEFAULTS.fixed_network;
}
}
},
{
key: 'fixed_network',
type: 'select',
title: gettext('Use an Existing Network'),
titleMap: networkTitleMap,
condition: 'model.create_network === false',
required: true
}
]
},
{
type: 'fieldset',
title: gettext('Network Access Control'),
items: [
{
key: 'floating_ip_enabled',
type: 'select',
title: gettext('Cluster API'),
titleMap: [
{value: false, name: gettext('Accessible on private network only')},
{value: true, name: gettext('Accessible on the public internet')}
]
},
// Warning message for the Cluster API
{
type: 'template',
template: '<div class="alert alert-warning">' +
'<span class="fa fa-warning"></span> ' +
gettext('It is generally not recommended to give public access.') +
'</div>',
condition: 'model.floating_ip_enabled == true'
}
]
},
{
type: 'fieldset',
title: gettext('Ingress'),
items: [
{
key: 'ingress_controller',
title: gettext('Ingress Controller'),
type: 'select',
titleMap: ingressTitleMap
}
]
}
]
}
]
},
{
title: gettext('Management'),
help: basePath + 'clusters/workflow/management.help.html',
type: 'section', type: 'section',
htmlClass: 'row', htmlClass: 'row',
items: [ items: [
{ {
type: 'section', type: 'section',
htmlClass: 'col-xs-12', htmlClass: 'col-md-8',
items: [
{
type: 'fieldset',
title: gettext('Auto Healing'),
items: [
{
key: 'auto_healing_enabled',
type: 'checkbox',
title: gettext('Automatically Repair Unhealthy Nodes')
}
]
}
]
}
]
},
{
title: gettext('Advanced'),
help: basePath + 'clusters/workflow/advanced.help.html',
type: 'section',
htmlClass: 'row',
items: [
{
type: 'section',
htmlClass: 'col-md-8',
items: [
{
type: 'fieldset',
title: gettext('Labels'),
items: [ items: [
{ {
key: 'labels', key: 'labels',
type: 'textarea', type: 'textarea',
placeholder: gettext('KEY1=VALUE1, KEY2=VALUE2...'), title: gettext('Additional Labels'),
readonly: action === 'update' placeholder: gettext('key=value,key2=value2...'),
validationMessage: {
invalidFormat: gettext('Invalid format. Must be a comma-separated ' +
'key-value string: key=value,key2=value2')
},
$validators: {
invalidFormat: function(labelsString) {
return labelsString === '' || REGEXP_KEY_VALUE.test(labelsString);
}
},
disableSuccessState: true
},
{
key: 'override_labels',
type: 'checkbox',
title: gettext('I do want to override Template and Workflow Labels'),
condition: 'model.labels !== ""',
},
// Warning message for the label override
{
type: 'template',
template: '<div class="alert alert-warning">' +
'<span class="fa fa-warning"></span> ' +
gettext('Overriding labels already defined by cluster template or' +
'workflow might result in unpredictable behaviour.') +
'</div>',
condition: 'model.override_labels == true'
}
]
} }
] ]
} }
@ -252,58 +460,160 @@
} }
]; ];
magnum.getClusterTemplates().then(onGetClusterTemplates); function getModelDefaults() {
nova.getKeypairs().then(onGetKeypairs); return {
nova.getFlavors(false, false).then(onGetFlavors); // 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: true,
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
};
}
function autosetScalingModelValues() {
var nodeCount = model.node_count;
if (nodeCount && nodeCount > 0 && model.auto_scaling_enabled) {
// Set defaults to related modal fields (have they not been changed)
if (model.min_node_count === MODEL_DEFAULTS.min_node_count) {
model.min_node_count = nodeCount > 1 ? nodeCount - 1 : 1;
} else if (nodeCount < model.min_node_count) {
model.min_node_count = nodeCount;
}
if (model.max_node_count === MODEL_DEFAULTS.max_node_count) {
model.max_node_count = nodeCount + 1;
} else if (nodeCount > model.max_node_count) {
model.max_node_count = nodeCount;
}
}
}
function onGetKeypairs(response) { function onGetKeypairs(response) {
angular.forEach(response.data.items, function(item) { var items = response.data.items;
keypairs.push({value: item.keypair.name, name: item.keypair.name});
angular.forEach(items, function(item) {
keypairsTitleMap.push({
value: item.keypair.name,
name: item.keypair.name
});
});
if (items.length === 1) {
model.keypair = items[0].keypair.name;
}
}
function onGetAvailabilityZones(response) {
angular.forEach(response.data.items, function(availabilityZone) {
availabilityZoneTitleMap.push({
value: availabilityZone.zoneName,
name: availabilityZone.zoneName
});
});
setSingleItemAsDefault(response.data.items, 'availability_zone', 'zoneName');
}
function onGetAddons(response) {
angular.forEach(response.data.addons, function(addon) {
addonsTitleMap.push({ value: addon, name: addon.name });
// Pre-selected by default
if (addon.selected) { model.addons.push(addon); }
}); });
} }
function onGetFlavors(response) { function onGetFlavors(response) {
nflavors = [{value:"", name: gettext("Choose a Flavor for the Node")}]; angular.forEach(response.data.items, function(flavor) {
mflavors = [{value:"", name: gettext("Choose a Flavor for the Master Node")}]; workerFlavorTitleMap.push({value: flavor.name, name: flavor.name});
angular.forEach(response.data.items, function(item) { masterFlavorTitleMap.push({value: flavor.name, name: flavor.name});
nflavors.push({value: item.name, name: item.name});
mflavors.push({value: item.name, name: item.name});
}); });
form[0].tabs[2].items[1].items[0].titleMap = mflavors;
form[0].tabs[2].items[2].items[0].titleMap = nflavors;
} }
function onGetClusterTemplates(response) { function onGetClusterTemplates(response) {
angular.forEach(response.data.items, function(item) { angular.forEach(response.data.items, function(clusterTemplate) {
clusterTemplates.push({value: item.id, name: item.name}); templateTitleMap.push({value: clusterTemplate.id, name: clusterTemplate.name});
}); });
} }
model = { function onGetNetworks(response) {
name: "", angular.forEach(response.data.items, function(network) {
cluster_template_id: "", networkTitleMap.push({value: network.id, name: network.name + ' (' + network.id + ')'});
master_count: null, });
node_count: null,
docker_volume_size: "",
rollback: false,
discovery_url: "",
create_timeout: null,
keypair: "",
flavor_id: "",
master_flavor_id: "",
labels: ""
};
var config = { setSingleItemAsDefault(response.data.items, 'fixed_network', 'id');
}
function onGetIngressControllers(response) {
angular.forEach(response.data.controllers, function(ingressController) {
ingressTitleMap.push({value: ingressController, name: ingressController.name});
});
model.ingressControllers = response.data.controllers;
// Set first item to defaults
if (model.ingressControllers.length > 0) {
model.ingress_controller = ingressTitleMap[1].value;
}
}
function setSingleItemAsDefault(itemsList, modelKey, itemKey) {
if (itemsList.length === 1) {
model[modelKey] = itemsList[0][itemKey];
}
}
$scope.$on('$destroy', function() {
isSingleMasterNodeWatcher();
});
// Fetch all the dependencies from APIs and return Promise
// with a form configuration object.
return $q.all([
magnum.getClusterTemplates().then(onGetClusterTemplates),
nova.getAvailabilityZones().then(onGetAvailabilityZones),
nova.getKeypairs().then(onGetKeypairs),
neutron.getNetworks().then(onGetNetworks),
magnum.getAddons().then(onGetAddons),
nova.getFlavors(false, false).then(onGetFlavors),
magnum.getIngressControllers().then(onGetIngressControllers)
]).then(function() {
$scope.model = model;
$scope.model.DEFAULTS = MODEL_DEFAULTS;
// Modal Config
return {
title: title, title: title,
schema: schema, schema: schema,
form: form, form: form,
model: model model: model
}; };
});
$scope.model = model;
return config;
} }
return workflow; return workflow;

View File

@ -18,8 +18,8 @@
'use strict'; 'use strict';
describe('horizon.dashboard.container-infra.clusters.workflow', function() { describe('horizon.dashboard.container-infra.clusters.workflow', function() {
var workflow, magnum, nova, neutron, $scope, $q, deferred, keyDeferred, controllersDeferred,
var workflow, magnum, nova, $scope, $q, deferred, keyDeferred; controllersResponse, networkDeferred, zoneDeferred, addonsResponse, addonDeferred;
beforeEach(module('horizon.app.core')); beforeEach(module('horizon.app.core'));
beforeEach(module('horizon.framework')); beforeEach(module('horizon.framework'));
@ -28,26 +28,74 @@
beforeEach(inject(function($injector, _$rootScope_, _$q_) { beforeEach(inject(function($injector, _$rootScope_, _$q_) {
$q = _$q_; $q = _$q_;
$scope = _$rootScope_.$new(); $scope = _$rootScope_.$new();
workflow = $injector.get( workflow = $injector.get(
'horizon.dashboard.container-infra.clusters.workflow'); 'horizon.dashboard.container-infra.clusters.workflow');
magnum = $injector.get('horizon.app.core.openstack-service-api.magnum'); magnum = $injector.get('horizon.app.core.openstack-service-api.magnum');
neutron = $injector.get('horizon.app.core.openstack-service-api.neutron');
nova = $injector.get('horizon.app.core.openstack-service-api.nova'); nova = $injector.get('horizon.app.core.openstack-service-api.nova');
deferred = $q.defer(); deferred = $q.defer();
deferred.resolve({data:{items:{1:{name:1},2:{name:2}}}}); deferred.resolve({data:{items:{1:{name:1},2:{name:2}}}});
keyDeferred = $q.defer(); keyDeferred = $q.defer();
keyDeferred.resolve({data:{items:{1:{keypair:{name:1}},2:{keypair:{name:2}}}}}); keyDeferred.resolve({data:{items:{1:{keypair:{name:1}},2:{keypair:{name:2}}}}});
controllersResponse = {controllers:[
{name: 'Controller1', labels:{ingress_controller:'ic1'}},
{name: 'Controller2', labels:{ingress_controller:'ic2'}},
{name: 'Controller3', labels:{ingress_controller:'ic3'}},
]};
controllersDeferred = $q.defer();
controllersDeferred.resolve({data: controllersResponse});
networkDeferred = $q.defer();
networkDeferred.resolve({data:{items:[
{id: '1', name: 'Network1'},
{id: '2', name: 'Network2'}
]}});
zoneDeferred = $q.defer();
zoneDeferred.resolve({data:{items:[
{zoneName: 'zone1'},
{zoneName: 'zone2'}
]}});
addonsResponse = {addons:[
{name: 'Addon1', labels:{}, selected: false},
{name: 'Addon2', labels:{}, selected: true},
{name: 'Addon3', labels:{}, selected: true},
]};
addonDeferred = $q.defer();
addonDeferred.resolve({data: addonsResponse});
spyOn(magnum, 'getClusterTemplates').and.returnValue(deferred.promise); spyOn(magnum, 'getClusterTemplates').and.returnValue(deferred.promise);
spyOn(magnum, 'getIngressControllers').and.returnValue(controllersDeferred.promise);
spyOn(magnum, 'getAddons').and.returnValue(addonDeferred.promise);
spyOn(nova, 'getAvailabilityZones').and.returnValue(zoneDeferred.promise);
spyOn(nova, 'getFlavors').and.returnValue(deferred.promise); spyOn(nova, 'getFlavors').and.returnValue(deferred.promise);
spyOn(nova, 'getKeypairs').and.returnValue(keyDeferred.promise); spyOn(nova, 'getKeypairs').and.returnValue(keyDeferred.promise);
spyOn(neutron, 'getNetworks').and.returnValue(networkDeferred.promise);
})); }));
it('should be init', inject(function($timeout) { it('should be initialised', inject(function($timeout) {
var config = workflow.init('create', 'Create Cluster', $scope); var config;
workflow.init('Create Cluster', $scope).then(function(conf) {
config = conf;
});
$timeout.flush(); $timeout.flush();
expect(config.title).toBeDefined(); expect(config.title).toBeDefined();
expect(config.schema).toBeDefined(); expect(config.schema).toBeDefined();
expect(config.form).toBeDefined(); expect(config.form).toBeDefined();
expect(config.model).toBeDefined(); expect(config.model).toBeDefined();
expect($scope.model).toBeDefined();
expect($scope.model.DEFAULTS).toBeDefined();
expect(config.model.ingressControllers).toBe(controllersResponse.controllers);
expect(config.model.addons.length).toBe(2);
})); }));
}); });

View File

@ -48,6 +48,8 @@
signCertificate: signCertificate, signCertificate: signCertificate,
rotateCertificate: rotateCertificate, rotateCertificate: rotateCertificate,
getStats: getStats, getStats: getStats,
getIngressControllers: getIngressControllers,
getAddons: getAddons,
getQuotas: getQuotas, getQuotas: getQuotas,
getQuota: getQuota, getQuota: getQuota,
createQuota: createQuota, createQuota: createQuota,
@ -213,6 +215,31 @@
}); });
} }
/////////////////
// Ingress //
// Controllers //
/////////////////
function getIngressControllers() {
return apiService.get('/api/container_infra/ingress_controllers/')
.error(function() {
toastService.add('error',
gettext('Unable to retrieve available ingress controllers.'));
});
}
//////////////
// Add-Ons //
//////////////
function getAddons() {
return apiService.get('/api/container_infra/available_addons/')
.error(function() {
toastService.add('error',
gettext('Unable to retrieve available add-ons.'));
});
}
////////////// //////////////
// Quotas // // Quotas //
////////////// //////////////

View File

@ -279,6 +279,18 @@
"method": "get", "method": "get",
"path": "/api/container_infra/stats/", "path": "/api/container_infra/stats/",
"error": "Unable to retrieve the stats." "error": "Unable to retrieve the stats."
},
{
"func": "getIngressControllers",
"method": "get",
"path": "/api/container_infra/ingress_controllers/",
"error": "Unable to retrieve available ingress controllers."
},
{
"func": "getAddons",
"method": "get",
"path": "/api/container_infra/available_addons/",
"error": "Unable to retrieve available add-ons."
} }
]; ];

View File

@ -0,0 +1,10 @@
---
features:
- >
Improve cluster launch workflow form.
- >
Add configuration for specifying ingress controllers and addon software
supported / available for use with clusters.
- >
Adds REST endpoints for retrieving configured ingress controllers and addon
software.