diff --git a/backend/src/routes/api/components/list.ts b/backend/src/routes/api/components/list.ts index d208617a7f..fad891d0ad 100644 --- a/backend/src/routes/api/components/list.ts +++ b/backend/src/routes/api/components/list.ts @@ -1,6 +1,10 @@ import { FastifyRequest } from 'fastify'; import { KubeFastifyInstance, OdhApplication } from '../../../types'; -import { getApplications, updateApplications } from '../../../utils/resourceUtils'; +import { + getApplications, + updateApplications, + isIntegrationApp, +} from '../../../utils/resourceUtils'; import { checkJupyterEnabled, getRouteForApplication } from '../../../utils/componentUtils'; export const listComponents = async ( @@ -17,7 +21,10 @@ export const listComponents = async ( return Promise.resolve(applications); } for (const app of applications) { - if (app.spec.shownOnEnabledPage) { + if (isIntegrationApp(app)) { + // Include all integration apps -- Client can check if it's enabled + installedComponents.push(app); + } else if (app.spec.shownOnEnabledPage) { const newApp = { ...app, spec: { diff --git a/backend/src/routes/api/integrations/nim/index.ts b/backend/src/routes/api/integrations/nim/index.ts new file mode 100644 index 0000000000..73c08f9628 --- /dev/null +++ b/backend/src/routes/api/integrations/nim/index.ts @@ -0,0 +1,97 @@ +import { FastifyReply, FastifyRequest } from 'fastify'; +import { secureAdminRoute } from '../../../../utils/route-security'; +import { KubeFastifyInstance } from '../../../../types'; +import { isString } from 'lodash'; +import { createNIMAccount, createNIMSecret, getNIMAccount, isAppEnabled } from './nimUtils'; + +module.exports = async (fastify: KubeFastifyInstance) => { + const { namespace } = fastify.kube; + const PAGE_NOT_FOUND_MESSAGE = '404 page not found'; + + fastify.get( + '/', + secureAdminRoute(fastify)(async (request: FastifyRequest, reply: FastifyReply) => { + await getNIMAccount(fastify, namespace) + .then((response) => { + if (response) { + // Installed + const isEnabled = isAppEnabled(response); + reply.send({ isInstalled: true, isEnabled: isEnabled, canInstall: false, error: '' }); + } else { + // Not installed + fastify.log.info(`NIM account does not exist`); + reply.send({ isInstalled: false, isEnabled: false, canInstall: true, error: '' }); + } + }) + .catch((e) => { + if (e.response?.statusCode === 404) { + // 404 error means the Account CRD does not exist, so cannot create CR based on it. + if ( + isString(e.response.body) && + e.response.body.trim() === PAGE_NOT_FOUND_MESSAGE.trim() + ) { + fastify.log.info(`NIM not installed, ${e.response?.body}`); + reply.send({ + isInstalled: false, + isEnabled: false, + canInstall: false, + error: 'NIM not installed', + }); + } + } else { + fastify.log.error(`An unexpected error occurred: ${e.message || e}`); + reply.send({ + isInstalled: false, + isAppEnabled: false, + canInstall: false, + error: 'An unexpected error occurred. Please try again later.', + }); + } + }); + }), + ); + + fastify.post( + '/', + secureAdminRoute(fastify)( + async ( + request: FastifyRequest<{ + Body: { [key: string]: string }; + }>, + reply: FastifyReply, + ) => { + const enableValues = request.body; + + await createNIMSecret(fastify, namespace, enableValues) + .then(async () => { + await createNIMAccount(fastify, namespace) + .then((response) => { + const isEnabled = isAppEnabled(response); + reply.send({ + isInstalled: true, + isEnabled: isEnabled, + canInstall: false, + error: '', + }); + }) + .catch((e) => { + const message = `Failed to create NIM account, ${e.response?.body?.message}`; + fastify.log.error(message); + reply.status(e.response.statusCode).send(new Error(message)); + }); + }) + .catch((e) => { + if (e.response?.statusCode === 409) { + fastify.log.error(`NIM secret already exists, skipping creation.`); + reply.status(409).send(new Error(`NIM secret already exists, skipping creation.`)); + } else { + fastify.log.error(`Failed to create NIM secret. ${e.response?.body?.message}`); + reply + .status(e.response.statusCode) + .send(new Error(`Failed to create NIM secret, ${e.response?.body?.message}`)); + } + }); + }, + ), + ); +}; diff --git a/backend/src/routes/api/integrations/nim/nimUtils.ts b/backend/src/routes/api/integrations/nim/nimUtils.ts new file mode 100644 index 0000000000..afc04c3b12 --- /dev/null +++ b/backend/src/routes/api/integrations/nim/nimUtils.ts @@ -0,0 +1,91 @@ +import { KubeFastifyInstance, NIMAccountKind, SecretKind } from '../../../../types'; + +const NIM_SECRET_NAME = 'nvidia-nim-access'; +const NIM_ACCOUNT_NAME = 'odh-nim-account'; + +export const isAppEnabled = (app: NIMAccountKind): boolean => { + const conditions = app?.status?.conditions || []; + return ( + conditions.find( + (condition) => condition.type === 'AccountStatus' && condition.status === 'True', + ) !== undefined + ); +}; + +export const getNIMAccount = async ( + fastify: KubeFastifyInstance, + namespace: string, +): Promise => { + const { customObjectsApi } = fastify.kube; + try { + const response = await customObjectsApi.listNamespacedCustomObject( + 'nim.opendatahub.io', + 'v1', + namespace, + 'accounts', + ); + // Get the list of accounts from the response + const accounts = response.body as { + items: NIMAccountKind[]; + }; + + return accounts.items[0] || undefined; + } catch (e) { + return Promise.reject(e); + } +}; + +export const createNIMAccount = async ( + fastify: KubeFastifyInstance, + namespace: string, +): Promise => { + const { customObjectsApi } = fastify.kube; + const account = { + apiVersion: 'nim.opendatahub.io/v1', + kind: 'Account', + metadata: { + name: NIM_ACCOUNT_NAME, + namespace, + labels: { + 'opendatahub.io/managed': 'true', + }, + }, + spec: { + apiKeySecret: { + name: NIM_SECRET_NAME, + }, + }, + }; + const response = await customObjectsApi.createNamespacedCustomObject( + 'nim.opendatahub.io', + 'v1', + namespace, + 'accounts', + account, + ); + return Promise.resolve(response.body as NIMAccountKind); +}; + +export const createNIMSecret = async ( + fastify: KubeFastifyInstance, + namespace: string, + enableValues: { [key: string]: string }, +): Promise => { + const { coreV1Api } = fastify.kube; + const nimSecret = { + apiVersion: 'v1', + kind: 'Secret', + metadata: { + name: NIM_SECRET_NAME, + namespace, + labels: { + 'opendatahub.io/managed': 'true', + }, + }, + type: 'Opaque', + stringData: enableValues, + }; + + const response = await coreV1Api.createNamespacedSecret(namespace, nimSecret); + return Promise.resolve(response.body as SecretKind); +}; diff --git a/backend/src/types.ts b/backend/src/types.ts index 3024ff6fbe..c4dd1e6cb4 100644 --- a/backend/src/types.ts +++ b/backend/src/types.ts @@ -326,6 +326,7 @@ export type OdhApplication = { displayName: string; docsLink: string; hidden?: boolean | null; + internalRoute?: string; enable?: { actionLabel: string; description?: string; @@ -1228,3 +1229,27 @@ export enum ServiceAddressAnnotation { EXTERNAL_REST = 'routing.opendatahub.io/external-address-rest', EXTERNAL_GRPC = 'routing.opendatahub.io/external-address-grpc', } + +export type NIMAccountKind = K8sResourceCommon & { + metadata: { + name: string; + namespace: string; + }; + spec: { + apiKeySecret: { + name: string; + }; + }; + status?: { + nimConfig?: { + name: string; + }; + runtimeTemplate?: { + name: string; + }; + nimPullSecret?: { + name: string; + }; + conditions?: K8sCondition[]; + }; +}; diff --git a/backend/src/utils/resourceUtils.ts b/backend/src/utils/resourceUtils.ts index c65703547e..f503c0182c 100644 --- a/backend/src/utils/resourceUtils.ts +++ b/backend/src/utils/resourceUtils.ts @@ -332,33 +332,38 @@ export const fetchApplications = async ( .then((result) => result.body) .catch(() => null); for (const appDef of applicationDefs) { - appDef.spec.shownOnEnabledPage = enabledAppsCM?.data?.[appDef.metadata.name] === 'true'; - appDef.spec.isEnabled = await getIsAppEnabled(fastify, appDef).catch((e) => { - fastify.log.warn( - `"${ - appDef.metadata.name - }" OdhApplication is being disabled due to an error determining if it's enabled. ${ - e.response?.body?.message || e.message - }`, - ); + if (isIntegrationApp(appDef)) { + // Ignore logic for apps that use internal routes for status information + applications.push(appDef); + } else { + appDef.spec.shownOnEnabledPage = enabledAppsCM?.data?.[appDef.metadata.name] === 'true'; + appDef.spec.isEnabled = await getIsAppEnabled(fastify, appDef).catch((e) => { + fastify.log.warn( + `"${ + appDef.metadata.name + }" OdhApplication is being disabled due to an error determining if it's enabled. ${ + e.response?.body?.message || e.message + }`, + ); - return false; - }); - if (appDef.spec.isEnabled) { - if (!appDef.spec.shownOnEnabledPage) { - changed = true; - enabledAppsCMData[appDef.metadata.name] = 'true'; - appDef.spec.shownOnEnabledPage = true; + return false; + }); + if (appDef.spec.isEnabled) { + if (!appDef.spec.shownOnEnabledPage) { + changed = true; + enabledAppsCMData[appDef.metadata.name] = 'true'; + appDef.spec.shownOnEnabledPage = true; + } } + applications.push({ + ...appDef, + spec: { + ...appDef.spec, + getStartedLink: getRouteForClusterId(fastify, appDef.spec.getStartedLink), + link: appDef.spec.isEnabled ? await getRouteForApplication(fastify, appDef) : undefined, + }, + }); } - applications.push({ - ...appDef, - spec: { - ...appDef.spec, - getStartedLink: getRouteForClusterId(fastify, appDef.spec.getStartedLink), - link: appDef.spec.isEnabled ? await getRouteForApplication(fastify, appDef) : undefined, - }, - }); } if (changed) { // write enabled apps configmap @@ -1037,3 +1042,5 @@ export const translateDisplayNameForK8s = (name: string): string => .toLowerCase() .replace(/\s/g, '-') .replace(/[^A-Za-z0-9-]/g, ''); +export const isIntegrationApp = (app: OdhApplication): boolean => + app.spec.internalRoute?.startsWith('/api/'); diff --git a/frontend/src/api/k8s/__tests__/pvcs.spec.ts b/frontend/src/api/k8s/__tests__/pvcs.spec.ts index b645fce99b..170de41389 100644 --- a/frontend/src/api/k8s/__tests__/pvcs.spec.ts +++ b/frontend/src/api/k8s/__tests__/pvcs.spec.ts @@ -1,13 +1,21 @@ import { - K8sStatus, k8sCreateResource, k8sDeleteResource, + k8sGetResource, k8sListResourceItems, + K8sStatus, k8sUpdateResource, } from '@openshift/dynamic-plugin-sdk-utils'; import { mock200Status, mock404Error } from '~/__mocks__/mockK8sStatus'; import { mockPVCK8sResource } from '~/__mocks__/mockPVCK8sResource'; -import { assemblePvc, createPvc, deletePvc, getDashboardPvcs, updatePvc } from '~/api/k8s/pvcs'; +import { + assemblePvc, + createPvc, + deletePvc, + getDashboardPvcs, + getPvc, + updatePvc, +} from '~/api/k8s/pvcs'; import { PVCModel } from '~/api/models/k8s'; import { PersistentVolumeClaimKind } from '~/k8sTypes'; import { CreatingStorageObject } from '~/pages/projects/types'; @@ -25,6 +33,7 @@ const k8sListResourceItemsMock = jest.mocked(k8sListResourceItems); const k8sUpdateResourceMock = jest.mocked(k8sUpdateResource); const k8sDeleteResourceMock = jest.mocked(k8sDeleteResource); +const k8sGetResourceMock = jest.mocked(k8sGetResource); const data: CreatingStorageObject = { nameDesc: { @@ -193,3 +202,30 @@ describe('deletePvc', () => { }); }); }); + +describe('getPvc', () => { + it('should fetch and return PVC', async () => { + k8sGetResourceMock.mockResolvedValue(pvcMock); + const result = await getPvc('projectName', 'pvcName'); + + expect(k8sGetResourceMock).toHaveBeenCalledWith({ + fetchOptions: { requestInit: {} }, + model: PVCModel, + queryOptions: { name: 'pvcName', ns: 'projectName', queryParams: {} }, + }); + expect(k8sGetResourceMock).toHaveBeenCalledTimes(1); + expect(result).toStrictEqual(pvcMock); + }); + + it('should handle errors and rethrow', async () => { + k8sGetResourceMock.mockRejectedValue(new Error('error1')); + + await expect(getPvc('projectName', 'pvcName')).rejects.toThrow('error1'); + expect(k8sGetResourceMock).toHaveBeenCalledTimes(1); + expect(k8sGetResourceMock).toHaveBeenCalledWith({ + fetchOptions: { requestInit: {} }, + model: PVCModel, + queryOptions: { name: 'pvcName', ns: 'projectName', queryParams: {} }, + }); + }); +}); diff --git a/frontend/src/concepts/areas/const.ts b/frontend/src/concepts/areas/const.ts index 55765f87fe..8790cc76c7 100644 --- a/frontend/src/concepts/areas/const.ts +++ b/frontend/src/concepts/areas/const.ts @@ -122,5 +122,6 @@ export const SupportedAreasStateMap: SupportedAreasState = { }, [SupportedArea.NIM_MODEL]: { featureFlags: ['disableNIMModelServing'], + reliantAreas: [SupportedArea.K_SERVE], }, }; diff --git a/frontend/src/pages/enabledApplications/EnabledApplications.tsx b/frontend/src/pages/enabledApplications/EnabledApplications.tsx index 8f461870ff..53242b3967 100644 --- a/frontend/src/pages/enabledApplications/EnabledApplications.tsx +++ b/frontend/src/pages/enabledApplications/EnabledApplications.tsx @@ -6,6 +6,7 @@ import { OdhApplication } from '~/types'; import ApplicationsPage from '~/pages/ApplicationsPage'; import OdhAppCard from '~/components/OdhAppCard'; import { fireMiscTrackingEvent } from '~/concepts/analyticsTracking/segmentIOUtils'; +import { useWatchIntegrationComponents } from '~/utilities/useWatchIntegrationComponents'; const description = `Launch your enabled applications, view documentation, or get started with quick start instructions and tasks.`; @@ -21,18 +22,21 @@ let enabledComponents: OdhApplication[] = []; export const EnabledApplicationsInner: React.FC = React.memo( ({ loaded, loadError, components }) => { const isEmpty = components.length === 0; + const { checkedComponents, isIntegrationComponentsChecked } = useWatchIntegrationComponents( + loaded ? components : undefined, + ); return ( - {components.map((c) => ( + {checkedComponents.map((c) => ( ))} diff --git a/frontend/src/pages/exploreApplication/EnableModal.tsx b/frontend/src/pages/exploreApplication/EnableModal.tsx index 39016cdd5c..bff4f5a1f2 100644 --- a/frontend/src/pages/exploreApplication/EnableModal.tsx +++ b/frontend/src/pages/exploreApplication/EnableModal.tsx @@ -31,6 +31,7 @@ const EnableModal: React.FC = ({ selectedApp, shown, onClose } selectedApp.metadata.name, selectedApp.spec.displayName, enableValues, + selectedApp.spec.internalRoute, ); const focusRef = (element: HTMLElement | null) => { if (element) { diff --git a/frontend/src/pages/exploreApplication/GetStartedPanel.tsx b/frontend/src/pages/exploreApplication/GetStartedPanel.tsx index 0a02c7505d..6f9da12fa0 100644 --- a/frontend/src/pages/exploreApplication/GetStartedPanel.tsx +++ b/frontend/src/pages/exploreApplication/GetStartedPanel.tsx @@ -1,19 +1,19 @@ import * as React from 'react'; import { + ActionList, + ActionListItem, Alert, Button, ButtonVariant, - DrawerPanelBody, - DrawerHead, - DrawerPanelContent, + Divider, DrawerActions, DrawerCloseButton, - Tooltip, + DrawerHead, + DrawerPanelBody, + DrawerPanelContent, Text, TextContent, - ActionList, - ActionListItem, - Divider, + Tooltip, } from '@patternfly/react-core'; import { ExternalLinkAltIcon } from '@patternfly/react-icons'; import { OdhApplication } from '~/types'; @@ -21,6 +21,7 @@ import MarkdownView from '~/components/MarkdownView'; import { markdownConverter } from '~/utilities/markdown'; import { useAppContext } from '~/app/AppContext'; import { fireMiscTrackingEvent } from '~/concepts/analyticsTracking/segmentIOUtils'; +import { useIntegratedAppStatus } from '~/pages/exploreApplication/useIntegratedAppStatus'; const DEFAULT_BETA_TEXT = 'This application is available for early access prior to official ' + @@ -36,16 +37,23 @@ type GetStartedPanelProps = { const GetStartedPanel: React.FC = ({ selectedApp, onClose, onEnable }) => { const { dashboardConfig } = useAppContext(); const { enablement } = dashboardConfig.spec.dashboardConfig; + const [{ isInstalled, canInstall, error }, loaded] = useIntegratedAppStatus(selectedApp); + if (!selectedApp) { return null; } const renderEnableButton = () => { - if (!selectedApp.spec.enable || selectedApp.spec.isEnabled) { + if (!selectedApp.spec.enable || selectedApp.spec.isEnabled || isInstalled) { return null; } const button = ( - ); diff --git a/frontend/src/pages/exploreApplication/useIntegratedAppStatus.ts b/frontend/src/pages/exploreApplication/useIntegratedAppStatus.ts new file mode 100644 index 0000000000..3fa6b13773 --- /dev/null +++ b/frontend/src/pages/exploreApplication/useIntegratedAppStatus.ts @@ -0,0 +1,35 @@ +import * as React from 'react'; +import { IntegrationAppStatus, OdhApplication } from '~/types'; +import useFetchState, { FetchState, NotReadyError } from '~/utilities/useFetchState'; +import { getIntegrationAppEnablementStatus } from '~/services/integrationAppService'; +import { isIntegrationApp } from '~/utilities/utils'; + +export const useIntegratedAppStatus = (app?: OdhApplication): FetchState => { + const callback = React.useCallback(() => { + if (!app) { + return Promise.reject(new NotReadyError('Need an app to check')); + } + if (!isIntegrationApp(app)) { + // Silently ignore apps who aren't an integration app -- the logic is not needed + return Promise.resolve({ + isInstalled: false, + isEnabled: false, + canInstall: true, + error: '', + }); + } + + return getIntegrationAppEnablementStatus(app.spec.internalRoute); + }, [app]); + + return useFetchState( + callback, + { + isInstalled: false, + isEnabled: false, + canInstall: false, + error: '', + }, + { initialPromisePurity: true }, + ); +}; diff --git a/frontend/src/pages/modelServing/screens/global/DeleteInferenceServiceModal.tsx b/frontend/src/pages/modelServing/screens/global/DeleteInferenceServiceModal.tsx index 1778dece22..d95ada9677 100644 --- a/frontend/src/pages/modelServing/screens/global/DeleteInferenceServiceModal.tsx +++ b/frontend/src/pages/modelServing/screens/global/DeleteInferenceServiceModal.tsx @@ -1,10 +1,13 @@ import * as React from 'react'; import DeleteModal from '~/pages/projects/components/DeleteModal'; import { InferenceServiceKind, ServingRuntimeKind } from '~/k8sTypes'; -import { deleteInferenceService, deletePvc, deleteSecret, deleteServingRuntime } from '~/api'; +import { deleteInferenceService, deleteServingRuntime } from '~/api'; import { getDisplayNameFromK8sResource } from '~/concepts/k8s/utils'; import { byName, ProjectsContext } from '~/concepts/projects/ProjectsContext'; -import { isProjectNIMSupported } from '~/pages/modelServing/screens/projects/nimUtils'; +import { + getNIMResourcesToDelete, + isProjectNIMSupported, +} from '~/pages/modelServing/screens/projects/nimUtils'; type DeleteInferenceServiceModalProps = { inferenceService?: InferenceServiceKind; @@ -34,62 +37,43 @@ const DeleteInferenceServiceModal: React.FC = ? getDisplayNameFromK8sResource(inferenceService) : 'this deployed model'; + const onDelete = async () => { + if (!inferenceService) { + return; + } + + setIsDeleting(true); + try { + const nimResourcesToDelete = + isKServeNIMEnabled && project && servingRuntime + ? await getNIMResourcesToDelete(project.metadata.name, servingRuntime) + : []; + + await Promise.all([ + deleteInferenceService(inferenceService.metadata.name, inferenceService.metadata.namespace), + ...(servingRuntime + ? [deleteServingRuntime(servingRuntime.metadata.name, servingRuntime.metadata.namespace)] + : []), + ...nimResourcesToDelete, + ]); + + onBeforeClose(true); + } catch (e: unknown) { + if (e instanceof Error) { + setError(e); + } else { + setError(new Error('An unknown error occurred')); + } + setIsDeleting(false); + } + }; + return ( onBeforeClose(false)} submitButtonLabel="Delete deployed model" - onDelete={() => { - if (inferenceService) { - setIsDeleting(true); - const pvcName = servingRuntime?.spec.volumes?.find( - (vol) => vol.persistentVolumeClaim?.claimName, - )?.persistentVolumeClaim?.claimName; - const containerWithEnv = servingRuntime?.spec.containers.find( - (container) => - container.env && container.env.some((env) => env.valueFrom?.secretKeyRef?.name), - ); - const nimSecretName = containerWithEnv?.env?.find( - (env) => env.valueFrom?.secretKeyRef?.name, - )?.valueFrom?.secretKeyRef?.name; - const imagePullSecretName = servingRuntime?.spec.imagePullSecrets?.[0]?.name ?? ''; - Promise.all([ - deleteInferenceService( - inferenceService.metadata.name, - inferenceService.metadata.namespace, - ), - ...(servingRuntime - ? [ - deleteServingRuntime( - servingRuntime.metadata.name, - servingRuntime.metadata.namespace, - ), - ] - : []), - ...(isKServeNIMEnabled && pvcName - ? [deletePvc(pvcName, inferenceService.metadata.namespace)] - : []), - ...(isKServeNIMEnabled && - project && - nimSecretName && - nimSecretName.length > 0 && - imagePullSecretName.length > 0 - ? [ - deleteSecret(project.metadata.name, nimSecretName), - deleteSecret(project.metadata.name, imagePullSecretName), - ] - : []), - ]) - - .then(() => { - onBeforeClose(true); - }) - .catch((e) => { - setError(e); - setIsDeleting(false); - }); - } - }} + onDelete={onDelete} deleting={isDeleting} error={error} deleteName={displayName} @@ -98,5 +82,4 @@ const DeleteInferenceServiceModal: React.FC = ); }; - export default DeleteInferenceServiceModal; diff --git a/frontend/src/pages/modelServing/screens/global/EmptyModelServing.tsx b/frontend/src/pages/modelServing/screens/global/EmptyModelServing.tsx index 510b20d254..89af0d014f 100644 --- a/frontend/src/pages/modelServing/screens/global/EmptyModelServing.tsx +++ b/frontend/src/pages/modelServing/screens/global/EmptyModelServing.tsx @@ -2,15 +2,12 @@ import * as React from 'react'; import { Button } from '@patternfly/react-core'; import { useNavigate } from 'react-router-dom'; import { ModelServingContext } from '~/pages/modelServing/ModelServingContext'; -import { getProjectModelServingPlatform } from '~/pages/modelServing/screens/projects/utils'; -import { ServingRuntimePlatform } from '~/types'; import useServingPlatformStatuses from '~/pages/modelServing/useServingPlatformStatuses'; import EmptyDetailsView from '~/components/EmptyDetailsView'; import { ProjectObjectType, typedEmptyImage } from '~/concepts/design/utils'; import { ProjectSectionID } from '~/pages/projects/screens/detail/types'; import ServeModelButton from '~/pages/modelServing/screens/global/ServeModelButton'; import { getDisplayNameFromK8sResource } from '~/concepts/k8s/utils'; -import { isProjectNIMSupported } from '~/pages/modelServing/screens/projects/nimUtils'; const EmptyModelServing: React.FC = () => { const navigate = useNavigate(); @@ -19,14 +16,9 @@ const EmptyModelServing: React.FC = () => { project, } = React.useContext(ModelServingContext); const servingPlatformStatuses = useServingPlatformStatuses(); - const isKServeNIMEnabled = project ? isProjectNIMSupported(project) : false; - if ( - (getProjectModelServingPlatform(project, servingPlatformStatuses).platform !== - ServingRuntimePlatform.SINGLE || - isKServeNIMEnabled) && - servingRuntimes.length === 0 - ) { + if (servingPlatformStatuses.modelMesh.enabled && servingRuntimes.length === 0) { + // Server needed -- must deploy from the project return ( { const deployingFromRegistry = !!(modelRegistryName && registeredModelId && modelVersionId); const servingPlatformStatuses = useServingPlatformStatuses(); - const { - kServe: { enabled: kServeEnabled }, - modelMesh: { enabled: modelMeshEnabled }, - nim: { available: isNIMAvailable }, - numServingPlatformsAvailable, - } = servingPlatformStatuses; + const kServeEnabled = servingPlatformStatuses.kServe.enabled; + const isNIMAvailable = servingPlatformStatuses.kServeNIM.enabled; + const modelMeshEnabled = servingPlatformStatuses.modelMesh.enabled; const { servingRuntimes: { @@ -100,8 +97,7 @@ const ModelServingPlatform: React.FC = () => { getProjectModelServingPlatform(currentProject, servingPlatformStatuses); const shouldShowPlatformSelection = - ((kServeEnabled && modelMeshEnabled) || (!kServeEnabled && !modelMeshEnabled)) && - !currentProjectServingPlatform; + servingPlatformStatuses.platformEnabledCount !== 1 && !currentProjectServingPlatform; const isProjectModelMesh = currentProjectServingPlatform === ServingRuntimePlatform.MULTI; @@ -222,23 +218,19 @@ const ModelServingPlatform: React.FC = () => { shouldShowPlatformSelection || platformError || emptyModelServer ? undefined : [ - ...(!isKServeNIMEnabled - ? [ - { - setPlatformSelected( - isProjectModelMesh - ? ServingRuntimePlatform.MULTI - : ServingRuntimePlatform.SINGLE, - ); - }} - key="serving-runtime-actions" - />, - ] - : []), + { + setPlatformSelected( + isProjectModelMesh + ? ServingRuntimePlatform.MULTI + : ServingRuntimePlatform.SINGLE, + ); + }} + key="serving-runtime-actions" + />, ] } description={ @@ -263,7 +255,7 @@ const ModelServingPlatform: React.FC = () => { isEmpty={shouldShowPlatformSelection} loadError={platformError || servingRuntimeError || templateError} emptyState={ - kServeEnabled && modelMeshEnabled ? ( + servingPlatformStatuses.platformEnabledCount > 1 ? ( { - - - - - - + {kServeEnabled && ( + + + + )} + {modelMeshEnabled && ( + + + + )} {isNIMAvailable && ( { ? 'Multi-model serving enabled' : 'Single-model serving enabled'} - {emptyModelServer && numServingPlatformsAvailable > 1 && ( + {emptyModelServer && servingPlatformStatuses.platformEnabledCount > 1 && ( = ({ } }, [dashboardNamespace, editInfo]); + const isSecretNeeded = async (ns: string, secretName: string): Promise => { + try { + await getSecret(ns, secretName); + return false; // Secret exists, no need to create + } catch { + return true; // Secret does not exist, needs to be created + } + }; + const onBeforeClose = (submitted: boolean) => { onClose(submitted); setError(undefined); @@ -239,14 +248,21 @@ const ManageNIMServingModal: React.FC = ({ submitServingRuntimeResources({ dryRun: false }).then(() => undefined), submitInferenceServiceResource({ dryRun: false }).then(() => undefined), ]; + if (!editInfo) { - promises.push( - createNIMSecret(namespace, NIM_SECRET_NAME, false, false).then(() => undefined), - createNIMSecret(namespace, NIM_NGC_SECRET_NAME, true, false).then(() => undefined), - createNIMPVC(namespace, nimPVCName, pvcSize, false).then(() => undefined), - ); + if (await isSecretNeeded(namespace, NIM_SECRET_NAME)) { + promises.push( + createNIMSecret(namespace, NIM_SECRET_NAME, false, false).then(() => undefined), + ); + } + if (await isSecretNeeded(namespace, NIM_NGC_SECRET_NAME)) { + promises.push( + createNIMSecret(namespace, NIM_NGC_SECRET_NAME, true, false).then(() => undefined), + ); + } + promises.push(createNIMPVC(namespace, nimPVCName, pvcSize, false).then(() => undefined)); } else if (pvc && pvc.spec.resources.requests.storage !== pvcSize) { - const createData: CreatingStorageObject = { + const updatePvcData: CreatingStorageObject = { size: pvcSize, // New size nameDesc: { name: pvc.metadata.name, @@ -255,7 +271,7 @@ const ManageNIMServingModal: React.FC = ({ storageClassName: pvc.spec.storageClassName, }; promises.push( - updatePvc(createData, pvc, namespace, { dryRun: false }).then(() => undefined), + updatePvc(updatePvcData, pvc, namespace, { dryRun: false }).then(() => undefined), ); } return Promise.all(promises); diff --git a/frontend/src/pages/modelServing/screens/projects/__tests__/utils.spec.ts b/frontend/src/pages/modelServing/screens/projects/__tests__/utils.spec.ts index 1cad3ac2bf..e49c46d2db 100644 --- a/frontend/src/pages/modelServing/screens/projects/__tests__/utils.spec.ts +++ b/frontend/src/pages/modelServing/screens/projects/__tests__/utils.spec.ts @@ -25,6 +25,7 @@ jest.mock('~/api', () => ({ getSecret: jest.fn(), createSecret: jest.fn(), createPvc: jest.fn(), + getInferenceServiceContext: jest.fn(), })); jest.mock('~/pages/modelServing/screens/projects/nimUtils', () => ({ @@ -66,21 +67,22 @@ const getMockServingPlatformStatuses = ({ kServeInstalled = true, modelMeshEnabled = true, modelMeshInstalled = true, - nimAvailable = true, + nimEnabled = false, + nimInstalled = false, }): ServingPlatformStatuses => ({ kServe: { enabled: kServeEnabled, installed: kServeInstalled, }, + kServeNIM: { + enabled: nimEnabled, + installed: nimInstalled, + }, modelMesh: { enabled: modelMeshEnabled, installed: modelMeshInstalled, }, - nim: { - available: nimAvailable, - }, - numServingPlatformsAvailable: [kServeEnabled, modelMeshEnabled, nimAvailable].filter(Boolean) - .length, + platformEnabledCount: [kServeEnabled, nimEnabled, modelMeshEnabled].filter(Boolean).length, }); describe('getProjectModelServingPlatform', () => { diff --git a/frontend/src/pages/modelServing/screens/projects/nimUtils.ts b/frontend/src/pages/modelServing/screens/projects/nimUtils.ts index 79a5160b05..4ce9c55ee9 100644 --- a/frontend/src/pages/modelServing/screens/projects/nimUtils.ts +++ b/frontend/src/pages/modelServing/screens/projects/nimUtils.ts @@ -2,7 +2,8 @@ import { K8sResourceCommon } from '@openshift/dynamic-plugin-sdk-utils'; import { ProjectKind, SecretKind, ServingRuntimeKind, TemplateKind } from '~/k8sTypes'; -import { getTemplate } from '~/api'; +import { deletePvc, deleteSecret, getTemplate } from '~/api'; +import { fetchInferenceServiceCount } from '~/pages/modelServing/screens/projects/utils'; const NIM_SECRET_NAME = 'nvidia-nim-access'; const NIM_NGC_SECRET_NAME = 'nvidia-nim-image-pull'; @@ -107,7 +108,7 @@ export const updateServingRuntimeTemplate = ( if (updatedServingRuntime.spec.volumes) { const updatedVolumes = updatedServingRuntime.spec.volumes.map((volume) => { - if (volume.name === 'nim-pvc') { + if (volume.name.startsWith('nim-pvc')) { return { ...volume, name: pvcName, @@ -123,3 +124,62 @@ export const updateServingRuntimeTemplate = ( } return updatedServingRuntime; }; + +export const getNIMResourcesToDelete = async ( + projectName: string, + servingRuntime: ServingRuntimeKind, +): Promise[]> => { + const resourcesToDelete: Promise[] = []; + + let inferenceCount = 0; + + try { + inferenceCount = await fetchInferenceServiceCount(projectName); + } catch (error) { + if (error instanceof Error) { + // eslint-disable-next-line no-console + console.error( + `Failed to fetch inference service count for project "${projectName}": ${error.message}`, + ); + } else { + // eslint-disable-next-line no-console + console.error( + `Failed to fetch inference service count for project "${projectName}": ${error}`, + ); + } + } + + const pvcName = servingRuntime.spec.volumes?.find((vol) => + vol.persistentVolumeClaim?.claimName.startsWith('nim-pvc'), + )?.persistentVolumeClaim?.claimName; + + if (pvcName) { + resourcesToDelete.push(deletePvc(pvcName, projectName).then(() => undefined)); + } + + let nimSecretName: string | undefined; + let imagePullSecretName: string | undefined; + + const pullNGCSecret = servingRuntime.spec.imagePullSecrets?.[0]?.name ?? ''; + if (pullNGCSecret === 'ngc-secret') { + imagePullSecretName = pullNGCSecret; + } + + servingRuntime.spec.containers.forEach((container) => { + container.env?.forEach((env) => { + const secretName = env.valueFrom?.secretKeyRef?.name; + if (secretName === 'nvidia-nim-secrets') { + nimSecretName = secretName; + } + }); + }); + + if (nimSecretName && imagePullSecretName && inferenceCount === 1) { + resourcesToDelete.push( + deleteSecret(projectName, nimSecretName).then(() => undefined), + deleteSecret(projectName, imagePullSecretName).then(() => undefined), + ); + } + + return resourcesToDelete; +}; diff --git a/frontend/src/pages/modelServing/screens/projects/useIsNIMAvailable.ts b/frontend/src/pages/modelServing/screens/projects/useIsNIMAvailable.ts index 244e55b466..bceb64b93f 100644 --- a/frontend/src/pages/modelServing/screens/projects/useIsNIMAvailable.ts +++ b/frontend/src/pages/modelServing/screens/projects/useIsNIMAvailable.ts @@ -1,8 +1,10 @@ import { useEffect, useState } from 'react'; import { SupportedArea, useIsAreaAvailable } from '~/concepts/areas'; import { isNIMServingRuntimeTemplateAvailable } from '~/pages/modelServing/screens/projects/nimUtils'; +import { useDashboardNamespace } from '~/redux/selectors'; -export const useIsNIMAvailable = (dashboardNamespace: string): boolean => { +export const useIsNIMAvailable = (): boolean => { + const { dashboardNamespace } = useDashboardNamespace(); const [isNIMAvailable, setIsNIMAvailable] = useState(false); const isNIMModelServingAvailable = useIsAreaAvailable(SupportedArea.NIM_MODEL).status; diff --git a/frontend/src/pages/modelServing/screens/projects/utils.ts b/frontend/src/pages/modelServing/screens/projects/utils.ts index ea1e7410b3..88c5a1d2fb 100644 --- a/frontend/src/pages/modelServing/screens/projects/utils.ts +++ b/frontend/src/pages/modelServing/screens/projects/utils.ts @@ -41,6 +41,7 @@ import { createPvc, createSecret, createServingRuntime, + getInferenceServiceContext, updateInferenceService, updateServingRuntime, } from '~/api'; @@ -297,13 +298,19 @@ export const getProjectModelServingPlatform = ( ): { platform?: ServingRuntimePlatform; error?: Error } => { const { kServe: { enabled: kServeEnabled, installed: kServeInstalled }, + kServeNIM: { enabled: nimEnabled }, modelMesh: { enabled: modelMeshEnabled, installed: modelMeshInstalled }, + platformEnabledCount, } = platformStatuses; + if (!project) { + // Likely temporary or a bad usage of the hook return {}; } + if (project.metadata.labels?.[KnownLabels.MODEL_SERVING_PROJECT] === undefined) { - if ((kServeEnabled && modelMeshEnabled) || (!kServeEnabled && !modelMeshEnabled)) { + // Auto-select logic + if (platformEnabledCount !== 1) { return {}; } if (modelMeshEnabled) { @@ -312,13 +319,21 @@ export const getProjectModelServingPlatform = ( if (kServeEnabled) { return { platform: ServingRuntimePlatform.SINGLE }; } - } - if (project.metadata.labels?.[KnownLabels.MODEL_SERVING_PROJECT] === 'true') { + if (nimEnabled) { + // TODO: this is weird, it relies on KServe today... so it's never "only installed" + return { platform: ServingRuntimePlatform.SINGLE }; + } + + // TODO: unreachable code unless adding a new platform? probably should throw an error + } else if (project.metadata.labels[KnownLabels.MODEL_SERVING_PROJECT] === 'true') { + // Model mesh logic return { platform: ServingRuntimePlatform.MULTI, error: modelMeshInstalled ? undefined : new Error('Multi-model platform is not installed'), }; } + + // KServe logic return { platform: ServingRuntimePlatform.SINGLE, error: kServeInstalled ? undefined : new Error('Single-model platform is not installed'), @@ -709,3 +724,16 @@ export const getCreateInferenceServiceLabels = ( export const isConnectionPathValid = (path: string): boolean => !(containsOnlySlashes(path) || !isS3PathValid(path) || path === ''); + +export const fetchInferenceServiceCount = async (namespace: string): Promise => { + try { + const inferenceServices = await getInferenceServiceContext(namespace); + return inferenceServices.length; + } catch (error) { + throw new Error( + `Failed to fetch inference services for namespace "${namespace}": ${ + error instanceof Error ? error.message : error + }`, + ); + } +}; diff --git a/frontend/src/pages/modelServing/screens/types.ts b/frontend/src/pages/modelServing/screens/types.ts index f22d2b6088..d309e69790 100644 --- a/frontend/src/pages/modelServing/screens/types.ts +++ b/frontend/src/pages/modelServing/screens/types.ts @@ -108,19 +108,15 @@ export type ServingRuntimeEditInfo = { secrets: SecretKind[]; }; +type PlatformStatus = { + enabled: boolean; + installed: boolean; +}; export type ServingPlatformStatuses = { - kServe: { - enabled: boolean; - installed: boolean; - }; - modelMesh: { - enabled: boolean; - installed: boolean; - }; - nim: { - available: boolean; - }; - numServingPlatformsAvailable: number; + kServe: PlatformStatus; + kServeNIM: PlatformStatus; + modelMesh: PlatformStatus; + platformEnabledCount: number; }; export type LabeledDataConnection = { diff --git a/frontend/src/pages/modelServing/useServingPlatformStatuses.ts b/frontend/src/pages/modelServing/useServingPlatformStatuses.ts index e7c8c421a3..369034c71b 100644 --- a/frontend/src/pages/modelServing/useServingPlatformStatuses.ts +++ b/frontend/src/pages/modelServing/useServingPlatformStatuses.ts @@ -1,33 +1,31 @@ import { StackComponent, SupportedArea, useIsAreaAvailable } from '~/concepts/areas'; import { ServingPlatformStatuses } from '~/pages/modelServing/screens/types'; -import { useDashboardNamespace } from '~/redux/selectors'; import { useIsNIMAvailable } from '~/pages/modelServing/screens/projects/useIsNIMAvailable'; const useServingPlatformStatuses = (): ServingPlatformStatuses => { - const { dashboardNamespace } = useDashboardNamespace(); - const kServeStatus = useIsAreaAvailable(SupportedArea.K_SERVE); const modelMeshStatus = useIsAreaAvailable(SupportedArea.MODEL_MESH); const kServeEnabled = kServeStatus.status; const modelMeshEnabled = modelMeshStatus.status; const kServeInstalled = !!kServeStatus.requiredComponents?.[StackComponent.K_SERVE]; const modelMeshInstalled = !!modelMeshStatus.requiredComponents?.[StackComponent.MODEL_MESH]; - const isNIMAvailable = useIsNIMAvailable(dashboardNamespace); + + const isNIMAvailable = useIsNIMAvailable(); return { kServe: { enabled: kServeEnabled, installed: kServeInstalled, }, + kServeNIM: { + enabled: isNIMAvailable, + installed: kServeInstalled, + }, modelMesh: { enabled: modelMeshEnabled, installed: modelMeshInstalled, }, - nim: { - available: isNIMAvailable, - }, - numServingPlatformsAvailable: [kServeEnabled, modelMeshEnabled, isNIMAvailable].filter(Boolean) - .length, + platformEnabledCount: [kServeEnabled, isNIMAvailable, modelMeshEnabled].filter(Boolean).length, }; }; diff --git a/frontend/src/pages/projects/screens/detail/overview/serverModels/PlatformSelectSection.tsx b/frontend/src/pages/projects/screens/detail/overview/serverModels/PlatformSelectSection.tsx index b985c5417d..5750042f0c 100644 --- a/frontend/src/pages/projects/screens/detail/overview/serverModels/PlatformSelectSection.tsx +++ b/frontend/src/pages/projects/screens/detail/overview/serverModels/PlatformSelectSection.tsx @@ -11,11 +11,12 @@ const PlatformSelectSection: React.FC = () => { const [errorSelectingPlatform, setErrorSelectingPlatform] = React.useState(); const servingPlatformStatuses = useServingPlatformStatuses(); - const { - nim: { available: isNIMAvailable }, - } = servingPlatformStatuses; + const kServeEnabled = servingPlatformStatuses.kServe.enabled; + const isNIMAvailable = servingPlatformStatuses.kServeNIM.enabled; + const modelMeshEnabled = servingPlatformStatuses.modelMesh.enabled; - const galleryWidths = isNIMAvailable + const threeEnabled = [kServeEnabled, modelMeshEnabled, isNIMAvailable].every((v) => v); + const galleryWidths = threeEnabled ? { minWidths: { default: '100%', lg: 'calc(33.33% - 1rem / 3 * 2)' }, maxWidths: { default: '100%', lg: 'calc(33.33% - 1rem / 3 * 2)' }, @@ -38,8 +39,12 @@ const PlatformSelectSection: React.FC = () => { - - + {kServeEnabled && ( + + )} + {modelMeshEnabled && ( + + )} {isNIMAvailable && ( )} diff --git a/frontend/src/pages/projects/screens/detail/overview/serverModels/ServeModelsSection.tsx b/frontend/src/pages/projects/screens/detail/overview/serverModels/ServeModelsSection.tsx index d38dce18ab..29a1e41515 100644 --- a/frontend/src/pages/projects/screens/detail/overview/serverModels/ServeModelsSection.tsx +++ b/frontend/src/pages/projects/screens/detail/overview/serverModels/ServeModelsSection.tsx @@ -10,8 +10,8 @@ import DeployedModelsSection from './deployedModels/DeployedModelsSection'; const ServeModelsSection: React.FC = () => { const servingPlatformStatuses = useServingPlatformStatuses(); const { - numServingPlatformsAvailable, modelMesh: { enabled: modelMeshEnabled }, + platformEnabledCount, } = servingPlatformStatuses; const { currentProject } = React.useContext(ProjectDetailsContext); @@ -21,11 +21,11 @@ const ServeModelsSection: React.FC = () => { servingPlatformStatuses, ); - if (numServingPlatformsAvailable > 1 && !currentProjectServingPlatform) { + if (platformEnabledCount > 1 && !currentProjectServingPlatform) { return ; } - if (numServingPlatformsAvailable === 0) { + if (platformEnabledCount === 0) { return ; } diff --git a/frontend/src/pages/projects/screens/detail/overview/serverModels/deployedModels/DeployedModelsSection.tsx b/frontend/src/pages/projects/screens/detail/overview/serverModels/deployedModels/DeployedModelsSection.tsx index 08f5bc3fea..f5e7d9768a 100644 --- a/frontend/src/pages/projects/screens/detail/overview/serverModels/deployedModels/DeployedModelsSection.tsx +++ b/frontend/src/pages/projects/screens/detail/overview/serverModels/deployedModels/DeployedModelsSection.tsx @@ -52,7 +52,6 @@ const DeployedModelsSection: React.FC = ({ isMultiPl } = React.useContext(ProjectDetailsContext); const servingPlatformStatuses = useServingPlatformStatuses(); - const { numServingPlatformsAvailable } = servingPlatformStatuses; const { error: platformError } = getProjectModelServingPlatform( currentProject, servingPlatformStatuses, @@ -130,7 +129,7 @@ const DeployedModelsSection: React.FC = ({ isMultiPl headerInfo={ - {numServingPlatformsAvailable > 1 && ( + {servingPlatformStatuses.platformEnabledCount > 1 && ( = ({ isMultiPl - {numServingPlatformsAvailable > 1 && ( + {servingPlatformStatuses.platformEnabledCount > 1 && ( => { + const body = JSON.stringify(enableValues); + const headers = { 'Content-Type': 'application/json', Accept: 'application/json' }; + return axios + .post(internalRoute, body, { headers }) + .then((res) => res.data) + .catch((e) => { + throw new Error(e.response.data?.message || e.message); + }); +}; + +export const getIntegrationAppEnablementStatus = ( + internalRoute: string, +): Promise => + axios + .get(internalRoute) + .then((res) => res.data) + .catch((e) => { + throw new Error(e.response.data?.message || e.message); + }); diff --git a/frontend/src/types.ts b/frontend/src/types.ts index d434fb3f0c..0b8c01ca01 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -179,6 +179,16 @@ export type OdhApplication = { }; }; +/** + * An OdhApplication that uses integration api to determine status. + * @see isIntegrationApp + */ +export type OdhIntegrationApplication = OdhApplication & { + spec: { + internalRoute: string; // starts with `/api/` + }; +}; + export enum OdhApplicationCategory { RedHatManaged = 'Red Hat managed', PartnerManaged = 'Partner managed', @@ -638,3 +648,10 @@ export type KeyValuePair = { key: string; value: string; }; + +export type IntegrationAppStatus = { + isInstalled: boolean; + isEnabled: boolean; + canInstall: boolean; + error: string; +}; diff --git a/frontend/src/utilities/useEnableApplication.tsx b/frontend/src/utilities/useEnableApplication.tsx index fbd0773747..7824d99ee0 100644 --- a/frontend/src/utilities/useEnableApplication.tsx +++ b/frontend/src/utilities/useEnableApplication.tsx @@ -1,8 +1,13 @@ import * as React from 'react'; import { AlertVariant } from '@patternfly/react-core'; import { getValidationStatus, postValidateIsv } from '~/services/validateIsvService'; +import { + enableIntegrationApp, + getIntegrationAppEnablementStatus, +} from '~/services/integrationAppService'; import { addNotification, forceComponentsUpdate } from '~/redux/actions/actions'; import { useAppDispatch } from '~/redux/hooks'; +import { isInternalRouteIntegrationsApp } from './utils'; export enum EnableApplicationStatus { INPROGRESS, @@ -16,6 +21,7 @@ export const useEnableApplication = ( appId: string, appName: string, enableValues: { [key: string]: string }, + internalRoute?: string, ): [EnableApplicationStatus, string] => { const [enableStatus, setEnableStatus] = React.useState<{ status: EnableApplicationStatus; @@ -59,26 +65,49 @@ export const useEnableApplication = ( let watchHandle: ReturnType; if (enableStatus.status === EnableApplicationStatus.INPROGRESS) { const watchStatus = () => { - getValidationStatus(appId) - .then((response) => { - if (!response.complete) { - watchHandle = setTimeout(watchStatus, 10 * 1000); - return; - } - setEnableStatus({ - status: response.valid - ? EnableApplicationStatus.SUCCESS - : EnableApplicationStatus.FAILED, - error: response.valid ? '' : response.error, + if (isInternalRouteIntegrationsApp(internalRoute)) { + getIntegrationAppEnablementStatus(internalRoute) + .then((response) => { + if (!response.isInstalled && response.canInstall) { + watchHandle = setTimeout(watchStatus, 10 * 1000); + return; + } + if (response.isInstalled) { + setEnableStatus({ + status: EnableApplicationStatus.SUCCESS, + error: '', + }); + dispatchResults(undefined); + } + }) + .catch((e) => { + if (!cancelled) { + setEnableStatus({ status: EnableApplicationStatus.FAILED, error: e.message }); + } + dispatchResults(e.message); }); - dispatchResults(response.valid ? undefined : response.error); - }) - .catch((e) => { - if (!cancelled) { - setEnableStatus({ status: EnableApplicationStatus.FAILED, error: e.message }); - } - dispatchResults(e.message); - }); + } else { + getValidationStatus(appId) + .then((response) => { + if (!response.complete) { + watchHandle = setTimeout(watchStatus, 10 * 1000); + return; + } + setEnableStatus({ + status: response.valid + ? EnableApplicationStatus.SUCCESS + : EnableApplicationStatus.FAILED, + error: response.valid ? '' : response.error, + }); + dispatchResults(response.valid ? undefined : response.error); + }) + .catch((e) => { + if (!cancelled) { + setEnableStatus({ status: EnableApplicationStatus.FAILED, error: e.message }); + } + dispatchResults(e.message); + }); + } }; watchStatus(); } @@ -86,39 +115,65 @@ export const useEnableApplication = ( cancelled = true; clearTimeout(watchHandle); }; - }, [appId, dispatchResults, enableStatus.status]); + }, [appId, dispatchResults, enableStatus.status, internalRoute]); React.useEffect(() => { let closed = false; if (doEnable) { - postValidateIsv(appId, enableValues) - .then((response) => { - if (!closed) { - if (!response.complete) { - setEnableStatus({ status: EnableApplicationStatus.INPROGRESS, error: '' }); - return; + if (isInternalRouteIntegrationsApp(internalRoute)) { + enableIntegrationApp(internalRoute, enableValues) + .then((response) => { + if (!closed) { + if (!response.isInstalled && response.canInstall) { + setEnableStatus({ status: EnableApplicationStatus.INPROGRESS, error: '' }); + return; + } + + if (response.isInstalled) { + setEnableStatus({ + status: EnableApplicationStatus.SUCCESS, + error: response.error, + }); + dispatchResults(undefined); + } + } + }) + .catch((e) => { + if (!closed) { + setEnableStatus({ status: EnableApplicationStatus.FAILED, error: e.m }); } + dispatchResults(e.message); + }); + } else { + postValidateIsv(appId, enableValues) + .then((response) => { + if (!closed) { + if (!response.complete) { + setEnableStatus({ status: EnableApplicationStatus.INPROGRESS, error: '' }); + return; + } - setEnableStatus({ - status: response.valid - ? EnableApplicationStatus.SUCCESS - : EnableApplicationStatus.FAILED, - error: response.valid ? '' : response.error, - }); - } - dispatchResults(response.valid ? undefined : response.error); - }) - .catch((e) => { - if (!closed) { - setEnableStatus({ status: EnableApplicationStatus.FAILED, error: e.m }); - } - dispatchResults(e.message); - }); + setEnableStatus({ + status: response.valid + ? EnableApplicationStatus.SUCCESS + : EnableApplicationStatus.FAILED, + error: response.valid ? '' : response.error, + }); + } + dispatchResults(response.valid ? undefined : response.error); + }) + .catch((e) => { + if (!closed) { + setEnableStatus({ status: EnableApplicationStatus.FAILED, error: e.m }); + } + dispatchResults(e.message); + }); + } } return () => { closed = true; }; - }, [appId, appName, dispatch, dispatchResults, doEnable, enableValues]); + }, [appId, appName, dispatch, dispatchResults, doEnable, enableValues, internalRoute]); return [enableStatus.status, enableStatus.error]; }; diff --git a/frontend/src/utilities/useWatchIntegrationComponents.tsx b/frontend/src/utilities/useWatchIntegrationComponents.tsx new file mode 100644 index 0000000000..f555efaf08 --- /dev/null +++ b/frontend/src/utilities/useWatchIntegrationComponents.tsx @@ -0,0 +1,101 @@ +import * as React from 'react'; +import { useAppSelector } from '~/redux/hooks'; +import { IntegrationAppStatus, OdhApplication, OdhIntegrationApplication } from '~/types'; +import { getIntegrationAppEnablementStatus } from '~/services/integrationAppService'; +import { allSettledPromises } from '~/utilities/allSettledPromises'; +import { POLL_INTERVAL } from './const'; +import { isIntegrationApp } from './utils'; + +export const useWatchIntegrationComponents = ( + components?: OdhApplication[], +): { checkedComponents: OdhApplication[]; isIntegrationComponentsChecked: boolean } => { + const [isIntegrationComponentsChecked, setIsIntegrationComponentsChecked] = React.useState(false); + const forceUpdate = useAppSelector((state) => state.forceComponentsUpdate); + const initForce = React.useRef(forceUpdate); + const integrationComponents = React.useMemo( + () => components?.filter(isIntegrationApp), + [components], + ); + const [newComponents, setNewComponents] = React.useState([]); + + const updateComponentEnablementStatus = async ( + integrationComponentList: OdhIntegrationApplication[], + componentList: OdhApplication[], + ): Promise => { + const updatePromises = integrationComponentList.map(async (component) => { + const response = await getIntegrationAppEnablementStatus(component.spec.internalRoute).catch( + (e) => + ({ + isInstalled: false, + isEnabled: false, + canInstall: false, + error: e.message ?? e.error, // might be an error from the server, might be an error in the network call itself + } satisfies IntegrationAppStatus), + ); + + if (response.error) { + // TODO: Show the error somehow + setNewComponents( + componentList.filter((app) => app.metadata.name !== component.metadata.name), + ); + } else { + const updatedComponents = componentList + .filter( + (app) => !(app.metadata.name === component.metadata.name && !response.isInstalled), + ) + .map((app) => + app.metadata.name === component.metadata.name + ? { + ...app, + spec: { + ...app.spec, + isEnabled: response.isEnabled, + }, + } + : app, + ); + setNewComponents(updatedComponents); + } + }); + await allSettledPromises(updatePromises); + }; + + React.useEffect(() => { + let watchHandle: ReturnType; + if (integrationComponents && components) { + if (integrationComponents.length === 0) { + setIsIntegrationComponentsChecked(true); + setNewComponents(components); + } else { + const watchComponents = () => { + updateComponentEnablementStatus(integrationComponents, components).then(() => { + setIsIntegrationComponentsChecked(true); + watchHandle = setTimeout(watchComponents, POLL_INTERVAL); + }); + }; + watchComponents(); + } + } + return () => { + clearTimeout(watchHandle); + }; + }, [components, integrationComponents]); + + React.useEffect(() => { + if (initForce.current !== forceUpdate) { + initForce.current = forceUpdate; + if (integrationComponents && components) { + if (integrationComponents.length === 0) { + setIsIntegrationComponentsChecked(true); + setNewComponents(components); + } else { + updateComponentEnablementStatus(integrationComponents, components).then(() => { + setIsIntegrationComponentsChecked(true); + }); + } + } + } + }, [forceUpdate, components, integrationComponents]); + + return { checkedComponents: newComponents, isIntegrationComponentsChecked }; +}; diff --git a/frontend/src/utilities/utils.ts b/frontend/src/utilities/utils.ts index f29ef82928..d43073cad1 100644 --- a/frontend/src/utilities/utils.ts +++ b/frontend/src/utilities/utils.ts @@ -1,5 +1,11 @@ import { LabelProps } from '@patternfly/react-core'; -import { ContainerResources, OdhApplication, OdhDocument, OdhDocumentType } from '~/types'; +import { + ContainerResources, + OdhApplication, + OdhDocument, + OdhDocumentType, + OdhIntegrationApplication, +} from '~/types'; import { AcceleratorProfileKind } from '~/k8sTypes'; import { CATEGORY_ANNOTATION, DASHBOARD_MAIN_CONTAINER_ID, ODH_PRODUCT_NAME } from './const'; @@ -183,3 +189,9 @@ export const isEnumMember = ( } return false; }; + +export const isInternalRouteIntegrationsApp = (internalRoute?: string): internalRoute is string => + internalRoute?.startsWith('/api/') ?? false; + +export const isIntegrationApp = (app: OdhApplication): app is OdhIntegrationApplication => + isInternalRouteIntegrationsApp(app.spec.internalRoute); diff --git a/manifests/rhoai/shared/apps/nvidia-nim/nvidia-nim-app.yaml b/manifests/rhoai/shared/apps/nvidia-nim/nvidia-nim-app.yaml index f7d6819e75..e8cbb6fd01 100644 --- a/manifests/rhoai/shared/apps/nvidia-nim/nvidia-nim-app.yaml +++ b/manifests/rhoai/shared/apps/nvidia-nim/nvidia-nim-app.yaml @@ -18,6 +18,7 @@ spec: docsLink: https://developer.nvidia.com/nim quickStart: '' getStartedLink: 'https://developer.nvidia.com/nim' + internalRoute: '/api/integrations/nim' enable: title: Enter NVIDIA AI Enterprise license key actionLabel: Submit @@ -28,9 +29,6 @@ spec: api_key: NVIDIA AI Enterprise license key variableHelpText: api_key: This key is given to you by NVIDIA - validationJob: nvidia-nim-periodic-validator - validationSecret: nvidia-nim-access - validationConfigMap: nvidia-nim-validation-result getStartedMarkDown: |- # **NVIDIA NIM** NVIDIA NIM, part of NVIDIA AI Enterprise, is a set of easy-to-use diff --git a/manifests/rhoai/shared/base/kustomization.yaml b/manifests/rhoai/shared/base/kustomization.yaml index 370831065d..73dbb3a979 100644 --- a/manifests/rhoai/shared/base/kustomization.yaml +++ b/manifests/rhoai/shared/base/kustomization.yaml @@ -3,7 +3,6 @@ kind: Kustomization resources: - ../../../core-bases/base - anaconda-ce-validator-cron.yaml - - nvidia-nim-validator-cron.yaml patchesJson6902: - path: service-account.yaml target: diff --git a/manifests/rhoai/shared/base/nvidia-nim-validator-cron.yaml b/manifests/rhoai/shared/base/nvidia-nim-validator-cron.yaml deleted file mode 100644 index b3a62aa607..0000000000 --- a/manifests/rhoai/shared/base/nvidia-nim-validator-cron.yaml +++ /dev/null @@ -1,438 +0,0 @@ -apiVersion: batch/v1 -kind: CronJob -metadata: - name: nvidia-nim-periodic-validator - labels: - opendatahub.io/modified: "false" -spec: - schedule: "0 0 * * *" - concurrencyPolicy: "Replace" - startingDeadlineSeconds: 200 - suspend: true - successfulJobsHistoryLimit: 3 - failedJobsHistoryLimit: 1 - jobTemplate: - spec: - template: - metadata: - labels: - parent: "nvidia-nim-periodic-validator" - spec: - serviceAccount: "rhods-dashboard" - containers: - - name: nvidia-nim-validator - image: registry.redhat.io/openshift4/ose-cli@sha256:75bf9b911b6481dcf29f7942240d1555adaa607eec7fc61bedb7f624f87c36d4 - command: - - /bin/sh - - -c - - > - #!/bin/sh - - RESULT_CONFIGMAP_NAME='nvidia-nim-validation-result' - DATA_CONFIGMAP_NAME='nvidia-nim-images-data' - IMAGE_PULL_SECRET_NAME='nvidia-nim-image-pull' - SERVING_RUNTIME_TEMPLATE_NAME='nvidia-nim-serving-template' - SERVING_RUNTIME_TEMPLATE_YAML=' - { - "apiVersion": "template.openshift.io/v1", - "kind": "Template", - "metadata": { - "annotations": { - "opendatahub.io/apiProtocol": "REST", - "opendatahub.io/modelServingSupport": "[\"single\"]" - }, - "name": "nvidia-nim-serving-template" - }, - "objects": [ - { - "apiVersion": "serving.kserve.io/v1alpha1", - "kind": "ServingRuntime", - "labels": { - "opendatahub.io/dashboard": "true" - }, - "metadata": { - "annotations": { - "opendatahub.io/recommended-accelerators": "[\"nvidia.com/gpu\"]", - "openshift.io/display-name": "NVIDIA NIM" - }, - "name": "nvidia-nim-runtime" - }, - "spec": { - "containers": [ - { - "env": [ - { - "name": "NIM_CACHE_PATH", - "value": "/mnt/models/cache" - }, - { - "name": "NGC_API_KEY", - "valueFrom": { - "secretKeyRef": { - "key": "NGC_API_KEY", - "name": "nvidia-nim-secrets" - } - } - } - ], - "image": null, - "name": "kserve-container", - "ports": [ - { - "containerPort": 8000, - "protocol": "TCP" - } - ], - "resources": { - "limits": { - "cpu": "2", - "memory": "8Gi", - "nvidia.com/gpu": 2 - }, - "requests": { - "cpu": "1", - "memory": "4Gi", - "nvidia.com/gpu": 2 - } - }, - "volumeMounts": [ - { - "mountPath": "/dev/shm", - "name": "shm" - }, - { - "mountPath": "/mnt/models/cache", - "name": "nim-pvc" - } - ] - } - ], - "imagePullSecrets": [ - { - "name": "ngc-secret" - } - ], - "multiModel": false, - "protocolVersions": [ - "grpc-v2", - "v2" - ], - "supportedModelFormats": [], - "volumes": [ - { - "name": "nim-pvc", - "persistentVolumeClaim": { - "claimName": "nim-pvc" - } - } - ] - } - } - ] - }' - - function verify_result_configmap_exists() { - if ! oc get configmap "${RESULT_CONFIGMAP_NAME}" &>/dev/null; then - echo "Result ConfigMap doesn't exist, creating" - - oc create configmap "${RESULT_CONFIGMAP_NAME}" --from-literal validation_result="false" - fi - } - - function write_result_configmap_value() { - oc patch configmap "${RESULT_CONFIGMAP_NAME}" -p '"data": { "validation_result": "'${1}'" }' - } - - function write_last_valid_time() { - oc patch configmap "${RESULT_CONFIGMAP_NAME}" -p '"data": { "last_valid_time": "'$(date -Is)'" }' - } - - function create_image_pull_secret() { - if ! oc get secret "${IMAGE_PULL_SECRET_NAME}" &>/dev/null; then - echo "Image pull Secret doesn't exist, creating" - - api_key=$(get_api_key) - - oc create secret docker-registry "${IMAGE_PULL_SECRET_NAME}" \ - --docker-server=nvcr.io \ - --docker-username='$oauthtoken' \ - --docker-password=${api_key} - fi - } - - function delete_image_pull_secret() { - echo "Deleting image pull Secret" - - oc delete secret "${IMAGE_PULL_SECRET_NAME}" --ignore-not-found=true - } - - function verify_image_data_configmap() { - if ! oc get configmap "${DATA_CONFIGMAP_NAME}" &>/dev/null; then - echo "Image data ConfigMap doesn't exist, creating" - - oc create configmap "${DATA_CONFIGMAP_NAME}" - fi - } - - function write_image_data_configmap() { - echo "Patching image data ConfigMap" - - oc get configmap "${DATA_CONFIGMAP_NAME}" -o json | jq --argjson data "$1" '.data = ($data)' | oc apply -f - - } - - function delete_image_data_configmap() { - echo "Deleting image data ConfigMap" - - oc delete configmap "${DATA_CONFIGMAP_NAME}" --ignore-not-found=true - } - - function create_serving_runtime_template() { - if ! oc get template "${SERVING_RUNTIME_TEMPLATE_NAME}" &>/dev/null; then - echo "Template for NIM ServingRuntime doesn't exist, creating" - - echo ${SERVING_RUNTIME_TEMPLATE_YAML} | oc create -f - - fi - } - - function delete_serving_runtime_template() { - echo "Deleting Template for ServingRuntime" - - oc delete template "${SERVING_RUNTIME_TEMPLATE_NAME}" --ignore-not-found=true - } - - function check_kserve() { - dsc=$(oc get -o=json datasciencecluster) - - if [ ! -z "$dsc" ]; then - enabled=$(jq -n --argjson data "$dsc" 'first($data.items[] | .status | select(.phase == "Ready") | .installedComponents | .kserve == true)') - echo $enabled - fi - } - - function success() { - echo "Validation succeeded, enabling NIM" - - create_image_pull_secret - verify_image_data_configmap - write_image_data_configmap "$1" - create_serving_runtime_template - verify_result_configmap_exists - write_result_configmap_value true - write_last_valid_time - } - - function failure() { - echo "Validation failed, disabling NIM" - - delete_image_pull_secret - delete_image_data_configmap - delete_serving_runtime_template - verify_result_configmap_exists - write_result_configmap_value false - } - - function get_api_key() { - cat "/etc/secret-volume/api_key" - } - - function get_ngc_token() { - tempfile=$(mktemp) - - http_code=$(curl -s --write-out "%{http_code}" -o $tempfile "https://authn.nvidia.com/token?service=ngc&" \ - -H "Authorization: ApiKey $1") - - if [ "${http_code}" == 200 ]; then - token=$(jq -r '.token' $tempfile) - echo $token - fi - } - - function get_nim_images() { - tempfile=$(mktemp) - - http_code=$(curl -s --write-out "%{http_code}" -o $tempfile \ - https://api.ngc.nvidia.com/v2/search/catalog/resources/CONTAINER?q=%7B%22query%22%3A+%22orgName%3Anim%22%7D) - - if [ "${http_code}" == 200 ]; then - nim_images=$(jq -r \ - '.results[] | select(.groupValue == "CONTAINER") | .resources[] | (.resourceId + ":" + (.attributes[] | select(.key == "latestTag") | .value))' \ - $tempfile) - echo $nim_images - fi - } - - function get_nim_image_details() { - IFS=':' read -r -a refs <<< "$1" - - if [ ${#refs[@]} -ne 2 ]; then - return - fi - - name="${refs[0]}" - tag="${refs[1]}" - - IFS='/' read -r -a parts <<< "$name" - - if [ ${#parts[@]} -ne 3 ]; then - return - fi - org="${parts[0]}" - team="${parts[1]}" - image="${parts[2]}" - - tempfile=$(mktemp) - - http_code=$(curl -s --write-out "%{http_code}" -o $tempfile \ - https://api.ngc.nvidia.com/v2/org/$org/team/$team/repos/$image?resolve-labels=true \ - -H "Authorization: Bearer $2") - - if [ "${http_code}" == 200 ]; then - raw_data=$(jq -r \ - '{name, displayName, shortDescription, namespace, tags, latestTag, updatedDate}' \ - $tempfile) - image_data=$(jq -n --arg name "$image" --arg data "$raw_data" '{($name): ($data)}') - echo $image_data - fi - } - - function get_image_data() { - images=("$@") - - api_key=$(get_api_key) - token=$(get_ngc_token $api_key) - - if [ ! -z "$token" ]; then - images_data=() - i=0 - for image in "${images[@]}"; - do - images_data[i]=$(get_nim_image_details $image $token) - i=$((i+1)) - done - - data='{}' - for image_data in "${images_data[@]}"; - do - data="$(jq --argjson data "$image_data" '. += $data' <<< "$data")" - done - - echo $data - fi - } - - function get_image_registry_token() { - tempfile=$(mktemp) - - http_code=$(curl -s --write-out "%{http_code}" -o $tempfile \ - "https://nvcr.io/proxy_auth?account=\$oauthtoken&offline_token=true&scope=repository:$1:pull" \ - -H "Authorization: Basic $2") - - if [ "${http_code}" == 200 ]; then - token=$(jq -r '.token' $tempfile) - echo $token - fi - } - - function get_image_manifest() { - tempfile=$(mktemp) - - http_code=$(curl -s --write-out "%{http_code}" -o $tempfile \ - "https://nvcr.io/v2/$1/manifests/$2" \ - -H "Authorization: Bearer $3") - - if [ "${http_code}" == 200 ]; then - cat $tempfile - fi - } - - function verify_api_key() { - api_key=$(get_api_key) - basic=$(printf "\$oauthtoken:$api_key" | base64 -w 0) - - token=$(get_image_registry_token $1 $basic) - - if [ ! -z "$token" ]; then - manifest=$(get_image_manifest $1 $2 $token) - - if [ ! -z "$manifest" ]; then - echo $manifest - fi - fi - } - - echo "Install jq" - - dnf install -y jq - - echo "Check KServe readiness" - - kserve_enabled=$(check_kserve) - - if [ "${kserve_enabled}" != "true" ]; then - echo "KServe is not enabled" - - failure - exit 0 - fi - - echo "Get NIM images" - - nim_images=$(get_nim_images) - - if [ ! -z "$nim_images" ]; then - images=($nim_images) - - IFS=':' read -r -a refs <<< "${images[0]}" - - if [ ${#refs[@]} -ne 2 ]; then - echo "Failed to parse NIM image name" - - failure - fi - - echo "Verify Api Key" - - verification=$(verify_api_key "${refs[0]}" "${refs[1]}") - - if [ ! -z "$verification" ]; then - echo "Get images data" - - nim_data=$(get_image_data "${images[@]}") - - if [ ! -z "$nim_data" ]; then - echo "Enable NIM app" - - success "$nim_data" - else - echo "Failed to retrieve NIM image details" - - failure - fi - else - echo "Api key verification failed" - - failure - fi - else - echo "Failed to get NIM images" - - failure - fi - - exit 0 - volumeMounts: - - name: secret-volume - mountPath: /etc/secret-volume - readOnly: true - resources: - limits: - cpu: 100m - memory: 256Mi - requests: - cpu: 100m - memory: 256Mi - volumes: - - name: secret-volume - secret: - secretName: nvidia-nim-access - restartPolicy: Never