// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // // Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file except in compliance // with the License. A copy of the License is located at // // http://aws.amazon.com/apache2.0/ // // or in the "LICENSE.txt" file accompanying this file. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES // OR CONDITIONS OF ANY KIND, express or implied. See the License for the specific language governing permissions and // limitations under the License. // Fameworks import React, {useCallback, useState as reactUseState} from 'react' import i18next from 'i18next' import {Trans, useTranslation} from 'react-i18next' import {clamp} from '../../util' // UI Elements import { Button, ColumnLayout, Container, FormField, Header, Input, InputProps, Select, SpaceBetween, Checkbox, Alert, Link, } from '@cloudscape-design/components' // State import {getState, setState, useState, clearState} from '../../store' // Components import { DeletionPolicy, EbsDeletionPolicy, EfsDeletionPolicy, FsxLustreDeletionPolicy, Storages, Storage, StorageType, STORAGE_TYPE_PROPS, UIStorageSettings, EbsStorage, EfsStorage, } from './Storage.types' import {useFeatureFlag} from '../../feature-flags/useFeatureFlag' import InfoLink from '../../components/InfoLink' import TitleDescriptionHelpPanel from '../../components/help-panel/TitleDescriptionHelpPanel' import {useMemo} from 'react' import {useHelpPanel} from '../../components/help-panel/HelpPanel' import { ebsErrorsMapping, efsErrorsMapping, externalFsErrorsMapping, storageNameErrorsMapping, STORAGE_NAME_MAX_LENGTH, validateEbs, validateEfs, validateExternalFileSystem, validateStorageName, } from './Storage/storage.validators' import {NonCancelableEventHandler} from '@cloudscape-design/components/internal/events' import {BaseChangeDetail} from '@cloudscape-design/components/input/interfaces' import {AddStorageForm} from './Storage/AddStorageForm' import {buildStorageEntries} from './Storage/buildStorageEntries' import {CheckboxWithHelpPanel} from './Components' import {DeletionPolicyFormField} from './Storage/DeletionPolicyFormField' // Constants const storagePath = ['app', 'wizard', 'config', 'SharedStorage'] const errorsPath = ['app', 'wizard', 'errors', 'sharedStorage'] const uiSettingsPath = ['app', 'wizard', 'storage', 'ui'] // Types export type StorageTypeOption = [StorageType, string] // Helper Functions export function itemToOption([value, label]: StorageTypeOption) { return {value: value, label: label} } function strToOption(str: any) { return {value: str, label: str.toString()} } function storageValidate() { const storages: Storages = getState(storagePath) let valid = true if (storages) { storages.forEach((storage: Storage, index: number) => { const settings = `${storage.StorageType}Settings` const idType = STORAGE_TYPE_PROPS[storage.StorageType].mountFilesystem ? 'FileSystemId' : 'VolumeId' const useExisting = getState(['app', 'wizard', 'storage', 'ui', index, 'useExisting']) || !(STORAGE_TYPE_PROPS[storage.StorageType].maxToCreate > 0) if (useExisting) { const [externalFsValid, error] = validateExternalFileSystem(storage) if (!externalFsValid) { const errorMessage = i18next.t(externalFsErrorsMapping[error!]) setState([...errorsPath, index, settings, idType], errorMessage) valid = false } else { clearState([...errorsPath, index, settings, idType]) } } else { if (storage.StorageType === 'Ebs') { const [ebsValid, error] = validateEbs(storage as EbsStorage) if (!ebsValid) { const errorMessage = i18next.t(ebsErrorsMapping[error!]) setState( [...errorsPath, index, 'EbsSettings', 'Size'], errorMessage, ) valid = false } else { clearState([...errorsPath, index, 'EbsSettings', 'Size']) } } if (storage.StorageType === 'Efs') { const [efsValid, error] = validateEfs(storage as EfsStorage) if (!efsValid) { const errorMessage = i18next.t(efsErrorsMapping[error!]) setState( [...errorsPath, index, 'EfsSettings', 'ProvisionedThroughput'], errorMessage, ) valid = false } else { clearState([ ...errorsPath, index, 'EfsSettings', 'ProvisionedThroughput', ]) } } } const name = getState([...storagePath, index, 'Name']) const [nameValid, error] = validateStorageName(name) if (!nameValid) { let errorMessage: string if (error === 'max_length') { errorMessage = i18next.t(storageNameErrorsMapping[error], { maxChars: STORAGE_NAME_MAX_LENGTH, }) } else { errorMessage = i18next.t(storageNameErrorsMapping[error!]) } setState([...errorsPath, index, 'Name'], errorMessage) valid = false } else { clearState([...errorsPath, index, 'Name']) } }) } setState([...errorsPath, 'validated'], true) return valid } const LUSTRE_PERSISTENT1_DEFAULT_THROUGHPUT = 200 const LUSTRE_PERSISTENT2_DEFAULT_THROUGHPUT = 125 const storageThroughputsP1 = [50, 100, LUSTRE_PERSISTENT1_DEFAULT_THROUGHPUT] const storageThroughputsP2 = [ LUSTRE_PERSISTENT2_DEFAULT_THROUGHPUT, 250, 500, 1000, ] const DEFAULT_DELETION_POLICY: DeletionPolicy = 'Retain' function isPersistentFsx(lustreType: string): boolean { return ['PERSISTENT_1', 'PERSISTENT_2'].includes(lustreType) } function setDefaultStorageThroughput( lustreType: string, storageThroughputPath: string[], ) { if (isPersistentFsx(lustreType)) { setState( storageThroughputPath, lustreType === 'PERSISTENT_1' ? LUSTRE_PERSISTENT1_DEFAULT_THROUGHPUT : LUSTRE_PERSISTENT2_DEFAULT_THROUGHPUT, ) } else { clearState(storageThroughputPath) } } export function FsxLustreSettings({index}: any) { const {t} = useTranslation() const isLustrePersistent2Active = useFeatureFlag('lustre_persistent2') const isDeletionPolicyEnabled = useFeatureFlag('lustre_deletion_policy') const useExisting = useState(['app', 'wizard', 'storage', 'ui', index, 'useExisting']) || false const fsxPath = useMemo( () => [...storagePath, index, 'FsxLustreSettings'], [index], ) const storageCapacityPath = [...fsxPath, 'StorageCapacity'] const lustreTypePath = [...fsxPath, 'DeploymentType'] // support FSx Lustre PERSISTENT_2 only in >= 3.2.0 const lustreTypes = [ isLustrePersistent2Active ? 'PERSISTENT_2' : null, 'PERSISTENT_1', 'SCRATCH_1', 'SCRATCH_2', ].filter(Boolean) const storageThroughputPath = [...fsxPath, 'PerUnitStorageThroughput'] const importPathPath = [...fsxPath, 'ImportPath'] const exportPathPath = [...fsxPath, 'ExportPath'] const compressionPath = [...fsxPath, 'DataCompressionType'] const deletionPolicyPath = [...fsxPath, 'DeletionPolicy'] const storageCapacity = useState(storageCapacityPath) const lustreType = useState(lustreTypePath) const storageThroughput = useState(storageThroughputPath) const importPath = useState(importPathPath) || '' const exportPath = useState(exportPathPath) || '' const compression = useState(compressionPath) const deletionPolicy = useState(deletionPolicyPath) const supportedDeletionPolicies: FsxLustreDeletionPolicy[] = [ 'Delete', 'Retain', ] React.useEffect(() => { const fsxPath = [...storagePath, index, 'FsxLustreSettings'] const storageCapacityPath = [...fsxPath, 'StorageCapacity'] const lustreTypePath = [...fsxPath, 'DeploymentType'] const deletionPolicyPath = [...fsxPath, 'DeletionPolicy'] const storageThroughputPath = [...fsxPath, 'PerUnitStorageThroughput'] if (isDeletionPolicyEnabled && deletionPolicy === null) setState(deletionPolicyPath, DEFAULT_DELETION_POLICY) if (storageCapacity === null && !useExisting) setState(storageCapacityPath, 1200) if (!storageThroughput && !useExisting) { setDefaultStorageThroughput(lustreType, storageThroughputPath) } if (lustreType === null && !useExisting) setState( lustreTypePath, isLustrePersistent2Active ? 'PERSISTENT_2' : 'PERSISTENT_1', ) }, [ storageCapacity, lustreType, storageThroughput, index, useExisting, isLustrePersistent2Active, deletionPolicy, isDeletionPolicyEnabled, ]) const toggleCompression = () => { if (compression) clearState(compressionPath) else setState(compressionPath, 'LZ4') } const setImportPath = (path: any) => { if (path !== '') setState(importPathPath, path) else clearState(importPathPath) } const setExportPath = (path: any) => { if (path !== '') setState(exportPathPath, path) else clearState(exportPathPath) } const capacityMin = 1200 const capacityMax = 100800 const capacityStep = 1200 const clampCapacity = (inCapacityStr: string) => { return clamp( parseInt(inCapacityStr), capacityMin, capacityMax, capacityStep, ).toString() } const onDeletionPolicyChange = useCallback( (selectedDeletionPolicy: DeletionPolicy) => { const deletionPolicyPath = [...fsxPath, 'DeletionPolicy'] setState(deletionPolicyPath, selectedDeletionPolicy) }, [fsxPath], ) const throughputFooterLinks = useMemo( () => [ { title: t('wizard.storage.Fsx.throughput.link.title'), href: t('wizard.storage.Fsx.throughput.link.href'), }, ], [t], ) const lustreTypeFooterLinks = useMemo( () => [ { title: t('wizard.storage.Fsx.lustreType.link.title'), href: t('wizard.storage.Fsx.lustreType.link.href'), }, ], [t], ) const lustreCompressionFooterLinks = useMemo( () => [ { title: t('wizard.storage.Fsx.compression.link.title'), href: t('wizard.storage.Fsx.compression.link.href'), }, ], [t], ) return ( } info={ } footerLinks={lustreTypeFooterLinks} /> } /> } > { setState(storageCapacityPath, detail.value) }} onBlur={_e => { setState(storageCapacityPath, clampCapacity(storageCapacity)) }} type="number" /> {lustreType === 'PERSISTENT_1' && ( <> } /> } /> } > setImportPath(detail.value)} /> } /> } /> } > { setExportPath(detail.value) }} /> )} {isPersistentFsx(lustreType) && ( } /> } > { setState(kmsPath, detail.value) }} /> ) : null} { setState( provisionedThroughputPath, clamp(parseInt(detail.value), 1, 1024), ) }} /> )} {isDeletionPolicyEnabled && ( )} ) } export function EbsSettings({index}: any) { const {t} = useTranslation() const isDeletionPolicyEnabled = useFeatureFlag('ebs_deletion_policy') const ebsPath = useMemo(() => [...storagePath, index, 'EbsSettings'], [index]) const volumeTypePath = [...ebsPath, 'VolumeType'] const volumeTypes = ['gp3', 'gp2', 'io1', 'io2', 'sc1', 'st1', 'standard'] const defaultVolumeType = 'gp3' const volumeSizePath = [...ebsPath, 'Size'] const encryptedPath = [...ebsPath, 'Encrypted'] const kmsPath = [...ebsPath, 'KmsKeyId'] const snapshotIdPath = useMemo(() => [...ebsPath, 'SnapshotId'], [ebsPath]) const deletionPolicyPath = [...ebsPath, 'DeletionPolicy'] const supportedDeletionPolicies: EbsDeletionPolicy[] = [ 'Delete', 'Retain', 'Snapshot', ] const volumeErrors = useState([...errorsPath, index, 'EbsSettings', 'Size']) let volumeType = useState(volumeTypePath) let volumeSize = useState(volumeSizePath) let encrypted = useState(encryptedPath) let kmsId = useState(kmsPath) let snapshotId = useState(snapshotIdPath) const [snapshostVisible, setSnapshotVisible] = reactUseState(!!snapshotId) let deletionPolicy = useState(deletionPolicyPath) let validated = useState([...errorsPath, 'validated']) React.useEffect(() => { const volumeTypePath = [...ebsPath, 'VolumeType'] const deletionPolicyPath = [...ebsPath, 'DeletionPolicy'] const volumeSizePath = [...ebsPath, 'Size'] if (volumeType === null) setState(volumeTypePath, defaultVolumeType) if (isDeletionPolicyEnabled && deletionPolicy === null) setState(deletionPolicyPath, DEFAULT_DELETION_POLICY) if (volumeSize === null) setState(volumeSizePath, 35) }, [volumeType, volumeSize, deletionPolicy, isDeletionPolicyEnabled, ebsPath]) const toggleEncrypted = () => { const setEncrypted = !encrypted setState(encryptedPath, setEncrypted) if (!setEncrypted) clearState(kmsPath) } const toggleSnapshotVisibility = useCallback(() => { clearState(snapshotIdPath) setSnapshotVisible(!snapshostVisible) }, [setSnapshotVisible, snapshostVisible, snapshotIdPath]) const encryptionFooterLinks = useMemo( () => [ { title: t('wizard.storage.Ebs.encrypted.encryptionLink.title'), href: t('wizard.storage.Ebs.encrypted.encryptionLink.href'), }, ], [t], ) const snapshotFooterLinks = useMemo( () => [ { title: t('wizard.storage.Ebs.snapshotId.snapshotLink.title'), href: t('wizard.storage.Ebs.snapshotId.snapshotLink.href'), }, ], [t], ) const onDeletionPolicyChange = useCallback( (selectedDeletionPolicy: DeletionPolicy) => { const deletionPolicyPath = [...ebsPath, 'DeletionPolicy'] setState(deletionPolicyPath, selectedDeletionPolicy) }, [ebsPath], ) return ( { setState(volumeSizePath, detail.value) validated && storageValidate() }} /> } > {t('wizard.storage.Ebs.encrypted.label')} {encrypted ? ( { setState(kmsPath, detail.value) }} /> ) : null} } > {t('wizard.storage.Ebs.snapshotId.label')} {snapshostVisible && ( { setState(snapshotIdPath, detail.value) }} /> )} {isDeletionPolicyEnabled && ( )} ) } function StorageInstance({index}: any) { const path = [...storagePath, index] const uiSettingsForStorage = ['app', 'wizard', 'storage', 'ui', index] const storageType: StorageType = useState([...path, 'StorageType']) const storageName = useState([...path, 'Name']) || '' const storageNameErrors = useState([...errorsPath, index, 'Name']) const mountPoint = useState([...path, 'MountDir']) const settingsPath = [...path, `${storageType}Settings`] const errorsInstancePath = [...errorsPath, index, `${storageType}Settings`] const useExisting = useState([...uiSettingsForStorage, 'useExisting']) || !(STORAGE_TYPE_PROPS[storageType].maxToCreate > 0) const existingPath = STORAGE_TYPE_PROPS[storageType].mountFilesystem ? [...settingsPath, 'FileSystemId'] : [...settingsPath, 'VolumeId'] const existingPathError = useState( STORAGE_TYPE_PROPS[storageType].mountFilesystem ? [...errorsInstancePath, 'FileSystemId'] : [...errorsInstancePath, 'VolumeId'], ) const existingId = useState(existingPath) || '' const storages = useState(storagePath) const uiSettings = useState(['app', 'wizard', 'storage', 'ui']) const {t} = useTranslation() const fsxFilesystems = useState(['aws', 'fsxFilesystems']) const fsxVolumes = useState(['aws', 'fsxVolumes']) const efsFilesystems = useState(['aws', 'efs_filesystems']) || [] const canEditFilesystems = useDynamicStorage() const canToggle = (useExisting && canCreateStorage(storageType, storages, uiSettings)) || (!useExisting && canAttachExistingStorage(storageType, storages, uiSettings)) const removeStorage = () => { if (index === 0 && storages.length === 1) { clearState(['app', 'wizard', 'storage', 'ui']) clearState(storagePath) } else { clearState(uiSettingsForStorage) clearState(path) } // Rename storages to keep indices correct and names unique const updatedStorages = getState(storagePath) if (updatedStorages) for (let i = 0; i < updatedStorages.length; i++) { const storage = getState([...storagePath, i]) setState([...storagePath, i, 'Name'], `${storage.StorageType}${i}`) } } const toggleUseExisting = () => { const value = !useExisting clearState(settingsPath) setState([...uiSettingsForStorage, 'useExisting'], value) } const idToOption = (id: any) => { return {label: id, value: id} } const updateStorageName = useCallback< NonCancelableEventHandler >( ({detail}) => { setState([...storagePath, index, 'Name'], detail.value) }, [index], ) const useExistingFooterLinks = useMemo( () => [ { title: t('wizard.storage.instance.useExisting.fsxLink.title'), href: t('wizard.storage.instance.useExisting.fsxLink.href'), }, { title: t('wizard.storage.instance.useExisting.efsLink.title'), href: t('wizard.storage.instance.useExisting.efsLink.href'), }, { title: t('wizard.storage.instance.useExisting.ebsLink.title'), href: t('wizard.storage.instance.useExisting.ebsLink.href'), }, ], [t], ) const storageTypeDisplay = ALL_STORAGES.find( ([type]) => type === storageType, )?.[1] return ( {t('wizard.storage.container.removeStorage')} } > {t('wizard.storage.container.sourceTitle', { index: index + 1, name: storageTypeDisplay, })} } > } /> } > { setState([...storagePath, index, 'MountDir'], detail.value) }} /> {STORAGE_TYPE_PROPS[storageType].maxToCreate > 0 ? ( } /> ) : null} {useExisting && { Ebs: ( { setState(existingPath, detail.value) }} /> ), FsxLustre: ( { setState(existingPath, detail.selectedOption.value) }} options={fsxVolumes.zfs.map((vol: any) => ({ value: vol.id, label: vol.displayName, }))} empty={t('wizard.storage.instance.useExisting.empty')} /> ), FsxOntap: ( { setState(existingPath, detail.selectedOption.value) }} options={efsFilesystems.map((x: any) => { return { value: x.FileSystemId, label: x.FileSystemId + (x.Name ? ` (${x.Name})` : ''), } })} empty={t('wizard.storage.instance.useExisting.empty')} /> ), }[storageType]} {!useExisting && { FsxLustre: , Efs: , Ebs: , FsxOntap: null, FsxOpenZfs: null, }[storageType]} ) } const ALL_STORAGES: StorageTypeOption[] = [ ['FsxLustre', 'Amazon FSx for Lustre (FSX)'], ['FsxOntap', 'Amazon FSx for NetApp ONTAP (FSX)'], ['FsxOpenZfs', 'Amazon FSx for OpenZFS (FSX)'], ['Efs', 'Amazon Elastic File System (EFS)'], ['Ebs', 'Amazon Elastic Block Store (EBS)'], ] function Storage() { const {t} = useTranslation() const storages = useState(storagePath) const uiStorageSettings = useState(['app', 'wizard', 'storage', 'ui']) const isFsxOnTapActive = useFeatureFlag('fsx_ontap') const isFsxOpenZsfActive = useFeatureFlag('fsx_openzsf') const canEditFilesystems = useDynamicStorage() const hasAddedStorage = storages?.length > 0 useHelpPanel() const storageMaxes: Record = { FsxLustre: 21, FsxOntap: 20, FsxOpenZfs: 20, Efs: 21, Ebs: 5, } const supportedStorages = ALL_STORAGES.filter(([storageType]) => { if (storageType === 'FsxOntap' && !isFsxOnTapActive) { return false } if (storageType === 'FsxOpenZfs' && !isFsxOpenZsfActive) { return false } return true }) const defaultCounts = {FsxLustre: 0, Efs: 0, Ebs: 0} const storageReducer = (eax: any, item: any) => { let ret = {...eax} ret[item.StorageType] += 1 return ret } const storageCounts = storages ? storages.reduce(storageReducer, defaultCounts) : defaultCounts const storageTypes = supportedStorages.reduce( (newStorages: StorageTypeOption[], storageType: StorageTypeOption) => { const st = storageType[0] return storageCounts[st] >= storageMaxes[st] ? newStorages : [...newStorages, storageType] }, [], ) const onAddStorageSubmit = React.useCallback( (selectedStorageTypes: StorageType[]) => { const existingStorages = storages || [] const existingUiStorageSettings = uiStorageSettings || [] const [storageEntries, uiSettingsEntries] = buildStorageEntries( existingStorages, existingUiStorageSettings, selectedStorageTypes, ) setState(storagePath, [...existingStorages, ...storageEntries]) setState(uiSettingsPath, [ ...existingUiStorageSettings, ...uiSettingsEntries, ]) }, [storages, uiStorageSettings], ) return ( } > {t('wizard.storage.container.title')} } > {!hasAddedStorage && ( {t('wizard.storage.container.noStorageSelected')} )} {canEditFilesystems && storageTypes.length > 0 && ( )} {hasAddedStorage ? storages.map((_: any, i: any) => ( )) : null} ) } const StorageHelpPanel = () => { const {t} = useTranslation() const footerLinks = useMemo( () => [ { title: t('wizard.storage.helpPanel.link.sharedStorage.title'), href: t('wizard.storage.helpPanel.link.sharedStorage.href'), }, { title: t('wizard.storage.helpPanel.link.sharedStorageSection.title'), href: t('wizard.storage.helpPanel.link.sharedStorageSection.href'), }, ], [t], ) return ( } description={ <>
} footerLinks={footerLinks} /> ) } function canCreateStorage( storageType: StorageType, storages: Storages, uiStorageSettings: UIStorageSettings, ) { if (!storageType) { return false } if (!storages || !uiStorageSettings) { return true } const maxToCreate = STORAGE_TYPE_PROPS[storageType].maxToCreate const alreadyCreated = storages .filter((_, index) => !uiStorageSettings[index].useExisting) .filter(storage => storage.StorageType === storageType).length return alreadyCreated < maxToCreate } function canAttachExistingStorage( storageType: StorageType, storages: Storages, uiStorageSettings: UIStorageSettings, ) { if (!storageType) { return false } if (!storages || !uiStorageSettings) { return true } const maxExistingToAttach = STORAGE_TYPE_PROPS[storageType].maxExistingToAttach const existingAlreadyAttached = storages .filter((_, index) => uiStorageSettings[index].useExisting) .filter(storage => storage.StorageType === storageType).length return existingAlreadyAttached < maxExistingToAttach } function useDynamicStorage() { const editingCluster = useState(['app', 'wizard', 'editing']) const isDynamicFSMountActive = useFeatureFlag('dynamic_fs_mount') return isDynamicFSMountActive || !editingCluster } export { Storage, storageValidate, canCreateStorage, canAttachExistingStorage, useDynamicStorage, StorageHelpPanel, }