From 69c27ff360374c52d2361ed012516ca37b4241c0 Mon Sep 17 00:00:00 2001 From: Dmitry Gusev Date: Thu, 18 Jul 2024 12:27:34 +0300 Subject: [PATCH] Support releases for CAPA (#4618) * Add support for v25 (and newer) CAPA releases * Fix uiSchema for CAPZ * Fix wording --- .../CreateClusterAppBundlesForm.tsx | 13 +- .../CreateClusterAppBundles/index.tsx | 295 ++++++++++++++---- .../CreateClusterAppBundles/schemaUtils.ts | 153 +++++---- .../clusters/CreateClusterAppBundles/utils.ts | 9 +- src/components/MAPI/releases/utils.ts | 12 +- .../services/mapi/releasev1alpha1/key.ts | 4 + 6 files changed, 359 insertions(+), 127 deletions(-) diff --git a/src/components/MAPI/clusters/CreateClusterAppBundles/CreateClusterAppBundlesForm.tsx b/src/components/MAPI/clusters/CreateClusterAppBundles/CreateClusterAppBundlesForm.tsx index f166d8910f..0536f30490 100644 --- a/src/components/MAPI/clusters/CreateClusterAppBundles/CreateClusterAppBundlesForm.tsx +++ b/src/components/MAPI/clusters/CreateClusterAppBundles/CreateClusterAppBundlesForm.tsx @@ -35,6 +35,7 @@ interface ICreateClusterAppBundlesFormProps { provider: PrototypeSchemas; organization: string; appVersion: string; + releaseVersion?: string; render: (args: { formDataPreview: React.ReactNode }) => React.ReactNode; onSubmit: ( e: React.SyntheticEvent, @@ -59,6 +60,7 @@ const CreateClusterAppBundlesForm: React.FC< provider, organization, appVersion, + releaseVersion, onSubmit, onChange, children, @@ -68,14 +70,19 @@ const CreateClusterAppBundlesForm: React.FC< const clusterName = useMemo( () => props.clusterName || generateUID(5), // eslint-disable-next-line react-hooks/exhaustive-deps - [provider, appVersion] + [provider, appVersion, releaseVersion] ); const [formProps, setFormProps] = useState< Pick, 'uiSchema' | 'formData'> >({ - ...getFormProps(provider, appVersion, clusterName, organization), - ...(formData && { formData }), + ...getFormProps( + provider, + appVersion, + clusterName, + organization, + releaseVersion + ), }); const [cleanFormData, setCleanFormData] = useState(); diff --git a/src/components/MAPI/clusters/CreateClusterAppBundles/index.tsx b/src/components/MAPI/clusters/CreateClusterAppBundles/index.tsx index e8a048b096..29f0f2ade8 100644 --- a/src/components/MAPI/clusters/CreateClusterAppBundles/index.tsx +++ b/src/components/MAPI/clusters/CreateClusterAppBundles/index.tsx @@ -10,13 +10,20 @@ import { fetchAppCatalogEntrySchemaKey, normalizeAppVersion, } from 'MAPI/apps/utils'; +import * as releasesUtils from 'MAPI/releases/utils'; import { extractErrorMessage } from 'MAPI/utils'; import { GenericResponseError } from 'model/clients/GenericResponseError'; import { Providers } from 'model/constants'; import { MainRoutes, OrganizationsRoutes } from 'model/constants/routes'; import * as applicationv1alpha1 from 'model/services/mapi/applicationv1alpha1'; import { getAppCatalogEntryValuesSchemaURL } from 'model/services/mapi/applicationv1alpha1'; +import * as releasev1alpha1 from 'model/services/mapi/releasev1alpha1'; +import { + getIsImpersonatingNonAdmin, + getUserIsAdmin, +} from 'model/stores/main/selectors'; import { selectOrganizations } from 'model/stores/organization/selectors'; +import { isPreRelease } from 'model/stores/releases/utils'; import React, { useEffect, useMemo, useRef, useState } from 'react'; import { Breadcrumb } from 'react-breadcrumbs'; import { useDispatch } from 'react-redux'; @@ -30,12 +37,14 @@ import ErrorMessage from 'UI/Display/ErrorMessage'; import { Tooltip, TooltipContainer } from 'UI/Display/Tooltip'; import InputGroup from 'UI/Inputs/InputGroup'; import Select from 'UI/Inputs/Select'; +import { getK8sVersionEOLDate } from 'utils/config'; import ErrorReporter from 'utils/errors/ErrorReporter'; import { FlashMessage, messageTTL, messageType } from 'utils/flashMessage'; import { useHttpClientFactory } from 'utils/hooks/useHttpClientFactory'; import RoutePath from 'utils/routePath'; import { compare } from 'utils/semver'; +import CreateClusterReleaseSelector from '../CreateCluster/CreateClusterReleaseSelector'; import CreateClusterAppBundlesForm from './CreateClusterAppBundlesForm'; import CreateClusterConfigViewer from './CreateClusterConfigViewer'; import CreateClusterStatus from './CreateClusterStatus'; @@ -96,6 +105,19 @@ function getLatestAppCatalogEntry( )[0]; } +function getLatestRelease( + entries: releasev1alpha1.IRelease[] +): releasev1alpha1.IRelease { + return entries + .slice() + .sort((a, b) => + compare( + releasesUtils.normalizeReleaseVersion(b.metadata.name), + releasesUtils.normalizeReleaseVersion(a.metadata.name) + ) + )[0]; +} + function formatClusterAppVersionSelectorLabel( clusterAppACE: applicationv1alpha1.IAppCatalogEntry, latestClusterAppACE: applicationv1alpha1.IAppCatalogEntry @@ -128,6 +150,17 @@ enum Pages { const CREATE_CLUSTER_FORM_ID = 'create-cluster-form'; +interface IRelease { + version: string; + state: releasev1alpha1.ReleaseState; + timestamp: string; + components: IReleaseComponent[]; + kubernetesVersion?: string; + releaseNotesURL?: string; + k8sVersionEOLDate?: string; + clusterAppVersion?: string; +} + interface ICreateClusterAppBundlesProps extends React.ComponentPropsWithoutRef {} @@ -146,6 +179,9 @@ const CreateClusterAppBundles: React.FC = ( const provider = window.config.info.general.provider; + const isAdmin = useSelector(getUserIsAdmin); + const isImpersonatingNonAdmin = useSelector(getIsImpersonatingNonAdmin); + const clientFactory = useHttpClientFactory(); const auth = useAuthProvider(); const dispatch = useDispatch(); @@ -156,6 +192,87 @@ const CreateClusterAppBundles: React.FC = ( const [isCreating, setIsCreating] = useState(false); const [hasErrors, setHasErrors] = useState(false); + const releaseListClient = useRef(clientFactory()); + const { + data: releaseList, + error: releaseListError, + isLoading: releaseListIsLoading, + } = useSWR( + releasev1alpha1.getReleaseListKey(), + () => releasev1alpha1.getReleaseList(releaseListClient.current, auth) + ); + + useEffect(() => { + if (releaseListError) { + ErrorReporter.getInstance().notify(releaseListError); + } + }, [releaseListError]); + + const releases = useMemo(() => { + if (!releaseList) return {}; + + return releaseList.items.reduce( + (acc: Record, curr: releasev1alpha1.IRelease) => { + const isActive = curr.spec.state === 'active'; + const isPreview = curr.spec.state === 'preview'; + const normalizedVersion = releasesUtils.normalizeReleaseVersion( + curr.metadata.name + ); + + if ( + !isPreview && + (!isAdmin || (isAdmin && isImpersonatingNonAdmin)) && + (!isActive || isPreRelease(normalizedVersion)) + ) { + return acc; + } + + const components = releasesUtils.reduceReleaseToComponents(curr); + + const k8sVersion = releasev1alpha1.getK8sVersion(curr); + const k8sVersionEOLDate = k8sVersion + ? getK8sVersionEOLDate(k8sVersion) ?? undefined + : undefined; + + const clusterAppVersion = releasev1alpha1.getClusterAppVersion(curr); + + acc[normalizedVersion] = { + version: normalizedVersion, + state: curr.spec.state, + timestamp: curr.metadata.creationTimestamp ?? '', + components: Object.values(components), + kubernetesVersion: k8sVersion, + k8sVersionEOLDate: k8sVersionEOLDate, + releaseNotesURL: releasev1alpha1.getReleaseNotesURL(curr), + clusterAppVersion, + }; + + return acc; + }, + {} + ); + }, [isAdmin, isImpersonatingNonAdmin, releaseList]); + + const latestRelease = useMemo(() => { + if (!releaseList) return undefined; + + return getLatestRelease(releaseList.items); + }, [releaseList]); + + const [selectedRelease, setSelectedRelease] = useState( + undefined + ); + + useEffect(() => { + if (selectedRelease || !latestRelease) return; + + setSelectedRelease( + releases[ + releasesUtils.normalizeReleaseVersion(latestRelease.metadata.name) + ] + ); + }, [latestRelease, releases, selectedRelease]); + const clusterDefaultAppsACEClient = useRef(clientFactory()); const { data: clusterDefaultAppsACEList, @@ -212,15 +329,34 @@ const CreateClusterAppBundles: React.FC = ( return getLatestAppCatalogEntry(clusterAppACEList.items); }, [clusterAppACEList]); + const selectedReleaseClusterAppACE = useMemo(() => { + if (!clusterAppACEList || !selectedRelease) { + return undefined; + } + + const selectedReleaseClusterAppVersion = selectedRelease.clusterAppVersion; + if (!selectedReleaseClusterAppVersion) { + return undefined; + } + + return clusterAppACEList.items.find( + (item) => + normalizeAppVersion(item.spec.version) === + selectedReleaseClusterAppVersion + ); + }, [clusterAppACEList, selectedRelease]); + const [selectedClusterApp, setSelectedClusterApp] = useState< applicationv1alpha1.IAppCatalogEntry | undefined >(undefined); useEffect(() => { - if (!latestClusterAppACE) return; - - setSelectedClusterApp(latestClusterAppACE); - }, [latestClusterAppACE]); + if (selectedReleaseClusterAppACE) { + setSelectedClusterApp(selectedReleaseClusterAppACE); + } else if (latestClusterAppACE) { + setSelectedClusterApp(latestClusterAppACE); + } + }, [selectedReleaseClusterAppACE, latestClusterAppACE]); const schemaURL = selectedClusterApp ? getAppCatalogEntryValuesSchemaURL(selectedClusterApp) @@ -284,7 +420,8 @@ const CreateClusterAppBundles: React.FC = ( await createClusterAppResources(clientFactory, auth, { clusterName, organization: orgId, - clusterAppVersion: latestClusterAppACE!.spec.version, + clusterAppVersion: selectedClusterApp?.spec.version ?? '', + releaseVersion: selectedRelease?.version, defaultAppsVersion: latestClusterDefaultAppsACE!.spec.version, provider, configMapContents: yaml.dump(formData), @@ -320,6 +457,7 @@ const CreateClusterAppBundles: React.FC = ( }; const isLoading = + releaseListIsLoading || clusterDefaultAppsACEIsLoading || clusterAppACEIsLoading || latestClusterAppACE === undefined || @@ -349,59 +487,100 @@ const CreateClusterAppBundles: React.FC = ( context. - - Cluster app version - - The cluster app version specifies versions and - configurations of cluster components, e.g. the - Kubernetes version. - + {releases && selectedRelease ? ( + + Release version + + The release version specifies versions and + configurations of cluster components, e.g. the + Kubernetes version. + + } + > + + + + } + contentProps={{ margin: { left: 'medium' } }} + > + + { + setSelectedRelease(releases[releaseVersion]); + }} + selectedRelease={selectedRelease.version} + /> + + + ) : null} + + {releases && Object.keys(releases).length === 0 ? ( + + Cluster app version + + The cluster app version specifies versions and + configurations of cluster components, e.g. the + Kubernetes version. + + } + > + + + + } + contentProps={{ margin: { left: 'medium' } }} + > + + - formatClusterAppVersionSelectorLabel( - currentACE, - latestClusterAppACE - ) - } - // eslint-disable-next-line @typescript-eslint/no-unsafe-argument - onChange={(e) => setSelectedClusterApp(e.option)} - options={clusterAppACEList!.items.sort((a, b) => - compare(b.spec.version, a.spec.version) - )} - /> - - - Details on all versions are available on{' '} - - GitHub - - . - - + + Details on all versions are available on{' '} + + GitHub + + . + + + ) : null} {appSchemaIsLoading && ( @@ -413,13 +592,14 @@ const CreateClusterAppBundles: React.FC = ( provider={provider as PrototypeSchemas} organization={organizationID} appVersion={selectedClusterApp.spec.version} + releaseVersion={selectedRelease?.version} onSubmit={handleSubmit} onError={(errors: RJSFValidationError[]) => setHasErrors(errors.length > 0) } onChange={handleChange} formData={formPayload.formData} - key={`${provider}${selectedClusterApp.spec.version}`} + key={`${provider}-${selectedClusterApp.spec.version}-${selectedRelease?.version}`} clusterName={formPayload.clusterName} id={CREATE_CLUSTER_FORM_ID} render={() => { @@ -471,6 +651,7 @@ const CreateClusterAppBundles: React.FC = ( clusterName: formPayload.clusterName!, organization: orgId, clusterAppVersion: selectedClusterApp.spec.version, + releaseVersion: selectedRelease?.version, defaultAppsVersion: latestClusterDefaultAppsACE!.spec.version, provider, configMapContents: yaml.dump(formPayload.formData), diff --git a/src/components/MAPI/clusters/CreateClusterAppBundles/schemaUtils.ts b/src/components/MAPI/clusters/CreateClusterAppBundles/schemaUtils.ts index 51d92f7a73..e4d8413642 100644 --- a/src/components/MAPI/clusters/CreateClusterAppBundles/schemaUtils.ts +++ b/src/components/MAPI/clusters/CreateClusterAppBundles/schemaUtils.ts @@ -43,7 +43,11 @@ export function getProviderForPrototypeSchema( interface FormPropsPartial { uiSchema: UiSchema; - formData: (clusterName: string, organization: string) => RJSFSchema; + formData: ( + clusterName: string, + organization: string, + releaseVersion?: string + ) => RJSFSchema; } const formPropsProviderCAPA: Record = { @@ -114,6 +118,11 @@ const formPropsProviderCAPA: Record = { }, }, }, + release: { + version: { + 'ui:disabled': true, + }, + }, }, managementCluster: { 'ui:widget': 'hidden', @@ -125,13 +134,16 @@ const formPropsProviderCAPA: Record = { 'ui:widget': 'hidden', }, }, - formData: (clusterName, organization) => { + formData: (clusterName, organization, releaseVersion) => { return { global: { metadata: { name: clusterName, organization, }, + release: { + version: releaseVersion, + }, }, }; }, @@ -141,90 +153,104 @@ const formPropsProviderCAPA: Record = { const formPropsProviderCAPZ: Record = { 0: { uiSchema: { - 'ui:order': [ - 'metadata', - 'providerSpecific', - 'controlPlane', - 'connectivity', - 'nodePools', - 'machineDeployments', - 'kubernetesVersion', - 'includeClusterResourceSet', - '*', - ], + 'ui:order': ['global', '*'], baseDomain: { 'ui:widget': 'hidden', }, - connectivity: { - 'ui:order': ['sshSSOPublicKey', '*'], - bastion: { - instanceType: { - 'ui:widget': InstanceTypeWidget, - }, - }, - network: { - 'ui:order': ['hostCidr', 'podCidr', 'serviceCidr', 'mode', '*'], - }, + cluster: { + 'ui:widget': 'hidden', }, - controlPlane: { + global: { 'ui:order': [ - 'instanceType', - 'replicas', - 'rootVolumeSizeGB', - 'etcdVolumeSizeGB', + 'metadata', + 'providerSpecific', + 'controlPlane', + 'connectivity', + 'nodePools', + 'machineDeployments', + 'kubernetesVersion', + 'includeClusterResourceSet', '*', ], - instanceType: { - 'ui:widget': InstanceTypeWidget, - }, - oidc: { - 'ui:order': ['issuerUrl', 'clientId', '*'], + connectivity: { + 'ui:order': ['sshSSOPublicKey', '*'], + bastion: { + instanceType: { + 'ui:widget': InstanceTypeWidget, + }, + }, + network: { + 'ui:order': ['hostCidr', 'podCidr', 'serviceCidr', 'mode', '*'], + }, }, - }, - 'cluster-shared': { - 'ui:widget': 'hidden', - }, - machineDeployments: { - items: { + controlPlane: { + 'ui:order': [ + 'instanceType', + 'replicas', + 'rootVolumeSizeGB', + 'etcdVolumeSizeGB', + '*', + ], instanceType: { 'ui:widget': InstanceTypeWidget, }, + oidc: { + 'ui:order': ['issuerUrl', 'clientId', '*'], + }, }, - }, - machinePools: { - 'ui:widget': 'hidden', - }, - managementCluster: { - 'ui:widget': 'hidden', - }, - metadata: { - 'ui:order': ['name', 'description', '*'], - name: { - 'ui:widget': ClusterNameWidget, + machineDeployments: { + items: { + instanceType: { + 'ui:widget': InstanceTypeWidget, + }, + }, }, - organization: { + machinePools: { 'ui:widget': 'hidden', }, - }, - nodePools: { - items: { - instanceType: { - 'ui:widget': InstanceTypeWidget, + managementCluster: { + 'ui:widget': 'hidden', + }, + metadata: { + 'ui:order': ['name', 'description', '*'], + name: { + 'ui:widget': ClusterNameWidget, + }, + organization: { + 'ui:widget': 'hidden', }, }, + nodePools: { + items: { + instanceType: { + 'ui:widget': InstanceTypeWidget, + }, + }, + }, + provider: { + 'ui:widget': 'hidden', + }, + providerSpecific: { + 'ui:order': ['location', 'subscriptionId', '*'], + }, + }, + managementCluster: { + 'ui:widget': 'hidden', }, provider: { 'ui:widget': 'hidden', }, - providerSpecific: { - 'ui:order': ['location', 'subscriptionId', '*'], + 'cluster-shared': { + 'ui:widget': 'hidden', }, }, formData: (clusterName, organization) => { return { - metadata: { - name: clusterName, - organization, + global: { + metadata: { + name: clusterName, + organization, + }, }, }; }, @@ -369,7 +395,8 @@ export function getFormProps( schema: PrototypeSchemas, version: string, clusterName: string, - organization: string + organization: string, + releaseVersion?: string ): Pick, 'uiSchema' | 'formData'> { const formPropsByVersions = formPropsByProvider[schema]; @@ -381,6 +408,6 @@ export function getFormProps( return { uiSchema: props.uiSchema, - formData: props.formData(clusterName, organization), + formData: props.formData(clusterName, organization, releaseVersion), }; } diff --git a/src/components/MAPI/clusters/CreateClusterAppBundles/utils.ts b/src/components/MAPI/clusters/CreateClusterAppBundles/utils.ts index 2161c3bb5f..c9a1ee1403 100644 --- a/src/components/MAPI/clusters/CreateClusterAppBundles/utils.ts +++ b/src/components/MAPI/clusters/CreateClusterAppBundles/utils.ts @@ -84,6 +84,7 @@ export interface IClusterAppConfig { organization: string; provider: PropertiesOf; clusterAppVersion: string; + releaseVersion?: string; defaultAppsVersion: string; configMapContents: string; } @@ -93,6 +94,7 @@ function templateClusterAppCR( orgNamespace: string, provider: PropertiesOf, appVersion: string, + releaseVersion?: string, clusterAppUserConfigMap?: corev1.IConfigMap, appCatalog: string = Constants.CLUSTER_APPS_CATALOG_NAME ): applicationv1alpha1.IApp { @@ -109,7 +111,7 @@ function templateClusterAppCR( spec: { name: applicationv1alpha1.getClusterAppNameForProvider(provider), namespace: orgNamespace, - version: appVersion, + version: releaseVersion ? '' : appVersion, catalog: appCatalog, config: { configMap: { @@ -260,7 +262,8 @@ export async function createClusterAppResources( clusterAppConfig.clusterName, orgNamespace, clusterAppConfig.provider, - clusterAppConfig.clusterAppVersion + clusterAppConfig.clusterAppVersion, + clusterAppConfig.releaseVersion ); if (clusterAppUserConfigMap) { @@ -343,6 +346,7 @@ export function templateClusterCreationManifest( orgNamespace, config.provider, config.clusterAppVersion, + config.releaseVersion, clusterAppUserConfigMap ), ] @@ -354,6 +358,7 @@ export function templateClusterCreationManifest( orgNamespace, config.provider, config.clusterAppVersion, + config.releaseVersion, clusterAppUserConfigMap ), templateDefaultAppsCR( diff --git a/src/components/MAPI/releases/utils.ts b/src/components/MAPI/releases/utils.ts index 9a0133a970..4b4f00402c 100644 --- a/src/components/MAPI/releases/utils.ts +++ b/src/components/MAPI/releases/utils.ts @@ -13,8 +13,7 @@ export function getReleaseHelper( ) { const mappedReleases = releases.reduce( (acc: Record, r: releasev1alpha1.IRelease) => { - // Remove the `v` prefix. - const normalizedVersion = r.metadata.name.slice(1); + const normalizedVersion = normalizeReleaseVersion(r.metadata.name); acc[normalizedVersion] = { version: normalizedVersion, @@ -168,3 +167,12 @@ export function getPreviewReleaseVersions( return releaseVersions; } + +export function normalizeReleaseVersion(version: string): string { + const normalizedVersion = version.replace(/^(aws-|azure-)/, ''); + if (normalizedVersion.toLowerCase().startsWith('v')) { + return normalizedVersion.substring(1); + } + + return normalizedVersion; +} diff --git a/src/model/services/mapi/releasev1alpha1/key.ts b/src/model/services/mapi/releasev1alpha1/key.ts index 6e2cb21ca5..d3232650f8 100644 --- a/src/model/services/mapi/releasev1alpha1/key.ts +++ b/src/model/services/mapi/releasev1alpha1/key.ts @@ -6,6 +6,10 @@ export function getK8sVersion(cr: IRelease): string | undefined { return cr.spec.components.find((c) => c.name === 'kubernetes')?.version; } +export function getClusterAppVersion(cr: IRelease): string | undefined { + return cr.spec.components.find((c) => c.name.startsWith('cluster-'))?.version; +} + export function getReleaseNotesURL(cr: IRelease): string | undefined { return cr.metadata.annotations?.[annotationReleaseNotesURL]; }