diff --git a/backend/lib/routes/config.js b/backend/lib/routes/config.js index c9c6734318..e77cf53c0d 100644 --- a/backend/lib/routes/config.js +++ b/backend/lib/routes/config.js @@ -57,7 +57,8 @@ function sanitizeFrontendConfig (frontendConfig) { } = {}, vendorHints = [], resourceQuotaHelp = '', - controlPlaneHighAvailabilityHelp = '' + controlPlaneHighAvailabilityHelp = '', + customCloudProviders = {} } = sanitizedFrontendConfig convertAndSanitize(alert, 'message') @@ -89,5 +90,12 @@ function sanitizeFrontendConfig (frontendConfig) { convertAndSanitize(vendorHint, 'message') } + for (const key of Object.keys(customCloudProviders)) { + const secret = customCloudProviders[key]?.secret + if (secret) { + convertAndSanitize(secret, 'help') + } + } + return sanitizedFrontendConfig } diff --git a/charts/__tests__/gardener-dashboard/runtime/dashboard/__snapshots__/configmap.spec.js.snap b/charts/__tests__/gardener-dashboard/runtime/dashboard/__snapshots__/configmap.spec.js.snap index 13a43a6b0d..beda9a38f5 100644 --- a/charts/__tests__/gardener-dashboard/runtime/dashboard/__snapshots__/configmap.spec.js.snap +++ b/charts/__tests__/gardener-dashboard/runtime/dashboard/__snapshots__/configmap.spec.js.snap @@ -93,6 +93,17 @@ Object { } `; +exports[`gardener-dashboard configmap cloudProviderList should render the template with cloudProviderList 1`] = ` +Object { + "frontend": Object { + "cloudProviderList": Array [ + "foo", + "bar", + ], + }, +} +`; + exports[`gardener-dashboard configmap controlPlaneHighAvailabilityHelp should render the template with controlPlaneHighAvailabilityHelp markdown 1`] = ` Object { "frontend": Object { @@ -103,6 +114,55 @@ Object { } `; +exports[`gardener-dashboard configmap customCloudProviders should render the template with customCloudProviders 1`] = ` +Object { + "frontend": Object { + "customCloudProviders": Object { + "bar": Object { + "secret": Object { + "fields": Array [ + Object { + "key": "user", + "type": "text", + }, + Object { + "key": "password", + "type": "password", + }, + ], + }, + "shoot": Object { + "provider": Object { + "type": "bar", + }, + }, + "zoned": false, + }, + "foo": Object { + "secret": Object { + "fields": Array [ + Object { + "key": "user", + "type": "text", + }, + Object { + "key": "password", + "type": "password", + }, + ], + }, + "shoot": Object { + "provider": Object { + "type": "foo", + }, + }, + "zoned": true, + }, + }, + }, +} +`; + exports[`gardener-dashboard configmap knownConditions should render the template with knownConditions markdown 1`] = ` Object { "frontend": Object { @@ -546,3 +606,15 @@ Object { }, } `; + +exports[`gardener-dashboard configmap vendors should render the template with vendors 1`] = ` +Object { + "frontend": Object { + "vendors": Object { + "foo": Object { + "icon": "foo_icon.svg", + }, + }, + }, +} +`; diff --git a/charts/__tests__/gardener-dashboard/runtime/dashboard/configmap.spec.js b/charts/__tests__/gardener-dashboard/runtime/dashboard/configmap.spec.js index 1b4e467b79..538ce34bb7 100644 --- a/charts/__tests__/gardener-dashboard/runtime/dashboard/configmap.spec.js +++ b/charts/__tests__/gardener-dashboard/runtime/dashboard/configmap.spec.js @@ -589,6 +589,111 @@ describe('gardener-dashboard', function () { }) }) + describe('cloudProviderList', function () { + it('should render the template with cloudProviderList', async function () { + const values = { + global: { + dashboard: { + frontendConfig: { + cloudProviderList: [ + 'foo', + 'bar' + ] + } + } + } + } + const documents = await renderTemplates(templates, values) + expect(documents).toHaveLength(1) + const [configMap] = documents + const config = yaml.load(configMap.data['config.yaml']) + expect(pick(config, ['frontend.cloudProviderList'])).toMatchSnapshot() + }) + }) + + describe('customCloudProviders', function () { + it('should render the template with customCloudProviders', async function () { + const values = { + global: { + dashboard: { + frontendConfig: { + customCloudProviders: { + foo: { + zoned: true, + shoot: { + provider: { + type: 'foo' + } + }, + secret: { + fields: [ + { + key: 'user', + type: 'text' + }, + { + key: 'password', + type: 'password' + } + ] + } + }, + bar: { + zoned: false, + shoot: { + provider: { + type: 'bar' + } + }, + secret: { + fields: [ + { + key: 'user', + type: 'text' + }, + { + key: 'password', + type: 'password' + } + ] + } + } + } + } + } + } + } + const documents = await renderTemplates(templates, values) + expect(documents).toHaveLength(1) + const [configMap] = documents + const config = yaml.load(configMap.data['config.yaml']) + expect(pick(config, ['frontend.customCloudProviders'])).toMatchSnapshot() + }) + }) + + describe('vendors', function () { + it('should render the template with vendors', async function () { + const values = { + global: { + dashboard: { + frontendConfig: { + vendors: { + foo: { + icon: 'foo_icon.svg' + } + } + } + } + } + } + const documents = await renderTemplates(templates, values) + expect(documents).toHaveLength(1) + const [configMap] = documents + const config = yaml.load(configMap.data['config.yaml']) + expect(pick(config, ['frontend.vendors'])).toMatchSnapshot() + }) + }) + describe('knownConditions', function () { it('should render the template with knownConditions markdown', async function () { const values = { diff --git a/charts/gardener-dashboard/charts/runtime/templates/dashboard/configmap-vendor-assets.yaml b/charts/gardener-dashboard/charts/runtime/templates/dashboard/configmap-vendor-assets.yaml new file mode 100644 index 0000000000..c8b426157d --- /dev/null +++ b/charts/gardener-dashboard/charts/runtime/templates/dashboard/configmap-vendor-assets.yaml @@ -0,0 +1,20 @@ +{{- if .Values.global.dashboard.enabled }} +{{- if .Values.global.dashboard.frontendConfig.vendorAssets }} +apiVersion: v1 +kind: ConfigMap +metadata: + name: dashboard-vendor-assets + namespace: {{ .Release.Namespace }} + labels: + app.kubernetes.io/name: gardener-dashboard + app.kubernetes.io/component: dashboard + helm.sh/chart: "{{ .Chart.Name }}-{{ .Chart.Version }}" + app.kubernetes.io/instance: "{{ .Release.Name }}" + app.kubernetes.io/managed-by: "{{ .Release.Service }}" +binaryData: +{{- range $file, $content := .Values.global.dashboard.frontendConfig.vendorAssets }} + {{ $file }}: | + {{ $content }} +{{- end }} +{{- end }} +{{- end }} diff --git a/charts/gardener-dashboard/charts/runtime/templates/dashboard/configmap.yaml b/charts/gardener-dashboard/charts/runtime/templates/dashboard/configmap.yaml index fe10774c6a..729b874586 100644 --- a/charts/gardener-dashboard/charts/runtime/templates/dashboard/configmap.yaml +++ b/charts/gardener-dashboard/charts/runtime/templates/dashboard/configmap.yaml @@ -325,6 +325,18 @@ data: {{- if .Values.global.dashboard.frontendConfig.serviceAccountDefaultTokenExpiration }} serviceAccountDefaultTokenExpiration: {{ .Values.global.dashboard.frontendConfig.serviceAccountDefaultTokenExpiration }} {{- end }} + {{- if .Values.global.dashboard.frontendConfig.cloudProviderList }} + cloudProviderList: + {{- range .Values.global.dashboard.frontendConfig.cloudProviderList }} + - {{ . }} + {{- end }} + {{- end }} + {{- if .Values.global.dashboard.frontendConfig.customCloudProviders }} + customCloudProviders: {{ toYaml .Values.global.dashboard.frontendConfig.customCloudProviders | nindent 8 }} + {{- end }} + {{- if .Values.global.dashboard.frontendConfig.vendors }} + vendors: {{ toYaml .Values.global.dashboard.frontendConfig.vendors | nindent 8 }} + {{- end }} {{- if .Values.global.dashboard.frontendConfig.knownConditions }} knownConditions: {{ toYaml .Values.global.dashboard.frontendConfig.knownConditions | nindent 8 }} {{- end }} diff --git a/charts/gardener-dashboard/charts/runtime/templates/dashboard/deployment.yaml b/charts/gardener-dashboard/charts/runtime/templates/dashboard/deployment.yaml index 2e8edebc05..79e371df0c 100644 --- a/charts/gardener-dashboard/charts/runtime/templates/dashboard/deployment.yaml +++ b/charts/gardener-dashboard/charts/runtime/templates/dashboard/deployment.yaml @@ -64,6 +64,12 @@ spec: name: dashboard-assets defaultMode: 0444 {{- end }} + {{- if .Values.global.dashboard.frontendConfig.vendorAssets }} + - name: vendor-assets + configMap: + name: dashboard-vendor-assets + defaultMode: 0444 + {{- end }} {{- if .Values.global.dashboard.serviceAccountTokenVolumeProjection.enabled }} - name: service-account-token projected: @@ -191,6 +197,10 @@ spec: - name: assets mountPath: /app/public/static/assets {{- end }} + {{- if .Values.global.dashboard.frontendConfig.vendorAssets }} + - name: vendor-assets + mountPath: /app/public/static/vendor-assets + {{- end }} {{- if .Values.global.dashboard.serviceAccountTokenVolumeProjection.enabled }} - name: service-account-token mountPath: /var/run/secrets/projected/serviceaccount diff --git a/charts/gardener-dashboard/values.yaml b/charts/gardener-dashboard/values.yaml index b0f72bb687..249dc178f1 100644 --- a/charts/gardener-dashboard/values.yaml +++ b/charts/gardener-dashboard/values.yaml @@ -305,6 +305,43 @@ global: # controlPlaneHighAvailabilityHelp: # text: Help text + # # cloudProviderList - configure available cloud providers. This allows to change order and to add additional (not built-in providers), see also customCloudProviders + # cloudProviderList: + # - aws + # - custom-provider + + # # customCloudProviders - configure additional cloud providers + # customCloudProviders: + # custom-provider: + # zoned: true + # shoot: # shoot template for this provider + # specTemplate: + # provider: + # type: custom-provider + # networking: + # nodes: ${workerCIDR} + # secret: # secret dialog + # fields: + # - key: user + # hint: Enter a valid user + # label: User + # type: text + # validators: + # required: + # type: required + # validationErrors: + # required: User is required + # help: | # help text for secret dialog + # #Foo Cloud Provider + + # # vendors - configure additional or overwrite built-in vendor icons + # vendors: + # custom-provider: + # icon: custom-provider.svg + # + # vendorAssets: + # custom-provider.svg: | + # # knownConditions - overwrite or add shoot condition descriptions # knownConditions: # ExampleConditionReady: diff --git a/frontend/src/components/CloudProfile.vue b/frontend/src/components/CloudProfile.vue index f74a49419d..b55c9207b1 100644 --- a/frontend/src/components/CloudProfile.vue +++ b/frontend/src/components/CloudProfile.vue @@ -36,7 +36,7 @@ import { getValidationErrors } from '@/utils' const validationErrors = { value: { - required: 'You can\'t leave this empty.' + required: 'You can\'t leave this empty' } } diff --git a/frontend/src/components/Purpose.vue b/frontend/src/components/Purpose.vue index a97b815273..c8e402a4a7 100644 --- a/frontend/src/components/Purpose.vue +++ b/frontend/src/components/Purpose.vue @@ -38,7 +38,7 @@ import map from 'lodash/map' const validationErrors = { internalPurpose: { - required: 'Purpose is required.' + required: 'Purpose is required' } } diff --git a/frontend/src/components/SelectSecret.vue b/frontend/src/components/SelectSecret.vue index c5b85de83c..5102076738 100644 --- a/frontend/src/components/SelectSecret.vue +++ b/frontend/src/components/SelectSecret.vue @@ -95,7 +95,7 @@ export default { const isSecretInProject = this.projectName === projectName return isSecretInProject - ? `${this.costObjectTitle} is required. Go to the ADMINISTRATION page to edit the project and set the ${this.costObjectTitle}.` + ? `${this.costObjectTitle} is required. Go to the ADMINISTRATION page to edit the project and set the ${this.costObjectTitle}` : `${this.costObjectTitle} is required and has to be set on the Project ${toUpper(projectName)}` } } diff --git a/frontend/src/components/ShootWorkers/ManageWorkers.vue b/frontend/src/components/ShootWorkers/ManageWorkers.vue index ea5ae96c61..3f504c7f00 100644 --- a/frontend/src/components/ShootWorkers/ManageWorkers.vue +++ b/frontend/src/components/ShootWorkers/ManageWorkers.vue @@ -68,7 +68,7 @@ SPDX-License-Identifier: Apache-2.0 diff --git a/frontend/src/components/dialogs/SecretDialogHCloud.vue b/frontend/src/components/dialogs/SecretDialogHCloud.vue index 1b934ff6e5..e77397d417 100644 --- a/frontend/src/components/dialogs/SecretDialogHCloud.vue +++ b/frontend/src/components/dialogs/SecretDialogHCloud.vue @@ -55,7 +55,7 @@ import { getValidationErrors, setDelayedInputFocus } from '@/utils' const validationErrors = { hcloudToken: { - required: 'You can\'t leave this empty.' + required: 'You can\'t leave this empty' } } @@ -80,7 +80,6 @@ export default { } }, validations () { - // had to move the code to a computed property so that the getValidationErrors method can access it return this.validators }, computed: { diff --git a/frontend/src/components/dialogs/SecretDialogInfoblox.vue b/frontend/src/components/dialogs/SecretDialogInfoblox.vue index 4a0c393fc9..231c001c2a 100644 --- a/frontend/src/components/dialogs/SecretDialogInfoblox.vue +++ b/frontend/src/components/dialogs/SecretDialogInfoblox.vue @@ -60,10 +60,10 @@ import { getValidationErrors, setDelayedInputFocus } from '@/utils' const validationErrors = { infobloxUsername: { - required: 'You can\'t leave this empty.' + required: 'You can\'t leave this empty' }, infobloxPassword: { - required: 'You can\'t leave this empty.' + required: 'You can\'t leave this empty' } } @@ -89,7 +89,6 @@ export default { } }, validations () { - // had to move the code to a computed property so that the getValidationErrors method can access it return this.validators }, computed: { diff --git a/frontend/src/components/dialogs/SecretDialogMetal.vue b/frontend/src/components/dialogs/SecretDialogMetal.vue index 21a9a97cef..00dd217657 100644 --- a/frontend/src/components/dialogs/SecretDialogMetal.vue +++ b/frontend/src/components/dialogs/SecretDialogMetal.vue @@ -61,10 +61,10 @@ import { getValidationErrors, setDelayedInputFocus } from '@/utils' const validationErrors = { apiHmac: { - required: 'You can\'t leave this empty.' + required: 'You can\'t leave this empty' }, apiUrl: { - required: 'You can\'t leave this empty.', + required: 'You can\'t leave this empty', url: 'You must enter a valid URL' } } @@ -91,7 +91,6 @@ export default { } }, validations () { - // had to move the code to a computed property so that the getValidationErrors method can access it return this.validators }, computed: { diff --git a/frontend/src/components/dialogs/SecretDialogNetlify.vue b/frontend/src/components/dialogs/SecretDialogNetlify.vue index aaf7e4efd5..9301159c34 100644 --- a/frontend/src/components/dialogs/SecretDialogNetlify.vue +++ b/frontend/src/components/dialogs/SecretDialogNetlify.vue @@ -60,7 +60,7 @@ import { getValidationErrors } from '@/utils' const validationErrors = { apiToken: { - required: 'You can\'t leave this empty.' + required: 'You can\'t leave this empty' } } @@ -86,7 +86,6 @@ export default { } }, validations () { - // had to move the code to a computed property so that the getValidationErrors method can access it return this.validators }, computed: { diff --git a/frontend/src/components/dialogs/SecretDialogOpenstack.vue b/frontend/src/components/dialogs/SecretDialogOpenstack.vue index 0ba4cb5a00..b26201c4a0 100644 --- a/frontend/src/components/dialogs/SecretDialogOpenstack.vue +++ b/frontend/src/components/dialogs/SecretDialogOpenstack.vue @@ -167,7 +167,7 @@ import { getValidationErrors, setDelayedInputFocus } from '@/utils' import HintColorizer from '@/components/HintColorizer' import ExternalLink from '@/components/ExternalLink' -const requiredMessage = 'You can\'t leave this empty.' +const requiredMessage = 'You can\'t leave this empty' const requiredUserMessage = 'Required for technical user authentication' const requiredApplicationCredentialsMessage = 'Required for application credentials authentication' @@ -185,7 +185,7 @@ const validationErrors = { required: requiredUserMessage }, authURL: { - required: 'Required for Secret Type DNS.' + required: 'Required for Secret Type DNS' }, applicationCredentialID: { required: requiredApplicationCredentialsMessage @@ -233,7 +233,6 @@ export default { } }, validations () { - // had to move the code to a computed property so that the getValidationErrors method can access it return this.validators }, computed: { diff --git a/frontend/src/components/dialogs/SecretDialogVSphere.vue b/frontend/src/components/dialogs/SecretDialogVSphere.vue index 846fe4af2e..e9a505eccc 100644 --- a/frontend/src/components/dialogs/SecretDialogVSphere.vue +++ b/frontend/src/components/dialogs/SecretDialogVSphere.vue @@ -98,16 +98,16 @@ import { getValidationErrors, setDelayedInputFocus } from '@/utils' const validationErrors = { vsphereUsername: { - required: 'You can\'t leave this empty.' + required: 'You can\'t leave this empty' }, vspherePassword: { - required: 'You can\'t leave this empty.' + required: 'You can\'t leave this empty' }, nsxtUsername: { - required: 'You can\'t leave this empty.' + required: 'You can\'t leave this empty' }, nsxtPassword: { - required: 'You can\'t leave this empty.' + required: 'You can\'t leave this empty' } } @@ -136,7 +136,6 @@ export default { } }, validations () { - // had to move the code to a computed property so that the getValidationErrors method can access it return this.validators }, computed: { diff --git a/frontend/src/store/index.js b/frontend/src/store/index.js index cd8dabfd33..fa74b5dca8 100644 --- a/frontend/src/store/index.js +++ b/frontend/src/store/index.js @@ -709,7 +709,8 @@ const getters = { return uniq(map(state.cloudProfiles.all, 'metadata.cloudProviderKind')) }, sortedCloudProviderKindList (state, getters) { - return intersection(['aws', 'azure', 'gcp', 'openstack', 'alicloud', 'metal', 'vsphere', 'hcloud', 'onmetal', 'local'], getters.cloudProviderKindList) + const cloudProviderList = state.cfg.cloudProviderList ?? ['aws', 'azure', 'gcp', 'openstack', 'alicloud', 'metal', 'vsphere', 'hcloud', 'onmetal', 'local'] + return intersection(cloudProviderList, getters.cloudProviderKindList) }, sortedDnsProviderList (state, getters) { const supportedProviderTypes = ['aws-route53', 'azure-dns', 'azure-private-dns', 'google-clouddns', 'openstack-designate', 'alicloud-dns', 'infoblox-dns', 'netlify-dns'] diff --git a/frontend/src/store/modules/shoots/index.js b/frontend/src/store/modules/shoots/index.js index a7699e7f58..25b4b3a3ca 100644 --- a/frontend/src/store/modules/shoots/index.js +++ b/frontend/src/store/modules/shoots/index.js @@ -234,7 +234,7 @@ const actions = { } const infrastructureKind = head(rootGetters.sortedCloudProviderKindList) - set(shootResource, 'spec', getSpecTemplate(infrastructureKind, rootGetters.nodesCIDR)) + set(shootResource, 'spec', getSpecTemplate(infrastructureKind, rootGetters.nodesCIDR, rootState.cfg.customCloudProviders)) const cloudProfileName = get(head(rootGetters.cloudProfilesByCloudProviderKind(infrastructureKind)), 'metadata.name') set(shootResource, 'spec.cloudProfileName', cloudProfileName) diff --git a/frontend/src/utils/createShoot.js b/frontend/src/utils/createShoot.js index 7af9055e14..72d58c4eb3 100644 --- a/frontend/src/utils/createShoot.js +++ b/frontend/src/utils/createShoot.js @@ -17,8 +17,30 @@ import filter from 'lodash/filter' import range from 'lodash/range' import pick from 'lodash/pick' import values from 'lodash/values' +import get from 'lodash/get' +import template from 'lodash/template' -export function getSpecTemplate (infrastructureKind, defaultWorkerCIDR) { +export function getSpecTemplate (infrastructureKind, defaultWorkerCIDR, customCloudProviders) { + const defaultShootSpec = { + networking: { + nodes: defaultWorkerCIDR + } + } + + const customCloudProviderSpec = get(customCloudProviders, [infrastructureKind, 'shoot', 'specTemplate']) + if (customCloudProviderSpec !== undefined) { + const customCloudProviderSpecJson = JSON.stringify(customCloudProviderSpec) + const compiled = template(customCloudProviderSpecJson) + const data = compiled({ + workerCIDR: defaultWorkerCIDR + }) + + const shootSpec = JSON.parse(data) + return { + ...defaultShootSpec, + ...shootSpec + } + } switch (infrastructureKind) { case 'metal': return { // TODO: Remove when metal extension sets this config via mutating webhook, see https://github.com/metal-stack/gardener-extension-provider-metal/issues/32 @@ -52,10 +74,8 @@ export function getSpecTemplate (infrastructureKind, defaultWorkerCIDR) { } default: return { - provider: getProviderTemplate(infrastructureKind, defaultWorkerCIDR), - networking: { - nodes: defaultWorkerCIDR - } + ...defaultShootSpec, + provider: getProviderTemplate(infrastructureKind, defaultWorkerCIDR) } } } diff --git a/frontend/src/utils/index.js b/frontend/src/utils/index.js index 66c66886ab..8918523164 100644 --- a/frontend/src/utils/index.js +++ b/frontend/src/utils/index.js @@ -524,7 +524,11 @@ export function defaultCriNameByKubernetesVersion (criNames, kubernetesVersion) ? criName : head(criNames) } -export function isZonedCluster ({ cloudProviderKind, shootSpec, isNewCluster }) { +export function isZonedCluster ({ cloudProviderKind, shootSpec, isNewCluster, customCloudProviders }) { + const customCloudProviderZone = get(customCloudProviders, [cloudProviderKind, 'zoned']) + if (customCloudProviderZone !== undefined) { + return customCloudProviderZone + } switch (cloudProviderKind) { case 'azure': if (isNewCluster) { diff --git a/frontend/src/views/NewShoot.vue b/frontend/src/views/NewShoot.vue index 38fa5c4710..058684877f 100644 --- a/frontend/src/views/NewShoot.vue +++ b/frontend/src/views/NewShoot.vue @@ -285,7 +285,7 @@ export default { const oldInfrastructureKind = get(shootResource, 'spec.provider.type') if (oldInfrastructureKind !== infrastructureKind) { // Infrastructure changed - set(shootResource, 'spec', getSpecTemplate(infrastructureKind, this.nodesCIDR)) + set(shootResource, 'spec', getSpecTemplate(infrastructureKind, this.nodesCIDR, this.cfg.customCloudProviders)) } set(shootResource, 'spec.cloudProfileName', cloudProfileName) set(shootResource, 'spec.region', region) @@ -461,7 +461,7 @@ export default { }) const workers = get(shootResource, 'spec.provider.workers') - const zonedCluster = isZonedCluster({ cloudProviderKind: infrastructureKind, isNewCluster: true }) + const zonedCluster = isZonedCluster({ cloudProviderKind: infrastructureKind, isNewCluster: true, customCloudProviders: this.cfg.customCloudProviders }) const newShootWorkerCIDR = get(shootResource, 'spec.networking.nodes', this.nodesCIDR) await this.$manageWorkers.dispatch('setWorkersData', { workers, cloudProfileName, region, updateOSMaintenance: osUpdates, zonedCluster, kubernetesVersion, newShootWorkerCIDR })