diff --git a/app/kubernetes/node/converter.js b/app/kubernetes/node/converter.js index ecc8a70a8..cc57e5c90 100644 --- a/app/kubernetes/node/converter.js +++ b/app/kubernetes/node/converter.js @@ -1,7 +1,10 @@ import _ from 'lodash-es'; -import { KubernetesNode, KubernetesNodeDetails } from 'Kubernetes/node/models'; +import { KubernetesNode, KubernetesNodeDetails, KubernetesNodeTaint } from 'Kubernetes/node/models'; import KubernetesResourceReservationHelper from 'Kubernetes/helpers/resourceReservationHelper'; +import { KubernetesNodeFormValues, KubernetesNodeTaintFormValues, KubernetesNodeLabelFormValues } from 'Kubernetes/node/formValues'; +import { KubernetesNodeCreatePayload, KubernetesNodeTaintPayload } from 'Kubernetes/node/payload'; +import * as JsonPatch from 'fast-json-patch'; class KubernetesNodeConverter { static apiToNode(data, res) { @@ -40,7 +43,13 @@ class KubernetesNodeConverter { res.Version = data.status.nodeInfo.kubeletVersion; const internalIP = _.find(data.status.addresses, { type: 'InternalIP' }); res.IPAddress = internalIP ? internalIP.address : '-'; - res.Taints = data.spec.taints ? data.spec.taints : []; + res.Taints = _.map(data.spec.taints, (taint) => { + const res = new KubernetesNodeTaint(); + res.Key = taint.key; + res.Value = taint.value; + res.Effect = taint.effect; + return res; + }); return res; } @@ -54,6 +63,82 @@ class KubernetesNodeConverter { res.Yaml = yaml ? yaml.data : ''; return res; } + + static nodeToFormValues(node) { + const res = new KubernetesNodeFormValues(); + + res.Taints = _.map(node.Taints, (taint) => { + const res = new KubernetesNodeTaintFormValues(); + res.Key = taint.Key; + res.Value = taint.Value; + res.Effect = taint.Effect; + res.NeedsDeletion = false; + res.IsNew = false; + return res; + }); + + res.Labels = _.map(node.Labels, (value, key) => { + const res = new KubernetesNodeLabelFormValues(); + res.Key = key; + res.Value = value; + res.NeedsDeletion = false; + res.IsNew = false; + return res; + }); + + return res; + } + + static formValuesToNode(node, formValues) { + const res = angular.copy(node); + + const filteredTaints = _.filter(formValues.Taints, (taint) => !taint.NeedsDeletion); + res.Taints = _.map(filteredTaints, (item) => { + const taint = new KubernetesNodeTaint(); + taint.Key = item.Key; + taint.Value = item.Value; + taint.Effect = item.Effect; + return taint; + }); + + const filteredLabels = _.filter(formValues.Labels, (label) => !label.NeedsDeletion); + res.Labels = _.reduce( + filteredLabels, + (acc, item) => { + acc[item.Key] = item.Value ? item.Value : ''; + return acc; + }, + {} + ); + + return res; + } + + static createPayload(node) { + const payload = new KubernetesNodeCreatePayload(); + payload.metadata.name = node.Name; + + const taints = _.map(node.Taints, (taint) => { + const res = new KubernetesNodeTaintPayload(); + res.key = taint.Key; + res.value = taint.Value; + res.effect = taint.Effect; + return res; + }); + + payload.spec.taints = taints.length ? taints : undefined; + + payload.metadata.labels = node.Labels; + + return payload; + } + + static patchPayload(oldNode, newNode) { + const oldPayload = KubernetesNodeConverter.createPayload(oldNode); + const newPayload = KubernetesNodeConverter.createPayload(newNode); + const payload = JsonPatch.compare(oldPayload, newPayload); + return payload; + } } export const KubernetesNodeConditionTypes = Object.freeze({ diff --git a/app/kubernetes/node/formValues.js b/app/kubernetes/node/formValues.js new file mode 100644 index 000000000..d7227ce70 --- /dev/null +++ b/app/kubernetes/node/formValues.js @@ -0,0 +1,40 @@ +const _KubernetesNodeFormValues = Object.freeze({ + Taints: [], + Labels: [], +}); + +export class KubernetesNodeFormValues { + constructor() { + Object.assign(JSON.parse(JSON.stringify(_KubernetesNodeFormValues))); + } +} + +const _KubernetesNodeTaintFormValues = Object.freeze({ + Key: '', + Values: '', + Effect: '', + NeedsDeletion: false, + IsNew: false, + IsChanged: false, +}); + +export class KubernetesNodeTaintFormValues { + constructor() { + Object.assign(JSON.parse(JSON.stringify(_KubernetesNodeTaintFormValues))); + } +} + +const _KubernetesNodeLabelFormValues = Object.freeze({ + Key: '', + Values: '', + NeedsDeletion: false, + IsNew: false, + IsUsed: false, + IsChanged: false, +}); + +export class KubernetesNodeLabelFormValues { + constructor() { + Object.assign(JSON.parse(JSON.stringify(_KubernetesNodeLabelFormValues))); + } +} diff --git a/app/kubernetes/node/helper.js b/app/kubernetes/node/helper.js new file mode 100644 index 000000000..57eaa583f --- /dev/null +++ b/app/kubernetes/node/helper.js @@ -0,0 +1,23 @@ +import _ from 'lodash-es'; + +export class KubernetesNodeHelper { + static isSystemLabel(label) { + return !label.IsNew && (_.startsWith(label.Key, 'beta.kubernetes.io') || _.startsWith(label.Key, 'kubernetes.io') || label.Key === 'node-role.kubernetes.io/master'); + } + + static reorderLabels(labels) { + return _.sortBy(labels, (label) => { + return !KubernetesNodeHelper.isSystemLabel(label); + }); + } + + static computeUsedLabels(applications, labels) { + const pods = _.flatten(_.map(applications, 'Pods')); + const nodeSelectors = _.uniq(_.flatten(_.map(pods, 'NodeSelector'))); + + return _.map(labels, (label) => { + label.IsUsed = _.find(nodeSelectors, (ns) => ns && ns[label.Key] === label.Value) ? true : false; + return label; + }); + } +} diff --git a/app/kubernetes/node/models.js b/app/kubernetes/node/models.js index f1c547969..e8e54a727 100644 --- a/app/kubernetes/node/models.js +++ b/app/kubernetes/node/models.js @@ -42,3 +42,24 @@ export class KubernetesNodeDetails { Object.assign(this, JSON.parse(JSON.stringify(_KubernetesNodeDetails))); } } + +/** + * KubernetesNodeTaint Model + */ +const _KubernetesNodeTaint = Object.freeze({ + Key: '', + Value: '', + Effect: '', +}); + +export class KubernetesNodeTaint { + constructor() { + Object.assign(this, JSON.parse(JSON.stringify(_KubernetesNodeTaint))); + } +} + +export const KubernetesNodeTaintEffects = Object.freeze({ + NOSCHEDULE: 'NoSchedule', + PREFERNOSCHEDULE: 'PreferNoSchedule', + NOEXECUTE: 'NoExecute', +}); diff --git a/app/kubernetes/node/payload.js b/app/kubernetes/node/payload.js new file mode 100644 index 000000000..fbd974f0e --- /dev/null +++ b/app/kubernetes/node/payload.js @@ -0,0 +1,31 @@ +/** + * KubernetesNode Create Payload Model + * Note: The current payload is here just to create patch payload. + */ +const _KubernetesNodeCreatePayload = Object.freeze({ + metadata: { + name: '', + labels: {}, + }, + spec: { + taints: undefined, + }, +}); + +export class KubernetesNodeCreatePayload { + constructor() { + Object.assign(this, JSON.parse(JSON.stringify(_KubernetesNodeCreatePayload))); + } +} + +const _KubernetesNodeTaintPayload = Object.freeze({ + key: '', + value: '', + effect: '', +}); + +export class KubernetesNodeTaintPayload { + constructor() { + Object.assign(this, JSON.parse(JSON.stringify(_KubernetesNodeTaintPayload))); + } +} diff --git a/app/kubernetes/node/rest.js b/app/kubernetes/node/rest.js index 5e27a53eb..a145ae017 100644 --- a/app/kubernetes/node/rest.js +++ b/app/kubernetes/node/rest.js @@ -29,6 +29,12 @@ angular.module('portainer.kubernetes').factory('KubernetesNodes', [ }, create: { method: 'POST' }, update: { method: 'PUT' }, + patch: { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json-patch+json', + }, + }, delete: { method: 'DELETE' }, } ); diff --git a/app/kubernetes/node/service.js b/app/kubernetes/node/service.js index 30aa9157c..36b81e481 100644 --- a/app/kubernetes/node/service.js +++ b/app/kubernetes/node/service.js @@ -13,6 +13,7 @@ class KubernetesNodeService { this.getAsync = this.getAsync.bind(this); this.getAllAsync = this.getAllAsync.bind(this); + this.patchAsync = this.patchAsync.bind(this); } /** @@ -44,6 +45,27 @@ class KubernetesNodeService { } return this.$async(this.getAllAsync); } + + /** + * PATCH + */ + + async patchAsync(node, nodeFormValues) { + try { + const params = new KubernetesCommonParams(); + params.id = node.Name; + const newNode = KubernetesNodeConverter.formValuesToNode(node, nodeFormValues); + const payload = KubernetesNodeConverter.patchPayload(node, newNode); + const data = await this.KubernetesNodes().patch(params, payload).$promise; + return data; + } catch (err) { + throw { msg: 'Unable to patch node', err: err }; + } + } + + patch(node, nodeFormValues) { + return this.$async(this.patchAsync, node, nodeFormValues); + } } export default KubernetesNodeService; diff --git a/app/kubernetes/views/applications/edit/applicationController.js b/app/kubernetes/views/applications/edit/applicationController.js index 0cfa9d206..e7c957135 100644 --- a/app/kubernetes/views/applications/edit/applicationController.js +++ b/app/kubernetes/views/applications/edit/applicationController.js @@ -17,10 +17,10 @@ function computeTolerations(nodes, application) { } n.UnmetTaints = []; _.forEach(n.Taints, (t) => { - const matchKeyMatchValueMatchEffect = _.find(pod.Tolerations, { Key: t.key, Operator: 'Equal', Value: t.value, Effect: t.effect }); - const matchKeyAnyValueMatchEffect = _.find(pod.Tolerations, { Key: t.key, Operator: 'Exists', Effect: t.effect }); - const matchKeyMatchValueAnyEffect = _.find(pod.Tolerations, { Key: t.key, Operator: 'Equal', Value: t.value, Effect: '' }); - const matchKeyAnyValueAnyEffect = _.find(pod.Tolerations, { Key: t.key, Operator: 'Exists', Effect: '' }); + const matchKeyMatchValueMatchEffect = _.find(pod.Tolerations, { Key: t.Key, Operator: 'Equal', Value: t.Value, Effect: t.Effect }); + const matchKeyAnyValueMatchEffect = _.find(pod.Tolerations, { Key: t.Key, Operator: 'Exists', Effect: t.Effect }); + const matchKeyMatchValueAnyEffect = _.find(pod.Tolerations, { Key: t.Key, Operator: 'Equal', Value: t.Value, Effect: '' }); + const matchKeyAnyValueAnyEffect = _.find(pod.Tolerations, { Key: t.Key, Operator: 'Exists', Effect: '' }); const anyKeyAnyValueAnyEffect = _.find(pod.Tolerations, { Key: '', Operator: 'Exists', Effect: '' }); if (!matchKeyMatchValueMatchEffect && !matchKeyAnyValueMatchEffect && !matchKeyMatchValueAnyEffect && !matchKeyAnyValueAnyEffect && !anyKeyAnyValueAnyEffect) { diff --git a/app/kubernetes/views/applications/edit/components/placements-datatable/template.html b/app/kubernetes/views/applications/edit/components/placements-datatable/template.html index 6b85f9e0c..c2659f1e6 100644 --- a/app/kubernetes/views/applications/edit/components/placements-datatable/template.html +++ b/app/kubernetes/views/applications/edit/components/placements-datatable/template.html @@ -94,7 +94,7 @@ - This application is missing a toleration for the taint {{ taint.key }}{{ taint.value ? '=' + taint.value : '' }}:{{ taint.effect }} + This application is missing a toleration for the taint {{ taint.Key }}{{ taint.Value ? '=' + taint.Value : '' }}:{{ taint.effect }} diff --git a/app/kubernetes/views/cluster/node/node.html b/app/kubernetes/views/cluster/node/node.html index 4132e5efe..87ae50227 100644 --- a/app/kubernetes/views/cluster/node/node.html +++ b/app/kubernetes/views/cluster/node/node.html @@ -13,7 +13,7 @@ Node -
+ @@ -54,6 +54,7 @@
+
+ +
+ +
+ Labels +
+ +
+ add label +
+ +
+
+ Key + +
+
+ Value + +
+
+
+ + + used + system +
+
+
+ +

Label key is required.

+
+

This label key is already defined.

+
+
+ + + + + +
+ Taints +
+ +
+ add taint +
+ +
+
+ Key + +
+
+ Value + +
+
+ Effect + +
+
+
+ + +
+
+
+ +

Taint key is required.

+
+

This taint key is already defined.

+
+
+ + + + + +
+ Actions +
+ +
+
+ + +
+
+ + +
diff --git a/app/kubernetes/views/cluster/node/nodeController.js b/app/kubernetes/views/cluster/node/nodeController.js index 1f803965a..22f96d8c0 100644 --- a/app/kubernetes/views/cluster/node/nodeController.js +++ b/app/kubernetes/views/cluster/node/nodeController.js @@ -3,6 +3,11 @@ import _ from 'lodash-es'; import KubernetesResourceReservationHelper from 'Kubernetes/helpers/resourceReservationHelper'; import { KubernetesResourceReservation } from 'Kubernetes/models/resource-reservation/models'; import KubernetesEventHelper from 'Kubernetes/helpers/eventHelper'; +import KubernetesNodeConverter from 'Kubernetes/node/converter'; +import { KubernetesNodeLabelFormValues, KubernetesNodeTaintFormValues } from 'Kubernetes/node/formValues'; +import { KubernetesNodeTaintEffects } from 'Kubernetes/node/models'; +import KubernetesFormValidationHelper from 'Kubernetes/helpers/formValidationHelper'; +import { KubernetesNodeHelper } from 'Kubernetes/node/helper'; class KubernetesNodeController { /* @ngInject */ @@ -11,6 +16,7 @@ class KubernetesNodeController { $state, Notifications, LocalStorage, + ModalService, KubernetesNodeService, KubernetesEventService, KubernetesPodService, @@ -21,6 +27,7 @@ class KubernetesNodeController { this.$state = $state; this.Notifications = Notifications; this.LocalStorage = LocalStorage; + this.ModalService = ModalService; this.KubernetesNodeService = KubernetesNodeService; this.KubernetesEventService = KubernetesEventService; this.KubernetesPodService = KubernetesPodService; @@ -33,12 +40,136 @@ class KubernetesNodeController { this.getEventsAsync = this.getEventsAsync.bind(this); this.getApplicationsAsync = this.getApplicationsAsync.bind(this); this.getEndpointsAsync = this.getEndpointsAsync.bind(this); + this.updateNodeAsync = this.updateNodeAsync.bind(this); } selectTab(index) { this.LocalStorage.storeActiveTab('node', index); } + /* #region taint */ + + onChangeTaintKey(index) { + this.state.duplicateTaintKeys = KubernetesFormValidationHelper.getDuplicates( + _.map(this.formValues.Taints, (taint) => { + if (taint.NeedsDeletion) { + return undefined; + } + return taint.Key; + }) + ); + this.state.hasDuplicateTaintKeys = Object.keys(this.state.duplicateTaintKeys).length > 0; + this.onChangeTaint(index); + } + + onChangeTaint(index) { + if (this.formValues.Taints[index]) { + this.formValues.Taints[index].IsChanged = true; + } + } + + addTaint() { + const taint = new KubernetesNodeTaintFormValues(); + taint.IsNew = true; + taint.Effect = KubernetesNodeTaintEffects.NOSCHEDULE; + this.formValues.Taints.push(taint); + } + + removeTaint(index) { + const taint = this.formValues.Taints[index]; + if (taint.IsNew) { + this.formValues.Taints.splice(index, 1); + } else { + taint.NeedsDeletion = true; + } + this.onChangeTaintKey(); + } + + restoreTaint(index) { + this.formValues.Taints[index].NeedsDeletion = false; + this.onChangeTaintKey(); + } + + computeTaintsWarning() { + return _.filter(this.formValues.Taints, (taint) => { + return taint.Effect === KubernetesNodeTaintEffects.NOEXECUTE && (taint.IsNew || taint.IsChanged); + }).length; + } + + /* #endregion */ + + /* #region label */ + + onChangeLabelKey(index) { + this.state.duplicateLabelKeys = KubernetesFormValidationHelper.getDuplicates( + _.map(this.formValues.Labels, (label) => { + if (label.NeedsDeletion) { + return undefined; + } + return label.Key; + }) + ); + this.state.hasDuplicateLabelKeys = Object.keys(this.state.duplicateLabelKeys).length > 0; + this.onChangeLabel(index); + } + + onChangeLabel(index) { + if (this.formValues.Labels[index]) { + this.formValues.Labels[index].IsChanged = true; + } + } + + addLabel() { + const label = new KubernetesNodeLabelFormValues(); + label.IsNew = true; + this.formValues.Labels.push(label); + } + + removeLabel(index) { + const label = this.formValues.Labels[index]; + if (label.IsNew) { + this.formValues.Labels.splice(index, 1); + } else { + label.NeedsDeletion = true; + } + this.onChangeLabelKey(); + } + + restoreLabel(index) { + this.formValues.Labels[index].NeedsDeletion = false; + this.onChangeLabelKey(); + } + + isSystemLabel(index) { + return KubernetesNodeHelper.isSystemLabel(this.formValues.Labels[index]); + } + + computeLabelsWarning() { + return _.filter(this.formValues.Labels, (label) => { + return label.IsUsed && (label.NeedsDeletion || label.IsChanged); + }).length; + } + + /* #endregion */ + + /* #region actions */ + + isNoChangesMade() { + const newNode = KubernetesNodeConverter.formValuesToNode(this.node, this.formValues); + const payload = KubernetesNodeConverter.patchPayload(this.node, newNode); + return !payload.length; + } + + isFormValid() { + return !this.state.hasDuplicateTaintKeys && !this.state.hasDuplicateLabelKeys && !this.isNoChangesMade(); + } + + resetFormValues() { + this.formValues = KubernetesNodeConverter.nodeToFormValues(this.node); + } + + /* #endregion */ + async getEndpointsAsync() { try { const endpoints = await this.KubernetesEndpointService.get(); @@ -63,6 +194,52 @@ class KubernetesNodeController { return this.$async(this.getEndpointsAsync); } + async updateNodeAsync() { + try { + await this.KubernetesNodeService.patch(this.node, this.formValues); + this.Notifications.success('Node updated successfully'); + this.$state.reload(); + } catch (err) { + this.Notifications.error('Failure', err, 'Unable to update node'); + } + } + + updateNode() { + const taintsWarning = this.computeTaintsWarning(); + const labelsWarning = this.computeLabelsWarning(); + + if (taintsWarning && !labelsWarning) { + this.ModalService.confirmUpdate( + 'Changes to taints will immediately deschedule applications running on this node without the corresponding tolerations. Do you wish to continue?', + (confirmed) => { + if (confirmed) { + return this.$async(this.updateNodeAsync); + } + } + ); + } else if (!taintsWarning && labelsWarning) { + this.ModalService.confirmUpdate( + 'Removing or changing a label that is used might prevent applications from being scheduled on this node in the future. Do you wish to continue?', + (confirmed) => { + if (confirmed) { + return this.$async(this.updateNodeAsync); + } + } + ); + } else if (taintsWarning && labelsWarning) { + this.ModalService.confirmUpdate( + 'Changes to taints will immediately deschedule applications running on this node without the corresponding tolerations.

Removing or changing a label that is used might prevent applications from scheduling on this node in the future.\n\nDo you wish to continue?', + (confirmed) => { + if (confirmed) { + return this.$async(this.updateNodeAsync); + } + } + ); + } else { + return this.$async(this.updateNodeAsync); + } + } + async getNodeAsync() { try { this.state.dataLoading = true; @@ -147,6 +324,10 @@ class KubernetesNodeController { showEditorTab: false, viewReady: false, eventWarningCount: 0, + duplicateTaintKeys: [], + hasDuplicateTaintKeys: false, + duplicateLabelKeys: [], + hasDuplicateLabelKeys: false, }; this.state.activeTab = this.LocalStorage.getActiveTab('node'); @@ -156,6 +337,11 @@ class KubernetesNodeController { await this.getApplications(); await this.getEndpoints(); + this.availableEffects = _.values(KubernetesNodeTaintEffects); + this.formValues = KubernetesNodeConverter.nodeToFormValues(this.node); + this.formValues.Labels = KubernetesNodeHelper.reorderLabels(this.formValues.Labels); + this.formValues.Labels = KubernetesNodeHelper.computeUsedLabels(this.applications, this.formValues.Labels); + this.state.viewReady = true; }