diff --git a/CHANGELOG.md b/CHANGELOG.md index 0634865b2876..66d8455859aa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -48,6 +48,7 @@ Inspired from [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) - [Workspace] Add a workspace client in workspace plugin ([#6094](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6094)) - [Multiple Datasource] Add component to show single selected data source in read only mode ([#6125](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6125)) - [Multiple Datasource] Add data source aggregated view to show all compatible data sources or only show used data sources ([#6129](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6129)) +- [Multiple Datasource] Add datasource version number to newly created data source object([#6178](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6178)) - [Workspace] Add workspace id in basePath ([#6060](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6060)) - Implement new home page ([#6065](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6065)) - Add sidecar service ([#5920](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5920)) diff --git a/src/plugins/data_source/server/plugin.ts b/src/plugins/data_source/server/plugin.ts index 12e01692b2e6..c239c816594e 100644 --- a/src/plugins/data_source/server/plugin.ts +++ b/src/plugins/data_source/server/plugin.ts @@ -30,6 +30,7 @@ import { DATA_SOURCE_SAVED_OBJECT_TYPE } from '../common'; import { ensureRawRequest } from '../../../../src/core/server/http/router'; import { createDataSourceError } from './lib/error'; import { registerTestConnectionRoute } from './routes/test_connection'; +import { registerFetchDataSourceVersionRoute } from './routes/fetch_data_source_version'; import { AuthenticationMethodRegistery, IAuthenticationMethodRegistery } from './auth_registry'; import { CustomApiSchemaRegistry } from './schema_registry'; @@ -133,6 +134,13 @@ export class DataSourcePlugin implements Plugin { this.logger.debug(`Registered Credential Provider for authType = ${method.name}`); diff --git a/src/plugins/data_source/server/routes/data_source_connection_validator.test.ts b/src/plugins/data_source/server/routes/data_source_connection_validator.test.ts index 219888199016..bb7a8b91d673 100644 --- a/src/plugins/data_source/server/routes/data_source_connection_validator.test.ts +++ b/src/plugins/data_source/server/routes/data_source_connection_validator.test.ts @@ -24,6 +24,23 @@ describe('DataSourceManagement: data_source_connection_validator.ts', () => { expect(validateDataSourcesResponse.statusCode).toBe(200); }); + test('fetchDataSourceVersion - Success: opensearch client response code is 200 and response body have version number', async () => { + const opensearchClient = opensearchServiceMock.createOpenSearchClient(); + opensearchClient.info.mockResolvedValue( + opensearchServiceMock.createApiResponse({ + statusCode: 200, + body: { + version: { + number: '2.11.0', + }, + }, + }) + ); + const dataSourceValidator = new DataSourceConnectionValidator(opensearchClient, {}); + const fetchDataSourcesVersionResponse = await dataSourceValidator.fetchDataSourceVersion(); + expect(fetchDataSourcesVersionResponse).toBe('2.11.0'); + }); + test('failure: opensearch client response code is 200 but response body not have cluster name', async () => { try { const opensearchClient = opensearchServiceMock.createOpenSearchClient(); @@ -43,6 +60,22 @@ describe('DataSourceManagement: data_source_connection_validator.ts', () => { } }); + // In case fetchDataSourceVersion call succeeded yet did not return version number, return an empty version instead of raising exceptions + test('fetchDataSourceVersion - Success:opensearch client response code is 200 but response body does not have version number', async () => { + const opensearchClient = opensearchServiceMock.createOpenSearchClient(); + opensearchClient.info.mockResolvedValue( + opensearchServiceMock.createApiResponse({ + statusCode: 200, + body: { + Message: 'Response without version number.', + }, + }) + ); + const dataSourceValidator = new DataSourceConnectionValidator(opensearchClient, {}); + const fetchDataSourcesVersionResponse = await dataSourceValidator.fetchDataSourceVersion(); + expect(fetchDataSourcesVersionResponse).toBe(''); + }); + test('failure: opensearch client response code is other than 200', async () => { const statusCodeList = [100, 202, 300, 400, 500]; statusCodeList.forEach(async function (code) { @@ -64,6 +97,25 @@ describe('DataSourceManagement: data_source_connection_validator.ts', () => { } }); }); + + // In case fetchDataSourceVersion call failed, return an empty version instead of raising exceptions + test('fetchDataSourceVersion - Failure: opensearch client response code is other than 200', async () => { + const statusCodeList = [100, 202, 300, 400, 500]; + statusCodeList.forEach(async function (code) { + const opensearchClient = opensearchServiceMock.createOpenSearchClient(); + opensearchClient.info.mockResolvedValue( + opensearchServiceMock.createApiResponse({ + statusCode: code, + body: { + Message: 'Your request is not correct.', + }, + }) + ); + const dataSourceValidator = new DataSourceConnectionValidator(opensearchClient, {}); + const fetchDataSourcesVersionResponse = await dataSourceValidator.fetchDataSourceVersion(); + expect(fetchDataSourcesVersionResponse).toBe(''); + }); + }); }); describe('Test datasource connection for SigV4 auth', () => { diff --git a/src/plugins/data_source/server/routes/data_source_connection_validator.ts b/src/plugins/data_source/server/routes/data_source_connection_validator.ts index 735d1429414c..60e00d855658 100644 --- a/src/plugins/data_source/server/routes/data_source_connection_validator.ts +++ b/src/plugins/data_source/server/routes/data_source_connection_validator.ts @@ -35,4 +35,27 @@ export class DataSourceConnectionValidator { throw createDataSourceError(e); } } + + async fetchDataSourceVersion() { + let dataSourceVersion = ''; + try { + // OpenSearch Serverless does not have version concept + if ( + this.dataSourceAttr.auth?.credentials?.service === SigV4ServiceName.OpenSearchServerless + ) { + return dataSourceVersion; + } + await this.callDataCluster + .info() + .then((response) => response.body) + .then((body) => { + dataSourceVersion = body.version.number; + }); + + return dataSourceVersion; + } catch (e) { + // return empty dataSoyrce version instead of throwing exception in case info() api call fails + return dataSourceVersion; + } + } } diff --git a/src/plugins/data_source/server/routes/fetch_data_source_version.test.ts b/src/plugins/data_source/server/routes/fetch_data_source_version.test.ts new file mode 100644 index 000000000000..d81073f9beba --- /dev/null +++ b/src/plugins/data_source/server/routes/fetch_data_source_version.test.ts @@ -0,0 +1,345 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import supertest from 'supertest'; +import { UnwrapPromise } from '@osd/utility-types'; +import { setupServer } from '../../../../../src/core/server/test_utils'; + +import { IAuthenticationMethodRegistery } from '../auth_registry'; +import { authenticationMethodRegisteryMock } from '../auth_registry/authentication_methods_registry.mock'; +import { CustomApiSchemaRegistry } from '../schema_registry'; +import { DataSourceServiceSetup } from '../../server/data_source_service'; +import { CryptographyServiceSetup } from '../cryptography_service'; +import { registerFetchDataSourceVersionRoute } from './fetch_data_source_version'; +import { AuthType } from '../../common/data_sources'; +// eslint-disable-next-line @osd/eslint/no-restricted-paths +import { opensearchClientMock } from '../../../../../src/core/server/opensearch/client/mocks'; + +type SetupServerReturn = UnwrapPromise>; + +const URL = '/internal/data-source-management/fetchDataSourceVersion'; + +describe(`Fetch DataSource Version ${URL}`, () => { + let server: SetupServerReturn['server']; + let httpSetup: SetupServerReturn['httpSetup']; + let handlerContext: SetupServerReturn['handlerContext']; + let cryptographyMock: jest.Mocked; + const customApiSchemaRegistry = new CustomApiSchemaRegistry(); + let customApiSchemaRegistryPromise: Promise; + let dataSourceClient: ReturnType; + let dataSourceServiceSetupMock: DataSourceServiceSetup; + let authRegistryPromiseMock: Promise; + const dataSourceAttr = { + endpoint: 'https://test.com', + auth: { + type: AuthType.UsernamePasswordType, + credentials: { + username: 'testUser', + password: 'testPassword', + }, + }, + }; + + const dataSourceAttrMissingCredentialForNoAuth = { + endpoint: 'https://test.com', + auth: { + type: AuthType.NoAuth, + credentials: {}, + }, + }; + + const dataSourceAttrMissingCredentialForBasicAuth = { + endpoint: 'https://test.com', + auth: { + type: AuthType.UsernamePasswordType, + credentials: {}, + }, + }; + + const dataSourceAttrMissingCredentialForSigV4Auth = { + endpoint: 'https://test.com', + auth: { + type: AuthType.SigV4, + credentials: {}, + }, + }; + + const dataSourceAttrPartialCredentialForSigV4Auth = { + endpoint: 'https://test.com', + auth: { + type: AuthType.SigV4, + credentials: { + accessKey: 'testKey', + service: 'service', + }, + }, + }; + + const dataSourceAttrPartialCredentialForBasicAuth = { + endpoint: 'https://test.com', + auth: { + type: AuthType.UsernamePasswordType, + credentials: { + username: 'testName', + }, + }, + }; + + const dataSourceAttrForSigV4Auth = { + endpoint: 'https://test.com', + auth: { + type: AuthType.SigV4, + credentials: { + accessKey: 'testKey', + service: 'es', + secretKey: 'testSecret', + region: 'testRegion', + }, + }, + }; + + const dataSourceAttrForRegisteredAuthWithCredentials = { + endpoint: 'https://test.com', + auth: { + type: 'Some Registered Type', + credentials: { + firstField: 'some value', + secondField: 'some value', + }, + }, + }; + + const dataSourceAttrForRegisteredAuthWithEmptyCredentials = { + endpoint: 'https://test.com', + auth: { + type: 'Some Registered Type', + credentials: {}, + }, + }; + + const dataSourceAttrForRegisteredAuthWithoutCredentials = { + endpoint: 'https://test.com', + auth: { + type: 'Some Registered Type', + }, + }; + + const dataSourceAttrForRegisteredAuthWithNoAuthType = { + endpoint: 'https://test.com', + auth: { + type: AuthType.NoAuth, + credentials: { + field: 'some value', + }, + }, + }; + + const dataSourceAttrForRegisteredAuthWithBasicAuthType = { + endpoint: 'https://test.com', + auth: { + type: AuthType.UsernamePasswordType, + credentials: {}, + }, + }; + + const dataSourceAttrForRegisteredAuthWithSigV4AuthType = { + endpoint: 'https://test.com', + auth: { + type: AuthType.SigV4, + credentials: {}, + }, + }; + + beforeEach(async () => { + ({ server, httpSetup, handlerContext } = await setupServer()); + customApiSchemaRegistryPromise = Promise.resolve(customApiSchemaRegistry); + authRegistryPromiseMock = Promise.resolve(authenticationMethodRegisteryMock.create()); + dataSourceClient = opensearchClientMock.createInternalClient(); + + dataSourceServiceSetupMock = { + getDataSourceClient: jest.fn(() => Promise.resolve(dataSourceClient)), + getDataSourceLegacyClient: jest.fn(), + }; + + const router = httpSetup.createRouter(''); + dataSourceClient.info.mockImplementationOnce(() => + opensearchClientMock.createSuccessTransportRequestPromise({ version: { number: '2.11.0' } }) + ); + registerFetchDataSourceVersionRoute( + router, + dataSourceServiceSetupMock, + cryptographyMock, + authRegistryPromiseMock, + customApiSchemaRegistryPromise + ); + + await server.start(); + }); + + afterEach(async () => { + await server.stop(); + }); + + it('shows successful response', async () => { + const result = await supertest(httpSetup.server.listener) + .post(URL) + .send({ + id: 'testId', + dataSourceAttr, + }) + .expect(200); + expect(result.body).toEqual({ dataSourceVersion: '2.11.0' }); + expect(dataSourceServiceSetupMock.getDataSourceClient).toHaveBeenCalledWith( + expect.objectContaining({ + savedObjects: handlerContext.savedObjects.client, + cryptography: cryptographyMock, + dataSourceId: 'testId', + testClientDataSourceAttr: dataSourceAttr, + customApiSchemaRegistryPromise, + }) + ); + }); + + it('no credential with no auth should succeed', async () => { + const result = await supertest(httpSetup.server.listener) + .post(URL) + .send({ + id: 'testId', + dataSourceAttr: dataSourceAttrMissingCredentialForNoAuth, + }) + .expect(200); + expect(result.body).toEqual({ dataSourceVersion: '2.11.0' }); + }); + + it('no credential with basic auth should fail', async () => { + const result = await supertest(httpSetup.server.listener) + .post(URL) + .send({ + id: 'testId', + dataSourceAttr: dataSourceAttrMissingCredentialForBasicAuth, + }) + .expect(400); + expect(result.body.error).toEqual('Bad Request'); + }); + + it('no credential with sigv4 auth should fail', async () => { + const result = await supertest(httpSetup.server.listener) + .post(URL) + .send({ + id: 'testId', + dataSourceAttr: dataSourceAttrMissingCredentialForSigV4Auth, + }) + .expect(400); + expect(result.body.error).toEqual('Bad Request'); + }); + + it('partial credential with sigv4 auth should fail', async () => { + const result = await supertest(httpSetup.server.listener) + .post(URL) + .send({ + id: 'testId', + dataSourceAttr: dataSourceAttrPartialCredentialForSigV4Auth, + }) + .expect(400); + expect(result.body.error).toEqual('Bad Request'); + }); + + it('partial credential with basic auth should fail', async () => { + const result = await supertest(httpSetup.server.listener) + .post(URL) + .send({ + id: 'testId', + dataSourceAttr: dataSourceAttrPartialCredentialForBasicAuth, + }) + .expect(400); + expect(result.body.error).toEqual('Bad Request'); + }); + + it('registered Auth with NoAuthType should fail', async () => { + const result = await supertest(httpSetup.server.listener) + .post(URL) + .send({ + id: 'testId', + dataSourceAttr: dataSourceAttrForRegisteredAuthWithNoAuthType, + }) + .expect(400); + expect(result.body.error).toEqual('Bad Request'); + expect(result.body.message).toContain( + `Must not be no_auth or username_password or sigv4 for registered auth types` + ); + }); + + it('registered Auth with Basic AuthType should fail', async () => { + const result = await supertest(httpSetup.server.listener) + .post(URL) + .send({ + id: 'testId', + dataSourceAttr: dataSourceAttrForRegisteredAuthWithBasicAuthType, + }) + .expect(400); + expect(result.body.error).toEqual('Bad Request'); + expect(result.body.message).toContain( + `Must not be no_auth or username_password or sigv4 for registered auth types` + ); + }); + + it('registered Auth with sigV4 AuthType should fail', async () => { + const result = await supertest(httpSetup.server.listener) + .post(URL) + .send({ + id: 'testId', + dataSourceAttr: dataSourceAttrForRegisteredAuthWithSigV4AuthType, + }) + .expect(400); + expect(result.body.error).toEqual('Bad Request'); + expect(result.body.message).toContain( + `Must not be no_auth or username_password or sigv4 for registered auth types` + ); + }); + + it('full credential with sigV4 auth should success', async () => { + const result = await supertest(httpSetup.server.listener) + .post(URL) + .send({ + id: 'testId', + dataSourceAttr: dataSourceAttrForSigV4Auth, + }) + .expect(200); + expect(result.body).toEqual({ dataSourceVersion: '2.11.0' }); + }); + + it('credential with registered auth type should success', async () => { + const result = await supertest(httpSetup.server.listener) + .post(URL) + .send({ + id: 'testId', + dataSourceAttr: dataSourceAttrForRegisteredAuthWithCredentials, + }) + .expect(200); + expect(result.body).toEqual({ dataSourceVersion: '2.11.0' }); + }); + + it('empty credential with registered auth type should success', async () => { + const result = await supertest(httpSetup.server.listener) + .post(URL) + .send({ + id: 'testId', + dataSourceAttr: dataSourceAttrForRegisteredAuthWithEmptyCredentials, + }) + .expect(200); + expect(result.body).toEqual({ dataSourceVersion: '2.11.0' }); + }); + + it('no credential with registered auth type should success', async () => { + const result = await supertest(httpSetup.server.listener) + .post(URL) + .send({ + id: 'testId', + dataSourceAttr: dataSourceAttrForRegisteredAuthWithoutCredentials, + }) + .expect(200); + expect(result.body).toEqual({ dataSourceVersion: '2.11.0' }); + }); +}); diff --git a/src/plugins/data_source/server/routes/fetch_data_source_version.ts b/src/plugins/data_source/server/routes/fetch_data_source_version.ts new file mode 100644 index 000000000000..5bd53f728e44 --- /dev/null +++ b/src/plugins/data_source/server/routes/fetch_data_source_version.ts @@ -0,0 +1,118 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { schema } from '@osd/config-schema'; +import { IRouter, OpenSearchClient } from 'opensearch-dashboards/server'; +import { AuthType, DataSourceAttributes, SigV4ServiceName } from '../../common/data_sources'; +import { DataSourceConnectionValidator } from './data_source_connection_validator'; +import { DataSourceServiceSetup } from '../data_source_service'; +import { CryptographyServiceSetup } from '../cryptography_service'; +import { IAuthenticationMethodRegistery } from '../auth_registry'; +import { CustomApiSchemaRegistry } from '../schema_registry/custom_api_schema_registry'; + +export const registerFetchDataSourceVersionRoute = async ( + router: IRouter, + dataSourceServiceSetup: DataSourceServiceSetup, + cryptography: CryptographyServiceSetup, + authRegistryPromise: Promise, + customApiSchemaRegistryPromise: Promise +) => { + const authRegistry = await authRegistryPromise; + router.post( + { + path: '/internal/data-source-management/fetchDataSourceVersion', + validate: { + body: schema.object({ + id: schema.maybe(schema.string()), + dataSourceAttr: schema.object({ + endpoint: schema.string(), + auth: schema.maybe( + schema.oneOf([ + schema.object({ + type: schema.literal(AuthType.NoAuth), + credentials: schema.object({}), + }), + schema.object({ + type: schema.literal(AuthType.UsernamePasswordType), + credentials: schema.object({ + username: schema.string(), + password: schema.string(), + }), + }), + schema.object({ + type: schema.literal(AuthType.SigV4), + credentials: schema.object({ + region: schema.string(), + accessKey: schema.string(), + secretKey: schema.string(), + service: schema.oneOf([ + schema.literal(SigV4ServiceName.OpenSearch), + schema.literal(SigV4ServiceName.OpenSearchServerless), + ]), + }), + }), + schema.object({ + type: schema.string({ + validate: (value) => { + if ( + value === AuthType.NoAuth || + value === AuthType.UsernamePasswordType || + value === AuthType.SigV4 + ) { + return `Must not be no_auth or username_password or sigv4 for registered auth types`; + } + }, + }), + credentials: schema.nullable(schema.any()), + }), + ]) + ), + }), + }), + }, + }, + async (context, request, response) => { + const { dataSourceAttr, id: dataSourceId } = request.body; + let dataSourceVersion = ''; + + try { + const dataSourceClient: OpenSearchClient = await dataSourceServiceSetup.getDataSourceClient( + { + savedObjects: context.core.savedObjects.client, + cryptography, + dataSourceId, + testClientDataSourceAttr: dataSourceAttr as DataSourceAttributes, + request, + authRegistry, + customApiSchemaRegistryPromise, + } + ); + + const dataSourceValidator = new DataSourceConnectionValidator( + dataSourceClient, + dataSourceAttr + ); + + dataSourceVersion = await dataSourceValidator.fetchDataSourceVersion(); + + return response.ok({ + body: { + dataSourceVersion, + }, + }); + } catch (err) { + return response.customError({ + statusCode: err.statusCode || 500, + body: { + message: err.message, + attributes: { + error: err.body?.error || err.message, + }, + }, + }); + } + } + ); +}; diff --git a/src/plugins/data_source_management/public/components/create_data_source_wizard/create_data_source_wizard.test.tsx b/src/plugins/data_source_management/public/components/create_data_source_wizard/create_data_source_wizard.test.tsx index adfbe8808637..1fe6e4f5d499 100644 --- a/src/plugins/data_source_management/public/components/create_data_source_wizard/create_data_source_wizard.test.tsx +++ b/src/plugins/data_source_management/public/components/create_data_source_wizard/create_data_source_wizard.test.tsx @@ -5,6 +5,7 @@ import React from 'react'; import { + fetchDataSourceVersion, getMappedDataSources, mockDataSourceAttributesWithAuth, mockManagementPlugin, @@ -27,6 +28,9 @@ describe('Datasource Management: Create Datasource Wizard', () => { describe('case1: should load resources successfully', () => { beforeEach(async () => { spyOn(utils, 'getDataSources').and.returnValue(Promise.resolve(getMappedDataSources)); + spyOn(utils, 'fetchDataSourceVersion').and.returnValue( + Promise.resolve(fetchDataSourceVersion) + ); await act(async () => { component = mount( wrapWithIntl( diff --git a/src/plugins/data_source_management/public/components/create_data_source_wizard/create_data_source_wizard.tsx b/src/plugins/data_source_management/public/components/create_data_source_wizard/create_data_source_wizard.tsx index 05489ca6258a..06b77efd9b94 100644 --- a/src/plugins/data_source_management/public/components/create_data_source_wizard/create_data_source_wizard.tsx +++ b/src/plugins/data_source_management/public/components/create_data_source_wizard/create_data_source_wizard.tsx @@ -16,7 +16,12 @@ import { } from '../../types'; import { getCreateBreadcrumbs } from '../breadcrumbs'; import { CreateDataSourceForm } from './components/create_form'; -import { createSingleDataSource, getDataSources, testConnection } from '../utils'; +import { + createSingleDataSource, + getDataSources, + testConnection, + fetchDataSourceVersion, +} from '../utils'; import { LoadingMask } from '../loading_mask'; type CreateDataSourceWizardProps = RouteComponentProps; @@ -68,6 +73,8 @@ export const CreateDataSourceWizard: React.FunctionComponent { setIsLoading(true); try { + const version = await fetchDataSourceVersion(http, attributes); + attributes.dataSourceVersion = version.dataSourceVersion; await createSingleDataSource(savedObjects.client, attributes); props.history.push(''); } catch (e) { diff --git a/src/plugins/data_source_management/public/components/utils.ts b/src/plugins/data_source_management/public/components/utils.ts index 5773363efd61..10ce2bb6bf43 100644 --- a/src/plugins/data_source_management/public/components/utils.ts +++ b/src/plugins/data_source_management/public/components/utils.ts @@ -119,6 +119,27 @@ export async function testConnection( }); } +export async function fetchDataSourceVersion( + http: HttpStart, + { endpoint, auth: { type, credentials } }: DataSourceAttributes, + dataSourceID?: string +) { + const query: any = { + id: dataSourceID, + dataSourceAttr: { + endpoint, + auth: { + type, + credentials, + }, + }, + }; + + return await http.post(`/internal/data-source-management/fetchDataSourceVersion`, { + body: JSON.stringify(query), + }); +} + export const isValidUrl = (endpoint: string) => { try { const url = new URL(endpoint); diff --git a/src/plugins/data_source_management/public/mocks.ts b/src/plugins/data_source_management/public/mocks.ts index 2d539cf19e12..e472860893ef 100644 --- a/src/plugins/data_source_management/public/mocks.ts +++ b/src/plugins/data_source_management/public/mocks.ts @@ -180,6 +180,10 @@ export const getMappedDataSources = [ }, ]; +export const fetchDataSourceVersion = { + dataSourceVersion: '2.11.0', +}; + export const mockDataSourceAttributesWithAuth = { id: 'test', title: 'create-test-ds', diff --git a/src/plugins/data_source_management/public/types.ts b/src/plugins/data_source_management/public/types.ts index bf0743468fd5..32c9a20b7f03 100644 --- a/src/plugins/data_source_management/public/types.ts +++ b/src/plugins/data_source_management/public/types.ts @@ -140,6 +140,7 @@ export interface DataSourceAttributes extends SavedObjectAttributes { title: string; description?: string; endpoint?: string; + dataSourceVersion?: string; auth: { type: AuthType | string; credentials: