import _ from 'lodash-es'; import angular from 'angular'; import { KubernetesStorageClass, KubernetesStorageClassAccessPolicies } from 'Kubernetes/models/storage-class/models'; import { KubernetesFormValidationReferences } from 'Kubernetes/models/application/formValues'; import { KubernetesIngressClassTypes } from 'Kubernetes/ingress/constants'; import KubernetesNamespaceHelper from 'Kubernetes/helpers/namespaceHelper'; import { FeatureId } from '@/react/portainer/feature-flags/enums'; import { getIngressControllerClassMap, updateIngressControllerClassMap } from '@/react/kubernetes/cluster/ingressClass/utils'; import { buildConfirmButton } from '@@/modals/utils'; import { confirm } from '@@/modals/confirm'; import { getIsRBACEnabled } from '@/react/kubernetes/cluster/getIsRBACEnabled'; class KubernetesConfigureController { /* #region CONSTRUCTOR */ /* @ngInject */ constructor( $async, $state, $scope, Notifications, KubernetesStorageService, EndpointService, EndpointProvider, KubernetesResourcePoolService, KubernetesIngressService, KubernetesMetricsService ) { this.$async = $async; this.$state = $state; this.$scope = $scope; this.Notifications = Notifications; this.KubernetesStorageService = KubernetesStorageService; this.EndpointService = EndpointService; this.EndpointProvider = EndpointProvider; this.KubernetesResourcePoolService = KubernetesResourcePoolService; this.KubernetesIngressService = KubernetesIngressService; this.KubernetesMetricsService = KubernetesMetricsService; this.IngressClassTypes = KubernetesIngressClassTypes; this.onInit = this.onInit.bind(this); this.configureAsync = this.configureAsync.bind(this); this.areControllersChanged = this.areControllersChanged.bind(this); this.areFormValuesChanged = this.areFormValuesChanged.bind(this); this.areStorageClassesChanged = this.areStorageClassesChanged.bind(this); this.onBeforeOnload = this.onBeforeOnload.bind(this); this.limitedFeature = FeatureId.K8S_SETUP_DEFAULT; this.limitedFeatureAutoWindow = FeatureId.HIDE_AUTO_UPDATE_WINDOW; this.limitedFeatureIngressDeploy = FeatureId.K8S_ADM_ONLY_USR_INGRESS_DEPLY; this.onToggleAutoUpdate = this.onToggleAutoUpdate.bind(this); this.onChangeControllers = this.onChangeControllers.bind(this); this.onChangeEnableResourceOverCommit = this.onChangeEnableResourceOverCommit.bind(this); this.onToggleIngressAvailabilityPerNamespace = this.onToggleIngressAvailabilityPerNamespace.bind(this); this.onToggleAllowNoneIngressClass = this.onToggleAllowNoneIngressClass.bind(this); this.onChangeStorageClassAccessMode = this.onChangeStorageClassAccessMode.bind(this); this.onToggleRestrictNs = this.onToggleRestrictNs.bind(this); } /* #endregion */ /* #region STORAGE CLASSES UI MANAGEMENT */ storageClassAvailable() { return this.StorageClasses && this.StorageClasses.length > 0; } hasValidStorageConfiguration() { let valid = true; _.forEach(this.StorageClasses, (item) => { if (item.selected && item.AccessModes.length === 0) { valid = false; } }); return valid; } /* #endregion */ /* #region INGRESS CLASSES UI MANAGEMENT */ onChangeControllers(controllerClassMap) { this.ingressControllers = controllerClassMap; } hasTraefikIngress() { return _.find(this.formValues.IngressClasses, { Type: this.IngressClassTypes.TRAEFIK }); } toggleAdvancedIngSettings() { this.$scope.$evalAsync(() => { this.state.isIngToggleSectionExpanded = !this.state.isIngToggleSectionExpanded; }); } onToggleAllowNoneIngressClass() { this.$scope.$evalAsync(() => { this.formValues.AllowNoneIngressClass = !this.formValues.AllowNoneIngressClass; }); } onToggleIngressAvailabilityPerNamespace() { this.$scope.$evalAsync(() => { this.formValues.IngressAvailabilityPerNamespace = !this.formValues.IngressAvailabilityPerNamespace; }); } /* #endregion */ /* #region RESOURCES AND METRICS */ onChangeEnableResourceOverCommit(enabled) { this.$scope.$evalAsync(() => { this.formValues.EnableResourceOverCommit = enabled; if (enabled) { this.formValues.ResourceOverCommitPercentage = 20; } }); } /* #endregion */ /* #region CONFIGURE */ assignFormValuesToEndpoint(endpoint, storageClasses, ingressClasses) { endpoint.Kubernetes.Configuration.StorageClasses = storageClasses; endpoint.Kubernetes.Configuration.UseLoadBalancer = this.formValues.UseLoadBalancer; endpoint.Kubernetes.Configuration.UseServerMetrics = this.formValues.UseServerMetrics; endpoint.Kubernetes.Configuration.EnableResourceOverCommit = this.formValues.EnableResourceOverCommit; endpoint.Kubernetes.Configuration.ResourceOverCommitPercentage = this.formValues.ResourceOverCommitPercentage; endpoint.Kubernetes.Configuration.IngressClasses = ingressClasses; endpoint.Kubernetes.Configuration.RestrictDefaultNamespace = this.formValues.RestrictDefaultNamespace; endpoint.Kubernetes.Configuration.IngressAvailabilityPerNamespace = this.formValues.IngressAvailabilityPerNamespace; endpoint.Kubernetes.Configuration.AllowNoneIngressClass = this.formValues.AllowNoneIngressClass; endpoint.ChangeWindow = this.state.autoUpdateSettings; } transformFormValues() { const storageClasses = _.map(this.StorageClasses, (item) => { if (item.selected) { const res = new KubernetesStorageClass(); res.Name = item.Name; res.AccessModes = _.map(item.AccessModes, 'Name'); res.Provisioner = item.Provisioner; res.AllowVolumeExpansion = item.AllowVolumeExpansion; return res; } }); _.pull(storageClasses, undefined); const ingressClasses = _.without( _.map(this.formValues.IngressClasses, (ic) => (ic.NeedsDeletion ? undefined : ic)), undefined ); _.pull(ingressClasses, undefined); return [storageClasses, ingressClasses]; } async removeIngressesAcrossNamespaces() { const ingressesToDel = _.filter(this.formValues.IngressClasses, { NeedsDeletion: true }); if (!ingressesToDel.length) { return; } const promises = []; const oldEndpoint = this.EndpointProvider.currentEndpoint(); this.EndpointProvider.setCurrentEndpoint(this.endpoint); try { const allResourcePools = await this.KubernetesResourcePoolService.get(); const resourcePools = _.filter( allResourcePools, (resourcePool) => !KubernetesNamespaceHelper.isSystemNamespace(resourcePool.Namespace.Name) && !KubernetesNamespaceHelper.isDefaultNamespace(resourcePool.Namespace.Name) ); ingressesToDel.forEach((ingress) => { resourcePools.forEach((resourcePool) => { promises.push(this.KubernetesIngressService.delete(resourcePool.Namespace.Name, ingress.Name)); }); }); } finally { this.EndpointProvider.setCurrentEndpoint(oldEndpoint); } const responses = await Promise.allSettled(promises); responses.forEach((respons) => { if (respons.status == 'rejected' && respons.reason.err.status != 404) { throw respons.reason; } }); } enableMetricsServer() { if (this.formValues.UseServerMetrics) { this.state.metrics.userClick = true; this.state.metrics.pending = true; this.KubernetesMetricsService.capabilities(this.endpoint.Id) .then(() => { this.state.metrics.isServerRunning = true; this.state.metrics.pending = false; this.formValues.UseServerMetrics = true; }) .catch(() => { this.state.metrics.isServerRunning = false; this.state.metrics.pending = false; this.formValues.UseServerMetrics = false; }); } else { this.state.metrics.userClick = false; this.formValues.UseServerMetrics = false; } } async configureAsync() { try { this.state.actionInProgress = true; const [storageClasses, ingressClasses] = this.transformFormValues(); await this.removeIngressesAcrossNamespaces(); this.assignFormValuesToEndpoint(this.endpoint, storageClasses, ingressClasses); await this.EndpointService.updateEndpoint(this.endpoint.Id, this.endpoint); // updateIngressControllerClassMap must be done after updateEndpoint, as a hacky workaround. A better solution: saving ingresscontrollers somewhere else, is being discussed await updateIngressControllerClassMap(this.state.endpointId, this.ingressControllers || []); this.state.isSaving = true; const storagePromises = _.map(storageClasses, (storageClass) => { const oldStorageClass = _.find(this.oldStorageClasses, { Name: storageClass.Name }); if (oldStorageClass) { return this.KubernetesStorageService.patch(this.state.endpointId, oldStorageClass, storageClass); } }); await Promise.all(storagePromises); this.$state.reload(); this.Notifications.success('Success', 'Configuration successfully applied'); } catch (err) { this.Notifications.error('Failure', err, 'Unable to apply configuration'); } finally { this.state.actionInProgress = false; } } configure() { return this.$async(this.configureAsync); } /* #endregion */ restrictDefaultToggledOn() { return this.formValues.RestrictDefaultNamespace && !this.oldFormValues.RestrictDefaultNamespace; } onToggleAutoUpdate(value) { return this.$scope.$evalAsync(() => { this.state.autoUpdateSettings.Enabled = value; }); } onChangeStorageClassAccessMode(storageClassName, accessModes) { return this.$scope.$evalAsync(() => { const storageClass = this.StorageClasses.find((item) => item.Name === storageClassName); if (!storageClass) { throw new Error('Storage class not found'); } storageClass.AccessModes = accessModes; }); } onToggleRestrictNs() { this.$scope.$evalAsync(() => { this.formValues.RestrictDefaultNamespace = !this.formValues.RestrictDefaultNamespace; }); } /* #region ON INIT */ async onInit() { this.state = { actionInProgress: false, displayConfigureClassPanel: {}, viewReady: false, isIngToggleSectionExpanded: false, endpointId: this.$state.params.endpointId, duplicates: { ingressClasses: new KubernetesFormValidationReferences(), }, metrics: { pending: false, isServerRunning: false, userClick: false, }, timeZone: '', isSaving: false, }; this.formValues = { UseLoadBalancer: false, UseServerMetrics: false, EnableResourceOverCommit: true, ResourceOverCommitPercentage: 20, IngressClasses: [], RestrictDefaultNamespace: false, enableAutoUpdateTimeWindow: false, IngressAvailabilityPerNamespace: false, }; // default to true if error is thrown this.isRBACEnabled = true; this.isIngressControllersLoading = true; try { this.availableAccessModes = new KubernetesStorageClassAccessPolicies(); [this.StorageClasses, this.endpoint, this.isRBACEnabled] = await Promise.all([ this.KubernetesStorageService.get(this.state.endpointId), this.EndpointService.endpoint(this.state.endpointId), getIsRBACEnabled(this.state.endpointId), ]); this.ingressControllers = await getIngressControllerClassMap({ environmentId: this.state.endpointId }); this.originalIngressControllers = structuredClone(this.ingressControllers) || []; this.state.autoUpdateSettings = this.endpoint.ChangeWindow; _.forEach(this.StorageClasses, (item) => { const storage = _.find(this.endpoint.Kubernetes.Configuration.StorageClasses, (sc) => sc.Name === item.Name); if (storage) { item.selected = true; item.AccessModes = storage.AccessModes.map((name) => this.availableAccessModes.find((accessMode) => accessMode.Name === name)); } else if (this.availableAccessModes.length) { // set a default access mode if the storage class is not enabled and there are available access modes item.AccessModes = [this.availableAccessModes[0]]; } }); this.formValues.UseLoadBalancer = this.endpoint.Kubernetes.Configuration.UseLoadBalancer; this.formValues.UseServerMetrics = this.endpoint.Kubernetes.Configuration.UseServerMetrics; this.formValues.EnableResourceOverCommit = this.endpoint.Kubernetes.Configuration.EnableResourceOverCommit; this.formValues.ResourceOverCommitPercentage = this.endpoint.Kubernetes.Configuration.ResourceOverCommitPercentage; this.formValues.RestrictDefaultNamespace = this.endpoint.Kubernetes.Configuration.RestrictDefaultNamespace; this.formValues.IngressClasses = _.map(this.endpoint.Kubernetes.Configuration.IngressClasses, (ic) => { ic.IsNew = false; ic.NeedsDeletion = false; return ic; }); this.formValues.IngressAvailabilityPerNamespace = this.endpoint.Kubernetes.Configuration.IngressAvailabilityPerNamespace; this.formValues.AllowNoneIngressClass = this.endpoint.Kubernetes.Configuration.AllowNoneIngressClass; this.oldStorageClasses = angular.copy(this.StorageClasses); this.oldFormValues = angular.copy(this.formValues); } catch (err) { this.Notifications.error('Failure', err, 'Unable to retrieve environment configuration'); } finally { this.state.viewReady = true; this.isIngressControllersLoading = false; } window.addEventListener('beforeunload', this.onBeforeOnload); } $onInit() { return this.$async(this.onInit); } /* #endregion */ $onDestroy() { window.removeEventListener('beforeunload', this.onBeforeOnload); } areControllersChanged() { return !_.isEqual(this.ingressControllers, this.originalIngressControllers); } areFormValuesChanged() { return !_.isEqual(this.formValues, this.oldFormValues); } areStorageClassesChanged() { // angular is pesky and modifies this.StorageClasses (adds $$hashkey to each item) // angular.toJson removes this to make the comparison work const storageClassesWithoutHashKey = angular.toJson(this.StorageClasses); const oldStorageClassesWithoutHashKey = angular.toJson(this.oldStorageClasses); return !_.isEqual(storageClassesWithoutHashKey, oldStorageClassesWithoutHashKey); } onBeforeOnload(event) { if (!this.state.isSaving && (this.areControllersChanged() || this.areFormValuesChanged() || this.areStorageClassesChanged())) { event.preventDefault(); event.returnValue = ''; } } uiCanExit() { if (!this.state.isSaving && (this.areControllersChanged() || this.areFormValuesChanged() || this.areStorageClassesChanged()) && !this.isIngressControllersLoading) { return confirm({ title: 'Are you sure?', message: 'You currently have unsaved changes in the cluster setup view. Are you sure you want to leave?', confirmButton: buildConfirmButton('Yes', 'danger'), }); } } } export default KubernetesConfigureController; angular.module('portainer.kubernetes').controller('KubernetesConfigureController', KubernetesConfigureController);