feat(k8s/application): add the ability to set the auto-scale policy of an application (#4118)

* feat(application): add horizontalpodautoscaler creation

* feat(application): Add the ability to set the auto-scale policy of an application

* feat(k8s/application): minor UI update

* fix(application): set api version and prevent to use hpa with global deployment type

* feat(settings): add a switch to enable features based on server metrics

* feat(k8s/applications): minor UI update

Co-authored-by: Anthony Lapenna <lapenna.anthony@gmail.com>
pull/4156/head
Maxime Bajeux 2020-08-05 00:08:11 +02:00 committed by GitHub
parent 909e1ef02c
commit 6756b04b67
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 534 additions and 84 deletions

View File

@ -3,8 +3,9 @@ package portainer
func KubernetesDefault() KubernetesData { func KubernetesDefault() KubernetesData {
return KubernetesData{ return KubernetesData{
Configuration: KubernetesConfiguration{ Configuration: KubernetesConfiguration{
UseLoadBalancer: false, UseLoadBalancer: false,
StorageClasses: []KubernetesStorageClassConfig{}, UseServerMetrics: false,
StorageClasses: []KubernetesStorageClassConfig{},
}, },
Snapshots: []KubernetesSnapshot{}, Snapshots: []KubernetesSnapshot{},
} }

View File

@ -337,8 +337,9 @@ type (
// KubernetesConfiguration represents the configuration of a Kubernetes endpoint // KubernetesConfiguration represents the configuration of a Kubernetes endpoint
KubernetesConfiguration struct { KubernetesConfiguration struct {
UseLoadBalancer bool `json:"UseLoadBalancer"` UseLoadBalancer bool `json:"UseLoadBalancer"`
StorageClasses []KubernetesStorageClassConfig `json:"StorageClasses"` UseServerMetrics bool `json:"UseServerMetrics"`
StorageClasses []KubernetesStorageClassConfig `json:"StorageClasses"`
} }
// KubernetesStorageClassConfig represents a Kubernetes Storage Class configuration // KubernetesStorageClassConfig represents a Kubernetes Storage Class configuration

View File

@ -260,6 +260,7 @@ class KubernetesApplicationConverter {
res.EnvironmentVariables = KubernetesApplicationHelper.generateEnvVariablesFromEnv(app.Env); res.EnvironmentVariables = KubernetesApplicationHelper.generateEnvVariablesFromEnv(app.Env);
res.PersistedFolders = KubernetesApplicationHelper.generatePersistedFoldersFormValuesFromPersistedFolders(app.PersistedFolders, persistentVolumeClaims); // generate from PVC and app.PersistedFolders res.PersistedFolders = KubernetesApplicationHelper.generatePersistedFoldersFormValuesFromPersistedFolders(app.PersistedFolders, persistentVolumeClaims); // generate from PVC and app.PersistedFolders
res.Configurations = KubernetesApplicationHelper.generateConfigurationFormValuesFromEnvAndVolumes(app.Env, app.ConfigurationVolumes, configurations); res.Configurations = KubernetesApplicationHelper.generateConfigurationFormValuesFromEnvAndVolumes(app.Env, app.ConfigurationVolumes, configurations);
res.AutoScaler = KubernetesApplicationHelper.generateAutoScalerFormValueFromHorizontalPodAutoScaler(app.AutoScaler);
if (app.ServiceType === KubernetesServiceTypes.LOAD_BALANCER) { if (app.ServiceType === KubernetesServiceTypes.LOAD_BALANCER) {
res.PublishingType = KubernetesApplicationPublishingTypes.LOAD_BALANCER; res.PublishingType = KubernetesApplicationPublishingTypes.LOAD_BALANCER;

View File

@ -9,6 +9,7 @@ import {
KubernetesApplicationConfigurationFormValueOverridenKey, KubernetesApplicationConfigurationFormValueOverridenKey,
KubernetesApplicationPersistedFolderFormValue, KubernetesApplicationPersistedFolderFormValue,
KubernetesApplicationPublishedPortFormValue, KubernetesApplicationPublishedPortFormValue,
KubernetesApplicationAutoScalerFormValue,
} from 'Kubernetes/models/application/formValues'; } from 'Kubernetes/models/application/formValues';
import { import {
KubernetesApplicationEnvConfigMapPayload, KubernetesApplicationEnvConfigMapPayload,
@ -263,6 +264,20 @@ class KubernetesApplicationHelper {
return finalRes; return finalRes;
} }
static generateAutoScalerFormValueFromHorizontalPodAutoScaler(autoScaler) {
const res = new KubernetesApplicationAutoScalerFormValue();
if (autoScaler) {
res.IsUsed = true;
res.MinReplicas = autoScaler.MinReplicas;
res.MaxReplicas = autoScaler.MaxReplicas;
res.TargetCPUUtilization = autoScaler.TargetCPUUtilization;
res.ApiVersion = autoScaler.ApiVersion;
} else {
res.ApiVersion = 'apps/v1';
}
return res;
}
/** /**
* !APPLICATION TO FORMVALUES FUNCTIONS * !APPLICATION TO FORMVALUES FUNCTIONS
*/ */

View File

@ -1,4 +1,6 @@
import * as JsonPatch from 'fast-json-patch';
import { KubernetesHorizontalPodAutoScaler } from './models'; import { KubernetesHorizontalPodAutoScaler } from './models';
import { KubernetesHorizontalPodAutoScalerCreatePayload } from './payload';
export class KubernetesHorizontalPodAutoScalerConverter { export class KubernetesHorizontalPodAutoScalerConverter {
/** /**
@ -11,7 +13,8 @@ export class KubernetesHorizontalPodAutoScalerConverter {
res.Name = data.metadata.name; res.Name = data.metadata.name;
res.MinReplicas = data.spec.minReplicas; res.MinReplicas = data.spec.minReplicas;
res.MaxReplicas = data.spec.maxReplicas; res.MaxReplicas = data.spec.maxReplicas;
res.TargetCPUUtilizationPercentage = data.spec.targetCPUUtilizationPercentage; res.TargetCPUUtilization = data.spec.targetCPUUtilizationPercentage;
if (data.spec.scaleTargetRef) { if (data.spec.scaleTargetRef) {
res.TargetEntity.ApiVersion = data.spec.scaleTargetRef.apiVersion; res.TargetEntity.ApiVersion = data.spec.scaleTargetRef.apiVersion;
res.TargetEntity.Kind = data.spec.scaleTargetRef.kind; res.TargetEntity.Kind = data.spec.scaleTargetRef.kind;
@ -20,4 +23,111 @@ export class KubernetesHorizontalPodAutoScalerConverter {
res.Yaml = yaml ? yaml.data : ''; res.Yaml = yaml ? yaml.data : '';
return res; return res;
} }
static createPayload(data) {
const payload = new KubernetesHorizontalPodAutoScalerCreatePayload();
payload.metadata.namespace = data.Namespace;
payload.metadata.name = data.TargetEntity.Name;
payload.spec.minReplicas = data.MinReplicas;
payload.spec.maxReplicas = data.MaxReplicas;
payload.spec.targetCPUUtilizationPercentage = data.TargetCPUUtilization;
payload.spec.scaleTargetRef.apiVersion = data.TargetEntity.ApiVersion;
payload.spec.scaleTargetRef.kind = data.TargetEntity.Kind;
payload.spec.scaleTargetRef.name = data.TargetEntity.Name;
return payload;
}
static patchPayload(oldScaler, newScaler) {
const oldPayload = KubernetesHorizontalPodAutoScalerConverter.createPayload(oldScaler);
const newPayload = KubernetesHorizontalPodAutoScalerConverter.createPayload(newScaler);
const payload = JsonPatch.compare(oldPayload, newPayload);
return payload;
}
static applicationFormValuesToModel(formValues, kind) {
const res = new KubernetesHorizontalPodAutoScaler();
res.Name = formValues.Name;
res.Namespace = formValues.ResourcePool.Namespace.Name;
res.MinReplicas = formValues.AutoScaler.MinReplicas;
res.MaxReplicas = formValues.AutoScaler.MaxReplicas;
res.TargetCPUUtilization = formValues.AutoScaler.TargetCPUUtilization;
res.TargetEntity.Name = formValues.Name;
res.TargetEntity.Kind = kind;
res.TargetEntity.ApiVersion = formValues.AutoScaler.ApiVersion;
return res;
}
/**
* Convertion functions to use with v2beta2 model
*/
// static apiToModel(data, yaml) {
// const res = new KubernetesHorizontalPodAutoScaler();
// res.Id = data.metadata.uid;
// res.Namespace = data.metadata.namespace;
// res.Name = data.metadata.name;
// res.MinReplicas = data.spec.minReplicas;
// res.MaxReplicas = data.spec.maxReplicas;
// res.TargetCPUUtilization = data.spec.targetCPUUtilization;
// _.forEach(data.spec.metrics, (metric) => {
// if (metric.type === 'Resource') {
// if (metric.resource.name === 'cpu') {
// res.TargetCPUUtilization = metric.resource.target.averageUtilization;
// }
// if (metric.resource.name === 'memory') {
// res.TargetMemoryValue = parseFloat(metric.resource.target.averageValue) / 1000;
// }
// }
// });
// if (data.spec.scaleTargetRef) {
// res.TargetEntity.ApiVersion = data.spec.scaleTargetRef.apiVersion;
// res.TargetEntity.Kind = data.spec.scaleTargetRef.kind;
// res.TargetEntity.Name = data.spec.scaleTargetRef.name;
// }
// res.Yaml = yaml ? yaml.data : '';
// return res;
// }
// static createPayload(data) {
// const payload = new KubernetesHorizontalPodAutoScalerCreatePayload();
// payload.metadata.namespace = data.Namespace;
// payload.metadata.name = data.TargetEntity.Name;
// payload.spec.minReplicas = data.MinReplicas;
// payload.spec.maxReplicas = data.MaxReplicas;
// if (data.TargetMemoryValue) {
// const memoryMetric = new KubernetesHorizontalPodAutoScalerMemoryMetric();
// memoryMetric.resource.target.averageValue = data.TargetMemoryValue;
// payload.spec.metrics.push(memoryMetric);
// }
// if (data.TargetCPUUtilization) {
// const cpuMetric = new KubernetesHorizontalPodAutoScalerCPUMetric();
// cpuMetric.resource.target.averageUtilization = data.TargetCPUUtilization;
// payload.spec.metrics.push(cpuMetric);
// }
// payload.spec.scaleTargetRef.apiVersion = data.TargetEntity.ApiVersion;
// payload.spec.scaleTargetRef.kind = data.TargetEntity.Kind;
// payload.spec.scaleTargetRef.name = data.TargetEntity.Name;
// return payload;
// }
// static applicationFormValuesToModel(formValues, kind) {
// const res = new KubernetesHorizontalPodAutoScaler();
// res.Name = formValues.Name;
// res.Namespace = formValues.ResourcePool.Namespace.Name;
// res.MinReplicas = formValues.AutoScaler.MinReplicas;
// res.MaxReplicas = formValues.AutoScaler.MaxReplicas;
// res.TargetCPUUtilization = formValues.AutoScaler.TargetCPUUtilization;
// if (formValues.AutoScaler.TargetMemoryValue) {
// res.TargetMemoryValue = formValues.AutoScaler.TargetMemoryValue + 'M';
// }
// res.TargetEntity.Name = formValues.Name;
// res.TargetEntity.Kind = kind;
// return res;
// }
} }

View File

@ -5,22 +5,22 @@ import { KubernetesDeployment } from 'Kubernetes/models/deployment/models';
import { KubernetesStatefulSet } from 'Kubernetes/models/stateful-set/models'; import { KubernetesStatefulSet } from 'Kubernetes/models/stateful-set/models';
import { KubernetesDaemonSet } from 'Kubernetes/models/daemon-set/models'; import { KubernetesDaemonSet } from 'Kubernetes/models/daemon-set/models';
function _getApplicationTypeString(app) {
if ((app instanceof KubernetesApplication && app.ApplicationType === KubernetesApplicationTypes.DEPLOYMENT) || app instanceof KubernetesDeployment) {
return KubernetesApplicationTypeStrings.DEPLOYMENT;
} else if ((app instanceof KubernetesApplication && app.ApplicationType === KubernetesApplicationTypes.DAEMONSET) || app instanceof KubernetesDaemonSet) {
return KubernetesApplicationTypeStrings.DAEMONSET;
} else if ((app instanceof KubernetesApplication && app.ApplicationType === KubernetesApplicationTypes.STATEFULSET) || app instanceof KubernetesStatefulSet) {
return KubernetesApplicationTypeStrings.STATEFULSET;
// } else if () { ---> TODO: refactor - handle bare pod type !
} else {
throw new PortainerError('Unable to determine application type');
}
}
export class KubernetesHorizontalPodAutoScalerHelper { export class KubernetesHorizontalPodAutoScalerHelper {
static findApplicationBoundScaler(sList, app) { static findApplicationBoundScaler(sList, app) {
const kind = _getApplicationTypeString(app); const kind = KubernetesHorizontalPodAutoScalerHelper.getApplicationTypeString(app);
return _.find(sList, (item) => item.TargetEntity.Kind === kind && item.TargetEntity.Name === app.Name); return _.find(sList, (item) => item.TargetEntity.Kind === kind && item.TargetEntity.Name === app.Name);
} }
static getApplicationTypeString(app) {
if ((app instanceof KubernetesApplication && app.ApplicationType === KubernetesApplicationTypes.DEPLOYMENT) || app instanceof KubernetesDeployment) {
return KubernetesApplicationTypeStrings.DEPLOYMENT;
} else if ((app instanceof KubernetesApplication && app.ApplicationType === KubernetesApplicationTypes.DAEMONSET) || app instanceof KubernetesDaemonSet) {
return KubernetesApplicationTypeStrings.DAEMONSET;
} else if ((app instanceof KubernetesApplication && app.ApplicationType === KubernetesApplicationTypes.STATEFULSET) || app instanceof KubernetesStatefulSet) {
return KubernetesApplicationTypeStrings.STATEFULSET;
// } else if () { ---> TODO: refactor - handle bare pod type !
} else {
throw new PortainerError('Unable to determine application type');
}
}
} }

View File

@ -7,7 +7,7 @@ const _KubernetesHorizontalPodAutoScaler = Object.freeze({
Name: '', Name: '',
MinReplicas: 1, MinReplicas: 1,
MaxReplicas: 1, MaxReplicas: 1,
TargetCPUUtilizationPercentage: undefined, TargetCPUUtilization: 0,
TargetEntity: { TargetEntity: {
ApiVersion: '', ApiVersion: '',
Kind: '', Kind: '',

View File

@ -0,0 +1,86 @@
/**
* KubernetesHorizontalPodAutoScaler Create Payload Model
*/
const _KubernetesHorizontalPodAutoScalerCreatePayload = Object.freeze({
metadata: {
namespace: '',
name: '',
},
spec: {
maxReplicas: 0,
minReplicas: 0,
targetCPUUtilizationPercentage: 0,
scaleTargetRef: {
kind: '',
name: '',
},
},
});
export class KubernetesHorizontalPodAutoScalerCreatePayload {
constructor() {
Object.assign(this, JSON.parse(JSON.stringify(_KubernetesHorizontalPodAutoScalerCreatePayload)));
}
}
/**
* KubernetesHorizontalPodAutoScaler Create Payload Model for v2beta2
* Include support of memory usage
*/
// const _KubernetesHorizontalPodAutoScalerCreatePayload = Object.freeze({
// metadata: {
// namespace: '',
// name: ''
// },
// spec: {
// maxReplicas: 0,
// minReplicas: 0,
// targetCPUUtilizationPercentage: 0,
// scaleTargetRef: {
// kind: '',
// name: ''
// },
// metrics: []
// }
// });
// export class KubernetesHorizontalPodAutoScalerCreatePayload {
// constructor() {
// Object.assign(this, JSON.parse(JSON.stringify(_KubernetesHorizontalPodAutoScalerCreatePayload)));
// }
// }
// const _KubernetesHorizontalPodAutoScalerCPUMetric = Object.freeze({
// type: 'Resource',
// resource: {
// name: 'cpu',
// target: {
// type: 'Utilization',
// averageUtilization: 0
// }
// }
// });
// export class KubernetesHorizontalPodAutoScalerCPUMetric {
// constructor() {
// Object.assign(this, JSON.parse(JSON.stringify(_KubernetesHorizontalPodAutoScalerCPUMetric)));
// }
// }
// const _KubernetesHorizontalPodAutoScalerMemoryMetric = Object.freeze({
// type: 'Resource',
// resource: {
// name: 'memory',
// target: {
// type: 'AverageValue',
// averageValue: ''
// }
// }
// });
// export class KubernetesHorizontalPodAutoScalerMemoryMetric {
// constructor() {
// Object.assign(this, JSON.parse(JSON.stringify(_KubernetesHorizontalPodAutoScalerMemoryMetric)));
// }
// }

View File

@ -12,10 +12,10 @@ class KubernetesHorizontalPodAutoScalerService {
this.getAsync = this.getAsync.bind(this); this.getAsync = this.getAsync.bind(this);
this.getAllAsync = this.getAllAsync.bind(this); this.getAllAsync = this.getAllAsync.bind(this);
// this.createAsync = this.createAsync.bind(this); this.createAsync = this.createAsync.bind(this);
// this.patchAsync = this.patchAsync.bind(this); this.patchAsync = this.patchAsync.bind(this);
// this.rollbackAsync = this.rollbackAsync.bind(this); // this.rollbackAsync = this.rollbackAsync.bind(this);
// this.deleteAsync = this.deleteAsync.bind(this); this.deleteAsync = this.deleteAsync.bind(this);
} }
/** /**
@ -53,65 +53,65 @@ class KubernetesHorizontalPodAutoScalerService {
return this.$async(this.getAllAsync, namespace); return this.$async(this.getAllAsync, namespace);
} }
// /** /**
// * CREATE * CREATE
// */ */
// async createAsync(horizontalPodAutoScaler) { async createAsync(horizontalPodAutoScaler) {
// try { try {
// const params = {}; const params = {};
// const payload = KubernetesHorizontalPodAutoScalerConverter.createPayload(horizontalPodAutoScaler); const payload = KubernetesHorizontalPodAutoScalerConverter.createPayload(horizontalPodAutoScaler);
// const namespace = payload.metadata.namespace; const namespace = payload.metadata.namespace;
// const data = await this.KubernetesHorizontalPodAutoScalers(namespace).create(params, payload).$promise; const data = await this.KubernetesHorizontalPodAutoScalers(namespace).create(params, payload).$promise;
// return data; return data;
// } catch (err) { } catch (err) {
// throw new PortainerError('Unable to create horizontalPodAutoScaler', err); throw new PortainerError('Unable to create horizontalPodAutoScaler', err);
// } }
// } }
// create(horizontalPodAutoScaler) { create(horizontalPodAutoScaler) {
// return this.$async(this.createAsync, horizontalPodAutoScaler); return this.$async(this.createAsync, horizontalPodAutoScaler);
// } }
// /** /**
// * PATCH * PATCH
// */ */
// async patchAsync(oldHorizontalPodAutoScaler, newHorizontalPodAutoScaler) { async patchAsync(oldHorizontalPodAutoScaler, newHorizontalPodAutoScaler) {
// try { try {
// const params = new KubernetesCommonParams(); const params = new KubernetesCommonParams();
// params.id = newHorizontalPodAutoScaler.Name; params.id = newHorizontalPodAutoScaler.Name;
// const namespace = newHorizontalPodAutoScaler.Namespace; const namespace = newHorizontalPodAutoScaler.Namespace;
// const payload = KubernetesHorizontalPodAutoScalerConverter.patchPayload(oldHorizontalPodAutoScaler, newHorizontalPodAutoScaler); const payload = KubernetesHorizontalPodAutoScalerConverter.patchPayload(oldHorizontalPodAutoScaler, newHorizontalPodAutoScaler);
// if (!payload.length) { if (!payload.length) {
// return; return;
// } }
// const data = await this.KubernetesHorizontalPodAutoScalers(namespace).patch(params, payload).$promise; const data = await this.KubernetesHorizontalPodAutoScalers(namespace).patch(params, payload).$promise;
// return data; return data;
// } catch (err) { } catch (err) {
// throw new PortainerError('Unable to patch horizontalPodAutoScaler', err); throw new PortainerError('Unable to patch horizontalPodAutoScaler', err);
// } }
// } }
// patch(oldHorizontalPodAutoScaler, newHorizontalPodAutoScaler) { patch(oldHorizontalPodAutoScaler, newHorizontalPodAutoScaler) {
// return this.$async(this.patchAsync, oldHorizontalPodAutoScaler, newHorizontalPodAutoScaler); return this.$async(this.patchAsync, oldHorizontalPodAutoScaler, newHorizontalPodAutoScaler);
// } }
// /** /**
// * DELETE * DELETE
// */ */
// async deleteAsync(horizontalPodAutoScaler) { async deleteAsync(horizontalPodAutoScaler) {
// try { try {
// const params = new KubernetesCommonParams(); const params = new KubernetesCommonParams();
// params.id = horizontalPodAutoScaler.Name; params.id = horizontalPodAutoScaler.Name;
// const namespace = horizontalPodAutoScaler.Namespace; const namespace = horizontalPodAutoScaler.Namespace;
// await this.KubernetesHorizontalPodAutoScalers(namespace).delete(params).$promise; await this.KubernetesHorizontalPodAutoScalers(namespace).delete(params).$promise;
// } catch (err) { } catch (err) {
// throw new PortainerError('Unable to remove horizontalPodAutoScaler', err); throw new PortainerError('Unable to remove horizontalPodAutoScaler', err);
// } }
// } }
// delete(horizontalPodAutoScaler) { delete(horizontalPodAutoScaler) {
// return this.$async(this.deleteAsync, horizontalPodAutoScaler); return this.$async(this.deleteAsync, horizontalPodAutoScaler);
// } }
// /** // /**
// * ROLLBACK // * ROLLBACK

View File

@ -1,4 +1,4 @@
import { KubernetesApplicationDeploymentTypes, KubernetesApplicationPublishingTypes, KubernetesApplicationDataAccessPolicies } from './models'; import { KubernetesApplicationDataAccessPolicies, KubernetesApplicationDeploymentTypes, KubernetesApplicationPublishingTypes } from './models';
/** /**
* KubernetesApplicationFormValues Model * KubernetesApplicationFormValues Model
@ -21,6 +21,7 @@ const _KubernetesApplicationFormValues = Object.freeze({
PublishingType: KubernetesApplicationPublishingTypes.INTERNAL, PublishingType: KubernetesApplicationPublishingTypes.INTERNAL,
DataAccessPolicy: KubernetesApplicationDataAccessPolicies.SHARED, DataAccessPolicy: KubernetesApplicationDataAccessPolicies.SHARED,
Configurations: [], // KubernetesApplicationConfigurationFormValue list Configurations: [], // KubernetesApplicationConfigurationFormValue list
AutoScaler: {},
}); });
export class KubernetesApplicationFormValues { export class KubernetesApplicationFormValues {
@ -116,3 +117,20 @@ export class KubernetesApplicationPublishedPortFormValue {
Object.assign(this, JSON.parse(JSON.stringify(_KubernetesApplicationPublishedPortFormValue))); Object.assign(this, JSON.parse(JSON.stringify(_KubernetesApplicationPublishedPortFormValue)));
} }
} }
/**
* KubernetesApplicationAutoScalerFormValue Model
*/
const _KubernetesApplicationAutoScalerFormValue = Object.freeze({
MinReplicas: 0,
MaxReplicas: 0,
TargetCPUUtilization: 50,
ApiVersion: '',
IsUsed: false,
});
export class KubernetesApplicationAutoScalerFormValue {
constructor() {
Object.assign(this, JSON.parse(JSON.stringify(_KubernetesApplicationAutoScalerFormValue)));
}
}

View File

@ -12,6 +12,7 @@ import { KubernetesDaemonSet } from 'Kubernetes/models/daemon-set/models';
import { KubernetesApplication } from 'Kubernetes/models/application/models'; import { KubernetesApplication } from 'Kubernetes/models/application/models';
import KubernetesServiceHelper from 'Kubernetes/helpers/serviceHelper'; import KubernetesServiceHelper from 'Kubernetes/helpers/serviceHelper';
import { KubernetesHorizontalPodAutoScalerHelper } from 'Kubernetes/horizontal-pod-auto-scaler/helper'; import { KubernetesHorizontalPodAutoScalerHelper } from 'Kubernetes/horizontal-pod-auto-scaler/helper';
import { KubernetesHorizontalPodAutoScalerConverter } from 'Kubernetes/horizontal-pod-auto-scaler/converter';
class KubernetesApplicationService { class KubernetesApplicationService {
/* @ngInject */ /* @ngInject */
@ -141,13 +142,14 @@ class KubernetesApplicationService {
const res = await Promise.all( const res = await Promise.all(
_.map(namespaces, async (ns) => { _.map(namespaces, async (ns) => {
const [deployments, daemonSets, statefulSets, services, pods, ingresses] = await Promise.all([ const [deployments, daemonSets, statefulSets, services, pods, ingresses, autoScalers] = await Promise.all([
this.KubernetesDeploymentService.get(ns), this.KubernetesDeploymentService.get(ns),
this.KubernetesDaemonSetService.get(ns), this.KubernetesDaemonSetService.get(ns),
this.KubernetesStatefulSetService.get(ns), this.KubernetesStatefulSetService.get(ns),
this.KubernetesServiceService.get(ns), this.KubernetesServiceService.get(ns),
this.KubernetesPodService.get(ns), this.KubernetesPodService.get(ns),
this.KubernetesIngressService.get(ns), this.KubernetesIngressService.get(ns),
this.KubernetesHorizontalPodAutoScalerService.get(ns),
]); ]);
const deploymentApplications = _.map(deployments, (item) => const deploymentApplications = _.map(deployments, (item) =>
@ -160,7 +162,15 @@ class KubernetesApplicationService {
convertToApplication(item, KubernetesApplicationConverter.apiStatefulSetToapplication, services, pods, ingresses) convertToApplication(item, KubernetesApplicationConverter.apiStatefulSetToapplication, services, pods, ingresses)
); );
return _.concat(deploymentApplications, daemonSetApplications, statefulSetApplications); const applications = _.concat(deploymentApplications, daemonSetApplications, statefulSetApplications);
await Promise.all(
_.forEach(applications, async (application) => {
const boundScaler = KubernetesHorizontalPodAutoScalerHelper.findApplicationBoundScaler(autoScalers, application);
const scaler = boundScaler ? await this.KubernetesHorizontalPodAutoScalerService.get(ns, boundScaler.Name) : undefined;
application.AutoScaler = scaler;
})
);
return applications;
}) })
); );
return _.flatten(res); return _.flatten(res);
@ -206,6 +216,12 @@ class KubernetesApplicationService {
await Promise.all(_.without(claimPromises, undefined)); await Promise.all(_.without(claimPromises, undefined));
} }
if (formValues.AutoScaler.IsUsed) {
const kind = KubernetesHorizontalPodAutoScalerHelper.getApplicationTypeString(app);
const autoScaler = KubernetesHorizontalPodAutoScalerConverter.applicationFormValuesToModel(formValues, kind);
await this.KubernetesHorizontalPodAutoScalerService.create(autoScaler);
}
await apiService.create(app); await apiService.create(app);
} catch (err) { } catch (err) {
throw err; throw err;
@ -257,6 +273,20 @@ class KubernetesApplicationService {
} else if (oldService && !newService) { } else if (oldService && !newService) {
await this.KubernetesServiceService.delete(oldService); await this.KubernetesServiceService.delete(oldService);
} }
const newKind = KubernetesHorizontalPodAutoScalerHelper.getApplicationTypeString(newApp);
const newAutoScaler = KubernetesHorizontalPodAutoScalerConverter.applicationFormValuesToModel(newFormValues, newKind);
if (_.isEmpty(oldFormValues.AutoScaler)) {
await this.KubernetesHorizontalPodAutoScalerService.create(newAutoScaler);
} else {
const oldKind = KubernetesHorizontalPodAutoScalerHelper.getApplicationTypeString(oldApp);
const oldAutoScaler = KubernetesHorizontalPodAutoScalerConverter.applicationFormValuesToModel(oldFormValues, oldKind);
if (newFormValues.AutoScaler.IsUsed) {
await this.KubernetesHorizontalPodAutoScalerService.patch(oldAutoScaler, newAutoScaler);
} else {
await this.KubernetesHorizontalPodAutoScalerService.delete(oldAutoScaler);
}
}
} catch (err) { } catch (err) {
throw err; throw err;
} }
@ -319,6 +349,10 @@ class KubernetesApplicationService {
if (application.ServiceType) { if (application.ServiceType) {
await this.KubernetesServiceService.delete(servicePayload); await this.KubernetesServiceService.delete(servicePayload);
} }
if (!_.isEmpty(application.AutoScaler)) {
await this.KubernetesHorizontalPodAutoScalerService.delete(application.AutoScaler);
}
} catch (err) { } catch (err) {
throw err; throw err;
} }

View File

@ -635,7 +635,13 @@
</label> </label>
</div> </div>
<div ng-if="ctrl.supportGlobalDeployment()"> <div ng-if="ctrl.supportGlobalDeployment()">
<input type="radio" id="deployment_global" ng-value="ctrl.ApplicationDeploymentTypes.GLOBAL" ng-model="ctrl.formValues.DeploymentType" /> <input
type="radio"
id="deployment_global"
ng-value="ctrl.ApplicationDeploymentTypes.GLOBAL"
ng-model="ctrl.formValues.DeploymentType"
ng-click="ctrl.unselectAutoScaler()"
/>
<label for="deployment_global"> <label for="deployment_global">
<div class="boxselector_header"> <div class="boxselector_header">
<i class="fa fa-cubes" aria-hidden="true" style="margin-right: 2px;"></i> <i class="fa fa-cubes" aria-hidden="true" style="margin-right: 2px;"></i>
@ -695,6 +701,116 @@
</div> </div>
</div> </div>
<!-- auto scaling -->
<div class="col-sm-12 form-section-title" ng-if="ctrl.formValues.DeploymentType !== ctrl.ApplicationDeploymentTypes.GLOBAL">
Auto-scaling
</div>
<div class="form-group" ng-if="ctrl.formValues.DeploymentType !== ctrl.ApplicationDeploymentTypes.GLOBAL && ctrl.state.useServerMetrics">
<div class="col-sm-12">
<label for="enable_auto_scaling" class="control-label text-left">
Enable auto scaling for this application
</label>
<label class="switch" style="margin-left: 20px;">
<input type="checkbox" class="form-control" name="enable_auto_scaling" ng-model="ctrl.formValues.AutoScaler.IsUsed" />
<i></i>
</label>
</div>
</div>
<div class="form-group" ng-if="ctrl.formValues.DeploymentType !== ctrl.ApplicationDeploymentTypes.GLOBAL && !ctrl.state.useServerMetrics">
<div class="col-sm-12 small text-muted">
<p ng-if="!ctrl.isAdmin">
This feature is currently disabled and must be enabled by an administrator user.
</p>
<p ng-if="ctrl.isAdmin">
Server metrics features must be enabled in the
<a ui-sref="portainer.endpoints.endpoint.kubernetesConfig({id: ctrl.endpoint.Id})" class="ctrl.isAdmin">endpoint configuration view</a>.
</p>
</div>
</div>
<div class="form-inline" ng-if="ctrl.formValues.AutoScaler.IsUsed">
<table class="table" style="margin-bottom: 0px;">
<tbody>
<tr class="small">
<td style="width: 33%; border: none; padding: 2px 0 2px 0;">Minimum instances</td>
<td style="width: 33%; border: none; padding: 2px 0 2px 0;">Maximum instances</td>
<td style="width: 33%; border: none; padding: 2px 0 2px 0;">
Target CPU usage (<b>%</b>)
<portainer-tooltip position="bottom" message="The autoscaler will ensure enough instances are running to maintain an average CPU usage across all instances.">
</portainer-tooltip>
</td>
</tr>
<tr>
<td style="padding: 8px 5px 5px 0; border: none;">
<div class="input-group input-group-sm" style="width: 100%;">
<input
type="number"
class="form-control"
name="auto_scaler_min"
min="0"
ng-max="ctrl.formValues.AutoScaler.MaxReplicas"
ng-model="ctrl.formValues.AutoScaler.MinReplicas"
required
/>
</div>
<div class="input-group input-group-sm" ng-show="kubernetesApplicationCreationForm['auto_scaler_min'].$invalid">
<div class="small text-warning" style="margin-top: 5px;">
<ng-messages for="kubernetesApplicationCreationForm['auto_scaler_min'].$error">
<p ng-message="required"><i class="fa fa-exclamation-triangle" aria-hidden="true"></i> Minimum instances is required.</p>
<p ng-message="min"><i class="fa fa-exclamation-triangle" aria-hidden="true"></i> Minimum instances must be greater than 0.</p>
<p ng-message="max"><i class="fa fa-exclamation-triangle" aria-hidden="true"></i> Minimum instances must be smaller than maximum instances.</p>
</ng-messages>
</div>
</div>
</td>
<td style="padding: 8px 5px 5px 0; border: none;">
<div class="input-group input-group-sm" style="width: 100%;">
<input
type="number"
class="form-control"
name="auto_scaler_max"
ng-min="ctrl.formValues.AutoScaler.MinReplicas"
ng-model="ctrl.formValues.AutoScaler.MaxReplicas"
/>
</div>
<div class="input-group input-group-sm" ng-show="kubernetesApplicationCreationForm['auto_scaler_max'].$invalid || ctrl.autoScalerOverflow()">
<div class="small text-warning" style="margin-top: 5px;">
<ng-messages for="kubernetesApplicationCreationForm['auto_scaler_max'].$error">
<p ng-message="required"><i class="fa fa-exclamation-triangle" aria-hidden="true"></i> Maximum instances is required.</p>
<p ng-message="min"><i class="fa fa-exclamation-triangle" aria-hidden="true"></i> Maximum instances must be greater than minimum instances.</p>
</ng-messages>
</div>
</div>
</td>
<td style="padding: 8px 5px 5px 0; border: none;">
<div class="input-group input-group-sm" style="width: 100%;">
<input type="number" class="form-control" name="auto_scaler_cpu" ng-model="ctrl.formValues.AutoScaler.TargetCPUUtilization" min="1" max="100" required />
</div>
<div class="input-group input-group-sm" ng-show="kubernetesApplicationCreationForm['auto_scaler_cpu'].$invalid">
<div class="small text-warning" style="margin-top: 5px;">
<ng-messages for="kubernetesApplicationCreationForm['auto_scaler_cpu'].$error">
<p ng-message="required"><i class="fa fa-exclamation-triangle" aria-hidden="true"></i> Target CPU usage is required.</p>
<p ng-message="min"><i class="fa fa-exclamation-triangle" aria-hidden="true"></i> Target CPU usage must be greater than 0.</p>
<p ng-message="max"><i class="fa fa-exclamation-triangle" aria-hidden="true"></i> Target CPU usage must be smaller than 100.</p>
</ng-messages>
</div>
</div>
</td>
</tr>
</tbody>
</table>
<div class="form-group" ng-if="ctrl.autoScalerOverflow()" style="margin-bottom: 10px;">
<div class="col-sm-12 small text-muted">
<i class="fa fa-exclamation-circle orange-icon" aria-hidden="true" style="margin-right: 2px;"></i>
This application would exceed available resources. Please review resource reservations or the maximum instance count of the auto-scaling policy.
</div>
</div>
</div>
<!-- !auto scaling -->
<div class="col-sm-12 form-section-title"> <div class="col-sm-12 form-section-title">
Publishing the application Publishing the application
</div> </div>

View File

@ -22,6 +22,7 @@ import KubernetesFormValidationHelper from 'Kubernetes/helpers/formValidationHel
import KubernetesApplicationConverter from 'Kubernetes/converters/application'; import KubernetesApplicationConverter from 'Kubernetes/converters/application';
import KubernetesResourceReservationHelper from 'Kubernetes/helpers/resourceReservationHelper'; import KubernetesResourceReservationHelper from 'Kubernetes/helpers/resourceReservationHelper';
import { KubernetesServiceTypes } from 'Kubernetes/models/service/models'; import { KubernetesServiceTypes } from 'Kubernetes/models/service/models';
import KubernetesApplicationHelper from 'Kubernetes/helpers/application/index';
class KubernetesCreateApplicationController { class KubernetesCreateApplicationController {
/* @ngInject */ /* @ngInject */
@ -80,6 +81,16 @@ class KubernetesCreateApplicationController {
this.state.alreadyExists = (this.state.isEdit && existingApplication && this.application.Id !== existingApplication.Id) || (!this.state.isEdit && existingApplication); this.state.alreadyExists = (this.state.isEdit && existingApplication && this.application.Id !== existingApplication.Id) || (!this.state.isEdit && existingApplication);
} }
/**
* AUTO SCALER UI MANAGEMENT
*/
unselectAutoScaler() {
if (this.formValues.DeploymentType === this.ApplicationDeploymentTypes.GLOBAL) {
this.formValues.AutoScaler.IsUsed = false;
}
}
/** /**
* CONFIGURATION UI MANAGEMENT * CONFIGURATION UI MANAGEMENT
*/ */
@ -319,6 +330,24 @@ class KubernetesCreateApplicationController {
return false; return false;
} }
autoScalerOverflow() {
const instances = this.formValues.AutoScaler.MaxReplicas;
const cpu = this.formValues.CpuLimit;
const maxCpu = this.state.sliders.cpu.max;
const memory = this.formValues.MemoryLimit;
const maxMemory = this.state.sliders.memory.max;
if (cpu * instances > maxCpu) {
return true;
}
if (memory * instances > maxMemory) {
return true;
}
return false;
}
publishViaLoadBalancerEnabled() { publishViaLoadBalancerEnabled() {
return this.state.useLoadBalancer; return this.state.useLoadBalancer;
} }
@ -345,11 +374,12 @@ class KubernetesCreateApplicationController {
isDeployUpdateButtonDisabled() { isDeployUpdateButtonDisabled() {
const overflow = this.resourceReservationsOverflow(); const overflow = this.resourceReservationsOverflow();
const autoScalerOverflow = this.autoScalerOverflow();
const inProgress = this.state.actionInProgress; const inProgress = this.state.actionInProgress;
const invalid = !this.isValid(); const invalid = !this.isValid();
const hasNoChanges = this.isEditAndNoChangesMade(); const hasNoChanges = this.isEditAndNoChangesMade();
const nonScalable = this.isNonScalable(); const nonScalable = this.isNonScalable();
const res = overflow || inProgress || invalid || hasNoChanges || nonScalable; const res = overflow || autoScalerOverflow || inProgress || invalid || hasNoChanges || nonScalable;
return res; return res;
} }
@ -549,6 +579,7 @@ class KubernetesCreateApplicationController {
this.state = { this.state = {
actionInProgress: false, actionInProgress: false,
useLoadBalancer: false, useLoadBalancer: false,
useServerMetrics: false,
sliders: { sliders: {
cpu: { cpu: {
min: 0, min: 0,
@ -580,6 +611,8 @@ class KubernetesCreateApplicationController {
}, },
}; };
this.isAdmin = this.Authentication.isAdmin();
this.editChanges = []; this.editChanges = [];
if (this.$transition$.params().namespace && this.$transition$.params().name) { if (this.$transition$.params().namespace && this.$transition$.params().name) {
@ -587,8 +620,10 @@ class KubernetesCreateApplicationController {
} }
const endpoint = this.EndpointProvider.currentEndpoint(); const endpoint = this.EndpointProvider.currentEndpoint();
this.endpoint = endpoint;
this.storageClasses = endpoint.Kubernetes.Configuration.StorageClasses; this.storageClasses = endpoint.Kubernetes.Configuration.StorageClasses;
this.state.useLoadBalancer = endpoint.Kubernetes.Configuration.UseLoadBalancer; this.state.useLoadBalancer = endpoint.Kubernetes.Configuration.UseLoadBalancer;
this.state.useServerMetrics = endpoint.Kubernetes.Configuration.UseServerMetrics;
this.formValues = new KubernetesApplicationFormValues(); this.formValues = new KubernetesApplicationFormValues();
@ -611,6 +646,10 @@ class KubernetesCreateApplicationController {
this.formValues = KubernetesApplicationConverter.applicationToFormValues(this.application, this.resourcePools, this.configurations, this.persistentVolumeClaims); this.formValues = KubernetesApplicationConverter.applicationToFormValues(this.application, this.resourcePools, this.configurations, this.persistentVolumeClaims);
this.savedFormValues = angular.copy(this.formValues); this.savedFormValues = angular.copy(this.formValues);
delete this.formValues.ApplicationType; delete this.formValues.ApplicationType;
} else {
this.formValues.AutoScaler = KubernetesApplicationHelper.generateAutoScalerFormValueFromHorizontalPodAutoScaler();
this.formValues.AutoScaler.MinReplicas = this.formValues.ReplicaCount;
this.formValues.AutoScaler.MaxReplicas = this.formValues.ReplicaCount;
} }
await this.updateSliders(); await this.updateSliders();

View File

@ -355,7 +355,7 @@
<tr> <tr>
<td>{{ ctrl.application.AutoScaler.MinReplicas }}</td> <td>{{ ctrl.application.AutoScaler.MinReplicas }}</td>
<td>{{ ctrl.application.AutoScaler.MaxReplicas }}</td> <td>{{ ctrl.application.AutoScaler.MaxReplicas }}</td>
<td>{{ ctrl.application.AutoScaler.TargetCPUUtilizationPercentage }}%</td> <td>{{ ctrl.application.AutoScaler.TargetCPUUtilization }}%</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>

View File

@ -13,10 +13,11 @@
<div class="col-sm-12 form-section-title"> <div class="col-sm-12 form-section-title">
Expose applications over external IP addresses Expose applications over external IP addresses
</div> </div>
<div class="form-group"> <div class="form-group">
<span class="col-sm-12 text-muted small"> <span class="col-sm-12 text-muted small">
Enabling this feature will allow users to expose application they deploy over an external IP address assigned by cloud provider. Enabling this feature will allow users to expose application they deploy over an external IP address assigned by cloud provider.
<p> <p style="margin-top: 2px;">
<i class="fa fa-exclamation-circle orange-icon" aria-hidden="true" style="margin-right: 2px;"></i> <i class="fa fa-exclamation-circle orange-icon" aria-hidden="true" style="margin-right: 2px;"></i>
Ensure that your cloud provider allows you to create load balancers if you want to use this feature. Might incur costs. Ensure that your cloud provider allows you to create load balancers if you want to use this feature. Might incur costs.
</p> </p>
@ -31,6 +32,30 @@
</div> </div>
</div> </div>
<div class="col-sm-12 form-section-title">
Metrics
</div>
<div class="form-group">
<span class="col-sm-12 text-muted small">
Enabling this feature will allow users to use specific features that leverage the server metrics component.
<p style="margin-top: 2px;">
<i class="fa fa-exclamation-circle orange-icon" aria-hidden="true" style="margin-right: 2px;"></i>
Ensure that <a href="https://kubernetes.io/docs/tasks/debug-application-cluster/resource-metrics-pipeline/#metrics-server" target="_blank">server metrics</a> is
running inside your cluster.
</p>
</span>
</div>
<div class="form-group">
<div class="col-sm-12">
<label class="control-label text-left">
Enable features using server metrics
</label>
<label class="switch" style="margin-left: 20px;"> <input type="checkbox" ng-model="ctrl.formValues.UseServerMetrics" /><i></i> </label>
</div>
</div>
<div class="col-sm-12 form-section-title"> <div class="col-sm-12 form-section-title">
Available storage options Available storage options
</div> </div>

View File

@ -50,12 +50,14 @@ class KubernetesConfigureController {
this.endpoint.Kubernetes.Configuration.StorageClasses = classes; this.endpoint.Kubernetes.Configuration.StorageClasses = classes;
this.endpoint.Kubernetes.Configuration.UseLoadBalancer = this.formValues.UseLoadBalancer; this.endpoint.Kubernetes.Configuration.UseLoadBalancer = this.formValues.UseLoadBalancer;
this.endpoint.Kubernetes.Configuration.UseServerMetrics = this.formValues.UseServerMetrics;
await this.EndpointService.updateEndpoint(this.endpoint.Id, this.endpoint); await this.EndpointService.updateEndpoint(this.endpoint.Id, this.endpoint);
const endpoints = this.EndpointProvider.endpoints(); const endpoints = this.EndpointProvider.endpoints();
const modifiedEndpoint = _.find(endpoints, (item) => item.Id === this.endpoint.Id); const modifiedEndpoint = _.find(endpoints, (item) => item.Id === this.endpoint.Id);
if (modifiedEndpoint) { if (modifiedEndpoint) {
modifiedEndpoint.Kubernetes.Configuration.StorageClasses = classes; modifiedEndpoint.Kubernetes.Configuration.StorageClasses = classes;
modifiedEndpoint.Kubernetes.Configuration.UseLoadBalancer = this.formValues.UseLoadBalancer; modifiedEndpoint.Kubernetes.Configuration.UseLoadBalancer = this.formValues.UseLoadBalancer;
modifiedEndpoint.Kubernetes.Configuration.UseServerMetrics = this.formValues.UseServerMetrics;
this.EndpointProvider.setEndpoints(endpoints); this.EndpointProvider.setEndpoints(endpoints);
} }
this.Notifications.success('Configuration successfully applied'); this.Notifications.success('Configuration successfully applied');
@ -80,6 +82,7 @@ class KubernetesConfigureController {
this.formValues = { this.formValues = {
UseLoadBalancer: false, UseLoadBalancer: false,
UseServerMetrics: false,
}; };
try { try {
@ -100,6 +103,7 @@ class KubernetesConfigureController {
}); });
this.formValues.UseLoadBalancer = this.endpoint.Kubernetes.Configuration.UseLoadBalancer; this.formValues.UseLoadBalancer = this.endpoint.Kubernetes.Configuration.UseLoadBalancer;
this.formValues.UseServerMetrics = this.endpoint.Kubernetes.Configuration.UseServerMetrics;
} catch (err) { } catch (err) {
this.Notifications.error('Failure', err, 'Unable to retrieve storage classes'); this.Notifications.error('Failure', err, 'Unable to retrieve storage classes');
} finally { } finally {