diff --git a/frontend/__fixtures__/cloudprofiles.js b/frontend/__fixtures__/cloudprofiles.js index 2dd300fc89..e7134d92a5 100644 --- a/frontend/__fixtures__/cloudprofiles.js +++ b/frontend/__fixtures__/cloudprofiles.js @@ -4,6 +4,118 @@ // SPDX-License-Identifier: Apache-2.0 // +const DEFAULT_ARCHITECTURE = 'amd64' + +const createMachineType = ({ + name, + cpu, + memory, + gpu = '0', + architecture = DEFAULT_ARCHITECTURE, + usable = true, + storage, +}) => ({ + name, + cpu, + gpu, + memory, + usable, + architecture, + ...(storage ? { storage: { ...storage } } : {}), +}) + +const createVolumeType = ({ + name, + class: volumeClass, + usable = true, + minSize, +}) => ({ + name, + class: volumeClass, + usable, + ...(minSize ? { minSize } : {}), +}) + +const createZone = ({ + name, + unavailableMachineTypes, + unavailableVolumeTypes, +}) => ({ + name, + ...(unavailableMachineTypes ? { unavailableMachineTypes } : {}), + ...(unavailableVolumeTypes ? { unavailableVolumeTypes } : {}), +}) + +const createRegion = ({ + name, + zones, + accessRestrictions, +}) => ({ + name, + zones, + ...(accessRestrictions?.length + ? { + accessRestrictions: accessRestrictions.map(name => ({ name })), + } + : {}), +}) + +const createCloudProfile = ({ + metadataName, + displayName, + type, + seedNames, + providerConfig, + machineTypes, + regions, + volumeTypes, + kubernetes = { versions: [...kubernetesVersions] }, + machineImagesProfile = [...machineImages], +}) => ({ + metadata: { + name: metadataName, + annotations: { + 'garden.sapcloud.io/displayName': displayName, + }, + }, + spec: { + ...(seedNames ? { seedNames } : {}), + kubernetes, + machineImages: machineImagesProfile, + machineTypes, + providerConfig, + regions, + type, + ...(volumeTypes ? { volumeTypes } : {}), + }, +}) + +const createOpenstackMachineTypes = ({ prefix, gpuType }) => { + const storage = { + class: 'standard', + size: '64Gi', + type: 'default', + } + return [ + createMachineType({ name: `${prefix}_c2_m16`, cpu: '2', memory: '16Gi', storage }), + createMachineType({ name: `${prefix}_c4_m32`, cpu: '4', memory: '32Gi', storage }), + createMachineType({ name: `${prefix}_c8_m64`, cpu: '8', memory: '64Gi', storage }), + createMachineType({ name: `${prefix}_c16_m128`, cpu: '16', memory: '128Gi', storage }), + createMachineType({ name: `${prefix}_c32_m256`, cpu: '32', memory: '256Gi', storage }), + createMachineType({ + name: gpuType, + cpu: '32', + gpu: '3', + memory: '384Gi', + storage: { + class: 'standard', + size: '1966Gi', + type: 'default', + }, + }), + ] +} + export const kubernetesVersions = [ { version: '1.29.2', @@ -153,1007 +265,610 @@ export const machineImages = [ }, ] +const alicloudMachineTypes = [ + createMachineType({ name: 'ecs.c1.small', cpu: '8', memory: '8Gi' }), + createMachineType({ name: 'ecs.c2.medium', cpu: '16', memory: '16Gi' }), + createMachineType({ name: 'ecs.c2.large', cpu: '16', memory: '32Gi' }), + createMachineType({ name: 'ecs.c2.xlarge', cpu: '16', memory: '64Gi' }), + createMachineType({ name: 'ecs.c6.large', cpu: '2', memory: '4Gi' }), + createMachineType({ name: 'ecs.c6.xlarge', cpu: '4', memory: '8Gi' }), +] + +const awsMachineTypes = [ + createMachineType({ name: 'm4.large', cpu: '2', memory: '8Gi' }), + createMachineType({ name: 'm4.xlarge', cpu: '4', memory: '16Gi' }), + createMachineType({ name: 'm4.2xlarge', cpu: '8', memory: '32Gi' }), + createMachineType({ name: 'a1.large', cpu: '2', memory: '4Gi', architecture: 'arm64' }), + createMachineType({ name: 'a1.xlarge', cpu: '4', memory: '8Gi', architecture: 'arm64' }), + createMachineType({ name: 'a1.2xlarge', cpu: '8', memory: '16Gi', architecture: 'arm64' }), + createMachineType({ name: 'g5.large', cpu: '2', memory: '8Gi', gpu: '1' }), + createMachineType({ name: 'g5.xlarge', cpu: '4', memory: '16Gi', gpu: '1' }), + createMachineType({ name: 'g5.2xlarge', cpu: '8', memory: '32Gi', gpu: '1' }), +] + +const azureMachineTypes = [ + createMachineType({ name: 'Standard_A4_v2', cpu: '4', memory: '8Gi' }), + createMachineType({ name: 'Basic_A3', cpu: '4', memory: '7Gi' }), + createMachineType({ name: 'Basic_A4', cpu: '8', memory: '14Gi' }), +] + +const gcpMachineTypes = [ + createMachineType({ name: 'n1-standard-2', cpu: '2', memory: '7Gi' }), + createMachineType({ name: 'n1-standard-4', cpu: '4', memory: '15Gi' }), + createMachineType({ name: 'n1-standard-8', cpu: '8', memory: '30Gi' }), + createMachineType({ name: 'n1-standard-16', cpu: '16', memory: '60Gi' }), + createMachineType({ name: 't2a-standard-2', cpu: '2', memory: '8Gi', architecture: 'arm64' }), + createMachineType({ name: 't2a-standard-4', cpu: '4', memory: '16Gi', architecture: 'arm64' }), + createMachineType({ name: 't2a-standard-8', cpu: '8', memory: '32Gi', architecture: 'arm64' }), + createMachineType({ name: 't2a-standard-16', cpu: '16', memory: '64Gi', architecture: 'arm64' }), +] + +const metalMachineTypes = [ + createMachineType({ name: 'c1-metal-small', cpu: '2', memory: '4Gi' }), + createMachineType({ name: 'c1-metal-medium', cpu: '4', memory: '8Gi' }), +] + +const onmetalMachineTypes = [ + createMachineType({ name: 'c1-onmetal-small', cpu: '2', memory: '4Gi' }), + createMachineType({ name: 'c1-onmetal-medium', cpu: '4', memory: '8Gi' }), +] + +const localMachineTypes = [ + createMachineType({ name: 'local-dev-small', cpu: '2', memory: '4Gi' }), +] + +const hcloudMachineTypes = [ + createMachineType({ name: 'cpx11', cpu: '2', memory: '2Gi' }), + createMachineType({ name: 'cpx21', cpu: '3', memory: '4Gi' }), +] + +const stackitMachineTypes = [ + createMachineType({ name: 'stackit-machine-1', cpu: '2', memory: '8Gi' }), + createMachineType({ name: 'stackit-machine-2', cpu: '4', memory: '16Gi' }), +] + +const vsphereMachineTypes = [ + createMachineType({ name: 'vsphere-machine-1', cpu: '2', memory: '4Gi' }), + createMachineType({ name: 'vsphere-machine-2', cpu: '4', memory: '8Gi' }), +] + +const openstack1MachineTypes = createOpenstackMachineTypes({ + prefix: 'g', + gpuType: 'xg1bcm1.medium', +}) + +const openstack2MachineTypes = createOpenstackMachineTypes({ + prefix: 'm', + gpuType: 'zg1bcm1.medium', +}) + export default [ - { - metadata: { - name: 'alicloud', - annotations: { - 'garden.sapcloud.io/displayName': 'Alibaba Cloud', - }, + createCloudProfile({ + metadataName: 'alicloud', + displayName: 'Alibaba Cloud', + type: 'alicloud', + machineTypes: alicloudMachineTypes, + providerConfig: { + apiVersion: 'alicloud.provider.extensions.gardener.cloud/v1alpha1', + kind: 'CloudProfileConfig', }, - spec: { - kubernetes: { - versions: [ - ...kubernetesVersions, + regions: [ + createRegion({ + name: 'eu-central-1', + zones: [ + createZone({ name: 'eu-central-1a' }), + createZone({ name: 'eu-central-1b' }), + createZone({ + name: 'eu-central-1c', + unavailableMachineTypes: [ + alicloudMachineTypes[0].name, + alicloudMachineTypes[1].name, + ], + unavailableVolumeTypes: [ + 'cloud_ssd', + ], + }), ], - }, - machineImages: [ - ...machineImages, - ], - machineTypes: [ - { - cpu: '8', - gpu: '0', - memory: '8Gi', - name: 'ecs.c1.small', - usable: true, - architecture: 'amd64', - }, - { - cpu: '16', - gpu: '0', - memory: '16Gi', - name: 'ecs.c2.medium', - usable: true, - architecture: 'amd64', - }, - { - cpu: '16', - gpu: '0', - memory: '32Gi', - name: 'ecs.c2.large', - usable: true, - architecture: 'amd64', - }, - { - cpu: '16', - gpu: '0', - memory: '64Gi', - name: 'ecs.c2.xlarge', - usable: true, - architecture: 'amd64', - }, - { - cpu: '2', - gpu: '0', - memory: '4Gi', - name: 'ecs.c6.large', - usable: true, - architecture: 'amd64', - }, - { - cpu: '4', - gpu: '0', - memory: '8Gi', - name: 'ecs.c6.xlarge', - usable: true, - architecture: 'amd64', - }, - ], - providerConfig: { - apiVersion: 'alicloud.provider.extensions.gardener.cloud/v1alpha1', - kind: 'CloudProfileConfig', - }, - regions: [ - { - name: 'eu-central-1', - zones: [ - { - name: 'eu-central-1a', - }, - { - name: 'eu-central-1b', - }, - { - name: 'eu-central-1c', - unavailableMachineTypes: [ - 'ecs.c1.small', - 'ecs.c2.medium', - ], - unavailableVolumeTypes: [ - 'cloud_ssd', - ], - }, - ], - }, - { - name: 'us-east-1', - zones: [ - { - name: 'us-east-1a', - }, - { - name: 'us-east-1b', - }, - ], - }, - ], - type: 'alicloud', - volumeTypes: [ - { - class: 'standard', - name: 'cloud', - usable: true, - }, - { - class: 'standard', - name: 'cloud_efficiency', - usable: true, - }, - { - class: 'premium', - name: 'cloud_ssd', - usable: true, - }, - ], - }, - }, - { - metadata: { - name: 'aws', - annotations: { - 'garden.sapcloud.io/displayName': 'aws', - }, - }, - spec: { - seedNames: [ - 'aws-ha', - ], - kubernetes: { - versions: [ - ...kubernetesVersions, + }), + createRegion({ + name: 'us-east-1', + zones: [ + createZone({ name: 'us-east-1a' }), + createZone({ name: 'us-east-1b' }), ], - }, - machineImages: [ - ...machineImages, - ], - machineTypes: [ - { - cpu: '2', - gpu: '0', - memory: '8Gi', - name: 'm4.large', - usable: true, - architecture: 'amd64', - }, - { - cpu: '4', - gpu: '0', - memory: '16Gi', - name: 'm4.xlarge', - usable: true, - architecture: 'amd64', - }, - { - cpu: '8', - gpu: '0', - memory: '32Gi', - name: 'm4.2xlarge', - usable: true, - architecture: 'amd64', - }, - { - cpu: '2', - gpu: '0', - memory: '4Gi', - name: 'a1.large', - usable: true, - architecture: 'arm64', - }, - { - cpu: '4', - gpu: '0', - memory: '8Gi', - name: 'a1.xlarge', - usable: true, - architecture: 'arm64', - }, - { - cpu: '8', - gpu: '0', - memory: '16Gi', - name: 'a1.2xlarge', - usable: true, - architecture: 'arm64', - }, - { - cpu: '2', - gpu: '1', - memory: '8Gi', - name: 'g5.large', - usable: true, - architecture: 'amd64', - }, - { - cpu: '4', - gpu: '1', - memory: '16Gi', - name: 'g5.xlarge', - usable: true, - architecture: 'amd64', - }, - { - cpu: '8', - gpu: '1', - memory: '32Gi', - name: 'g5.2xlarge', - usable: true, - architecture: 'amd64', - }, - ], - providerConfig: { - apiVersion: 'aws.provider.extensions.gardener.cloud/v1alpha1', - kind: 'CloudProfileConfig', - }, - regions: [ - { - name: 'eu-central-1', - zones: [ - { - name: 'eu-central-1a', - }, - { - name: 'eu-central-1b', - }, - { - name: 'eu-central-1c', - unavailableMachineTypes: [ - 'm4.large', - 'a1.large', - 'g5.large', - ], - unavailableVolumeTypes: [ - 'gp3', - 'io1', - ], - }, - ], - accessRestrictions: [{ - name: 'eu-access-only', - }], - }, - { - name: 'eu-west-1', - zones: [ - { - name: 'eu-west-1a', - }, - { - name: 'eu-west-1b', - }, - { - name: 'eu-west-1c', - unavailableMachineTypes: [ - 'm4.xlarge', - 'a1.xlarge', - 'g5.xlarge', - ], - unavailableVolumeTypes: [ - 'io1', - ], - }, - ], - accessRestrictions: [{ - name: 'eu-access-only', - }], - }, - { - name: 'us-east-1', - zones: [ - { - name: 'us-east-1a', - }, - { - name: 'us-east-1b', - }, - { - name: 'us-east-1c', - }, - { - name: 'us-east-1d', - }, - ], - }, - ], - type: 'aws', - volumeTypes: [ - { - class: 'standard', - name: 'gp3', - usable: true, - }, - { - class: 'standard', - name: 'gp2', - usable: true, - }, - { - class: 'standard', - name: 'io1', - usable: true, - minSize: '4Gi', - }, - ], - }, - }, - { - metadata: { - name: 'az', - annotations: { - 'garden.sapcloud.io/displayName': 'Azure', - }, + }), + ], + volumeTypes: [ + createVolumeType({ name: 'cloud', class: 'standard' }), + createVolumeType({ name: 'cloud_efficiency', class: 'standard' }), + createVolumeType({ name: 'cloud_ssd', class: 'premium' }), + ], + }), + createCloudProfile({ + metadataName: 'aws', + displayName: 'aws', + type: 'aws', + seedNames: [ + 'aws-ha', + ], + machineTypes: awsMachineTypes, + providerConfig: { + apiVersion: 'aws.provider.extensions.gardener.cloud/v1alpha1', + kind: 'CloudProfileConfig', }, - spec: { - seedNames: [ - 'az-ha', - ], - kubernetes: { - versions: [ - ...kubernetesVersions, + regions: [ + createRegion({ + name: 'eu-central-1', + zones: [ + createZone({ name: 'eu-central-1a' }), + createZone({ name: 'eu-central-1b' }), + createZone({ + name: 'eu-central-1c', + unavailableMachineTypes: [ + awsMachineTypes[0].name, + awsMachineTypes[3].name, + awsMachineTypes[6].name, + ], + unavailableVolumeTypes: [ + 'gp3', + 'io1', + ], + }), ], - }, - machineImages: [ - ...machineImages, - ], - machineTypes: [ - { - cpu: '4', - gpu: '0', - memory: '8Gi', - name: 'Standard_A4_v2', - usable: true, - architecture: 'amd64', - }, - { - cpu: '4', - gpu: '0', - memory: '7Gi', - name: 'Basic_A3', - usable: true, - architecture: 'amd64', - }, - { - cpu: '8', - gpu: '0', - memory: '14Gi', - name: 'Basic_A4', - usable: true, - architecture: 'amd64', - }, - ], - providerConfig: { - apiVersion: 'azure.provider.extensions.gardener.cloud/v1alpha1', - kind: 'CloudProfileConfig', - }, - regions: [ - { - name: 'westeurope', - zones: [ - { - name: '1', - }, - { - name: '2', - }, - { - name: '3', - unavailableMachineTypes: [ - 'Standard_A4_v2', - ], - unavailableVolumeTypes: [ - 'StandardSSD_LRS', - ], - }, - ], - accessRestrictions: [{ - name: 'eu-access-only', - }], - }, - { - name: 'eastus', - zones: [ - { - name: '1', - }, - { - name: '2', - }, - { - name: '3', - }, - ], - }, - ], - type: 'azure', - volumeTypes: [ - { - class: 'premium', - name: 'StandardSSD_LRS', - usable: true, - minSize: '20Gi', - }, - { - class: 'standard', - name: 'Standard_LRS', - usable: true, - minSize: '20Gi', - }, - { - class: 'premium', - name: 'Premium_LRS', - usable: true, - minSize: '20Gi', - }, - ], - }, - }, - { - metadata: { - name: 'openstack-1', - annotations: { - 'garden.sapcloud.io/displayName': 'Openstack 1', - }, + accessRestrictions: ['eu-access-only'], + }), + createRegion({ + name: 'eu-west-1', + zones: [ + createZone({ name: 'eu-west-1a' }), + createZone({ name: 'eu-west-1b' }), + createZone({ + name: 'eu-west-1c', + unavailableMachineTypes: [ + awsMachineTypes[1].name, + awsMachineTypes[4].name, + awsMachineTypes[7].name, + ], + unavailableVolumeTypes: [ + 'io1', + ], + }), + ], + accessRestrictions: ['eu-access-only'], + }), + createRegion({ + name: 'us-east-1', + zones: [ + createZone({ name: 'us-east-1a' }), + createZone({ name: 'us-east-1b' }), + createZone({ name: 'us-east-1c' }), + createZone({ name: 'us-east-1d' }), + ], + }), + ], + volumeTypes: [ + createVolumeType({ name: 'gp3', class: 'standard' }), + createVolumeType({ name: 'gp2', class: 'standard' }), + createVolumeType({ name: 'io1', class: 'standard', minSize: '4Gi' }), + ], + }), + createCloudProfile({ + metadataName: 'az', + displayName: 'Azure', + type: 'azure', + seedNames: [ + 'az-ha', + ], + machineTypes: azureMachineTypes, + providerConfig: { + apiVersion: 'azure.provider.extensions.gardener.cloud/v1alpha1', + kind: 'CloudProfileConfig', }, - spec: { - seedNames: [ - 'openstack-ha', - ], - kubernetes: { - versions: [ - ...kubernetesVersions, + regions: [ + createRegion({ + name: 'westeurope', + zones: [ + createZone({ name: '1' }), + createZone({ name: '2' }), + createZone({ + name: '3', + unavailableMachineTypes: [ + azureMachineTypes[0].name, + ], + unavailableVolumeTypes: [ + 'StandardSSD_LRS', + ], + }), ], - }, - machineImages: [ - ...machineImages, - ], - machineTypes: [ - { - cpu: '2', - gpu: '0', - memory: '16Gi', - name: 'g_c2_m16', - storage: { - class: 'standard', - size: '64Gi', - type: 'default', - }, - usable: true, - architecture: 'amd64', - }, - { - cpu: '4', - gpu: '0', - memory: '32Gi', - name: 'g_c4_m32', - storage: { - class: 'standard', - size: '64Gi', - type: 'default', - }, - usable: true, - architecture: 'amd64', - }, - { - cpu: '8', - gpu: '0', - memory: '64Gi', - name: 'g_c8_m64', - storage: { - class: 'standard', - size: '64Gi', - type: 'default', - }, - usable: true, - architecture: 'amd64', - }, - { - cpu: '16', - gpu: '0', - memory: '128Gi', - name: 'g_c16_m128', - storage: { - class: 'standard', - size: '64Gi', - type: 'default', + accessRestrictions: ['eu-access-only'], + }), + createRegion({ + name: 'eastus', + zones: [ + createZone({ name: '1' }), + createZone({ name: '2' }), + createZone({ name: '3' }), + ], + }), + ], + volumeTypes: [ + createVolumeType({ name: 'StandardSSD_LRS', class: 'premium', minSize: '20Gi' }), + createVolumeType({ name: 'Standard_LRS', class: 'standard', minSize: '20Gi' }), + createVolumeType({ name: 'Premium_LRS', class: 'premium', minSize: '20Gi' }), + ], + }), + createCloudProfile({ + metadataName: 'openstack-1', + displayName: 'Openstack 1', + type: 'openstack', + seedNames: [ + 'openstack-ha', + ], + machineTypes: openstack1MachineTypes, + providerConfig: { + apiVersion: 'openstack.provider.extensions.gardener.cloud/v1alpha1', + constraints: { + floatingPools: [ + { + name: 'FloatingIP*', + region: 'eu-de-1', }, - usable: true, - architecture: 'amd64', - }, - { - cpu: '32', - gpu: '0', - memory: '256Gi', - name: 'g_c32_m256', - storage: { - class: 'standard', - size: '64Gi', - type: 'default', + { + name: 'FloatingIP*', + region: 'na-us-1', }, - usable: true, - architecture: 'amd64', - }, - { - cpu: '32', - gpu: '3', - memory: '384Gi', - name: 'xg1bcm1.medium', - storage: { - class: 'standard', - size: '1966Gi', - type: 'default', + ], + loadBalancerProviders: [ + { + name: 'f5', }, - usable: true, - architecture: 'amd64', - }, - ], - providerConfig: { - apiVersion: 'openstack.provider.extensions.gardener.cloud/v1alpha1', - constraints: { - floatingPools: [ - { - name: 'FloatingIP*', - region: 'eu-de-1', - }, - { - name: 'FloatingIP*', - region: 'na-us-1', - }, - ], - loadBalancerProviders: [ - { - name: 'f5', - }, - ], - }, - kind: 'CloudProfileConfig', - }, - regions: [ - { - name: 'eu-de-1', - zones: [ - { - name: 'eu-de-1a', - }, - { - name: 'eu-de-1b', - }, - { - name: 'eu-de-1d', - unavailableMachineTypes: [ - 'g_c2_m16', - ], - }, - ], - accessRestrictions: [{ - name: 'eu-access-only', - }], - }, - { - name: 'na-us-1', - zones: [ - { - name: 'na-us-1a', - }, - { - name: 'na-us-1b', - }, - { - name: 'na-us-1d', - }, - ], - }, - ], - type: 'openstack', - }, - }, - { - metadata: { - name: 'openstack-2', - annotations: { - 'garden.sapcloud.io/displayName': 'Openstack 2', + ], }, + kind: 'CloudProfileConfig', }, - spec: { - seedNames: [ - 'openstack-ha', - ], - kubernetes: { - versions: [ - ...kubernetesVersions, + regions: [ + createRegion({ + name: 'eu-de-1', + zones: [ + createZone({ name: 'eu-de-1a' }), + createZone({ name: 'eu-de-1b' }), + createZone({ + name: 'eu-de-1d', + unavailableMachineTypes: [ + openstack1MachineTypes[0].name, + ], + }), ], - }, - machineImages: [ - ...machineImages, - ], - machineTypes: [ - { - cpu: '2', - gpu: '0', - memory: '16Gi', - name: 'm_c2_m16', - storage: { - class: 'standard', - size: '64Gi', - type: 'default', - }, - usable: true, - architecture: 'amd64', - }, - { - cpu: '4', - gpu: '0', - memory: '32Gi', - name: 'm_c4_m32', - storage: { - class: 'standard', - size: '64Gi', - type: 'default', - }, - usable: true, - architecture: 'amd64', - }, - { - cpu: '8', - gpu: '0', - memory: '64Gi', - name: 'm_c8_m64', - storage: { - class: 'standard', - size: '64Gi', - type: 'default', - }, - usable: true, - architecture: 'amd64', - }, - { - cpu: '16', - gpu: '0', - memory: '128Gi', - name: 'm_c16_m128', - storage: { - class: 'standard', - size: '64Gi', - type: 'default', + accessRestrictions: ['eu-access-only'], + }), + createRegion({ + name: 'na-us-1', + zones: [ + createZone({ name: 'na-us-1a' }), + createZone({ name: 'na-us-1b' }), + createZone({ name: 'na-us-1d' }), + ], + }), + ], + }), + createCloudProfile({ + metadataName: 'openstack-2', + displayName: 'Openstack 2', + type: 'openstack', + seedNames: [ + 'openstack-ha', + ], + machineTypes: openstack2MachineTypes, + providerConfig: { + apiVersion: 'openstack.provider.extensions.gardener.cloud/v1alpha1', + constraints: { + floatingPools: [ + { + defaultFloatingSubnet: 'FloatingIP-intranet-*', + loadBalancerClasses: [ + { + floatingSubnetName: 'FloatingIP-internet-*', + name: 'internet', + }, + { + floatingSubnetName: 'FloatingIP-intranet-*', + name: 'intranet', + purpose: 'default', + }, + ], + name: 'FloatingIP*', + region: 'eu-de-2', }, - usable: true, - architecture: 'amd64', - }, - { - cpu: '32', - gpu: '0', - memory: '256Gi', - name: 'm_c32_m256', - storage: { - class: 'standard', - size: '64Gi', - type: 'default', + { + defaultFloatingSubnet: 'FloatingIP-intranet-*', + loadBalancerClasses: [ + { + floatingSubnetName: 'FloatingIP-internet-*', + name: 'internet', + }, + { + floatingSubnetName: 'FloatingIP-intranet-*', + name: 'intranet', + purpose: 'default', + }, + ], + name: 'FloatingIP*', + region: 'na-us-2', }, - usable: true, - architecture: 'amd64', - }, - { - cpu: '32', - gpu: '3', - memory: '384Gi', - name: 'zg1bcm1.medium', - storage: { - class: 'standard', - size: '1966Gi', - type: 'default', + ], + loadBalancerProviders: [ + { + name: 'f5', }, - usable: true, - architecture: 'amd64', - }, - ], - providerConfig: { - apiVersion: 'openstack.provider.extensions.gardener.cloud/v1alpha1', - constraints: { - floatingPools: [ - { - defaultFloatingSubnet: 'FloatingIP-intranet-*', - loadBalancerClasses: [ - { - floatingSubnetName: 'FloatingIP-internet-*', - name: 'internet', - }, - { - floatingSubnetName: 'FloatingIP-intranet-*', - name: 'intranet', - purpose: 'default', - }, - ], - name: 'FloatingIP*', - region: 'eu-de-2', - }, - { - defaultFloatingSubnet: 'FloatingIP-intranet-*', - loadBalancerClasses: [ - { - floatingSubnetName: 'FloatingIP-internet-*', - name: 'internet', - }, - { - floatingSubnetName: 'FloatingIP-intranet-*', - name: 'intranet', - purpose: 'default', - }, - ], - name: 'FloatingIP*', - region: 'na-us-2', - }, - ], - loadBalancerProviders: [ - { - name: 'f5', - }, - ], - }, - kind: 'CloudProfileConfig', + ], }, - regions: [ - { - name: 'eu-de-2', - zones: [ - { - name: 'eu-de-2a', - }, - { - name: 'eu-de-2b', - }, - { - name: 'eu-de-2d', - unavailableMachineTypes: [ - 'm_c2_m16', - ], - }, - ], - accessRestrictions: [{ - name: 'eu-access-only', - }], - }, - { - name: 'na-us-2', - zones: [ - { - name: 'na-us-2a', - }, - { - name: 'na-us-2b', - }, - { - name: 'na-us-2d', - }, - ], - }, - ], - type: 'openstack', + kind: 'CloudProfileConfig', }, - }, - { - metadata: { - name: 'gcp', - annotations: { - 'garden.sapcloud.io/displayName': 'Google Cloud', - }, + regions: [ + createRegion({ + name: 'eu-de-2', + zones: [ + createZone({ name: 'eu-de-2a' }), + createZone({ name: 'eu-de-2b' }), + createZone({ + name: 'eu-de-2d', + unavailableMachineTypes: [ + openstack2MachineTypes[0].name, + ], + }), + ], + accessRestrictions: ['eu-access-only'], + }), + createRegion({ + name: 'na-us-2', + zones: [ + createZone({ name: 'na-us-2a' }), + createZone({ name: 'na-us-2b' }), + createZone({ name: 'na-us-2d' }), + ], + }), + ], + }), + createCloudProfile({ + metadataName: 'gcp', + displayName: 'Google Cloud', + type: 'gcp', + seedNames: [ + 'gcp-ha', + ], + machineTypes: gcpMachineTypes, + providerConfig: { + apiVersion: 'gcp.provider.extensions.gardener.cloud/v1alpha1', + kind: 'CloudProfileConfig', }, - spec: { - seedNames: [ - 'gcp-ha', - ], - kubernetes: { - versions: [ - ...kubernetesVersions, + regions: [ + createRegion({ + name: 'europe-west1', + zones: [ + createZone({ name: 'europe-west1-b' }), + createZone({ name: 'europe-west1-c' }), + createZone({ + name: 'europe-west1-d', + unavailableMachineTypes: [ + gcpMachineTypes[0].name, + gcpMachineTypes[1].name, + gcpMachineTypes[4].name, + gcpMachineTypes[6].name, + ], + unavailableVolumeTypes: [ + 'pd-balanced', + 'pd-ssd', + ], + }), ], - }, - machineImages: [ - ...machineImages, - ], - machineTypes: [ - { - cpu: '2', - gpu: '0', - memory: '7Gi', - name: 'n1-standard-2', - usable: true, - architecture: 'amd64', - }, - { - cpu: '4', - gpu: '0', - memory: '15Gi', - name: 'n1-standard-4', - usable: true, - architecture: 'amd64', - }, - { - cpu: '8', - gpu: '0', - memory: '30Gi', - name: 'n1-standard-8', - usable: true, - architecture: 'amd64', - }, - { - cpu: '16', - gpu: '0', - memory: '60Gi', - name: 'n1-standard-16', - usable: true, - architecture: 'amd64', - }, - { - cpu: '2', - gpu: '0', - memory: '8Gi', - name: 't2a-standard-2', - usable: true, - architecture: 'arm64', - }, - { - cpu: '4', - gpu: '0', - memory: '16Gi', - name: 't2a-standard-4', - usable: true, - architecture: 'arm64', - }, - { - cpu: '8', - gpu: '0', - memory: '32Gi', - name: 't2a-standard-8', - usable: true, - architecture: 'arm64', - }, - { - cpu: '16', - gpu: '0', - memory: '64Gi', - name: 't2a-standard-16', - usable: true, - architecture: 'arm64', - }, - ], - providerConfig: { - apiVersion: 'gcp.provider.extensions.gardener.cloud/v1alpha1', - kind: 'CloudProfileConfig', - }, - regions: [ - { - name: 'europe-west1', - zones: [ - { - name: 'europe-west1-b', - }, - { - name: 'europe-west1-c', - }, - { - name: 'europe-west1-d', - unavailableMachineTypes: [ - 'n1-standard-2', - 'n1-standard-4', - 't2a-standard-2', - 't2a-standard-8', - ], - unavailableVolumeTypes: [ - 'pd-balanced', - 'pd-ssd', - ], - }, - ], - }, - { - name: 'us-east1', - zones: [ - { - name: 'us-east1-b', - }, - { - name: 'us-east1-c', - }, - { - name: 'us-east1-d', - }, - ], - }, - ], - type: 'gcp', - volumeTypes: [ + }), + createRegion({ + name: 'us-east1', + zones: [ + createZone({ name: 'us-east1-b' }), + createZone({ name: 'us-east1-c' }), + createZone({ name: 'us-east1-d' }), + ], + }), + ], + volumeTypes: [ + createVolumeType({ name: 'pd-balanced', class: 'premium', minSize: '20Gi' }), + createVolumeType({ name: 'pd-standard', class: 'standard', minSize: '20Gi' }), + createVolumeType({ name: 'pd-ssd', class: 'premium', minSize: '20Gi' }), + ], + }), + createCloudProfile({ + metadataName: 'hcloud', + displayName: 'Hetzner Cloud', + type: 'hcloud', + seedNames: [ + 'hcloud-ha', + ], + machineTypes: hcloudMachineTypes, + providerConfig: { + apiVersion: 'hcloud.provider.extensions.gardener.cloud/v1alpha1', + kind: 'CloudProfileConfig', + }, + regions: [ + createRegion({ + name: 'fsn1', + zones: [ + createZone({ name: 'fsn1-dc14' }), + createZone({ name: 'fsn1-dc8' }), + ], + }), + createRegion({ + name: 'hel1', + zones: [ + createZone({ name: 'hel1-dc2' }), + ], + }), + ], + }), + createCloudProfile({ + metadataName: 'metal', + displayName: 'Metal', + type: 'metal', + seedNames: [ + 'metal-ha', + ], + machineTypes: metalMachineTypes, + providerConfig: { + apiVersion: 'metal.provider.extensions.gardener.cloud/v1alpha1', + kind: 'CloudProfileConfig', + }, + regions: [ + createRegion({ + name: 'eu01', + zones: [ + createZone({ name: 'eu01-a' }), + ], + }), + ], + }), + createCloudProfile({ + metadataName: 'onmetal', + displayName: 'OnMetal', + type: 'onmetal', + seedNames: [ + 'onmetal-ha', + ], + machineTypes: onmetalMachineTypes, + providerConfig: { + apiVersion: 'onmetal.provider.extensions.gardener.cloud/v1alpha1', + kind: 'CloudProfileConfig', + }, + regions: [ + createRegion({ + name: 'on01', + zones: [ + createZone({ name: 'on01-a' }), + ], + }), + ], + }), + createCloudProfile({ + metadataName: 'local', + displayName: 'Local', + type: 'local', + machineTypes: localMachineTypes, + providerConfig: { + apiVersion: 'local.provider.extensions.gardener.cloud/v1alpha1', + kind: 'CloudProfileConfig', + }, + regions: [ + createRegion({ + name: 'local', + zones: [ + createZone({ name: 'local-a' }), + ], + }), + ], + }), + createCloudProfile({ + metadataName: 'stackit', + displayName: 'stackit', + type: 'stackit', + seedNames: [ + 'stackit-ha', + ], + machineTypes: stackitMachineTypes, + providerConfig: { + apiVersion: 'stackit.provider.extensions.gardener.cloud/v1alpha1', + kind: 'CloudProfileConfig', + }, + regions: [ + createRegion({ + name: 'eu01', + zones: [ + createZone({ name: 'eu01-1' }), + createZone({ name: 'eu01-2' }), + ], + }), + ], + }), + createCloudProfile({ + metadataName: 'vsphere', + displayName: 'vSphere', + type: 'vsphere', + seedNames: [ + 'vsphere-ha', + ], + machineTypes: vsphereMachineTypes, + providerConfig: { + apiVersion: 'vsphere.provider.extensions.gardener.cloud/v1alpha1', + kind: 'CloudProfileConfig', + }, + regions: [ + createRegion({ + name: 'region1', + zones: [ + createZone({ name: 'zone1' }), + createZone({ name: 'zone2' }), + ], + }), + ], + }), + createCloudProfile({ + metadataName: 'ironcore', + displayName: 'IronCore', + type: 'ironcore', + seedNames: [ + 'ironcore-ha', + ], + kubernetes: { + versions: [ { - class: 'premium', - name: 'pd-balanced', - usable: true, - minSize: '20Gi', + version: '1.28.4', }, { - class: 'standard', - name: 'pd-standard', - usable: true, - minSize: '20Gi', + version: '1.27.8', }, { - class: 'premium', - name: 'pd-ssd', - usable: true, - minSize: '20Gi', + version: '1.26.11', }, ], }, - }, - { - metadata: { - name: 'ironcore', - annotations: { - 'garden.sapcloud.io/displayName': 'IronCore', - }, - }, - spec: { - seedNames: [ - 'gcp-ha', - ], - kubernetes: { + machineImagesProfile: [ + { + name: 'gardenlinux', versions: [ { - version: '1.28.4', - }, - { - version: '1.27.8', - }, - { - version: '1.26.11', + version: '1312.2.0', + cri: [ + { + name: 'containerd', + }, + { + name: 'docker', + }, + ], + architectures: [ + 'amd64', + ], }, ], + updateStrategy: 'major', }, - machineImages: [ - { - name: 'gardenlinux', - versions: [ - { - version: '1312.2.0', - cri: [ - { - name: 'containerd', - }, - { - name: 'docker', - }, - ], - architectures: [ - 'amd64', - ], - }, - ], - updateStrategy: 'major', - }, - ], - machineTypes: [ - { - cpu: '4', - gpu: '0', - memory: '8Gi', - name: 'x3-xlarge', - usable: true, - architecture: 'amd64', - }, - ], - providerConfig: { - apiVersion: 'ironcore.provider.extensions.gardener.cloud/v1alpha1', - kind: 'CloudProfileConfig', - }, - regions: [ - { - name: 'de-fra', - zones: [ - { - name: 'fra3', - }, - ], - }, - ], - type: 'ironcore', - volumeTypes: [ - { - class: 'standard', - name: 'general-purpose', - usable: true, - }, - { - class: 'premium', - name: 'io-optimized', - usable: true, - }, - ], + ], + machineTypes: [ + createMachineType({ name: 'x3-xlarge', cpu: '4', memory: '8Gi' }), + ], + providerConfig: { + apiVersion: 'ironcore.provider.extensions.gardener.cloud/v1alpha1', + kind: 'CloudProfileConfig', }, - }, + regions: [ + createRegion({ + name: 'de-fra', + zones: [ + createZone({ name: 'fra3' }), + ], + }), + ], + volumeTypes: [ + createVolumeType({ name: 'general-purpose', class: 'standard' }), + createVolumeType({ name: 'io-optimized', class: 'premium' }), + ], + }), ] diff --git a/frontend/__tests__/components/GGenericInputFields.spec.js b/frontend/__tests__/components/GGenericInputFields.spec.js new file mode 100644 index 0000000000..889a35eb68 --- /dev/null +++ b/frontend/__tests__/components/GGenericInputFields.spec.js @@ -0,0 +1,388 @@ +// +// SPDX-FileCopyrightText: 2026 SAP SE or an SAP affiliate company and Gardener contributors +// +// SPDX-License-Identifier: Apache-2.0 +// + +import { nextTick } from 'vue' +import { + mount, + shallowMount, +} from '@vue/test-utils' + +import GGenericInputField from '@/components/GGenericInputField' +import GGenericInputFields from '@/components/GGenericInputFields' + +import { useLogger } from '@/composables/useLogger' + +const { createVuetifyPlugin } = global.fixtures.helper + +describe('GGenericInputFields', () => { + function lastEmittedValue (wrapper) { + const emitted = wrapper.emitted('update:modelValue') + return emitted[emitted.length - 1][0] + } + + it('initializes field data with configured empty defaults', async () => { + const wrapper = shallowMount(GGenericInputFields, { + props: { + fields: [ + { + key: 'TSIGSecretAlgorithm', + label: 'TSIG Secret Algorithm', + type: 'select', + defaultValue: '', + }, + ], + modelValue: {}, + }, + global: { + stubs: { + GGenericInputField: true, + }, + }, + }) + + await nextTick() + + expect(lastEmittedValue(wrapper)).toEqual({ + TSIGSecretAlgorithm: '', + }) + }) + + it('keeps existing field data over configured defaults without redundant emit', async () => { + const wrapper = shallowMount(GGenericInputFields, { + props: { + fields: [ + { + key: 'TSIGSecretAlgorithm', + label: 'TSIG Secret Algorithm', + type: 'select', + defaultValue: 'hmac-sha256', + }, + ], + modelValue: { + TSIGSecretAlgorithm: 'hmac-sha512', + }, + }, + global: { + stubs: { + GGenericInputField: true, + }, + }, + }) + + await nextTick() + + expect(wrapper.findComponent(GGenericInputField).props('modelValue')).toBe('hmac-sha512') + expect(wrapper.emitted('update:modelValue')).toBeUndefined() + }) + + it('loads existing field data after initially applying configured defaults', async () => { + const wrapper = shallowMount(GGenericInputFields, { + props: { + fields: [ + { + key: 'AZURE_CLOUD', + label: 'Azure Cloud', + type: 'select', + defaultValue: '', + }, + { + key: 'CLIENT_ID', + label: 'Client ID', + type: 'text', + }, + ], + modelValue: {}, + }, + global: { + stubs: { + GGenericInputField: true, + }, + }, + }) + + await nextTick() + await wrapper.setProps({ + modelValue: { + AZURE_CLOUD: 'AzureChina', + CLIENT_ID: 'client-id', + }, + }) + await nextTick() + + const fields = wrapper.findAllComponents(GGenericInputField) + expect(fields[0].props('modelValue')).toBe('AzureChina') + expect(fields[1].props('modelValue')).toBe('client-id') + expect(lastEmittedValue(wrapper)).toEqual({ + AZURE_CLOUD: '', + }) + }) + + it('shows the configured empty default as the selected item', async () => { + const wrapper = mount(GGenericInputFields, { + props: { + fields: [ + { + key: 'AZURE_CLOUD', + label: 'Azure Cloud', + type: 'select', + defaultValue: '', + values: [ + { + title: 'Provider default (Azure Public)', + value: '', + }, + { + title: 'AzureChina', + value: 'AzureChina', + }, + ], + }, + ], + modelValue: {}, + }, + global: { + plugins: [ + createVuetifyPlugin(), + ], + }, + }) + + await nextTick() + + expect(wrapper.findComponent({ name: 'VSelect' }).props('modelValue')).toBe('') + expect(wrapper.text()).toContain('Provider default (Azure Public)') + }) + + it('does not emit when initialized with empty field data and no defaults', async () => { + const wrapper = shallowMount(GGenericInputFields, { + props: { + fields: [ + { + key: 'accessKeyID', + label: 'Access Key ID', + type: 'text', + }, + ], + modelValue: {}, + }, + global: { + stubs: { + GGenericInputField: true, + }, + }, + }) + + await nextTick() + + expect(wrapper.emitted('update:modelValue')).toBeUndefined() + }) +}) + +describe('GGenericInputField', () => { + let warnSpy + const originalFileReader = globalThis.FileReader + + const TextareaStub = { + name: 'VTextarea', + props: { + modelValue: { + type: [String, Object, Number, Boolean], + }, + errorMessages: { + type: Array, + default: () => [], + }, + }, + emits: [ + 'update:modelValue', + 'blur', + 'click:append', + ], + template: ` + + `, + } + + beforeEach(() => { + warnSpy = vi.spyOn(useLogger(), 'warn').mockImplementation(() => {}) + }) + + afterEach(() => { + warnSpy.mockRestore() + vi.stubGlobal('FileReader', originalFileReader) + }) + + function createDropEvent (files) { + const event = new Event('drop', { + bubbles: true, + cancelable: true, + }) + Object.defineProperty(event, 'dataTransfer', { + value: { files }, + }) + return event + } + + function mountStructuredInputField (props = {}) { + return shallowMount(GGenericInputField, { + props: { + modelValue: '', + field: { + key: 'secret', + label: 'Secret', + type: 'json-secret', + validators: {}, + }, + ...props, + }, + global: { + stubs: { + VTextarea: TextareaStub, + }, + }, + }) + } + + it('warns for unsupported validator types', async () => { + shallowMount(GGenericInputField, { + props: { + modelValue: '', + field: { + key: 'foo', + label: 'Foo', + type: 'text', + validators: { + unsupported: { + type: 'doesNotExist', + }, + }, + }, + }, + global: { + stubs: { + VTextField: true, + }, + }, + }) + + await nextTick() + + expect(warnSpy).toHaveBeenCalledWith('Ignoring unsupported validator type \'doesNotExist\' for field \'foo\'') + }) + + it('does not mutate validator configuration when deriving default messages', async () => { + const field = { + key: 'secret', + label: 'Secret', + type: 'json-secret', + validators: { + validObject: { + type: 'isValidObject', + }, + projectID: { + type: 'hasObjectProp', + key: 'project_id', + pattern: /^[a-z][a-z0-9-]+$/, + }, + accountType: { + type: 'hasObjectProp', + key: 'type', + value: 'service_account', + }, + }, + } + + mountStructuredInputField({ field }) + await nextTick() + + expect(field.validators.validObject).not.toHaveProperty('message') + expect(field.validators.projectID).not.toHaveProperty('message') + expect(field.validators.accountType).not.toHaveProperty('message') + }) + + it('imports a JSON file dropped by extension when the MIME type is missing', async () => { + vi.stubGlobal('FileReader', class { + readAsText () { + this.onload({ + target: { + result: '{"foo":"bar"}', + }, + }) + } + }) + const wrapper = mountStructuredInputField() + + await nextTick() + wrapper.find('textarea').element.dispatchEvent(createDropEvent([{ + name: 'secret.json', + type: '', + }])) + + expect(wrapper.emitted('update:modelValue')).toEqual([ + [{ foo: 'bar' }], + ]) + }) + + it('shows the rejection reason for unsupported dropped files', async () => { + const wrapper = mountStructuredInputField() + + await nextTick() + wrapper.find('textarea').element.dispatchEvent(createDropEvent([{ + name: 'secret.txt', + type: 'text/plain', + }])) + await nextTick() + + expect(wrapper.findComponent(TextareaStub).props('errorMessages')).toEqual([ + 'File "secret.txt" was rejected. Expected a JSON file (.json), but received text/plain.', + ]) + }) + + it('shows the drop rejection reason before validation errors', async () => { + const wrapper = mountStructuredInputField({ + field: { + key: 'secret', + label: 'Secret', + type: 'json-secret', + validators: { + required: { + type: 'required', + }, + }, + }, + }) + + await nextTick() + wrapper.find('textarea').element.dispatchEvent(createDropEvent([{ + name: 'secret.txt', + type: 'text/plain', + }])) + await nextTick() + + const errorMessages = wrapper.findComponent(TextareaStub).props('errorMessages') + expect(errorMessages[0]).toBe('File "secret.txt" was rejected. Expected a JSON file (.json), but received text/plain.') + expect(errorMessages).toContain('Value is required') + }) + + it('clears the drop rejection reason after the field is edited', async () => { + const wrapper = mountStructuredInputField() + + await nextTick() + wrapper.find('textarea').element.dispatchEvent(createDropEvent([{ + name: 'secret.txt', + type: 'text/plain', + }])) + await nextTick() + + await wrapper.find('textarea').setValue('{"foo":"bar"}') + + expect(wrapper.findComponent(TextareaStub).props('errorMessages')).toEqual([]) + }) +}) diff --git a/frontend/__tests__/composables/__snapshots__/credential.helper.spec.js.snap b/frontend/__tests__/composables/__snapshots__/credential.helper.spec.js.snap index 6baf07644a..7bd80b9c91 100644 --- a/frontend/__tests__/composables/__snapshots__/credential.helper.spec.js.snap +++ b/frontend/__tests__/composables/__snapshots__/credential.helper.spec.js.snap @@ -88,7 +88,7 @@ exports[`secretDetails > matches snapshots for all known provider types (includi "label": "API Key", }, ], - "onMetal": undefined, + "onmetal": undefined, "openstack": [ { "label": "Domain Name", diff --git a/frontend/__tests__/composables/__snapshots__/useShootContext.spec.js.snap b/frontend/__tests__/composables/__snapshots__/useShootContext.spec.js.snap index 143f5c3758..f5288d9ddd 100644 --- a/frontend/__tests__/composables/__snapshots__/useShootContext.spec.js.snap +++ b/frontend/__tests__/composables/__snapshots__/useShootContext.spec.js.snap @@ -622,6 +622,78 @@ exports[`composables > useShootContext > should create a default "gcp" shoot man } `; +exports[`composables > useShootContext > should create a default "hcloud" shoot manifest 1`] = ` +{ + "apiVersion": "core.gardener.cloud/v1beta1", + "kind": "Shoot", + "metadata": { + "name": "m6kgstc1b0", + "namespace": "garden-test", + }, + "spec": { + "cloudProfile": { + "kind": "CloudProfile", + "name": "hcloud", + }, + "kubernetes": { + "version": "1.28.6", + }, + "maintenance": { + "autoUpdate": { + "kubernetesVersion": true, + "machineImageVersion": true, + }, + "timeWindow": { + "begin": "220000+0100", + "end": "230000+0100", + }, + }, + "networking": { + "nodes": "10.180.0.0/16", + "type": "calico", + }, + "provider": { + "controlPlaneConfig": { + "apiVersion": "hcloud.provider.extensions.gardener.cloud/v1alpha1", + "kind": "ControlPlaneConfig", + "zone": "fsn1-dc14", + }, + "infrastructureConfig": { + "apiVersion": "hcloud.provider.extensions.gardener.cloud/v1alpha1", + "kind": "InfrastructureConfig", + "networks": { + "workers": "10.180.0.0/16", + }, + }, + "type": "hcloud", + "workers": [ + { + "cri": { + "name": "containerd", + }, + "machine": { + "architecture": "amd64", + "image": { + "name": "gardenlinux", + "version": "1312.3.0", + }, + "type": "cpx11", + }, + "maxSurge": 1, + "maximum": 2, + "minimum": 1, + "name": "worker-m6kgs", + "zones": [ + "fsn1-dc14", + ], + }, + ], + }, + "region": "fsn1", + }, +} +`; + exports[`composables > useShootContext > should create a default "ironcore" shoot manifest 1`] = ` { "apiVersion": "core.gardener.cloud/v1beta1", @@ -696,6 +768,212 @@ exports[`composables > useShootContext > should create a default "ironcore" shoo } `; +exports[`composables > useShootContext > should create a default "local" shoot manifest 1`] = ` +{ + "apiVersion": "core.gardener.cloud/v1beta1", + "kind": "Shoot", + "metadata": { + "name": "m6kgstc1b0", + "namespace": "garden-test", + }, + "spec": { + "cloudProfile": { + "kind": "CloudProfile", + "name": "local", + }, + "kubernetes": { + "version": "1.28.6", + }, + "maintenance": { + "autoUpdate": { + "kubernetesVersion": true, + "machineImageVersion": true, + }, + "timeWindow": { + "begin": "220000+0100", + "end": "230000+0100", + }, + }, + "networking": { + "nodes": "10.180.0.0/16", + "type": "calico", + }, + "provider": { + "type": "local", + "workers": [ + { + "cri": { + "name": "containerd", + }, + "machine": { + "architecture": "amd64", + "image": { + "name": "gardenlinux", + "version": "1312.3.0", + }, + "type": "local-dev-small", + }, + "maxSurge": 1, + "maximum": 2, + "minimum": 1, + "name": "worker-m6kgs", + }, + ], + }, + "region": "local", + }, +} +`; + +exports[`composables > useShootContext > should create a default "metal" shoot manifest 1`] = ` +{ + "apiVersion": "core.gardener.cloud/v1beta1", + "kind": "Shoot", + "metadata": { + "name": "m6kgstc1b0", + "namespace": "garden-test", + }, + "spec": { + "cloudProfile": { + "kind": "CloudProfile", + "name": "metal", + }, + "kubernetes": { + "kubeControllerManager": { + "nodeCIDRMaskSize": 23, + }, + "kubelet": { + "maxPods": 510, + }, + "version": "1.28.6", + }, + "maintenance": { + "autoUpdate": { + "kubernetesVersion": true, + "machineImageVersion": true, + }, + "timeWindow": { + "begin": "220000+0100", + "end": "230000+0100", + }, + }, + "networking": { + "pods": "10.244.128.0/18", + "providerConfig": { + "apiVersion": "calico.networking.extensions.gardener.cloud/v1alpha1", + "backend": "vxlan", + "ipv4": { + "autoDetectionMethod": "interface=lo", + "mode": "Always", + "pool": "vxlan", + }, + "kind": "NetworkConfig", + "typha": { + "enabled": true, + }, + }, + "services": "10.244.192.0/18", + "type": "calico", + }, + "provider": { + "controlPlaneConfig": { + "apiVersion": "metal.provider.extensions.gardener.cloud/v1alpha1", + "kind": "ControlPlaneConfig", + }, + "infrastructureConfig": { + "apiVersion": "metal.provider.extensions.gardener.cloud/v1alpha1", + "firewall": { + "size": "c1-metal-small", + }, + "kind": "InfrastructureConfig", + "partitionID": "eu01-a", + }, + "type": "metal", + "workers": [ + { + "cri": { + "name": "containerd", + }, + "machine": { + "architecture": "amd64", + "image": { + "name": "gardenlinux", + "version": "1312.3.0", + }, + "type": "c1-metal-small", + }, + "maxSurge": 1, + "maximum": 2, + "minimum": 1, + "name": "worker-m6kgs", + }, + ], + }, + "region": "eu01", + }, +} +`; + +exports[`composables > useShootContext > should create a default "onmetal" shoot manifest 1`] = ` +{ + "apiVersion": "core.gardener.cloud/v1beta1", + "kind": "Shoot", + "metadata": { + "name": "m6kgstc1b0", + "namespace": "garden-test", + }, + "spec": { + "cloudProfile": { + "kind": "CloudProfile", + "name": "onmetal", + }, + "kubernetes": { + "version": "1.28.6", + }, + "maintenance": { + "autoUpdate": { + "kubernetesVersion": true, + "machineImageVersion": true, + }, + "timeWindow": { + "begin": "220000+0100", + "end": "230000+0100", + }, + }, + "networking": { + "nodes": "10.180.0.0/16", + "type": "calico", + }, + "provider": { + "type": "onmetal", + "workers": [ + { + "cri": { + "name": "containerd", + }, + "machine": { + "architecture": "amd64", + "image": { + "name": "gardenlinux", + "version": "1312.3.0", + }, + "type": "c1-onmetal-small", + }, + "maxSurge": 1, + "maximum": 2, + "minimum": 1, + "name": "worker-m6kgs", + "zones": [ + "on01-a", + ], + }, + ], + }, + "region": "on01", + }, +} +`; + exports[`composables > useShootContext > should create a default "openstack" shoot manifest 1`] = ` { "apiVersion": "core.gardener.cloud/v1beta1", @@ -778,3 +1056,138 @@ exports[`composables > useShootContext > should create a default "openstack" sho }, } `; + +exports[`composables > useShootContext > should create a default "stackit" shoot manifest 1`] = ` +{ + "apiVersion": "core.gardener.cloud/v1beta1", + "kind": "Shoot", + "metadata": { + "name": "m6kgstc1b0", + "namespace": "garden-test", + }, + "spec": { + "cloudProfile": { + "kind": "CloudProfile", + "name": "stackit", + }, + "kubernetes": { + "version": "1.28.6", + }, + "maintenance": { + "autoUpdate": { + "kubernetesVersion": true, + "machineImageVersion": true, + }, + "timeWindow": { + "begin": "220000+0100", + "end": "230000+0100", + }, + }, + "networking": { + "nodes": "10.180.0.0/16", + "type": "calico", + }, + "provider": { + "controlPlaneConfig": { + "apiVersion": "stackit.provider.extensions.gardener.cloud/v1alpha1", + "kind": "ControlPlaneConfig", + }, + "infrastructureConfig": { + "apiVersion": "stackit.provider.extensions.gardener.cloud/v1alpha1", + "kind": "InfrastructureConfig", + "networks": { + "workers": "10.180.0.0/16", + }, + }, + "type": "stackit", + "workers": [ + { + "cri": { + "name": "containerd", + }, + "machine": { + "architecture": "amd64", + "image": { + "name": "gardenlinux", + "version": "1312.3.0", + }, + "type": "stackit-machine-1", + }, + "maxSurge": 1, + "maximum": 2, + "minimum": 1, + "name": "worker-m6kgs", + "zones": [ + "eu01-1", + ], + }, + ], + }, + "region": "eu01", + }, +} +`; + +exports[`composables > useShootContext > should create a default "vsphere" shoot manifest 1`] = ` +{ + "apiVersion": "core.gardener.cloud/v1beta1", + "kind": "Shoot", + "metadata": { + "name": "m6kgstc1b0", + "namespace": "garden-test", + }, + "spec": { + "cloudProfile": { + "kind": "CloudProfile", + "name": "vsphere", + }, + "kubernetes": { + "version": "1.28.6", + }, + "maintenance": { + "autoUpdate": { + "kubernetesVersion": true, + "machineImageVersion": true, + }, + "timeWindow": { + "begin": "220000+0100", + "end": "230000+0100", + }, + }, + "networking": { + "nodes": "10.180.0.0/16", + "type": "calico", + }, + "provider": { + "controlPlaneConfig": { + "apiVersion": "vsphere.provider.extensions.gardener.cloud/v1alpha1", + "kind": "ControlPlaneConfig", + }, + "type": "vsphere", + "workers": [ + { + "cri": { + "name": "containerd", + }, + "machine": { + "architecture": "amd64", + "image": { + "name": "gardenlinux", + "version": "1312.3.0", + }, + "type": "vsphere-machine-1", + }, + "maxSurge": 1, + "maximum": 2, + "minimum": 1, + "name": "worker-m6kgs", + "zones": [ + "zone1", + ], + }, + ], + }, + "region": "region1", + }, +} +`; diff --git a/frontend/__tests__/composables/credential.helper.spec.js b/frontend/__tests__/composables/credential.helper.spec.js index fc7291fd97..c8f7428e1c 100644 --- a/frontend/__tests__/composables/credential.helper.spec.js +++ b/frontend/__tests__/composables/credential.helper.spec.js @@ -57,6 +57,14 @@ describe('secretDetails', () => { ...dnsProviders, ] + function providerConfig (name) { + return providerConfigs.find(config => config.name === name) + } + + function secretField (providerName, fieldKey) { + return providerConfig(providerName).secret.fields.find(field => field.key === fieldKey) + } + it('matches snapshots for all known provider types (including unhandled types)', () => { const secret = { data: createSecretData(), @@ -213,4 +221,64 @@ describe('secretDetails', () => { }, ]) }) + + it('preserves migrated provider field validators and defaults', () => { + expect(secretField('aws', 'accessKeyID').validators).toMatchObject({ + required: { type: 'required' }, + minLength: { type: 'minLength', length: 16 }, + maxLength: { type: 'maxLength', length: 128 }, + alphaNumUnderscore: { type: 'alphaNumUnderscore' }, + }) + expect(secretField('aws', 'secretAccessKey').validators).toMatchObject({ + required: { type: 'required' }, + minLength: { type: 'minLength', length: 40 }, + base64: { type: 'base64' }, + }) + + expect(secretField('alicloud', 'accessKeyID').validators).toMatchObject({ + required: { type: 'required' }, + minLength: { type: 'minLength', length: 16 }, + maxLength: { type: 'maxLength', length: 128 }, + }) + expect(secretField('alicloud', 'accessKeySecret').validators).toMatchObject({ + required: { type: 'required' }, + minLength: { type: 'minLength', length: 30 }, + }) + + expect(secretField('azure', 'clientID').validators.guid).toEqual({ type: 'guid' }) + expect(secretField('azure', 'tenantID').validators.guid).toEqual({ type: 'guid' }) + expect(secretField('azure', 'subscriptionID').validators.guid).toEqual({ type: 'guid' }) + + expect(secretField('rfc2136', 'TSIGSecretAlgorithm')).toMatchObject({ + defaultValue: '', + omitWhenEmpty: true, + values: expect.arrayContaining([ + expect.objectContaining({ + value: '', + }), + ]), + }) + expect(secretField('rfc2136', 'TSIGSecretAlgorithm').validators).toBeUndefined() + + expect(secretField('aws-route53', 'AWS_REGION')).toMatchObject({ + omitWhenEmpty: true, + }) + expect(secretField('azure-dns', 'AZURE_CLOUD')).toMatchObject({ + defaultValue: '', + omitWhenEmpty: true, + values: expect.arrayContaining([ + expect.objectContaining({ + value: '', + }), + ]), + }) + }) + + it('limits GCP project IDs to the documented length', () => { + const projectIDValidator = secretField('gcp', 'serviceaccount.json').validators.projectID + + expect(projectIDValidator.pattern.test('abcde1')).toBe(true) + expect(projectIDValidator.pattern.test(`a${'b'.repeat(29)}`)).toBe(true) + expect(projectIDValidator.pattern.test(`a${'b'.repeat(30)}`)).toBe(false) + }) }) diff --git a/frontend/__tests__/composables/useSecretContext.spec.js b/frontend/__tests__/composables/useSecretContext.spec.js index 84d7293295..d9c1538436 100644 --- a/frontend/__tests__/composables/useSecretContext.spec.js +++ b/frontend/__tests__/composables/useSecretContext.spec.js @@ -17,7 +17,10 @@ import { useAuthzStore } from '@/store/authz' import { createSecretContextComposable } from '@/composables/credential/useSecretContext' -import { encodeBase64 } from '@/utils' +import { + decodeBase64, + encodeBase64, +} from '@/utils' describe('composables', () => { describe('useSecretContext', () => { @@ -128,6 +131,137 @@ describe('composables', () => { }) }) + it('should preserve decoded values as strings in secretStringData', () => { + secretContext.setSecretManifest({ + metadata: { + name: 'my-secret', + namespace: testNamespace, + }, + data: { + zero: encodeBase64('0'), + bool: encodeBase64('false'), + nullValue: encodeBase64('null'), + empty: encodeBase64(''), + object: encodeBase64('{"enabled":true}'), + array: encodeBase64('[1,2,3]'), + }, + }) + + expect(secretContext.secretStringData).toEqual({ + zero: '0', + bool: 'false', + nullValue: 'null', + empty: '', + object: '{"enabled":true}', + array: '[1,2,3]', + }) + }) + + it('should parse only configured structured fields', () => { + secretContext.setSecretManifest({ + metadata: { + name: 'my-secret', + namespace: testNamespace, + }, + data: { + plainJson: encodeBase64('{"enabled":true}'), + serviceAccount: encodeBase64('{"project_id":"example","type":"service_account"}'), + yamlConfig: encodeBase64('enabled: true\n'), + invalidJson: encodeBase64('{"broken":'), + }, + }) + + const fields = [ + { key: 'plainJson', type: 'text' }, + { key: 'serviceAccount', type: 'json-secret' }, + { key: 'yamlConfig', type: 'yaml-secret' }, + { key: 'invalidJson', type: 'json-secret' }, + ] + + expect(secretContext.secretStringDataForFields(fields)).toEqual({ + plainJson: '{"enabled":true}', + serviceAccount: { + project_id: 'example', + type: 'service_account', + }, + yamlConfig: { + enabled: true, + }, + invalidJson: '{"broken":', + }) + }) + + it('should encode structured fields according to their configured type', () => { + secretContext.createSecretManifest() + + const fields = [ + { key: 'plainJson', type: 'text' }, + { key: 'serviceAccount', type: 'json-secret' }, + { key: 'yamlConfig', type: 'yaml-secret' }, + ] + + secretContext.setSecretStringDataForFields(fields, { + plainJson: '{"enabled":true}', + serviceAccount: { + project_id: 'example', + type: 'service_account', + }, + yamlConfig: { + enabled: true, + }, + }) + + expect(decodeBase64(secretContext.secretData.plainJson)).toBe('{"enabled":true}') + expect(decodeBase64(secretContext.secretData.serviceAccount)).toBe('{"project_id":"example","type":"service_account"}') + expect(decodeBase64(secretContext.secretData.yamlConfig)).toBe('enabled: true\n') + }) + + it('should omit empty configured fields while preserving other empty values', () => { + secretContext.createSecretManifest() + + const fields = [ + { key: 'AWS_REGION', type: 'text', omitWhenEmpty: true }, + { key: 'AZURE_CLOUD', type: 'select', omitWhenEmpty: true }, + { key: 'TSIGSecretAlgorithm', type: 'select', omitWhenEmpty: true }, + { key: 'emptyValue', type: 'text' }, + ] + + secretContext.setSecretStringDataForFields(fields, { + AWS_REGION: '', + AZURE_CLOUD: '', + TSIGSecretAlgorithm: '', + emptyValue: '', + }) + + expect(secretContext.secretData).toEqual({ + AWS_REGION: undefined, + AZURE_CLOUD: undefined, + TSIGSecretAlgorithm: undefined, + emptyValue: encodeBase64(''), + }) + expect(secretContext.secretManifest.data).toEqual({ + emptyValue: encodeBase64(''), + }) + }) + + it('should preserve falsy values when encoding secretStringData', () => { + secretContext.createSecretManifest() + + secretContext.secretStringData = { + zero: 0, + bool: false, + empty: '', + nullValue: null, + } + + expect(secretContext.secretData).toEqual({ + zero: encodeBase64('0'), + bool: encodeBase64('false'), + empty: encodeBase64(''), + nullValue: undefined, + }) + }) + describe('dnsSecretProviderType', () => { it('should get and set dns provider type correctly', () => { secretContext.setSecretManifest({ diff --git a/frontend/__tests__/composables/useShootContext.spec.js b/frontend/__tests__/composables/useShootContext.spec.js index c13af31e24..0bd49efecf 100644 --- a/frontend/__tests__/composables/useShootContext.spec.js +++ b/frontend/__tests__/composables/useShootContext.spec.js @@ -19,10 +19,13 @@ import { useAuthzStore } from '@/store/authz' import { createShootContextComposable } from '@/composables/useShootContext' +import infraProviders from '@/data/vendors/infra' + import cloneDeep from 'lodash/cloneDeep' describe('composables', () => { let shootContextStore + const infraProviderTypes = infraProviders.map(({ name }) => name) const systemTime = new Date('2024-03-15T14:00:00+01:00') @@ -71,9 +74,11 @@ describe('composables', () => { } describe('useShootContext', () => { - it('should create a default "aws" shoot manifest', async () => { - expect(createShootManifest('aws')).toMatchSnapshot() - }) + for (const providerType of infraProviderTypes) { + it(`should create a default "${providerType}" shoot manifest`, () => { + expect(createShootManifest(providerType)).toMatchSnapshot() + }) + } it('should omit spec.addons if no addon is enabled', () => { createShootManifest('aws') @@ -125,26 +130,6 @@ describe('composables', () => { }) }) - it('should create a default "azure" shoot manifest', async () => { - expect(createShootManifest('azure')).toMatchSnapshot() - }) - - it('should create a default "alicloud" shoot manifest', async () => { - expect(createShootManifest('alicloud')).toMatchSnapshot() - }) - - it('should create a default "gcp" shoot manifest', async () => { - expect(createShootManifest('gcp')).toMatchSnapshot() - }) - - it('should create a default "openstack" shoot manifest', async () => { - expect(createShootManifest('openstack')).toMatchSnapshot() - }) - - it('should create a default "ironcore" shoot manifest', async () => { - expect(createShootManifest('ironcore')).toMatchSnapshot() - }) - it('should change the infrastructure kind', async () => { shootContextStore.createShootManifest() shootContextStore.providerType = 'gcp' diff --git a/frontend/__tests__/composables/useShootDns.spec.js b/frontend/__tests__/composables/useShootDns.spec.js index bfa427211b..684c85d4bf 100644 --- a/frontend/__tests__/composables/useShootDns.spec.js +++ b/frontend/__tests__/composables/useShootDns.spec.js @@ -38,18 +38,41 @@ describe('composables', () => { it('should return dns credentials as a plain array for a dns provider type', () => { const credentialStore = useCredentialStore() - const gardenerExtensionStore = useGardenerExtensionStore() - const cloudProfileStore = useCloudProfileStore() const credentialsForProviderType = getCloudProviderEntityList('aws-route53', { credentialStore, - gardenerExtensionStore, - cloudProfileStore, + vendorType: 'dns', }) expect(Array.isArray(credentialsForProviderType)).toBe(true) expect(credentialsForProviderType).toEqual(credentialStore.dnsCredentialList.filter(credential => credential.metadata?.labels?.['dashboard.gardener.cloud/dnsProviderType'] === 'aws-route53')) }) + + it('should select the entity list by vendor type when provider names overlap', () => { + const infrastructureBinding = { + provider: { type: 'shared-provider' }, + } + const dnsCredential = { + metadata: { + labels: { + 'dashboard.gardener.cloud/dnsProviderType': 'shared-provider', + }, + }, + } + const credentialStore = { + infrastructureBindingList: [infrastructureBinding], + dnsCredentialList: [dnsCredential], + } + + expect(getCloudProviderEntityList('shared-provider', { + credentialStore, + vendorType: 'infra', + })).toEqual([infrastructureBinding]) + expect(getCloudProviderEntityList('shared-provider', { + credentialStore, + vendorType: 'dns', + })).toEqual([dnsCredential]) + }) }) describe('useShootDns', () => { diff --git a/frontend/__tests__/composables/useStructuredTextField.spec.js b/frontend/__tests__/composables/useStructuredTextField.spec.js new file mode 100644 index 0000000000..605399fbf7 --- /dev/null +++ b/frontend/__tests__/composables/useStructuredTextField.spec.js @@ -0,0 +1,41 @@ +// +// SPDX-FileCopyrightText: 2026 SAP SE or an SAP affiliate company and Gardener contributors +// +// SPDX-License-Identifier: Apache-2.0 +// + +import { computed } from 'vue' + +import { useStructuredTextField } from '@/composables/useStructuredTextField' + +describe('composables', () => { + describe('useStructuredTextField', () => { + it('should keep raw strings when initialized from invalid structured data', () => { + const { + rawText, + setRawTextWithValue, + parseRawTextToObject, + } = useStructuredTextField(computed(() => 'json-secret')) + + setRawTextWithValue('{"broken":') + + expect(rawText.value).toBe('{"broken":') + expect(parseRawTextToObject()).toBeUndefined() + }) + + it('should parse valid YAML objects and reject non-object YAML values', () => { + const { + rawText, + parseRawTextToObject, + } = useStructuredTextField(computed(() => 'yaml-secret')) + + rawText.value = 'enabled: true\n' + expect(parseRawTextToObject()).toEqual({ + enabled: true, + }) + + rawText.value = '- item\n' + expect(parseRawTextToObject()).toBeUndefined() + }) + }) +}) diff --git a/frontend/__tests__/stores/__snapshots__/credential.spec.js.snap b/frontend/__tests__/stores/__snapshots__/credential.spec.js.snap index db1b0aa401..d817075198 100644 --- a/frontend/__tests__/stores/__snapshots__/credential.spec.js.snap +++ b/frontend/__tests__/stores/__snapshots__/credential.spec.js.snap @@ -23,6 +23,12 @@ exports[`stores > credential > should return infrastructureBindingList 1`] = ` "gcp", "openstack", "alicloud", + "metal", + "vsphere", + "hcloud", + "onmetal", "ironcore", + "stackit", + "local", ] `; diff --git a/frontend/__tests__/utils/createShoot.spec.js b/frontend/__tests__/utils/createShoot.spec.js index c119088304..a222896837 100644 --- a/frontend/__tests__/utils/createShoot.spec.js +++ b/frontend/__tests__/utils/createShoot.spec.js @@ -8,7 +8,14 @@ import { splitCIDR, getZonesNetworkConfiguration, findFreeNetworks, + getKubernetesTemplate, + getWorkerProviderConfig, } from '@/utils/shoot' +import infraProviders from '@/data/vendors/infra' + +function infraVendor (name) { + return infraProviders.find(vendor => vendor.name === name) +} describe('utils', () => { describe('createShoot', () => { @@ -79,7 +86,7 @@ describe('utils', () => { ] const workerCIDR = '10.251.0.0/16' - const freeNetworks = findFreeNetworks(existingZonesNetworkConfiguration, workerCIDR, 'aws', 4) + const freeNetworks = findFreeNetworks(existingZonesNetworkConfiguration, workerCIDR, 4, infraVendor('aws')) expect(freeNetworks).toBeInstanceOf(Array) expect(freeNetworks).toHaveLength(2) }) @@ -113,7 +120,7 @@ describe('utils', () => { ] const workerCIDR = '10.251.0.0/16' - const freeNetworks = findFreeNetworks(existingZonesNetworkConfiguration, workerCIDR, 'aws', 4) + const freeNetworks = findFreeNetworks(existingZonesNetworkConfiguration, workerCIDR, 4, infraVendor('aws')) expect(freeNetworks).toBeInstanceOf(Array) expect(freeNetworks).toHaveLength(0) }) @@ -129,7 +136,7 @@ describe('utils', () => { ] const workerCIDR = '10.251.0.0/16' - const freeNetworks = findFreeNetworks(existingZonesNetworkConfiguration, workerCIDR, 'aws', 4) + const freeNetworks = findFreeNetworks(existingZonesNetworkConfiguration, workerCIDR, 4, infraVendor('aws')) expect(freeNetworks).toBeInstanceOf(Array) expect(freeNetworks).toHaveLength(0) }) @@ -137,7 +144,7 @@ describe('utils', () => { it('should return networks for all zones if existingZonesNetworkConfiguration is undefined', () => { const workerCIDR = '10.251.0.0/16' - const freeNetworks = findFreeNetworks(undefined, workerCIDR, 'aws', 4) + const freeNetworks = findFreeNetworks(undefined, workerCIDR, 4, infraVendor('aws')) expect(freeNetworks).toBeInstanceOf(Array) expect(freeNetworks).toHaveLength(4) }) @@ -185,23 +192,23 @@ describe('utils', () => { ] it('should return undefined for infrastructures that do not require network config for zones (new cluster)', () => { - const zonesNetworkConfiguration = getZonesNetworkConfiguration(undefined, workers, 'azure', 3, undefined, nodeCIDR) + const zonesNetworkConfiguration = getZonesNetworkConfiguration(undefined, workers, 3, undefined, nodeCIDR, infraVendor('azure')) expect(zonesNetworkConfiguration).toBeUndefined() }) it('should return undefined for infrastructures that do not require network config for zones (existing cluster)', () => { - const zonesNetworkConfiguration = getZonesNetworkConfiguration(undefined, workers, 'azure', 3, nodeCIDR, undefined) + const zonesNetworkConfiguration = getZonesNetworkConfiguration(undefined, workers, 3, nodeCIDR, undefined, infraVendor('azure')) expect(zonesNetworkConfiguration).toBeUndefined() }) it('should return initial network config', () => { - const zonesNetworkConfiguration = getZonesNetworkConfiguration(undefined, workers, 'aws', 3, undefined, nodeCIDR) + const zonesNetworkConfiguration = getZonesNetworkConfiguration(undefined, workers, 3, undefined, nodeCIDR, infraVendor('aws')) expect(zonesNetworkConfiguration).toBeInstanceOf(Array) expect(zonesNetworkConfiguration).toHaveLength(2) }) it('should keep network config if zones are the same', () => { - const zonesNetworkConfiguration = getZonesNetworkConfiguration(customZonesNetworkConfiguration, workers, 'aws', 3, undefined, nodeCIDR) + const zonesNetworkConfiguration = getZonesNetworkConfiguration(customZonesNetworkConfiguration, workers, 3, undefined, nodeCIDR, infraVendor('aws')) expect(zonesNetworkConfiguration).toBeInstanceOf(Array) expect(zonesNetworkConfiguration).toHaveLength(2) expect(zonesNetworkConfiguration).toEqual(customZonesNetworkConfiguration) @@ -225,7 +232,7 @@ describe('utils', () => { }, ] - const zonesNetworkConfiguration = getZonesNetworkConfiguration(customZonesNetworkConfiguration, workers, 'aws', 3, undefined, newNodeCIDR) + const zonesNetworkConfiguration = getZonesNetworkConfiguration(customZonesNetworkConfiguration, workers, 3, undefined, newNodeCIDR, infraVendor('aws')) expect(zonesNetworkConfiguration).toBeInstanceOf(Array) expect(zonesNetworkConfiguration).toHaveLength(2) expect(zonesNetworkConfiguration).toEqual(newCustomZonesNetworkConfiguration) @@ -245,7 +252,7 @@ describe('utils', () => { ], }, ] - const zonesNetworkConfiguration = getZonesNetworkConfiguration(customZonesNetworkConfiguration, workersWithDifferentZones, 'aws', 3, undefined, nodeCIDR) + const zonesNetworkConfiguration = getZonesNetworkConfiguration(customZonesNetworkConfiguration, workersWithDifferentZones, 3, undefined, nodeCIDR, infraVendor('aws')) expect(zonesNetworkConfiguration).toBeInstanceOf(Array) expect(zonesNetworkConfiguration).toHaveLength(2) expect(zonesNetworkConfiguration).not.toEqual(customZonesNetworkConfiguration) @@ -260,7 +267,7 @@ describe('utils', () => { }, ] - const zonesNetworkConfiguration = getZonesNetworkConfiguration(customZonesNetworkConfiguration, oneZoneWorkers, 'aws', 3, nodeCIDR, undefined) + const zonesNetworkConfiguration = getZonesNetworkConfiguration(customZonesNetworkConfiguration, oneZoneWorkers, 3, nodeCIDR, undefined, infraVendor('aws')) expect(zonesNetworkConfiguration).toBeInstanceOf(Array) expect(zonesNetworkConfiguration).toHaveLength(2) expect(zonesNetworkConfiguration).toEqual(customZonesNetworkConfiguration) @@ -298,7 +305,7 @@ describe('utils', () => { }, ] - const zonesNetworkConfiguration = getZonesNetworkConfiguration(existingZonesNetworkConfiguration, workersWithDifferentZones, 'aws', 3, nodeCIDR, undefined) + const zonesNetworkConfiguration = getZonesNetworkConfiguration(existingZonesNetworkConfiguration, workersWithDifferentZones, 3, nodeCIDR, undefined, infraVendor('aws')) expect(zonesNetworkConfiguration).toBeInstanceOf(Array) expect(zonesNetworkConfiguration).toHaveLength(3) expect(zonesNetworkConfiguration).toEqual(newZonesNetworkConfiguration) @@ -320,9 +327,39 @@ describe('utils', () => { }, ] - const zonesNetworkConfiguration = getZonesNetworkConfiguration(customZonesNetworkConfiguration, workersWithDifferentZones, 'aws', 3, nodeCIDR, undefined) + const zonesNetworkConfiguration = getZonesNetworkConfiguration(customZonesNetworkConfiguration, workersWithDifferentZones, 3, nodeCIDR, undefined, infraVendor('aws')) expect(zonesNetworkConfiguration).toBeUndefined() }) }) + + describe('vendor config templates', () => { + it('should return cloned kubernetes templates and worker provider configs', () => { + const shootVendor = { + shoot: { + templates: { + kubernetes: { + kubelet: { + maxPods: 100, + }, + }, + }, + workerProviderConfig: { + volume: { + iops: 100, + }, + }, + }, + } + + const kubernetesTemplate = getKubernetesTemplate(shootVendor) + const workerProviderConfig = getWorkerProviderConfig(shootVendor) + + kubernetesTemplate.kubelet.maxPods = 200 + workerProviderConfig.volume.iops = 200 + + expect(shootVendor.shoot.templates.kubernetes.kubelet.maxPods).toBe(100) + expect(shootVendor.shoot.workerProviderConfig.volume.iops).toBe(100) + }) + }) }) }) diff --git a/frontend/__tests__/utils/index.spec.js b/frontend/__tests__/utils/index.spec.js index a8f9a58324..35602611a0 100644 --- a/frontend/__tests__/utils/index.spec.js +++ b/frontend/__tests__/utils/index.spec.js @@ -20,12 +20,176 @@ import { isEmail, convertToGi, convertToGibibyte, + handleTextFieldDrop, } from '@/utils' import pick from 'lodash/pick' import find from 'lodash/find' describe('utils', () => { + describe('#handleTextFieldDrop', () => { + const originalFileReader = globalThis.FileReader + + afterEach(() => { + vi.stubGlobal('FileReader', originalFileReader) + }) + + function createTextField () { + const element = document.createElement('div') + document.body.appendChild(element) + return { $el: element } + } + + function createDropEvent (files) { + const event = new Event('drop', { + bubbles: true, + cancelable: true, + }) + Object.defineProperty(event, 'dataTransfer', { + value: { files }, + }) + return event + } + + it('accepts files by configured extension if the MIME type is missing', () => { + vi.stubGlobal('FileReader', class { + readAsText () { + this.onload({ + target: { + result: '{"foo":"bar"}', + }, + }) + } + }) + + const onDrop = vi.fn() + const onReject = vi.fn() + const textField = createTextField() + const dispose = handleTextFieldDrop(textField, /json/, onDrop, { + acceptedFileDescription: 'a JSON file (.json)', + acceptedFileExtensions: ['.json'], + onReject, + }) + + textField.$el.dispatchEvent(createDropEvent([{ + name: 'secret.json', + type: '', + }])) + + expect(onDrop).toHaveBeenCalledWith('{"foo":"bar"}') + expect(onReject).not.toHaveBeenCalled() + + dispose() + textField.$el.remove() + }) + + it('rejects drops without files', () => { + const onDrop = vi.fn() + const onReject = vi.fn() + const textField = createTextField() + const dispose = handleTextFieldDrop(textField, /json/, onDrop, { + acceptedFileDescription: 'a JSON file (.json)', + onReject, + }) + + textField.$el.dispatchEvent(createDropEvent([])) + + expect(onDrop).not.toHaveBeenCalled() + expect(onReject).toHaveBeenCalledWith({ + code: 'missing-file', + file: undefined, + message: 'Drop a JSON file (.json) to import its contents.', + }) + + dispose() + textField.$el.remove() + }) + + it('rejects multiple files', () => { + const onDrop = vi.fn() + const onReject = vi.fn() + const textField = createTextField() + const dispose = handleTextFieldDrop(textField, /json/, onDrop, { + acceptedFileDescription: 'a JSON file (.json)', + onReject, + }) + + textField.$el.dispatchEvent(createDropEvent([ + { name: 'one.json', type: 'application/json' }, + { name: 'two.json', type: 'application/json' }, + ])) + + expect(onDrop).not.toHaveBeenCalled() + expect(onReject).toHaveBeenCalledWith({ + code: 'too-many-files', + file: undefined, + message: 'Drop only one file at a time. Expected a JSON file (.json).', + }) + + dispose() + textField.$el.remove() + }) + + it('rejects unsupported file types', () => { + const onDrop = vi.fn() + const onReject = vi.fn() + const textField = createTextField() + const file = { + name: 'secret.txt', + type: 'text/plain', + } + const dispose = handleTextFieldDrop(textField, /json/, onDrop, { + acceptedFileDescription: 'a JSON file (.json)', + acceptedFileExtensions: ['.json'], + onReject, + }) + + textField.$el.dispatchEvent(createDropEvent([file])) + + expect(onDrop).not.toHaveBeenCalled() + expect(onReject).toHaveBeenCalledWith({ + code: 'unsupported-file-type', + file, + message: 'File "secret.txt" was rejected. Expected a JSON file (.json), but received text/plain.', + }) + + dispose() + textField.$el.remove() + }) + + it('reports file read errors', () => { + vi.stubGlobal('FileReader', class { + readAsText () { + this.onerror() + } + }) + + const onDrop = vi.fn() + const onReject = vi.fn() + const textField = createTextField() + const file = { + name: 'secret.json', + type: 'application/json', + } + const dispose = handleTextFieldDrop(textField, /json/, onDrop, { + acceptedFileDescription: 'a JSON file (.json)', + onReject, + }) + + textField.$el.dispatchEvent(createDropEvent([file])) + + expect(onDrop).not.toHaveBeenCalled() + expect(onReject).toHaveBeenCalledWith({ + code: 'read-error', + file, + message: 'Could not read file "secret.json".', + }) + + dispose() + textField.$el.remove() + }) + }) + describe('authorization', () => { describe('#canI', () => { let rulesReview diff --git a/frontend/__tests__/utils/inputFieldTypes.spec.js b/frontend/__tests__/utils/inputFieldTypes.spec.js new file mode 100644 index 0000000000..8ffb241368 --- /dev/null +++ b/frontend/__tests__/utils/inputFieldTypes.spec.js @@ -0,0 +1,52 @@ +// +// SPDX-FileCopyrightText: 2026 SAP SE or an SAP affiliate company and Gardener contributors +// +// SPDX-License-Identifier: Apache-2.0 +// + +import { + isJsonFieldType, + isSecretFieldType, + isStructuredFieldType, + isStructuredSecretFieldType, + isYamlFieldType, + structuredFieldTypes, +} from '@/utils/inputFieldTypes' + +describe('utils', () => { + describe('inputFieldTypes', () => { + it('should classify structured field types', () => { + expect(structuredFieldTypes).toEqual(new Set([ + 'json', + 'json-secret', + 'yaml', + 'yaml-secret', + ])) + + expect(isStructuredFieldType('json')).toBe(true) + expect(isStructuredFieldType('json-secret')).toBe(true) + expect(isStructuredFieldType('yaml')).toBe(true) + expect(isStructuredFieldType('yaml-secret')).toBe(true) + expect(isStructuredFieldType('text')).toBe(false) + }) + + it('should classify json, yaml and secret-like field types', () => { + expect(isJsonFieldType('json')).toBe(true) + expect(isJsonFieldType('json-secret')).toBe(true) + expect(isJsonFieldType('yaml')).toBe(false) + + expect(isYamlFieldType('yaml')).toBe(true) + expect(isYamlFieldType('yaml-secret')).toBe(true) + expect(isYamlFieldType('json')).toBe(false) + + expect(isStructuredSecretFieldType('json-secret')).toBe(true) + expect(isStructuredSecretFieldType('yaml-secret')).toBe(true) + expect(isStructuredSecretFieldType('password')).toBe(false) + + expect(isSecretFieldType('password')).toBe(true) + expect(isSecretFieldType('json-secret')).toBe(true) + expect(isSecretFieldType('yaml-secret')).toBe(true) + expect(isSecretFieldType('json')).toBe(false) + }) + }) +}) diff --git a/frontend/src/components/Credentials/GSecretDialog.vue b/frontend/src/components/Credentials/GSecretDialog.vue index 32e0e97318..eada220004 100644 --- a/frontend/src/components/Credentials/GSecretDialog.vue +++ b/frontend/src/components/Credentials/GSecretDialog.vue @@ -31,26 +31,28 @@ SPDX-License-Identifier: Apache-2.0 ref="secretDetails" class="d-flex flex-column flex-grow-1" > -
- Before you can provision and access a Kubernetes cluster on Alibaba Cloud, you need to add account credentials. To manage
- credentials for Alibaba Cloud Resource Access Management (RAM), use the
-
- Gardener uses encrypted system disk when creating Shoot, please enable ECS disk encryption on Alibaba Cloud Console
- (
- Copy the Alibaba Cloud RAM policy document below and attach it to the RAM user
- (
- You need to provide an access key (access key ID and secret access key) for Alibaba Cloud to allow the dns-controller-manager to authenticate to Alibaba Cloud DNS. -
-
- For details see
-
- Before you can provision and access a Kubernetes cluster, you need to add account credentials. Gardener needs the credentials to provision and operate the AWS infrastructure for your Kubernetes cluster. -
-- Before you can use an external DNS provider, you need to add account credentials. - The user needs permissions on the hosted zone to list and change DNS records. -
-
- To manage
- credentials for AWS Identity and Access Management (IAM), use the
-
- Copy the AWS IAM policy document below and attach it to the IAM user
- (
In this example, the placeholder for the hosted zone is Z2XXXXXXXXXXXX
-- Before you can provision and access a Kubernetes cluster on Azure, you need to add account/subscription credentials. - The Gardener needs the credentials of a service principal assigned to an account/subscription to provision - and operate the Azure infrastructure for your Kubernetes cluster. -
-
- Ensure that the service principal has the permissions defined
-
- Read the
-
- Follow the steps as described in the Azure documentation to
-
- To use this provider you need to generate an API token from the Cloudflare dashboard. A detailed documentation to generate an API token is available at
-
- Note: You need to generate an API token and not an API key. -
-- To generate the token make sure the token has permission of Zone:Read and DNS:Edit for all zones. Optionally you can exclude certain zones. -
-- Note: You need to Include All zones in the Zone Resources section. Setting Specific zone doesn't work. But you can still add one or more Excludes. -
-- Generate the token and keep this key safe as it won't be shown again. -
-$ echo -n '1234567890123456789' | base64-
- to get the base64 encoded token. In this eg. this would be MTIzNDU2Nzg5MDEyMzQ1Njc4OQ==. -
-
- This DNS provider allows you to create and manage DNS entries for authoritive DNS server supporting dynamic updates with DNS messages following
-
- The configuration is depending on the DNS server product. You need permissions for update and transfer (AXFR) actions on your zones and a TSIG secret.
-
- For details see
-
- A service account is a special account that can be used by services and applications running on your Google - Compute Engine instance to interact with other Google Cloud Platform APIs. Applications can use service - account credentials to authorize themselves to a set of APIs and perform actions within the permissions - granted to the service account and virtual machine instance. -
- -- Ensure that the service account has at least the roles below. -
- -- The Service Account has to be enabled for the Google Identity and Access Management API. -
- -
- Read the
-
- You need to provide a service account and a key (serviceaccount.json) to allow the dns-controller-manager to authenticate and execute calls to Cloud DNS. -
-
- For details on Cloud DNS see
-
- The service account needs permissions on the hosted zone to list and change DNS records. For details on which permissions or roles are required see
This is a generic secret dialog.
- Please enter data required for {{ vendorName }}.
+ Please enter data required for {{ vendorName }}.
- Before you can provision and access a Kubernetes cluster on Hetzner Cloud, you need to add a Hetzner Cloud token. - The Gardener needs these credentials to provision and operate Hetzner Cloud infrastructure for your Kubernetes cluster. -
-
- Please read the
-
Before you can use Infoblox DNS provider, you need to add account credentials.
-Please enter account information for a technical user.
-- Before you can provision and access a Kubernetes cluster on Metal Stack, you need to provide HMAC credentials and the endpoint of your Metal API. - The Gardener needs the credentials to provision and operate the Metal Stack infrastructure for your Kubernetes cluster. -
-- You need to provide an access token for Netlify to allow the dns-controller-manager to authenticate with the Netlify DNS API. -
-- Then, base64 encode the token. For example, if the generated token in 1234567890123456, use -
-$ echo -n '1234567890123456789' | base64-
- For details, see
-
- Before you can provision and access a Kubernetes cluster on VMware vSphere, you need to add vSphere and NSX-T account credentials. - The Gardener needs these credentials to provision and operate the VMware vSphere infrastructure for your Kubernetes cluster. -
-- Ensure that these accounts have privileges to create, modify and delete VMs and Networking resources. -
-
- Please read the
-
+ You need to provide an access key (access key ID and secret access key) for Alibaba Cloud to allow the dns-controller-manager to authenticate to Alibaba Cloud DNS. +
++ For details see + AccessKey Client. + Currently the regionId is fixed to cn-shanghai. +
+ `, }, } diff --git a/frontend/src/data/vendors/dns/aws-route53.js b/frontend/src/data/vendors/dns/aws-route53.js index 45da4b20b4..10859a42aa 100644 --- a/frontend/src/data/vendors/dns/aws-route53.js +++ b/frontend/src/data/vendors/dns/aws-route53.js @@ -1,3 +1,5 @@ +import aws from '../infra/aws' + export default { name: 'aws-route53', displayName: 'Amazon Route53', @@ -12,5 +14,60 @@ export default { }, }, ], + fields: [ + ...aws.secret.fields, + { + key: 'AWS_REGION', + label: 'Region (optional)', + hint: 'Overwrite default region of Route 53 endpoint. Required for certain regions. Example value: eu-central-1', + type: 'text', + omitWhenEmpty: true, + }, + ], + help: ` ++ Before you can use an external DNS provider, you need to add account credentials. + The user needs permissions on the hosted zone to list and change DNS records. +
++ To manage + credentials for AWS Identity and Access Management (IAM), use the + IAM Console. +
++ Copy the AWS IAM policy document below and attach it to the IAM user + (official documentation). +
+In this example, the placeholder for the hosted zone is Z2XXXXXXXXXXXX
+ `, + helpJSONTemplate: { + Version: '2012-10-17', + Statement: [ + { + Sid: 'VisualEditor0', + Effect: 'Allow', + Action: 'route53:ListResourceRecordSets', + Resource: 'arn:aws:route53:::hostedzone/*', + }, + { + Sid: 'VisualEditor1', + Effect: 'Allow', + Action: 'route53:GetHostedZone', + Resource: 'arn:aws:route53:::hostedzone/Z2XXXXXXXXXXXX', + }, + { + Sid: 'VisualEditor2', + Effect: 'Allow', + Action: 'route53:ListHostedZones', + Resource: '*', + }, + { + Sid: 'VisualEditor3', + Effect: 'Allow', + Action: 'route53:ChangeResourceRecordSets', + Resource: 'arn:aws:route53:::hostedzone/Z2XXXXXXXXXXXX', + }, + ], + }, }, } diff --git a/frontend/src/data/vendors/dns/azure-dns.js b/frontend/src/data/vendors/dns/azure-dns.js index 246c274049..896a66235e 100644 --- a/frontend/src/data/vendors/dns/azure-dns.js +++ b/frontend/src/data/vendors/dns/azure-dns.js @@ -1,3 +1,5 @@ +import azure from '../infra/azure' + export default { name: 'azure-dns', displayName: 'Azure DNS', @@ -15,5 +17,40 @@ export default { }, }, ], + fields: [ + ...azure.secret.fields, + { + key: 'AZURE_CLOUD', + label: 'Azure Cloud', + type: 'select', + defaultValue: '', + omitWhenEmpty: true, + values: [ + { + title: 'Provider default (Azure Public)', + value: '', + }, + { + title: 'AzurePublic', + value: 'AzurePublic', + }, + { + title: 'AzureChina', + value: 'AzureChina', + }, + { + title: 'AzureGovernment', + value: 'AzureGovernment', + }, + ], + }, + ], + help: ` ++ Follow the steps as described in the Azure documentation to + create a service principal account + and grant the service principal account 'DNS Zone Contributor' permissions to the resource group. +
+ `, }, } diff --git a/frontend/src/data/vendors/dns/azure-private-dns.js b/frontend/src/data/vendors/dns/azure-private-dns.js index 3768053fa6..a10b23eb2a 100644 --- a/frontend/src/data/vendors/dns/azure-private-dns.js +++ b/frontend/src/data/vendors/dns/azure-private-dns.js @@ -5,7 +5,5 @@ export default { displayName: 'Azure Private DNS', weight: 300, icon: 'azure-dns.svg', - secret: { - details: azureDns.secret.details, - }, + secret: azureDns.secret, } diff --git a/frontend/src/data/vendors/dns/cloudflare.js b/frontend/src/data/vendors/dns/cloudflare.js index 8de30c151c..ed655938be 100644 --- a/frontend/src/data/vendors/dns/cloudflare.js +++ b/frontend/src/data/vendors/dns/cloudflare.js @@ -10,5 +10,39 @@ export default { hidden: true, }, ], + fields: [ + { + key: 'apiToken', + label: 'Cloudflare API Token', + type: 'password', + validators: { + required: { + type: 'required', + }, + }, + }, + ], + help: ` ++ To use this provider you need to generate an API token from the Cloudflare dashboard. A detailed documentation to generate an API token is available at + Managing API Tokens and Keys. +
++ Note: You need to generate an API token and not an API key. +
++ To generate the token make sure the token has permission of Zone:Read and DNS:Edit for all zones. Optionally you can exclude certain zones. +
++ Note: You need to Include All zones in the Zone Resources section. Setting Specific zone doesn't work. But you can still add one or more Excludes. +
++ Generate the token and keep this key safe as it won't be shown again. +
+$ echo -n '1234567890123456789' | base64+
+ to get the base64 encoded token. In this eg. this would be MTIzNDU2Nzg5MDEyMzQ1Njc4OQ==. +
+ `, }, } diff --git a/frontend/src/data/vendors/dns/google-clouddns.js b/frontend/src/data/vendors/dns/google-clouddns.js index 8802f4fdc7..b4f4026741 100644 --- a/frontend/src/data/vendors/dns/google-clouddns.js +++ b/frontend/src/data/vendors/dns/google-clouddns.js @@ -1,3 +1,5 @@ +import gcp from '../infra/gcp' + export default { name: 'google-clouddns', displayName: 'Google Cloud DNS', @@ -14,5 +16,22 @@ export default { }, }, ], + fields: gcp.secret.fields, + help: ` ++ You need to provide a service account and a key (serviceaccount.json) to allow the dns-controller-manager to authenticate and execute calls to Cloud DNS. +
++ For details on Cloud DNS see + Cloud DNS documentation, + and on Service Accounts see + Service Account documentation. +
++ The service account needs permissions on the hosted zone to list and change DNS records. For details on which permissions or roles are required see + Access Control documentation. + A possible role is roles/dns.admin "DNS Administrator". +
+ `, }, } diff --git a/frontend/src/data/vendors/dns/index.js b/frontend/src/data/vendors/dns/index.js index 98ae6b1738..8b75f7d4bc 100644 --- a/frontend/src/data/vendors/dns/index.js +++ b/frontend/src/data/vendors/dns/index.js @@ -1,25 +1,25 @@ import awsRoute53 from './aws-route53' +import alicloudDns from './alicloud-dns' import azureDns from './azure-dns' import azurePrivateDns from './azure-private-dns' -import googleCloudDns from './google-clouddns' -import openstackDesignate from './openstack-designate' -import alicloudDns from './alicloud-dns' import cloudflareDns from './cloudflare' -import infobloxDns from './infoblox' -import netlifyDns from './netlify' -import powerdns from './powerdns' import rfc2136 from './rfc2136' +import googleCloudDns from './google-clouddns' +import infoblox from './infoblox' +import netlify from './netlify' +import powerdns from './powerdns' +import openstackDesignate from './openstack-designate' export default [ awsRoute53, + alicloudDns, azureDns, azurePrivateDns, - googleCloudDns, - openstackDesignate, - alicloudDns, cloudflareDns, - infobloxDns, - netlifyDns, - powerdns, rfc2136, + googleCloudDns, + infoblox, + netlify, + powerdns, + openstackDesignate, ] diff --git a/frontend/src/data/vendors/dns/infoblox.js b/frontend/src/data/vendors/dns/infoblox.js index 3ec19875bd..a8ce714d93 100644 --- a/frontend/src/data/vendors/dns/infoblox.js +++ b/frontend/src/data/vendors/dns/infoblox.js @@ -12,5 +12,31 @@ export default { }, }, ], + fields: [ + { + key: 'USERNAME', + label: 'Infoblox Username', + type: 'text', + validators: { + required: { + type: 'required', + }, + }, + }, + { + key: 'PASSWORD', + label: 'Infoblox Password', + type: 'password', + validators: { + required: { + type: 'required', + }, + }, + }, + ], + help: ` +Before you can use Infoblox DNS provider, you need to add account credentials.
+Please enter account information for a technical user.
+ `, }, } diff --git a/frontend/src/data/vendors/dns/netlify.js b/frontend/src/data/vendors/dns/netlify.js index 7dfa5e828c..a0204941ea 100644 --- a/frontend/src/data/vendors/dns/netlify.js +++ b/frontend/src/data/vendors/dns/netlify.js @@ -10,5 +10,30 @@ export default { hidden: true, }, ], + fields: [ + { + key: 'apiToken', + label: 'Netlify API Token', + type: 'password', + validators: { + required: { + type: 'required', + }, + }, + }, + ], + help: ` ++ You need to provide an access token for Netlify to allow the dns-controller-manager to authenticate with the Netlify DNS API. +
++ Then, base64 encode the token. For example, if the generated token is 1234567890123456789, use +
+$ echo -n '1234567890123456789' | base64+
+ For details, see + Netlify Documentation. +
+ `, }, } diff --git a/frontend/src/data/vendors/dns/openstack-designate.js b/frontend/src/data/vendors/dns/openstack-designate.js index 6fa28abab9..444ce01353 100644 --- a/frontend/src/data/vendors/dns/openstack-designate.js +++ b/frontend/src/data/vendors/dns/openstack-designate.js @@ -1,3 +1,5 @@ +import openstack from '../infra/openstack' + export default { name: 'openstack-designate', displayName: 'OpenStack Designate', @@ -18,5 +20,6 @@ export default { }, }, ], + fields: openstack.secret.fields, }, } diff --git a/frontend/src/data/vendors/dns/powerdns.js b/frontend/src/data/vendors/dns/powerdns.js index b1553ccb55..d84bac6a83 100644 --- a/frontend/src/data/vendors/dns/powerdns.js +++ b/frontend/src/data/vendors/dns/powerdns.js @@ -16,5 +16,36 @@ export default { hidden: true, }, ], + fields: [ + { + key: 'server', + label: 'Server', + type: 'text', + validators: { + required: { + type: 'required', + }, + url: { + type: 'url', + }, + }, + }, + { + key: 'apiKey', + label: 'API Key', + type: 'password', + validators: { + required: { + type: 'required', + }, + }, + }, + ], + help: ` ++ To use this provider you need to configure the PowerDNS API with an API key. A detailed documentation to generate an API key is available at + PowerDNS Documentation. +
+ `, }, } diff --git a/frontend/src/data/vendors/dns/rfc2136.js b/frontend/src/data/vendors/dns/rfc2136.js index ebe587d683..a41d8ac2c8 100644 --- a/frontend/src/data/vendors/dns/rfc2136.js +++ b/frontend/src/data/vendors/dns/rfc2136.js @@ -33,5 +33,113 @@ export default { }, }, ], + fields: [ + { + key: 'Server', + label: '+ This DNS provider allows you to create and manage DNS entries for authoritative DNS server supporting dynamic updates with DNS messages following + RFC2136 + (DNS Update) like knot-dns or others. +
+
+ The configuration is depending on the DNS server product. You need permissions for update and transfer (AXFR) actions on your zones and a TSIG secret.
+
+ For details see + Gardener RFC2136 DNS Provider Documentation +
+ `, }, } diff --git a/frontend/src/data/vendors/infra/alicloud.js b/frontend/src/data/vendors/infra/alicloud.js index 03940eac02..d164fe6475 100644 --- a/frontend/src/data/vendors/infra/alicloud.js +++ b/frontend/src/data/vendors/infra/alicloud.js @@ -3,6 +3,32 @@ export default { displayName: 'Alibaba Cloud', weight: 500, icon: 'alicloud.svg', + shoot: { + templates: { + provider: { + type: 'alicloud', + infrastructureConfig: { + apiVersion: 'alicloud.provider.extensions.gardener.cloud/v1alpha1', + kind: 'InfrastructureConfig', + networks: { + vpc: { + cidr: '__DEFAULT_WORKER_CIDR__', + }, + }, + }, + controlPlaneConfig: { + apiVersion: 'alicloud.provider.extensions.gardener.cloud/v1alpha1', + kind: 'ControlPlaneConfig', + }, + }, + networking: { + nodes: '__DEFAULT_WORKER_CIDR__', + }, + }, + zoneNetworking: { + strategy: 'split-workers', + }, + }, secret: { details: [ { @@ -12,5 +38,93 @@ export default { }, }, ], + fields: [ + { + key: 'accessKeyID', + label: 'Access Key ID', + hint: 'e.g. QNJebZ17v5Q7pYpP', + type: 'text', + validators: { + required: { + type: 'required', + }, + minLength: { + type: 'minLength', + length: 16, + }, + maxLength: { + type: 'maxLength', + length: 128, + }, + }, + }, + { + key: 'accessKeySecret', + label: 'Access Key Secret', + hint: 'e.g. WJalrXUtnFEMIK7MDENG/bPxRfiCYz', + type: 'password', + validators: { + required: { + type: 'required', + }, + minLength: { + type: 'minLength', + length: 30, + }, + }, + }, + ], + help: ` ++ Before you can provision and access a Kubernetes cluster on Alibaba Cloud, you need to add account credentials. To manage + credentials for Alibaba Cloud Resource Access Management (RAM), use the + RAM Console. + The Gardener needs the credentials to provision and operate the Alibaba Cloud infrastructure for your Kubernetes cluster. +
++ Gardener uses encrypted system disk when creating Shoot, please enable ECS disk encryption on Alibaba Cloud Console + (official documentation). +
++ Copy the Alibaba Cloud RAM policy document below and attach it to the RAM user + (official documentation). + Alternatively, you can assign following permissions to the RAM + user: AliyunECSFullAccess, AliyunSLBFullAccess, AliyunVPCFullAccess, AliyunEIPFullAccess, AliyunNATGatewayFullAccess. +
+ `, + helpJSONTemplate: { + Statement: [ + { + Action: 'vpc:*', + Effect: 'Allow', + Resource: '*', + }, + { + Action: 'ecs:*', + Effect: 'Allow', + Resource: '*', + }, + { + Action: 'slb:*', + Effect: 'Allow', + Resource: '*', + }, + { + Action: [ + 'ram:GetRole', + 'ram:CreateRole', + 'ram:CreateServiceLinkedRole', + ], + Effect: 'Allow', + Resource: '*', + }, + { + Action: 'ros:*', + Effect: 'Allow', + Resource: '*', + }, + ], + Version: '1', + }, }, } diff --git a/frontend/src/data/vendors/infra/aws.js b/frontend/src/data/vendors/infra/aws.js index d8ffd945ef..cdfc72d11a 100644 --- a/frontend/src/data/vendors/infra/aws.js +++ b/frontend/src/data/vendors/infra/aws.js @@ -3,6 +3,43 @@ export default { displayName: 'AWS', weight: 100, icon: 'aws.svg', + shoot: { + templates: { + provider: { + type: 'aws', + infrastructureConfig: { + apiVersion: 'aws.provider.extensions.gardener.cloud/v1alpha1', + kind: 'InfrastructureConfig', + networks: { + vpc: { + cidr: '__DEFAULT_WORKER_CIDR__', + }, + }, + }, + controlPlaneConfig: { + apiVersion: 'aws.provider.extensions.gardener.cloud/v1alpha1', + kind: 'ControlPlaneConfig', + }, + }, + networking: { + nodes: '__DEFAULT_WORKER_CIDR__', + }, + }, + zoneNetworking: { + strategy: 'split-workers-public-internal', + }, + workerVolume: { + iops: { + min: 100, + hiddenForVolumeTypes: ['gp2'], + requiredForVolumeTypes: ['io1', 'io2'], + }, + }, + workerProviderConfig: { + apiVersion: 'aws.provider.extensions.gardener.cloud/v1alpha1', + kind: 'WorkerConfig', + }, + }, secret: { details: [ { @@ -12,5 +49,113 @@ export default { }, }, ], + fields: [ + { + key: 'accessKeyID', + label: 'Access Key ID', + hint: 'e.g. AKIAIOSFODNN7EXAMPLE', + type: 'text', + validators: { + required: { + type: 'required', + }, + minLength: { + type: 'minLength', + length: 16, + }, + maxLength: { + type: 'maxLength', + length: 128, + }, + alphaNumUnderscore: { + type: 'alphaNumUnderscore', + }, + }, + }, + { + key: 'secretAccessKey', + label: 'Secret Access Key', + hint: 'e.g. wJalrXUtnFEMIK7MDENG/bPxRfiCYzEXAMPLEKEY', + type: 'password', + validators: { + required: { + type: 'required', + }, + minLength: { + type: 'minLength', + length: 40, + }, + base64: { + type: 'base64', + }, + }, + }, + ], + help: ` ++ Before you can provision and access a Kubernetes cluster, you need to add account credentials. Gardener needs the credentials to provision and operate the AWS infrastructure for your Kubernetes cluster. +
++ To manage + credentials for AWS Identity and Access Management (IAM), use the + IAM Console. +
++ Copy the AWS IAM policy document below and attach it to the IAM user + (official documentation). +
+ `, + helpJSONTemplate: { + Version: '2012-10-17', + Statement: [ + { + Effect: 'Allow', + Action: 'autoscaling:*', + Resource: '*', + }, + { + Effect: 'Allow', + Action: 'ec2:*', + Resource: '*', + }, + { + Effect: 'Allow', + Action: 'elasticloadbalancing:*', + Resource: '*', + }, + { + Action: [ + 'iam:GetInstanceProfile', + 'iam:GetPolicy', + 'iam:GetPolicyVersion', + 'iam:GetRole', + 'iam:GetRolePolicy', + 'iam:ListPolicyVersions', + 'iam:ListRolePolicies', + 'iam:ListAttachedRolePolicies', + 'iam:ListInstanceProfilesForRole', + 'iam:CreateInstanceProfile', + 'iam:CreatePolicy', + 'iam:CreatePolicyVersion', + 'iam:CreateRole', + 'iam:CreateServiceLinkedRole', + 'iam:AddRoleToInstanceProfile', + 'iam:AttachRolePolicy', + 'iam:DetachRolePolicy', + 'iam:RemoveRoleFromInstanceProfile', + 'iam:DeletePolicy', + 'iam:DeletePolicyVersion', + 'iam:DeleteRole', + 'iam:DeleteRolePolicy', + 'iam:DeleteInstanceProfile', + 'iam:PutRolePolicy', + 'iam:PassRole', + 'iam:UpdateAssumeRolePolicy', + ], + Effect: 'Allow', + Resource: '*', + }, + ], + }, }, } diff --git a/frontend/src/data/vendors/infra/azure.js b/frontend/src/data/vendors/infra/azure.js index 5b6fbe1862..8259d46abb 100644 --- a/frontend/src/data/vendors/infra/azure.js +++ b/frontend/src/data/vendors/infra/azure.js @@ -3,6 +3,34 @@ export default { displayName: 'Azure', weight: 200, icon: 'azure.svg', + shoot: { + templates: { + provider: { + type: 'azure', + infrastructureConfig: { + apiVersion: 'azure.provider.extensions.gardener.cloud/v1alpha1', + kind: 'InfrastructureConfig', + networks: { + vnet: { + cidr: '__DEFAULT_WORKER_CIDR__', + }, + workers: '__DEFAULT_WORKER_CIDR__', + }, + zoned: true, + }, + controlPlaneConfig: { + apiVersion: 'azure.provider.extensions.gardener.cloud/v1alpha1', + kind: 'ControlPlaneConfig', + }, + }, + networking: { + nodes: '__DEFAULT_WORKER_CIDR__', + }, + }, + zones: { + mode: 'infrastructure-config-zoned', + }, + }, secret: { details: [ { @@ -15,5 +43,74 @@ export default { }, }, ], + fields: [ + { + key: 'clientID', + label: 'Client Id', + type: 'text', + validators: { + required: { + type: 'required', + }, + guid: { + type: 'guid', + }, + }, + }, + { + key: 'clientSecret', + label: 'Client Secret', + type: 'password', + validators: { + required: { + type: 'required', + }, + }, + }, + { + key: 'tenantID', + label: 'Tenant Id', + type: 'text', + validators: { + required: { + type: 'required', + }, + guid: { + type: 'guid', + }, + }, + }, + { + key: 'subscriptionID', + label: 'Subscription Id', + type: 'text', + validators: { + required: { + type: 'required', + }, + guid: { + type: 'guid', + }, + }, + }, + ], + help: ` ++ Before you can provision and access a Kubernetes cluster on Azure, you need to add account/subscription credentials. + The Gardener needs the credentials of a service principal assigned to an account/subscription to provision + and operate the Azure infrastructure for your Kubernetes cluster. +
++ Ensure that the service principal has the permissions defined + here + within your subscription assigned. + If no fine-grained permissions are required then assign the Contributor role. +
++ Read the + IAM Console help section + on how to manage your credentials and subscriptions. +
+ `, }, } diff --git a/frontend/src/data/vendors/infra/gcp.js b/frontend/src/data/vendors/infra/gcp.js index 93faff9053..6cb5509317 100644 --- a/frontend/src/data/vendors/infra/gcp.js +++ b/frontend/src/data/vendors/infra/gcp.js @@ -3,6 +3,30 @@ export default { displayName: 'Google Cloud', weight: 300, icon: 'gcp.svg', + shoot: { + templates: { + provider: { + type: 'gcp', + infrastructureConfig: { + apiVersion: 'gcp.provider.extensions.gardener.cloud/v1alpha1', + kind: 'InfrastructureConfig', + networks: { + workers: '__DEFAULT_WORKER_CIDR__', + }, + }, + controlPlaneConfig: { + apiVersion: 'gcp.provider.extensions.gardener.cloud/v1alpha1', + kind: 'ControlPlaneConfig', + }, + }, + networking: { + nodes: '__DEFAULT_WORKER_CIDR__', + }, + }, + controlPlane: { + zoneStrategy: 'worker-zones', + }, + }, secret: { details: [ { @@ -14,5 +38,60 @@ export default { }, }, ], + fields: [ + { + key: 'serviceaccount.json', + label: 'Service Account Key', + hint: 'Enter or drop a service account key in JSON format', + type: 'json-secret', + validators: { + required: { + type: 'required', + }, + isJSON: { + type: 'isValidObject', + }, + projectID: { + type: 'hasObjectProp', + key: 'project_id', + pattern: /^[a-z][a-z0-9-]{4,28}[a-z0-9]$/, + }, + type: { + type: 'hasObjectProp', + key: 'type', + value: 'service_account', + }, + }, + }, + ], + help: ` ++ A service account is a special account that can be used by services and applications running on your Google + Compute Engine instance to interact with other Google Cloud Platform APIs. Applications can use service + account credentials to authorize themselves to a set of APIs and perform actions within the permissions + granted to the service account and virtual machine instance. +
+ ++ Ensure that the service account has at least the roles below. +
+ ++ The Service Account has to be enabled for the Google Identity and Access Management API. +
+ ++ Read the + Service Account Documentation + on how to apply for credentials to service accounts. +
+ `, }, } diff --git a/frontend/src/data/vendors/infra/hcloud.js b/frontend/src/data/vendors/infra/hcloud.js index 6a8b1546fe..5cf9f9c920 100644 --- a/frontend/src/data/vendors/infra/hcloud.js +++ b/frontend/src/data/vendors/infra/hcloud.js @@ -3,6 +3,30 @@ export default { displayName: 'Hetzner Cloud', weight: 800, icon: 'hcloud.svg', + shoot: { + templates: { + provider: { + type: 'hcloud', + infrastructureConfig: { + apiVersion: 'hcloud.provider.extensions.gardener.cloud/v1alpha1', + kind: 'InfrastructureConfig', + networks: { + workers: '__DEFAULT_WORKER_CIDR__', + }, + }, + controlPlaneConfig: { + apiVersion: 'hcloud.provider.extensions.gardener.cloud/v1alpha1', + kind: 'ControlPlaneConfig', + }, + }, + networking: { + nodes: '__DEFAULT_WORKER_CIDR__', + }, + }, + controlPlane: { + zoneStrategy: 'worker-zones', + }, + }, secret: { details: [ { @@ -10,5 +34,27 @@ export default { hidden: true, }, ], + fields: [ + { + key: 'hcloudToken', + label: 'Hetzner Cloud Token', + type: 'password', + validators: { + required: { + type: 'required', + }, + }, + }, + ], + help: ` ++ Before you can provision and access a Kubernetes cluster on Hetzner Cloud, you need to add a Hetzner Cloud token. + The Gardener needs these credentials to provision and operate Hetzner Cloud infrastructure for your Kubernetes cluster. +
++ Please read the + Hetzner Cloud Documentation. +
+ `, }, } diff --git a/frontend/src/data/vendors/infra/index.js b/frontend/src/data/vendors/infra/index.js index 39a57dc510..06ddb385be 100644 --- a/frontend/src/data/vendors/infra/index.js +++ b/frontend/src/data/vendors/infra/index.js @@ -1,26 +1,26 @@ import aws from './aws' +import alicloud from './alicloud' import azure from './azure' +import hcloud from './hcloud' import gcp from './gcp' -import openstack from './openstack' -import alicloud from './alicloud' import metal from './metal' -import vsphere from './vsphere' -import hcloud from './hcloud' import onmetal from './onmetal' +import openstack from './openstack' +import vsphere from './vsphere' import ironcore from './ironcore' import stackit from './stackit' import local from './local' export default [ aws, + alicloud, azure, + hcloud, gcp, - openstack, - alicloud, metal, - vsphere, - hcloud, onmetal, + openstack, + vsphere, ironcore, stackit, local, diff --git a/frontend/src/data/vendors/infra/local.js b/frontend/src/data/vendors/infra/local.js index fdbe50477b..de417ab69b 100644 --- a/frontend/src/data/vendors/infra/local.js +++ b/frontend/src/data/vendors/infra/local.js @@ -2,4 +2,9 @@ export default { name: 'local', displayName: 'Local', weight: 10100, + shoot: { + zones: { + mode: 'never', + }, + }, } diff --git a/frontend/src/data/vendors/infra/metal.js b/frontend/src/data/vendors/infra/metal.js index 63d0161c14..fbfaf1b498 100644 --- a/frontend/src/data/vendors/infra/metal.js +++ b/frontend/src/data/vendors/infra/metal.js @@ -3,6 +3,50 @@ export default { displayName: 'metal-stack', weight: 600, icon: 'metal.svg', + shoot: { + templates: { + provider: { + type: 'metal', + infrastructureConfig: { + apiVersion: 'metal.provider.extensions.gardener.cloud/v1alpha1', + kind: 'InfrastructureConfig', + }, + controlPlaneConfig: { + apiVersion: 'metal.provider.extensions.gardener.cloud/v1alpha1', + kind: 'ControlPlaneConfig', + }, + }, + networking: { + type: 'calico', + pods: '10.244.128.0/18', + services: '10.244.192.0/18', + providerConfig: { + apiVersion: 'calico.networking.extensions.gardener.cloud/v1alpha1', + kind: 'NetworkConfig', + backend: 'vxlan', + ipv4: { + autoDetectionMethod: 'interface=lo', + mode: 'Always', + pool: 'vxlan', + }, + typha: { + enabled: true, + }, + }, + }, + kubernetes: { + kubeControllerManager: { + nodeCIDRMaskSize: 23, + }, + kubelet: { + maxPods: 510, + }, + }, + }, + zones: { + mode: 'never', + }, + }, secret: { details: [ { @@ -12,5 +56,36 @@ export default { }, }, ], + fields: [ + { + key: 'metalAPIURL', + label: 'API URL', + type: 'text', + validators: { + required: { + type: 'required', + }, + url: { + type: 'url', + }, + }, + }, + { + key: 'metalAPIHMac', + label: 'API HMAC', + type: 'password', + validators: { + required: { + type: 'required', + }, + }, + }, + ], + help: ` ++ Before you can provision and access a Kubernetes cluster on Metal Stack, you need to provide HMAC credentials and the endpoint of your Metal API. + The Gardener needs the credentials to provision and operate the Metal Stack infrastructure for your Kubernetes cluster. +
+ `, }, } diff --git a/frontend/src/data/vendors/infra/onmetal.js b/frontend/src/data/vendors/infra/onmetal.js index cec462b584..71650e8f20 100644 --- a/frontend/src/data/vendors/infra/onmetal.js +++ b/frontend/src/data/vendors/infra/onmetal.js @@ -1,6 +1,6 @@ export default { - name: 'onMetal', + name: 'onmetal', displayName: 'OnMetal', - weight: 900, + weight: 901, icon: 'onmetal.svg', } diff --git a/frontend/src/data/vendors/infra/openstack.js b/frontend/src/data/vendors/infra/openstack.js index fd3d2d27fd..1c0a226d3e 100644 --- a/frontend/src/data/vendors/infra/openstack.js +++ b/frontend/src/data/vendors/infra/openstack.js @@ -18,5 +18,110 @@ export default { }, }, ], + fields: [ + { + key: 'authURL', + label: 'Auth URL', + type: 'text', + validators: { + required: { + type: 'required', + }, + }, + }, + { + key: 'domainName', + label: 'Domain Name', + type: 'text', + validators: { + required: { + type: 'required', + }, + }, + }, + { + key: 'tenantName', + label: 'Project / Tenant Name', + type: 'text', + validators: { + required: { + type: 'required', + }, + }, + }, + { + key: 'applicationCredentialID', + label: 'ID', + type: 'text', + validators: { + required: { + type: 'required', + }, + }, + }, + { + key: 'applicationCredentialName', + label: 'Name', + type: 'text', + validators: { + required: { + type: 'required', + }, + }, + }, + { + key: 'applicationCredentialSecret', + label: 'Secret', + type: 'password', + validators: { + required: { + type: 'required', + }, + }, + }, + { + key: 'username', + label: 'Technical User', + type: 'text', + hint: 'Do not use personalized login credentials. Instead, use credentials of a technical user', + validators: { + required: { + type: 'required', + }, + }, + }, + { + key: 'password', + label: 'Password', + type: 'password', + hint: 'Do not use personalized login credentials. Instead, use credentials of a technical user', + validators: { + required: { + type: 'required', + }, + }, + }, + ], + }, + shoot: { + templates: { + provider: { + type: 'openstack', + infrastructureConfig: { + apiVersion: 'openstack.provider.extensions.gardener.cloud/v1alpha1', + kind: 'InfrastructureConfig', + networks: { + workers: '__DEFAULT_WORKER_CIDR__', + }, + }, + controlPlaneConfig: { + apiVersion: 'openstack.provider.extensions.gardener.cloud/v1alpha1', + kind: 'ControlPlaneConfig', + }, + }, + networking: { + nodes: '__DEFAULT_WORKER_CIDR__', + }, + }, }, } diff --git a/frontend/src/data/vendors/infra/stackit.js b/frontend/src/data/vendors/infra/stackit.js index 4de0cf6be3..ed72a4a0e9 100644 --- a/frontend/src/data/vendors/infra/stackit.js +++ b/frontend/src/data/vendors/infra/stackit.js @@ -3,4 +3,25 @@ export default { displayName: 'stackit', weight: 1100, icon: 'stackit.svg', + shoot: { + templates: { + provider: { + type: 'stackit', + infrastructureConfig: { + apiVersion: 'stackit.provider.extensions.gardener.cloud/v1alpha1', + kind: 'InfrastructureConfig', + networks: { + workers: '__DEFAULT_WORKER_CIDR__', + }, + }, + controlPlaneConfig: { + apiVersion: 'stackit.provider.extensions.gardener.cloud/v1alpha1', + kind: 'ControlPlaneConfig', + }, + }, + networking: { + nodes: '__DEFAULT_WORKER_CIDR__', + }, + }, + }, } diff --git a/frontend/src/data/vendors/infra/vsphere.js b/frontend/src/data/vendors/infra/vsphere.js index f61dda7894..a2211fff5a 100644 --- a/frontend/src/data/vendors/infra/vsphere.js +++ b/frontend/src/data/vendors/infra/vsphere.js @@ -3,6 +3,20 @@ export default { displayName: 'vSphere', weight: 700, icon: 'vsphere.svg', + shoot: { + templates: { + provider: { + type: 'vsphere', + controlPlaneConfig: { + apiVersion: 'vsphere.provider.extensions.gardener.cloud/v1alpha1', + kind: 'ControlPlaneConfig', + }, + }, + networking: { + nodes: '__DEFAULT_WORKER_CIDR__', + }, + }, + }, secret: { details: [ { @@ -24,5 +38,66 @@ export default { }, }, ], + fields: [ + { + key: 'vSphereUsername', + label: 'vSphere Username', + type: 'text', + validators: { + required: { + type: 'required', + }, + }, + }, + { + key: 'vSpherePassword', + label: 'vSphere Password', + type: 'password', + validators: { + required: { + type: 'required', + }, + }, + }, + { + key: 'NSXTUsername', + label: 'NSX-T Username', + type: 'text', + validators: { + required: { + type: 'required', + }, + }, + }, + { + key: 'NSXTPassword', + label: 'NSX-T Password', + type: 'password', + validators: { + required: { + type: 'required', + }, + }, + }, + ], + help: ` ++ Before you can provision and access a Kubernetes cluster on VMware vSphere, you need to add vSphere and NSX-T account credentials. + The Gardener needs these credentials to provision and operate the VMware vSphere infrastructure for your Kubernetes cluster. +
++ Ensure that these accounts have privileges to create, modify and delete VMs and Networking resources. +
++ Please read the + + VMware vSphere Documentation + + and the + + VMware NSX-T Data Center Documentation + . +
+ `, }, } diff --git a/frontend/src/store/config.js b/frontend/src/store/config.js index e362d798f7..454f361467 100644 --- a/frontend/src/store/config.js +++ b/frontend/src/store/config.js @@ -43,7 +43,7 @@ const wellKnownConditions = { name: 'Control Plane', shortName: 'CP', description: 'Indicates whether all control plane components are up and running.', - showLandscapeViewerOnly: true, + showAdminOnly: true, sortOrder: '1', }, SystemComponentsHealthy: { diff --git a/frontend/src/utils/index.js b/frontend/src/utils/index.js index a006573607..c92dc71b61 100644 --- a/frontend/src/utils/index.js +++ b/frontend/src/utils/index.js @@ -52,27 +52,89 @@ export function emailToDisplayName (value) { } } -export function handleTextFieldDrop (textField, fileTypePattern, onDrop = () => {}) { +function fileExtension (file) { + const name = file?.name ?? '' + const extensionIndex = name.lastIndexOf('.') + if (extensionIndex === -1) { + return '' + } + return name.slice(extensionIndex).toLowerCase() +} + +function normalizedFileExtensions (extensions = []) { + return extensions.map(extension => { + const normalizedExtension = extension.toLowerCase() + return normalizedExtension.startsWith('.') ? normalizedExtension : `.${normalizedExtension}` + }) +} + +function fileTypeDescription (file) { + if (file.type) { + return file.type + } + + const extension = fileExtension(file) + return extension || 'unknown file type' +} + +export function handleTextFieldDrop (textField, fileTypePattern, onDrop = () => {}, { + acceptedFileDescription = 'a supported file', + acceptedFileExtensions = [], + onReject = () => {}, +} = {}) { + const extensions = normalizedFileExtensions(acceptedFileExtensions) + + function rejectDrop (code, message, file) { + onReject({ + code, + file, + message, + }) + } + + function isAcceptedFile (file) { + fileTypePattern.lastIndex = 0 + if (file.type && fileTypePattern.test(file.type)) { + return true + } + + const extension = fileExtension(file) + return !!extension && extensions.includes(extension) + } + function drop (event) { event.stopPropagation() event.preventDefault() - const files = event.dataTransfer.files - if (files.length) { - const file = files[0] - if (fileTypePattern.test(file.type)) { - const reader = new FileReader() - const onLoaded = event => { - try { - const result = JSON.parse(event.target.result) - - onDrop(JSON.stringify(result, null, ' ')) - } catch (err) { /* ignore error */ } - } - reader.onloadend = onLoaded - reader.readAsText(file) - } + const files = event.dataTransfer?.files ?? [] + if (!files.length) { + rejectDrop('missing-file', `Drop ${acceptedFileDescription} to import its contents.`) + return + } + + if (files.length > 1) { + rejectDrop('too-many-files', `Drop only one file at a time. Expected ${acceptedFileDescription}.`) + return } + + const file = files[0] + if (!isAcceptedFile(file)) { + rejectDrop( + 'unsupported-file-type', + `File "${file.name}" was rejected. Expected ${acceptedFileDescription}, but received ${fileTypeDescription(file)}.`, + file, + ) + return + } + + const reader = new FileReader() + reader.onload = event => { + onDrop(event.target.result) + } + reader.onerror = () => { + rejectDrop('read-error', `Could not read file "${file.name}".`, file) + } + reader.readAsText(file) } function dragOver (event) { @@ -81,9 +143,14 @@ export function handleTextFieldDrop (textField, fileTypePattern, onDrop = () => event.dataTransfer.dropEffect = 'copy' } - const textarea = textField.$refs['input-slot'] - textarea.addEventListener('dragover', dragOver, false) - textarea.addEventListener('drop', drop, false) + const field = textField.$el + field.addEventListener('dragover', dragOver, false) + field.addEventListener('drop', drop, false) + + return () => { + field.removeEventListener('dragover', dragOver, false) + field.removeEventListener('drop', drop, false) + } } export function getErrorMessages (property) { @@ -507,7 +574,7 @@ export function transformHtml (html, transformToExternalLinks = true) { const linkElements = documentFragment.querySelectorAll('a') linkElements.forEach(linkElement => { if (transformToExternalLinks) { - linkElement.classList.add('text-anchor', 'text-decoration-none') + linkElement.classList.add('text-anchor', 'text-decoration-none', 'text-no-wrap') linkElement.setAttribute('target', '_blank') linkElement.setAttribute('rel', 'noopener') const linkText = linkElement.innerHTML diff --git a/frontend/src/utils/inputFieldTypes.js b/frontend/src/utils/inputFieldTypes.js new file mode 100644 index 0000000000..fb8ce8b38e --- /dev/null +++ b/frontend/src/utils/inputFieldTypes.js @@ -0,0 +1,50 @@ +// +// SPDX-FileCopyrightText: 2026 SAP SE or an SAP affiliate company and Gardener contributors +// +// SPDX-License-Identifier: Apache-2.0 +// + +export const jsonFieldTypes = new Set([ + 'json', + 'json-secret', +]) + +export const yamlFieldTypes = new Set([ + 'yaml', + 'yaml-secret', +]) + +export const structuredFieldTypes = new Set([ + ...jsonFieldTypes, + ...yamlFieldTypes, +]) + +export const structuredSecretFieldTypes = new Set([ + 'json-secret', + 'yaml-secret', +]) + +export const secretFieldTypes = new Set([ + 'password', + ...structuredSecretFieldTypes, +]) + +export function isJsonFieldType (type) { + return jsonFieldTypes.has(type) +} + +export function isYamlFieldType (type) { + return yamlFieldTypes.has(type) +} + +export function isStructuredFieldType (type) { + return structuredFieldTypes.has(type) +} + +export function isStructuredSecretFieldType (type) { + return structuredSecretFieldTypes.has(type) +} + +export function isSecretFieldType (type) { + return secretFieldTypes.has(type) +} diff --git a/frontend/src/utils/shoot.js b/frontend/src/utils/shoot.js index 76a9c3d41e..e92ecaaf34 100644 --- a/frontend/src/utils/shoot.js +++ b/frontend/src/utils/shoot.js @@ -17,201 +17,63 @@ import filter from 'lodash/filter' import range from 'lodash/range' import isEmpty from 'lodash/isEmpty' import compact from 'lodash/compact' +import cloneDeep from 'lodash/cloneDeep' -export function getSpecTemplate (providerType, defaultWorkerCIDR) { +const DEFAULT_WORKER_CIDR_PLACEHOLDER = '__DEFAULT_WORKER_CIDR__' + +function replacePlaceholderInTemplate (value, defaultWorkerCIDR) { + if (typeof value === 'string') { + return value === DEFAULT_WORKER_CIDR_PLACEHOLDER ? defaultWorkerCIDR : value + } + if (Array.isArray(value)) { + return map(value, item => replacePlaceholderInTemplate(item, defaultWorkerCIDR)) + } + if (value && typeof value === 'object') { + return Object.fromEntries( + Object.entries(value).map(([key, nestedValue]) => { + return [key, replacePlaceholderInTemplate(nestedValue, defaultWorkerCIDR)] + }), + ) + } + return value +} + +export function getSpecTemplate (providerType, defaultWorkerCIDR, shootVendor) { const spec = { - provider: getProviderTemplate(providerType, defaultWorkerCIDR), - networking: getNetworkingTemplate(providerType, defaultWorkerCIDR), + provider: getProviderTemplate(providerType, defaultWorkerCIDR, shootVendor), + networking: getNetworkingTemplate(providerType, defaultWorkerCIDR, shootVendor), } - const kubernetes = getKubernetesTemplate(providerType) + const kubernetes = getKubernetesTemplate(shootVendor) if (!isEmpty(kubernetes)) { spec.kubernetes = kubernetes } return spec } -export function getProviderTemplate (providerType, defaultWorkerCIDR) { - switch (providerType) { - case 'aws': - return { - type: 'aws', - infrastructureConfig: { - apiVersion: 'aws.provider.extensions.gardener.cloud/v1alpha1', - kind: 'InfrastructureConfig', - networks: { - vpc: { - cidr: defaultWorkerCIDR, - }, - }, - }, - controlPlaneConfig: { - apiVersion: 'aws.provider.extensions.gardener.cloud/v1alpha1', - kind: 'ControlPlaneConfig', - }, - } - case 'azure': - return { - type: 'azure', - infrastructureConfig: { - apiVersion: 'azure.provider.extensions.gardener.cloud/v1alpha1', - kind: 'InfrastructureConfig', - networks: { - vnet: { - cidr: defaultWorkerCIDR, - }, - workers: defaultWorkerCIDR, - }, - zoned: true, - }, - controlPlaneConfig: { - apiVersion: 'azure.provider.extensions.gardener.cloud/v1alpha1', - kind: 'ControlPlaneConfig', - }, - } - case 'gcp': - return { - type: 'gcp', - infrastructureConfig: { - apiVersion: 'gcp.provider.extensions.gardener.cloud/v1alpha1', - kind: 'InfrastructureConfig', - networks: { - workers: defaultWorkerCIDR, - }, - }, - controlPlaneConfig: { - apiVersion: 'gcp.provider.extensions.gardener.cloud/v1alpha1', - kind: 'ControlPlaneConfig', - }, - } - case 'openstack': - return { - type: 'openstack', - infrastructureConfig: { - apiVersion: 'openstack.provider.extensions.gardener.cloud/v1alpha1', - kind: 'InfrastructureConfig', - networks: { - workers: defaultWorkerCIDR, - }, - }, - controlPlaneConfig: { - apiVersion: 'openstack.provider.extensions.gardener.cloud/v1alpha1', - kind: 'ControlPlaneConfig', - }, - } - case 'stackit': - return { - type: 'stackit', - infrastructureConfig: { - apiVersion: 'stackit.provider.extensions.gardener.cloud/v1alpha1', - kind: 'InfrastructureConfig', - networks: { - workers: defaultWorkerCIDR, - }, - }, - controlPlaneConfig: { - apiVersion: 'stackit.provider.extensions.gardener.cloud/v1alpha1', - kind: 'ControlPlaneConfig', - }, - } - case 'alicloud': - return { - type: 'alicloud', - infrastructureConfig: { - apiVersion: 'alicloud.provider.extensions.gardener.cloud/v1alpha1', - kind: 'InfrastructureConfig', - networks: { - vpc: { - cidr: defaultWorkerCIDR, - }, - }, - }, - controlPlaneConfig: { - apiVersion: 'alicloud.provider.extensions.gardener.cloud/v1alpha1', - kind: 'ControlPlaneConfig', - }, - } - case 'metal': - return { - type: 'metal', - infrastructureConfig: { - apiVersion: 'metal.provider.extensions.gardener.cloud/v1alpha1', - kind: 'InfrastructureConfig', - }, - controlPlaneConfig: { - apiVersion: 'metal.provider.extensions.gardener.cloud/v1alpha1', - kind: 'ControlPlaneConfig', - }, - } - case 'vsphere': - return { - type: 'vsphere', - controlPlaneConfig: { - apiVersion: 'vsphere.provider.extensions.gardener.cloud/v1alpha1', - kind: 'ControlPlaneConfig', - }, - } - case 'hcloud': - return { - type: 'hcloud', - infrastructureConfig: { - apiVersion: 'hcloud.provider.extensions.gardener.cloud/v1alpha1', - kind: 'InfrastructureConfig', - networks: { - workers: defaultWorkerCIDR, - }, - }, - controlPlaneConfig: { - apiVersion: 'hcloud.provider.extensions.gardener.cloud/v1alpha1', - kind: 'ControlPlaneConfig', - }, - } - default: - return { - type: providerType, - } +export function getProviderTemplate (providerType, defaultWorkerCIDR, shootVendor) { + const template = shootVendor?.shoot?.templates?.provider + if (!template) { + return { + type: providerType, + } } + + return replacePlaceholderInTemplate(template, defaultWorkerCIDR) } -export function getNetworkingTemplate (providerType, defaultWorkerCIDR) { - switch (providerType) { - case 'metal': - return { - type: 'calico', - pods: '10.244.128.0/18', - services: '10.244.192.0/18', - providerConfig: { - apiVersion: 'calico.networking.extensions.gardener.cloud/v1alpha1', - kind: 'NetworkConfig', - backend: 'vxlan', - ipv4: { - autoDetectionMethod: 'interface=lo', - mode: 'Always', - pool: 'vxlan', - }, - typha: { - enabled: true, - }, - }, - } - default: - return { - nodes: defaultWorkerCIDR, - } +export function getNetworkingTemplate (providerType, defaultWorkerCIDR, shootVendor) { + const template = shootVendor?.shoot?.templates?.networking + if (!template) { + return { + nodes: defaultWorkerCIDR, + } } + + return replacePlaceholderInTemplate(template, defaultWorkerCIDR) } -export function getKubernetesTemplate (providerType) { - switch (providerType) { - case 'metal': - return { - kubeControllerManager: { - nodeCIDRMaskSize: 23, - }, - kubelet: { - maxPods: 510, - }, - } - } +export function getKubernetesTemplate (shootVendor) { + return cloneDeep(shootVendor?.shoot?.templates?.kubernetes) } export function splitCIDR (cidrToSplitStr, numberOfNetworks) { @@ -232,12 +94,12 @@ export function splitCIDR (cidrToSplitStr, numberOfNetworks) { return cidrArray } -export function getDefaultNetworkConfigurationForAllZones (numberOfZones, providerType, workerCIDR) { - switch (providerType) { - case 'aws': { - const zoneNetworksAws = splitCIDR(workerCIDR, numberOfZones) +export function getDefaultNetworkConfigurationForAllZones (numberOfZones, workerCIDR, shootVendor) { + switch (shootVendor?.shoot?.zoneNetworking?.strategy) { + case 'split-workers-public-internal': { + const zoneNetworks = splitCIDR(workerCIDR, numberOfZones) return map(range(numberOfZones), index => { - const zoneNetwork = zoneNetworksAws[index] // eslint-disable-line security/detect-object-injection + const zoneNetwork = zoneNetworks[index] // eslint-disable-line security/detect-object-injection const bigNetWorks = splitCIDR(zoneNetwork, 2) const workerNetwork = bigNetWorks[0] const smallNetworks = splitCIDR(bigNetWorks[1], 2) @@ -250,10 +112,10 @@ export function getDefaultNetworkConfigurationForAllZones (numberOfZones, provid } }) } - case 'alicloud': { - const zoneNetworksAli = splitCIDR(workerCIDR, numberOfZones) + case 'split-workers': { + const zoneNetworks = splitCIDR(workerCIDR, numberOfZones) return map(range(numberOfZones), index => { - const zoneNetwork = zoneNetworksAli[index] // eslint-disable-line security/detect-object-injection + const zoneNetwork = zoneNetworks[index] // eslint-disable-line security/detect-object-injection return { workers: zoneNetwork, } @@ -262,8 +124,8 @@ export function getDefaultNetworkConfigurationForAllZones (numberOfZones, provid } } -export function getDefaultZonesNetworkConfiguration (zones, providerType, maxNumberOfZones, workerCIDR) { - const zoneConfigurations = getDefaultNetworkConfigurationForAllZones(maxNumberOfZones, providerType, workerCIDR) +export function getDefaultZonesNetworkConfiguration (zones, maxNumberOfZones, workerCIDR, shootVendor) { + const zoneConfigurations = getDefaultNetworkConfigurationForAllZones(maxNumberOfZones, workerCIDR, shootVendor) if (!zoneConfigurations) { return undefined } @@ -276,12 +138,12 @@ export function getDefaultZonesNetworkConfiguration (zones, providerType, maxNum }) } -export function findFreeNetworks (existingZonesNetworkConfiguration, workerCIDR, providerType, maxNumberOfZones) { +export function findFreeNetworks (existingZonesNetworkConfiguration, workerCIDR, maxNumberOfZones, shootVendor) { if (!existingZonesNetworkConfiguration) { - return getDefaultNetworkConfigurationForAllZones(maxNumberOfZones, providerType, workerCIDR) + return getDefaultNetworkConfigurationForAllZones(maxNumberOfZones, workerCIDR, shootVendor) } for (let numberOfZones = maxNumberOfZones; numberOfZones >= existingZonesNetworkConfiguration.length; numberOfZones--) { - const newZonesNetworkConfiguration = getDefaultNetworkConfigurationForAllZones(numberOfZones, providerType, workerCIDR) + const newZonesNetworkConfiguration = getDefaultNetworkConfigurationForAllZones(numberOfZones, workerCIDR, shootVendor) const freeZoneNetworks = filter(newZonesNetworkConfiguration, networkConfiguration => { return !some(existingZonesNetworkConfiguration, networkConfiguration) }) @@ -293,15 +155,15 @@ export function findFreeNetworks (existingZonesNetworkConfiguration, workerCIDR, return [] } -export function getZonesNetworkConfiguration (oldZonesNetworkConfiguration, workers, providerType, maxNumberOfZones, existingShootWorkerCIDR, newShootWorkerCIDR) { - if (isEmpty(workers) || !providerType || !maxNumberOfZones) { +export function getZonesNetworkConfiguration (oldZonesNetworkConfiguration, workers, maxNumberOfZones, existingShootWorkerCIDR, newShootWorkerCIDR, shootVendor) { + if (isEmpty(workers) || !maxNumberOfZones) { return } const usedZones = uniq(flatMap(workers, 'zones')) const workerCIDR = existingShootWorkerCIDR || newShootWorkerCIDR - const defaultZonesNetworkConfiguration = getDefaultZonesNetworkConfiguration(usedZones, providerType, maxNumberOfZones, workerCIDR) + const defaultZonesNetworkConfiguration = getDefaultZonesNetworkConfiguration(usedZones, maxNumberOfZones, workerCIDR, shootVendor) if (!defaultZonesNetworkConfiguration) { return } @@ -309,7 +171,7 @@ export function getZonesNetworkConfiguration (oldZonesNetworkConfiguration, work const existingZonesNetworkConfiguration = filter(oldZonesNetworkConfiguration, ({ name }) => includes(usedZones, name)) if (existingShootWorkerCIDR) { - const freeZoneNetworks = findFreeNetworks(existingZonesNetworkConfiguration, existingShootWorkerCIDR, providerType, maxNumberOfZones) + const freeZoneNetworks = findFreeNetworks(existingZonesNetworkConfiguration, existingShootWorkerCIDR, maxNumberOfZones, shootVendor) const availableNetworksLength = existingZonesNetworkConfiguration.length + freeZoneNetworks.length if (availableNetworksLength < usedZones.length) { return @@ -341,11 +203,10 @@ export function getZonesNetworkConfiguration (oldZonesNetworkConfiguration, work : existingZonesNetworkConfiguration } -export function getControlPlaneZone (workers, providerType, oldControlPlaneZone) { +export function getControlPlaneZone (workers, shootVendor, oldControlPlaneZone) { const workerZones = uniq(flatMap(workers, 'zones')) - switch (providerType) { - case 'gcp': - case 'hcloud': + switch (shootVendor?.shoot?.controlPlane?.zoneStrategy) { + case 'worker-zones': if (includes(workerZones, oldControlPlaneZone)) { return oldControlPlaneZone } @@ -355,13 +216,6 @@ export function getControlPlaneZone (workers, providerType, oldControlPlaneZone) } } -export function getWorkerProviderConfig (providerType) { - switch (providerType) { - case 'aws': { - return { - apiVersion: 'aws.provider.extensions.gardener.cloud/v1alpha1', - kind: 'WorkerConfig', - } - } - } +export function getWorkerProviderConfig (shootVendor) { + return cloneDeep(shootVendor?.shoot?.workerProviderConfig) } diff --git a/frontend/src/views/GCredentials.vue b/frontend/src/views/GCredentials.vue index 69d8ac15c9..b5fc0fa53d 100644 --- a/frontend/src/views/GCredentials.vue +++ b/frontend/src/views/GCredentials.vue @@ -378,8 +378,8 @@ export default { }, data () { return { - selectedInfraBinding: {}, - selectedDnsCredential: {}, + selectedInfraBinding: undefined, + selectedDnsCredential: undefined, infraCredentialFilter: '', createInfraCredentialMenu: false, dnsCredentialFilter: '',