From 61f97469ab9ef4ee87bdb996f4eb0fbb44b49e61 Mon Sep 17 00:00:00 2001 From: Maxime Bajeux Date: Fri, 7 Aug 2020 06:40:24 +0200 Subject: [PATCH] feat(application): Add the ability to use existing volumes when creating an application (#4044) * feat(applications): update UI to use existing volumes * feat(application): Add the ability to use existing volumes when creating an application * feat(application): Existing persisted folders should default to associated volumes * feat(application): add form validation to existing volume * feat(application): remove the ability to use an existing volume with statefulset application * feat(k8s/applications): minor UI update * feat(k8s/application): minor UI update * feat(volume): allow to increase volume size and few other things * feat(volumes): add the ability to allow volume expansion * fix(storage): fix the storage patch request * fix(k8s/applications): remove conflict leftover * feat(k8s/configure): minor UI update * feat(k8s/volume): minor UI update * fix(storage): change few things Co-authored-by: Anthony Lapenna --- api/portainer.go | 7 +- .../converters/persistentVolumeClaim.js | 24 +++-- app/kubernetes/converters/storageClass.js | 18 ++++ .../models/application/formValues.js | 2 + app/kubernetes/models/storage-class/models.js | 1 + .../models/storage-class/payload.js | 16 +++ app/kubernetes/rest/storage.js | 6 ++ app/kubernetes/services/applicationService.js | 9 +- app/kubernetes/services/storageService.js | 20 ++++ .../create/createApplication.html | 102 +++++++++++++++--- .../create/createApplicationController.js | 96 ++++++++++++++++- app/kubernetes/views/configure/configure.html | 8 +- .../views/configure/configureController.js | 13 +++ app/kubernetes/views/volumes/edit/volume.html | 60 ++++++++++- .../views/volumes/edit/volumeController.js | 76 ++++++++++++- app/portainer/services/modalService.js | 18 ++++ 16 files changed, 436 insertions(+), 40 deletions(-) create mode 100644 app/kubernetes/models/storage-class/payload.js diff --git a/api/portainer.go b/api/portainer.go index 06f133957..f6f960380 100644 --- a/api/portainer.go +++ b/api/portainer.go @@ -344,9 +344,10 @@ type ( // KubernetesStorageClassConfig represents a Kubernetes Storage Class configuration KubernetesStorageClassConfig struct { - Name string `json:"Name"` - AccessModes []string `json:"AccessModes"` - Provisioner string `json:"Provisioner"` + Name string `json:"Name"` + AccessModes []string `json:"AccessModes"` + Provisioner string `json:"Provisioner"` + AllowVolumeExpansion bool `json:"AllowVolumeExpansion"` } // LDAPGroupSearchSettings represents settings used to search for groups in a LDAP server diff --git a/app/kubernetes/converters/persistentVolumeClaim.js b/app/kubernetes/converters/persistentVolumeClaim.js index 8a7a76060..60ea28c58 100644 --- a/app/kubernetes/converters/persistentVolumeClaim.js +++ b/app/kubernetes/converters/persistentVolumeClaim.js @@ -28,16 +28,28 @@ class KubernetesPersistentVolumeClaimConverter { _.remove(formValues.PersistedFolders, (item) => item.NeedsDeletion); const res = _.map(formValues.PersistedFolders, (item) => { const pvc = new KubernetesPersistentVolumeClaim(); - if (item.PersistentVolumeClaimName) { - pvc.Name = item.PersistentVolumeClaimName; - pvc.PreviousName = item.PersistentVolumeClaimName; + if (!_.isEmpty(item.ExistingVolume)) { + const existantPVC = item.ExistingVolume.PersistentVolumeClaim; + pvc.Name = existantPVC.Name; + if (item.PersistentVolumeClaimName) { + pvc.PreviousName = item.PersistentVolumeClaimName; + } + pvc.StorageClass = existantPVC.StorageClass; + pvc.Storage = existantPVC.Storage.charAt(0) + 'i'; + pvc.CreationDate = existantPVC.CreationDate; + pvc.Id = existantPVC.Id; } else { - pvc.Name = formValues.Name + '-' + pvc.Name; + if (item.PersistentVolumeClaimName) { + pvc.Name = item.PersistentVolumeClaimName; + pvc.PreviousName = item.PersistentVolumeClaimName; + } else { + pvc.Name = formValues.Name + '-' + pvc.Name; + } + pvc.Storage = '' + item.Size + item.SizeUnit.charAt(0) + 'i'; + pvc.StorageClass = item.StorageClass; } pvc.MountPath = item.ContainerPath; pvc.Namespace = formValues.ResourcePool.Namespace.Name; - pvc.Storage = '' + item.Size + item.SizeUnit.charAt(0) + 'i'; - pvc.StorageClass = item.StorageClass; pvc.ApplicationOwner = formValues.ApplicationOwner; pvc.ApplicationName = formValues.Name; return pvc; diff --git a/app/kubernetes/converters/storageClass.js b/app/kubernetes/converters/storageClass.js index 773c96862..7491c32e7 100644 --- a/app/kubernetes/converters/storageClass.js +++ b/app/kubernetes/converters/storageClass.js @@ -1,4 +1,6 @@ import { KubernetesStorageClass } from 'Kubernetes/models/storage-class/models'; +import { KubernetesStorageClassCreatePayload } from 'Kubernetes/models/storage-class/payload'; +import * as JsonPatch from 'fast-json-patch'; class KubernetesStorageClassConverter { /** @@ -8,8 +10,24 @@ class KubernetesStorageClassConverter { const res = new KubernetesStorageClass(); res.Name = data.metadata.name; res.Provisioner = data.provisioner; + res.AllowVolumeExpansion = data.allowVolumeExpansion; return res; } + + static createPayload(storageClass) { + const res = new KubernetesStorageClassCreatePayload(); + res.metadata.name = storageClass.Name; + res.provisioner = storageClass.Provisioner; + res.allowVolumeExpansion = storageClass.AllowVolumeExpansion; + return res; + } + + static patchPayload(oldStorageClass, newStorageClass) { + const oldPayload = KubernetesStorageClassConverter.createPayload(oldStorageClass); + const newPayload = KubernetesStorageClassConverter.createPayload(newStorageClass); + const payload = JsonPatch.compare(oldPayload, newPayload); + return payload; + } } export default KubernetesStorageClassConverter; diff --git a/app/kubernetes/models/application/formValues.js b/app/kubernetes/models/application/formValues.js index 49d3cc827..7ca9ca1b9 100644 --- a/app/kubernetes/models/application/formValues.js +++ b/app/kubernetes/models/application/formValues.js @@ -92,6 +92,8 @@ const _KubernetesApplicationPersistedFolderFormValue = Object.freeze({ Size: '', SizeUnit: 'GB', StorageClass: {}, + ExistingVolume: null, + UseNewVolume: true, }); export class KubernetesApplicationPersistedFolderFormValue { diff --git a/app/kubernetes/models/storage-class/models.js b/app/kubernetes/models/storage-class/models.js index 153976f64..beb5bb3d5 100644 --- a/app/kubernetes/models/storage-class/models.js +++ b/app/kubernetes/models/storage-class/models.js @@ -25,6 +25,7 @@ const _KubernetesStorageClass = Object.freeze({ Name: '', AccessModes: [], Provisioner: '', + AllowVolumeExpansion: false, }); export class KubernetesStorageClass { diff --git a/app/kubernetes/models/storage-class/payload.js b/app/kubernetes/models/storage-class/payload.js new file mode 100644 index 000000000..47e183669 --- /dev/null +++ b/app/kubernetes/models/storage-class/payload.js @@ -0,0 +1,16 @@ +import { KubernetesCommonMetadataPayload } from 'Kubernetes/models/common/payloads'; + +/** + * KubernetesStorageClassCreatePayload Model + */ +const _KubernetesStorageClassCreatePayload = Object.freeze({ + metadata: new KubernetesCommonMetadataPayload(), + provisioner: '', + allowVolumeExpansion: false, +}); + +export class KubernetesStorageClassCreatePayload { + constructor() { + Object.assign(this, JSON.parse(JSON.stringify(_KubernetesStorageClassCreatePayload))); + } +} diff --git a/app/kubernetes/rest/storage.js b/app/kubernetes/rest/storage.js index 44e6406bf..d3ef80d68 100644 --- a/app/kubernetes/rest/storage.js +++ b/app/kubernetes/rest/storage.js @@ -29,6 +29,12 @@ angular.module('portainer.kubernetes').factory('KubernetesStorage', [ }, create: { method: 'POST' }, update: { method: 'PUT' }, + patch: { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json-patch+json', + }, + }, delete: { method: 'DELETE' }, } ); diff --git a/app/kubernetes/services/applicationService.js b/app/kubernetes/services/applicationService.js index e3c549de8..614267068 100644 --- a/app/kubernetes/services/applicationService.js +++ b/app/kubernetes/services/applicationService.js @@ -209,7 +209,7 @@ class KubernetesApplicationService { app.ServiceName = headlessService.metadata.name; } else { const claimPromises = _.map(claims, (item) => { - if (!item.PreviousName) { + if (!item.PreviousName && !item.Id) { return this.KubernetesPersistentVolumeClaimService.create(item); } }); @@ -255,11 +255,12 @@ class KubernetesApplicationService { await this.KubernetesServiceService.patch(oldHeadlessService, newHeadlessService); } else { const claimPromises = _.map(newClaims, (newClaim) => { - if (!newClaim.PreviousName) { + if (!newClaim.PreviousName && !newClaim.Id) { return this.KubernetesPersistentVolumeClaimService.create(newClaim); + } else if (!newClaim.Id) { + const oldClaim = _.find(oldClaims, { Name: newClaim.PreviousName }); + return this.KubernetesPersistentVolumeClaimService.patch(oldClaim, newClaim); } - const oldClaim = _.find(oldClaims, { Name: newClaim.PreviousName }); - return this.KubernetesPersistentVolumeClaimService.patch(oldClaim, newClaim); }); await Promise.all(claimPromises); } diff --git a/app/kubernetes/services/storageService.js b/app/kubernetes/services/storageService.js index ea2a5f053..550a8f5c9 100644 --- a/app/kubernetes/services/storageService.js +++ b/app/kubernetes/services/storageService.js @@ -2,6 +2,7 @@ import angular from 'angular'; import _ from 'lodash-es'; import PortainerError from 'Portainer/error'; import KubernetesStorageClassConverter from 'Kubernetes/converters/storageClass'; +import { KubernetesCommonParams } from 'Kubernetes/models/common/params'; class KubernetesStorageService { /* @ngInject */ @@ -10,6 +11,7 @@ class KubernetesStorageService { this.KubernetesStorage = KubernetesStorage; this.getAsync = this.getAsync.bind(this); + this.patchAsync = this.patchAsync.bind(this); } /** @@ -31,6 +33,24 @@ class KubernetesStorageService { get(endpointId) { return this.$async(this.getAsync, endpointId); } + + /** + * PATCH + */ + async patchAsync(oldStorageClass, newStorageClass) { + try { + const params = new KubernetesCommonParams(); + params.id = newStorageClass.Name; + const payload = KubernetesStorageClassConverter.patchPayload(oldStorageClass, newStorageClass); + await this.KubernetesStorage().patch(params, payload).$promise; + } catch (err) { + throw new PortainerError('Unable to patch storage class', err); + } + } + + patch(oldStorageClass, newStorageClass) { + return this.$async(this.patchAsync, oldStorageClass, newStorageClass); + } } export default KubernetesStorageService; diff --git a/app/kubernetes/views/applications/create/createApplication.html b/app/kubernetes/views/applications/create/createApplication.html index 4dfaf0d00..57315ce2f 100644 --- a/app/kubernetes/views/applications/create/createApplication.html +++ b/app/kubernetes/views/applications/create/createApplication.html @@ -319,8 +319,8 @@ -
-
+
+
path in container
-
+
+ + + + +
+ +
requested size
-
+
storage + + +
+ +
+
+ + +
@@ -401,13 +450,26 @@
-
+
+ +

Size is required.

This value must be greater than zero.

+
+ +

Volume is required.

+
+

This volume is already used.

+
@@ -467,7 +529,12 @@

All the instances of this application will use the same data

-
+
Every instance of this application will use their own data

-
+
diff --git a/app/kubernetes/views/volumes/edit/volumeController.js b/app/kubernetes/views/volumes/edit/volumeController.js index a91be622c..6f20455c9 100644 --- a/app/kubernetes/views/volumes/edit/volumeController.js +++ b/app/kubernetes/views/volumes/edit/volumeController.js @@ -2,10 +2,23 @@ import angular from 'angular'; import _ from 'lodash-es'; import KubernetesVolumeHelper from 'Kubernetes/helpers/volumeHelper'; import KubernetesEventHelper from 'Kubernetes/helpers/eventHelper'; +import filesizeParser from 'filesize-parser'; class KubernetesVolumeController { /* @ngInject */ - constructor($async, $state, Notifications, LocalStorage, KubernetesVolumeService, KubernetesEventService, KubernetesNamespaceHelper, KubernetesApplicationService) { + constructor( + $async, + $state, + Notifications, + LocalStorage, + KubernetesVolumeService, + KubernetesEventService, + KubernetesNamespaceHelper, + KubernetesApplicationService, + KubernetesPersistentVolumeClaimService, + ModalService, + KubernetesPodService + ) { this.$async = $async; this.$state = $state; this.Notifications = Notifications; @@ -15,10 +28,14 @@ class KubernetesVolumeController { this.KubernetesEventService = KubernetesEventService; this.KubernetesNamespaceHelper = KubernetesNamespaceHelper; this.KubernetesApplicationService = KubernetesApplicationService; + this.KubernetesPersistentVolumeClaimService = KubernetesPersistentVolumeClaimService; + this.ModalService = ModalService; + this.KubernetesPodService = KubernetesPodService; this.onInit = this.onInit.bind(this); this.getVolume = this.getVolume.bind(this); this.getVolumeAsync = this.getVolumeAsync.bind(this); + this.updateVolumeAsync = this.updateVolumeAsync.bind(this); this.getEvents = this.getEvents.bind(this); this.getEventsAsync = this.getEventsAsync.bind(this); } @@ -44,9 +61,57 @@ class KubernetesVolumeController { return KubernetesVolumeHelper.isUsed(this.volume); } + onChangeSize() { + if (this.state.volumeSize) { + const size = filesizeParser(this.state.volumeSize + this.state.volumeSizeUnit); + if (this.state.oldVolumeSize > size) { + this.state.volumeSizeError = true; + } else { + this.volume.PersistentVolumeClaim.Storage = size; + this.state.volumeSizeError = false; + } + } + } + + sizeIsValid() { + return !this.state.volumeSizeError && this.state.oldVolumeSize !== this.volume.PersistentVolumeClaim.Storage; + } + /** * VOLUME */ + + async updateVolumeAsync(redeploy) { + try { + this.volume.PersistentVolumeClaim.Storage = this.state.volumeSize + this.state.volumeSizeUnit.charAt(0) + 'i'; + await this.KubernetesPersistentVolumeClaimService.patch(this.oldVolume.PersistentVolumeClaim, this.volume.PersistentVolumeClaim); + this.Notifications.success('Volume successfully updated'); + + if (redeploy) { + const promises = _.flatten( + _.map(this.volume.Applications, (app) => { + return _.map(app.Pods, (item) => this.KubernetesPodService.delete(item)); + }) + ); + await Promise.all(promises); + this.Notifications.success('Applications successfully redeployed'); + } + + this.$state.reload(); + } catch (err) { + this.Notifications.error('Failure', err, 'Unable to update volume.'); + } + } + + updateVolume() { + this.ModalService.confirmRedeploy( + 'One or multiple applications are currently using this volume.
For the change to be taken into account these applications will need to be redeployed. Do you want us to reschedule it now?', + (redeploy) => { + return this.$async(this.updateVolumeAsync, redeploy); + } + ); + } + async getVolumeAsync() { try { const [volume, applications] = await Promise.all([ @@ -55,6 +120,10 @@ class KubernetesVolumeController { ]); volume.Applications = KubernetesVolumeHelper.getUsingApplications(volume, applications); this.volume = volume; + this.oldVolume = angular.copy(volume); + this.state.volumeSize = parseInt(volume.PersistentVolumeClaim.Storage.slice(0, -2)); + this.state.volumeSizeUnit = volume.PersistentVolumeClaim.Storage.slice(-2); + this.state.oldVolumeSize = filesizeParser(volume.PersistentVolumeClaim.Storage); } catch (err) { this.Notifications.error('Failure', err, 'Unable to retrieve volume'); } @@ -101,6 +170,11 @@ class KubernetesVolumeController { namespace: this.$transition$.params().namespace, name: this.$transition$.params().name, eventWarningCount: 0, + availableSizeUnits: ['MB', 'GB', 'TB'], + increaseSize: false, + volumeSize: 0, + volumeSizeUnit: 'GB', + volumeSizeError: false, }; this.state.activeTab = this.LocalStorage.getActiveTab('volume'); diff --git a/app/portainer/services/modalService.js b/app/portainer/services/modalService.js index 196483ba4..18e6f0b14 100644 --- a/app/portainer/services/modalService.js +++ b/app/portainer/services/modalService.js @@ -150,6 +150,24 @@ angular.module('portainer.app').factory('ModalService', [ }); }; + service.confirmRedeploy = function (message, callback) { + message = $sanitize(message); + service.confirm({ + title: '', + message: message, + buttons: { + confirm: { + label: 'Redeploy the applications', + className: 'btn-primary', + }, + cancel: { + label: "I'll do it later", + }, + }, + callback: callback, + }); + }; + service.confirmDeletionAsync = function confirmDeletionAsync(message) { return new Promise((resolve) => { service.confirmDeletion(message, (confirmed) => resolve(confirmed));