diff --git a/frontend/src/__mocks__/mockDashboardConfig.ts b/frontend/src/__mocks__/mockDashboardConfig.ts index 5096817893..63788271ad 100644 --- a/frontend/src/__mocks__/mockDashboardConfig.ts +++ b/frontend/src/__mocks__/mockDashboardConfig.ts @@ -1,7 +1,7 @@ import { DashboardConfigKind, KnownLabels } from '~/k8sTypes'; import { NotebookSize } from '~/types'; -type MockDashboardConfigType = { +export type MockDashboardConfigType = { disableInfo?: boolean; disableSupport?: boolean; disableClusterManager?: boolean; diff --git a/frontend/src/__mocks__/mockNimResource.ts b/frontend/src/__mocks__/mockNimResource.ts new file mode 100644 index 0000000000..39b6684141 --- /dev/null +++ b/frontend/src/__mocks__/mockNimResource.ts @@ -0,0 +1,145 @@ +import { + ConfigMapKind, + InferenceServiceKind, + PersistentVolumeClaimKind, + ProjectKind, + SecretKind, + ServingRuntimeKind, + TemplateKind, +} from '~/k8sTypes'; +import { ServingRuntimeAPIProtocol, ServingRuntimePlatform } from '~/types'; +import { mockProjectK8sResource } from '~/__mocks__/mockProjectK8sResource'; +import { NimServingResponse } from '~/__tests__/cypress/cypress/types'; +import { mockConfigMap } from './mockConfigMap'; +import { mockServingRuntimeK8sResource } from './mockServingRuntimeK8sResource'; +import { mockInferenceServiceK8sResource } from './mockInferenceServiceK8sResource'; +import { mockServingRuntimeTemplateK8sResource } from './mockServingRuntimeTemplateK8sResource'; +import { mockSecretK8sResource } from './mockSecretK8sResource'; +import { mockPVCK8sResource } from './mockPVCK8sResource'; + +export const mockNimImages = (): ConfigMapKind => + mockConfigMap({ + name: 'nvidia-nim-images-data', + namespace: 'opendatahub', + data: { + alphafold2: JSON.stringify({ + name: 'alphafold2', + displayName: 'AlphaFold2', + shortDescription: + 'A widely used model for predicting the 3D structures of proteins from their amino acid sequences.', + namespace: 'nim/deepmind', + tags: ['1.0.0'], + latestTag: '1.0.0', + updatedDate: '2024-08-27T01:51:55.642Z', + }), + 'arctic-embed-l': JSON.stringify({ + name: 'arctic-embed-l', + displayName: 'Snowflake Arctic Embed Large Embedding', + shortDescription: + 'NVIDIA NIM for GPU accelerated Snowflake Arctic Embed Large Embedding inference', + namespace: 'nim/snowflake', + tags: ['1.0.1', '1.0.0'], + latestTag: '1.0.1', + updatedDate: '2024-07-27T00:38:40.927Z', + }), + }, + }); + +export const mockNimInferenceService = (): InferenceServiceKind => { + const inferenceService = mockInferenceServiceK8sResource({ + name: 'test-name', + modelName: 'test-name', + displayName: 'Test Name', + kserveInternalLabel: true, + resources: { + limits: { cpu: '2', memory: '8Gi' }, + requests: { cpu: '1', memory: '4Gi' }, + }, + }); + delete inferenceService.metadata.labels?.name; + delete inferenceService.metadata.creationTimestamp; + delete inferenceService.metadata.generation; + delete inferenceService.metadata.resourceVersion; + delete inferenceService.metadata.uid; + if (inferenceService.spec.predictor.model?.modelFormat) { + inferenceService.spec.predictor.model.modelFormat.name = 'arctic-embed-l'; + } + delete inferenceService.spec.predictor.model?.modelFormat?.version; + delete inferenceService.spec.predictor.model?.storage; + delete inferenceService.status; + + return inferenceService; +}; + +export const mockNimServingRuntime = (): ServingRuntimeKind => { + const servingRuntime = mockServingRuntimeK8sResource({ + name: 'test-name', + displayName: 'Test Name', + }); + if (servingRuntime.metadata.annotations) { + servingRuntime.metadata.annotations['opendatahub.io/template-display-name'] = 'NVIDIA NIM'; + servingRuntime.metadata.annotations['opendatahub.io/template-name'] = 'nvidia-nim-runtime'; + } + + return servingRuntime; +}; + +export const mockNimServingRuntimeTemplate = (): TemplateKind => { + const templateMock = mockServingRuntimeTemplateK8sResource({ + name: 'nvidia-nim-serving-template', + displayName: 'NVIDIA NIM', + platforms: [ServingRuntimePlatform.SINGLE], + apiProtocol: ServingRuntimeAPIProtocol.REST, + namespace: 'opendatahub', + }); + if (templateMock.metadata.annotations != null) { + templateMock.metadata.annotations['opendatahub.io/dashboard'] = 'true'; + } + + return templateMock; +}; + +export const mockNvidiaNimAccessSecret = (): SecretKind => { + const secret = mockSecretK8sResource({ + name: 'nvidia-nim-access', + }); + delete secret.data; + secret.data = {}; + secret.data.api_key = 'api-key'; // eslint-disable-line camelcase + secret.data.configMapName = 'bnZpZGlhLW5pbS12YWxpZGF0aW9uLXJlc3VsdA=='; + + return secret; +}; + +export const mockNvidiaNimImagePullSecret = (): SecretKind => { + const secret = mockSecretK8sResource({ + name: 'nvidia-nim-image-pull', + }); + delete secret.data; + secret.data = {}; + secret.data['.dockerconfigjson'] = 'ZG9ja2VyY29uZmlnCg=='; + + return secret; +}; + +export const mockNimProject = (hasAllModels: boolean): ProjectKind => { + const project = mockProjectK8sResource({ + hasAnnotations: true, + enableModelMesh: hasAllModels ? undefined : false, + }); + if (project.metadata.annotations != null) { + project.metadata.annotations['opendatahub.io/nim-support'] = 'true'; + } + return project; +}; + +export const mockNimModelPVC = (): PersistentVolumeClaimKind => { + const pvc = mockPVCK8sResource({ + name: 'nim-pvc', + }); + return pvc; +}; + +export const mockNimServingResource = ( + resource: ConfigMapKind | SecretKind, +): NimServingResponse => ({ body: { body: resource } }); diff --git a/frontend/src/__tests__/cypress/cypress/pages/components/NIMDeployModal.ts b/frontend/src/__tests__/cypress/cypress/pages/components/NIMDeployModal.ts new file mode 100644 index 0000000000..3bd40637e3 --- /dev/null +++ b/frontend/src/__tests__/cypress/cypress/pages/components/NIMDeployModal.ts @@ -0,0 +1,49 @@ +import { Modal } from '~/__tests__/cypress/cypress/pages/components/Modal'; + +class NIMDeployModal extends Modal { + constructor(private edit = false) { + super(`${edit ? 'Edit' : 'Deploy'} model with NVIDIA NIM`); + } + + findSubmitButton() { + return this.findFooter().findByTestId('modal-submit-button'); + } + + findModelNameInput() { + return this.find().findByTestId('model-deployment-name-section'); + } + + findNIMToDeploy() { + return this.find().findByTestId('nim-model-list-selection'); + } + + findNimStorageSizeInput() { + return cy.get('[data-testid="pvc-size"] input'); + } + + findStorageSizeMinusButton() { + return this.find().findByTestId('pvc-size').findByRole('button', { name: 'Minus' }); + } + + findStorageSizePlusButton() { + return this.find().findByTestId('pvc-size').findByRole('button', { name: 'Plus' }); + } + + findNimModelReplicas() { + return cy.get('[id="model-server-replicas"]'); + } + + findNimModelReplicasMinusButton() { + return this.find().find('button[aria-label="Minus"]').eq(1); + } + + findNimModelReplicasPlusButton() { + return this.find().find('button[aria-label="Plus"]').eq(1); + } + + shouldDisplayError(msg: string): void { + this.find().should('contain.text', msg); + } +} + +export const nimDeployModal = new NIMDeployModal(); diff --git a/frontend/src/__tests__/cypress/cypress/pages/modelServing.ts b/frontend/src/__tests__/cypress/cypress/pages/modelServing.ts index 50cc4f2fd8..d2dad593c7 100644 --- a/frontend/src/__tests__/cypress/cypress/pages/modelServing.ts +++ b/frontend/src/__tests__/cypress/cypress/pages/modelServing.ts @@ -311,6 +311,14 @@ class InferenceServiceRow extends TableRow { findInternalServicePopover() { return cy.findByTestId('internal-service-popover'); } + + findServingRuntime() { + return this.find().find(`[data-label="Serving Runtime"]`); + } + + findProject() { + return this.find().find(`[data-label=Project]`); + } } class ServingPlatformCard extends Contextual { findDeployModelButton() { diff --git a/frontend/src/__tests__/cypress/cypress/pages/projects.ts b/frontend/src/__tests__/cypress/cypress/pages/projects.ts index 458ad42b8a..5aded2111c 100644 --- a/frontend/src/__tests__/cypress/cypress/pages/projects.ts +++ b/frontend/src/__tests__/cypress/cypress/pages/projects.ts @@ -181,7 +181,7 @@ class ProjectDetails { return this.findDataConnectionTable().find('thead').findByRole('button', { name }); } - private findModelServingPlatform(name: string) { + findModelServingPlatform(name: string) { return this.findComponent('model-server').findByTestId(`${name}-serving-platform-card`); } @@ -286,13 +286,23 @@ class ProjectDetails { return cy.findByTestId('unsupported-pipeline-version-alert'); } - private findKserveModelsTable() { + findKserveModelsTable() { return cy.findByTestId('kserve-inference-service-table'); } getKserveModelMetricLink(name: string) { return this.findKserveModelsTable().findByTestId(`metrics-link-${name}`); } + + getKserveTableRow(name: string) { + return new KserveTableRow(() => + this.findKserveModelsTable() + .find('tbody') + .find('[data-label="Name"]') + .contains(name) + .closest('tr'), + ); + } } class ProjectDetailsSettingsTab extends ProjectDetails { @@ -335,9 +345,51 @@ class TrustyAIUninstallModal extends DeleteModal { } } +class ProjectDetailsOverviewTab extends ProjectDetails { + visit(project: string) { + super.visitSection(project, 'overview'); + } + + findDeployedModelServingRuntime(name: string) { + return cy + .findByTestId('section-overview') + .get('div') + .contains(name) + .parents('.odh-type-bordered-card .model-server') + .get('dd'); + } + + findModelServingPlatform(name: string) { + return cy.findByTestId(`${name}-platform-card`); + } +} + +class KserveTableRow extends TableRow { + findAPIProtocol() { + return this.find().find(`[data-label="API protocol"]`); + } + + findServiceRuntime() { + return this.find().find(`[data-label="Serving Runtime"]`); + } + + findDetailsTriggerButton() { + return this.find().findByTestId('kserve-model-row-item').find('button'); + } + + private findDetailsCell() { + return this.find().next('tr').find('td').eq(1); + } + + findInfoValueFor(label: string) { + return this.findDetailsCell().find('dt').contains(label).closest('div').find('dd'); + } +} + export const projectListPage = new ProjectListPage(); export const createProjectModal = new CreateEditProjectModal(); export const editProjectModal = new CreateEditProjectModal(true); export const deleteProjectModal = new DeleteModal(); export const projectDetails = new ProjectDetails(); export const projectDetailsSettingsTab = new ProjectDetailsSettingsTab(); +export const projectDetailsOverviewTab = new ProjectDetailsOverviewTab(); diff --git a/frontend/src/__tests__/cypress/cypress/support/commands/odh.ts b/frontend/src/__tests__/cypress/cypress/support/commands/odh.ts index d16745d4d5..f14d23c7b3 100644 --- a/frontend/src/__tests__/cypress/cypress/support/commands/odh.ts +++ b/frontend/src/__tests__/cypress/cypress/support/commands/odh.ts @@ -29,6 +29,7 @@ import type { StatusResponse } from '~/redux/types'; import type { BYONImage, ClusterSettingsType, + DetectedAccelerators, ImageInfo, OdhApplication, OdhDocument, @@ -54,6 +55,7 @@ import type { GrpcResponse } from '~/__mocks__/mlmd/utils'; import type { BuildMockPipelinveVersionsType } from '~/__mocks__'; import type { ArtifactStorage } from '~/concepts/pipelines/types'; import type { ConnectionTypeConfigMap } from '~/concepts/connectionTypes/types'; +import type { NimServingResponse } from '~/__tests__/cypress/cypress/types'; type SuccessErrorResponse = { success: boolean; @@ -637,6 +639,19 @@ declare global { (( type: 'POST /api/modelRegistryRoleBindings', response: OdhResponse, + ) => Cypress.Chainable) & + (( + type: 'GET /api/accelerators', + response: OdhResponse, + ) => Cypress.Chainable) & + (( + type: 'GET /api/nim-serving/:resource', + options: { + path: { + resource: 'nvidia-nim-images-data' | 'nvidia-nim-access' | 'nvidia-nim-image-pull'; + }; + }, + response: OdhResponse, ) => Cypress.Chainable); } } diff --git a/frontend/src/__tests__/cypress/cypress/tests/mocked/modelServing/modelServingNim.cy.ts b/frontend/src/__tests__/cypress/cypress/tests/mocked/modelServing/modelServingNim.cy.ts new file mode 100644 index 0000000000..62933c8a8c --- /dev/null +++ b/frontend/src/__tests__/cypress/cypress/tests/mocked/modelServing/modelServingNim.cy.ts @@ -0,0 +1,61 @@ +import { initInterceptsToEnableNim } from '~/__tests__/cypress/cypress/utils/nimUtils'; +import { mockNimInferenceService, mockNimServingRuntime } from '~/__mocks__/mockNimResource'; +import { + InferenceServiceModel, + ServingRuntimeModel, +} from '~/__tests__/cypress/cypress/utils/models'; +import { mockK8sResourceList } from '~/__mocks__'; +import { + modelServingGlobal, + modelServingSection, +} from '~/__tests__/cypress/cypress/pages/modelServing'; + +describe('NIM Models Deployments', () => { + it('should be listed in the global models list', () => { + initInterceptsToEnableNim({ hasAllModels: false }); + cy.interceptK8sList(InferenceServiceModel, mockK8sResourceList([mockNimInferenceService()])); + cy.interceptK8sList(ServingRuntimeModel, mockK8sResourceList([mockNimServingRuntime()])); + + modelServingGlobal.visit('test-project'); + + // Table is visible and has 1 row + modelServingSection.findInferenceServiceTable().should('have.length', 1); + + // First row matches the NIM inference service details + modelServingSection + .getInferenceServiceRow('Test Name') + .findProject() + .should('contains.text', 'Test Project'); + modelServingSection + .getInferenceServiceRow('Test Name') + .findProject() + .should('contains.text', 'Single-model serving enabled'); + modelServingSection + .getInferenceServiceRow('Test Name') + .findServingRuntime() + .should('have.text', 'NVIDIA NIM'); + modelServingSection + .getInferenceServiceRow('Test Name') + .findAPIProtocol() + .should('have.text', 'REST'); + }); + + it('should only be allowed to be deleted, no edit', () => { + initInterceptsToEnableNim({}); + cy.interceptK8sList(InferenceServiceModel, mockK8sResourceList([mockNimInferenceService()])); + cy.interceptK8sList(ServingRuntimeModel, mockK8sResourceList([mockNimServingRuntime()])); + + modelServingGlobal.visit('test-project'); + modelServingGlobal.getModelRow('Test Name').get('button[aria-label="Kebab toggle"]').click(); + + modelServingGlobal + .getModelRow('Test Name') + .get('button[role="menuitem"]') + .should('have.length', 1); + modelServingGlobal + .getModelRow('Test Name') + .get('button[role="menuitem"]') + .contains('Delete') + .should('exist'); + }); +}); diff --git a/frontend/src/__tests__/cypress/cypress/tests/mocked/projects/modelServingNim.cy.ts b/frontend/src/__tests__/cypress/cypress/tests/mocked/projects/modelServingNim.cy.ts new file mode 100644 index 0000000000..26c98337b4 --- /dev/null +++ b/frontend/src/__tests__/cypress/cypress/tests/mocked/projects/modelServingNim.cy.ts @@ -0,0 +1,341 @@ +import { mockK8sResourceList } from '~/__mocks__/mockK8sResourceList'; +import { mockNimInferenceService, mockNimServingRuntime } from '~/__mocks__/mockNimResource'; +import { + InferenceServiceModel, + ServingRuntimeModel, +} from '~/__tests__/cypress/cypress/utils/models'; +import { + projectDetails, + projectDetailsOverviewTab, +} from '~/__tests__/cypress/cypress/pages/projects'; +import { nimDeployModal } from '~/__tests__/cypress/cypress/pages/components/NIMDeployModal'; +import { + initInterceptorsValidatingNimEnablement, + initInterceptsForDeleteModel, + initInterceptsToDeployModel, + initInterceptsToEnableNim, +} from '~/__tests__/cypress/cypress/utils/nimUtils'; +import { deleteModal } from '~/__tests__/cypress/cypress/pages/components/DeleteModal'; + +describe('NIM Model Serving', () => { + describe('Deploying a model from an existing Project', () => { + it('should be disabled if the modal is empty', () => { + initInterceptsToEnableNim({ hasAllModels: true }); + + projectDetails.visitSection('test-project', 'model-server'); + // For multiple cards use case + projectDetails + .findModelServingPlatform('nvidia-nim-model') + .findByTestId('nim-serving-deploy-button') + .click(); + + // test that you can not submit on empty + nimDeployModal.shouldBeOpen(); + nimDeployModal.findSubmitButton().should('be.disabled'); + }); + + it('should be enabled if the modal has the minimal info', () => { + initInterceptsToEnableNim({}); + const nimInferenceService = mockNimInferenceService(); + initInterceptsToDeployModel(nimInferenceService); + + projectDetails.visitSection('test-project', 'model-server'); + cy.get('button[data-testid=deploy-button]').click(); + + // test that you can not submit on empty + nimDeployModal.shouldBeOpen(); + nimDeployModal.findSubmitButton().should('be.disabled'); + + // test filling in minimum required fields + nimDeployModal.findModelNameInput().type('Test Name'); + nimDeployModal + .findNIMToDeploy() + .findSelectOption('Snowflake Arctic Embed Large Embedding - 1.0.0') + .click(); + nimDeployModal.findSubmitButton().should('be.enabled'); + + nimDeployModal.findNimStorageSizeInput().should('have.value', '30'); + nimDeployModal.findStorageSizeMinusButton().click(); + nimDeployModal.findNimStorageSizeInput().should('have.value', '29'); + nimDeployModal.findStorageSizePlusButton().click(); + nimDeployModal.findNimStorageSizeInput().should('have.value', '30'); + + nimDeployModal.findNimModelReplicas().should('have.value', '1'); + nimDeployModal.findNimModelReplicasPlusButton().click(); + nimDeployModal.findNimModelReplicas().should('have.value', '2'); + nimDeployModal.findNimModelReplicasMinusButton().click(); + nimDeployModal.findNimModelReplicas().should('have.value', '1'); + + nimDeployModal.findSubmitButton().click(); + + //dry run request + if (nimInferenceService.status) { + delete nimInferenceService.status; + } + cy.wait('@createInferenceService').then((interception) => { + expect(interception.request.url).to.include('?dryRun=All'); + expect(interception.request.body).to.eql(nimInferenceService); + }); + + // Actual request + cy.wait('@createInferenceService').then((interception) => { + expect(interception.request.url).not.to.include('?dryRun=All'); + }); + + cy.get('@createInferenceService.all').then((interceptions) => { + expect(interceptions).to.have.length(2); // 1 dry run request and 1 actual request + }); + + nimDeployModal.shouldBeOpen(false); + }); + + it('should list the deployed model in Models tab', () => { + initInterceptsToEnableNim({ hasAllModels: false }); + cy.interceptK8sList(InferenceServiceModel, mockK8sResourceList([mockNimInferenceService()])); + cy.interceptK8sList(ServingRuntimeModel, mockK8sResourceList([mockNimServingRuntime()])); + + projectDetails.visitSection('test-project', 'model-server'); + + // Table is visible and has 1 row + projectDetails.findKserveModelsTable().should('have.length', 1); + + // First row matches the NIM inference service details + projectDetails + .getKserveTableRow('Test Name') + .findServiceRuntime() + .should('have.text', 'NVIDIA NIM'); + projectDetails.getKserveTableRow('Test Name').findAPIProtocol().should('have.text', 'REST'); + + // Open toggle to validate Model details + projectDetails.getKserveTableRow('Test Name').findDetailsTriggerButton().click(); + + projectDetails + .getKserveTableRow('Test Name') + .findInfoValueFor('Framework') + .should('have.text', 'arctic-embed-l'); + projectDetails + .getKserveTableRow('Test Name') + .findInfoValueFor('Model server replicas') + .should('have.text', '1'); + projectDetails + .getKserveTableRow('Test Name') + .findInfoValueFor('Model server size') + .should('contain.text', 'Small'); + projectDetails + .getKserveTableRow('Test Name') + .findInfoValueFor('Model server size') + .should('contain.text', '1 CPUs, 4Gi Memory requested'); + projectDetails + .getKserveTableRow('Test Name') + .findInfoValueFor('Model server size') + .should('contain.text', '2 CPUs, 8Gi Memory limit'); + projectDetails + .getKserveTableRow('Test Name') + .findInfoValueFor('Accelerator') + .should('have.text', 'No accelerator selected'); + }); + + it('should list the deployed model in Overview tab', () => { + initInterceptsToEnableNim({ hasAllModels: false }); + cy.interceptK8sList(InferenceServiceModel, mockK8sResourceList([mockNimInferenceService()])); + cy.interceptK8sList(ServingRuntimeModel, mockK8sResourceList([mockNimServingRuntime()])); + + projectDetails.visit('test-project'); + + // Card is visible + projectDetailsOverviewTab + .findDeployedModelServingRuntime('Test Name') + .should('have.text', 'NVIDIA NIM'); + }); + + it('should be blocked if failed to fetch NIM model list', () => { + initInterceptsToEnableNim({}); + projectDetailsOverviewTab.visit('test-project'); + cy.findByTestId('model-serving-platform-button').click(); + nimDeployModal.shouldDisplayError( + 'There was a problem fetching the NIM models. Please try again later.', + ); + nimDeployModal.findSubmitButton().should('be.disabled'); + }); + }); + + describe('Enabling NIM', () => { + describe('When NIM feature is enabled', () => { + it("should allow deploying NIM from a Project's Overview tab when the only platform", () => { + initInterceptsToEnableNim({}); + projectDetailsOverviewTab.visit('test-project'); + cy.findByTestId('model-serving-platform-button').click(); + nimDeployModal.shouldBeOpen(); + }); + + it("should allow deploying NIM from a Project's Overview tab when multiple platforms exist", () => { + initInterceptorsValidatingNimEnablement({ + disableKServe: false, + disableModelMesh: false, + disableNIMModelServing: false, + }); + projectDetailsOverviewTab.visit('test-project'); + projectDetailsOverviewTab + .findModelServingPlatform('nvidia-nim') + .findByTestId('model-serving-platform-button') + .click(); + nimDeployModal.shouldBeOpen(); + }); + + it("should allow deploying NIM from a Project's Models tab when the only platform", () => { + initInterceptsToEnableNim({}); + projectDetails.visitSection('test-project', 'model-server'); + cy.get('button[data-testid=deploy-button]').click(); + nimDeployModal.shouldBeOpen(); + }); + + it("should allow deploying NIM from a Project's Models tab when multiple platforms exist", () => { + initInterceptorsValidatingNimEnablement({ + disableKServe: false, + disableModelMesh: false, + disableNIMModelServing: false, + }); + projectDetails.visitSection('test-project', 'model-server'); + projectDetails + .findModelServingPlatform('nvidia-nim-model') + .findByTestId('nim-serving-deploy-button') + .click(); + nimDeployModal.shouldBeOpen(); + }); + }); + + describe('When NIM feature is disabled', () => { + it("should NOT allow deploying NIM from a Project's Overview tab when the only platform", () => { + initInterceptorsValidatingNimEnablement({ + disableKServe: true, + disableModelMesh: true, + disableNIMModelServing: true, + }); + projectDetailsOverviewTab.visit('test-project'); + cy.findByTestId('model-serving-platform-button').should('not.exist'); + }); + + it("should NOT allow deploying NIM from a Project's Overview tab when multiple platforms exist", () => { + initInterceptorsValidatingNimEnablement({ + disableKServe: false, + disableModelMesh: false, + disableNIMModelServing: true, + }); + projectDetailsOverviewTab.visit('test-project'); + projectDetailsOverviewTab.findModelServingPlatform('nvidia-nim').should('not.exist'); + cy.findByTestId('model-serving-platform-button').should('not.exist'); + }); + + it("should NOT allow deploying NIM from a Project's Models tab when the only platform", () => { + initInterceptorsValidatingNimEnablement({ + disableKServe: true, + disableModelMesh: true, + disableNIMModelServing: true, + }); + projectDetails.visitSection('test-project', 'model-server'); + cy.get('button[data-testid=deploy-button]').should('not.exist'); + }); + + it("should NOT allow deploying NIM to a Project's Models tab when multiple platforms exist", () => { + initInterceptorsValidatingNimEnablement({ + disableKServe: false, + disableModelMesh: false, + disableNIMModelServing: true, + }); + projectDetails.visitSection('test-project', 'model-server'); + projectDetails.findModelServingPlatform('nvidia-nim-model').should('not.exist'); + cy.findByTestId('nim-serving-deploy-button').should('not.exist'); + }); + }); + + describe('When the Template is missing', () => { + it("should NOT allow deploying NIM from a Project's Overview tab when the only platform", () => { + initInterceptorsValidatingNimEnablement( + { + disableKServe: false, + disableModelMesh: false, + disableNIMModelServing: false, + }, + true, + ); + projectDetailsOverviewTab.visit('test-project'); + cy.findByTestId('model-serving-platform-button').should('not.exist'); + }); + + it("should NOT allow deploying NIM from a Project's Overview tab when multiple platforms exist", () => { + initInterceptorsValidatingNimEnablement( + { + disableKServe: false, + disableModelMesh: false, + disableNIMModelServing: false, + }, + true, + ); + projectDetailsOverviewTab.visit('test-project'); + projectDetailsOverviewTab.findModelServingPlatform('nvidia-nim').should('not.exist'); + cy.findByTestId('model-serving-platform-button').should('not.exist'); + }); + + it("should NOT allow deploying NIM from a Project's Models tab when the only platform", () => { + initInterceptorsValidatingNimEnablement( + { + disableKServe: false, + disableModelMesh: false, + disableNIMModelServing: false, + }, + true, + ); + projectDetails.visitSection('test-project', 'model-server'); + cy.get('button[data-testid=deploy-button]').should('not.exist'); + }); + + it("should NOT allow deploying NIM to a Project's Models tab when multiple platforms exist", () => { + initInterceptorsValidatingNimEnablement( + { + disableKServe: false, + disableModelMesh: false, + disableNIMModelServing: false, + }, + true, + ); + projectDetails.visitSection('test-project', 'model-server'); + projectDetails.findModelServingPlatform('nvidia-nim-model').should('not.exist'); + cy.findByTestId('nim-serving-deploy-button').should('not.exist'); + }); + }); + }); + + describe('Deleting an existing model', () => { + it("should be the only option available from the Project's Models tab", () => { + initInterceptsToEnableNim({}); + initInterceptsForDeleteModel(); + + // go the Models tab in the created project + projectDetails.visitSection('test-project', 'model-server'); + // grab the deployed models table and click the kebab menu + cy.findByTestId('kserve-model-row-item').get('button[aria-label="Kebab toggle"').click(); + cy.get('ul[role="menu"]').should('have.length', 1); + cy.get('button').contains('Delete').should('exist'); + }); + + it('should delete the underlying InferenceService and ServingRuntime', () => { + initInterceptsToEnableNim({}); + initInterceptsForDeleteModel(); + + // go the Models tab in the created project + projectDetails.visitSection('test-project', 'model-server'); + // grab the deployed models table and click the kebab menu + cy.findByTestId('kserve-model-row-item').get('button[aria-label="Kebab toggle"').click(); + // grab the delete menu and click it + cy.get('button').contains('Delete').click(); + // grab the delete menu window and put in the project name + deleteModal.findInput().type('Test Name'); + // grab the delete button and click it + deleteModal.findSubmitButton().click(); + + // verify the model was deleted + cy.wait('@deleteInference'); + cy.wait('@deleteRuntime'); + }); + }); +}); diff --git a/frontend/src/__tests__/cypress/cypress/types.ts b/frontend/src/__tests__/cypress/cypress/types.ts index 6b7297b9aa..ea00beb7ac 100644 --- a/frontend/src/__tests__/cypress/cypress/types.ts +++ b/frontend/src/__tests__/cypress/cypress/types.ts @@ -1,4 +1,5 @@ import type { RouteMatcher } from 'cypress/types/net-stubbing'; +import type { ConfigMapKind, SecretKind } from '~/k8sTypes'; export type Snapshot = { method: string; @@ -71,3 +72,9 @@ export type TestConfig = { OCP_ADMIN_USER: UserAuthConfig; S3: AWSS3Buckets; }; + +export type NimServingResponse = { + body: { + body: ConfigMapKind | SecretKind; + }; +}; diff --git a/frontend/src/__tests__/cypress/cypress/utils/nimUtils.ts b/frontend/src/__tests__/cypress/cypress/utils/nimUtils.ts new file mode 100644 index 0000000000..e11cb1d7cb --- /dev/null +++ b/frontend/src/__tests__/cypress/cypress/utils/nimUtils.ts @@ -0,0 +1,157 @@ +import type { MockDashboardConfigType } from '~/__mocks__'; +import { + mock200Status, + mockDashboardConfig, + mockDscStatus, + mockK8sResourceList, + mockProjectK8sResource, + mockSecretK8sResource, +} from '~/__mocks__'; +import { + AcceleratorProfileModel, + ConfigMapModel, + InferenceServiceModel, + ProjectModel, + PVCModel, + SecretModel, + ServingRuntimeModel, + TemplateModel, +} from '~/__tests__/cypress/cypress/utils/models'; +import { + mockNimImages, + mockNimInferenceService, + mockNimModelPVC, + mockNimProject, + mockNimServingResource, + mockNimServingRuntime, + mockNimServingRuntimeTemplate, + mockNvidiaNimAccessSecret, + mockNvidiaNimImagePullSecret, +} from '~/__mocks__/mockNimResource'; +import { mockAcceleratorProfile } from '~/__mocks__/mockAcceleratorProfile'; +import type { InferenceServiceKind } from '~/k8sTypes'; + +/* ################################################### + ###### Interception Initialization Utilities ###### + ################################################### */ + +type EnableNimConfigType = { + hasAllModels?: boolean; +}; + +// intercept all APIs required for enabling NIM +export const initInterceptsToEnableNim = ({ hasAllModels = false }: EnableNimConfigType): void => { + cy.interceptOdh( + 'GET /api/dsc/status', + mockDscStatus({ + installedComponents: { + kserve: true, + 'model-mesh': true, + }, + }), + ); + + cy.interceptOdh( + 'GET /api/config', + mockDashboardConfig({ + disableKServe: false, + disableModelMesh: false, + disableNIMModelServing: false, + }), + ); + + cy.interceptK8sList(ProjectModel, mockK8sResourceList([mockNimProject(hasAllModels)])); + + const templateMock = mockNimServingRuntimeTemplate(); + cy.interceptK8sList(TemplateModel, mockK8sResourceList([templateMock])); + cy.interceptK8s(TemplateModel, templateMock); + + cy.interceptK8sList( + AcceleratorProfileModel, + mockK8sResourceList([mockAcceleratorProfile({ namespace: 'opendatahub' })]), + ); + + cy.interceptOdh('GET /api/accelerators', { + configured: true, + available: { 'nvidia.com/gpu': 1 }, + total: { 'nvidia.com/gpu': 1 }, + allocated: { 'nvidia.com/gpu': 1 }, + }); +}; + +// intercept all APIs required for deploying new NIM models in existing projects +export const initInterceptsToDeployModel = (nimInferenceService: InferenceServiceKind): void => { + cy.interceptK8s(ConfigMapModel, mockNimImages()); + cy.interceptK8s('POST', SecretModel, mockSecretK8sResource({})); + cy.interceptK8s('POST', InferenceServiceModel, nimInferenceService).as('createInferenceService'); + + cy.interceptK8s('POST', ServingRuntimeModel, mockNimServingRuntime()).as('createServingRuntime'); + + cy.interceptOdh( + `GET /api/nim-serving/:resource`, + { path: { resource: 'nvidia-nim-images-data' } }, + mockNimServingResource(mockNimImages()), + ); + + cy.interceptOdh( + `GET /api/nim-serving/:resource`, + { path: { resource: 'nvidia-nim-access' } }, + mockNimServingResource(mockNvidiaNimAccessSecret()), + ); + + cy.interceptOdh( + `GET /api/nim-serving/:resource`, + { path: { resource: 'nvidia-nim-image-pull' } }, + mockNimServingResource(mockNvidiaNimImagePullSecret()), + ); + + cy.interceptK8s('POST', PVCModel, mockNimModelPVC()); +}; + +// intercept all APIs required for deleting an existing NIM models +export const initInterceptsForDeleteModel = (): void => { + // create initial inference and runtime + cy.interceptK8sList(InferenceServiceModel, mockK8sResourceList([mockNimInferenceService()])); + cy.interceptK8sList(ServingRuntimeModel, mockK8sResourceList([mockNimServingRuntime()])); + + // intercept delete inference request + cy.interceptK8s( + 'DELETE', + { + model: InferenceServiceModel, + ns: 'test-project', + name: 'test-name', + }, + mock200Status({}), + ).as('deleteInference'); + + // intercept delete runtime request + cy.interceptK8s( + 'DELETE', + { + model: ServingRuntimeModel, + ns: 'test-project', + name: 'test-name', + }, + mock200Status({}), + ).as('deleteRuntime'); +}; + +// intercept all APIs required for verifying NIM enablement +export const initInterceptorsValidatingNimEnablement = ( + dashboardConfig: MockDashboardConfigType, + disableServingRuntime = false, +): void => { + cy.interceptOdh('GET /api/config', mockDashboardConfig(dashboardConfig)); + + if (!disableServingRuntime) { + const templateMock = mockNimServingRuntimeTemplate(); + cy.interceptK8sList(TemplateModel, mockK8sResourceList([templateMock])); + cy.interceptK8s(TemplateModel, templateMock); + } + + cy.interceptK8sList( + ProjectModel, + mockK8sResourceList([mockProjectK8sResource({ hasAnnotations: true })]), + ); +};