From bcb738361578d1a7c6eaf5177d5d184ce6f0c970 Mon Sep 17 00:00:00 2001 From: Simeon Widdis Date: Fri, 14 Jul 2023 15:21:21 -0700 Subject: [PATCH] Re-apply the integrations kibana backend PR (#680) * Merge Kibana backend from osints/dev into main (#565) * Merge in kibana backend from osints/dev Signed-off-by: Simeon Widdis * Add integration type to .kibana from osints/dev Signed-off-by: Simeon Widdis * Re-add license header Signed-off-by: Simeon Widdis * Fix integrations type Signed-off-by: Simeon Widdis --------- Signed-off-by: Simeon Widdis * Remove extra test files Signed-off-by: Simeon Widdis --------- Signed-off-by: Simeon Widdis --- .../integrations/__test__/builder.test.ts | 326 +++++++++++++++ .../__test__/kibana_backend.test.ts | 384 ++++++++++++++++++ .../integrations/integrations_builder.ts | 102 +++++ server/adaptors/integrations/types.ts | 6 +- server/adaptors/integrations/validators.ts | 11 +- server/plugin.ts | 27 ++ .../__tests__/integrations_router.test.ts | 22 +- .../integrations/integrations_router.ts | 4 +- 8 files changed, 853 insertions(+), 29 deletions(-) create mode 100644 server/adaptors/integrations/__test__/builder.test.ts create mode 100644 server/adaptors/integrations/__test__/kibana_backend.test.ts create mode 100644 server/adaptors/integrations/integrations_builder.ts diff --git a/server/adaptors/integrations/__test__/builder.test.ts b/server/adaptors/integrations/__test__/builder.test.ts new file mode 100644 index 000000000..81af1d6f6 --- /dev/null +++ b/server/adaptors/integrations/__test__/builder.test.ts @@ -0,0 +1,326 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { SavedObjectsClientContract } from '../../../../../../src/core/server'; +import { IntegrationInstanceBuilder } from '../integrations_builder'; +import { Integration } from '../repository/integration'; + +const mockSavedObjectsClient: SavedObjectsClientContract = ({ + bulkCreate: jest.fn(), + create: jest.fn(), + delete: jest.fn(), + find: jest.fn(), + get: jest.fn(), + update: jest.fn(), +} as unknown) as SavedObjectsClientContract; + +const sampleIntegration: Integration = ({ + deepCheck: jest.fn().mockResolvedValue(true), + getAssets: jest.fn().mockResolvedValue({ + savedObjects: [ + { + id: 'asset1', + references: [{ id: 'ref1' }], + }, + { + id: 'asset2', + references: [{ id: 'ref2' }], + }, + ], + }), + getConfig: jest.fn().mockResolvedValue({ + name: 'integration-template', + type: 'integration-type', + }), +} as unknown) as Integration; + +describe('IntegrationInstanceBuilder', () => { + let builder: IntegrationInstanceBuilder; + + beforeEach(() => { + builder = new IntegrationInstanceBuilder(mockSavedObjectsClient); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('build', () => { + it('should build an integration instance', async () => { + const options = { + dataSource: 'instance-datasource', + name: 'instance-name', + }; + + const remappedAssets = [ + { + id: 'remapped-asset1', + references: [{ id: 'remapped-ref1' }], + }, + { + id: 'remapped-asset2', + references: [{ id: 'remapped-ref2' }], + }, + ]; + const postAssetsResponse = { + saved_objects: [ + { id: 'created-asset1', type: 'dashboard', attributes: { title: 'Dashboard 1' } }, + { id: 'created-asset2', type: 'visualization', attributes: { title: 'Visualization 1' } }, + ], + }; + const expectedInstance = { + name: 'instance-name', + templateName: 'integration-template', + dataSource: 'instance-datasource', + creationDate: expect.any(String), + assets: [ + { + assetType: 'dashboard', + assetId: 'created-asset1', + status: 'available', + isDefaultAsset: true, + description: 'Dashboard 1', + }, + { + assetType: 'visualization', + assetId: 'created-asset2', + status: 'available', + isDefaultAsset: false, + description: 'Visualization 1', + }, + ], + }; + + // Mock the implementation of the methods in the Integration class + sampleIntegration.deepCheck = jest.fn().mockResolvedValue(true); + sampleIntegration.getAssets = jest.fn().mockResolvedValue({ savedObjects: remappedAssets }); + sampleIntegration.getConfig = jest.fn().mockResolvedValue({ + name: 'integration-template', + type: 'integration-type', + }); + + // Mock builder sub-methods + const remapIDsSpy = jest.spyOn(builder, 'remapIDs'); + const postAssetsSpy = jest.spyOn(builder, 'postAssets'); + + (mockSavedObjectsClient.bulkCreate as jest.Mock).mockResolvedValue(postAssetsResponse); + + const instance = await builder.build(sampleIntegration, options); + + expect(sampleIntegration.deepCheck).toHaveBeenCalled(); + expect(sampleIntegration.getAssets).toHaveBeenCalled(); + expect(remapIDsSpy).toHaveBeenCalledWith(remappedAssets); + expect(postAssetsSpy).toHaveBeenCalledWith(remappedAssets); + expect(instance).toEqual(expectedInstance); + }); + + it('should reject with an error if integration is not valid', async () => { + const options = { + dataSource: 'instance-datasource', + name: 'instance-name', + }; + sampleIntegration.deepCheck = jest.fn().mockResolvedValue(false); + + await expect(builder.build(sampleIntegration, options)).rejects.toThrowError( + 'Integration is not valid' + ); + }); + + it('should reject with an error if getAssets throws an error', async () => { + const options = { + dataSource: 'instance-datasource', + name: 'instance-name', + }; + + const errorMessage = 'Failed to get assets'; + sampleIntegration.deepCheck = jest.fn().mockResolvedValue(true); + sampleIntegration.getAssets = jest.fn().mockRejectedValue(new Error(errorMessage)); + + await expect(builder.build(sampleIntegration, options)).rejects.toThrowError(errorMessage); + }); + + it('should reject with an error if postAssets throws an error', async () => { + const options = { + dataSource: 'instance-datasource', + name: 'instance-name', + }; + const remappedAssets = [ + { + id: 'remapped-asset1', + references: [{ id: 'remapped-ref1' }], + }, + ]; + const errorMessage = 'Failed to post assets'; + sampleIntegration.deepCheck = jest.fn().mockResolvedValue(true); + sampleIntegration.getAssets = jest.fn().mockResolvedValue({ savedObjects: remappedAssets }); + builder.postAssets = jest.fn().mockRejectedValue(new Error(errorMessage)); + + await expect(builder.build(sampleIntegration, options)).rejects.toThrowError(errorMessage); + }); + + it('should reject with an error if getConfig returns null', async () => { + const options = { + dataSource: 'instance-datasource', + name: 'instance-name', + }; + sampleIntegration.getConfig = jest.fn().mockResolvedValue(null); + + await expect(builder.build(sampleIntegration, options)).rejects.toThrowError(); + }); + }); + + describe('remapIDs', () => { + it('should remap IDs and references in assets', () => { + const assets = [ + { + id: 'asset1', + references: [{ id: 'ref1' }, { id: 'ref2' }], + }, + { + id: 'asset2', + references: [{ id: 'ref1' }, { id: 'ref3' }], + }, + ]; + const expectedRemappedAssets = [ + { + id: expect.any(String), + references: [{ id: expect.any(String) }, { id: expect.any(String) }], + }, + { + id: expect.any(String), + references: [{ id: expect.any(String) }, { id: expect.any(String) }], + }, + ]; + + const remappedAssets = builder.remapIDs(assets); + + expect(remappedAssets).toEqual(expectedRemappedAssets); + }); + }); + + describe('postAssets', () => { + it('should post assets and return asset references', async () => { + const assets = [ + { + id: 'asset1', + type: 'dashboard', + attributes: { title: 'Dashboard 1' }, + }, + { + id: 'asset2', + type: 'visualization', + attributes: { title: 'Visualization 1' }, + }, + ]; + const expectedRefs = [ + { + assetType: 'dashboard', + assetId: 'created-asset1', + status: 'available', + isDefaultAsset: true, + description: 'Dashboard 1', + }, + { + assetType: 'visualization', + assetId: 'created-asset2', + status: 'available', + isDefaultAsset: false, + description: 'Visualization 1', + }, + ]; + const bulkCreateResponse = { + saved_objects: [ + { id: 'created-asset1', type: 'dashboard', attributes: { title: 'Dashboard 1' } }, + { id: 'created-asset2', type: 'visualization', attributes: { title: 'Visualization 1' } }, + ], + }; + + (mockSavedObjectsClient.bulkCreate as jest.Mock).mockResolvedValue(bulkCreateResponse); + + const refs = await builder.postAssets(assets); + + expect(mockSavedObjectsClient.bulkCreate).toHaveBeenCalledWith(assets); + expect(refs).toEqual(expectedRefs); + }); + + it('should reject with an error if bulkCreate throws an error', async () => { + const assets = [ + { + id: 'asset1', + type: 'dashboard', + attributes: { title: 'Dashboard 1' }, + }, + ]; + const errorMessage = 'Failed to create assets'; + (mockSavedObjectsClient.bulkCreate as jest.Mock).mockRejectedValue(new Error(errorMessage)); + + await expect(builder.postAssets(assets)).rejects.toThrowError(errorMessage); + }); + }); + + describe('buildInstance', () => { + it('should build an integration instance', async () => { + const integration = { + getConfig: jest.fn().mockResolvedValue({ + name: 'integration-template', + type: 'integration-type', + }), + }; + const refs = [ + { + assetType: 'dashboard', + assetId: 'created-asset1', + status: 'available', + isDefaultAsset: true, + description: 'Dashboard 1', + }, + ]; + const options = { + dataSource: 'instance-datasource', + name: 'instance-name', + }; + const expectedInstance = { + name: 'instance-name', + templateName: 'integration-template', + dataSource: 'instance-datasource', + tags: undefined, + creationDate: expect.any(String), + assets: refs, + }; + + const instance = await builder.buildInstance( + (integration as unknown) as Integration, + refs, + options + ); + + expect(integration.getConfig).toHaveBeenCalled(); + expect(instance).toEqual(expectedInstance); + }); + + it('should reject with an error if getConfig returns null', async () => { + const integration = { + getConfig: jest.fn().mockResolvedValue(null), + }; + const refs = [ + { + assetType: 'dashboard', + assetId: 'created-asset1', + status: 'available', + isDefaultAsset: true, + description: 'Dashboard 1', + }, + ]; + const options = { + dataSource: 'instance-datasource', + name: 'instance-name', + }; + + await expect( + builder.buildInstance((integration as unknown) as Integration, refs, options) + ).rejects.toThrowError(); + }); + }); +}); diff --git a/server/adaptors/integrations/__test__/kibana_backend.test.ts b/server/adaptors/integrations/__test__/kibana_backend.test.ts new file mode 100644 index 000000000..63d62764c --- /dev/null +++ b/server/adaptors/integrations/__test__/kibana_backend.test.ts @@ -0,0 +1,384 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { IntegrationsKibanaBackend } from '../integrations_kibana_backend'; +import { SavedObject, SavedObjectsClientContract } from '../../../../../../src/core/server/types'; +import { Repository } from '../repository/repository'; +import { IntegrationInstanceBuilder } from '../integrations_builder'; +import { Integration } from '../repository/integration'; +import { SavedObjectsFindResponse } from '../../../../../../src/core/server'; + +describe('IntegrationsKibanaBackend', () => { + let mockSavedObjectsClient: jest.Mocked; + let mockRepository: jest.Mocked; + let backend: IntegrationsKibanaBackend; + + beforeEach(() => { + mockSavedObjectsClient = { + get: jest.fn(), + find: jest.fn(), + create: jest.fn(), + delete: jest.fn(), + } as any; + mockRepository = { + getIntegration: jest.fn(), + getIntegrationList: jest.fn(), + } as any; + backend = new IntegrationsKibanaBackend(mockSavedObjectsClient, mockRepository); + }); + + describe('deleteIntegrationInstance', () => { + it('should delete the integration instance and associated assets', async () => { + const instanceId = 'instance-id'; + const asset1Id = 'asset1-id'; + const asset2Id = 'asset2-id'; + + const instanceData = { + attributes: { + assets: [ + { assetId: asset1Id, assetType: 'asset-type-1' }, + { assetId: asset2Id, assetType: 'asset-type-2' }, + ], + }, + }; + + mockSavedObjectsClient.get.mockResolvedValue(instanceData as SavedObject); + mockSavedObjectsClient.delete.mockResolvedValueOnce({}); + mockSavedObjectsClient.delete.mockResolvedValueOnce({}); + mockSavedObjectsClient.delete.mockResolvedValueOnce({}); + + const result = await backend.deleteIntegrationInstance(instanceId); + + expect(mockSavedObjectsClient.get).toHaveBeenCalledWith('integration-instance', instanceId); + expect(mockSavedObjectsClient.delete).toHaveBeenCalledWith('asset-type-1', asset1Id); + expect(mockSavedObjectsClient.delete).toHaveBeenCalledWith('asset-type-2', asset2Id); + expect(mockSavedObjectsClient.delete).toHaveBeenCalledWith( + 'integration-instance', + instanceId + ); + expect(result).toEqual([asset1Id, asset2Id, instanceId]); + }); + + it('should handle a 404 error when getting the integration instance', async () => { + const instanceId = 'instance-id'; + + mockSavedObjectsClient.get.mockRejectedValue({ output: { statusCode: 404 } }); + + const result = await backend.deleteIntegrationInstance(instanceId); + + expect(mockSavedObjectsClient.get).toHaveBeenCalledWith('integration-instance', instanceId); + expect(result).toEqual([instanceId]); + }); + + it('should handle a non-404 error when getting the integration instance', async () => { + const instanceId = 'instance-id'; + const error = new Error('Internal Server Error'); + + mockSavedObjectsClient.get.mockRejectedValue(error); + + await expect(backend.deleteIntegrationInstance(instanceId)).rejects.toThrow(error); + expect(mockSavedObjectsClient.get).toHaveBeenCalledWith('integration-instance', instanceId); + }); + + it('should handle a 404 error when deleting assets', async () => { + const instanceId = 'instance-id'; + const asset1Id = 'asset1-id'; + const asset2Id = 'asset2-id'; + + const instanceData = { + attributes: { + assets: [ + { assetId: asset1Id, assetType: 'asset-type-1' }, + { assetId: asset2Id, assetType: 'asset-type-2' }, + ], + }, + }; + + mockSavedObjectsClient.get.mockResolvedValue(instanceData as SavedObject); + mockSavedObjectsClient.delete.mockRejectedValueOnce({ output: { statusCode: 404 } }); + mockSavedObjectsClient.delete.mockRejectedValueOnce({ output: { statusCode: 404 } }); + mockSavedObjectsClient.delete.mockRejectedValueOnce({ output: { statusCode: 404 } }); + + const result = await backend.deleteIntegrationInstance(instanceId); + + expect(mockSavedObjectsClient.get).toHaveBeenCalledWith('integration-instance', instanceId); + expect(mockSavedObjectsClient.delete).toHaveBeenCalledWith('asset-type-1', asset1Id); + expect(mockSavedObjectsClient.delete).toHaveBeenCalledWith('asset-type-2', asset2Id); + expect(mockSavedObjectsClient.delete).toHaveBeenCalledWith( + 'integration-instance', + instanceId + ); + expect(result).toEqual([asset1Id, asset2Id, instanceId]); + }); + + it('should handle a non-404 error when deleting assets', async () => { + const instanceId = 'instance-id'; + const asset1Id = 'asset1-id'; + const asset2Id = 'asset2-id'; + + const instanceData = { + attributes: { + assets: [ + { assetId: asset1Id, assetType: 'asset-type-1' }, + { assetId: asset2Id, assetType: 'asset-type-2' }, + ], + }, + }; + + const error = new Error('Internal Server Error'); + + mockSavedObjectsClient.get.mockResolvedValue(instanceData as SavedObject); + mockSavedObjectsClient.delete.mockRejectedValueOnce({ output: { statusCode: 404 } }); + mockSavedObjectsClient.delete.mockRejectedValueOnce(error); + + await expect(backend.deleteIntegrationInstance(instanceId)).rejects.toThrow(error); + expect(mockSavedObjectsClient.get).toHaveBeenCalledWith('integration-instance', instanceId); + expect(mockSavedObjectsClient.delete).toHaveBeenCalledWith('asset-type-1', asset1Id); + expect(mockSavedObjectsClient.delete).toHaveBeenCalledWith('asset-type-2', asset2Id); + expect(mockSavedObjectsClient.delete).toHaveBeenCalledWith( + 'integration-instance', + instanceId + ); + }); + }); + + describe('getIntegrationTemplates', () => { + it('should get integration templates by name', async () => { + const query = { name: 'template1' }; + const integration = { getConfig: jest.fn().mockResolvedValue({ name: 'template1' }) }; + mockRepository.getIntegration.mockResolvedValue((integration as unknown) as Integration); + + const result = await backend.getIntegrationTemplates(query); + + expect(mockRepository.getIntegration).toHaveBeenCalledWith(query.name); + expect(integration.getConfig).toHaveBeenCalled(); + expect(result).toEqual({ hits: [await integration.getConfig()] }); + }); + + it('should get all integration templates', async () => { + const integrationList = [ + { getConfig: jest.fn().mockResolvedValue({ name: 'template1' }) }, + { getConfig: jest.fn().mockResolvedValue(null) }, + { getConfig: jest.fn().mockResolvedValue({ name: 'template2' }) }, + ]; + mockRepository.getIntegrationList.mockResolvedValue( + (integrationList as unknown) as Integration[] + ); + + const result = await backend.getIntegrationTemplates(); + + expect(mockRepository.getIntegrationList).toHaveBeenCalled(); + expect(integrationList[0].getConfig).toHaveBeenCalled(); + expect(integrationList[1].getConfig).toHaveBeenCalled(); + expect(integrationList[2].getConfig).toHaveBeenCalled(); + expect(result).toEqual({ + hits: [await integrationList[0].getConfig(), await integrationList[2].getConfig()], + }); + }); + }); + + describe('getIntegrationInstances', () => { + it('should get all integration instances', async () => { + const savedObjects = [ + { id: 'instance1', attributes: { name: 'instance1' } }, + { id: 'instance2', attributes: { name: 'instance2' } }, + ]; + const findResult = { total: savedObjects.length, saved_objects: savedObjects }; + mockSavedObjectsClient.find.mockResolvedValue( + (findResult as unknown) as SavedObjectsFindResponse + ); + + const result = await backend.getIntegrationInstances(); + + expect(mockSavedObjectsClient.find).toHaveBeenCalledWith({ type: 'integration-instance' }); + expect(result).toEqual({ + total: findResult.total, + hits: savedObjects.map((obj) => ({ id: obj.id, ...obj.attributes })), + }); + }); + }); + + describe('getIntegrationInstance', () => { + it('should get integration instance by ID', async () => { + const instanceId = 'instance1'; + const integrationInstance = { id: instanceId, attributes: { name: 'instance1' } }; + mockSavedObjectsClient.get.mockResolvedValue(integrationInstance as SavedObject); + + const result = await backend.getIntegrationInstance({ id: instanceId }); + + expect(mockSavedObjectsClient.get).toHaveBeenCalledWith('integration-instance', instanceId); + expect(result).toEqual({ id: instanceId, status: 'available', name: 'instance1' }); + }); + }); + + describe('loadIntegrationInstance', () => { + it('should load and create an integration instance', async () => { + const templateName = 'template1'; + const name = 'instance1'; + const template = { + getConfig: jest.fn().mockResolvedValue({ name: templateName }), + }; + const instanceBuilder = { + build: jest.fn().mockResolvedValue({ name, dataset: 'nginx', namespace: 'prod' }), + }; + const createdInstance = { name, dataset: 'nginx', namespace: 'prod' }; + mockRepository.getIntegration.mockResolvedValue((template as unknown) as Integration); + mockSavedObjectsClient.create.mockResolvedValue(({ + result: 'created', + } as unknown) as SavedObject); + backend.instanceBuilder = (instanceBuilder as unknown) as IntegrationInstanceBuilder; + + const result = await backend.loadIntegrationInstance(templateName, name, 'datasource'); + + expect(mockRepository.getIntegration).toHaveBeenCalledWith(templateName); + expect(instanceBuilder.build).toHaveBeenCalledWith(template, { + name, + dataSource: 'datasource', + }); + expect(mockSavedObjectsClient.create).toHaveBeenCalledWith( + 'integration-instance', + createdInstance + ); + expect(result).toEqual(createdInstance); + }); + + it('should reject with a 404 if template is not found', async () => { + const templateName = 'template1'; + mockRepository.getIntegration.mockResolvedValue(null); + + await expect( + backend.loadIntegrationInstance(templateName, 'instance1', 'datasource') + ).rejects.toHaveProperty('statusCode', 404); + }); + + it('should reject with an error status if building fails', async () => { + const templateName = 'template1'; + const name = 'instance1'; + const template = { + getConfig: jest.fn().mockResolvedValue({ name: templateName }), + }; + const instanceBuilder = { + build: jest.fn().mockRejectedValue(new Error('Failed to build instance')), + }; + backend.instanceBuilder = (instanceBuilder as unknown) as IntegrationInstanceBuilder; + mockRepository.getIntegration.mockResolvedValue((template as unknown) as Integration); + + await expect( + backend.loadIntegrationInstance(templateName, name, 'datasource') + ).rejects.toHaveProperty('statusCode'); + }); + }); + + describe('getStatic', () => { + it('should get static asset data', async () => { + const templateName = 'template1'; + const staticPath = 'path/to/static'; + const assetData = Buffer.from('asset data'); + const integration = { + getStatic: jest.fn().mockResolvedValue(assetData), + }; + mockRepository.getIntegration.mockResolvedValue((integration as unknown) as Integration); + + const result = await backend.getStatic(templateName, staticPath); + + expect(mockRepository.getIntegration).toHaveBeenCalledWith(templateName); + expect(integration.getStatic).toHaveBeenCalledWith(staticPath); + expect(result).toEqual(assetData); + }); + + it('should reject with a 404 if asset is not found', async () => { + const templateName = 'template1'; + const staticPath = 'path/to/static'; + mockRepository.getIntegration.mockResolvedValue(null); + + await expect(backend.getStatic(templateName, staticPath)).rejects.toHaveProperty( + 'statusCode', + 404 + ); + }); + }); + + describe('getAssetStatus', () => { + it('should return "available" if all assets are available', async () => { + const assets = [ + { assetId: 'asset1', assetType: 'type1' }, + { assetId: 'asset2', assetType: 'type2' }, + ]; + + const result = await backend.getAssetStatus(assets as AssetReference[]); + + expect(result).toBe('available'); + expect(mockSavedObjectsClient.get).toHaveBeenCalledTimes(2); + expect(mockSavedObjectsClient.get).toHaveBeenCalledWith('type1', 'asset1'); + expect(mockSavedObjectsClient.get).toHaveBeenCalledWith('type2', 'asset2'); + }); + + it('should return "unavailable" if every asset is unavailable', async () => { + mockSavedObjectsClient.get = jest + .fn() + .mockRejectedValueOnce({ output: { statusCode: 404 } }) + .mockRejectedValueOnce({ output: { statusCode: 404 } }) + .mockRejectedValueOnce({ output: { statusCode: 404 } }); + + const assets = [ + { assetId: 'asset1', assetType: 'type1' }, + { assetId: 'asset2', assetType: 'type2' }, + { assetId: 'asset3', assetType: 'type3' }, + ]; + + const result = await backend.getAssetStatus(assets as AssetReference[]); + + expect(result).toBe('unavailable'); + expect(mockSavedObjectsClient.get).toHaveBeenCalledTimes(3); + expect(mockSavedObjectsClient.get).toHaveBeenCalledWith('type1', 'asset1'); + expect(mockSavedObjectsClient.get).toHaveBeenCalledWith('type2', 'asset2'); + expect(mockSavedObjectsClient.get).toHaveBeenCalledWith('type3', 'asset3'); + }); + + it('should return "partially-available" if some assets are available and some are unavailable', async () => { + mockSavedObjectsClient.get = jest + .fn() + .mockResolvedValueOnce({}) // Available + .mockRejectedValueOnce({ output: { statusCode: 404 } }) // Unavailable + .mockResolvedValueOnce({}); // Available + + const assets = [ + { assetId: 'asset1', assetType: 'type1' }, + { assetId: 'asset2', assetType: 'type2' }, + { assetId: 'asset3', assetType: 'type3' }, + ]; + + const result = await backend.getAssetStatus(assets as AssetReference[]); + + expect(result).toBe('partially-available'); + expect(mockSavedObjectsClient.get).toHaveBeenCalledTimes(3); + expect(mockSavedObjectsClient.get).toHaveBeenCalledWith('type1', 'asset1'); + expect(mockSavedObjectsClient.get).toHaveBeenCalledWith('type2', 'asset2'); + expect(mockSavedObjectsClient.get).toHaveBeenCalledWith('type3', 'asset3'); + }); + + it('should return "unknown" if at least one asset has an unknown status', async () => { + mockSavedObjectsClient.get = jest + .fn() + .mockResolvedValueOnce({}) // Available + .mockRejectedValueOnce({}) // Unknown + .mockResolvedValueOnce({}); // Available + + const assets = [ + { assetId: 'asset1', assetType: 'type1' }, + { assetId: 'asset2', assetType: 'type2' }, + { assetId: 'asset3', assetType: 'type3' }, + ]; + + const result = await backend.getAssetStatus(assets as AssetReference[]); + + expect(result).toBe('unknown'); + expect(mockSavedObjectsClient.get).toHaveBeenCalledTimes(3); + expect(mockSavedObjectsClient.get).toHaveBeenCalledWith('type1', 'asset1'); + expect(mockSavedObjectsClient.get).toHaveBeenCalledWith('type2', 'asset2'); + expect(mockSavedObjectsClient.get).toHaveBeenCalledWith('type3', 'asset3'); + }); + }); +}); diff --git a/server/adaptors/integrations/integrations_builder.ts b/server/adaptors/integrations/integrations_builder.ts new file mode 100644 index 000000000..b12e1a132 --- /dev/null +++ b/server/adaptors/integrations/integrations_builder.ts @@ -0,0 +1,102 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { v4 as uuidv4 } from 'uuid'; +import { uuidRx } from 'public/components/custom_panels/redux/panel_slice'; +import { SavedObjectsClientContract } from '../../../../../src/core/server'; +import { Integration } from './repository/integration'; +import { SavedObjectsBulkCreateObject } from '../../../../../src/core/public'; + +interface BuilderOptions { + name: string; + dataSource: string; +} + +export class IntegrationInstanceBuilder { + client: SavedObjectsClientContract; + + constructor(client: SavedObjectsClientContract) { + this.client = client; + } + + async build(integration: Integration, options: BuilderOptions): Promise { + const instance = integration + .deepCheck() + .then((result) => { + if (!result) { + return Promise.reject(new Error('Integration is not valid')); + } + }) + .then(() => integration.getAssets()) + .then((assets) => this.remapIDs(assets.savedObjects!)) + .then((assets) => this.remapDataSource(assets, options.dataSource)) + .then((assets) => this.postAssets(assets)) + .then((refs) => this.buildInstance(integration, refs, options)); + return instance; + } + + remapDataSource(assets: any[], dataSource: string | undefined): any[] { + if (!dataSource) return assets; + assets = assets.map((asset) => { + if (asset.type === 'index-pattern') { + asset.attributes.title = dataSource; + } + return asset; + }); + return assets; + } + + remapIDs(assets: any[]): any[] { + const toRemap = assets.filter((asset) => asset.id); + const idMap = new Map(); + return toRemap.map((item) => { + if (!idMap.has(item.id)) { + idMap.set(item.id, uuidv4()); + } + item.id = idMap.get(item.id)!; + for (let ref = 0; ref < item.references.length; ref++) { + const refId = item.references[ref].id; + if (!idMap.has(refId)) { + idMap.set(refId, uuidv4()); + } + item.references[ref].id = idMap.get(refId)!; + } + return item; + }); + } + + async postAssets(assets: any[]): Promise { + try { + const response = await this.client.bulkCreate(assets as SavedObjectsBulkCreateObject[]); + const refs: AssetReference[] = response.saved_objects.map((obj: any) => { + return { + assetType: obj.type, + assetId: obj.id, + status: 'available', // Assuming a successfully created object is available + isDefaultAsset: obj.type === 'dashboard', // Assuming for now that dashboards are default + description: obj.attributes?.title, + }; + }); + return Promise.resolve(refs); + } catch (err: any) { + return Promise.reject(err); + } + } + + async buildInstance( + integration: Integration, + refs: AssetReference[], + options: BuilderOptions + ): Promise { + const config: IntegrationTemplate = (await integration.getConfig())!; + return Promise.resolve({ + name: options.name, + templateName: config.name, + dataSource: options.dataSource, + creationDate: new Date().toISOString(), + assets: refs, + }); + } +} diff --git a/server/adaptors/integrations/types.ts b/server/adaptors/integrations/types.ts index 58293580f..8e8d51ca8 100644 --- a/server/adaptors/integrations/types.ts +++ b/server/adaptors/integrations/types.ts @@ -55,11 +55,7 @@ interface IntegrationTemplateQuery { interface IntegrationInstance { name: string; templateName: string; - dataSource: { - sourceType: string; - dataset: string; - namespace: string; - }; + dataSource: string; creationDate: string; assets: AssetReference[]; } diff --git a/server/adaptors/integrations/validators.ts b/server/adaptors/integrations/validators.ts index 0bc7029b0..7c1964587 100644 --- a/server/adaptors/integrations/validators.ts +++ b/server/adaptors/integrations/validators.ts @@ -87,16 +87,7 @@ const instanceSchema: JSONSchemaType = { properties: { name: { type: 'string' }, templateName: { type: 'string' }, - dataSource: { - type: 'object', - properties: { - sourceType: { type: 'string' }, - dataset: { type: 'string' }, - namespace: { type: 'string' }, - }, - required: ['sourceType', 'dataset', 'namespace'], - additionalProperties: false, - }, + dataSource: { type: 'string' }, creationDate: { type: 'string' }, assets: { type: 'array', diff --git a/server/plugin.ts b/server/plugin.ts index f315f809b..31db6b3ef 100644 --- a/server/plugin.ts +++ b/server/plugin.ts @@ -78,7 +78,34 @@ export class ObservabilityPlugin }, }; + const integrationInstanceType: SavedObjectsType = { + name: 'integration-instance', + hidden: false, + namespaceType: 'single', + mappings: { + dynamic: false, + properties: { + name: { + type: 'text', + }, + templateName: { + type: 'text', + }, + dataSource: { + type: 'text', + }, + creationDate: { + type: 'date', + }, + assets: { + type: 'nested', + }, + }, + }, + }; + core.savedObjects.registerType(obsPanelType); + core.savedObjects.registerType(integrationInstanceType); // Register server side APIs setupRoutes({ router, client: openSearchObservabilityClient }); diff --git a/server/routes/integrations/__tests__/integrations_router.test.ts b/server/routes/integrations/__tests__/integrations_router.test.ts index c74a2e8db..15d2bac28 100644 --- a/server/routes/integrations/__tests__/integrations_router.test.ts +++ b/server/routes/integrations/__tests__/integrations_router.test.ts @@ -3,27 +3,26 @@ * SPDX-License-Identifier: Apache-2.0 */ +import { DeepPartial } from 'redux'; import { OpenSearchDashboardsResponseFactory } from '../../../../../../src/core/server/http/router'; import { handleWithCallback } from '../integrations_router'; import { IntegrationsAdaptor } from 'server/adaptors/integrations/integrations_adaptor'; -describe('handleWithCallback', () => { - let adaptorMock: jest.Mocked; - let responseMock: jest.Mocked; +jest + .mock('../../../../../../src/core/server', () => jest.fn()) + .mock('../../../../../../src/core/server/http/router', () => jest.fn()); - beforeEach(() => { - adaptorMock = {} as any; - responseMock = { - custom: jest.fn((data) => data), - ok: jest.fn((data) => data), - } as any; - }); +describe('Data wrapper', () => { + const adaptorMock: Partial = {}; + const responseMock: DeepPartial = { + custom: jest.fn((data) => data), + ok: jest.fn((data) => data), + }; it('retrieves data from the callback method', async () => { const callback = jest.fn((_) => { return { test: 'data' }; }); - const result = await handleWithCallback( adaptorMock as IntegrationsAdaptor, responseMock as OpenSearchDashboardsResponseFactory, @@ -39,7 +38,6 @@ describe('handleWithCallback', () => { const callback = jest.fn((_) => { throw new Error('test error'); }); - const result = await handleWithCallback( adaptorMock as IntegrationsAdaptor, responseMock as OpenSearchDashboardsResponseFactory, diff --git a/server/routes/integrations/integrations_router.ts b/server/routes/integrations/integrations_router.ts index 179fb6d77..5a4813127 100644 --- a/server/routes/integrations/integrations_router.ts +++ b/server/routes/integrations/integrations_router.ts @@ -13,6 +13,7 @@ import { OpenSearchDashboardsRequest, OpenSearchDashboardsResponseFactory, } from '../../../../../src/core/server/http/router'; +import { IntegrationsKibanaBackend } from '../../adaptors/integrations/integrations_kibana_backend'; /** * Handle an `OpenSearchDashboardsRequest` using the provided `callback` function. @@ -53,8 +54,7 @@ const getAdaptor = ( context: RequestHandlerContext, _request: OpenSearchDashboardsRequest ): IntegrationsAdaptor => { - // Stub - return {} as IntegrationsAdaptor; + return new IntegrationsKibanaBackend(context.core.savedObjects.client); }; export function registerIntegrationsRoute(router: IRouter) {