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/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/services/integrationAppService.ts b/frontend/src/services/integrationAppService.ts new file mode 100644 index 0000000000..dcaae146ab --- /dev/null +++ b/frontend/src/services/integrationAppService.ts @@ -0,0 +1,26 @@ +import axios from '~/utilities/axios'; +import { IntegrationAppStatus } from '~/types'; + +export const enableIntegrationApp = ( + internalRoute: string, + enableValues: { [key: string]: string }, +): Promise => { + 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