diff --git a/CHANGELOG.md b/CHANGELOG.md index b7e9cff5c6e6..9e67c86790f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -74,6 +74,7 @@ Inspired from [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) - [Multiple Datasource] Refactor client and legacy client to use authentication registry ([#5881](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5881)) - [Multiple Datasource] Improved error handling for the search API when a null value is passed for the dataSourceId ([#5882](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5882)) - [Multiple Datasource] Hide/Show authentication method in multi data source plugin based on configuration ([#5916](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5916)) +- [[Dynamic Configurations] Add support for dynamic application configurations ([#5855](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5855)) ### 🐛 Bug Fixes diff --git a/config/opensearch_dashboards.yml b/config/opensearch_dashboards.yml index 4f86a8729a3e..f1858ac35dde 100644 --- a/config/opensearch_dashboards.yml +++ b/config/opensearch_dashboards.yml @@ -29,6 +29,13 @@ # dashboards. OpenSearch Dashboards creates a new index if the index doesn't already exist. #opensearchDashboards.index: ".opensearch_dashboards" +# OpenSearch Dashboards uses an index in OpenSearch to store dynamic configurations. +# This shall be a different index from opensearchDashboards.index. +# opensearchDashboards.configIndex: ".opensearch_dashboards_config" + +# Set the value of this setting to true to enable plugin application config. By default it is disabled. +# application_config.enabled: false + # The default application to load. #opensearchDashboards.defaultAppId: "home" diff --git a/src/core/server/mocks.ts b/src/core/server/mocks.ts index 2a6114013b22..687d408e40a6 100644 --- a/src/core/server/mocks.ts +++ b/src/core/server/mocks.ts @@ -77,6 +77,7 @@ export function pluginInitializerContextConfigMock(config: T) { const globalConfig: SharedGlobalConfig = { opensearchDashboards: { index: '.opensearch_dashboards_tests', + configIndex: '.opensearch_dashboards_config_tests', autocompleteTerminateAfter: duration(100000), autocompleteTimeout: duration(1000), }, diff --git a/src/core/server/opensearch_dashboards_config.ts b/src/core/server/opensearch_dashboards_config.ts index 107d02ea3377..47fa8a126501 100644 --- a/src/core/server/opensearch_dashboards_config.ts +++ b/src/core/server/opensearch_dashboards_config.ts @@ -48,6 +48,7 @@ export const config = { schema: schema.object({ enabled: schema.boolean({ defaultValue: true }), index: schema.string({ defaultValue: '.kibana' }), + configIndex: schema.string({ defaultValue: '.opensearch_dashboards_config' }), autocompleteTerminateAfter: schema.duration({ defaultValue: 100000 }), autocompleteTimeout: schema.duration({ defaultValue: 1000 }), branding: schema.object({ diff --git a/src/core/server/plugins/plugin_context.test.ts b/src/core/server/plugins/plugin_context.test.ts index 48c9eb6d6823..7a8ba042825b 100644 --- a/src/core/server/plugins/plugin_context.test.ts +++ b/src/core/server/plugins/plugin_context.test.ts @@ -98,6 +98,7 @@ describe('createPluginInitializerContext', () => { expect(configObject).toStrictEqual({ opensearchDashboards: { index: '.kibana', + configIndex: '.opensearch_dashboards_config', autocompleteTerminateAfter: duration(100000), autocompleteTimeout: duration(1000), }, diff --git a/src/core/server/plugins/types.ts b/src/core/server/plugins/types.ts index b7667b5bd2d2..59b9881279c3 100644 --- a/src/core/server/plugins/types.ts +++ b/src/core/server/plugins/types.ts @@ -287,7 +287,12 @@ export interface Plugin< export const SharedGlobalConfigKeys = { // We can add more if really needed - opensearchDashboards: ['index', 'autocompleteTerminateAfter', 'autocompleteTimeout'] as const, + opensearchDashboards: [ + 'index', + 'configIndex', + 'autocompleteTerminateAfter', + 'autocompleteTimeout', + ] as const, opensearch: ['shardTimeout', 'requestTimeout', 'pingTimeout'] as const, path: ['data'] as const, savedObjects: ['maxImportPayloadBytes'] as const, diff --git a/src/legacy/server/config/schema.js b/src/legacy/server/config/schema.js index 5cf8e9ac1901..a102268effca 100644 --- a/src/legacy/server/config/schema.js +++ b/src/legacy/server/config/schema.js @@ -227,6 +227,7 @@ export default () => opensearchDashboards: Joi.object({ enabled: Joi.boolean().default(true), index: Joi.string().default('.kibana'), + configIndex: Joi.string().default('.opensearch_dashboards_config'), autocompleteTerminateAfter: Joi.number().integer().min(1).default(100000), // TODO Also allow units here like in opensearch config once this is moved to the new platform autocompleteTimeout: Joi.number().integer().min(1).default(1000), diff --git a/src/plugins/application_config/README.md b/src/plugins/application_config/README.md new file mode 100755 index 000000000000..cad28722d63e --- /dev/null +++ b/src/plugins/application_config/README.md @@ -0,0 +1,112 @@ +# ApplicationConfig Plugin + +An OpenSearch Dashboards plugin for application configuration and a default implementation based on OpenSearch as storage. + +--- + +## Introduction + +This plugin introduces the support of dynamic application configurations as opposed to the existing static configuration in OSD YAML file `opensearch_dashboards.yml`. It stores the configuration in an index whose default name is `.opensearch_dashboards_config` and could be customized through the key `opensearchDashboards.configIndex` in OSD YAML file. Initially the new index does not exist. Only OSD users who need dynamic configurations will create it. + +It also provides an interface `ConfigurationClient` for future extensions of external configuration clients. A default implementation based on OpenSearch as database is used. + +This plugin is disabled by default. + +## Configuration + +OSD users who want to set up application configurations will first need to enable this plugin by the following line in OSD YML. + +``` +application_config.enabled: true + +``` + +Then they can perform configuration operations through CURL the OSD APIs. + +(Note that the commands following could be first obtained from a copy as curl option from the network tab of a browser development tool and then replaced with the API names) + +Below is the CURL command to view all configurations. + +``` +curl '{osd endpoint}/api/appconfig' -X GET +``` + +Below is the CURL command to view the configuration of an entity. + +``` +curl '{osd endpoint}/api/appconfig/{entity}' -X GET + +``` + +Below is the CURL command to update the configuration of an entity. + +``` +curl '{osd endpoint}/api/appconfig/{entity}' -X POST -H 'Accept: application/json' -H 'Content-Type: application/json' -H 'osd-xsrf: osd-fetch' -H 'Sec-Fetch-Dest: empty' --data-raw '{"newValue":"{new value}"}' +``` + +Below is the CURL command to delete the configuration of an entity. + +``` +curl '{osd endpoint}/api/appconfig/{entity}' -X DELETE -H 'osd-xsrf: osd-fetch' -H 'Sec-Fetch-Dest: empty' + +``` + + +## External Configuration Clients + +While a default OpenSearch based client is implemented, OSD users can use external configuration clients through an OSD plugin (outside OSD). + +Let's call this plugin `MyConfigurationClientPlugin`. + +First, this plugin will need to implement a class `MyConfigurationClient` based on interface `ConfigurationClient` defined in the `types.ts` under directory `src/plugins/application_config/server/types.ts`. Below are the functions inside the interface. + +``` + getConfig(): Promise>; + + getEntityConfig(entity: string): Promise; + + updateEntityConfig(entity: string, newValue: string): Promise; + + deleteEntityConfig(entity: string): Promise; +``` + +Second, this plugin needs to declare `applicationConfig` as its dependency by adding it to `requiredPlugins` in its own `opensearch_dashboards.json`. + +Third, the plugin will define a new type called `AppPluginSetupDependencies` as follows in its own `types.ts`. + +``` +export interface AppPluginSetupDependencies { + applicationConfig: ApplicationConfigPluginSetup; +} + +``` + +Then the plugin will import the new type `AppPluginSetupDependencies` and add to its own setup input. Below is the skeleton of the class `MyConfigurationClientPlugin`. + +``` +// MyConfigurationClientPlugin + public setup(core: CoreSetup, { applicationConfig }: AppPluginSetupDependencies) { + + ... + // The function createClient provides an instance of ConfigurationClient which + // could have a underlying DynamoDB or Postgres implementation. + const myConfigurationClient: ConfigurationClient = this.createClient(); + + applicationConfig.registerConfigurationClient(myConfigurationClient); + ... + return {}; + } + +``` + +## Onboarding Configurations + +Since the APIs and interfaces can take an entity, a new use case to this plugin could just pass their entity into the parameters. There is no need to implement new APIs or interfaces. To programmatically call the functions in `ConfigurationClient` from a plugin (the caller plugin), below is the code example. + +Similar to [section](#external-configuration-clients), a new type `AppPluginSetupDependencies` which encapsulates `ApplicationConfigPluginSetup` is needed. Then it can be imported into the `setup` function of the caller plugin. Then the caller plugin will have access to the `getConfigurationClient` and `registerConfigurationClient` exposed by `ApplicationConfigPluginSetup`. + +## Development + +See the [OpenSearch Dashboards contributing +guide](https://github.com/opensearch-project/OpenSearch-Dashboards/blob/main/CONTRIBUTING.md) for instructions +setting up your development environment. diff --git a/src/plugins/application_config/common/index.ts b/src/plugins/application_config/common/index.ts new file mode 100644 index 000000000000..57af4908f4a3 --- /dev/null +++ b/src/plugins/application_config/common/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export const PLUGIN_ID = 'applicationConfig'; +export const PLUGIN_NAME = 'application_config'; diff --git a/src/plugins/application_config/config.ts b/src/plugins/application_config/config.ts new file mode 100644 index 000000000000..4968c8a9a7c7 --- /dev/null +++ b/src/plugins/application_config/config.ts @@ -0,0 +1,12 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { schema, TypeOf } from '@osd/config-schema'; + +export const configSchema = schema.object({ + enabled: schema.boolean({ defaultValue: false }), +}); + +export type ApplicationConfigSchema = TypeOf; diff --git a/src/plugins/application_config/opensearch_dashboards.json b/src/plugins/application_config/opensearch_dashboards.json new file mode 100644 index 000000000000..728c282a2108 --- /dev/null +++ b/src/plugins/application_config/opensearch_dashboards.json @@ -0,0 +1,9 @@ +{ + "id": "applicationConfig", + "version": "opensearchDashboards", + "opensearchDashboardsVersion": "opensearchDashboards", + "server": true, + "ui": false, + "requiredPlugins": [], + "optionalPlugins": [] +} \ No newline at end of file diff --git a/src/plugins/application_config/server/index.ts b/src/plugins/application_config/server/index.ts new file mode 100644 index 000000000000..1ef2bbc3baf9 --- /dev/null +++ b/src/plugins/application_config/server/index.ts @@ -0,0 +1,23 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { PluginConfigDescriptor, PluginInitializerContext } from '../../../core/server'; +import { ApplicationConfigSchema, configSchema } from '../config'; +import { ApplicationConfigPlugin } from './plugin'; + +/* +This exports static code and TypeScript types, +as well as, OpenSearch Dashboards Platform `plugin()` initializer. +*/ + +export const config: PluginConfigDescriptor = { + schema: configSchema, +}; + +export function plugin(initializerContext: PluginInitializerContext) { + return new ApplicationConfigPlugin(initializerContext); +} + +export { ApplicationConfigPluginSetup, ApplicationConfigPluginStart } from './types'; diff --git a/src/plugins/application_config/server/opensearch_config_client.test.ts b/src/plugins/application_config/server/opensearch_config_client.test.ts new file mode 100644 index 000000000000..827d309303cb --- /dev/null +++ b/src/plugins/application_config/server/opensearch_config_client.test.ts @@ -0,0 +1,359 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { ResponseError } from '@opensearch-project/opensearch/lib/errors'; +import { OpenSearchConfigurationClient } from './opensearch_config_client'; +import { MockedLogger, loggerMock } from '@osd/logging/target/mocks'; + +const INDEX_NAME = 'test_index'; +const ERROR_MESSAGE = 'Service unavailable'; +const ERROR_MESSSAGE_FOR_EMPTY_INPUT = 'Input cannot be empty!'; +const EMPTY_INPUT = ' '; + +describe('OpenSearch Configuration Client', () => { + let logger: MockedLogger; + + beforeEach(() => { + logger = loggerMock.create(); + }); + + describe('getConfig', () => { + it('returns configurations from the index', async () => { + const opensearchClient = { + asInternalUser: { + search: jest.fn().mockImplementation(() => { + return { + body: { + hits: { + hits: [ + { + _id: 'config1', + _source: { + value: 'value1', + }, + }, + { + _id: 'config2', + _source: { + value: 'value2', + }, + }, + ], + }, + }, + }; + }), + }, + }; + + const client = new OpenSearchConfigurationClient(opensearchClient, INDEX_NAME, logger); + + const value = await client.getConfig(); + + expect(JSON.stringify(value)).toBe(JSON.stringify({ config1: 'value1', config2: 'value2' })); + }); + + it('throws error when opensearch errors happen', async () => { + const error = new ResponseError({ + statusCode: 401, + body: { + error: { + type: ERROR_MESSAGE, + }, + }, + warnings: [], + headers: { + 'WWW-Authenticate': 'content', + }, + meta: {} as any, + }); + + const opensearchClient = { + asInternalUser: { + search: jest.fn().mockImplementation(() => { + throw error; + }), + }, + }; + const client = new OpenSearchConfigurationClient(opensearchClient, INDEX_NAME, logger); + + await expect(client.getConfig()).rejects.toThrowError(ERROR_MESSAGE); + }); + }); + + describe('getEntityConfig', () => { + it('return configuration value from the document in the index', async () => { + const opensearchClient = { + asInternalUser: { + get: jest.fn().mockImplementation(() => { + return { + body: { + _source: { + value: 'value1', + }, + }, + }; + }), + }, + }; + + const client = new OpenSearchConfigurationClient(opensearchClient, INDEX_NAME, logger); + + const value = await client.getEntityConfig('config1'); + + expect(value).toBe('value1'); + }); + + it('throws error when input is empty', async () => { + const opensearchClient = { + asInternalUser: { + get: jest.fn().mockImplementation(() => { + return { + body: { + _source: { + value: 'value1', + }, + }, + }; + }), + }, + }; + + const client = new OpenSearchConfigurationClient(opensearchClient, INDEX_NAME, logger); + + await expect(client.getEntityConfig(EMPTY_INPUT)).rejects.toThrowError( + ERROR_MESSSAGE_FOR_EMPTY_INPUT + ); + }); + + it('throws error when opensearch errors happen', async () => { + const error = new ResponseError({ + statusCode: 401, + body: { + error: { + type: ERROR_MESSAGE, + }, + }, + warnings: [], + headers: { + 'WWW-Authenticate': 'content', + }, + meta: {} as any, + }); + + const opensearchClient = { + asInternalUser: { + get: jest.fn().mockImplementation(() => { + throw error; + }), + }, + }; + + const client = new OpenSearchConfigurationClient(opensearchClient, INDEX_NAME, logger); + + await expect(client.getEntityConfig('config1')).rejects.toThrowError(ERROR_MESSAGE); + }); + }); + + describe('deleteEntityConfig', () => { + it('return deleted entity when opensearch deletes successfully', async () => { + const opensearchClient = { + asCurrentUser: { + delete: jest.fn().mockImplementation(() => { + return {}; + }), + }, + }; + + const client = new OpenSearchConfigurationClient(opensearchClient, INDEX_NAME, logger); + + const value = await client.deleteEntityConfig('config1'); + + expect(value).toBe('config1'); + }); + + it('throws error when input entity is empty', async () => { + const opensearchClient = { + asCurrentUser: { + delete: jest.fn().mockImplementation(() => { + return {}; + }), + }, + }; + + const client = new OpenSearchConfigurationClient(opensearchClient, INDEX_NAME, logger); + + await expect(client.deleteEntityConfig(EMPTY_INPUT)).rejects.toThrowError( + ERROR_MESSSAGE_FOR_EMPTY_INPUT + ); + }); + + it('return deleted document entity when deletion fails due to index not found', async () => { + const error = new ResponseError({ + statusCode: 401, + body: { + error: { + type: 'index_not_found_exception', + }, + }, + warnings: [], + headers: { + 'WWW-Authenticate': 'content', + }, + meta: {} as any, + }); + + const opensearchClient = { + asCurrentUser: { + delete: jest.fn().mockImplementation(() => { + throw error; + }), + }, + }; + + const client = new OpenSearchConfigurationClient(opensearchClient, INDEX_NAME, logger); + + const value = await client.deleteEntityConfig('config1'); + + expect(value).toBe('config1'); + }); + + it('return deleted document entity when deletion fails due to document not found', async () => { + const error = new ResponseError({ + statusCode: 401, + body: { + result: 'not_found', + }, + warnings: [], + headers: { + 'WWW-Authenticate': 'content', + }, + meta: {} as any, + }); + + const opensearchClient = { + asCurrentUser: { + delete: jest.fn().mockImplementation(() => { + throw error; + }), + }, + }; + + const client = new OpenSearchConfigurationClient(opensearchClient, INDEX_NAME, logger); + + const value = await client.deleteEntityConfig('config1'); + + expect(value).toBe('config1'); + }); + + it('throws error when opensearch throws error', async () => { + const error = new ResponseError({ + statusCode: 401, + body: { + error: { + type: ERROR_MESSAGE, + }, + }, + warnings: [], + headers: { + 'WWW-Authenticate': 'content', + }, + meta: {} as any, + }); + + const opensearchClient = { + asCurrentUser: { + delete: jest.fn().mockImplementation(() => { + throw error; + }), + }, + }; + + const client = new OpenSearchConfigurationClient(opensearchClient, INDEX_NAME, logger); + + await expect(client.deleteEntityConfig('config1')).rejects.toThrowError(ERROR_MESSAGE); + }); + }); + + describe('updateEntityConfig', () => { + it('returns updated value when opensearch updates successfully', async () => { + const opensearchClient = { + asCurrentUser: { + index: jest.fn().mockImplementation(() => { + return {}; + }), + }, + }; + + const client = new OpenSearchConfigurationClient(opensearchClient, INDEX_NAME, logger); + + const value = await client.updateEntityConfig('config1', 'newValue1'); + + expect(value).toBe('newValue1'); + }); + + it('throws error when entity is empty ', async () => { + const opensearchClient = { + asCurrentUser: { + index: jest.fn().mockImplementation(() => { + return {}; + }), + }, + }; + + const client = new OpenSearchConfigurationClient(opensearchClient, INDEX_NAME, logger); + + await expect(client.updateEntityConfig(EMPTY_INPUT, 'newValue1')).rejects.toThrowError( + ERROR_MESSSAGE_FOR_EMPTY_INPUT + ); + }); + + it('throws error when new value is empty ', async () => { + const opensearchClient = { + asCurrentUser: { + index: jest.fn().mockImplementation(() => { + return {}; + }), + }, + }; + + const client = new OpenSearchConfigurationClient(opensearchClient, INDEX_NAME, logger); + + await expect(client.updateEntityConfig('config1', EMPTY_INPUT)).rejects.toThrowError( + ERROR_MESSSAGE_FOR_EMPTY_INPUT + ); + }); + + it('throws error when opensearch throws error', async () => { + const error = new ResponseError({ + statusCode: 401, + body: { + error: { + type: ERROR_MESSAGE, + }, + }, + warnings: [], + headers: { + 'WWW-Authenticate': 'content', + }, + meta: {} as any, + }); + + const opensearchClient = { + asCurrentUser: { + index: jest.fn().mockImplementation(() => { + throw error; + }), + }, + }; + + const client = new OpenSearchConfigurationClient(opensearchClient, INDEX_NAME, logger); + + await expect(client.updateEntityConfig('config1', 'newValue1')).rejects.toThrowError( + ERROR_MESSAGE + ); + }); + }); +}); diff --git a/src/plugins/application_config/server/opensearch_config_client.ts b/src/plugins/application_config/server/opensearch_config_client.ts new file mode 100644 index 000000000000..9103919c396f --- /dev/null +++ b/src/plugins/application_config/server/opensearch_config_client.ts @@ -0,0 +1,123 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { IScopedClusterClient, Logger } from '../../../../src/core/server'; + +import { ConfigurationClient } from './types'; +import { validate } from './string_utils'; + +export class OpenSearchConfigurationClient implements ConfigurationClient { + private client: IScopedClusterClient; + private configurationIndexName: string; + private readonly logger: Logger; + + constructor( + scopedClusterClient: IScopedClusterClient, + configurationIndexName: string, + logger: Logger + ) { + this.client = scopedClusterClient; + this.configurationIndexName = configurationIndexName; + this.logger = logger; + } + + async getEntityConfig(entity: string) { + const entityValidated = validate(entity, this.logger); + + try { + const data = await this.client.asInternalUser.get({ + index: this.configurationIndexName, + id: entityValidated, + }); + + return data?.body?._source?.value || ''; + } catch (e) { + const errorMessage = `Failed to get entity ${entityValidated} due to error ${e}`; + + this.logger.error(errorMessage); + + throw e; + } + } + + async updateEntityConfig(entity: string, newValue: string) { + const entityValidated = validate(entity, this.logger); + const newValueValidated = validate(newValue, this.logger); + + try { + await this.client.asCurrentUser.index({ + index: this.configurationIndexName, + id: entityValidated, + body: { + value: newValueValidated, + }, + }); + + return newValueValidated; + } catch (e) { + const errorMessage = `Failed to update entity ${entityValidated} with newValue ${newValueValidated} due to error ${e}`; + + this.logger.error(errorMessage); + + throw e; + } + } + + async deleteEntityConfig(entity: string) { + const entityValidated = validate(entity, this.logger); + + try { + await this.client.asCurrentUser.delete({ + index: this.configurationIndexName, + id: entityValidated, + }); + + return entityValidated; + } catch (e) { + if (e?.body?.error?.type === 'index_not_found_exception') { + this.logger.info('Attemp to delete a not found index.'); + return entityValidated; + } + + if (e?.body?.result === 'not_found') { + this.logger.info('Attemp to delete a not found document.'); + return entityValidated; + } + + const errorMessage = `Failed to delete entity ${entityValidated} due to error ${e}`; + + this.logger.error(errorMessage); + + throw e; + } + } + + async getConfig(): Promise> { + try { + const data = await this.client.asInternalUser.search({ + index: this.configurationIndexName, + }); + + return this.transformIndexSearchResponse(data.body.hits.hits); + } catch (e) { + const errorMessage = `Failed to call getConfig due to error ${e}`; + + this.logger.error(errorMessage); + + throw e; + } + } + + transformIndexSearchResponse(hits): Map { + const configurations = {}; + + for (let i = 0; i < hits.length; i++) { + const doc = hits[i]; + configurations[doc._id] = doc?._source?.value; + } + + return configurations; + } +} diff --git a/src/plugins/application_config/server/plugin.test.ts b/src/plugins/application_config/server/plugin.test.ts new file mode 100644 index 000000000000..e1ac45444c14 --- /dev/null +++ b/src/plugins/application_config/server/plugin.test.ts @@ -0,0 +1,76 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { of } from 'rxjs'; +import { ApplicationConfigPlugin } from './plugin'; +import { ConfigurationClient } from './types'; + +describe('application config plugin', () => { + it('throws error when trying to register twice', async () => { + const initializerContext = { + logger: { + get: jest.fn().mockImplementation(() => { + return { + info: jest.fn(), + error: jest.fn(), + }; + }), + }, + config: { + legacy: { + globalConfig$: of({ + opensearchDashboards: { + configIndex: '.osd_test', + }, + }), + }, + }, + }; + + const plugin = new ApplicationConfigPlugin(initializerContext); + + const coreSetup = { + http: { + createRouter: jest.fn().mockImplementation(() => { + return { + get: jest.fn(), + post: jest.fn(), + delete: jest.fn(), + }; + }), + }, + }; + + const setup = await plugin.setup(coreSetup); + + const client1: ConfigurationClient = { + getConfig: jest.fn(), + getEntityConfig: jest.fn(), + updateEntityConfig: jest.fn(), + deleteEntityConfig: jest.fn(), + }; + + setup.registerConfigurationClient(client1); + + const scopedClient = {}; + expect(setup.getConfigurationClient(scopedClient)).toBe(client1); + + const client2: ConfigurationClient = { + getConfig: jest.fn(), + getEntityConfig: jest.fn(), + updateEntityConfig: jest.fn(), + deleteEntityConfig: jest.fn(), + }; + + // call the register function again + const secondCall = () => setup.registerConfigurationClient(client2); + + expect(secondCall).toThrowError( + 'Configuration client is already registered! Cannot register again!' + ); + + expect(setup.getConfigurationClient(scopedClient)).toBe(client1); + }); +}); diff --git a/src/plugins/application_config/server/plugin.ts b/src/plugins/application_config/server/plugin.ts new file mode 100644 index 000000000000..d0bd2ab42270 --- /dev/null +++ b/src/plugins/application_config/server/plugin.ts @@ -0,0 +1,88 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Observable } from 'rxjs'; +import { first } from 'rxjs/operators'; + +import { + PluginInitializerContext, + CoreSetup, + CoreStart, + Plugin, + Logger, + IScopedClusterClient, + SharedGlobalConfig, +} from '../../../core/server'; + +import { + ApplicationConfigPluginSetup, + ApplicationConfigPluginStart, + ConfigurationClient, +} from './types'; +import { defineRoutes } from './routes'; +import { OpenSearchConfigurationClient } from './opensearch_config_client'; + +export class ApplicationConfigPlugin + implements Plugin { + private readonly logger: Logger; + private readonly config$: Observable; + + private configurationClient: ConfigurationClient; + private configurationIndexName: string; + + constructor(initializerContext: PluginInitializerContext) { + this.logger = initializerContext.logger.get(); + this.config$ = initializerContext.config.legacy.globalConfig$; + this.configurationIndexName = ''; + } + + private registerConfigurationClient(configurationClient: ConfigurationClient) { + this.logger.info('Register a configuration client.'); + + if (this.configurationClient) { + const errorMessage = 'Configuration client is already registered! Cannot register again!'; + this.logger.error(errorMessage); + throw new Error(errorMessage); + } + + this.configurationClient = configurationClient; + } + + private getConfigurationClient(scopedClusterClient: IScopedClusterClient): ConfigurationClient { + if (this.configurationClient) { + return this.configurationClient; + } + + const openSearchConfigurationClient = new OpenSearchConfigurationClient( + scopedClusterClient, + this.configurationIndexName, + this.logger + ); + + return openSearchConfigurationClient; + } + + public async setup(core: CoreSetup) { + const router = core.http.createRouter(); + + const config = await this.config$.pipe(first()).toPromise(); + + this.configurationIndexName = config.opensearchDashboards.configIndex; + + // Register server side APIs + defineRoutes(router, this.getConfigurationClient.bind(this), this.logger); + + return { + getConfigurationClient: this.getConfigurationClient.bind(this), + registerConfigurationClient: this.registerConfigurationClient.bind(this), + }; + } + + public start(core: CoreStart) { + return {}; + } + + public stop() {} +} diff --git a/src/plugins/application_config/server/routes/index.test.ts b/src/plugins/application_config/server/routes/index.test.ts new file mode 100644 index 000000000000..086baa646d2b --- /dev/null +++ b/src/plugins/application_config/server/routes/index.test.ts @@ -0,0 +1,353 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { httpServiceMock } from '../../../../core/server/mocks'; +import { loggerMock } from '@osd/logging/target/mocks'; +import { + defineRoutes, + handleDeleteEntityConfig, + handleGetConfig, + handleGetEntityConfig, + handleUpdateEntityConfig, +} from '.'; + +const ERROR_MESSAGE = 'Service unavailable'; + +const ERROR_RESPONSE = { + statusCode: 500, +}; + +const ENTITY_NAME = 'config1'; +const ENTITY_VALUE = 'value1'; +const ENTITY_NEW_VALUE = 'newValue1'; + +describe('application config routes', () => { + describe('defineRoutes', () => { + it('check route paths are defined', () => { + const router = httpServiceMock.createRouter(); + const configurationClient = { + existsCspRules: jest.fn().mockReturnValue(true), + getCspRules: jest.fn().mockReturnValue(''), + }; + + const getConfigurationClient = jest.fn().mockReturnValue(configurationClient); + + const logger = loggerMock.create(); + + defineRoutes(router, getConfigurationClient, logger); + + expect(router.get).toHaveBeenCalledWith( + expect.objectContaining({ + path: '/api/appconfig', + }), + expect.any(Function) + ); + + expect(router.get).toHaveBeenCalledWith( + expect.objectContaining({ + path: '/api/appconfig/{entity}', + }), + expect.any(Function) + ); + + expect(router.post).toHaveBeenCalledWith( + expect.objectContaining({ + path: '/api/appconfig/{entity}', + }), + expect.any(Function) + ); + + expect(router.delete).toHaveBeenCalledWith( + expect.objectContaining({ + path: '/api/appconfig/{entity}', + }), + expect.any(Function) + ); + }); + }); + + describe('handleGetConfig', () => { + it('returns configurations when client returns', async () => { + const configurations = { + config1: 'value1', + config2: 'value2', + }; + + const client = { + getConfig: jest.fn().mockReturnValue(configurations), + }; + + const okResponse = { + statusCode: 200, + }; + + const response = { + ok: jest.fn().mockReturnValue(okResponse), + }; + + const logger = loggerMock.create(); + + const returnedResponse = await handleGetConfig(client, response, logger); + + expect(returnedResponse).toBe(okResponse); + + expect(response.ok).toBeCalledWith({ + body: { + value: configurations, + }, + }); + }); + + it('return error response when client throws error', async () => { + const error = new Error(ERROR_MESSAGE); + + const client = { + getConfig: jest.fn().mockImplementation(() => { + throw error; + }), + }; + + const response = { + customError: jest.fn().mockReturnValue(ERROR_RESPONSE), + }; + + const logger = loggerMock.create(); + + const returnedResponse = await handleGetConfig(client, response, logger); + + expect(returnedResponse).toBe(ERROR_RESPONSE); + + expect(client.getConfig).toBeCalledTimes(1); + + expect(response.customError).toBeCalledWith({ + body: error, + statusCode: 500, + }); + + expect(logger.error).toBeCalledWith(error); + }); + }); + + describe('handleGetEntityConfig', () => { + it('returns value when client returns value', async () => { + const client = { + getEntityConfig: jest.fn().mockReturnValue(ENTITY_VALUE), + }; + + const okResponse = { + statusCode: 200, + }; + + const request = { + params: { + entity: ENTITY_NAME, + }, + }; + + const response = { + ok: jest.fn().mockReturnValue(okResponse), + }; + + const logger = loggerMock.create(); + + const returnedResponse = await handleGetEntityConfig(client, request, response, logger); + + expect(returnedResponse).toBe(okResponse); + + expect(response.ok).toBeCalledWith({ + body: { + value: ENTITY_VALUE, + }, + }); + }); + + it('return error response when client throws error', async () => { + const error = new Error(ERROR_MESSAGE); + + const client = { + getEntityConfig: jest.fn().mockImplementation(() => { + throw error; + }), + }; + + const request = { + params: { + entity: ENTITY_NAME, + }, + }; + + const response = { + customError: jest.fn().mockReturnValue(ERROR_RESPONSE), + }; + + const logger = loggerMock.create(); + + const returnedResponse = await handleGetEntityConfig(client, request, response, logger); + + expect(returnedResponse).toBe(ERROR_RESPONSE); + + expect(client.getEntityConfig).toBeCalledTimes(1); + + expect(response.customError).toBeCalledWith({ + body: error, + statusCode: 500, + }); + + expect(logger.error).toBeCalledWith(error); + }); + }); + + describe('handleUpdateEntityConfig', () => { + it('return success when client succeeds', async () => { + const client = { + updateEntityConfig: jest.fn().mockReturnValue(ENTITY_NEW_VALUE), + }; + + const okResponse = { + statusCode: 200, + }; + + const request = { + params: { + entity: ENTITY_NAME, + }, + body: { + newValue: ENTITY_NEW_VALUE, + }, + }; + + const response = { + ok: jest.fn().mockReturnValue(okResponse), + }; + + const logger = loggerMock.create(); + + const returnedResponse = await handleUpdateEntityConfig(client, request, response, logger); + + expect(returnedResponse).toBe(okResponse); + + expect(client.updateEntityConfig).toBeCalledTimes(1); + + expect(response.ok).toBeCalledWith({ + body: { + newValue: ENTITY_NEW_VALUE, + }, + }); + + expect(logger.error).not.toBeCalled(); + }); + + it('return error response when client fails', async () => { + const error = new Error(ERROR_MESSAGE); + + const client = { + updateEntityConfig: jest.fn().mockImplementation(() => { + throw error; + }), + }; + + const request = { + params: { + entity: ENTITY_NAME, + }, + body: { + newValue: ENTITY_NEW_VALUE, + }, + }; + + const response = { + customError: jest.fn().mockReturnValue(ERROR_RESPONSE), + }; + + const logger = loggerMock.create(); + + const returnedResponse = await handleUpdateEntityConfig(client, request, response, logger); + + expect(returnedResponse).toBe(ERROR_RESPONSE); + + expect(client.updateEntityConfig).toBeCalledTimes(1); + + expect(response.customError).toBeCalledWith({ + body: error, + statusCode: 500, + }); + + expect(logger.error).toBeCalledWith(error); + }); + }); + + describe('handleDeleteEntityConfig', () => { + it('returns successful response when client succeeds', async () => { + const client = { + deleteEntityConfig: jest.fn().mockReturnValue(ENTITY_NAME), + }; + + const okResponse = { + statusCode: 200, + }; + + const request = { + params: { + entity: ENTITY_NAME, + }, + }; + + const response = { + ok: jest.fn().mockReturnValue(okResponse), + }; + + const logger = loggerMock.create(); + + const returnedResponse = await handleDeleteEntityConfig(client, request, response, logger); + + expect(returnedResponse).toBe(okResponse); + + expect(client.deleteEntityConfig).toBeCalledTimes(1); + + expect(response.ok).toBeCalledWith({ + body: { + deletedEntity: ENTITY_NAME, + }, + }); + + expect(logger.error).not.toBeCalled(); + }); + + it('return error response when client fails', async () => { + const error = new Error(ERROR_MESSAGE); + + const client = { + deleteEntityConfig: jest.fn().mockImplementation(() => { + throw error; + }), + }; + + const request = { + params: { + entity: ENTITY_NAME, + }, + }; + + const response = { + customError: jest.fn().mockReturnValue(ERROR_RESPONSE), + }; + + const logger = loggerMock.create(); + + const returnedResponse = await handleDeleteEntityConfig(client, request, response, logger); + + expect(returnedResponse).toBe(ERROR_RESPONSE); + + expect(client.deleteEntityConfig).toBeCalledTimes(1); + + expect(response.customError).toBeCalledWith({ + body: error, + statusCode: 500, + }); + + expect(logger.error).toBeCalledWith(error); + }); + }); +}); diff --git a/src/plugins/application_config/server/routes/index.ts b/src/plugins/application_config/server/routes/index.ts new file mode 100644 index 000000000000..7a059bf52f35 --- /dev/null +++ b/src/plugins/application_config/server/routes/index.ts @@ -0,0 +1,162 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { schema } from '@osd/config-schema'; +import { + IRouter, + IScopedClusterClient, + Logger, + OpenSearchDashboardsRequest, + OpenSearchDashboardsResponseFactory, +} from '../../../../core/server'; +import { ConfigurationClient } from '../types'; + +export function defineRoutes( + router: IRouter, + getConfigurationClient: (configurationClient: IScopedClusterClient) => ConfigurationClient, + logger: Logger +) { + router.get( + { + path: '/api/appconfig', + validate: false, + }, + async (context, request, response) => { + const client = getConfigurationClient(context.core.opensearch.client); + + return await handleGetConfig(client, response, logger); + } + ); + router.get( + { + path: '/api/appconfig/{entity}', + validate: { + params: schema.object({ + entity: schema.string(), + }), + }, + }, + async (context, request, response) => { + const client = getConfigurationClient(context.core.opensearch.client); + + return await handleGetEntityConfig(client, request, response, logger); + } + ); + router.post( + { + path: '/api/appconfig/{entity}', + validate: { + params: schema.object({ + entity: schema.string(), + }), + body: schema.object({ + newValue: schema.string(), + }), + }, + }, + async (context, request, response) => { + const client = getConfigurationClient(context.core.opensearch.client); + + return await handleUpdateEntityConfig(client, request, response, logger); + } + ); + router.delete( + { + path: '/api/appconfig/{entity}', + validate: { + params: schema.object({ + entity: schema.string(), + }), + }, + }, + async (context, request, response) => { + const client = getConfigurationClient(context.core.opensearch.client); + + return await handleDeleteEntityConfig(client, request, response, logger); + } + ); +} + +export async function handleGetEntityConfig( + client: ConfigurationClient, + request: OpenSearchDashboardsRequest, + response: OpenSearchDashboardsResponseFactory, + logger: Logger +) { + try { + const result = await client.getEntityConfig(request.params.entity); + return response.ok({ + body: { + value: result, + }, + }); + } catch (e) { + logger.error(e); + return errorResponse(response, e); + } +} + +export async function handleUpdateEntityConfig( + client: ConfigurationClient, + request: OpenSearchDashboardsRequest, + response: OpenSearchDashboardsResponseFactory, + logger: Logger +) { + try { + const result = await client.updateEntityConfig(request.params.entity, request.body.newValue); + return response.ok({ + body: { + newValue: result, + }, + }); + } catch (e) { + logger.error(e); + return errorResponse(response, e); + } +} + +export async function handleDeleteEntityConfig( + client: ConfigurationClient, + request: OpenSearchDashboardsRequest, + response: OpenSearchDashboardsResponseFactory, + logger: Logger +) { + try { + const result = await client.deleteEntityConfig(request.params.entity); + return response.ok({ + body: { + deletedEntity: result, + }, + }); + } catch (e) { + logger.error(e); + return errorResponse(response, e); + } +} + +export async function handleGetConfig( + client: ConfigurationClient, + response: OpenSearchDashboardsResponseFactory, + logger: Logger +) { + try { + const result = await client.getConfig(); + return response.ok({ + body: { + value: result, + }, + }); + } catch (e) { + logger.error(e); + return errorResponse(response, e); + } +} + +function errorResponse(response: OpenSearchDashboardsResponseFactory, error: any) { + return response.customError({ + statusCode: error?.statusCode || 500, + body: error, + }); +} diff --git a/src/plugins/application_config/server/string_utils.test.ts b/src/plugins/application_config/server/string_utils.test.ts new file mode 100644 index 000000000000..2baf765a5bc0 --- /dev/null +++ b/src/plugins/application_config/server/string_utils.test.ts @@ -0,0 +1,44 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { validate } from './string_utils'; + +describe('application config string utils', () => { + it('returns input when input is not empty and no prefix or suffix whitespaces', () => { + const logger = { + error: jest.fn(), + }; + + const input = 'abc'; + + const validatedInput = validate(input, logger); + + expect(validatedInput).toBe(input); + expect(logger.error).not.toBeCalled(); + }); + + it('returns trimmed input when input is not empty and prefix or suffix whitespaces', () => { + const logger = { + error: jest.fn(), + }; + + const input = ' abc '; + + const validatedInput = validate(input, logger); + + expect(validatedInput).toBe('abc'); + expect(logger.error).not.toBeCalled(); + }); + + it('throws error when input is empty', () => { + const logger = { + error: jest.fn(), + }; + + expect(() => { + validate(' ', logger); + }).toThrowError('Input cannot be empty!'); + }); +}); diff --git a/src/plugins/application_config/server/string_utils.ts b/src/plugins/application_config/server/string_utils.ts new file mode 100644 index 000000000000..34e9842b7b6d --- /dev/null +++ b/src/plugins/application_config/server/string_utils.ts @@ -0,0 +1,26 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Logger } from 'src/core/server'; + +const ERROR_MESSSAGE_FOR_EMPTY_INPUT = 'Input cannot be empty!'; +const ERROR_FOR_EMPTY_INPUT = new Error(ERROR_MESSSAGE_FOR_EMPTY_INPUT); + +function isEmpty(input: string): boolean { + if (!input) { + return true; + } + + return !input.trim(); +} + +export function validate(input: string, logger: Logger): string { + if (isEmpty(input)) { + logger.error(ERROR_MESSSAGE_FOR_EMPTY_INPUT); + throw ERROR_FOR_EMPTY_INPUT; + } + + return input.trim(); +} diff --git a/src/plugins/application_config/server/types.ts b/src/plugins/application_config/server/types.ts new file mode 100644 index 000000000000..49fc11d99c53 --- /dev/null +++ b/src/plugins/application_config/server/types.ts @@ -0,0 +1,52 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { IScopedClusterClient } from 'src/core/server'; + +export interface ApplicationConfigPluginSetup { + getConfigurationClient: (inputOpenSearchClient: IScopedClusterClient) => ConfigurationClient; + registerConfigurationClient: (inputConfigurationClient: ConfigurationClient) => void; +} +// eslint-disable-next-line @typescript-eslint/no-empty-interface +export interface ApplicationConfigPluginStart {} + +/** + * The interface defines the operations against the application configurations at both entity level and whole level. + * + */ +export interface ConfigurationClient { + /** + * Get all the configurations. + * + * @param {array} array of connections + * @returns {ConnectionPool} + */ + getConfig(): Promise>; + + /** + * Get the value for the input entity. + * + * @param {entity} name of the entity + * @returns {string} value of the entity + */ + getEntityConfig(entity: string): Promise; + + /** + * Update the input entity with a new value. + * + * @param {entity} name of the entity + * @param {newValue} new configuration value of the entity + * @returns {string} updated configuration value of the entity + */ + updateEntityConfig(entity: string, newValue: string): Promise; + + /** + * Delete the input entity from configurations. + * + * @param {entity} name of the entity + * @returns {string} name of the deleted entity + */ + deleteEntityConfig(entity: string): Promise; +}