From 4d1ff3b318580ef2b3f858cf1fead760818cbb44 Mon Sep 17 00:00:00 2001 From: Manaswini Das Date: Wed, 18 Dec 2024 19:58:56 +0530 Subject: [PATCH 1/4] Add backend route for Configmaps and secrets for MR namespace (#3582) * Add backend route for CMs and secrets for MR namespace * Fix linting issue in backend * Add tooltips and populate dropdowns for third radio option * Remove extra state and cleanup --- .../api/modelRegistryCertificates/index.ts | 24 ++ .../modelRegistryCertificatesUtils.ts | 63 ++++++ backend/src/types.ts | 10 + .../useModelRegistryCertificateNames.ts | 23 ++ frontend/src/k8sTypes.ts | 10 + .../CreateMRSecureDBSection.tsx | 212 ++++++++++++------ .../modelRegistrySettings/CreateModal.tsx | 44 ++-- .../services/modelRegistrySettingsService.ts | 11 +- 8 files changed, 306 insertions(+), 91 deletions(-) create mode 100644 backend/src/routes/api/modelRegistryCertificates/index.ts create mode 100644 backend/src/routes/api/modelRegistryCertificates/modelRegistryCertificatesUtils.ts create mode 100644 frontend/src/concepts/modelRegistrySettings/useModelRegistryCertificateNames.ts diff --git a/backend/src/routes/api/modelRegistryCertificates/index.ts b/backend/src/routes/api/modelRegistryCertificates/index.ts new file mode 100644 index 0000000000..d20122babc --- /dev/null +++ b/backend/src/routes/api/modelRegistryCertificates/index.ts @@ -0,0 +1,24 @@ +import { secureAdminRoute } from '../../../utils/route-security'; +import { KubeFastifyInstance } from '../../../types'; +import { getModelRegistryNamespace } from '../modelRegistries/modelRegistryUtils'; +import { listModelRegistryCertificateNames } from './modelRegistryCertificatesUtils'; +import { FastifyReply, FastifyRequest } from 'fastify'; + +export default async (fastify: KubeFastifyInstance): Promise => { + fastify.get( + '/', + secureAdminRoute(fastify)(async (request: FastifyRequest, reply: FastifyReply) => { + try { + const modelRegistryNamespace = getModelRegistryNamespace(fastify); + return listModelRegistryCertificateNames(fastify, modelRegistryNamespace); + } catch (e) { + fastify.log.error( + `Model registry certificate names could not be listed, ${ + e.response?.body?.message || e.message + }`, + ); + reply.send(e); + } + }), + ); +}; diff --git a/backend/src/routes/api/modelRegistryCertificates/modelRegistryCertificatesUtils.ts b/backend/src/routes/api/modelRegistryCertificates/modelRegistryCertificatesUtils.ts new file mode 100644 index 0000000000..a5333a4c80 --- /dev/null +++ b/backend/src/routes/api/modelRegistryCertificates/modelRegistryCertificatesUtils.ts @@ -0,0 +1,63 @@ +import { V1ConfigMap, V1Secret } from '@kubernetes/client-node'; +import { ConfigSecretItem, KubeFastifyInstance } from '../../../types'; + +export const listSecrets = async ( + fastify: KubeFastifyInstance, + modelRegistryNamespace: string, +): Promise<{ items: V1Secret[] }> => { + const response = await (fastify.kube.coreV1Api.listNamespacedSecret( + modelRegistryNamespace, + ) as Promise<{ body: { items: V1Secret[] } }>); + return response.body; +}; + +export const listConfigMaps = async ( + fastify: KubeFastifyInstance, + modelRegistryNamespace: string, +): Promise<{ items: V1ConfigMap[] }> => { + const response = await (fastify.kube.coreV1Api.listNamespacedConfigMap( + modelRegistryNamespace, + ) as Promise<{ body: { items: V1ConfigMap[] } }>); + return response.body; +}; + +export const listModelRegistryCertificateNames = async ( + fastify: KubeFastifyInstance, + namespace: string, +): Promise<{ + secrets: ConfigSecretItem[]; + configMaps: ConfigSecretItem[]; +}> => { + try { + const [secretsResponse, configMapsResponse] = await Promise.all([ + listSecrets(fastify, namespace), + listConfigMaps(fastify, namespace), + ]); + + const secrets = secretsResponse.items + .filter((secret) => secret.type === 'Opaque') + .map((secret) => { + const keys = Object.keys(secret.data || {}).filter( + (key) => secret.data?.[key] !== undefined && secret.data[key] !== '', + ); + return { name: secret.metadata?.name || 'unknown', keys }; + }) + .filter((secret) => secret.keys.length > 0); + + const configMaps = configMapsResponse.items + .map((configMap) => { + const keys = Object.keys(configMap.data || {}).filter( + (key) => configMap.data?.[key] !== undefined && configMap.data[key] !== '', + ); + return { name: configMap.metadata?.name || 'unknown', keys }; + }) + .filter((configMap) => configMap.keys.length > 0); + + return { secrets, configMaps }; + } catch (e: any) { + fastify.log.error( + `Error fetching config maps and secrets, ${e.response?.body?.message || e.message}`, + ); + throw e; + } +}; diff --git a/backend/src/types.ts b/backend/src/types.ts index 59a132bd14..df185bfa40 100644 --- a/backend/src/types.ts +++ b/backend/src/types.ts @@ -1261,3 +1261,13 @@ export type ResourceAccessReviewResponse = { groups?: string[]; users?: string[]; }; + +export type ConfigSecretItem = { + name: string; + keys: string[]; +}; + +export type ListConfigSecretsResponse = { + secrets: ConfigSecretItem[]; + configMaps: ConfigSecretItem[]; +}; diff --git a/frontend/src/concepts/modelRegistrySettings/useModelRegistryCertificateNames.ts b/frontend/src/concepts/modelRegistrySettings/useModelRegistryCertificateNames.ts new file mode 100644 index 0000000000..8bfcd97442 --- /dev/null +++ b/frontend/src/concepts/modelRegistrySettings/useModelRegistryCertificateNames.ts @@ -0,0 +1,23 @@ +import React from 'react'; +import useFetchState, { FetchState, NotReadyError } from '~/utilities/useFetchState'; +import { ListConfigSecretsResponse } from '~/k8sTypes'; +import { listModelRegistryCertificateNames } from '~/services/modelRegistrySettingsService'; + +const useModelRegistryCertificateNames = ( + isDisabled?: boolean, +): FetchState => { + const fetchData = React.useCallback(() => { + if (isDisabled) { + return Promise.reject(new NotReadyError('Model registry certificate names is disabled')); + } + + return listModelRegistryCertificateNames(); + }, [isDisabled]); + + return useFetchState(fetchData, { + secrets: [], + configMaps: [], + }); +}; + +export default useModelRegistryCertificateNames; diff --git a/frontend/src/k8sTypes.ts b/frontend/src/k8sTypes.ts index 0ab062d0b0..56c3f8990b 100644 --- a/frontend/src/k8sTypes.ts +++ b/frontend/src/k8sTypes.ts @@ -1363,3 +1363,13 @@ export type NIMAccountKind = K8sResourceCommon & { conditions?: K8sCondition[]; }; }; + +export type ConfigSecretItem = { + name: string; + keys: string[]; +}; + +export type ListConfigSecretsResponse = { + secrets: ConfigSecretItem[]; + configMaps: ConfigSecretItem[]; +}; diff --git a/frontend/src/pages/modelRegistrySettings/CreateMRSecureDBSection.tsx b/frontend/src/pages/modelRegistrySettings/CreateMRSecureDBSection.tsx index 2ee7aafa8f..c7bdf4ebcf 100644 --- a/frontend/src/pages/modelRegistrySettings/CreateMRSecureDBSection.tsx +++ b/frontend/src/pages/modelRegistrySettings/CreateMRSecureDBSection.tsx @@ -1,8 +1,10 @@ import React, { useState } from 'react'; -import { FormGroup, Radio, Alert, MenuItem, MenuGroup } from '@patternfly/react-core'; +import { FormGroup, Radio, Alert, MenuItem, MenuGroup, Tooltip } from '@patternfly/react-core'; import SearchSelector from '~/components/searchSelector/SearchSelector'; import { translateDisplayNameForK8s } from '~/concepts/k8s/utils'; import { RecursivePartial } from '~/typeHelpers'; +import { ConfigSecretItem } from '~/k8sTypes'; +import { ODH_PRODUCT_NAME } from '~/utilities/const'; import { PemFileUpload } from './PemFileUpload'; export enum SecureDBRType { @@ -19,15 +21,15 @@ export interface SecureDBInfo { certificate: string; nameSpace: string; isValid: boolean; + resourceType?: 'ConfigMap' | 'Secret'; } interface CreateMRSecureDBSectionProps { secureDBInfo: SecureDBInfo; modelRegistryNamespace: string; nameDesc: { name: string }; - existingCertKeys: string[]; - existingCertConfigMaps: string[]; - existingCertSecrets: string[]; + existingCertConfigMaps: ConfigSecretItem[]; + existingCertSecrets: ConfigSecretItem[]; setSecureDBInfo: (info: SecureDBInfo) => void; } @@ -35,12 +37,15 @@ export const CreateMRSecureDBSection: React.FC = ( secureDBInfo, modelRegistryNamespace, nameDesc, - existingCertKeys, existingCertConfigMaps, existingCertSecrets, setSecureDBInfo, }) => { - const [searchValue, setSearchValue] = useState(''); + const [searchConfigSecretName, setSearchConfigSecretName] = useState(''); + const [searchKey, setSearchKey] = useState(''); + const ODH_TRUSTED_BUNDLE = 'odh-trusted-ca-bundle'; + const CA_BUNDLE_CRT = 'ca-bundle.crt'; + const ODH_CA_BUNDLE_CRT = 'odh-ca-bundle.crt'; const hasContent = (value: string): boolean => !!value.trim().length; @@ -58,12 +63,33 @@ export const CreateMRSecureDBSection: React.FC = ( return false; }; + const clusterWideCABundle = existingCertConfigMaps.find( + (configMap) => configMap.name === ODH_TRUSTED_BUNDLE && configMap.keys.includes(CA_BUNDLE_CRT), + ); + + const isClusterWideCABundleAvailable = !!clusterWideCABundle; + + const openshiftCAbundle = existingCertConfigMaps.find( + (configMap) => + configMap.name === ODH_TRUSTED_BUNDLE && configMap.keys.includes(ODH_CA_BUNDLE_CRT), + ); + + const isProductCABundleAvailable = !!openshiftCAbundle; + + const getKeysByName = (configMapsSecrets: ConfigSecretItem[], targetName: string): string[] => { + const configMapSecret = configMapsSecrets.find( + (configMapOrSecret) => configMapOrSecret.name === targetName, + ); + return configMapSecret ? configMapSecret.keys : []; + }; + const handleSecureDBTypeChange = (type: SecureDBRType) => { const newInfo = { type, nameSpace: '', key: '', configMap: '', + resourceType: undefined, certificate: '', }; setSecureDBInfo({ @@ -72,51 +98,46 @@ export const CreateMRSecureDBSection: React.FC = ( }); }; + const handleResourceSelect = (selectedName: string, resourceType: 'ConfigMap' | 'Secret') => { + setSearchConfigSecretName(''); + + const newInfo = { + ...secureDBInfo, + configMap: selectedName, + key: '', + resourceType, + }; + + setSecureDBInfo({ ...newInfo, isValid: isValid(newInfo) }); + }; + const getFilteredExistingCAResources = () => ( <> {existingCertConfigMaps - .filter((configMap) => configMap.toLowerCase().includes(searchValue.toLowerCase())) + .filter((configMap) => + configMap.name.toLowerCase().includes(searchConfigSecretName.toLowerCase()), + ) .map((configMap, index) => ( { - setSearchValue(''); - const newInfo = { - ...secureDBInfo, - configMap, - key: '', - }; - setSecureDBInfo({ - ...newInfo, - isValid: isValid(newInfo), - }); - }} + onClick={() => handleResourceSelect(configMap.name, 'ConfigMap')} > - {configMap} + {configMap.name} ))} {existingCertSecrets - .filter((secret) => secret.toLowerCase().includes(searchValue.toLowerCase())) + .filter((secret) => + secret.name.toLowerCase().includes(searchConfigSecretName.toLowerCase()), + ) .map((secret, index) => ( { - setSearchValue(''); - const newInfo = { - ...secureDBInfo, - configMap: secret, - key: '', - }; - setSecureDBInfo({ - ...newInfo, - isValid: isValid(newInfo), - }); - }} + onClick={() => handleResourceSelect(secret.name, 'Secret')} > - {secret} + {secret.name} ))} @@ -125,32 +146,72 @@ export const CreateMRSecureDBSection: React.FC = ( return ( <> - handleSecureDBTypeChange(SecureDBRType.CLUSTER_WIDE)} - label="Use cluster-wide CA bundle" - description={ - <> - Use the ca-bundle.crt bundle in the{' '} - odh-trusted-ca-bundle ConfigMap. - - } - id="cluster-wide-ca" - /> - handleSecureDBTypeChange(SecureDBRType.OPENSHIFT)} - label="Use OpenShift AI CA bundle" - description={ - <> - Use the odh-ca-bundle.crt bundle in the{' '} - odh-trusted-ca-bundle ConfigMap. - - } - id="openshift-ca" - /> + {!isClusterWideCABundleAvailable ? ( + + handleSecureDBTypeChange(SecureDBRType.CLUSTER_WIDE)} + label="Use cluster-wide CA bundle" + description={ + <> + Use the {CA_BUNDLE_CRT} bundle in the{' '} + {ODH_TRUSTED_BUNDLE} ConfigMap. + + } + id="cluster-wide-ca" + /> + + ) : ( + handleSecureDBTypeChange(SecureDBRType.CLUSTER_WIDE)} + label="Use cluster-wide CA bundle" + description={ + <> + Use the {CA_BUNDLE_CRT} bundle in the{' '} + {ODH_TRUSTED_BUNDLE} ConfigMap. + + } + id="cluster-wide-ca" + /> + )} + {!isProductCABundleAvailable ? ( + + handleSecureDBTypeChange(SecureDBRType.OPENSHIFT)} + label={`Use ${ODH_PRODUCT_NAME} CA bundle`} + description={ + <> + Use the {ODH_CA_BUNDLE_CRT} bundle in the{' '} + {ODH_TRUSTED_BUNDLE} ConfigMap. + + } + id="openshift-ca" + /> + + ) : ( + handleSecureDBTypeChange(SecureDBRType.OPENSHIFT)} + label={`Use ${ODH_PRODUCT_NAME} CA bundle`} + description={ + <> + Use the {ODH_CA_BUNDLE_CRT} bundle in the{' '} + {ODH_TRUSTED_BUNDLE} ConfigMap. + + } + id="openshift-ca" + /> + )} = ( setSearchValue(newValue)} - onSearchClear={() => setSearchValue('')} - searchValue={searchValue} + onSearchChange={(newValue) => setSearchConfigSecretName(newValue)} + onSearchClear={() => setSearchConfigSecretName('')} + searchValue={searchConfigSecretName} toggleText={secureDBInfo.configMap || 'Select a ConfigMap or a Secret'} > {getFilteredExistingCAResources()} @@ -192,18 +253,29 @@ export const CreateMRSecureDBSection: React.FC = ( setSearchValue(newValue)} - onSearchClear={() => setSearchValue('')} - searchValue={searchValue} - toggleText={secureDBInfo.key || 'Select a key'} + onSearchChange={(newValue) => setSearchKey(newValue)} + isDisabled={!secureDBInfo.configMap} + onSearchClear={() => setSearchKey('')} + searchValue={searchKey} + toggleText={ + secureDBInfo.key || + (!secureDBInfo.configMap + ? 'Select a resource to view its available keys' + : 'Select a key') + } > - {existingCertKeys - .filter((item) => item.toLowerCase().includes(searchValue.toLowerCase())) + {getKeysByName( + secureDBInfo.resourceType === 'ConfigMap' + ? existingCertConfigMaps + : existingCertSecrets, + secureDBInfo.configMap, + ) + .filter((item) => item.toLowerCase().includes(searchKey.toLowerCase())) .map((item, index) => ( { - setSearchValue(''); + setSearchKey(''); const newInfo = { ...secureDBInfo, key: item, diff --git a/frontend/src/pages/modelRegistrySettings/CreateModal.tsx b/frontend/src/pages/modelRegistrySettings/CreateModal.tsx index c669b08a47..0dac12743f 100644 --- a/frontend/src/pages/modelRegistrySettings/CreateModal.tsx +++ b/frontend/src/pages/modelRegistrySettings/CreateModal.tsx @@ -1,11 +1,14 @@ import * as React from 'react'; import { + Alert, Button, Checkbox, + EmptyState, Form, FormGroup, HelperText, HelperTextItem, + Spinner, TextInput, } from '@patternfly/react-core'; import { Modal } from '@patternfly/react-core/deprecated'; @@ -23,6 +26,7 @@ import { SupportedArea, useIsAreaAvailable } from '~/concepts/areas'; import K8sNameDescriptionField, { useK8sNameDescriptionFieldData, } from '~/concepts/k8s/K8sNameDescriptionField/K8sNameDescriptionField'; +import useModelRegistryCertificateNames from '~/concepts/modelRegistrySettings/useModelRegistryCertificateNames'; import { CreateMRSecureDBSection, SecureDBInfo, SecureDBRType } from './CreateMRSecureDBSection'; import ModelRegistryDatabasePassword from './ModelRegistryDatabasePassword'; @@ -60,18 +64,12 @@ const CreateModal: React.FC = ({ onClose, refresh, modelRegist const [showPassword, setShowPassword] = React.useState(false); const { dscStatus } = React.useContext(AreaContext); const secureDbEnabled = useIsAreaAvailable(SupportedArea.MODEL_REGISTRY_SECURE_DB).status; + const [configSecrets, configSecretsLoaded, configSecretsError] = useModelRegistryCertificateNames( + !addSecureDB, + ); const modelRegistryNamespace = dscStatus?.components?.modelregistry?.registriesNamespace || ''; - // the 3 following consts are temporary hard-coded values to be replaced as part of RHOAIENG-15899 - const existingCertConfigMaps = [ - 'config-service-cabundle', - 'odh-trusted-ca-bundle', - 'foo-ca-bundle', - ]; - const existingCertSecrets = ['builder-dockercfg-b7gdr', 'builder-token-hwsps', 'foo-secret']; - const existingCertKeys = ['service-ca.crt', 'foo-ca.crt']; - React.useEffect(() => { if (mr) { const dbSpec = mr.spec.mysql || mr.spec.postgres; @@ -317,17 +315,23 @@ const CreateModal: React.FC = ({ onClose, refresh, modelRegist name="add-secure-db" /> - {addSecureDB && ( - - )} + {addSecureDB && + (!configSecretsLoaded && !configSecretsError ? ( + + ) : configSecretsLoaded ? ( + + ) : ( + + {configSecretsError?.message} + + ))} )} diff --git a/frontend/src/services/modelRegistrySettingsService.ts b/frontend/src/services/modelRegistrySettingsService.ts index ce0ba44a14..71ba133842 100644 --- a/frontend/src/services/modelRegistrySettingsService.ts +++ b/frontend/src/services/modelRegistrySettingsService.ts @@ -1,11 +1,12 @@ import * as _ from 'lodash-es'; import { K8sStatus } from '@openshift/dynamic-plugin-sdk-utils'; import axios from '~/utilities/axios'; -import { ModelRegistryKind, RoleBindingKind } from '~/k8sTypes'; +import { ListConfigSecretsResponse, ModelRegistryKind, RoleBindingKind } from '~/k8sTypes'; import { RecursivePartial } from '~/typeHelpers'; const registriesUrl = '/api/modelRegistries'; const mrRoleBindingsUrl = '/api/modelRegistryRoleBindings'; +const configSecretsUrl = '/api/modelRegistryCertificates'; type ModelRegistryAndDBPassword = { modelRegistry: ModelRegistryKind; @@ -90,3 +91,11 @@ export const deleteModelRegistryRoleBinding = (roleBindingName: string): Promise .catch((e) => { throw new Error(e.response.data.message); }); + +export const listModelRegistryCertificateNames = (): Promise => + axios + .get(configSecretsUrl) + .then((response) => response.data) + .catch((e) => { + throw new Error(e.response.data.message); + }); From 7852a7f1931501fda2612eda265f5665d6a60eec Mon Sep 17 00:00:00 2001 From: Jeff Phillips Date: Wed, 18 Dec 2024 10:16:11 -0500 Subject: [PATCH 2/4] Fix for typed icons in dark theme, make sizes consistent (#3581) --- frontend/src/concepts/design/HeaderIcon.tsx | 3 + .../src/concepts/design/InfoGalleryItem.tsx | 30 +---- frontend/src/concepts/design/utils.ts | 74 ++++++++++++ frontend/src/concepts/design/vars.scss | 105 ++++++++++++------ .../overview/components/OverviewCard.tsx | 2 +- .../deployedModels/DeployedModelsCard.tsx | 2 +- 6 files changed, 153 insertions(+), 63 deletions(-) diff --git a/frontend/src/concepts/design/HeaderIcon.tsx b/frontend/src/concepts/design/HeaderIcon.tsx index ea06063114..e1c720c439 100644 --- a/frontend/src/concepts/design/HeaderIcon.tsx +++ b/frontend/src/concepts/design/HeaderIcon.tsx @@ -4,6 +4,8 @@ import { typedBackgroundColor, ProjectObjectType, SectionType, + sectionTypeIconColor, + typedIconColor, } from '~/concepts/design/utils'; import TypedObjectIcon from '~/concepts/design/TypedObjectIcon'; @@ -32,6 +34,7 @@ const HeaderIcon: React.FC = ({ background: sectionType ? sectionTypeBackgroundColor(sectionType) : typedBackgroundColor(type), + color: sectionType ? sectionTypeIconColor(sectionType) : typedIconColor(type), }} > = ({ direction={{ default: isOpen ? 'column' : 'row' }} alignItems={{ default: isOpen ? 'alignItemsFlexStart' : 'alignItemsCenter' }} > - - + + {onClick ? (