From 4e20d70a99b3eccd91e46a76bb42042bbebe7d53 Mon Sep 17 00:00:00 2001 From: Ali <83188384+testA113@users.noreply.github.com> Date: Fri, 23 Sep 2022 16:35:47 +1200 Subject: [PATCH] feat(secrets): allow creating secrets beyond opaque [EE-2625] (#7709) --- ...plications-datatable-details.controller.js | 4 +- .../applicationsDatatableController.js | 4 +- .../configurationsDatatable.html | 12 +- .../kubernetesConfigurationData.html | 64 ++++-- .../kubernetesConfigurationData.js | 2 + .../kubernetesConfigurationDataController.js | 167 +++++++++++++-- app/kubernetes/converters/configuration.js | 7 +- app/kubernetes/converters/secret.js | 21 +- .../filters/configurationFilters.js | 8 +- app/kubernetes/helpers/application/index.js | 6 +- app/kubernetes/helpers/configurationHelper.js | 4 +- .../models/configuration/formvalues.js | 8 +- app/kubernetes/models/configuration/models.js | 16 +- app/kubernetes/models/secret/models.js | 2 + app/kubernetes/models/secret/payloads.js | 4 +- app/kubernetes/rest/serviceAccount.js | 16 ++ .../services/configurationService.js | 8 +- .../create/createConfiguration.html | 193 +++++++++++++----- .../create/createConfigurationController.js | 120 ++++++++++- .../configurations/edit/configuration.html | 21 +- .../edit/configurationController.js | 36 +++- .../views/configurations/validation.js | 59 ++++++ .../resources/configurationResources.js | 12 +- 23 files changed, 659 insertions(+), 135 deletions(-) create mode 100644 app/kubernetes/rest/serviceAccount.js create mode 100644 app/kubernetes/views/configurations/validation.js diff --git a/app/kubernetes/components/datatables/applications-datatable-details/applications-datatable-details.controller.js b/app/kubernetes/components/datatables/applications-datatable-details/applications-datatable-details.controller.js index b20b42ec9..15099586d 100644 --- a/app/kubernetes/components/datatables/applications-datatable-details/applications-datatable-details.controller.js +++ b/app/kubernetes/components/datatables/applications-datatable-details/applications-datatable-details.controller.js @@ -1,9 +1,9 @@ -import { KubernetesConfigurationTypes } from 'Kubernetes/models/configuration/models'; +import { KubernetesConfigurationKinds } from 'Kubernetes/models/configuration/models'; export default class { $onInit() { const secrets = (this.configurations || []) - .filter((config) => config.Data && config.Type === KubernetesConfigurationTypes.SECRET) + .filter((config) => config.Data && config.Type === KubernetesConfigurationKinds.SECRET) .flatMap((config) => Object.entries(config.Data)) .map(([key, value]) => ({ key, value })); diff --git a/app/kubernetes/components/datatables/applications-datatable/applicationsDatatableController.js b/app/kubernetes/components/datatables/applications-datatable/applicationsDatatableController.js index e876f5550..c199f9edb 100644 --- a/app/kubernetes/components/datatables/applications-datatable/applicationsDatatableController.js +++ b/app/kubernetes/components/datatables/applications-datatable/applicationsDatatableController.js @@ -2,7 +2,7 @@ import _ from 'lodash-es'; import { KubernetesApplicationDeploymentTypes, KubernetesApplicationTypes } from 'Kubernetes/models/application/models'; import KubernetesApplicationHelper from 'Kubernetes/helpers/application'; import KubernetesNamespaceHelper from 'Kubernetes/helpers/namespaceHelper'; -import { KubernetesConfigurationTypes } from 'Kubernetes/models/configuration/models'; +import { KubernetesConfigurationKinds } from 'Kubernetes/models/configuration/models'; angular.module('portainer.docker').controller('KubernetesApplicationsDatatableController', [ '$scope', @@ -112,7 +112,7 @@ angular.module('portainer.docker').controller('KubernetesApplicationsDatatableCo }; this.hasConfigurationSecrets = function (item) { - return item.Configurations && item.Configurations.some((config) => config.Data && config.Type === KubernetesConfigurationTypes.SECRET); + return item.Configurations && item.Configurations.some((config) => config.Data && config.Type === KubernetesConfigurationKinds.SECRET); }; /** diff --git a/app/kubernetes/components/datatables/configurations-datatable/configurationsDatatable.html b/app/kubernetes/components/datatables/configurations-datatable/configurationsDatatable.html index 066762182..c5474fc2d 100644 --- a/app/kubernetes/components/datatables/configurations-datatable/configurationsDatatable.html +++ b/app/kubernetes/components/datatables/configurations-datatable/configurationsDatatable.html @@ -3,7 +3,7 @@
-
+
@@ -125,11 +125,11 @@ @@ -160,7 +160,7 @@ {{ item.Namespace }} - {{ item.Type | kubernetesConfigurationTypeText }} + {{ item.Kind | kubernetesConfigurationKindText }} {{ item.CreationDate | getisodate }} {{ item.ConfigurationOwner ? 'by ' + item.ConfigurationOwner : '' }} diff --git a/app/kubernetes/components/kubernetes-configuration-data/kubernetesConfigurationData.html b/app/kubernetes/components/kubernetes-configuration-data/kubernetesConfigurationData.html index 223135a7c..528ff8f0b 100644 --- a/app/kubernetes/components/kubernetes-configuration-data/kubernetesConfigurationData.html +++ b/app/kubernetes/components/kubernetes-configuration-data/kubernetesConfigurationData.html @@ -21,33 +21,70 @@
-
+
- + + +
- +
-

- This field is required. -

+

This field is required.

- This key is already defined. + This key is already defined.

- This key is invalid. A valid key must - consist of alphanumeric characters, '-', '_' or '.' + This key is invalid. A valid key must consist of alphanumeric + characters, '-', '_' or '.'

@@ -106,10 +141,11 @@
diff --git a/app/kubernetes/views/configurations/create/createConfigurationController.js b/app/kubernetes/views/configurations/create/createConfigurationController.js index e834ded25..c344eddff 100644 --- a/app/kubernetes/views/configurations/create/createConfigurationController.js +++ b/app/kubernetes/views/configurations/create/createConfigurationController.js @@ -1,43 +1,134 @@ import angular from 'angular'; import _ from 'lodash-es'; import { KubernetesConfigurationFormValues, KubernetesConfigurationFormValuesEntry } from 'Kubernetes/models/configuration/formvalues'; -import { KubernetesConfigurationTypes } from 'Kubernetes/models/configuration/models'; +import { KubernetesConfigurationKinds, KubernetesSecretTypes } from 'Kubernetes/models/configuration/models'; import KubernetesConfigurationHelper from 'Kubernetes/helpers/configurationHelper'; import KubernetesNamespaceHelper from 'Kubernetes/helpers/namespaceHelper'; +import { getServiceAccounts } from 'Kubernetes/rest/serviceAccount'; + +import { isConfigurationFormValid } from '../validation'; class KubernetesCreateConfigurationController { /* @ngInject */ - constructor($async, $state, $window, ModalService, Notifications, Authentication, KubernetesConfigurationService, KubernetesResourcePoolService) { + constructor($async, $state, $window, ModalService, Notifications, Authentication, KubernetesConfigurationService, KubernetesResourcePoolService, EndpointProvider) { this.$async = $async; this.$state = $state; this.$window = $window; + this.EndpointProvider = EndpointProvider; this.ModalService = ModalService; this.Notifications = Notifications; this.Authentication = Authentication; this.KubernetesConfigurationService = KubernetesConfigurationService; this.KubernetesResourcePoolService = KubernetesResourcePoolService; - this.KubernetesConfigurationTypes = KubernetesConfigurationTypes; + this.KubernetesConfigurationKinds = KubernetesConfigurationKinds; + this.KubernetesSecretTypes = KubernetesSecretTypes; this.onInit = this.onInit.bind(this); this.createConfigurationAsync = this.createConfigurationAsync.bind(this); this.getConfigurationsAsync = this.getConfigurationsAsync.bind(this); + this.onResourcePoolSelectionChangeAsync = this.onResourcePoolSelectionChangeAsync.bind(this); + this.onSecretTypeChange = this.onSecretTypeChange.bind(this); } onChangeName() { - const filteredConfigurations = _.filter(this.configurations, (config) => config.Namespace === this.formValues.ResourcePool.Namespace.Name); + const filteredConfigurations = _.filter( + this.configurations, + (config) => config.Namespace === this.formValues.ResourcePool.Namespace.Name && config.Kind === this.formValues.Kind + ); this.state.alreadyExist = _.find(filteredConfigurations, (config) => config.Name === this.formValues.Name) !== undefined; } - onResourcePoolSelectionChange() { + onChangeKind() { this.onChangeName(); + // if there is no data field, add one + if (this.formValues.Data.length === 0) { + this.formValues.Data.push(new KubernetesConfigurationFormValuesEntry()); + } + // if changing back to a secret, that is a service account token, remove the data field + if (this.formValues.Kind === this.KubernetesConfigurationKinds.SECRET) { + this.onSecretTypeChange(); + } else { + this.isDockerConfig = false; + } + } + + async onResourcePoolSelectionChangeAsync() { + try { + this.onChangeName(); + this.availableServiceAccounts = await getServiceAccounts(this.environmentId, this.formValues.ResourcePool.Namespace.Name); + this.formValues.ServiceAccountName = this.availableServiceAccounts.length > 0 ? this.availableServiceAccounts[0].metadata.name : ''; + } catch (err) { + this.Notifications.error('Failure', err, 'Unable load service accounts'); + } + } + onResourcePoolSelectionChange() { + this.$async(this.onResourcePoolSelectionChangeAsync); + } + + onSecretTypeChange() { + switch (this.formValues.Type.value) { + case KubernetesSecretTypes.OPAQUE.value: + case KubernetesSecretTypes.CUSTOM.value: + this.formValues.Data = this.formValues.Data.filter((entry) => entry.Value !== ''); + if (this.formValues.Data.length === 0) { + this.addRequiredKeysToForm(['']); + } + this.state.isDockerConfig = false; + break; + case KubernetesSecretTypes.SERVICEACCOUNTTOKEN.value: + // data isn't required for service account tokens, so remove the data fields if they are empty + this.addRequiredKeysToForm([]); + this.state.isDockerConfig = false; + break; + case KubernetesSecretTypes.DOCKERCONFIGJSON.value: + this.addRequiredKeysToForm(['.dockerconfigjson']); + this.state.isDockerConfig = true; + break; + case KubernetesSecretTypes.DOCKERCFG.value: + this.addRequiredKeysToForm(['.dockercfg']); + this.state.isDockerConfig = true; + break; + case KubernetesSecretTypes.BASICAUTH.value: + this.addRequiredKeysToForm(['username', 'password']); + this.state.isDockerConfig = false; + break; + case KubernetesSecretTypes.SSHAUTH.value: + this.addRequiredKeysToForm(['ssh-privatekey']); + this.state.isDockerConfig = false; + break; + case KubernetesSecretTypes.TLS.value: + this.addRequiredKeysToForm(['tls.crt', 'tls.key']); + this.state.isDockerConfig = false; + break; + case KubernetesSecretTypes.BOOTSTRAPTOKEN.value: + this.addRequiredKeysToForm(['token-id', 'token-secret']); + this.state.isDockerConfig = false; + break; + default: + this.state.isDockerConfig = false; + break; + } + this.isFormValid(); + } + + addRequiredKeysToForm(keys) { + // remove data entries that have an empty value + this.formValues.Data = this.formValues.Data.filter((entry) => entry.Value); + + keys.forEach((key) => { + // if the key doesn't exist on the form, add a new formValues.Data entry + if (!this.formValues.Data.some((data) => data.Key === key)) { + this.formValues.Data.push(new KubernetesConfigurationFormValuesEntry()); + const index = this.formValues.Data.length - 1; + this.formValues.Data[index].Key = key; + } + }); } isFormValid() { - const uniqueCheck = !this.state.alreadyExist && this.state.isDataValid; - if (this.formValues.IsSimple) { - return this.formValues.Data.length > 0 && uniqueCheck; - } - return uniqueCheck; + const [isValid, warningMessage] = isConfigurationFormValid(this.state.alreadyExist, this.state.isDataValid, this.formValues); + this.state.secretWarningMessage = warningMessage; + return isValid; } async createConfigurationAsync() { @@ -47,6 +138,7 @@ class KubernetesCreateConfigurationController { if (!this.formValues.IsSimple) { this.formValues.Data = KubernetesConfigurationHelper.parseYaml(this.formValues); } + await this.KubernetesConfigurationService.create(this.formValues); this.Notifications.success('Success', 'Configuration succesfully created'); this.state.isEditorDirty = false; @@ -87,10 +179,12 @@ class KubernetesCreateConfigurationController { alreadyExist: false, isDataValid: true, isEditorDirty: false, + isDockerConfig: false, + secretWarningMessage: '', }; this.formValues = new KubernetesConfigurationFormValues(); - this.formValues.Data.push(new KubernetesConfigurationFormValuesEntry()); + this.formValues.Data = [new KubernetesConfigurationFormValuesEntry()]; try { const resourcePools = await this.KubernetesResourcePoolService.get(); @@ -98,6 +192,10 @@ class KubernetesCreateConfigurationController { this.formValues.ResourcePool = this.resourcePools[0]; await this.getConfigurations(); + + this.environmentId = this.EndpointProvider.endpointID(); + this.availableServiceAccounts = await getServiceAccounts(this.environmentId, this.resourcePools[0].Namespace.Name); + this.formValues.ServiceAccountName = this.availableServiceAccounts.length > 0 ? this.availableServiceAccounts[0].metadata.name : ''; } catch (err) { this.Notifications.error('Failure', err, 'Unable to load view data'); } finally { diff --git a/app/kubernetes/views/configurations/edit/configuration.html b/app/kubernetes/views/configurations/edit/configuration.html index 26346fe49..bedfea72f 100644 --- a/app/kubernetes/views/configurations/edit/configuration.html +++ b/app/kubernetes/views/configurations/edit/configuration.html @@ -46,9 +46,15 @@ - Configuration type + Configuration kind - {{ ctrl.configuration.Type | kubernetesConfigurationTypeText }} + {{ ctrl.configuration.Kind | kubernetesConfigurationKindText }} + + + + Secret Type + + {{ ctrl.secretTypeName }} @@ -99,11 +105,20 @@ +
+
+ + {{ ctrl.state.secretWarningMessage }} +
+
+ - Update {{ ctrl.configuration.Type | kubernetesConfigurationTypeText }} + Update {{ ctrl.configuration.Kind | kubernetesConfigurationKindText }} Update in progress...
diff --git a/app/kubernetes/views/configurations/edit/configurationController.js b/app/kubernetes/views/configurations/edit/configurationController.js index 3a8fdc0aa..283793568 100644 --- a/app/kubernetes/views/configurations/edit/configurationController.js +++ b/app/kubernetes/views/configurations/edit/configurationController.js @@ -2,12 +2,14 @@ import angular from 'angular'; import _ from 'lodash-es'; import { KubernetesConfigurationFormValues } from 'Kubernetes/models/configuration/formvalues'; -import { KubernetesConfigurationTypes } from 'Kubernetes/models/configuration/models'; +import { KubernetesConfigurationKinds, KubernetesSecretTypes } from 'Kubernetes/models/configuration/models'; import KubernetesConfigurationHelper from 'Kubernetes/helpers/configurationHelper'; import KubernetesConfigurationConverter from 'Kubernetes/converters/configuration'; import KubernetesEventHelper from 'Kubernetes/helpers/eventHelper'; import KubernetesNamespaceHelper from 'Kubernetes/helpers/namespaceHelper'; +import { isConfigurationFormValid } from '../validation'; + class KubernetesConfigurationController { /* @ngInject */ constructor( @@ -36,7 +38,8 @@ class KubernetesConfigurationController { this.KubernetesResourcePoolService = KubernetesResourcePoolService; this.KubernetesApplicationService = KubernetesApplicationService; this.KubernetesEventService = KubernetesEventService; - this.KubernetesConfigurationTypes = KubernetesConfigurationTypes; + this.KubernetesConfigurationKinds = KubernetesConfigurationKinds; + this.KubernetesSecretTypes = KubernetesSecretTypes; this.KubernetesConfigMapService = KubernetesConfigMapService; this.KubernetesSecretService = KubernetesSecretService; @@ -76,10 +79,9 @@ class KubernetesConfigurationController { } isFormValid() { - if (this.formValues.IsSimple) { - return this.formValues.Data.length > 0 && this.state.isDataValid; - } - return this.state.isDataValid; + const [isValid, warningMessage] = isConfigurationFormValid(this.state.alreadyExist, this.state.isDataValid, this.formValues); + this.state.secretWarningMessage = warningMessage; + return isValid; } // TODO: refactor @@ -89,7 +91,7 @@ class KubernetesConfigurationController { try { this.state.actionInProgress = true; if ( - this.formValues.Type !== this.configuration.Type || + this.formValues.Kind !== this.configuration.Kind || this.formValues.ResourcePool.Namespace.Name !== this.configuration.Namespace || this.formValues.Name !== this.configuration.Name ) { @@ -153,6 +155,7 @@ class KubernetesConfigurationController { this.formValues.Id = this.configuration.Id; this.formValues.Name = this.configuration.Name; this.formValues.Type = this.configuration.Type; + this.formValues.Kind = this.configuration.Kind; this.oldDataYaml = this.formValues.DataYaml; return this.configuration; @@ -254,6 +257,8 @@ class KubernetesConfigurationController { currentName: this.$state.$current.name, isDataValid: true, isEditorDirty: false, + isDockerConfig: false, + secretWarningMessage: '', }; this.state.activeTab = this.LocalStorage.getActiveTab('configuration'); @@ -267,6 +272,23 @@ class KubernetesConfigurationController { await this.getEvents(this.configuration.Namespace); await this.getConfigurations(); } + + // after loading the configuration, check if it is a docker config secret type + if ( + this.formValues.Kind === this.KubernetesConfigurationKinds.SECRET && + (this.formValues.Type === this.KubernetesSecretTypes.DOCKERCONFIGJSON.value || this.formValues.Type === this.KubernetesSecretTypes.DOCKERCFG.value) + ) { + this.state.isDockerConfig = true; + } + // convert the secret type to a human readable value + if (this.formValues.Type) { + const secretTypeValues = Object.values(this.KubernetesSecretTypes); + const secretType = secretTypeValues.find((secretType) => secretType.value === this.formValues.Type); + this.secretTypeName = secretType ? secretType.name : this.formValues.Type; + } else { + this.secretTypeName = ''; + } + this.tagUsedDataKeys(); } catch (err) { this.Notifications.error('Failure', err, 'Unable to load view data'); diff --git a/app/kubernetes/views/configurations/validation.js b/app/kubernetes/views/configurations/validation.js new file mode 100644 index 000000000..2e4a7b741 --- /dev/null +++ b/app/kubernetes/views/configurations/validation.js @@ -0,0 +1,59 @@ +import { KubernetesSecretTypes } from '@/kubernetes/models/configuration/models'; +import { KubernetesConfigurationKinds } from '@/kubernetes/models/configuration/models'; + +export function isConfigurationFormValid(alreadyExist, isDataValid, formValues) { + const uniqueCheck = !alreadyExist && isDataValid; + let secretWarningMessage = ''; + let isFormValid = false; + + if (formValues.IsSimple) { + if (formValues.Kind === KubernetesConfigurationKinds.SECRET) { + let isSecretDataValid = true; + const secretTypeValue = typeof formValues.Type === 'string' ? formValues.Type : formValues.Type.value; + + switch (secretTypeValue) { + case KubernetesSecretTypes.SERVICEACCOUNTTOKEN.value: + // data isn't required for service account tokens + isFormValid = uniqueCheck && formValues.ResourcePool; + return [isFormValid, '']; + case KubernetesSecretTypes.DOCKERCFG.value: + // needs to contain a .dockercfg key + isSecretDataValid = formValues.Data.some((entry) => entry.Key === '.dockercfg'); + secretWarningMessage = isSecretDataValid ? '' : 'A data entry with a .dockercfg key is required.'; + break; + case KubernetesSecretTypes.DOCKERCONFIGJSON.value: + // needs to contain a .dockerconfigjson key + isSecretDataValid = formValues.Data.some((entry) => entry.Key === '.dockerconfigjson'); + secretWarningMessage = isSecretDataValid ? '' : 'A data entry with a .dockerconfigjson key. is required.'; + break; + case KubernetesSecretTypes.BASICAUTH.value: + isSecretDataValid = formValues.Data.some((entry) => entry.Key === 'username' || entry.Key === 'password'); + secretWarningMessage = isSecretDataValid ? '' : 'A data entry with a username or password key is required.'; + break; + case KubernetesSecretTypes.SSHAUTH.value: + isSecretDataValid = formValues.Data.some((entry) => entry.Key === 'ssh-privatekey'); + secretWarningMessage = isSecretDataValid ? '' : `A data entry with a 'ssh-privatekey' key is required.`; + break; + case KubernetesSecretTypes.TLS.value: + isSecretDataValid = formValues.Data.some((entry) => entry.Key === 'tls.crt') && formValues.Data.some((entry) => entry.Key === 'tls.key'); + secretWarningMessage = isSecretDataValid ? '' : `Data entries containing a 'tls.crt' key and a 'tls.key' key are required.`; + break; + case KubernetesSecretTypes.BOOTSTRAPTOKEN.value: + isSecretDataValid = formValues.Data.some((entry) => entry.Key === 'token-id') && formValues.Data.some((entry) => entry.Key === 'token-secret'); + secretWarningMessage = isSecretDataValid ? '' : `Data entries containing a 'token-id' key and a 'token-secret' key are required.`; + break; + default: + break; + } + + isFormValid = uniqueCheck && formValues.ResourcePool && formValues.Data.length >= 1 && isSecretDataValid; + return [isFormValid, secretWarningMessage]; + } + + isFormValid = formValues.Data.length > 0 && uniqueCheck && formValues.ResourcePool; + return [isFormValid, secretWarningMessage]; + } + + isFormValid = uniqueCheck && formValues.ResourcePool; + return [isFormValid, secretWarningMessage]; +} diff --git a/app/kubernetes/views/summary/resources/configurationResources.js b/app/kubernetes/views/summary/resources/configurationResources.js index a2e04e1b0..cd2a7ec5b 100644 --- a/app/kubernetes/views/summary/resources/configurationResources.js +++ b/app/kubernetes/views/summary/resources/configurationResources.js @@ -1,13 +1,17 @@ import { KubernetesResourceTypes, KubernetesResourceActions } from 'Kubernetes/models/resource-types/models'; -import { KubernetesConfigurationTypes } from 'Kubernetes/models/configuration/models'; +import { KubernetesConfigurationKinds } from 'Kubernetes/models/configuration/models'; const { CREATE, UPDATE } = KubernetesResourceActions; export default function (formValues) { const action = formValues.Id ? UPDATE : CREATE; - if (formValues.Type === KubernetesConfigurationTypes.CONFIGMAP) { + if (formValues.Kind === KubernetesConfigurationKinds.CONFIGMAP) { return [{ action, kind: KubernetesResourceTypes.CONFIGMAP, name: formValues.Name }]; - } else if (formValues.Type === KubernetesConfigurationTypes.SECRET) { - return [{ action, kind: KubernetesResourceTypes.SECRET, name: formValues.Name }]; + } else if (formValues.Kind === KubernetesConfigurationKinds.SECRET) { + let type = typeof formValues.Type === 'string' ? formValues.Type : formValues.Type.name; + if (formValues.customType) { + type = formValues.customType; + } + return [{ action, kind: KubernetesResourceTypes.SECRET, name: formValues.Name, type }]; } }