diff --git a/changelogs/fragments/7901.yml b/changelogs/fragments/7901.yml new file mode 100644 index 000000000000..9ca9fd670808 --- /dev/null +++ b/changelogs/fragments/7901.yml @@ -0,0 +1,2 @@ +fix: +- Fix bootstrap errors in 2.x ([#7901](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/7901)) \ No newline at end of file diff --git a/config/opensearch_dashboards.yml b/config/opensearch_dashboards.yml index 5e09c2359aa4..eb80d20854c4 100644 --- a/config/opensearch_dashboards.yml +++ b/config/opensearch_dashboards.yml @@ -359,6 +359,12 @@ # This publishes the Application Usage and UI Metrics into the saved object, which can be accessed by /api/stats?extended=true&legacy=true&exclude_usage=false # usageCollection.uiMetric.enabled: false +# Set the value to true to enable enhancements for the data plugin +# data.enhancements.enabled: false + +# Set the value to true to enable dynamic config service to obtain configs from a config store. By default, it's disabled +# dynamic_config_service.enabled: false + # Set the backend roles in groups or users, whoever has the backend roles or exactly match the user ids defined in this config will be regard as dashboard admin. # Dashboard admin will have the access to all the workspaces(workspace.enabled: true) and objects inside OpenSearch Dashboards. # opensearchDashboards.dashboardAdmin.groups: ["dashboard_admin"] diff --git a/src/core/server/capabilities/integration_tests/capabilities_service.test.ts b/src/core/server/capabilities/integration_tests/capabilities_service.test.ts index 3cab62478cbc..eaa5e14538af 100644 --- a/src/core/server/capabilities/integration_tests/capabilities_service.test.ts +++ b/src/core/server/capabilities/integration_tests/capabilities_service.test.ts @@ -37,6 +37,7 @@ import { Env } from '../../config'; import { getEnvOptions } from '../../config/mocks'; import { CapabilitiesService, CapabilitiesSetup } from '..'; import { createHttpServer } from '../../http/test_utils'; +import { dynamicConfigServiceMock } from '../../config/dynamic_config_service.mock'; const coreId = Symbol('core'); @@ -59,9 +60,12 @@ describe('CapabilitiesService', () => { env, logger: loggingSystemMock.create(), configService: {} as any, + dynamicConfigService: dynamicConfigServiceMock.create(), }); serviceSetup = await service.setup({ http: httpSetup }); - await server.start(); + await server.start({ + dynamicConfigService: dynamicConfigServiceMock.createInternalStartContract(), + }); }); afterEach(async () => { diff --git a/src/core/server/config/dynamic_config_service.mock.ts b/src/core/server/config/dynamic_config_service.mock.ts new file mode 100644 index 000000000000..c4665fd8d320 --- /dev/null +++ b/src/core/server/config/dynamic_config_service.mock.ts @@ -0,0 +1,96 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { IDynamicConfigService } from './dynamic_config_service'; +import { + DynamicConfigurationClientMockProps, + dynamicConfigurationClientMock, +} from './service/configuration_client.mock'; +import { + AsyncLocalStorageContext, + DynamicConfigServiceSetup, + DynamicConfigServiceStart, + InternalDynamicConfigServiceSetup, + InternalDynamicConfigServiceStart, +} from './types'; + +const createDynamicConfigServiceMock = ( + mockClientReturnValues?: DynamicConfigurationClientMockProps, + mockAsyncLocalStoreValues?: AsyncLocalStorageContext +) => { + const mocked: jest.Mocked = { + setup: jest.fn().mockReturnValue(createInternalSetupContractMock()), + start: jest + .fn() + .mockReturnValue( + createInternalStartContractMock(mockClientReturnValues, mockAsyncLocalStoreValues) + ), + stop: jest.fn(), + setSchema: jest.fn(), + hasDefaultConfigs: jest.fn(), + registerRoutesAndHandlers: jest.fn(), + }; + + return mocked; +}; + +const createSetupContractMock = () => { + const mocked: jest.Mocked = { + registerDynamicConfigClientFactory: jest.fn(), + registerAsyncLocalStoreRequestHeader: jest.fn(), + getStartService: jest.fn(), + }; + + return mocked; +}; +const createInternalSetupContractMock = () => { + const mocked: jest.Mocked = { + registerDynamicConfigClientFactory: jest.fn(), + registerAsyncLocalStoreRequestHeader: jest.fn(), + getStartService: jest.fn(), + }; + + return mocked; +}; +const createStartContractMock = ( + mockClientReturnValues?: DynamicConfigurationClientMockProps, + mockAsyncLocalStoreValues?: AsyncLocalStorageContext +) => { + const client = mockClientReturnValues + ? dynamicConfigurationClientMock.create(mockClientReturnValues) + : dynamicConfigurationClientMock.create(); + + const mocked: jest.Mocked = { + getClient: jest.fn().mockReturnValue(client), + getAsyncLocalStore: jest.fn().mockReturnValue(mockAsyncLocalStoreValues), + createStoreFromRequest: jest.fn().mockRejectedValue(mockAsyncLocalStoreValues), + }; + + return mocked; +}; +const createInternalStartContractMock = ( + mockClientReturnValues?: DynamicConfigurationClientMockProps, + mockAsyncLocalStoreValues?: AsyncLocalStorageContext +) => { + const client = mockClientReturnValues + ? dynamicConfigurationClientMock.create(mockClientReturnValues) + : dynamicConfigurationClientMock.create(); + + const mocked: jest.Mocked = { + getClient: jest.fn().mockReturnValue(client), + getAsyncLocalStore: jest.fn().mockReturnValue(mockAsyncLocalStoreValues), + createStoreFromRequest: jest.fn().mockRejectedValue(mockAsyncLocalStoreValues), + }; + + return mocked; +}; + +export const dynamicConfigServiceMock = { + create: createDynamicConfigServiceMock, + createInternalSetupContract: createInternalSetupContractMock, + createInternalStartContract: createInternalStartContractMock, + createSetupContract: createSetupContractMock, + createStartContract: createStartContractMock, +}; diff --git a/src/core/server/config/dynamic_config_service.test.ts b/src/core/server/config/dynamic_config_service.test.ts new file mode 100644 index 000000000000..6dbbeb4cfe79 --- /dev/null +++ b/src/core/server/config/dynamic_config_service.test.ts @@ -0,0 +1,103 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { DynamicConfigService, IDynamicConfigService } from './dynamic_config_service'; +import { configServiceMock, httpServiceMock, opensearchServiceMock } from '../mocks'; +import { loggerMock } from '../logging/logger.mock'; +import { LoggerFactory } from '@osd/logging'; +import { schema, Type } from '@osd/config-schema'; +import { IDynamicConfigStoreClient } from 'opensearch-dashboards/server'; + +describe('DynamicConfigService', () => { + let dynamicConfigService: IDynamicConfigService; + const openSearchMock = opensearchServiceMock.createStart(); + + beforeEach(() => { + const loggerFactoryMock = {} as LoggerFactory; + loggerFactoryMock.get = jest.fn().mockReturnValue(loggerMock.create()); + + dynamicConfigService = new DynamicConfigService( + configServiceMock.create(), + {} as any, + loggerFactoryMock + ); + }); + + it('setup() and start() should return the same clients/async local stores', async () => { + const dynamicConfigServiceSetup = await dynamicConfigService.setup(); + expect(dynamicConfigServiceSetup.getStartService()).toBeDefined(); + expect(dynamicConfigServiceSetup.registerDynamicConfigClientFactory).toBeDefined(); + expect(dynamicConfigServiceSetup.registerAsyncLocalStoreRequestHeader).toBeDefined(); + + dynamicConfigServiceSetup.registerDynamicConfigClientFactory({ + create: () => { + return {} as IDynamicConfigStoreClient; + }, + }); + + const dynamicConfigServiceStart = await dynamicConfigService.start({ + opensearch: openSearchMock, + }); + expect(dynamicConfigServiceStart.getAsyncLocalStore).toBeDefined(); + expect(dynamicConfigServiceStart.getClient()).toBeDefined(); + + const actualGetStartServices = await dynamicConfigServiceSetup.getStartService(); + + expect(actualGetStartServices.getClient()).toMatchObject(dynamicConfigServiceStart.getClient()); + expect(actualGetStartServices.getAsyncLocalStore).toBeDefined(); + }); + + describe('After http is setup', () => { + it('setupHTTP() should add the async local store preAuth middleware', () => { + const httpSetupMock = httpServiceMock.createInternalSetupContract(); + dynamicConfigService.registerRoutesAndHandlers({ http: httpSetupMock }); + expect(httpSetupMock.registerOnPostAuth).toHaveBeenCalled(); + }); + }); + + it('setSchema() and hasDefaultConfigs() should set and check if schemas have been registered', () => { + const schemaList: Map> = new Map(); + + schemaList.set( + 'foo', + schema.object({ + a: schema.boolean(), + b: schema.object({ + c: schema.string(), + }), + }) + ); + schemaList.set( + 'bar', + schema.object({ + a: schema.boolean(), + b: schema.boolean(), + c: schema.object({ + d: schema.string(), + }), + }) + ); + schemaList.set( + 'baz', + schema.object({ + a: schema.object({ + c: schema.object({ + d: schema.boolean(), + }), + }), + }) + ); + + schemaList.forEach((value, key) => { + dynamicConfigService.setSchema(key, value); + }); + + [...schemaList.keys()].forEach((key) => { + expect(dynamicConfigService.hasDefaultConfigs({ name: key })).toBe(true); + }); + + expect(dynamicConfigService.hasDefaultConfigs({ name: 'nonexistent_config' })).toBe(false); + }); +}); diff --git a/src/core/server/config/dynamic_config_service.ts b/src/core/server/config/dynamic_config_service.ts new file mode 100644 index 000000000000..bb83ecdb3167 --- /dev/null +++ b/src/core/server/config/dynamic_config_service.ts @@ -0,0 +1,214 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { ConfigPath, Env, IConfigService } from '@osd/config'; +import { Type } from '@osd/config-schema'; +import { PublicMethodsOf } from '@osd/utility-types'; +import { AsyncLocalStorage } from 'async_hooks'; +import { first } from 'rxjs/operators'; +import { Observable } from 'rxjs'; +import { InternalHttpServiceSetup, OpenSearchDashboardsRequest } from '../http'; +import { CoreService } from '../../types'; +import { + AsyncLocalStorageContext, + ConfigIdentifier, + IDynamicConfigStoreClient, + IDynamicConfigStoreClientFactory, + InternalDynamicConfigServiceSetup, + InternalDynamicConfigServiceStart, +} from './types'; +import { InternalDynamicConfigurationClient } from './service/internal_dynamic_configuration_client'; +import { Logger, LoggerFactory } from '../logging'; +import { createLocalStoreFromOsdRequest, pathToString } from './utils/utils'; +import { DynamicConfigurationClient } from './service/dynamic_configuration_client'; +import { OpenSearchDynamicConfigStoreFactory } from './service/config_store_client/opensearch_config_store_factory'; +import { InternalOpenSearchServiceStart } from '../opensearch'; +import { DynamicConfigServiceConfigType } from './dynamic_config_service_config'; +import { DummyDynamicConfigStoreFactory } from './service/config_store_client/dummy_config_store_factory'; + +export interface RegisterHTTPSetupDeps { + http: InternalHttpServiceSetup; +} + +export interface StartDeps { + opensearch: InternalOpenSearchServiceStart; +} + +export type IDynamicConfigService = PublicMethodsOf; + +/** @internal */ +export class DynamicConfigService + implements CoreService { + readonly #configService: IConfigService; + readonly #envService: Env; + readonly #logger: Logger; + readonly #schemas = new Map>(); + readonly #config$: Observable; + readonly #asyncLocalStorage: AsyncLocalStorage< + AsyncLocalStorageContext + > = new AsyncLocalStorage(); + readonly #requestHeaders: string[] = []; + #configStoreClientFactory?: IDynamicConfigStoreClientFactory; + #started = false; + #startPromiseResolver?: (startServices: InternalDynamicConfigServiceStart) => void; + readonly #startPromise: Promise; + + constructor(configService: IConfigService, envService: Env, logger: LoggerFactory) { + this.#configService = configService; + this.#envService = envService; + this.#logger = logger.get('dynamic-config-service'); + this.#startPromise = new Promise( + (resolve) => (this.#startPromiseResolver = resolve) + ); + this.#config$ = configService + .atPath('dynamic_config_service') + .pipe(first()); + } + public async setup(): Promise { + return { + registerDynamicConfigClientFactory: (factory: IDynamicConfigStoreClientFactory) => { + if (this.#configStoreClientFactory) { + throw new Error('Dynamic config store client factory is already set'); + } + if (this.#started) { + throw new Error( + 'Cannot set config store client factory because dynamic configuration service has already started' + ); + } + this.#configStoreClientFactory = factory; + }, + registerAsyncLocalStoreRequestHeader: (key: string | string[]) => { + if (typeof key === 'string') { + this.#requestHeaders.push(key); + } else { + this.#requestHeaders.push(...key); + } + }, + getStartService: async () => { + return await this.#startPromise; + }, + }; + } + + public async start({ opensearch }: StartDeps): Promise { + this.#logger.info('initiating start()'); + const config = await this.#config$.pipe(first()).toPromise(); + let configStoreClient: IDynamicConfigStoreClient; + + if (!config.enabled) { + const dummyDynamicConfigStoreClientFactory = new DummyDynamicConfigStoreFactory(); + configStoreClient = dummyDynamicConfigStoreClientFactory.create(); + } else { + if (this.#configStoreClientFactory) { + configStoreClient = this.#configStoreClientFactory.create(); + } else { + const defaultDynamicConfigStoreClientFactory = new OpenSearchDynamicConfigStoreFactory( + opensearch + ); + const defaultConfigStoreClient = defaultDynamicConfigStoreClientFactory.create(); + if (!config.skipMigrations) { + await defaultConfigStoreClient.createDynamicConfigIndex(); + } + configStoreClient = defaultConfigStoreClient; + } + } + + // Create the clients + const internalClient = new InternalDynamicConfigurationClient({ + client: configStoreClient, + configService: this.#configService, + env: this.#envService, + logger: this.#logger, + schemas: this.#schemas, + }); + const client = new DynamicConfigurationClient(internalClient); + + const startServices: InternalDynamicConfigServiceStart = { + getClient: () => { + return client; + }, + getAsyncLocalStore: () => { + return this.#asyncLocalStorage.getStore(); + }, + createStoreFromRequest: (request: OpenSearchDashboardsRequest) => { + return createLocalStoreFromOsdRequest(this.#logger, request, this.#requestHeaders); + }, + }; + + this.#logger.info('finished start()'); + this.#started = true; + if (this.#startPromiseResolver) { + this.#startPromiseResolver(startServices); + } + + return startServices; + } + + /** + * Extra setup step to register any HTTP routes and the async local store. This should be called after all plugins are setup but before dynamicConfigService is started + * + * @param setupDeps + */ + public async registerRoutesAndHandlers(setupDeps: RegisterHTTPSetupDeps) { + const { http } = setupDeps; + + /** + * TODO Register the routes + * - validate (needed for CP) + * - Optional: + * - create + * - bulkCreate + * - get + * - bulkGet + * - list + * - delete + * - bulkDelete + */ + + // FIXME: This seems not working as expected, as sometimes the context is not available to request handlers after registering + // in the PostAuth handler. Needs to do more research. + // For now, we can use DynamicConfigService.createStoreFromRequest(request) to create context store when it needs to + // fetch configrations from DynamicConfigStore. + this.#logger.info('registering middleware to inject context to AsyncLocalStorage'); + http.registerOnPostAuth((request, response, context) => { + if (request.auth.isAuthenticated) { + const localStore = createLocalStoreFromOsdRequest( + this.#logger, + request, + this.#requestHeaders + ) as AsyncLocalStorageContext; + this.#asyncLocalStorage.enterWith(localStore); + } + return context.next(); + }); + } + + public async stop() {} + + /** + * Mimics Config Service schema registration, which should be finished calling before start() is called. Validation is not needed as the Config Service handles that + * + * @param path {string} the core ID, plugin ID, or the plugin configPath (if specified) + * @param schema {Type} the schema object defined in config.ts + */ + public setSchema(path: ConfigPath, schema: Type) { + // Even though server configs are not pluginConfigPaths, the logic to parse the namespace will not change + const namespace = pathToString({ pluginConfigPath: path }); + if (this.#schemas.has(namespace)) { + throw new Error(`Validation schema for [${namespace}] was already registered.`); + } + this.#schemas.set(namespace, schema); + } + + /** + * Checks if a certain config already exists + * + * @param configIdentifier {ConfigIdentifier} the core ID, plugin ID, or the plugin configPath (if specified) + */ + public hasDefaultConfigs(configIdentifier: ConfigIdentifier) { + const namespace = pathToString(configIdentifier); + return this.#schemas.has(namespace); + } +} diff --git a/src/core/server/config/dynamic_config_service_config.ts b/src/core/server/config/dynamic_config_service_config.ts new file mode 100644 index 000000000000..6a2762916ec7 --- /dev/null +++ b/src/core/server/config/dynamic_config_service_config.ts @@ -0,0 +1,20 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { schema, TypeOf } from '@osd/config-schema'; +import { ServiceConfigDescriptor } from '../internal_types'; + +export type DynamicConfigServiceConfigType = TypeOf; + +export const configSchema = schema.object({ + skipMigrations: schema.boolean({ defaultValue: false }), + // If not enabled, the core service will exist but the client returned will just return static configs + enabled: schema.boolean({ defaultValue: false }), +}); + +export const config: ServiceConfigDescriptor = { + path: 'dynamic_config_service', + schema: configSchema, +}; diff --git a/src/core/server/config/index.ts b/src/core/server/config/index.ts index 7951d0cbd05e..ec9f8ed2c8bd 100644 --- a/src/core/server/config/index.ts +++ b/src/core/server/config/index.ts @@ -30,6 +30,8 @@ export { coreDeprecationProvider } from './deprecation'; +export { config } from './dynamic_config_service_config'; + export { ConfigService, IConfigService, @@ -50,3 +52,5 @@ export { PackageInfo, LegacyObjectToConfigAdapter, } from '@osd/config'; + +export * from './types'; diff --git a/src/core/server/config/mocks.ts b/src/core/server/config/mocks.ts index 0ed455454999..3114dad39dc4 100644 --- a/src/core/server/config/mocks.ts +++ b/src/core/server/config/mocks.ts @@ -34,3 +34,10 @@ export { configServiceMock, configMock, } from '@osd/config/target/mocks'; + +export { dynamicConfigServiceMock } from './dynamic_config_service.mock'; + +export { + internalDynamicConfigurationClientMock, + dynamicConfigurationClientMock, +} from './service/configuration_client.mock'; diff --git a/src/core/server/config/service/config_store_client/dummy_config_store_client.test.ts b/src/core/server/config/service/config_store_client/dummy_config_store_client.test.ts new file mode 100644 index 000000000000..e51393d07124 --- /dev/null +++ b/src/core/server/config/service/config_store_client/dummy_config_store_client.test.ts @@ -0,0 +1,63 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { DummyConfigStoreClient } from './dummy_config_store_client'; + +describe('DummyConfigStoreClient', () => { + const expectedResponse = { + body: {}, + statusCode: 200, + headers: {}, + warnings: [], + meta: {}, + }; + const dummyStoreConfigClient = new DummyConfigStoreClient(); + + it('should return empty map for listConfigs', async () => { + const response = await dummyStoreConfigClient.listConfigs(); + expect(response).toMatchObject(new Map()); + }); + + it('should return expectedResponse for bulkCreateConfigs', async () => { + const response = await dummyStoreConfigClient.bulkCreateConfigs({ + configs: [], + }); + expect(response).toMatchObject(expectedResponse); + }); + + it('should return expectedResponse for createConfigs', async () => { + const response = await dummyStoreConfigClient.createConfig({ + config: { + name: 'foo', + updatedConfig: {}, + }, + }); + expect(response).toMatchObject(expectedResponse); + }); + + it('should return expectedResponse for bulkDeleteConfigs', async () => { + const response = await dummyStoreConfigClient.bulkDeleteConfigs({ + paths: [], + }); + expect(response).toMatchObject(expectedResponse); + }); + + it('should return expectedResponse for deleteConfig', async () => { + const response = await dummyStoreConfigClient.deleteConfig({ + pluginConfigPath: 'foo', + }); + expect(response).toMatchObject(expectedResponse); + }); + + it('should return undefined for getConfig', async () => { + const response = await dummyStoreConfigClient.getConfig('foo'); + expect(response).toBeUndefined(); + }); + + it('should return empty for bulkGetConfigs', async () => { + const response = await dummyStoreConfigClient.bulkGetConfigs([]); + expect(response).toMatchObject(new Map()); + }); +}); diff --git a/src/core/server/config/service/config_store_client/dummy_config_store_client.ts b/src/core/server/config/service/config_store_client/dummy_config_store_client.ts new file mode 100644 index 000000000000..05f4831bebf1 --- /dev/null +++ b/src/core/server/config/service/config_store_client/dummy_config_store_client.ts @@ -0,0 +1,67 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + BulkCreateConfigProps, + BulkDeleteConfigProps, + ConfigIdentifier, + CreateConfigProps, + DynamicConfigurationClientOptions, + IDynamicConfigStoreClient, +} from '../../types'; +import { createApiResponse } from '../../utils/utils'; + +/** + * The DummyConfigStoreClient is the client DAO that will used when dynamic config service is "disabled". + * The client will return nothing, which will cause the dynamic config service to return static configs only. + * It is important to note that the DynamicConfigService will always exist as it's a core service. + */ +export class DummyConfigStoreClient implements IDynamicConfigStoreClient { + public async listConfigs(options?: DynamicConfigurationClientOptions | undefined) { + return Promise.resolve(new Map()); + } + + public async bulkCreateConfigs( + bulkCreateConfigProps: BulkCreateConfigProps, + options?: DynamicConfigurationClientOptions | undefined + ) { + return Promise.resolve(createApiResponse()); + } + + public async createConfig( + createConfigProps: CreateConfigProps, + options?: DynamicConfigurationClientOptions | undefined + ) { + return Promise.resolve(createApiResponse()); + } + + public async bulkDeleteConfigs( + bulkDeleteConfigs: BulkDeleteConfigProps, + options?: DynamicConfigurationClientOptions | undefined + ) { + return Promise.resolve(createApiResponse()); + } + + public async deleteConfig( + deleteConfigs: ConfigIdentifier, + options?: DynamicConfigurationClientOptions | undefined + ) { + return Promise.resolve(createApiResponse()); + } + + public async getConfig( + namespace: string, + options?: DynamicConfigurationClientOptions | undefined + ) { + return Promise.resolve(undefined); + } + + public async bulkGetConfigs( + namespaces: string[], + options?: DynamicConfigurationClientOptions | undefined + ) { + return Promise.resolve(new Map()); + } +} diff --git a/src/core/server/config/service/config_store_client/dummy_config_store_factory.ts b/src/core/server/config/service/config_store_client/dummy_config_store_factory.ts new file mode 100644 index 000000000000..494d355ebde2 --- /dev/null +++ b/src/core/server/config/service/config_store_client/dummy_config_store_factory.ts @@ -0,0 +1,13 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { IDynamicConfigStoreClientFactory } from 'opensearch-dashboards/server'; +import { DummyConfigStoreClient } from './dummy_config_store_client'; + +export class DummyDynamicConfigStoreFactory implements IDynamicConfigStoreClientFactory { + public create() { + return new DummyConfigStoreClient(); + } +} diff --git a/src/core/server/config/service/config_store_client/opensearch_config_store.test.ts b/src/core/server/config/service/config_store_client/opensearch_config_store.test.ts new file mode 100644 index 000000000000..967ed2900bbc --- /dev/null +++ b/src/core/server/config/service/config_store_client/opensearch_config_store.test.ts @@ -0,0 +1,631 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { SearchResponse } from '../../../opensearch'; +import { opensearchClientMock } from '../../../opensearch/client/mocks'; +import { DYNAMIC_APP_CONFIG_ALIAS } from '../../utils/constants'; +import { OpenSearchConfigStoreClient } from './opensearch_config_store_client'; +import { ConfigDocument } from './types'; +import _ from 'lodash'; +import { ConfigBlob } from '../../types'; +import { BulkOperationContainer } from '@opensearch-project/opensearch/api/types'; +import { getDynamicConfigIndexName } from '../../utils/utils'; + +describe('OpenSearchConfigStoreClient', () => { + /** + * Helper function to assert general map equality + * + * @param map1 + * @param map2 + */ + const assertMapsAreEqual = (map1: Map, map2: Map) => { + expect(map1.size).toBe(map2.size); + + map1.forEach((value, key) => { + expect(map2.get(key)).toMatchObject(value); + }); + }; + + interface OpenSearchClientMockProps { + isListConfig: boolean; + configDocuments: ConfigDocument[]; + existsAliasResult: boolean; + } + + /** + * Creates a new OpenSearch client mock complete with a mock for existsAlias() and search() results + * + * @param param0 + * @returns + */ + const createOpenSearchClientMock = ({ + isListConfig, + configDocuments, + existsAliasResult, + }: OpenSearchClientMockProps) => { + const mockClient = opensearchClientMock.createOpenSearchClient(); + + mockClient.indices.existsAlias.mockResolvedValue( + opensearchClientMock.createApiResponse({ + body: existsAliasResult as any, + }) + ); + + // @ts-expect-error + mockClient.search.mockImplementation((request, options) => { + // Filters out results when the request is for getting/bulk getting configs + const mockHits = isListConfig + ? configDocuments + : configDocuments.filter((configDocument) => { + // @ts-expect-error + const namespaces: string[] = request!.body!.query!.bool!.should![0].terms.config_name; + return namespaces.includes(configDocument.config_name); + }); + + return Promise.resolve( + opensearchClientMock.createApiResponse>({ + body: { + hits: { + hits: mockHits.map((hit) => ({ + _index: getDynamicConfigIndexName(1), + _id: JSON.stringify(hit), + _version: 1, + _source: hit, + })), + }, + }, + }) + ); + }); + + return mockClient; + }; + + const configDocument: ConfigDocument = { + config_name: 'some_config_name', + config_blob: { + foo: { + bar: 1, + }, + baz: 'value', + }, + }; + const configDocuments: ConfigDocument[] = [ + { + config_name: 'config_a', + config_blob: { + level1: { + name: 'Object1', + value: 10, + level2: { + description: 'This is level 2 of object 1', + }, + }, + }, + }, + { + config_name: 'config_b', + config_blob: { + levelA: { + flag: true, + levelB: { + items: ['item1', 'item2', 'item3'], + levelC: { + count: 3, + }, + }, + }, + }, + }, + { + config_name: 'config_c', + config_blob: { + section: { + id: 'sec1', + levelX: { + title: 'Section Title', + levelY: { + status: 'active', + }, + }, + }, + }, + }, + ]; + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('createDynamicConfigIndex()', () => { + it.each([ + { + existsAliasResult: false, + numCreateCalls: 1, + }, + { + existsAliasResult: true, + numCreateCalls: 0, + }, + ])( + 'should create config index $numCreateCalls times when existsAlias() is $existsAliasResult', + async ({ existsAliasResult, numCreateCalls }) => { + const mockClient = createOpenSearchClientMock({ + isListConfig: false, + configDocuments: [], + existsAliasResult, + }); + const configStoreClient = new OpenSearchConfigStoreClient(mockClient); + await configStoreClient.createDynamicConfigIndex(); + + expect(mockClient.indices.existsAlias).toBeCalled(); + expect(mockClient.indices.create).toBeCalledTimes(numCreateCalls); + expect(mockClient.indices.updateAliases).toBeCalledTimes(numCreateCalls); + } + ); + }); + + describe('getConfig()', () => { + const expectedConfigBlob = _.cloneDeep(configDocument.config_blob); + + it('should use cache when necessary and return correct config', async () => { + const mockClient = createOpenSearchClientMock({ + isListConfig: false, + configDocuments: [configDocument], + existsAliasResult: false, + }); + const configStoreClient = new OpenSearchConfigStoreClient(mockClient); + + // Testing cache + for (let i = 0; i < 2; i++) { + const result = await configStoreClient.getConfig('some_config_name'); + expect(result).toMatchObject(expectedConfigBlob); + expect(mockClient.search).toBeCalledTimes(1); + } + + // Clearing cache should induce another search call + configStoreClient.clearCache(); + const result3 = await configStoreClient.getConfig('some_config_name'); + expect(result3).toMatchObject(expectedConfigBlob); + expect(mockClient.search).toBeCalledTimes(2); + + const nonExistentResult = await configStoreClient.getConfig('non_existent_config'); + expect(nonExistentResult).not.toBeDefined(); + expect(mockClient.search).toBeCalledTimes(3); + }); + }); + + describe('bulkGetConfigs()', () => { + const expectedConfigMap = new Map( + configDocuments.map((document) => [document.config_name, document.config_blob]) + ); + const createPartialExpectedConfigMap = (namesToKeep: string[]) => { + return new Map([...expectedConfigMap].filter(([name, config]) => namesToKeep.includes(name))); + }; + + it('should use cache when necessary and return correct configs', async () => { + const mockClient = createOpenSearchClientMock({ + isListConfig: false, + configDocuments, + existsAliasResult: false, + }); + const configStoreClient = new OpenSearchConfigStoreClient(mockClient); + + const partialNames = ['config_a', 'config_b']; + const expectedConfigMapPartial = createPartialExpectedConfigMap(partialNames); + + for (let i = 0; i < 2; i++) { + const results = await configStoreClient.bulkGetConfigs(partialNames); + assertMapsAreEqual(expectedConfigMapPartial, results); + expect(mockClient.search).toBeCalledTimes(1); + } + + // Partial cache hit initially, results are searched and found + for (let i = 0; i < 2; i++) { + const results = await configStoreClient.bulkGetConfigs([...partialNames, 'config_c']); + assertMapsAreEqual(expectedConfigMap, results); + expect(mockClient.search).toBeCalledTimes(2); + } + + // Partial results + for (let i = 0; i < 2; i++) { + const results = await configStoreClient.bulkGetConfigs([ + ...partialNames, + 'non_existent_config', + ]); + assertMapsAreEqual(expectedConfigMapPartial, results); + expect(mockClient.search).toBeCalledTimes(3); + } + + // No results + for (let i = 0; i < 2; i++) { + const results = await configStoreClient.bulkGetConfigs([ + 'non_existent_config', + 'other_nonexistent_config', + ]); + assertMapsAreEqual(new Map(), results); + expect(mockClient.search).toBeCalledTimes(4); + } + }); + }); + + describe('listConfigs', () => { + it.each([ + { + allConfigDocuments: [], + expectedMap: new Map(), + }, + { + allConfigDocuments: [configDocument], + expectedMap: new Map([[configDocument.config_name, configDocument.config_blob]]), + }, + { + allConfigDocuments: configDocuments, + expectedMap: new Map( + configDocuments.map((document) => [document.config_name, document.config_blob]) + ), + }, + ])( + 'should return a Map containing $configDocuments.length configs', + async ({ allConfigDocuments, expectedMap }) => { + const mockClient = createOpenSearchClientMock({ + isListConfig: true, + configDocuments: allConfigDocuments, + existsAliasResult: false, + }); + const configStoreClient = new OpenSearchConfigStoreClient(mockClient); + const actualMap = await configStoreClient.listConfigs(); + + assertMapsAreEqual(actualMap, expectedMap); + } + ); + }); + + describe('createConfig()', () => { + const itemId = 'some_item_id'; + const updatedConfigBlob = { + foo: { + bar: 5, + }, + foobar: ['new', 'config'], + }; + + it.each([ + { + newConfigDocuments: [], + newConfigBlob: updatedConfigBlob, + expectedBulkRequest: [ + { + create: { + _id: itemId, + _index: DYNAMIC_APP_CONFIG_ALIAS, + retry_on_conflict: 2, + routing: '', + version: 1, + version_type: 'external', + }, + }, + { + config_name: 'some_config_name', + config_blob: updatedConfigBlob, + }, + ], + }, + { + newConfigDocuments: [configDocument], + newConfigBlob: updatedConfigBlob, + expectedBulkRequest: [ + { + update: { + _id: JSON.stringify(configDocument), + _index: DYNAMIC_APP_CONFIG_ALIAS, + retry_on_conflict: 2, + routing: '', + version: 2, + version_type: 'external', + }, + }, + { + doc: { + config_blob: updatedConfigBlob, + }, + }, + ], + }, + ])( + 'should call bulk() with correct operations', + async ({ newConfigBlob, newConfigDocuments, expectedBulkRequest }) => { + jest.spyOn(_, 'uniqueId').mockImplementation(() => itemId); + const mockClient = createOpenSearchClientMock({ + isListConfig: false, + configDocuments: newConfigDocuments, + existsAliasResult: false, + }); + const configStoreClient = new OpenSearchConfigStoreClient(mockClient); + await configStoreClient.createConfig({ + config: { + name: 'some_config_name', + updatedConfig: newConfigBlob, + }, + }); + + expect(mockClient.bulk).toBeCalledWith({ + index: DYNAMIC_APP_CONFIG_ALIAS, + body: expectedBulkRequest, + }); + + // Should cache result (search() is always called before bulk() to find existing configs) + const result = await configStoreClient.getConfig('some_config_name'); + expect(result).toMatchObject(newConfigBlob); + expect(mockClient.search).toBeCalledTimes(1); + } + ); + }); + + describe('bulkCreateConfigs()', () => { + const spyFunction = jest.spyOn(_, 'uniqueId'); + + interface BulkCreateConfigTestCaseFormat { + configsToCreate: ConfigDocument[]; + configsToUpdate: ConfigDocument[]; + existingConfigs: ConfigDocument[]; + } + it.each([ + { + configsToCreate: [configDocument], + existingConfigs: [], + configsToUpdate: [], + }, + { + configsToCreate: [], + existingConfigs: [configDocument], + configsToUpdate: [ + { + ...configDocument, + config_blob: { + foo: { + bar: 5, + }, + foobar: ['new', 'values'], + }, + }, + ], + }, + { + configsToCreate: configDocuments, + existingConfigs: [], + configsToUpdate: [], + }, + { + configsToCreate: [configDocument], + existingConfigs: configDocuments.slice(0, 2), + configsToUpdate: [ + { + ...configDocuments[0], + config_blob: { + level1: { + name: 'updatedObject', + level2: { + description: 'New description here', + }, + bar: [1, 5], + }, + }, + }, + { + ...configDocuments[1], + config_blob: { + levelA: { + flag: false, + levelB: { + items: ['item1', 'item2', 'item4'], + }, + }, + }, + }, + ], + }, + { + configsToCreate: [], + existingConfigs: configDocuments, + configsToUpdate: [ + { + ...configDocuments[0], + config_blob: { + level1: { + name: 'updatedObject', + level2: { + description: 'New description here', + }, + bar: [1, 5], + }, + }, + }, + { + ...configDocuments[1], + config_blob: { + levelA: { + flag: false, + levelB: { + items: ['item1', 'item2', 'item4'], + }, + }, + }, + }, + { + ...configDocuments[2], + config_blob: { + section: { + id: 'sec1', + levelX: { + title: 'Section Title', + levelY: { + status: 'active', + }, + }, + }, + otherSection: { + values: [14, 11, 2], + }, + }, + }, + ], + }, + ])( + 'should call bulk() with correct operations', + async ({ configsToCreate, configsToUpdate, existingConfigs }) => { + configsToCreate.forEach((config) => { + spyFunction.mockImplementationOnce(() => JSON.stringify(config)); + }); + const configMap = new Map(); + const expectedBulkRequest: Array< + ConfigDocument | { doc: Pick } | BulkOperationContainer + > = []; + const bulkCreateConfigsRequest: ConfigBlob[] = []; + + configsToUpdate.forEach((config) => { + const oldConfig = existingConfigs.filter( + (existingConfig) => existingConfig.config_name === config.config_name + )[0]; + configMap.set(config.config_name, config.config_blob); + expectedBulkRequest.push( + { + update: { + _id: JSON.stringify(oldConfig), + _index: DYNAMIC_APP_CONFIG_ALIAS, + retry_on_conflict: 2, + routing: '', + version: 2, + version_type: 'external', + }, + }, + { + doc: { + config_blob: config.config_blob, + }, + } + ); + bulkCreateConfigsRequest.push({ + name: config.config_name, + updatedConfig: config.config_blob, + }); + }); + + configsToCreate.forEach((config) => { + configMap.set(config.config_name, config.config_blob); + expectedBulkRequest.push( + { + create: { + _id: JSON.stringify(config), + _index: DYNAMIC_APP_CONFIG_ALIAS, + retry_on_conflict: 2, + routing: '', + version: 1, + version_type: 'external', + }, + }, + { + ...config, + } + ); + bulkCreateConfigsRequest.push({ + name: config.config_name, + updatedConfig: config.config_blob, + }); + }); + + const mockClient = createOpenSearchClientMock({ + isListConfig: false, + configDocuments: existingConfigs, + existsAliasResult: false, + }); + const configStoreClient = new OpenSearchConfigStoreClient(mockClient); + await configStoreClient.bulkCreateConfigs({ + configs: bulkCreateConfigsRequest, + }); + + expect(mockClient.bulk).toBeCalledWith({ + index: DYNAMIC_APP_CONFIG_ALIAS, + body: expectedBulkRequest, + }); + + // Should cache result (search() is always called before bulk() to find existing configs) + const result = await configStoreClient.bulkGetConfigs([...configMap.keys()]); + assertMapsAreEqual(result, configMap); + expect(mockClient.search).toBeCalledTimes(1); + } + ); + }); + + describe('deleteConfig()', () => { + it('should call deleteByQuery() with correct arguments', async () => { + const mockClient = createOpenSearchClientMock({ + isListConfig: false, + configDocuments: [], + existsAliasResult: false, + }); + const configStoreClient = new OpenSearchConfigStoreClient(mockClient); + await configStoreClient.deleteConfig({ name: 'some_config_name' }); + + expect(mockClient.deleteByQuery).toBeCalledWith({ + index: DYNAMIC_APP_CONFIG_ALIAS, + body: { + query: { + bool: { + should: [ + { + terms: { + config_name: ['some_config_name'], + }, + }, + ], + }, + }, + }, + }); + }); + }); + + describe('bulkDeleteConfigs()', () => { + it.each([ + { + namespaces: [], + }, + { + namespaces: ['foo'], + }, + { + namespaces: ['foo', 'bar'], + }, + ])('should call deleteByQuery() with $namespaces.length namespaces', async ({ namespaces }) => { + const mockClient = createOpenSearchClientMock({ + isListConfig: false, + configDocuments: [], + existsAliasResult: false, + }); + const configStoreClient = new OpenSearchConfigStoreClient(mockClient); + await configStoreClient.bulkDeleteConfigs({ + paths: namespaces.map((name) => ({ name })), + }); + + expect(mockClient.deleteByQuery).toBeCalledWith({ + index: DYNAMIC_APP_CONFIG_ALIAS, + body: { + query: { + bool: { + should: [ + { + terms: { + config_name: namespaces, + }, + }, + ], + }, + }, + }, + }); + }); + }); +}); diff --git a/src/core/server/config/service/config_store_client/opensearch_config_store_client.ts b/src/core/server/config/service/config_store_client/opensearch_config_store_client.ts new file mode 100644 index 000000000000..8cae9284a937 --- /dev/null +++ b/src/core/server/config/service/config_store_client/opensearch_config_store_client.ts @@ -0,0 +1,345 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { BulkOperationContainer, SearchHit } from '@opensearch-project/opensearch/api/types'; +import { + DynamicConfigurationClientOptions, + IDynamicConfigStoreClient, +} from 'opensearch-dashboards/server'; +import { uniqueId } from 'lodash'; +import { OpenSearchClient } from '../../../opensearch'; +import { + BulkCreateConfigProps, + BulkDeleteConfigProps, + ConfigBlob, + ConfigObject, + CreateConfigProps, + DeleteConfigProps, +} from '../../types'; +import { + DYNAMIC_APP_CONFIG_ALIAS, + DYNAMIC_APP_CONFIG_MAX_RESULT_SIZE, +} from '../../utils/constants'; +import { pathToString, getDynamicConfigIndexName } from '../../utils/utils'; +import { ConfigDocument } from './types'; + +interface ConfigMapEntry { + configBlob: ConfigBlob; +} + +/** + * This is the default client DAO when "dynamic_config_service.enabled: true" and no plugin has registered a DAO factory. + * This client will fetch configs from .opensearch_dashboards_config alias. + * The alias is important as it will always point to the latest "version" of the config index + */ +export class OpenSearchConfigStoreClient implements IDynamicConfigStoreClient { + readonly #openSearchClient: OpenSearchClient; + readonly #cache: Map = new Map(); + + constructor(openSearchClient: OpenSearchClient) { + this.#openSearchClient = openSearchClient; + } + + /** + * Inserts the config index and an alias that points to it + * + * TODO Add migration logic + */ + public async createDynamicConfigIndex() { + const existsResponse = await this.#openSearchClient.indices.existsAlias({ + name: DYNAMIC_APP_CONFIG_ALIAS, + }); + if (!existsResponse.body) { + await this.#openSearchClient.indices.create({ + index: getDynamicConfigIndexName(1), + }); + await this.#openSearchClient.indices.updateAliases({ + body: { + actions: [ + { + add: { + index: getDynamicConfigIndexName(1), + alias: DYNAMIC_APP_CONFIG_ALIAS, + }, + }, + ], + }, + }); + } + } + + public async getConfig(namespace: string, options?: DynamicConfigurationClientOptions) { + if (this.#cache.has(namespace)) { + return this.#cache.get(namespace); + } + + const result = (await this.searchConfigsRequest([namespace])).body.hits.hits; + + if (result.length <= 0) { + this.#cache.set(namespace, undefined); + return undefined; + } + + const source = result[0]._source; + this.setCacheFromSearch(result[0]); + + return source?.config_blob; + } + + public async bulkGetConfigs(namespaces: string[], options?: DynamicConfigurationClientOptions) { + const results = new Map(); + const configsToQuery = namespaces.filter((namespace) => { + const isCached = this.#cache.has(namespace); + const config = this.#cache.get(namespace); + if (config) { + results.set(namespace, config); + } + return !isCached; + }); + + if (configsToQuery.length <= 0) { + return results; + } + + let nonExistentConfigs = [...configsToQuery]; + const configs = await this.searchConfigsRequest(configsToQuery); + configs.body.hits.hits.forEach((config) => { + // eslint-disable-next-line @typescript-eslint/naming-convention + const { config_name, config_blob } = config._source!; + nonExistentConfigs = nonExistentConfigs.filter((name) => name !== config_name); + if (config_blob) { + results.set(config_name, config_blob); + } + + this.setCacheFromSearch(config); + }); + + // Cache results that weren't found + nonExistentConfigs.forEach((name) => { + this.#cache.set(name, undefined); + }); + + return results; + } + + public async listConfigs(options?: DynamicConfigurationClientOptions) { + // Cannot get from cache since config keys can be missing + const configs = await this.#openSearchClient.search({ + index: DYNAMIC_APP_CONFIG_ALIAS, + body: { + size: DYNAMIC_APP_CONFIG_MAX_RESULT_SIZE, + query: { + match_all: {}, + }, + }, + }); + + const results = new Map( + configs.body.hits.hits + .filter((config) => { + this.setCacheFromSearch(config); + return !!config._source?.config_blob; + }) + .map((config) => { + return [config._source?.config_name!, config._source?.config_blob!]; + }) + ); + + return results; + } + + public async createConfig( + createConfigProps: CreateConfigProps, + options?: DynamicConfigurationClientOptions + ) { + const { config } = createConfigProps; + const name = pathToString(config); + return await this.createConfigsRequest(new Map([[name, { configBlob: config }]])); + } + + public async bulkCreateConfigs( + bulkCreateConfigProps: BulkCreateConfigProps, + options?: DynamicConfigurationClientOptions + ) { + return await this.createConfigsRequest( + new Map( + bulkCreateConfigProps.configs.map((configBlob) => { + const name = pathToString(configBlob); + return [name, { configBlob }]; + }) + ) + ); + } + + public async deleteConfig( + deleteConfigs: DeleteConfigProps, + options?: DynamicConfigurationClientOptions + ) { + const name = pathToString(deleteConfigs); + return await this.deleteConfigsRequest([name]); + } + + public async bulkDeleteConfigs( + bulkDeleteConfigs: BulkDeleteConfigProps, + options?: DynamicConfigurationClientOptions + ) { + const namespaces = bulkDeleteConfigs.paths.map((path) => { + return pathToString(path); + }); + return await this.deleteConfigsRequest(namespaces); + } + + public clearCache() { + this.#cache.clear(); + } + + /** + * Adds config names to the cache from search hits + * + * @param config + */ + private setCacheFromSearch(config: SearchHit) { + // eslint-disable-next-line @typescript-eslint/naming-convention + const { config_blob, config_name } = config._source!; + this.#cache.set(config_name, config_blob); + } + + /** + * Adds config names to the cache from a config document + * + * @param config + */ + private setCache(config: ConfigDocument) { + this.#cache.set(config.config_name, config.config_blob); + } + + /** + * Sends a bulk update/request to create/update the new configs + * + * @param configMap config name and config blob key/pair values + */ + private async createConfigsRequest(configMap: Map) { + const existingConfigs = await this.searchConfigsRequest([...configMap.keys()], true); + const existingConfigNames: string[] = []; + + // Update the existing configs with the new config blob + const bulkConfigs: Array< + ConfigDocument | BulkOperationContainer + > = existingConfigs.body.hits.hits.flatMap((config) => { + const configName = config._source?.config_name!; + existingConfigNames.push(configName); + const configBlob = configMap.get(configName)?.configBlob.updatedConfig; + this.setCache({ + ...config._source!, + config_blob: configBlob!, + }); + + return [ + { + update: { + _id: config._id, + _index: DYNAMIC_APP_CONFIG_ALIAS, + retry_on_conflict: 2, + routing: '', + version: config._version! + 1, + version_type: 'external', + }, + }, + { + doc: { + // Only need to update the blob + config_blob: configBlob, + }, + }, + ]; + }); + + // Create the rest + const configsToCreate = [...configMap.keys()].filter( + (name) => !existingConfigNames.includes(name) + ); + configsToCreate.forEach((name) => { + const { configBlob } = configMap.get(name)!; + const newConfigDocument = { + config_name: name, + config_blob: configBlob.updatedConfig, + }; + this.setCache(newConfigDocument); + + bulkConfigs.push( + { + create: { + _id: uniqueId(), + _index: DYNAMIC_APP_CONFIG_ALIAS, + retry_on_conflict: 2, + routing: '', + version: 1, + version_type: 'external', + }, + }, + newConfigDocument + ); + }); + + return await this.#openSearchClient.bulk({ + index: DYNAMIC_APP_CONFIG_ALIAS, + body: bulkConfigs, + }); + } + + /** + * Deletes documents whose config name matches the query + * + * @param namespaces list of config names to search + * @returns + */ + private async deleteConfigsRequest(namespaces: string[]) { + namespaces.forEach((name) => this.#cache.delete(name)); + + return await this.#openSearchClient.deleteByQuery({ + index: DYNAMIC_APP_CONFIG_ALIAS, + body: { + query: { + bool: { + should: [ + { + terms: { + config_name: namespaces, + }, + }, + ], + }, + }, + }, + }); + } + + /** + * Returns documents whose config name matches the query + * + * @param namespaces list of config names to search + * @param excludeConfigBlob whether to include the config blob in the response + * @returns + */ + private async searchConfigsRequest(namespaces: string[], excludeConfigBlob: boolean = false) { + return await this.#openSearchClient.search({ + ...(excludeConfigBlob && { _source: ['config_name'] }), + index: DYNAMIC_APP_CONFIG_ALIAS, + body: { + query: { + bool: { + should: [ + { + terms: { + config_name: namespaces, + }, + }, + ], + }, + }, + }, + }); + } +} diff --git a/src/core/server/config/service/config_store_client/opensearch_config_store_factory.ts b/src/core/server/config/service/config_store_client/opensearch_config_store_factory.ts new file mode 100644 index 000000000000..3fac7911925c --- /dev/null +++ b/src/core/server/config/service/config_store_client/opensearch_config_store_factory.ts @@ -0,0 +1,26 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + IDynamicConfigStoreClientFactory, + OpenSearchServiceStart, +} from 'opensearch-dashboards/server'; +import { OpenSearchClient } from '../../../opensearch'; +import { OpenSearchConfigStoreClient } from './opensearch_config_store_client'; + +export class OpenSearchDynamicConfigStoreFactory implements IDynamicConfigStoreClientFactory { + readonly #opensearchClient: OpenSearchClient; + + constructor(opensearch: OpenSearchServiceStart) { + this.#opensearchClient = opensearch.client.asInternalUser; + } + + /** + * TODO Once the OpenSearch client is implemented, finish implementing factory method + */ + public create() { + return new OpenSearchConfigStoreClient(this.#opensearchClient); + } +} diff --git a/src/core/server/config/service/config_store_client/types.ts b/src/core/server/config/service/config_store_client/types.ts new file mode 100644 index 000000000000..3a20f828c260 --- /dev/null +++ b/src/core/server/config/service/config_store_client/types.ts @@ -0,0 +1,9 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export interface ConfigDocument { + config_name: string; + config_blob: Record; +} diff --git a/src/core/server/config/service/configuration_client.mock.ts b/src/core/server/config/service/configuration_client.mock.ts new file mode 100644 index 000000000000..25bbfa559b81 --- /dev/null +++ b/src/core/server/config/service/configuration_client.mock.ts @@ -0,0 +1,87 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { ApiResponse } from '@opensearch-project/opensearch/.'; +import { IDynamicConfigurationClient, IInternalDynamicConfigurationClient } from '../types'; +import { createApiResponse } from '../utils/utils'; + +const createInternalDynamicConfigurationClientMock = ( + props: InternalDynamicConfigurationClientMockProps = { + getConfig: {}, + bulkGetConfigs: new Map>(), + listConfigs: new Map>(), + } +) => { + const mocked: jest.Mocked = { + getConfig: jest.fn(), + bulkGetConfigs: jest.fn(), + listConfigs: jest.fn(), + createConfig: jest.fn(), + bulkCreateConfigs: jest.fn(), + deleteConfig: jest.fn(), + bulkDeleteConfigs: jest.fn(), + }; + mocked.getConfig.mockResolvedValue(props.getConfig); + mocked.bulkGetConfigs.mockResolvedValue(props.bulkGetConfigs); + mocked.listConfigs.mockResolvedValue(props.listConfigs); + mocked.createConfig.mockResolvedValue(createApiResponse(props.createConfig)); + mocked.bulkCreateConfigs.mockResolvedValue(createApiResponse(props.bulkCreateConfigs)); + mocked.deleteConfig.mockResolvedValue(createApiResponse(props.deleteConfig)); + mocked.bulkDeleteConfigs.mockResolvedValue(createApiResponse(props.bulkDeleteConfigs)); + return mocked; +}; + +const createDynamicConfigurationClientMock = ( + props: DynamicConfigurationClientMockProps = { + getConfig: {}, + bulkGetConfigs: new Map>(), + listConfigs: new Map>(), + } +) => { + const mocked: jest.Mocked = { + getConfig: jest.fn(), + bulkGetConfigs: jest.fn(), + listConfigs: jest.fn(), + }; + mocked.getConfig.mockImplementation((getConfigProps, options) => { + if (getConfigProps.name && getConfigProps.name === 'csp') { + return Promise.resolve({ + rules: [], + strict: false, + warnLegacyBrowsers: false, + }); + } + + return Promise.resolve(props.getConfig); + }); + mocked.bulkGetConfigs.mockResolvedValue(props.bulkGetConfigs); + mocked.listConfigs.mockResolvedValue(props.listConfigs); + return mocked; +}; + +export interface InternalDynamicConfigurationClientMockProps { + getConfig: Record; + bulkGetConfigs: Map>; + listConfigs: Map>; + createConfig?: Partial; + bulkCreateConfigs?: Partial; + deleteConfig?: Partial; + bulkDeleteConfigs?: Partial; +} + +export type DynamicConfigurationClientMockProps = Pick< + InternalDynamicConfigurationClientMockProps, + 'getConfig' | 'bulkGetConfigs' | 'listConfigs' +>; + +export const internalDynamicConfigurationClientMock = { + create: createInternalDynamicConfigurationClientMock, + createApiResponse, +}; + +export const dynamicConfigurationClientMock = { + create: createDynamicConfigurationClientMock, + createApiResponse, +}; diff --git a/src/core/server/config/service/dynamic_configuration_client.ts b/src/core/server/config/service/dynamic_configuration_client.ts new file mode 100644 index 000000000000..0cfd6a8c8b27 --- /dev/null +++ b/src/core/server/config/service/dynamic_configuration_client.ts @@ -0,0 +1,35 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { InternalDynamicConfigurationClient } from './internal_dynamic_configuration_client'; +import { + BulkGetConfigProps, + DynamicConfigurationClientOptions, + GetConfigProps, + IDynamicConfigurationClient, +} from '../types'; + +export class DynamicConfigurationClient implements IDynamicConfigurationClient { + readonly #dynamicConfigurationClient: InternalDynamicConfigurationClient; + + constructor(internalDynamicConfigurationClient: InternalDynamicConfigurationClient) { + this.#dynamicConfigurationClient = internalDynamicConfigurationClient; + } + + public async getConfig(props: GetConfigProps, options?: DynamicConfigurationClientOptions) { + return this.#dynamicConfigurationClient.getConfig(props, options); + } + + public async bulkGetConfigs( + props: BulkGetConfigProps, + options?: DynamicConfigurationClientOptions + ) { + return this.#dynamicConfigurationClient.bulkGetConfigs(props, options); + } + + public async listConfigs(options?: DynamicConfigurationClientOptions) { + return this.#dynamicConfigurationClient.listConfigs(options); + } +} diff --git a/src/core/server/config/service/internal_dynamic_configuration_client.test.ts b/src/core/server/config/service/internal_dynamic_configuration_client.test.ts new file mode 100644 index 000000000000..d772c77fca74 --- /dev/null +++ b/src/core/server/config/service/internal_dynamic_configuration_client.test.ts @@ -0,0 +1,744 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { of } from 'rxjs'; +import { configMock, configServiceMock } from '../mocks'; +import { InternalDynamicConfigurationClient } from './internal_dynamic_configuration_client'; +import { loggerMock } from '../../logging/logger.mock'; +import { Env } from '@osd/config'; +import _ from 'lodash'; +import { schema, Type } from '@osd/config-schema'; +import { IDynamicConfigStoreClient } from 'opensearch-dashboards/server'; +import { ApiResponse } from '@opensearch-project/opensearch/.'; + +describe('InternalDynamicConfigStoreClient', () => { + const existingConfigServiceMock = configServiceMock.create(); + + interface CreateConfigStoreClientMockProps { + getConfigReturnValue?: Record; + bulkGetConfigsReturnValue?: Map>; + listConfigsReturnValue?: Map>; + createConfigReturnValue?: ApiResponse; + bulkCreateConfigsReturnValue?: ApiResponse; + deleteConfigReturnValue?: ApiResponse; + bulkDeleteConfigsReturnValue?: ApiResponse; + } + + const createConfigStoreClientMock = ({ + getConfigReturnValue, + bulkGetConfigsReturnValue, + listConfigsReturnValue, + createConfigReturnValue, + bulkCreateConfigsReturnValue, + deleteConfigReturnValue, + bulkDeleteConfigsReturnValue, + }: CreateConfigStoreClientMockProps): IDynamicConfigStoreClient => { + return { + getConfig: jest.fn().mockResolvedValue(getConfigReturnValue), + bulkGetConfigs: jest.fn().mockResolvedValue(bulkGetConfigsReturnValue), + listConfigs: jest.fn().mockResolvedValue(listConfigsReturnValue), + createConfig: jest.fn().mockResolvedValue(createConfigReturnValue), + bulkCreateConfigs: jest.fn().mockResolvedValue(bulkCreateConfigsReturnValue), + deleteConfig: jest.fn().mockResolvedValue(deleteConfigReturnValue), + bulkDeleteConfigs: jest.fn().mockResolvedValue(bulkDeleteConfigsReturnValue), + }; + }; + + beforeEach(() => { + existingConfigServiceMock.atPath = jest.fn().mockImplementation((namespace) => {}); + existingConfigServiceMock.getConfig$ = jest.fn().mockReturnValue({}); + }); + + describe('getConfig()', () => { + interface GetConfigTestCaseFormat { + configName: string; + oldConfig: Record; + expectedConfig: Record; + dynamicConfigStoreMockResponse: Record; + schemaStructure: Type; + } + + it('should throw an error for a non-existent config name', async () => { + const dynamicConfigStoreClient = createConfigStoreClientMock({ getConfigReturnValue: {} }); + + const internalClient = new InternalDynamicConfigurationClient({ + client: dynamicConfigStoreClient, + configService: configServiceMock.create(), + env: {} as Env, + logger: loggerMock.create(), + schemas: new Map(), + }); + + const name = 'non_existent_name'; + await expect(internalClient.getConfig({ name })).rejects.toThrow( + `schema for ${name} not found` + ); + }); + + it.each([ + { + configName: 'somePlugin', + oldConfig: { + a: 'foo', + b: 'old', + c: { + d: 2, + }, + }, + expectedConfig: { + a: 'foo', + b: 'bar', + c: { + d: 4, + }, + }, + dynamicConfigStoreMockResponse: { + b: 'bar', + c: { + d: 4, + }, + }, + schemaStructure: schema.object({ + a: schema.string(), + b: schema.string(), + c: schema.object({ + d: schema.number(), + }), + }), + }, + { + configName: 'somePluginWithNoCustomerConfigs', + oldConfig: { + a: 'foo', + b: { + c: { + d: true, + }, + }, + }, + expectedConfig: { + a: 'foo', + b: { + c: { + d: true, + }, + }, + }, + dynamicConfigStoreMockResponse: {}, + schemaStructure: schema.object({ + a: schema.string(), + b: schema.object({ + c: schema.object({ + d: schema.boolean(), + }), + }), + }), + }, + ])( + '$configName should merge configs from DDB and old config service', + async ({ + configName, + oldConfig, + expectedConfig, + dynamicConfigStoreMockResponse, + schemaStructure, + }) => { + const schemas = new Map([[_.snakeCase(configName), schemaStructure]]); + + const dynamicConfigStoreClient = createConfigStoreClientMock({ + getConfigReturnValue: dynamicConfigStoreMockResponse, + }); + + const mockedObservable = of(oldConfig); + const configService = configServiceMock.create(); + configService.atPath = jest.fn().mockReturnValue(mockedObservable); + + const internalClient = new InternalDynamicConfigurationClient({ + client: dynamicConfigStoreClient as any, + configService, + env: {} as Env, + logger: loggerMock.create(), + schemas, + }); + + const actualConfigResponse = await internalClient.getConfig({ name: configName }); + + expect(_.isEqual(actualConfigResponse, expectedConfig)).toBe(true); + } + ); + }); + + describe('bulkGetConfigs()', () => { + interface ConfigListItem { + configName: string; + oldConfig: Record; + dynamicConfigStoreMockResponse: Record; + schemaStructure: Type; + } + interface BulkGetConfigTestCaseFormat { + configsList: ConfigListItem[]; + expectedConfigs: Map>; + } + + interface BulkGetConfigNonExistentTestCaseFormat { + configsList: ConfigListItem[]; + nonExistentConfigs: string[]; + } + + it.each([ + { + configsList: [], + nonExistentConfigs: ['foo'], + }, + { + configsList: [], + nonExistentConfigs: ['foo', 'bar'], + }, + { + configsList: [ + { + configName: 'foo', + oldConfig: { + a: 13, + b: { + c: true, + }, + }, + dynamicConfigStoreMockResponse: { + b: { + c: false, + }, + }, + schemaStructure: schema.object({ + a: schema.number(), + b: schema.object({ + c: schema.boolean(), + }), + }), + }, + ], + nonExistentConfigs: ['foo', 'bar'], + }, + ])( + 'should fail when the configs $nonExistentConfigs are attempted to be called', + async ({ configsList, nonExistentConfigs }) => { + const schemas = new Map(); + const dynamicConfigStoreMockResponses = new Map(); + const mockedObservableMap = new Map(); + + configsList.forEach((configItem) => { + const processedConfigName = _.snakeCase(configItem.configName); + schemas.set(processedConfigName, configItem.schemaStructure); + dynamicConfigStoreMockResponses.set( + processedConfigName, + configItem.dynamicConfigStoreMockResponse + ); + mockedObservableMap.set(processedConfigName, of(configItem.oldConfig)); + }); + + const dynamicConfigStoreClient = createConfigStoreClientMock({ + bulkGetConfigsReturnValue: dynamicConfigStoreMockResponses, + }); + + const configService = configServiceMock.create(); + configService.atPath = jest.fn().mockImplementation((namespace: string) => { + if (!mockedObservableMap.has(namespace)) { + throw new Error(`${namespace} is not defined/found in the mockedObservableMap`); + } + return mockedObservableMap.get(namespace); + }); + + const internalClient = new InternalDynamicConfigurationClient({ + client: dynamicConfigStoreClient as any, + configService, + env: {} as Env, + logger: loggerMock.create(), + schemas, + }); + + const paths = nonExistentConfigs.map((configName: string) => { + return { name: configName }; + }); + + await expect(internalClient.bulkGetConfigs({ paths })).rejects.toThrow(/schema for/); + } + ); + + it.each([ + { + configsList: [ + { + configName: 'foo', + oldConfig: { + a: 'value', + b: true, + c: { + d: { + e: 1, + }, + }, + }, + dynamicConfigStoreMockResponse: { + c: { + d: { + e: 536, + }, + }, + }, + schemaStructure: schema.object({ + a: schema.string(), + b: schema.boolean(), + c: schema.object({ + d: schema.object({ + e: schema.number(), + }), + }), + }), + }, + ], + expectedConfigs: new Map([ + [ + 'foo', + { + a: 'value', + b: true, + c: { + d: { + e: 536, + }, + }, + }, + ], + ]), + }, + { + configsList: [ + { + configName: 'foo', + oldConfig: { + a: 'value', + b: true, + c: { + d: { + e: 1, + }, + }, + }, + dynamicConfigStoreMockResponse: { + c: { + d: { + e: 536, + }, + }, + }, + schemaStructure: schema.object({ + a: schema.string(), + b: schema.boolean(), + c: schema.object({ + d: schema.object({ + e: schema.number(), + }), + }), + }), + }, + { + configName: 'bar', + oldConfig: { + a: '13', + b: 13, + c: { + d: true, + }, + }, + dynamicConfigStoreMockResponse: { + a: '24561', + }, + schemaStructure: schema.object({ + a: schema.string(), + b: schema.number(), + c: schema.object({ + d: schema.boolean(), + }), + }), + }, + { + configName: 'baz', + oldConfig: { + a: { + b: true, + }, + c: 'someString', + }, + dynamicConfigStoreMockResponse: {}, + schemaStructure: schema.object({ + a: schema.object({ + b: schema.boolean(), + }), + c: schema.string(), + }), + }, + ], + expectedConfigs: new Map([ + [ + 'foo', + { + a: 'value', + b: true, + c: { + d: { + e: 536, + }, + }, + }, + ], + [ + 'bar', + { + a: '24561', + b: 13, + c: { + d: true, + }, + }, + ], + [ + 'baz', + { + a: { + b: true, + }, + c: 'someString', + }, + ], + ]), + }, + ])( + 'should merge configs for one or many requested configs', + async ({ configsList, expectedConfigs }) => { + const schemas = new Map(); + const dynamicConfigStoreMockResponses = new Map(); + const mockedObservableMap = new Map(); + + configsList.forEach((configItem) => { + const processedConfigName = _.snakeCase(configItem.configName); + schemas.set(processedConfigName, configItem.schemaStructure); + dynamicConfigStoreMockResponses.set( + processedConfigName, + configItem.dynamicConfigStoreMockResponse + ); + mockedObservableMap.set(processedConfigName, of(configItem.oldConfig)); + }); + + const dynamicConfigStoreClient = createConfigStoreClientMock({ + bulkGetConfigsReturnValue: dynamicConfigStoreMockResponses, + }); + + const configService = configServiceMock.create(); + configService.atPath = jest.fn().mockImplementation((namespace: string) => { + if (!mockedObservableMap.has(namespace)) { + throw new Error(`${namespace} is not defined/found in the mockedObservableMap`); + } + return mockedObservableMap.get(namespace); + }); + + const internalClient = new InternalDynamicConfigurationClient({ + client: dynamicConfigStoreClient as any, + configService, + env: {} as Env, + logger: loggerMock.create(), + schemas, + }); + + const paths = configsList.map((configItem) => { + return { name: configItem.configName }; + }); + + const actualResponse = await internalClient.bulkGetConfigs({ paths }); + + expect(actualResponse.size).toBe(expectedConfigs.size); + for (const [key, value] of expectedConfigs) { + expect(actualResponse.has(key)).toBe(true); + expect(_.isEqual(actualResponse.get(key), value)).toBe(true); + } + } + ); + }); + + describe('listConfigs()', () => { + interface ListConfigsTestCaseFormat { + oldConfigs: Map>; + dynamicConfigStoreMockResponse: Map>; + expectedConfigs: Map>; + } + + it.each([ + { + oldConfigs: new Map([ + [ + 'foo', + { + a: true, + b: 1, + c: { + d: '143', + }, + }, + ], + ]), + dynamicConfigStoreMockResponse: new Map(), + expectedConfigs: new Map(), + }, + { + oldConfigs: new Map([ + [ + 'foo', + { + key1: 'value1', + key2: { + subKey1: 123, + subKey2: true, + subKey3: ['a', 'b', 'c'], + }, + key3: { + subKey4: '', + subKey5: { + subSubKey1: false, + subSubKey2: 0, + }, + }, + }, + ], + [ + 'bar', + { + key1: 'value1', + key2: { + subKey1: 42, + subKey2: true, + subKey3: ['x', 'y', 'z'], + }, + key3: { + subKey4: 'abc', + subKey5: { + subSubKey1: false, + subSubKey2: -10, + }, + }, + }, + ], + ]), + dynamicConfigStoreMockResponse: new Map([ + [ + 'foo', + { + key1: 'value3', + key2: { + subKey2: false, + subKey3: ['b', 'c'], + }, + key3: { + subKey5: { + subSubKey1: true, + }, + }, + }, + ], + ]), + expectedConfigs: new Map([ + [ + 'foo', + { + key1: 'value3', + key2: { + subKey2: false, + subKey3: ['b', 'c'], + }, + key3: { + subKey5: { + subSubKey1: true, + }, + }, + }, + ], + ]), + }, + { + oldConfigs: new Map([ + [ + 'foo', + { + a: 'John', + b: 30, + c: true, + d: ['reading', 'painting'], + e: { + f: 'New York', + g: 10001, + h: { + i: 40.7128, + j: -74.006, + }, + }, + }, + ], + [ + 'bar', + { + key1: 'value1', + key2: { + subKey1: 3.14, + subKey2: true, + subKey3: ['a', 'b', 'c'], + }, + key3: { + subKey4: 'hello', + subKey5: { + subSubKey1: false, + subSubKey2: 0, + }, + }, + }, + ], + [ + 'baz', + { + a: { + a1: 'Smartphone', + a2: 'ABC', + a3: 599.99, + a4: true, + a5: { + aa1: { + aaa1: '6.5 inches', + aaa2: 'AMOLED', + }, + aa2: { + aab1: '12 MP', + aab2: ['HDR', 'Night Mode'], + }, + }, + }, + b: { + b1: 'XYZ Electronics', + b2: 4.7, + b3: { + bb1: 'Los Angeles', + bb2: 'California', + }, + }, + }, + ], + ]), + dynamicConfigStoreMockResponse: new Map([ + [ + 'foo', + { + c: true, + d: ['reading', 'running', 'drawing'], + e: { + h: { + j: -13.01, + }, + }, + }, + ], + [ + 'baz', + { + a: { + a5: { + aa1: { + aaa2: 'LED', + }, + }, + }, + b: { + b1: 'ABC Corp', + b3: { + bb1: 'San Francisco', + bb2: 'California', + }, + }, + }, + ], + ]), + expectedConfigs: new Map([ + [ + 'foo', + { + c: true, + d: ['reading', 'running', 'drawing'], + e: { + h: { + j: -13.01, + }, + }, + }, + ], + [ + 'baz', + { + a: { + a5: { + aa1: { + aaa2: 'LED', + }, + }, + }, + b: { + b1: 'ABC Corp', + b3: { + bb1: 'San Francisco', + bb2: 'California', + }, + }, + }, + ], + ]), + }, + ])( + 'should merge configs when 0, 1, or multiple customer configs are present', + async ({ oldConfigs, dynamicConfigStoreMockResponse, expectedConfigs }) => { + const configStoreMock = configMock.create(); + configStoreMock.get = jest.fn().mockImplementation((configName: string) => { + if (!oldConfigs.has(configName)) { + throw new Error(`${configName} not found/defined in oldConfigs`); + } + + return oldConfigs.get(configName); + }); + const configService = configServiceMock.create(); + configService.getConfig$ = jest.fn().mockReturnValue(configStoreMock); + + const schemas = new Map(); + oldConfigs.forEach((value, key) => { + const processedConfigName = _.snakeCase(key); + // Since schemas values will not be used, the value is stubbed + schemas.set(processedConfigName, {}); + }); + + const dynamicConfigStoreClient = createConfigStoreClientMock({ + listConfigsReturnValue: dynamicConfigStoreMockResponse, + }); + + const internalClient = new InternalDynamicConfigurationClient({ + client: dynamicConfigStoreClient as any, + configService, + env: {} as Env, + logger: loggerMock.create(), + schemas, + }); + + const actualResponse = await internalClient.listConfigs(); + + expect(actualResponse.size).toBe(expectedConfigs.size); + for (const [key, value] of expectedConfigs) { + expect(actualResponse.has(key)).toBe(true); + expect(_.isEqual(actualResponse.get(key), value)).toBe(true); + } + } + ); + }); +}); diff --git a/src/core/server/config/service/internal_dynamic_configuration_client.ts b/src/core/server/config/service/internal_dynamic_configuration_client.ts new file mode 100644 index 000000000000..2e60ac78ed33 --- /dev/null +++ b/src/core/server/config/service/internal_dynamic_configuration_client.ts @@ -0,0 +1,169 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Type, TypeOf } from '@osd/config-schema'; +import { Env, IConfigService } from '@osd/config'; +import { first } from 'rxjs/operators'; +import { Logger } from '@osd/logging'; +import { + BulkCreateConfigProps, + BulkDeleteConfigProps, + BulkGetConfigProps, + CreateConfigProps, + DeleteConfigProps, + IInternalDynamicConfigurationClient, + DynamicConfigurationClientOptions, + GetConfigProps, + IDynamicConfigStoreClient, +} from '../types'; +import { mergeConfigs, pathToString } from '../utils/utils'; + +export interface InternalDynamicConfigurationClientProps { + client: IDynamicConfigStoreClient; + logger: Logger; + schemas: Map>; + env: Env; + configService: IConfigService; +} + +export class InternalDynamicConfigurationClient implements IInternalDynamicConfigurationClient { + readonly #client: IDynamicConfigStoreClient; + readonly #logger: Logger; + readonly #schemas: Map>; + readonly #configService: IConfigService; + + constructor(props: InternalDynamicConfigurationClientProps) { + const { client, logger, schemas, configService } = props; + this.#client = client; + this.#schemas = schemas; + this.#configService = configService; + this.#logger = logger; + } + + public async getConfig( + getConfigProps: GetConfigProps, + options?: DynamicConfigurationClientOptions + ) { + const namespace = pathToString(getConfigProps); + const defaultConfig = await this.getDefaultConfig(namespace); + + // If this call fails/returns undefined, default to the defaultConfig + const configStoreConfig = await this.#client.getConfig(namespace, options); + + return configStoreConfig ? mergeConfigs(defaultConfig, configStoreConfig) : defaultConfig; + } + + public async bulkGetConfigs( + bulkGetConfig: BulkGetConfigProps, + options?: DynamicConfigurationClientOptions + ) { + const namespaces = bulkGetConfig.paths.map((path) => pathToString(path)); + const defaultConfigsMap = new Map>(); + + // TODO Determine whether to pass through or completely fail a bulkGet() call if a namespace does not exist + for (const namespace of namespaces) { + const config = await this.getDefaultConfig(namespace); + defaultConfigsMap.set(namespace, config); + } + + // If this call fails/returns undefined, default to the defaultConfig + const configStoreConfig = await this.#client.bulkGetConfigs(namespaces, options); + if (!configStoreConfig.size) { + return defaultConfigsMap; + } + + const finalConfigsMap = new Map>([...defaultConfigsMap]); + configStoreConfig.forEach((newConfig, configName) => { + const oldConfig = defaultConfigsMap.get(configName); + + if (!oldConfig) { + this.#logger.warn(`Config ${configName} not found`); + return defaultConfigsMap; + } + + const finalConfig = mergeConfigs(oldConfig!, newConfig); + finalConfigsMap.set(configName, finalConfig); + }); + + return finalConfigsMap; + } + + // TODO Determine if the listConfigs() should only list the configs for the config store or ALL configs + public async listConfigs(options?: DynamicConfigurationClientOptions) { + return await this.#client.listConfigs(options); + } + + public async createConfig( + createConfigProps: CreateConfigProps, + options?: DynamicConfigurationClientOptions + ) { + // TODO Add validation logic + return await this.#client.createConfig(createConfigProps, options); + } + + public async bulkCreateConfigs( + bulkCreateConfigProps: BulkCreateConfigProps, + options?: DynamicConfigurationClientOptions + ) { + // TODO Add validation logic + return await this.#client.bulkCreateConfigs(bulkCreateConfigProps, options); + } + + public async deleteConfig( + deleteConfigs: DeleteConfigProps, + options?: DynamicConfigurationClientOptions + ) { + return await this.#client.deleteConfig(deleteConfigs, options); + } + + public async bulkDeleteConfigs( + bulkDeleteConfigProps: BulkDeleteConfigProps, + options?: DynamicConfigurationClientOptions + ) { + return await this.#client.bulkDeleteConfigs(bulkDeleteConfigProps, options); + } + + /** + * Given the top level config, obtain the top level config from the config store + * + * @param namespace The config name to fetch the registered schema + * @private + */ + private async getDefaultConfig(namespace: string) { + const schema = this.#schemas.get(namespace); + if (!schema) { + throw new Error(`schema for ${namespace} not found`); + } + return (await this.#configService + .atPath>(namespace) + .pipe(first()) + .toPromise()) as Record; + } + + /** + * Returns the entire config as a Map of config names and schema values + * + * @private + * + * TODO This should only be implemented if listConfigs() will show configs not shown in config store + * private async getAllDefaultConfigs(): Promise>> { + * const configStore = await this.configService.getConfig$().toPromise(); + * const configMap = new Map(); + * Array.from(this.schemas.keys()).map((configName) => { + * configMap.set(configName, configStore.get(configName)); + * }); + * return configMap; + * } + */ + + /** + * TODO Implement validateConfig, which given a config blob and top level config name, validates it against the registered schema + * - see {@link ConfigService} validateAtPath() for reference + * + * @param configIdentifier + * @param config + * @private + */ +} diff --git a/src/core/server/config/types.ts b/src/core/server/config/types.ts new file mode 100644 index 000000000000..4b02701195cd --- /dev/null +++ b/src/core/server/config/types.ts @@ -0,0 +1,147 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { ApiResponse } from '@opensearch-project/opensearch/.'; +import { ConfigPath } from '@osd/config'; +import { OpenSearchDashboardsRequest } from '../http'; + +/** + * Setup allows core services and plugins to register config stores and any context to passed into async local store + * + * @interface + */ +export interface InternalDynamicConfigServiceSetup { + registerDynamicConfigClientFactory: (factory: IDynamicConfigStoreClientFactory) => void; + registerAsyncLocalStoreRequestHeader: (key: string | string[]) => void; + getStartService: () => Promise; +} + +export type DynamicConfigServiceSetup = InternalDynamicConfigServiceSetup; + +export interface InternalDynamicConfigServiceStart { + getClient: () => IDynamicConfigurationClient; + getAsyncLocalStore: () => AsyncLocalStorageContext | undefined; + createStoreFromRequest: ( + request: OpenSearchDashboardsRequest + ) => AsyncLocalStorageContext | undefined; +} + +export type DynamicConfigServiceStart = InternalDynamicConfigServiceStart; + +export type IDynamicConfigurationClient = Pick< + IInternalDynamicConfigurationClient, + 'getConfig' | 'bulkGetConfigs' | 'listConfigs' +>; + +export interface IInternalDynamicConfigurationClient { + createConfig: ( + createConfigProps: CreateConfigProps, + options?: DynamicConfigurationClientOptions + ) => Promise; + bulkCreateConfigs: ( + bulkCreateConfigProps: BulkCreateConfigProps, + options?: DynamicConfigurationClientOptions + ) => Promise; + getConfig: ( + getConfigProps: GetConfigProps, + options?: DynamicConfigurationClientOptions + ) => Promise; + bulkGetConfigs: ( + bulkGetConfigProps: BulkGetConfigProps, + options?: DynamicConfigurationClientOptions + ) => Promise>; + listConfigs: (options?: DynamicConfigurationClientOptions) => Promise>; + deleteConfig: ( + deleteConfigs: DeleteConfigProps, + options?: DynamicConfigurationClientOptions + ) => Promise; + bulkDeleteConfigs: ( + bulkDeleteConfigs: BulkDeleteConfigProps, + options?: DynamicConfigurationClientOptions + ) => Promise; +} + +export interface IDynamicConfigStoreClientFactory { + create: () => IDynamicConfigStoreClient; +} + +/** + * Client used to retrieve dynamic configs from the config store of choice + * + * @interface + */ +export type IDynamicConfigStoreClient = Pick< + IInternalDynamicConfigurationClient, + 'listConfigs' | 'bulkCreateConfigs' | 'createConfig' | 'bulkDeleteConfigs' | 'deleteConfig' +> & { + getConfig: ( + namespace: string, + options?: DynamicConfigurationClientOptions + ) => Promise; + bulkGetConfigs: ( + namespaces: string[], + options?: DynamicConfigurationClientOptions + ) => Promise>; +}; + +export interface DynamicConfigurationClientOptions { + asyncLocalStorageContext: AsyncLocalStorageContext; +} + +export type ConfigObject = Record; + +/** + * Provides the necessary context needed when a request first hits the http server + * + * @interface AsyncLocalStorageContext + */ +export type AsyncLocalStorageContext = Map; + +export type ConfigIdentifier = + | { + /** + * How plugin and core service schemas are identified. + * - For plugins, this is the plugin id (the 'id' field in camelCase that can be found in the plugin manifest) + * - Use pluginConfigPath if a plugin has the 'configPath' property set in its manifest + * - For core services, this is the path property found in the config schema + * - example: {@link HttpConfig} config name is 'server' + */ + name: string; + pluginConfigPath?: never; + } + | { + /** + * For plugins ONLY. This is the optional field 'configPath' in the plugin manifest. If a given plugin has 'configPath' in its manifest, set this value instead of name. + */ + pluginConfigPath: ConfigPath; + name?: never; + }; + +export type ConfigBlob = ConfigIdentifier & { + /** + * The new config blob to override the old config in the config store. This will update the entire config blob for a configIdentifier + */ + updatedConfig: ConfigObject; +}; + +export interface CreateConfigProps { + config: ConfigBlob; +} + +export interface BulkCreateConfigProps { + configs: ConfigBlob[]; +} + +export type GetConfigProps = ConfigIdentifier; + +export interface BulkGetConfigProps { + paths: ConfigIdentifier[]; +} + +export type DeleteConfigProps = ConfigIdentifier; + +export interface BulkDeleteConfigProps { + paths: ConfigIdentifier[]; +} diff --git a/src/core/server/config/utils/constants.ts b/src/core/server/config/utils/constants.ts new file mode 100644 index 000000000000..5401063af385 --- /dev/null +++ b/src/core/server/config/utils/constants.ts @@ -0,0 +1,9 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +// TODO Once the migration logic is added, refactor the index name to some function that returns the Nth version +export const DYNAMIC_APP_CONFIG_INDEX_PREFIX = '.opensearch_dashboards_dynamic_config'; +export const DYNAMIC_APP_CONFIG_ALIAS = '.opensearch_dashboards_dynamic_config'; +export const DYNAMIC_APP_CONFIG_MAX_RESULT_SIZE = 1000; diff --git a/src/core/server/config/utils/utils.test.ts b/src/core/server/config/utils/utils.test.ts new file mode 100644 index 000000000000..1f0a3c0a5aa6 --- /dev/null +++ b/src/core/server/config/utils/utils.test.ts @@ -0,0 +1,181 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { createLocalStore, mergeConfigs, pathToString } from './utils'; +import { Request } from 'hapi__hapi'; +import { loggerMock } from '../../logging/logger.mock'; + +describe('Utils', () => { + test.each([ + { + name: '', + pluginConfigPath: undefined, + expected: '', + }, + { + name: 'somePlugin', + pluginConfigPath: undefined, + expected: 'some_plugin', + }, + { + name: undefined, + pluginConfigPath: '', + expected: '', + }, + { + name: undefined, + pluginConfigPath: 'someOtherPlugin', + expected: 'someOtherPlugin', + }, + { + name: undefined, + pluginConfigPath: 'some_other_plugin', + expected: 'some_other_plugin', + }, + { + name: undefined, + pluginConfigPath: ['some'], + expected: 'some', + }, + { + name: undefined, + pluginConfigPath: ['some', 'Path'], + expected: 'some.Path', + }, + { + name: undefined, + pluginConfigPath: ['some', 'path'], + expected: 'some.path', + }, + ])( + 'pathToString() should correctly parse name objects', + ({ name, pluginConfigPath, expected }) => { + const result = name + ? pathToString({ name }) + : pathToString({ pluginConfigPath: pluginConfigPath! }); + expect(result).toBe(expected); + } + ); + + test.each([ + { + updatedConfig: { x: 10, y: 20 }, + oldConfig: { y: 15, z: 25 }, + finalConfig: { x: 10, y: 20, z: 25 }, + }, + { + updatedConfig: { x: 10, y: { z: 20, w: 30 } }, + oldConfig: { x: 5, y: { z: 15, v: 25 }, q: 50 }, + finalConfig: { x: 10, y: { z: 20, v: 25, w: 30 }, q: 50 }, + }, + { + updatedConfig: { x: [1, 2, 3], y: 20 }, + oldConfig: { x: [4, 5], y: 15 }, + finalConfig: { x: [1, 2, 3], y: 20 }, + }, + { + updatedConfig: { x: { y: [1, 2, 3], z: 30 } }, + oldConfig: { x: { y: [4, 5], w: 25 } }, + finalConfig: { x: { y: [1, 2, 3], z: 30, w: 25 } }, + }, + { + updatedConfig: { a: { b: { c: { d: 10 } } } }, + oldConfig: { a: { b: { c: { e: 20 } } } }, + finalConfig: { a: { b: { c: { d: 10, e: 20 } } } }, + }, + // This test case demonstrates that if updated configs have undefined fields, they will not be applied + { + updatedConfig: { a: null, b: undefined, c: 30 }, + oldConfig: { a: 10, b: 20, c: null }, + finalConfig: { a: null, b: 20, c: 30 }, + }, + { + updatedConfig: {}, + oldConfig: { x: 10, y: 20 }, + finalConfig: { x: 10, y: 20 }, + }, + { + updatedConfig: { x: 10, y: 20 }, + oldConfig: {}, + finalConfig: { x: 10, y: 20 }, + }, + ])( + 'mergeConfigs() should override all oldConfigs with newConfigs for specified fields except for undefined fields', + ({ oldConfig, updatedConfig, finalConfig }) => { + expect(mergeConfigs(oldConfig, updatedConfig)).toMatchObject(finalConfig); + } + ); + + test.each([ + { + headers: [], + requestMock: { + headers: { + foo: 'bar', + }, + }, + expectedMap: new Map(), + }, + { + headers: ['some-header'], + requestMock: { + headers: { + 'some-header': 'some-value', + foo: 'bar', + }, + }, + expectedMap: new Map([['some-header', 'some-value']]), + }, + { + headers: ['some-header'], + requestMock: { + headers: { + foo: 'bar', + }, + }, + expectedMap: new Map([['some-header', undefined]]), + }, + { + headers: ['some-header', 'some-other-header'], + requestMock: { + headers: { + foo: 'bar', + 'some-header': 'some-value', + }, + }, + expectedMap: new Map([ + ['some-header', 'some-value'], + ['some-other-header', undefined], + ]), + }, + { + headers: ['some-header', 'some-other-header'], + requestMock: { + headers: { + foo: 'bar', + 'some-header': 'some-value', + 'some-other-header': 'some-other-value', + }, + }, + expectedMap: new Map([ + ['some-header', 'some-value'], + ['some-other-header', 'some-other-value'], + ]), + }, + ])( + 'createLocalStore() should create a local store from the following headers: $headers', + ({ headers, requestMock, expectedMap }) => { + const actualMap = createLocalStore( + loggerMock.create(), + (requestMock as unknown) as Request, + headers + ); + expect(actualMap.size).toEqual(expectedMap.size); + expectedMap.forEach((value, key) => { + expect(actualMap.get(key)).toEqual(value); + }); + } + ); +}); diff --git a/src/core/server/config/utils/utils.ts b/src/core/server/config/utils/utils.ts new file mode 100644 index 000000000000..46d992fbc369 --- /dev/null +++ b/src/core/server/config/utils/utils.ts @@ -0,0 +1,97 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import _ from 'lodash'; +import { Logger } from '@osd/logging'; +import { Request } from 'hapi__hapi'; +import { ApiResponse } from '@opensearch-project/opensearch/.'; +import { ConfigIdentifier } from '../types'; +import { DYNAMIC_APP_CONFIG_INDEX_PREFIX } from './constants'; +import { OpenSearchDashboardsRequest } from '../../http'; + +/** + * Given a configIdentifier: + * - if name is provided, convert it from camelCase to snake_case + * - if pluginConfigPath is provided (for plugin configs ONLY), convert the ["config", "path"] to config.path + * + * @param configIdentifier + */ +export const pathToString = (configIdentifier: ConfigIdentifier) => { + const { name, pluginConfigPath } = configIdentifier; + if (pluginConfigPath) { + return Array.isArray(pluginConfigPath) ? pluginConfigPath.join('.') : pluginConfigPath; + } + return _.snakeCase(name); +}; + +export const createApiResponse = >( + opts: Partial = {} +): ApiResponse => { + return { + body: {} as any, + statusCode: 200, + headers: {}, + warnings: [], + meta: {} as any, + ...opts, + }; +}; + +/** + * Given the config from the config file and the config store, merge the two configs. + * + * @param defaultConfigs + * @param configStoreConfigs + */ +export const mergeConfigs = ( + defaultConfigs: Record, + configStoreConfigs: Record +) => { + // Ensures that the entire array of the configStoreConfigs overrides existing configs + const mergeCustomizer = (target: any, source: any) => { + if (_.isArray(target)) { + return source; + } + }; + return _.mergeWith(defaultConfigs, configStoreConfigs, mergeCustomizer); +}; + +export const createLocalStore = (logger: Logger, request: Request, headers: string[]) => { + return new Map( + headers.map((header: string) => { + try { + return [header, request.headers[header]]; + } catch (err) { + logger.warn(`Header ${header} not found in request`); + return [header, undefined]; + } + }) + ); +}; + +export const getDynamicConfigIndexName = (n: number) => { + return `${DYNAMIC_APP_CONFIG_INDEX_PREFIX}_${n}`; +}; + +export const createLocalStoreFromOsdRequest = ( + logger: Logger, + request: OpenSearchDashboardsRequest, + headers: string[] +) => { + if (!request.auth.isAuthenticated) { + return undefined; + } + return new Map( + headers.map((header: string) => { + try { + logger.debug(`${header}: ${request.headers[header]}`); + return [header, request.headers[header]]; + } catch (err) { + logger.warn(`Header ${header} not found in request`); + return [header, undefined]; + } + }) + ); +}; diff --git a/src/core/server/core_context.mock.ts b/src/core/server/core_context.mock.ts index b4f08e467cc1..d8e7a3ca5f32 100644 --- a/src/core/server/core_context.mock.ts +++ b/src/core/server/core_context.mock.ts @@ -34,17 +34,21 @@ import { Env, IConfigService } from './config'; import { configServiceMock, getEnvOptions } from './config/mocks'; import { loggingSystemMock } from './logging/logging_system.mock'; import { ILoggingSystem } from './logging'; +import { dynamicConfigServiceMock } from './config/dynamic_config_service.mock'; +import { IDynamicConfigService } from './config/dynamic_config_service'; function create({ env = Env.createDefault(REPO_ROOT, getEnvOptions()), logger = loggingSystemMock.create(), configService = configServiceMock.create(), + dynamicConfigService = dynamicConfigServiceMock.create(), }: { env?: Env; logger?: jest.Mocked; configService?: jest.Mocked; + dynamicConfigService?: jest.Mocked; } = {}): DeeplyMockedKeys { - return { coreId: Symbol(), env, logger, configService }; + return { coreId: Symbol(), env, logger, configService, dynamicConfigService }; } export const mockCoreContext = { diff --git a/src/core/server/core_context.ts b/src/core/server/core_context.ts index 6c6474f51ea2..b3219a164899 100644 --- a/src/core/server/core_context.ts +++ b/src/core/server/core_context.ts @@ -30,6 +30,7 @@ import { IConfigService, Env } from './config'; import { LoggerFactory } from './logging'; +import { IDynamicConfigService } from './config/dynamic_config_service'; /** @internal */ export type CoreId = symbol; @@ -44,4 +45,5 @@ export interface CoreContext { env: Env; configService: IConfigService; logger: LoggerFactory; + dynamicConfigService: IDynamicConfigService; } diff --git a/src/core/server/core_route_handler_context.ts b/src/core/server/core_route_handler_context.ts index 92be535f61ab..547f3231c6c0 100644 --- a/src/core/server/core_route_handler_context.ts +++ b/src/core/server/core_route_handler_context.ts @@ -40,6 +40,11 @@ import { } from './opensearch'; import { Auditor } from './audit_trail'; import { InternalUiSettingsServiceStart, IUiSettingsClient } from './ui_settings'; +import { + AsyncLocalStorageContext, + IDynamicConfigurationClient, + InternalDynamicConfigServiceStart, +} from './config'; class CoreOpenSearchRouteHandlerContext { #client?: IScopedClusterClient; @@ -109,12 +114,30 @@ class CoreUiSettingsRouteHandlerContext { } } +class CoreDynamicConfigRouteHandlerContext { + #client?: IDynamicConfigurationClient; + #asyncLocalStore?: AsyncLocalStorageContext; + + constructor(private readonly dynamicConfigServiceStart: InternalDynamicConfigServiceStart) {} + + public get client() { + this.#client = this.dynamicConfigServiceStart.getClient(); + return this.#client; + } + + public get asyncLocalStore() { + this.#asyncLocalStore = this.dynamicConfigServiceStart.getAsyncLocalStore(); + return this.#asyncLocalStore; + } +} + export class CoreRouteHandlerContext { #auditor?: Auditor; readonly opensearch: CoreOpenSearchRouteHandlerContext; readonly savedObjects: CoreSavedObjectsRouteHandlerContext; readonly uiSettings: CoreUiSettingsRouteHandlerContext; + readonly dynamicConfig: CoreDynamicConfigRouteHandlerContext; constructor( private readonly coreStart: InternalCoreStart, @@ -132,6 +155,7 @@ export class CoreRouteHandlerContext { this.coreStart.uiSettings, this.savedObjects ); + this.dynamicConfig = new CoreDynamicConfigRouteHandlerContext(this.coreStart.dynamicConfig); } public get auditor() { diff --git a/src/core/server/http/cookie_session_storage.test.ts b/src/core/server/http/cookie_session_storage.test.ts index ce0161f04e22..96a41ef82b8d 100644 --- a/src/core/server/http/cookie_session_storage.test.ts +++ b/src/core/server/http/cookie_session_storage.test.ts @@ -45,6 +45,7 @@ import { getEnvOptions, configServiceMock } from '../config/mocks'; import { httpServerMock } from './http_server.mocks'; import { createCookieSessionStorageFactory } from './cookie_session_storage'; +import { dynamicConfigServiceMock } from '../config/dynamic_config_service.mock'; let server: HttpService; @@ -53,6 +54,13 @@ let env: Env; let coreContext: CoreContext; const configService = configServiceMock.create(); const contextSetup = contextServiceMock.createSetupContract(); +const dynamicConfigServiceStart = dynamicConfigServiceMock.createInternalStartContract({ + getConfig: { + rules: [], + }, + bulkGetConfigs: new Map(), + listConfigs: new Map(), +}); const setupDeps = { context: contextSetup, @@ -85,8 +93,15 @@ configService.atPath.mockReturnValue( beforeEach(() => { logger = loggingSystemMock.create(); env = Env.createDefault(REPO_ROOT, getEnvOptions()); - - coreContext = { coreId: Symbol(), env, logger, configService: configService as any }; + const dynamicConfigService = dynamicConfigServiceMock.create(); + + coreContext = { + coreId: Symbol(), + env, + logger, + configService: configService as any, + dynamicConfigService, + }; server = new HttpService(coreContext); }); @@ -149,7 +164,7 @@ describe('Cookie based SessionStorage', () => { innerServer, cookieOptions ); - await server.start(); + await server.start({ dynamicConfigService: dynamicConfigServiceStart }); const response = await supertest(innerServer.listener).get('/').expect(200); @@ -186,7 +201,7 @@ describe('Cookie based SessionStorage', () => { innerServer, cookieOptions ); - await server.start(); + await server.start({ dynamicConfigService: dynamicConfigServiceStart }); const response = await supertest(innerServer.listener).get('/').expect(200); @@ -217,7 +232,7 @@ describe('Cookie based SessionStorage', () => { innerServer, cookieOptions ); - await server.start(); + await server.start({ dynamicConfigService: dynamicConfigServiceStart }); const response = await supertest(innerServer.listener).get('/').expect(200, { value: null }); @@ -247,7 +262,7 @@ describe('Cookie based SessionStorage', () => { innerServer, cookieOptions ); - await server.start(); + await server.start({ dynamicConfigService: dynamicConfigServiceStart }); const response = await supertest(innerServer.listener) .get('/') @@ -292,7 +307,7 @@ describe('Cookie based SessionStorage', () => { innerServer, cookieOptions ); - await server.start(); + await server.start({ dynamicConfigService: dynamicConfigServiceStart }); const response = await supertest(innerServer.listener) .get('/') @@ -422,7 +437,7 @@ describe('Cookie based SessionStorage', () => { innerServer, cookieOptions ); - await server.start(); + await server.start({ dynamicConfigService: dynamicConfigServiceStart }); const response = await supertest(innerServer.listener).get('/').expect(200); @@ -477,7 +492,7 @@ describe('Cookie based SessionStorage', () => { name: `sid-${sameSite}`, sameSite, }); - await server.start(); + await server.start({ dynamicConfigService: dynamicConfigServiceStart }); const response = await supertest(innerServer.listener).get('/').expect(200); diff --git a/src/core/server/http/http_server.test.ts b/src/core/server/http/http_server.test.ts index 4db4c4fac17f..8d3a798f0f08 100644 --- a/src/core/server/http/http_server.test.ts +++ b/src/core/server/http/http_server.test.ts @@ -48,6 +48,7 @@ import { HttpServer } from './http_server'; import { Readable } from 'stream'; import { RequestHandlerContext } from 'opensearch-dashboards/server'; import { OSD_CERT_PATH, OSD_KEY_PATH } from '@osd/dev-utils'; +import { dynamicConfigServiceMock } from '../config/dynamic_config_service.mock'; const cookieOptions = { name: 'sid', @@ -63,6 +64,7 @@ let configWithSSL: HttpConfig; const loggingService = loggingSystemMock.create(); const logger = loggingService.get(); const enhanceWithContext = (fn: (...args: any[]) => any) => fn.bind(null, {}); +const dynamicConfigService = dynamicConfigServiceMock.createInternalStartContract(); let certificate: string; let key: string; @@ -110,7 +112,7 @@ test('log listening address after started', async () => { expect(server.isListening()).toBe(false); await server.setup(config); - await server.start(); + await server.start(dynamicConfigService); expect(server.isListening()).toBe(true); expect(loggingSystemMock.collect(loggingService).info).toMatchInlineSnapshot(` @@ -125,8 +127,12 @@ test('log listening address after started', async () => { test('log listening address after started when configured with BasePath and rewriteBasePath = false', async () => { expect(server.isListening()).toBe(false); - await server.setup({ ...config, basePath: '/bar', rewriteBasePath: false }); - await server.start(); + await server.setup({ + ...config, + basePath: '/bar', + rewriteBasePath: false, + }); + await server.start(dynamicConfigService); expect(server.isListening()).toBe(true); expect(loggingSystemMock.collect(loggingService).info).toMatchInlineSnapshot(` @@ -141,8 +147,12 @@ test('log listening address after started when configured with BasePath and rewr test('log listening address after started when configured with BasePath and rewriteBasePath = true', async () => { expect(server.isListening()).toBe(false); - await server.setup({ ...config, basePath: '/bar', rewriteBasePath: true }); - await server.start(); + await server.setup({ + ...config, + basePath: '/bar', + rewriteBasePath: true, + }); + await server.start(dynamicConfigService); expect(server.isListening()).toBe(true); expect(loggingSystemMock.collect(loggingService).info).toMatchInlineSnapshot(` @@ -174,7 +184,7 @@ test('valid params', async () => { const { registerRouter, server: innerServer } = await server.setup(config); registerRouter(router); - await server.start(); + await server.start(dynamicConfigService); await supertest(innerServer.listener) .get('/foo/some-string') @@ -204,7 +214,7 @@ test('invalid params', async () => { const { registerRouter, server: innerServer } = await server.setup(config); registerRouter(router); - await server.start(); + await server.start(dynamicConfigService); await supertest(innerServer.listener) .get('/foo/some-string') @@ -239,7 +249,7 @@ test('valid query', async () => { const { registerRouter, server: innerServer } = await server.setup(config); registerRouter(router); - await server.start(); + await server.start(dynamicConfigService); await supertest(innerServer.listener) .get('/foo/?bar=test&quux=123') @@ -269,7 +279,7 @@ test('invalid query', async () => { const { registerRouter, server: innerServer } = await server.setup(config); registerRouter(router); - await server.start(); + await server.start(dynamicConfigService); await supertest(innerServer.listener) .get('/foo/?bar=test') @@ -304,7 +314,7 @@ test('valid body', async () => { const { registerRouter, server: innerServer } = await server.setup(config); registerRouter(router); - await server.start(); + await server.start(dynamicConfigService); await supertest(innerServer.listener) .post('/foo/') @@ -342,7 +352,7 @@ test('valid body with validate function', async () => { const { registerRouter, server: innerServer } = await server.setup(config); registerRouter(router); - await server.start(); + await server.start(dynamicConfigService); await supertest(innerServer.listener) .post('/foo/') @@ -385,7 +395,7 @@ test('not inline validation - specifying params', async () => { const { registerRouter, server: innerServer } = await server.setup(config); registerRouter(router); - await server.start(); + await server.start(dynamicConfigService); await supertest(innerServer.listener) .post('/foo/') @@ -428,7 +438,7 @@ test('not inline validation - specifying validation handler', async () => { const { registerRouter, server: innerServer } = await server.setup(config); registerRouter(router); - await server.start(); + await server.start(dynamicConfigService); await supertest(innerServer.listener) .post('/foo/') @@ -478,7 +488,7 @@ test('not inline handler - OpenSearchDashboardsRequest', async () => { const { registerRouter, server: innerServer } = await server.setup(config); registerRouter(router); - await server.start(); + await server.start(dynamicConfigService); await supertest(innerServer.listener) .post('/foo/') @@ -527,7 +537,7 @@ test('not inline handler - RequestHandler', async () => { const { registerRouter, server: innerServer } = await server.setup(config); registerRouter(router); - await server.start(); + await server.start(dynamicConfigService); await supertest(innerServer.listener) .post('/foo/') @@ -561,7 +571,7 @@ test('invalid body', async () => { const { registerRouter, server: innerServer } = await server.setup(config); registerRouter(router); - await server.start(); + await server.start(dynamicConfigService); await supertest(innerServer.listener) .post('/foo/') @@ -596,7 +606,7 @@ test('handles putting', async () => { const { registerRouter, server: innerServer } = await server.setup(config); registerRouter(router); - await server.start(); + await server.start(dynamicConfigService); await supertest(innerServer.listener) .put('/foo/') @@ -627,7 +637,7 @@ test('handles deleting', async () => { const { registerRouter, server: innerServer } = await server.setup(config); registerRouter(router); - await server.start(); + await server.start(dynamicConfigService); await supertest(innerServer.listener) .delete('/foo/3') @@ -657,7 +667,7 @@ describe('with `basepath: /bar` and `rewriteBasePath: false`', () => { const { registerRouter, server: innerServer } = await server.setup(configWithBasePath); registerRouter(router); - await server.start(); + await server.start(dynamicConfigService); innerServerListener = innerServer.listener; }); @@ -712,7 +722,7 @@ describe('with `basepath: /bar` and `rewriteBasePath: true`', () => { const { registerRouter, server: innerServer } = await server.setup(configWithBasePath); registerRouter(router); - await server.start(); + await server.start(dynamicConfigService); innerServerListener = innerServer.listener; }); @@ -759,7 +769,7 @@ test('with defined `redirectHttpFromPort`', async () => { const { registerRouter } = await server.setup(configWithSSL); registerRouter(router); - await server.start(); + await server.start(dynamicConfigService); }); test('returns server and connection options on start', async () => { @@ -774,7 +784,7 @@ test('returns server and connection options on start', async () => { }); test('throws an error if starts without set up', async () => { - await expect(server.start()).rejects.toThrowErrorMatchingInlineSnapshot( + await expect(server.start(dynamicConfigService)).rejects.toThrowErrorMatchingInlineSnapshot( `"Http server is not setup up yet"` ); }); @@ -792,7 +802,7 @@ test('allows attaching metadata to attach meta-data tag strings to a route', asy ); registerRouter(router); - await server.start(); + await server.start(dynamicConfigService); await supertest(innerServer.listener).get('/with-tags').expect(200, { tags }); await supertest(innerServer.listener).get('/without-tags').expect(200, { tags: [] }); @@ -805,7 +815,7 @@ test('exposes route details of incoming request to a route handler', async () => router.get({ path: '/', validate: false }, (context, req, res) => res.ok({ body: req.route })); registerRouter(router); - await server.start(); + await server.start(dynamicConfigService); await supertest(innerServer.listener) .get('/') .expect(200, { @@ -831,7 +841,7 @@ describe('conditional compression', () => { }; router.get({ path: '/', validate: false }, (_context, _req, res) => res.ok(largeRequest)); registerRouter(router); - await server.start(); + await server.start(dynamicConfigService); return innerServer.listener; } @@ -910,7 +920,7 @@ describe('conditional compression', () => { ); registerRouter(router); - await server.start(); + await server.start(dynamicConfigService); const response = await supertest(innerServer.listener) .get('/') .set('Connection', 'keep-alive') @@ -929,7 +939,7 @@ describe('conditional compression', () => { ); registerRouter(router); - await server.start(); + await server.start(dynamicConfigService); const response = await supertest(innerServer.listener).get('/').expect(200); const restHeaders = omit(response.header, ['date', 'content-length']); @@ -959,7 +969,7 @@ test('exposes route details of incoming request to a route handler (POST + paylo ); registerRouter(router); - await server.start(); + await server.start(dynamicConfigService); await supertest(innerServer.listener) .post('/') .send({ test: 1 }) @@ -998,7 +1008,7 @@ describe('body options', () => { ); registerRouter(router); - await server.start(); + await server.start(dynamicConfigService); await supertest(innerServer.listener).post('/').send({ test: 1 }).expect(415, { statusCode: 415, error: 'Unsupported Media Type', @@ -1020,7 +1030,7 @@ describe('body options', () => { ); registerRouter(router); - await server.start(); + await server.start(dynamicConfigService); await supertest(innerServer.listener).post('/').send({ test: 1 }).expect(413, { statusCode: 413, error: 'Request Entity Too Large', @@ -1050,7 +1060,7 @@ describe('body options', () => { ); registerRouter(router); - await server.start(); + await server.start(dynamicConfigService); await supertest(innerServer.listener).post('/').send({ test: 1 }).expect(200, { parse: false, maxBytes: 1024, // hapi populates the default @@ -1088,7 +1098,7 @@ describe('timeout options', () => { } ); registerRouter(router); - await server.start(); + await server.start(dynamicConfigService); await supertest(innerServer.listener) .post('/') .send({ test: 1 }) @@ -1126,7 +1136,7 @@ describe('timeout options', () => { } ); registerRouter(router); - await server.start(); + await server.start(dynamicConfigService); await supertest(innerServer.listener) .delete('/') .expect(200, { @@ -1163,7 +1173,7 @@ describe('timeout options', () => { } ); registerRouter(router); - await server.start(); + await server.start(dynamicConfigService); await supertest(innerServer.listener) .put('/') .expect(200, { @@ -1200,7 +1210,7 @@ describe('timeout options', () => { } ); registerRouter(router); - await server.start(); + await server.start(dynamicConfigService); await supertest(innerServer.listener) .patch('/') .expect(200, { @@ -1234,7 +1244,7 @@ describe('timeout options', () => { ); registerRouter(router); - await server.start(); + await server.start(dynamicConfigService); await supertest(innerServer.listener) .get('/') .send() @@ -1268,7 +1278,7 @@ describe('timeout options', () => { ); registerRouter(router); - await server.start(); + await server.start(dynamicConfigService); await supertest(innerServer.listener) .get('/') .send() @@ -1302,7 +1312,7 @@ describe('timeout options', () => { registerRouter(router); - await server.start(); + await server.start(dynamicConfigService); }); }); @@ -1327,7 +1337,7 @@ test('should return a stream in the body', async () => { ); registerRouter(router); - await server.start(); + await server.start(dynamicConfigService); await supertest(innerServer.listener).put('/').send({ test: 1 }).expect(200, { parse: true, maxBytes: 1024, // hapi populates the default diff --git a/src/core/server/http/http_server.ts b/src/core/server/http/http_server.ts index dc3727662c77..e519b0fae7f6 100644 --- a/src/core/server/http/http_server.ts +++ b/src/core/server/http/http_server.ts @@ -56,6 +56,7 @@ import { IsAuthenticated, AuthStateStorage, GetAuthState } from './auth_state_st import { AuthHeadersStorage, GetAuthHeaders } from './auth_headers_storage'; import { BasePath } from './base_path_service'; import { HttpServiceSetup, HttpServerInfo } from './types'; +import { InternalDynamicConfigServiceStart } from '../config'; /** @internal */ export interface HttpServerSetup { @@ -166,7 +167,7 @@ export class HttpServer { }; } - public async start() { + public async start(dynamicConfigService: InternalDynamicConfigServiceStart) { if (this.server === undefined) { throw new Error('Http server is not setup up yet'); } diff --git a/src/core/server/http/http_service.mock.ts b/src/core/server/http/http_service.mock.ts index 5a35be5d3c33..500466f30b21 100644 --- a/src/core/server/http/http_service.mock.ts +++ b/src/core/server/http/http_service.mock.ts @@ -99,6 +99,7 @@ const createInternalSetupContractMock = () => { start: jest.fn(), stop: jest.fn(), config: jest.fn().mockReturnValue(configMock.create()), + ext: jest.fn(), // @ts-expect-error it thinks that `Server` isn't a `Construtable` } as unknown) as jest.MockedClass, createCookieSessionStorageFactory: jest.fn(), diff --git a/src/core/server/http/http_service.test.ts b/src/core/server/http/http_service.test.ts index 4402bc1fb573..7b26a46bfe61 100644 --- a/src/core/server/http/http_service.test.ts +++ b/src/core/server/http/http_service.test.ts @@ -41,10 +41,13 @@ import { ConfigService, Env } from '../config'; import { loggingSystemMock } from '../logging/logging_system.mock'; import { contextServiceMock } from '../context/context_service.mock'; import { config as cspConfig } from '../csp'; +import { dynamicConfigServiceMock } from '../config/dynamic_config_service.mock'; const logger = loggingSystemMock.create(); const env = Env.createDefault(REPO_ROOT, getEnvOptions()); const coreId = Symbol(); +const dynamicConfigService = dynamicConfigServiceMock.create(); +const dynamicConfigServiceStart = dynamicConfigServiceMock.createInternalStartContract(); const createConfigService = (value: Partial = {}) => { const configService = new ConfigService( @@ -90,7 +93,7 @@ test('creates and sets up http server', async () => { }; mockHttpServer.mockImplementation(() => httpServer); - const service = new HttpService({ coreId, configService, env, logger }); + const service = new HttpService({ coreId, configService, env, logger, dynamicConfigService }); expect(mockHttpServer.mock.instances.length).toBe(1); @@ -100,7 +103,7 @@ test('creates and sets up http server', async () => { expect(httpServer.setup).toHaveBeenCalled(); expect(httpServer.start).not.toHaveBeenCalled(); - await service.start(); + await service.start({ dynamicConfigService: dynamicConfigServiceStart }); expect(httpServer.start).toHaveBeenCalled(); }); @@ -129,6 +132,7 @@ test('spins up notReady server until started if configured with `autoListen:true configService, env: Env.createDefault(REPO_ROOT, getEnvOptions()), logger, + dynamicConfigService, }); await service.setup(setupDeps); @@ -150,7 +154,7 @@ test('spins up notReady server until started if configured with `autoListen:true header: mockResponse.header.mock.calls, }).toMatchSnapshot('503 response'); - await service.start(); + await service.start({ dynamicConfigService: dynamicConfigServiceStart }); expect(httpServer.start).toBeCalledTimes(1); expect(notReadyHapiServer.stop).toBeCalledTimes(1); @@ -167,7 +171,7 @@ test('logs error if already set up', async () => { }; mockHttpServer.mockImplementation(() => httpServer); - const service = new HttpService({ coreId, configService, env, logger }); + const service = new HttpService({ coreId, configService, env, logger, dynamicConfigService }); await service.setup(setupDeps); @@ -185,10 +189,10 @@ test('stops http server', async () => { }; mockHttpServer.mockImplementation(() => httpServer); - const service = new HttpService({ coreId, configService, env, logger }); + const service = new HttpService({ coreId, configService, env, logger, dynamicConfigService }); await service.setup(setupDeps); - await service.start(); + await service.start({ dynamicConfigService: dynamicConfigServiceStart }); expect(httpServer.stop).toHaveBeenCalledTimes(0); @@ -212,7 +216,7 @@ test('stops not ready server if it is running', async () => { }; mockHttpServer.mockImplementation(() => httpServer); - const service = new HttpService({ coreId, configService, env, logger }); + const service = new HttpService({ coreId, configService, env, logger, dynamicConfigService }); await service.setup(setupDeps); @@ -235,7 +239,7 @@ test('register route handler', async () => { }; mockHttpServer.mockImplementation(() => httpServer); - const service = new HttpService({ coreId, configService, env, logger }); + const service = new HttpService({ coreId, configService, env, logger, dynamicConfigService }); const { createRouter } = await service.setup(setupDeps); const router = createRouter('/foo'); @@ -254,7 +258,7 @@ test('returns http server contract on setup', async () => { stop: noop, })); - const service = new HttpService({ coreId, configService, env, logger }); + const service = new HttpService({ coreId, configService, env, logger, dynamicConfigService }); const setupContract = await service.setup(setupDeps); expect(setupContract).toMatchObject(httpServer); expect(setupContract).toMatchObject({ @@ -283,10 +287,11 @@ test('does not start http server if process is dev cluster master (deprecated) o }) ), logger, + dynamicConfigService, }); await service.setup(setupDeps); - await service.start(); + await service.start({ dynamicConfigService: dynamicConfigServiceStart }); expect(httpServer.start).not.toHaveBeenCalled(); }); @@ -312,10 +317,11 @@ test('does not start http server if process is dev cluster manager', async () => }) ), logger, + dynamicConfigService, }); await service.setup(setupDeps); - await service.start(); + await service.start({ dynamicConfigService: dynamicConfigServiceStart }); expect(httpServer.start).not.toHaveBeenCalled(); }); @@ -341,10 +347,11 @@ test('does not start http server if process is dev cluster master (deprecated)', }) ), logger, + dynamicConfigService, }); await service.setup(setupDeps); - await service.start(); + await service.start({ dynamicConfigService: dynamicConfigServiceStart }); expect(httpServer.start).not.toHaveBeenCalled(); }); @@ -366,10 +373,11 @@ test('does not start http server if configured with `autoListen:false`', async ( configService, env: Env.createDefault(REPO_ROOT, getEnvOptions()), logger, + dynamicConfigService, }); await service.setup(setupDeps); - await service.start(); + await service.start({ dynamicConfigService: dynamicConfigServiceStart }); expect(httpServer.start).not.toHaveBeenCalled(); }); diff --git a/src/core/server/http/http_service.ts b/src/core/server/http/http_service.ts index 8627557c7332..e58a5081234e 100644 --- a/src/core/server/http/http_service.ts +++ b/src/core/server/http/http_service.ts @@ -36,7 +36,7 @@ import { pick } from '@osd/std'; import { CoreService } from '../../types'; import { Logger, LoggerFactory } from '../logging'; import { ContextSetup } from '../context'; -import { Env } from '../config'; +import { Env, InternalDynamicConfigServiceStart } from '../config'; import { CoreContext } from '../core_context'; import { PluginOpaqueId } from '../plugins'; import { CspConfigType, config as cspConfig } from '../csp'; @@ -60,6 +60,10 @@ interface SetupDeps { context: ContextSetup; } +export interface StartDeps { + dynamicConfigService: InternalDynamicConfigServiceStart; +} + /** @internal */ export class HttpService implements CoreService { @@ -140,7 +144,7 @@ export class HttpService }; } - public async start() { + public async start(deps: StartDeps) { const config = await this.config$.pipe(first()).toPromise(); if (this.shouldListen(config)) { if (this.notReadyServer) { @@ -154,7 +158,7 @@ export class HttpService await this.httpsRedirectServer.start(config); } - await this.httpServer.start(); + await this.httpServer.start(deps.dynamicConfigService); } return this.getStartContract(); diff --git a/src/core/server/http/http_tools.test.ts b/src/core/server/http/http_tools.test.ts index 82339127ad39..7ce4112fde2c 100644 --- a/src/core/server/http/http_tools.test.ts +++ b/src/core/server/http/http_tools.test.ts @@ -28,6 +28,8 @@ * under the License. */ +import { dynamicConfigServiceMock } from '../config/dynamic_config_service.mock'; + jest.mock('fs', () => { const original = jest.requireActual('fs'); return { @@ -121,10 +123,12 @@ describe('timeouts', () => { allowFromAnyIp: true, ipAllowlist: [], }, + updateConfigs: jest.fn(), } as any); registerRouter(router); - await server.start(); + const dynamicConfigService = dynamicConfigServiceMock.createInternalStartContract(); + await server.start(dynamicConfigService); expect(supertest(innerServer.listener).get('/a')).rejects.toThrow('socket hang up'); diff --git a/src/core/server/http/integration_tests/lifecycle.test.ts b/src/core/server/http/integration_tests/lifecycle.test.ts index 17fc7f74062a..9cbd6cca2155 100644 --- a/src/core/server/http/integration_tests/lifecycle.test.ts +++ b/src/core/server/http/integration_tests/lifecycle.test.ts @@ -38,6 +38,7 @@ import { HttpService } from '../http_service'; import { contextServiceMock } from '../../context/context_service.mock'; import { loggingSystemMock } from '../../logging/logging_system.mock'; import { createHttpServer } from '../test_utils'; +import { dynamicConfigServiceMock } from '../../config/dynamic_config_service.mock'; let server: HttpService; @@ -49,6 +50,8 @@ const setupDeps = { context: contextSetup, }; +const dynamicConfigService = dynamicConfigServiceMock.createInternalStartContract(); + beforeEach(() => { logger = loggingSystemMock.create(); server = createHttpServer({ logger }); @@ -87,7 +90,7 @@ describe('OnPreRouting', () => { callingOrder.push('second'); return t.next(); }); - await server.start(); + await server.start({ dynamicConfigService }); await supertest(innerServer.listener).get('/').expect(200, 'ok'); @@ -120,7 +123,7 @@ describe('OnPreRouting', () => { return t.next(); }); - await server.start(); + await server.start({ dynamicConfigService }); await supertest(innerServer.listener).get('/initial').expect(200, 'redirected'); @@ -144,7 +147,7 @@ describe('OnPreRouting', () => { }, }) ); - await server.start(); + await server.start({ dynamicConfigService }); const result = await supertest(innerServer.listener).get('/initial').expect(302); @@ -166,7 +169,7 @@ describe('OnPreRouting', () => { }, }) ); - await server.start(); + await server.start({ dynamicConfigService }); const result = await supertest(innerServer.listener).get('/').expect(401); @@ -184,7 +187,7 @@ describe('OnPreRouting', () => { registerOnPreRouting((req, res, t) => { throw new Error('reason'); }); - await server.start(); + await server.start({ dynamicConfigService }); const result = await supertest(innerServer.listener).get('/').expect(500); @@ -207,7 +210,7 @@ describe('OnPreRouting', () => { router.get({ path: '/', validate: false }, (context, req, res) => res.ok()); registerOnPreRouting((req, res, t) => ({} as any)); - await server.start(); + await server.start({ dynamicConfigService }); const result = await supertest(innerServer.listener).get('/').expect(500); @@ -244,7 +247,7 @@ describe('OnPreRouting', () => { res.ok({ body: { customField: String((req as any).customField) } }) ); - await server.start(); + await server.start({ dynamicConfigService }); await supertest(innerServer.listener).get('/').expect(200, { customField: 'undefined' }); }); @@ -267,7 +270,7 @@ describe('OnPreAuth', () => { callingOrder.push('second'); return t.next(); }); - await server.start(); + await server.start({ dynamicConfigService }); await supertest(innerServer.listener).get('/').expect(200, 'ok'); @@ -288,7 +291,7 @@ describe('OnPreAuth', () => { }, }) ); - await server.start(); + await server.start({ dynamicConfigService }); const result = await supertest(innerServer.listener).get('/initial').expect(302); @@ -308,7 +311,7 @@ describe('OnPreAuth', () => { }, }) ); - await server.start(); + await server.start({ dynamicConfigService }); const result = await supertest(innerServer.listener).get('/').expect(401); @@ -324,7 +327,7 @@ describe('OnPreAuth', () => { registerOnPreAuth((req, res, t) => { throw new Error('reason'); }); - await server.start(); + await server.start({ dynamicConfigService }); const result = await supertest(innerServer.listener).get('/').expect(500); @@ -345,7 +348,7 @@ describe('OnPreAuth', () => { router.get({ path: '/', validate: false }, (context, req, res) => res.ok()); registerOnPreAuth((req, res, t) => ({} as any)); - await server.start(); + await server.start({ dynamicConfigService }); const result = await supertest(innerServer.listener).get('/').expect(500); @@ -380,7 +383,7 @@ describe('OnPreAuth', () => { res.ok({ body: { customField: String(req.customField) } }) ); - await server.start(); + await server.start({ dynamicConfigService }); await supertest(innerServer.listener).get('/').expect(200, { customField: 'undefined' }); }); @@ -406,7 +409,7 @@ describe('OnPreAuth', () => { (context, req, res) => res.ok({ body: req.body.term }) ); - await server.start(); + await server.start({ dynamicConfigService }); await supertest(innerServer.listener) .post('/') @@ -436,7 +439,7 @@ describe('OnPostAuth', () => { callingOrder.push('second'); return t.next(); }); - await server.start(); + await server.start({ dynamicConfigService }); await supertest(innerServer.listener).get('/').expect(200, 'ok'); @@ -457,7 +460,7 @@ describe('OnPostAuth', () => { }, }) ); - await server.start(); + await server.start({ dynamicConfigService }); const result = await supertest(innerServer.listener).get('/initial').expect(302); @@ -476,7 +479,7 @@ describe('OnPostAuth', () => { }, }) ); - await server.start(); + await server.start({ dynamicConfigService }); const result = await supertest(innerServer.listener).get('/').expect(401); @@ -491,7 +494,7 @@ describe('OnPostAuth', () => { registerOnPostAuth((req, res, t) => { throw new Error('reason'); }); - await server.start(); + await server.start({ dynamicConfigService }); const result = await supertest(innerServer.listener).get('/').expect(500); @@ -511,7 +514,7 @@ describe('OnPostAuth', () => { router.get({ path: '/', validate: false }, (context, req, res) => res.ok()); registerOnPostAuth((req, res, t) => ({} as any)); - await server.start(); + await server.start({ dynamicConfigService }); const result = await supertest(innerServer.listener).get('/').expect(500); @@ -547,7 +550,7 @@ describe('OnPostAuth', () => { res.ok({ body: { customField: String((req as any).customField) } }) ); - await server.start(); + await server.start({ dynamicConfigService }); await supertest(innerServer.listener).get('/').expect(200, { customField: 'undefined' }); }); @@ -573,7 +576,7 @@ describe('OnPostAuth', () => { (context, req, res) => res.ok({ body: req.body.term }) ); - await server.start(); + await server.start({ dynamicConfigService }); await supertest(innerServer.listener) .post('/') @@ -610,7 +613,7 @@ describe('Auth', () => { res.ok({ body: { content: 'ok' } }) ); registerAuth((req, res, t) => t.authenticated()); - await server.start(); + await server.start({ dynamicConfigService }); await supertest(innerServer.listener).get('/').expect(200, { content: 'ok' }); }); @@ -623,7 +626,7 @@ describe('Auth', () => { res.ok({ body: { content: 'ok' } }) ); registerAuth((req, res, t) => t.notHandled()); - await server.start(); + await server.start({ dynamicConfigService }); const result = await supertest(innerServer.listener).get('/').expect(401); @@ -640,7 +643,7 @@ describe('Auth', () => { const authenticate = jest.fn().mockImplementation((req, res, t) => t.authenticated()); registerAuth(authenticate); - await server.start(); + await server.start({ dynamicConfigService }); await supertest(innerServer.listener).get('/').expect(200, { authRequired: true }); expect(authenticate).toHaveBeenCalledTimes(1); @@ -658,7 +661,7 @@ describe('Auth', () => { const authenticate = jest.fn(); registerAuth(authenticate); - await server.start(); + await server.start({ dynamicConfigService }); await supertest(innerServer.listener).get('/').expect(200, { authRequired: false }); expect(authenticate).toHaveBeenCalledTimes(0); @@ -676,7 +679,7 @@ describe('Auth', () => { const authenticate = jest.fn().mockImplementation((req, res, t) => t.authenticated({})); await registerAuth(authenticate); - await server.start(); + await server.start({ dynamicConfigService }); await supertest(innerServer.listener).get('/').expect(200, { authRequired: true }); expect(authenticate).toHaveBeenCalledTimes(1); @@ -688,7 +691,7 @@ describe('Auth', () => { router.get({ path: '/', validate: false }, (context, req, res) => res.ok()); registerAuth((req, res) => res.unauthorized()); - await server.start(); + await server.start({ dynamicConfigService }); await supertest(innerServer.listener).get('/').expect(401); }); @@ -704,7 +707,7 @@ describe('Auth', () => { location: redirectTo, }) ); - await server.start(); + await server.start({ dynamicConfigService }); const response = await supertest(innerServer.listener).get('/').expect(302); expect(response.header.location).toBe(redirectTo); @@ -716,7 +719,7 @@ describe('Auth', () => { router.get({ path: '/', validate: false }, (context, req, res) => res.ok()); registerAuth((req, res, t) => t.redirected({} as any)); - await server.start(); + await server.start({ dynamicConfigService }); await supertest(innerServer.listener).get('/').expect(500); }); @@ -729,7 +732,7 @@ describe('Auth', () => { registerAuth((req, t) => { throw new Error('reason'); }); - await server.start(); + await server.start({ dynamicConfigService }); const result = await supertest(innerServer.listener).get('/').expect(500); @@ -764,7 +767,7 @@ describe('Auth', () => { return toolkit.authenticated({ state: user }); }); - await server.start(); + await server.start({ dynamicConfigService }); const response = await supertest(innerServer.listener).get('/').expect(200); @@ -808,7 +811,7 @@ describe('Auth', () => { sessionStorage.clear(); return res.ok(); }); - await server.start(); + await server.start({ dynamicConfigService }); const responseToSetCookie = await supertest(innerServer.listener).get('/').expect(200); @@ -857,7 +860,7 @@ describe('Auth', () => { fromRouteHandler = req.headers.authorization; return res.ok(); }); - await server.start(); + await server.start({ dynamicConfigService }); const token = 'Basic: user:password'; await supertest(innerServer.listener).get('/').set('Authorization', token).expect(200); @@ -880,7 +883,7 @@ describe('Auth', () => { }); router.get({ path: '/', validate: false }, (context, req, res) => res.ok()); - await server.start(); + await server.start({ dynamicConfigService }); const response = await supertest(innerServer.listener).get('/').expect(200); @@ -899,7 +902,7 @@ describe('Auth', () => { }); router.get({ path: '/', validate: false }, (context, req, res) => res.badRequest()); - await server.start(); + await server.start({ dynamicConfigService }); const response = await supertest(innerServer.listener).get('/').expect(400); @@ -926,7 +929,7 @@ describe('Auth', () => { }, }) ); - await server.start(); + await server.start({ dynamicConfigService }); const response = await supertest(innerServer.listener).get('/').expect(200); @@ -959,7 +962,7 @@ describe('Auth', () => { }, }) ); - await server.start(); + await server.start({ dynamicConfigService }); const response = await supertest(innerServer.listener).get('/').expect(400); @@ -986,7 +989,7 @@ describe('Auth', () => { }, }) ); - await server.start(); + await server.start({ dynamicConfigService }); const result = await supertest(innerServer.listener).get('/initial').expect(302); @@ -1006,7 +1009,7 @@ describe('Auth', () => { }, }) ); - await server.start(); + await server.start({ dynamicConfigService }); const result = await supertest(innerServer.listener).get('/').expect(401); @@ -1021,7 +1024,7 @@ describe('Auth', () => { registerOnPostAuth((req, res, t) => { throw new Error('reason'); }); - await server.start(); + await server.start({ dynamicConfigService }); const result = await supertest(innerServer.listener).get('/').expect(500); @@ -1041,7 +1044,7 @@ describe('Auth', () => { router.get({ path: '/', validate: false }, (context, req, res) => res.ok()); registerOnPostAuth((req, res, t) => ({} as any)); - await server.start(); + await server.start({ dynamicConfigService }); const result = await supertest(innerServer.listener).get('/').expect(500); @@ -1076,7 +1079,7 @@ describe('Auth', () => { res.ok({ body: { customField: String((req as any).customField) } }) ); - await server.start(); + await server.start({ dynamicConfigService }); await supertest(innerServer.listener).get('/').expect(200, { customField: 'undefined' }); }); @@ -1102,7 +1105,7 @@ describe('Auth', () => { (context, req, res) => res.ok({ body: req.body.term }) ); - await server.start(); + await server.start({ dynamicConfigService }); await supertest(innerServer.listener) .post('/') @@ -1134,7 +1137,7 @@ describe('OnPreResponse', () => { callingOrder.push('second'); return t.next(); }); - await server.start(); + await server.start({ dynamicConfigService }); await supertest(innerServer.listener).get('/').expect(200, 'ok'); @@ -1162,7 +1165,7 @@ describe('OnPreResponse', () => { }, }) ); - await server.start(); + await server.start({ dynamicConfigService }); const result = await supertest(innerServer.listener).get('/').expect(200); @@ -1186,7 +1189,7 @@ describe('OnPreResponse', () => { headers: { 'x-opensearch-dashboards-header': 'value' }, }) ); - await server.start(); + await server.start({ dynamicConfigService }); await supertest(innerServer.listener).get('/').expect(200); @@ -1209,7 +1212,7 @@ describe('OnPreResponse', () => { registerOnPreResponse((req, res, t) => { throw new Error('reason'); }); - await server.start(); + await server.start({ dynamicConfigService }); const result = await supertest(innerServer.listener).get('/').expect(500); @@ -1231,7 +1234,7 @@ describe('OnPreResponse', () => { router.get({ path: '/', validate: false }, (context, req, res) => res.ok()); registerOnPreResponse((req, res, t) => ({} as any)); - await server.start(); + await server.start({ dynamicConfigService }); const result = await supertest(innerServer.listener).get('/').expect(500); @@ -1258,7 +1261,7 @@ describe('OnPreResponse', () => { router.get({ path: '/', validate: false }, (context, req, res) => res.ok({ body: 'ok' })); - await server.start(); + await server.start({ dynamicConfigService }); await supertest(innerServer.listener).get('/').expect(200); }); @@ -1286,7 +1289,7 @@ describe('OnPreResponse', () => { (context, req, res) => res.ok({ body: req.body.term }) ); - await server.start(); + await server.start({ dynamicConfigService }); await supertest(innerServer.listener) .post('/') @@ -1317,7 +1320,7 @@ describe('OnPreResponse', () => { return t.render({ body: 'overridden' }); }); - await server.start(); + await server.start({ dynamicConfigService }); const result = await supertest(innerServer.listener).get('/').expect(200, 'overridden'); @@ -1350,7 +1353,7 @@ describe('OnPreResponse', () => { }); }); - await server.start(); + await server.start({ dynamicConfigService }); const result = await supertest(innerServer.listener).get('/').expect(200, 'overridden'); @@ -1398,7 +1401,7 @@ describe('run interceptors in the right order', () => { router.get({ path: '/', validate: false }, (context, req, res) => res.ok({ body: 'ok' })); - await server.start(); + await server.start({ dynamicConfigService }); await supertest(innerServer.listener).get('/').expect(200); expect(executionOrder).toEqual([ @@ -1442,7 +1445,7 @@ describe('run interceptors in the right order', () => { router.get({ path: '/', validate: false }, (context, req, res) => res.ok({ body: 'ok' })); - await server.start(); + await server.start({ dynamicConfigService }); await supertest(innerServer.listener).get('/').expect(200); expect(executionOrder).toEqual(['onPreRouting', 'onPreAuth', 'onPostAuth', 'onPreResponse']); @@ -1485,7 +1488,7 @@ describe('run interceptors in the right order', () => { router.get({ path: '/', validate: false }, (context, req, res) => res.ok({ body: 'ok' })); - await server.start(); + await server.start({ dynamicConfigService }); await supertest(innerServer.listener).get('/').expect(403); expect(executionOrder).toEqual(['onPreRouting', 'onPreAuth', 'auth', 'onPreResponse']); diff --git a/src/core/server/http/integration_tests/lifecycle_handlers.test.ts b/src/core/server/http/integration_tests/lifecycle_handlers.test.ts index 0742e8c08d6d..e9bb062c8319 100644 --- a/src/core/server/http/integration_tests/lifecycle_handlers.test.ts +++ b/src/core/server/http/integration_tests/lifecycle_handlers.test.ts @@ -39,6 +39,7 @@ import { IRouter, RouteRegistrar } from '../router'; import { configServiceMock } from '../../config/mocks'; import { contextServiceMock } from '../../context/context_service.mock'; +import { dynamicConfigServiceMock } from '../../config/dynamic_config_service.mock'; // eslint-disable-next-line @typescript-eslint/no-var-requires const pkg = require('../../../../../package.json'); @@ -53,6 +54,7 @@ const opensearchDashboardsName = 'my-opensearch-dashboards-name'; const setupDeps = { context: contextServiceMock.createSetupContract(), }; +const dynamicConfigService = dynamicConfigServiceMock.createInternalStartContract(); describe('core lifecycle handlers', () => { let server: HttpService; @@ -99,7 +101,7 @@ describe('core lifecycle handlers', () => { router.get({ path: testRoute, validate: false }, (context, req, res) => { return res.ok({ body: 'ok' }); }); - await server.start(); + await server.start({ dynamicConfigService }); }); it('accepts requests with the correct version passed in the version header', async () => { @@ -132,7 +134,7 @@ describe('core lifecycle handlers', () => { router.get({ path: testErrorRoute, validate: false }, (context, req, res) => { return res.badRequest({ body: 'bad request' }); }); - await server.start(); + await server.start({ dynamicConfigService }); }); it('adds the osd-name header', async () => { @@ -203,7 +205,7 @@ describe('core lifecycle handlers', () => { ); }); - await server.start(); + await server.start({ dynamicConfigService }); }); nonDestructiveMethods.forEach((method) => { diff --git a/src/core/server/http/integration_tests/request.test.ts b/src/core/server/http/integration_tests/request.test.ts index 45eebb540553..23a271644c54 100644 --- a/src/core/server/http/integration_tests/request.test.ts +++ b/src/core/server/http/integration_tests/request.test.ts @@ -28,6 +28,8 @@ * under the License. */ +import { dynamicConfigServiceMock } from '../../config/dynamic_config_service.mock'; + jest.mock('uuid', () => ({ v4: jest.fn().mockReturnValue('xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx'), })); @@ -49,6 +51,7 @@ const contextSetup = contextServiceMock.createSetupContract(); const setupDeps = { context: contextSetup, }; +const dynamicConfigService = dynamicConfigServiceMock.createInternalStartContract(); beforeEach(() => { logger = loggingSystemMock.create(); @@ -71,7 +74,7 @@ describe('OpenSearchDashboardsRequest', () => { { path: '/', validate: false, options: { authRequired: true } }, (context, req, res) => res.ok({ body: { isAuthenticated: req.auth.isAuthenticated } }) ); - await server.start(); + await server.start({ dynamicConfigService }); await supertest(innerServer.listener).get('/').expect(200, { isAuthenticated: false, @@ -85,7 +88,7 @@ describe('OpenSearchDashboardsRequest', () => { { path: '/', validate: false, options: { authRequired: 'optional' } }, (context, req, res) => res.ok({ body: { isAuthenticated: req.auth.isAuthenticated } }) ); - await server.start(); + await server.start({ dynamicConfigService }); await supertest(innerServer.listener).get('/').expect(200, { isAuthenticated: false, @@ -99,7 +102,7 @@ describe('OpenSearchDashboardsRequest', () => { { path: '/', validate: false, options: { authRequired: 'optional' } }, (context, req, res) => res.ok({ body: { isAuthenticated: req.auth.isAuthenticated } }) ); - await server.start(); + await server.start({ dynamicConfigService }); await supertest(innerServer.listener).get('/').expect(200, { isAuthenticated: false, @@ -113,7 +116,7 @@ describe('OpenSearchDashboardsRequest', () => { { path: '/', validate: false, options: { authRequired: 'optional' } }, (context, req, res) => res.ok({ body: { isAuthenticated: req.auth.isAuthenticated } }) ); - await server.start(); + await server.start({ dynamicConfigService }); await supertest(innerServer.listener).get('/').expect(200, { isAuthenticated: true, @@ -127,7 +130,7 @@ describe('OpenSearchDashboardsRequest', () => { { path: '/', validate: false, options: { authRequired: true } }, (context, req, res) => res.ok({ body: { isAuthenticated: req.auth.isAuthenticated } }) ); - await server.start(); + await server.start({ dynamicConfigService }); await supertest(innerServer.listener).get('/').expect(200, { isAuthenticated: true, @@ -146,7 +149,7 @@ describe('OpenSearchDashboardsRequest', () => { { path: '/', validate: false, options: { authRequired: false } }, (context, req, res) => res.ok({ body: { authRequired: req.route.options.authRequired } }) ); - await server.start(); + await server.start({ dynamicConfigService }); await supertest(innerServer.listener).get('/').expect(200, { authRequired: false, @@ -160,7 +163,7 @@ describe('OpenSearchDashboardsRequest', () => { { path: '/', validate: false, options: { authRequired: 'optional' } }, (context, req, res) => res.ok({ body: { authRequired: req.route.options.authRequired } }) ); - await server.start(); + await server.start({ dynamicConfigService }); await supertest(innerServer.listener).get('/').expect(200, { authRequired: 'optional', @@ -174,7 +177,7 @@ describe('OpenSearchDashboardsRequest', () => { { path: '/', validate: false, options: { authRequired: true } }, (context, req, res) => res.ok({ body: { authRequired: req.route.options.authRequired } }) ); - await server.start(); + await server.start({ dynamicConfigService }); await supertest(innerServer.listener).get('/').expect(200, { authRequired: true, @@ -205,7 +208,7 @@ describe('OpenSearchDashboardsRequest', () => { }); }); - await server.start(); + await server.start({ dynamicConfigService }); const incomingRequest = supertest(innerServer.listener) .get('/') @@ -232,7 +235,7 @@ describe('OpenSearchDashboardsRequest', () => { return res.ok({ body: 'ok' }); }); - await server.start(); + await server.start({ dynamicConfigService }); await supertest(innerServer.listener).get('/'); @@ -255,7 +258,7 @@ describe('OpenSearchDashboardsRequest', () => { return res.badRequest(); }); - await server.start(); + await server.start({ dynamicConfigService }); await supertest(innerServer.listener).get('/'); @@ -288,7 +291,7 @@ describe('OpenSearchDashboardsRequest', () => { } ); - await server.start(); + await server.start({ dynamicConfigService }); await supertest(innerServer.listener).post('/').send({ data: 'test' }).expect(200); @@ -316,7 +319,7 @@ describe('OpenSearchDashboardsRequest', () => { return res.ok({ body: 'ok' }); }); - await server.start(); + await server.start({ dynamicConfigService }); await supertest(innerServer.listener).get('/').expect(200); expect(nextSpy).toHaveBeenCalledTimes(1); @@ -343,7 +346,7 @@ describe('OpenSearchDashboardsRequest', () => { }); }); - await server.start(); + await server.start({ dynamicConfigService }); const incomingRequest = supertest(innerServer.listener) .get('/') @@ -377,7 +380,7 @@ describe('OpenSearchDashboardsRequest', () => { ); }); - await server.start(); + await server.start({ dynamicConfigService }); const incomingRequest = supertest(innerServer.listener) .post('/') @@ -398,7 +401,7 @@ describe('OpenSearchDashboardsRequest', () => { router.get({ path: '/', validate: false }, async (context, req, res) => { return res.ok({ body: { requestId: req.id } }); }); - await server.start(); + await server.start({ dynamicConfigService }); const st = supertest(innerServer.listener); @@ -417,7 +420,7 @@ describe('OpenSearchDashboardsRequest', () => { router.get({ path: '/', validate: false }, async (context, req, res) => { return res.ok({ body: { requestUuid: req.uuid } }); }); - await server.start(); + await server.start({ dynamicConfigService }); const st = supertest(innerServer.listener); diff --git a/src/core/server/http/integration_tests/router.test.ts b/src/core/server/http/integration_tests/router.test.ts index b1785042691f..0a4b1664029d 100644 --- a/src/core/server/http/integration_tests/router.test.ts +++ b/src/core/server/http/integration_tests/router.test.ts @@ -38,6 +38,7 @@ import { HttpService } from '../http_service'; import { contextServiceMock } from '../../context/context_service.mock'; import { loggingSystemMock } from '../../logging/logging_system.mock'; import { createHttpServer } from '../test_utils'; +import { dynamicConfigServiceMock } from '../../config/dynamic_config_service.mock'; let server: HttpService; @@ -47,6 +48,7 @@ const contextSetup = contextServiceMock.createSetupContract(); const setupDeps = { context: contextSetup, }; +const dynamicConfigService = dynamicConfigServiceMock.createInternalStartContract(); beforeEach(() => { logger = loggingSystemMock.create(); @@ -75,7 +77,7 @@ describe('Options', () => { }, }) ); - await server.start(); + await server.start({ dynamicConfigService }); await supertest(innerServer.listener).get('/').expect(200, { httpAuthIsAuthenticated: false, @@ -102,7 +104,7 @@ describe('Options', () => { }, }) ); - await server.start(); + await server.start({ dynamicConfigService }); await supertest(innerServer.listener).get('/').expect(200, { httpAuthIsAuthenticated: true, @@ -128,7 +130,7 @@ describe('Options', () => { }, }) ); - await server.start(); + await server.start({ dynamicConfigService }); await supertest(innerServer.listener).get('/').expect(200, { httpAuthIsAuthenticated: false, @@ -146,7 +148,7 @@ describe('Options', () => { { path: '/', validate: false, options: { authRequired: 'optional' } }, (context, req, res) => res.ok({ body: 'ok' }) ); - await server.start(); + await server.start({ dynamicConfigService }); await supertest(innerServer.listener).get('/').expect(401); }); @@ -173,7 +175,7 @@ describe('Options', () => { }, }) ); - await server.start(); + await server.start({ dynamicConfigService }); await supertest(innerServer.listener).get('/').expect(200, { httpAuthIsAuthenticated: false, @@ -197,7 +199,7 @@ describe('Options', () => { }, }) ); - await server.start(); + await server.start({ dynamicConfigService }); await supertest(innerServer.listener).get('/').expect(200, { httpAuthIsAuthenticated: false, @@ -224,7 +226,7 @@ describe('Options', () => { }, }) ); - await server.start(); + await server.start({ dynamicConfigService }); await supertest(innerServer.listener).get('/').expect(200, { httpAuthIsAuthenticated: true, @@ -241,7 +243,7 @@ describe('Options', () => { { path: '/', validate: false, options: { authRequired: true } }, (context, req, res) => res.ok({ body: 'ok' }) ); - await server.start(); + await server.start({ dynamicConfigService }); await supertest(innerServer.listener).get('/').expect(401); }); @@ -256,7 +258,7 @@ describe('Options', () => { { path: '/', validate: false, options: { authRequired: true } }, (context, req, res) => res.ok({ body: 'ok' }) ); - await server.start(); + await server.start({ dynamicConfigService }); await supertest(innerServer.listener).get('/').expect(401); }); @@ -276,7 +278,7 @@ describe('Options', () => { { path: '/', validate: false, options: { authRequired: true } }, (context, req, res) => res.ok({ body: 'ok' }) ); - await server.start(); + await server.start({ dynamicConfigService }); const result = await supertest(innerServer.listener).get('/').expect(302); @@ -303,7 +305,7 @@ describe('Options', () => { }, }) ); - await server.start(); + await server.start({ dynamicConfigService }); await supertest(innerServer.listener).get('/').expect(200, { httpAuthIsAuthenticated: false, @@ -356,7 +358,7 @@ describe('Options', () => { return res.ok({}); } ); - await server.start(); + await server.start({ dynamicConfigService }); // start the request const request = supertest(innerServer.listener) @@ -381,7 +383,7 @@ describe('Options', () => { }, async (context, req, res) => res.ok({}) ); - await server.start(); + await server.start({ dynamicConfigService }); // start the request const request = supertest(innerServer.listener) @@ -416,7 +418,7 @@ describe('Options', () => { } ); - await server.start(); + await server.start({ dynamicConfigService }); // start the request const request = supertest(innerServer.listener) @@ -449,7 +451,7 @@ describe('Options', () => { } ); - await server.start(); + await server.start({ dynamicConfigService }); // start the request const request = supertest(innerServer.listener) @@ -483,7 +485,7 @@ describe('Options', () => { } ); - await server.start(); + await server.start({ dynamicConfigService }); await expect(supertest(innerServer.listener).post('/a')).rejects.toThrow('socket hang up'); }); @@ -508,7 +510,7 @@ describe('Options', () => { } ); - await server.start(); + await server.start({ dynamicConfigService }); await expect(supertest(innerServer.listener).post('/a')).resolves.toHaveProperty( 'status', 200 @@ -524,7 +526,7 @@ describe('Cache-Control', () => { const router = createRouter('/'); router.get({ path: '/', validate: false, options: {} }, (context, req, res) => res.ok()); - await server.start(); + await server.start({ dynamicConfigService }); await supertest(innerServer.listener) .get('/') @@ -542,7 +544,7 @@ describe('Cache-Control', () => { }, }) ); - await server.start(); + await server.start({ dynamicConfigService }); await supertest(innerServer.listener).get('/').expect('Cache-Control', 'public, max-age=1200'); }); @@ -556,7 +558,7 @@ describe('Handler', () => { router.get({ path: '/', validate: false }, (context, req, res) => { throw new Error('unexpected error'); }); - await server.start(); + await server.start({ dynamicConfigService }); const result = await supertest(innerServer.listener).get('/').expect(500); @@ -577,7 +579,7 @@ describe('Handler', () => { router.get({ path: '/', validate: false }, (context, req, res) => { throw Boom.unauthorized(); }); - await server.start(); + await server.start({ dynamicConfigService }); const result = await supertest(innerServer.listener).get('/').expect(500); @@ -596,7 +598,7 @@ describe('Handler', () => { const router = createRouter('/'); router.get({ path: '/', validate: false }, (context, req, res) => 'ok' as any); - await server.start(); + await server.start({ dynamicConfigService }); const result = await supertest(innerServer.listener).get('/').expect(500); @@ -625,7 +627,7 @@ describe('Handler', () => { }, (context, req, res) => res.noContent() ); - await server.start(); + await server.start({ dynamicConfigService }); const result = await supertest(innerServer.listener) .get('/') @@ -656,7 +658,7 @@ describe('Handler', () => { return res.ok({ body: 'ok' }); } ); - await server.start(); + await server.start({ dynamicConfigService }); await supertest(innerServer.listener) .post('/') @@ -683,7 +685,7 @@ describe('Handler', () => { return res.ok({ body: 'ok' }); } ); - await server.start(); + await server.start({ dynamicConfigService }); await supertest(innerServer.listener).post('/').type('json').send('12').expect(200); @@ -702,7 +704,7 @@ describe('handleLegacyErrors', () => { throw Boom.notFound(); }) ); - await server.start(); + await server.start({ dynamicConfigService }); const result = await supertest(innerServer.listener).get('/').expect(404); @@ -722,7 +724,7 @@ describe('handleLegacyErrors', () => { throw new Error('Unexpected'); }) ); - await server.start(); + await server.start({ dynamicConfigService }); const result = await supertest(innerServer.listener).get('/').expect(500); @@ -744,7 +746,7 @@ describe('Response factory', () => { return res.ok({ body: { key: 'value' } }); }); - await server.start(); + await server.start({ dynamicConfigService }); const result = await supertest(innerServer.listener).get('/').expect(200); @@ -760,7 +762,7 @@ describe('Response factory', () => { return res.ok({ body: 'result' }); }); - await server.start(); + await server.start({ dynamicConfigService }); const result = await supertest(innerServer.listener).get('/').expect(200); @@ -776,7 +778,7 @@ describe('Response factory', () => { return res.ok(undefined); }); - await server.start(); + await server.start({ dynamicConfigService }); await supertest(innerServer.listener).get('/').expect(200); }); @@ -798,7 +800,7 @@ describe('Response factory', () => { return res.ok({ body: stream }); }); - await server.start(); + await server.start({ dynamicConfigService }); const result = await supertest(innerServer.listener).get('/').expect(200); @@ -826,7 +828,7 @@ describe('Response factory', () => { }, }); }); - await server.start(); + await server.start({ dynamicConfigService }); const result = await supertest(innerServer.listener).get('/').expect(200); expect(result.text).toBe('abc'); @@ -851,7 +853,7 @@ describe('Response factory', () => { }, }); }); - await server.start(); + await server.start({ dynamicConfigService }); const result = await supertest(innerServer.listener).get('/').expect(200); expect(result.text).toBe('abc'); @@ -871,7 +873,7 @@ describe('Response factory', () => { }, }); }); - await server.start(); + await server.start({ dynamicConfigService }); const result = await supertest(innerServer.listener).get('/').expect(200).buffer(true); expect(result.header['content-encoding']).toBe('binary'); @@ -891,7 +893,7 @@ describe('Response factory', () => { }, }); }); - await server.start(); + await server.start({ dynamicConfigService }); const result = await supertest(innerServer.listener).get('/').expect(200).buffer(true); expect(result.text).toBe('abc'); @@ -910,7 +912,7 @@ describe('Response factory', () => { }, }); }); - await server.start(); + await server.start({ dynamicConfigService }); const result = await supertest(innerServer.listener).get('/').expect(200); expect(result.text).toEqual('value'); @@ -929,7 +931,7 @@ describe('Response factory', () => { }, }); }); - await server.start(); + await server.start({ dynamicConfigService }); const result = await supertest(innerServer.listener).get('/').expect(200); expect(result.text).toEqual('value'); @@ -948,7 +950,7 @@ describe('Response factory', () => { }, }); }); - await server.start(); + await server.start({ dynamicConfigService }); const result = await supertest(innerServer.listener).get('/').expect(200); expect(result.header.etag).toBe('1234'); @@ -965,7 +967,7 @@ describe('Response factory', () => { }, }); }); - await server.start(); + await server.start({ dynamicConfigService }); const result = await supertest(innerServer.listener).get('/').expect(200); expect(result.header['set-cookie']).toEqual(['foo', 'bar']); @@ -979,7 +981,7 @@ describe('Response factory', () => { payload.key.payload = payload; return res.ok({ body: payload }); }); - await server.start(); + await server.start({ dynamicConfigService }); await supertest(innerServer.listener).get('/').expect(500); // error happens within hapi when route handler already finished execution. @@ -992,7 +994,7 @@ describe('Response factory', () => { router.get({ path: '/', validate: false }, (context, req, res) => { return res.ok({ body: { key: 'value' } }); }); - await server.start(); + await server.start({ dynamicConfigService }); const result = await supertest(innerServer.listener).get('/').expect(200); expect(result.body).toEqual({ key: 'value' }); @@ -1005,7 +1007,7 @@ describe('Response factory', () => { router.get({ path: '/', validate: false }, (context, req, res) => { return res.accepted({ body: { location: 'somewhere' } }); }); - await server.start(); + await server.start({ dynamicConfigService }); const result = await supertest(innerServer.listener).get('/').expect(202); expect(result.body).toEqual({ location: 'somewhere' }); @@ -1018,7 +1020,7 @@ describe('Response factory', () => { router.get({ path: '/', validate: false }, (context, req, res) => { return res.noContent(); }); - await server.start(); + await server.start({ dynamicConfigService }); const result = await supertest(innerServer.listener).get('/').expect(204); expect(result.noContent).toBe(true); @@ -1040,7 +1042,7 @@ describe('Response factory', () => { }); }); - await server.start(); + await server.start({ dynamicConfigService }); const result = await supertest(innerServer.listener).get('/').expect(302); @@ -1061,7 +1063,7 @@ describe('Response factory', () => { } as any); // location headers is required }); - await server.start(); + await server.start({ dynamicConfigService }); const result = await supertest(innerServer.listener).get('/').expect(500); @@ -1086,7 +1088,7 @@ describe('Response factory', () => { return res.badRequest({ body: error }); }); - await server.start(); + await server.start({ dynamicConfigService }); const result = await supertest(innerServer.listener).get('/').expect(400); @@ -1105,7 +1107,7 @@ describe('Response factory', () => { return res.badRequest(); }); - await server.start(); + await server.start({ dynamicConfigService }); const result = await supertest(innerServer.listener).get('/').expect(400); @@ -1126,7 +1128,7 @@ describe('Response factory', () => { }); }); - await server.start(); + await server.start({ dynamicConfigService }); const result = await supertest(innerServer.listener).get('/').expect(400); @@ -1162,7 +1164,7 @@ describe('Response factory', () => { } ); - await server.start(); + await server.start({ dynamicConfigService }); await supertest(innerServer.listener) .post('/foo/') @@ -1210,7 +1212,7 @@ describe('Response factory', () => { } ); - await server.start(); + await server.start({ dynamicConfigService }); await supertest(innerServer.listener) .post('/foo/') @@ -1264,7 +1266,7 @@ describe('Response factory', () => { }); }); - await server.start(); + await server.start({ dynamicConfigService }); const result = await supertest(innerServer.listener).get('/').expect(401); @@ -1280,7 +1282,7 @@ describe('Response factory', () => { return res.unauthorized(); }); - await server.start(); + await server.start({ dynamicConfigService }); const result = await supertest(innerServer.listener).get('/').expect(401); @@ -1296,7 +1298,7 @@ describe('Response factory', () => { return res.forbidden({ body: error }); }); - await server.start(); + await server.start({ dynamicConfigService }); const result = await supertest(innerServer.listener).get('/').expect(403); @@ -1311,7 +1313,7 @@ describe('Response factory', () => { return res.forbidden(); }); - await server.start(); + await server.start({ dynamicConfigService }); const result = await supertest(innerServer.listener).get('/').expect(403); @@ -1327,7 +1329,7 @@ describe('Response factory', () => { return res.notFound({ body: error }); }); - await server.start(); + await server.start({ dynamicConfigService }); const result = await supertest(innerServer.listener).get('/').expect(404); @@ -1342,7 +1344,7 @@ describe('Response factory', () => { return res.notFound(); }); - await server.start(); + await server.start({ dynamicConfigService }); const result = await supertest(innerServer.listener).get('/').expect(404); @@ -1358,7 +1360,7 @@ describe('Response factory', () => { return res.conflict({ body: error }); }); - await server.start(); + await server.start({ dynamicConfigService }); const result = await supertest(innerServer.listener).get('/').expect(409); @@ -1373,7 +1375,7 @@ describe('Response factory', () => { return res.conflict(); }); - await server.start(); + await server.start({ dynamicConfigService }); const result = await supertest(innerServer.listener).get('/').expect(409); @@ -1392,7 +1394,7 @@ describe('Response factory', () => { }); }); - await server.start(); + await server.start({ dynamicConfigService }); const result = await supertest(innerServer.listener).get('/').expect(418); @@ -1416,7 +1418,7 @@ describe('Response factory', () => { }); }); - await server.start(); + await server.start({ dynamicConfigService }); const result = await supertest(innerServer.listener).get('/').expect(500); @@ -1440,7 +1442,7 @@ describe('Response factory', () => { }); }); - await server.start(); + await server.start({ dynamicConfigService }); const result = await supertest(innerServer.listener).get('/').expect(500); @@ -1463,7 +1465,7 @@ describe('Response factory', () => { }); }); - await server.start(); + await server.start({ dynamicConfigService }); const result = await supertest(innerServer.listener).get('/').expect(500); @@ -1497,7 +1499,7 @@ describe('Response factory', () => { }); }); - await server.start(); + await server.start({ dynamicConfigService }); const result = await supertest(innerServer.listener).get('/').expect(201); @@ -1518,7 +1520,7 @@ describe('Response factory', () => { }); }); - await server.start(); + await server.start({ dynamicConfigService }); const result = await supertest(innerServer.listener).get('/').expect(301); @@ -1537,7 +1539,7 @@ describe('Response factory', () => { }); }); - await server.start(); + await server.start({ dynamicConfigService }); await supertest(innerServer.listener).get('/').expect(500); @@ -1562,7 +1564,7 @@ describe('Response factory', () => { }); }); - await server.start(); + await server.start({ dynamicConfigService }); const result = await supertest(innerServer.listener).get('/').expect(401); @@ -1583,7 +1585,7 @@ describe('Response factory', () => { }); }); - await server.start(); + await server.start({ dynamicConfigService }); const result = await supertest(innerServer.listener).get('/').expect(401); @@ -1609,7 +1611,7 @@ describe('Response factory', () => { }); }); - await server.start(); + await server.start({ dynamicConfigService }); const result = await supertest(innerServer.listener).get('/').expect(401); @@ -1633,7 +1635,7 @@ describe('Response factory', () => { }); }); - await server.start(); + await server.start({ dynamicConfigService }); const result = await supertest(innerServer.listener).get('/').expect(401); @@ -1651,7 +1653,7 @@ describe('Response factory', () => { }); }); - await server.start(); + await server.start({ dynamicConfigService }); const result = await supertest(innerServer.listener).get('/').expect(500); @@ -1670,7 +1672,7 @@ describe('Response factory', () => { }); }); - await server.start(); + await server.start({ dynamicConfigService }); const result = await supertest(innerServer.listener).get('/').expect(500); @@ -1694,7 +1696,7 @@ describe('Response factory', () => { } as any); // requires error message }); - await server.start(); + await server.start({ dynamicConfigService }); const result = await supertest(innerServer.listener).get('/').expect(500); @@ -1717,7 +1719,7 @@ describe('Response factory', () => { return res.custom({ body: error } as any); // options.statusCode is required }); - await server.start(); + await server.start({ dynamicConfigService }); const result = await supertest(innerServer.listener).get('/').expect(500); @@ -1740,7 +1742,7 @@ describe('Response factory', () => { return res.custom({ body: error, statusCode: 20 }); }); - await server.start(); + await server.start({ dynamicConfigService }); const result = await supertest(innerServer.listener).get('/').expect(500); diff --git a/src/core/server/http/test_utils.ts b/src/core/server/http/test_utils.ts index bf2063368cd7..0a805e54b968 100644 --- a/src/core/server/http/test_utils.ts +++ b/src/core/server/http/test_utils.ts @@ -36,6 +36,7 @@ import { HttpService } from './http_service'; import { CoreContext } from '../core_context'; import { getEnvOptions, configServiceMock } from '../config/mocks'; import { loggingSystemMock } from '../logging/logging_system.mock'; +import { dynamicConfigServiceMock } from '../config/dynamic_config_service.mock'; const coreId = Symbol('core'); const env = Env.createDefault(REPO_ROOT, getEnvOptions()); @@ -66,11 +67,14 @@ configService.atPath.mockReturnValue( } as any) ); +const dynamicConfigService = dynamicConfigServiceMock.create(); + const defaultContext: CoreContext = { coreId, env, logger, configService, + dynamicConfigService, }; export const createCoreContext = (overrides: Partial = {}): CoreContext => ({ diff --git a/src/core/server/index.ts b/src/core/server/index.ts index a450aad5e854..921228416f42 100644 --- a/src/core/server/index.ts +++ b/src/core/server/index.ts @@ -70,6 +70,12 @@ import { SavedObjectsServiceSetup, SavedObjectsServiceStart, } from './saved_objects'; +import { + AsyncLocalStorageContext, + DynamicConfigServiceSetup, + DynamicConfigServiceStart, + IDynamicConfigurationClient, +} from './config'; import { CapabilitiesSetup, CapabilitiesStart } from './capabilities'; import { MetricsServiceSetup, MetricsServiceStart } from './metrics'; import { StatusServiceSetup } from './status'; @@ -102,6 +108,13 @@ export { ConfigDeprecationFactory, EnvironmentMode, PackageInfo, + IDynamicConfigurationClient, + DynamicConfigurationClientOptions, + ConfigIdentifier, + GetConfigProps, + BulkGetConfigProps, + IDynamicConfigStoreClient, + IDynamicConfigStoreClientFactory, } from './config'; export { IContextContainer, @@ -399,6 +412,7 @@ export { CoreUsageDataStart } from './core_usage_data'; * - {@link IUiSettingsClient | uiSettings.client} - uiSettings client * which uses the credentials of the incoming request * - {@link Auditor | uiSettings.auditor} - AuditTrail client scoped to the incoming request + * - {@link IDynamicConfigurationClient | dynamicConfig.client} - Dynamic configuration client * * @public */ @@ -417,6 +431,10 @@ export interface RequestHandlerContext { uiSettings: { client: IUiSettingsClient; }; + dynamicConfig: { + client: IDynamicConfigurationClient; + asyncLocalStore: AsyncLocalStorageContext | undefined; + }; auditor: Auditor; }; } @@ -458,6 +476,8 @@ export interface CoreSetup; /** {@link AuditTrailSetup} */ auditTrail: AuditTrailSetup; + /** {@link DynamicConfigServiceSetup} */ + dynamicConfigService: DynamicConfigServiceSetup; } /** @@ -497,6 +517,8 @@ export interface CoreStart { coreUsageData: CoreUsageDataStart; /** {@link CrossCompatibilityServiceStart} */ crossCompatibility: CrossCompatibilityServiceStart; + /** {@link DynamicConfigServiceStart} */ + dynamicConfig: DynamicConfigServiceStart; } export { diff --git a/src/core/server/internal_types.ts b/src/core/server/internal_types.ts index 105f84dd9916..571dc3ad6ca0 100644 --- a/src/core/server/internal_types.ts +++ b/src/core/server/internal_types.ts @@ -31,7 +31,11 @@ import { Type } from '@osd/config-schema'; import { CapabilitiesSetup, CapabilitiesStart } from './capabilities'; -import { ConfigDeprecationProvider } from './config'; +import { + ConfigDeprecationProvider, + InternalDynamicConfigServiceSetup, + InternalDynamicConfigServiceStart, +} from './config'; import { ContextSetup } from './context'; import { InternalOpenSearchServiceSetup, InternalOpenSearchServiceStart } from './opensearch'; import { InternalHttpServiceSetup, InternalHttpServiceStart } from './http'; @@ -67,6 +71,7 @@ export interface InternalCoreSetup { logging: InternalLoggingServiceSetup; metrics: InternalMetricsServiceSetup; security: InternalSecurityServiceSetup; + dynamicConfig: InternalDynamicConfigServiceSetup; } /** @@ -82,6 +87,7 @@ export interface InternalCoreStart { auditTrail: AuditTrailStart; coreUsageData: CoreUsageDataStart; crossCompatibility: CrossCompatibilityServiceStart; + dynamicConfig: InternalDynamicConfigServiceStart; } /** diff --git a/src/core/server/legacy/legacy_service.test.ts b/src/core/server/legacy/legacy_service.test.ts index 3dd4ce6589bd..7fc8729264ce 100644 --- a/src/core/server/legacy/legacy_service.test.ts +++ b/src/core/server/legacy/legacy_service.test.ts @@ -28,6 +28,8 @@ * under the License. */ +import { dynamicConfigServiceMock } from '../config/dynamic_config_service.mock'; + jest.mock('../../../legacy/server/osd_server'); jest.mock('./cluster_manager'); @@ -75,6 +77,7 @@ let startDeps: LegacyServiceStartDeps; const logger = loggingSystemMock.create(); let configService: ReturnType; let environmentSetup: ReturnType; +const dynamicConfigService = dynamicConfigServiceMock.create(); beforeEach(() => { coreId = Symbol(); @@ -110,6 +113,7 @@ beforeEach(() => { logging: loggingServiceMock.createInternalSetupContract(), metrics: metricsServiceMock.createInternalSetupContract(), security: securityServiceMock.createSetupContract(), + dynamicConfig: dynamicConfigServiceMock.createInternalSetupContract(), }, plugins: { 'plugin-id': 'plugin-value' }, uiPlugins: { @@ -159,6 +163,7 @@ describe('once LegacyService is set up with connection info', () => { env, logger, configService, + dynamicConfigService, }); await legacyService.setupLegacyConfig(); @@ -191,6 +196,7 @@ describe('once LegacyService is set up with connection info', () => { env, logger, configService: configService as any, + dynamicConfigService, }); await legacyService.setupLegacyConfig(); await legacyService.setup(setupDeps); @@ -221,6 +227,7 @@ describe('once LegacyService is set up with connection info', () => { env, logger, configService: configService as any, + dynamicConfigService, }); await legacyService.setupLegacyConfig(); @@ -241,6 +248,7 @@ describe('once LegacyService is set up with connection info', () => { env, logger, configService: configService as any, + dynamicConfigService, }); await expect(legacyService.setupLegacyConfig()).rejects.toThrowErrorMatchingInlineSnapshot( @@ -263,6 +271,7 @@ describe('once LegacyService is set up with connection info', () => { env, logger, configService: configService as any, + dynamicConfigService, }); await legacyService.setupLegacyConfig(); await legacyService.setup(setupDeps); @@ -284,6 +293,7 @@ describe('once LegacyService is set up with connection info', () => { env, logger, configService: configService as any, + dynamicConfigService, }); await legacyService.setupLegacyConfig(); await legacyService.setup(setupDeps); @@ -309,6 +319,7 @@ describe('once LegacyService is set up with connection info', () => { env, logger, configService: configService as any, + dynamicConfigService, }); await legacyService.setupLegacyConfig(); await legacyService.setup(setupDeps); @@ -329,7 +340,13 @@ describe('once LegacyService is set up with connection info', () => { describe('once LegacyService is set up without connection info', () => { let legacyService: LegacyService; beforeEach(async () => { - legacyService = new LegacyService({ coreId, env, logger, configService: configService as any }); + legacyService = new LegacyService({ + coreId, + env, + logger, + configService: configService as any, + dynamicConfigService, + }); await legacyService.setupLegacyConfig(); await legacyService.setup(setupDeps); await legacyService.start(startDeps); @@ -383,6 +400,7 @@ describe('once LegacyService is set up in `devClusterMaster` mode', () => { ), logger, configService: configService as any, + dynamicConfigService, }); await devClusterLegacyService.setupLegacyConfig(); @@ -413,6 +431,7 @@ describe('once LegacyService is set up in `devClusterMaster` mode', () => { ), logger, configService: configService as any, + dynamicConfigService, }); await devClusterLegacyService.setupLegacyConfig(); @@ -438,6 +457,7 @@ describe('start', () => { env, logger, configService: configService as any, + dynamicConfigService, }); await expect(legacyService.start(startDeps)).rejects.toThrowErrorMatchingInlineSnapshot( `"Legacy service is not setup yet."` @@ -452,6 +472,7 @@ test('Sets the server.uuid property on the legacy configuration', async () => { env, logger, configService: configService as any, + dynamicConfigService, }); environmentSetup.instanceUuid = 'UUID_FROM_SERVICE'; diff --git a/src/core/server/legacy/legacy_service.ts b/src/core/server/legacy/legacy_service.ts index 4dec545fd186..1dd9be84a663 100644 --- a/src/core/server/legacy/legacy_service.ts +++ b/src/core/server/legacy/legacy_service.ts @@ -234,6 +234,11 @@ export class LegacyService implements CoreService { }, }, crossCompatibility: startDeps.core.crossCompatibility, + dynamicConfig: { + getClient: startDeps.core.dynamicConfig.getClient, + getAsyncLocalStore: startDeps.core.dynamicConfig.getAsyncLocalStore, + createStoreFromRequest: startDeps.core.dynamicConfig.createStoreFromRequest, + }, }; const router = setupDeps.core.http.createRouter('', this.legacyId); diff --git a/src/core/server/metrics/integration_tests/server_collector.test.ts b/src/core/server/metrics/integration_tests/server_collector.test.ts index ef0b2fc23ae7..bcf703adc854 100644 --- a/src/core/server/metrics/integration_tests/server_collector.test.ts +++ b/src/core/server/metrics/integration_tests/server_collector.test.ts @@ -36,6 +36,7 @@ import { createHttpServer } from '../../http/test_utils'; import { HttpService, IRouter } from '../../http'; import { contextServiceMock } from '../../context/context_service.mock'; import { ServerMetricsCollector } from '../collectors/server'; +import { dynamicConfigServiceMock } from '../../config/dynamic_config_service.mock'; const requestWaitDelay = 25; @@ -44,6 +45,7 @@ describe('ServerMetricsCollector', () => { let collector: ServerMetricsCollector; let hapiServer: HapiServer; let router: IRouter; + const dynamicConfigService = dynamicConfigServiceMock.createInternalStartContract(); const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); const sendGet = (path: string) => supertest(hapiServer.listener).get(path); @@ -65,7 +67,7 @@ describe('ServerMetricsCollector', () => { router.get({ path: '/', validate: false }, async (ctx, req, res) => { return res.ok({ body: '' }); }); - await server.start(); + await server.start({ dynamicConfigService }); let metrics = await collector.collect(); @@ -103,7 +105,7 @@ describe('ServerMetricsCollector', () => { await never; return res.ok({ body: '' }); }); - await server.start(); + await server.start({ dynamicConfigService }); await sendGet('/'); const discoReq1 = sendGet('/disconnect').end(() => null); @@ -159,7 +161,7 @@ describe('ServerMetricsCollector', () => { await delay(250); return res.ok({ body: '' }); }); - await server.start(); + await server.start({ dynamicConfigService }); await Promise.all([sendGet('/no-delay'), sendGet('/250-ms')]); let metrics = await collector.collect(); @@ -183,7 +185,7 @@ describe('ServerMetricsCollector', () => { await waitSubject.pipe(take(1)).toPromise(); return res.ok({ body: '' }); }); - await server.start(); + await server.start({ dynamicConfigService }); const waitForHits = (hits: number) => hitSubject @@ -222,7 +224,7 @@ describe('ServerMetricsCollector', () => { router.get({ path: '/', validate: false }, async (ctx, req, res) => { return res.ok({ body: '' }); }); - await server.start(); + await server.start({ dynamicConfigService }); await sendGet('/'); await sendGet('/'); @@ -272,7 +274,7 @@ describe('ServerMetricsCollector', () => { return res.ok({ body: '' }); }); - await server.start(); + await server.start({ dynamicConfigService }); await Promise.all([sendGet('/no-delay'), sendGet('/500-ms')]); let metrics = await collector.collect(); diff --git a/src/core/server/mocks.ts b/src/core/server/mocks.ts index d688afbd5450..823e778b469e 100644 --- a/src/core/server/mocks.ts +++ b/src/core/server/mocks.ts @@ -52,8 +52,10 @@ import { auditTrailServiceMock } from './audit_trail/audit_trail_service.mock'; import { coreUsageDataServiceMock } from './core_usage_data/core_usage_data_service.mock'; import { securityServiceMock } from './security/security_service.mock'; import { crossCompatibilityServiceMock } from './cross_compatibility/cross_compatibility.mock'; +import { dynamicConfigServiceMock } from './config/dynamic_config_service.mock'; export { configServiceMock } from './config/mocks'; +export { dynamicConfigServiceMock } from './config/mocks'; export { httpServerMock } from './http/http_server.mocks'; export { httpResourcesMock } from './http_resources/http_resources_service.mock'; export { sessionStorageMock } from './http/cookie_session_storage.mocks'; @@ -167,6 +169,7 @@ function createCoreSetupMock({ .fn, object, any]>, []>() .mockResolvedValue([createCoreStartMock(), pluginStartDeps, pluginStartContract]), security: securityServiceMock.createSetupContract(), + dynamicConfigService: dynamicConfigServiceMock.createSetupContract(), }; return mock; @@ -183,6 +186,7 @@ function createCoreStartMock() { uiSettings: uiSettingsServiceMock.createStartContract(), coreUsageData: coreUsageDataServiceMock.createStartContract(), crossCompatibility: crossCompatibilityServiceMock.createStartContract(), + dynamicConfig: dynamicConfigServiceMock.createStartContract(), }; return mock; @@ -204,6 +208,7 @@ function createInternalCoreSetupMock() { logging: loggingServiceMock.createInternalSetupContract(), metrics: metricsServiceMock.createInternalSetupContract(), security: securityServiceMock.createSetupContract(), + dynamicConfig: dynamicConfigServiceMock.createInternalSetupContract(), }; return setupDeps; } @@ -219,6 +224,7 @@ function createInternalCoreStartMock() { auditTrail: auditTrailServiceMock.createStartContract(), coreUsageData: coreUsageDataServiceMock.createStartContract(), crossCompatibility: crossCompatibilityServiceMock.createStartContract(), + dynamicConfig: dynamicConfigServiceMock.createInternalStartContract(), }; return startDeps; } @@ -239,6 +245,10 @@ function createCoreRequestHandlerContextMock() { client: uiSettingsServiceMock.createClient(), }, auditor: auditTrailServiceMock.createAuditor(), + dynamicConfig: { + client: dynamicConfigServiceMock.createInternalStartContract().getClient(), + asyncLocalStore: dynamicConfigServiceMock.createInternalStartContract().getAsyncLocalStore(), + }, }; } diff --git a/src/core/server/opensearch/opensearch_service.test.ts b/src/core/server/opensearch/opensearch_service.test.ts index 1280479314a6..69bf16071eaf 100644 --- a/src/core/server/opensearch/opensearch_service.test.ts +++ b/src/core/server/opensearch/opensearch_service.test.ts @@ -43,12 +43,14 @@ import { OpenSearchService } from './opensearch_service'; import { opensearchServiceMock } from './opensearch_service.mock'; import { opensearchClientMock } from './client/mocks'; import { duration } from 'moment'; +import { dynamicConfigServiceMock } from '../config/dynamic_config_service.mock'; const delay = async (durationMs: number) => await new Promise((resolve) => setTimeout(resolve, durationMs)); let opensearchService: OpenSearchService; const configService = configServiceMock.create(); +const dynamicConfigService = dynamicConfigServiceMock.create(); const setupDeps = { http: httpServiceMock.createInternalSetupContract(), }; @@ -77,7 +79,13 @@ let mockLegacyClusterClientInstance: ReturnType { env = Env.createDefault(REPO_ROOT, getEnvOptions()); - coreContext = { coreId: Symbol(), env, logger, configService: configService as any }; + coreContext = { + coreId: Symbol(), + env, + logger, + configService: configService as any, + dynamicConfigService, + }; opensearchService = new OpenSearchService(coreContext); MockLegacyClusterClient.mockClear(); diff --git a/src/core/server/plugins/discovery/plugins_discovery.test.ts b/src/core/server/plugins/discovery/plugins_discovery.test.ts index 550acaf3f4f3..0f3db651defc 100644 --- a/src/core/server/plugins/discovery/plugins_discovery.test.ts +++ b/src/core/server/plugins/discovery/plugins_discovery.test.ts @@ -43,6 +43,7 @@ import type { InstanceInfo } from '../plugin_context'; import { discover } from './plugins_discovery'; import { CoreContext } from '../../core_context'; import { PROCESS_WORKING_DIR, standardize } from '@osd/cross-platform'; +import { dynamicConfigServiceMock } from '../../config/dynamic_config_service.mock'; const Plugins = { invalid: () => ({ @@ -117,12 +118,14 @@ describe('plugins discovery system', () => { logger ); await configService.setSchema(config.path, config.schema); + const dynamicConfigService = dynamicConfigServiceMock.create(); coreContext = { coreId: Symbol(), configService, env, logger, + dynamicConfigService, }; pluginConfig = await configService @@ -408,6 +411,7 @@ describe('plugins discovery system', () => { cliArgs: { dev: false, envName: 'development' }, }) ); + const dynamicConfigService = dynamicConfigServiceMock.create(); discover( new PluginsConfig({ ...pluginConfig, paths: [extraPluginTestPath] }, env), @@ -416,6 +420,7 @@ describe('plugins discovery system', () => { configService, env, logger, + dynamicConfigService, }, instanceInfo ); @@ -436,6 +441,7 @@ describe('plugins discovery system', () => { cliArgs: { dev: false, envName: 'production' }, }) ); + const dynamicConfigService = dynamicConfigServiceMock.create(); discover( new PluginsConfig({ ...pluginConfig, paths: [extraPluginTestPath] }, env), @@ -444,6 +450,7 @@ describe('plugins discovery system', () => { configService, env, logger, + dynamicConfigService, }, instanceInfo ); diff --git a/src/core/server/plugins/integration_tests/plugins_service.test.ts b/src/core/server/plugins/integration_tests/plugins_service.test.ts index ea400ddcd913..c30d78a18249 100644 --- a/src/core/server/plugins/integration_tests/plugins_service.test.ts +++ b/src/core/server/plugins/integration_tests/plugins_service.test.ts @@ -44,6 +44,7 @@ import { environmentServiceMock } from '../../environment/environment_service.mo import { coreMock } from '../../mocks'; import { Plugin, CompatibleEnginePluginVersions } from '../types'; import { PluginWrapper } from '../plugin'; +import { dynamicConfigServiceMock } from '../../config/dynamic_config_service.mock'; describe('PluginsService', () => { const logger = loggingSystemMock.create(); @@ -117,12 +118,14 @@ describe('PluginsService', () => { const rawConfigService = rawConfigServiceMock.create({ rawConfig$: config$ }); const configService = new ConfigService(rawConfigService, env, logger); await configService.setSchema(config.path, config.schema); + const dynamicConfigService = dynamicConfigServiceMock.create(); pluginsService = new PluginsService({ coreId: Symbol('core'), env, logger, configService, + dynamicConfigService, }); }); diff --git a/src/core/server/plugins/plugin.test.ts b/src/core/server/plugins/plugin.test.ts index 2fc8a182a860..f1e91c9eb6f7 100644 --- a/src/core/server/plugins/plugin.test.ts +++ b/src/core/server/plugins/plugin.test.ts @@ -46,6 +46,7 @@ import { createPluginSetupContext, InstanceInfo, } from './plugin_context'; +import { dynamicConfigServiceMock } from '../config/dynamic_config_service.mock'; const { join } = posix; const mockPluginInitializer = jest.fn(); @@ -83,6 +84,7 @@ function createPluginManifest(manifestProps: Partial = {}): Plug }; } +const dynamicConfigService = dynamicConfigServiceMock.create(); const configService = configServiceMock.create(); configService.atPath.mockReturnValue(new BehaviorSubject({ initialize: true })); @@ -100,7 +102,7 @@ beforeEach(() => { uuid: 'instance-uuid', }; - coreContext = { coreId, env, logger, configService: configService as any }; + coreContext = { coreId, env, logger, configService: configService as any, dynamicConfigService }; }); afterEach(() => { diff --git a/src/core/server/plugins/plugin_context.test.ts b/src/core/server/plugins/plugin_context.test.ts index 3bd8d711c011..6d0457b2b6de 100644 --- a/src/core/server/plugins/plugin_context.test.ts +++ b/src/core/server/plugins/plugin_context.test.ts @@ -77,7 +77,13 @@ describe('createPluginInitializerContext', () => { const config$ = rawConfigServiceMock.create({ rawConfig: {} }); server = new Server(config$, env, logger); await server.setupCoreConfig(); - coreContext = { coreId, env, logger, configService: server.configService }; + coreContext = { + coreId, + env, + logger, + configService: server.configService, + dynamicConfigService: server.dynamicConfigService, + }; }); it('should return a globalConfig handler in the context', async () => { diff --git a/src/core/server/plugins/plugin_context.ts b/src/core/server/plugins/plugin_context.ts index c0eb5b29bb63..d9e79ec51a2b 100644 --- a/src/core/server/plugins/plugin_context.ts +++ b/src/core/server/plugins/plugin_context.ts @@ -221,6 +221,11 @@ export function createPluginSetupContext( getStartServices: () => plugin.startDependencies, auditTrail: deps.auditTrail, security: deps.security, + dynamicConfigService: { + registerDynamicConfigClientFactory: deps.dynamicConfig.registerDynamicConfigClientFactory, + registerAsyncLocalStoreRequestHeader: deps.dynamicConfig.registerAsyncLocalStoreRequestHeader, + getStartService: deps.dynamicConfig.getStartService, + }, }; } @@ -272,5 +277,10 @@ export function createPluginStartContext( auditTrail: deps.auditTrail, coreUsageData: deps.coreUsageData, crossCompatibility: deps.crossCompatibility, + dynamicConfig: { + getAsyncLocalStore: deps.dynamicConfig.getAsyncLocalStore, + getClient: deps.dynamicConfig.getClient, + createStoreFromRequest: deps.dynamicConfig.createStoreFromRequest, + }, }; } diff --git a/src/core/server/plugins/plugins_service.test.ts b/src/core/server/plugins/plugins_service.test.ts index 06b12643a64a..502e882dc28e 100644 --- a/src/core/server/plugins/plugins_service.test.ts +++ b/src/core/server/plugins/plugins_service.test.ts @@ -48,6 +48,7 @@ import { PluginsSystem } from './plugins_system'; import { config } from './plugins_config'; import { take } from 'rxjs/operators'; import { DiscoveredPlugin, CompatibleEnginePluginVersions } from './types'; +import { dynamicConfigServiceMock } from '../config/dynamic_config_service.mock'; const { join } = posix; const MockPluginsSystem: jest.Mock = PluginsSystem as any; @@ -60,6 +61,7 @@ let env: Env; let mockPluginSystem: jest.Mocked; let environmentSetup: ReturnType; +const dynamicConfigService = dynamicConfigServiceMock.create(); const setupDeps = coreMock.createInternalSetup(); const logger = loggingSystemMock.create(); @@ -140,7 +142,13 @@ describe('PluginsService', () => { const rawConfigService = rawConfigServiceMock.create({ rawConfig$: config$ }); configService = new ConfigService(rawConfigService, env, logger); await configService.setSchema(config.path, config.schema); - pluginsService = new PluginsService({ coreId, env, logger, configService }); + pluginsService = new PluginsService({ + coreId, + env, + logger, + configService, + dynamicConfigService, + }); [mockPluginSystem] = MockPluginsSystem.mock.instances as any; mockPluginSystem.uiPlugins.mockReturnValue(new Map()); @@ -411,7 +419,7 @@ describe('PluginsService', () => { resolve(process.cwd(), '..', 'opensearch-dashboards-extra'), ], }, - { coreId, env, logger, configService }, + { coreId, env, logger, configService, dynamicConfigService }, { uuid: 'uuid' } ); diff --git a/src/core/server/plugins/plugins_service.ts b/src/core/server/plugins/plugins_service.ts index 2f4a3dfbc07c..81a13aad678f 100644 --- a/src/core/server/plugins/plugins_service.ts +++ b/src/core/server/plugins/plugins_service.ts @@ -246,6 +246,10 @@ export class PluginsService implements CoreService { env = Env.createDefault(REPO_ROOT, getEnvOptions()); - coreContext = { coreId: Symbol(), env, logger, configService: configService as any }; + coreContext = { + coreId: Symbol(), + env, + logger, + configService: configService as any, + dynamicConfigService, + }; pluginsSystem = new PluginsSystem(coreContext); }); diff --git a/src/core/server/rendering/__mocks__/params.ts b/src/core/server/rendering/__mocks__/params.ts index 77a081f01a31..146713ed99e3 100644 --- a/src/core/server/rendering/__mocks__/params.ts +++ b/src/core/server/rendering/__mocks__/params.ts @@ -32,15 +32,18 @@ import { mockCoreContext } from '../../core_context.mock'; import { httpServiceMock } from '../../http/http_service.mock'; import { pluginServiceMock } from '../../plugins/plugins_service.mock'; import { statusServiceMock } from '../../status/status_service.mock'; +import { dynamicConfigServiceMock } from '../../config/dynamic_config_service.mock'; const context = mockCoreContext.create(); const http = httpServiceMock.createInternalSetupContract(); const uiPlugins = pluginServiceMock.createUiPlugins(); const status = statusServiceMock.createInternalSetupContract(); +const dynamicConfig = dynamicConfigServiceMock.createInternalSetupContract(); export const mockRenderingServiceParams = context; export const mockRenderingSetupDeps = { http, uiPlugins, status, + dynamicConfig, }; diff --git a/src/core/server/rendering/rendering_service.tsx b/src/core/server/rendering/rendering_service.tsx index a94056372667..36f55bb22097 100644 --- a/src/core/server/rendering/rendering_service.tsx +++ b/src/core/server/rendering/rendering_service.tsx @@ -68,6 +68,7 @@ export class RenderingService { http, status, uiPlugins, + dynamicConfig, }: RenderingSetupDeps): Promise { const [opensearchDashboardsConfig, serverConfig] = await Promise.all([ this.coreContext.configService @@ -117,6 +118,8 @@ export class RenderingService { opensearchDashboardsConfig as OpenSearchDashboardsConfigType ); + const dynamicConfigStartServices = await dynamicConfig.getStartService(); + const metadata: RenderingMetadata = { strictCsp: http.csp.strict, uiPublicUrl, @@ -142,7 +145,15 @@ export class RenderingService { [...uiPlugins.public].map(async ([id, plugin]) => ({ id, plugin, - config: await this.getUiConfig(uiPlugins, id), + // TODO Scope the client so that only exposedToBrowser configs are exposed + config: this.coreContext.dynamicConfigService.hasDefaultConfigs({ name: id }) + ? await dynamicConfigStartServices.getClient().getConfig( + { name: id }, + { + asyncLocalStorageContext: dynamicConfigStartServices.getAsyncLocalStore()!, + } + ) + : await this.getUiConfig(uiPlugins, id), })) ), legacyMetadata: { diff --git a/src/core/server/rendering/types.ts b/src/core/server/rendering/types.ts index 45821c2b8228..15e6af4c83f4 100644 --- a/src/core/server/rendering/types.ts +++ b/src/core/server/rendering/types.ts @@ -31,7 +31,7 @@ import { i18n } from '@osd/i18n'; import { Branding } from 'src/core/types'; -import { EnvironmentMode, PackageInfo } from '../config'; +import { EnvironmentMode, InternalDynamicConfigServiceSetup, PackageInfo } from '../config'; import { ICspConfig } from '../csp'; import { InternalHttpServiceSetup, OpenSearchDashboardsRequest, LegacyRequest } from '../http'; import { UiPlugins, DiscoveredPlugin } from '../plugins'; @@ -84,6 +84,7 @@ export interface RenderingSetupDeps { http: InternalHttpServiceSetup; status: InternalStatusServiceSetup; uiPlugins: UiPlugins; + dynamicConfig: InternalDynamicConfigServiceSetup; } /** @public */ diff --git a/src/core/server/saved_objects/routes/integration_tests/bulk_create.test.ts b/src/core/server/saved_objects/routes/integration_tests/bulk_create.test.ts index 6fcd62372d8d..f082d1a743e4 100644 --- a/src/core/server/saved_objects/routes/integration_tests/bulk_create.test.ts +++ b/src/core/server/saved_objects/routes/integration_tests/bulk_create.test.ts @@ -33,6 +33,7 @@ import { UnwrapPromise } from '@osd/utility-types'; import { registerBulkCreateRoute } from '../bulk_create'; import { savedObjectsClientMock } from '../../../../../core/server/mocks'; import { setupServer } from '../test_utils'; +import { dynamicConfigServiceMock } from '../../../config/dynamic_config_service.mock'; type SetupServerReturn = UnwrapPromise>; @@ -50,7 +51,8 @@ describe('POST /api/saved_objects/_bulk_create', () => { const router = httpSetup.createRouter('/api/saved_objects/'); registerBulkCreateRoute(router); - await server.start(); + const dynamicConfigService = dynamicConfigServiceMock.createInternalStartContract(); + await server.start({ dynamicConfigService }); }); afterEach(async () => { diff --git a/src/core/server/saved_objects/routes/integration_tests/bulk_get.test.ts b/src/core/server/saved_objects/routes/integration_tests/bulk_get.test.ts index e3fb8dc3e823..4fd6aabe188c 100644 --- a/src/core/server/saved_objects/routes/integration_tests/bulk_get.test.ts +++ b/src/core/server/saved_objects/routes/integration_tests/bulk_get.test.ts @@ -33,6 +33,7 @@ import { UnwrapPromise } from '@osd/utility-types'; import { registerBulkGetRoute } from '../bulk_get'; import { savedObjectsClientMock } from '../../../../../core/server/mocks'; import { setupServer } from '../test_utils'; +import { dynamicConfigServiceMock } from '../../../config/dynamic_config_service.mock'; type SetupServerReturn = UnwrapPromise>; @@ -52,7 +53,8 @@ describe('POST /api/saved_objects/_bulk_get', () => { const router = httpSetup.createRouter('/api/saved_objects/'); registerBulkGetRoute(router); - await server.start(); + const dynamicConfigService = dynamicConfigServiceMock.createInternalStartContract(); + await server.start({ dynamicConfigService }); }); afterEach(async () => { diff --git a/src/core/server/saved_objects/routes/integration_tests/bulk_update.test.ts b/src/core/server/saved_objects/routes/integration_tests/bulk_update.test.ts index 1af732e39d1b..b21999cb0091 100644 --- a/src/core/server/saved_objects/routes/integration_tests/bulk_update.test.ts +++ b/src/core/server/saved_objects/routes/integration_tests/bulk_update.test.ts @@ -33,6 +33,7 @@ import { UnwrapPromise } from '@osd/utility-types'; import { registerBulkUpdateRoute } from '../bulk_update'; import { savedObjectsClientMock } from '../../../../../core/server/mocks'; import { setupServer } from '../test_utils'; +import { dynamicConfigServiceMock } from '../../../config/dynamic_config_service.mock'; type SetupServerReturn = UnwrapPromise>; @@ -49,7 +50,8 @@ describe('PUT /api/saved_objects/_bulk_update', () => { const router = httpSetup.createRouter('/api/saved_objects/'); registerBulkUpdateRoute(router); - await server.start(); + const dynamicConfigService = dynamicConfigServiceMock.createInternalStartContract(); + await server.start({ dynamicConfigService }); }); afterEach(async () => { diff --git a/src/core/server/saved_objects/routes/integration_tests/create.test.ts b/src/core/server/saved_objects/routes/integration_tests/create.test.ts index 6348a6f7901d..5272b038b279 100644 --- a/src/core/server/saved_objects/routes/integration_tests/create.test.ts +++ b/src/core/server/saved_objects/routes/integration_tests/create.test.ts @@ -33,6 +33,7 @@ import { UnwrapPromise } from '@osd/utility-types'; import { registerCreateRoute } from '../create'; import { savedObjectsClientMock } from '../../service/saved_objects_client.mock'; import { setupServer } from '../test_utils'; +import { dynamicConfigServiceMock } from '../../../config/dynamic_config_service.mock'; type SetupServerReturn = UnwrapPromise>; @@ -59,7 +60,8 @@ describe('POST /api/saved_objects/{type}', () => { const router = httpSetup.createRouter('/api/saved_objects/'); registerCreateRoute(router); - await server.start(); + const dynamicConfigService = dynamicConfigServiceMock.createInternalStartContract(); + await server.start({ dynamicConfigService }); }); afterEach(async () => { diff --git a/src/core/server/saved_objects/routes/integration_tests/delete.test.ts b/src/core/server/saved_objects/routes/integration_tests/delete.test.ts index 6f8da59348e9..c4c23cb6e74d 100644 --- a/src/core/server/saved_objects/routes/integration_tests/delete.test.ts +++ b/src/core/server/saved_objects/routes/integration_tests/delete.test.ts @@ -33,6 +33,7 @@ import { UnwrapPromise } from '@osd/utility-types'; import { registerDeleteRoute } from '../delete'; import { savedObjectsClientMock } from '../../../../../core/server/mocks'; import { setupServer } from '../test_utils'; +import { dynamicConfigServiceMock } from '../../../config/dynamic_config_service.mock'; type SetupServerReturn = UnwrapPromise>; @@ -49,7 +50,8 @@ describe('DELETE /api/saved_objects/{type}/{id}', () => { const router = httpSetup.createRouter('/api/saved_objects/'); registerDeleteRoute(router); - await server.start(); + const dynamicConfigService = dynamicConfigServiceMock.createInternalStartContract(); + await server.start({ dynamicConfigService }); }); afterEach(async () => { diff --git a/src/core/server/saved_objects/routes/integration_tests/export.test.ts b/src/core/server/saved_objects/routes/integration_tests/export.test.ts index 1b40a1942457..a313aa505e25 100644 --- a/src/core/server/saved_objects/routes/integration_tests/export.test.ts +++ b/src/core/server/saved_objects/routes/integration_tests/export.test.ts @@ -28,6 +28,8 @@ * under the License. */ +import { dynamicConfigServiceMock } from '../../../config/dynamic_config_service.mock'; + jest.mock('../../export', () => ({ exportSavedObjectsToStream: jest.fn(), })); @@ -62,7 +64,8 @@ describe('POST /api/saved_objects/_export', () => { const router = httpSetup.createRouter('/api/saved_objects/'); registerExportRoute(router, config); - await server.start(); + const dynamicConfigService = dynamicConfigServiceMock.createInternalStartContract(); + await server.start({ dynamicConfigService }); }); afterEach(async () => { diff --git a/src/core/server/saved_objects/routes/integration_tests/find.test.ts b/src/core/server/saved_objects/routes/integration_tests/find.test.ts index fc21eefed434..292f22cde1b3 100644 --- a/src/core/server/saved_objects/routes/integration_tests/find.test.ts +++ b/src/core/server/saved_objects/routes/integration_tests/find.test.ts @@ -35,6 +35,7 @@ import { UnwrapPromise } from '@osd/utility-types'; import { registerFindRoute } from '../find'; import { savedObjectsClientMock } from '../../../../../core/server/mocks'; import { setupServer } from '../test_utils'; +import { dynamicConfigServiceMock } from '../../../config/dynamic_config_service.mock'; type SetupServerReturn = UnwrapPromise>; @@ -60,7 +61,8 @@ describe('GET /api/saved_objects/_find', () => { const router = httpSetup.createRouter('/api/saved_objects/'); registerFindRoute(router); - await server.start(); + const dynamicConfigService = dynamicConfigServiceMock.createInternalStartContract(); + await server.start({ dynamicConfigService }); }); afterEach(async () => { diff --git a/src/core/server/saved_objects/routes/integration_tests/get.test.ts b/src/core/server/saved_objects/routes/integration_tests/get.test.ts index 9b41c0b61005..167adc9b73ed 100644 --- a/src/core/server/saved_objects/routes/integration_tests/get.test.ts +++ b/src/core/server/saved_objects/routes/integration_tests/get.test.ts @@ -35,6 +35,7 @@ import { savedObjectsClientMock } from '../../service/saved_objects_client.mock' import { HttpService, InternalHttpServiceSetup } from '../../../http'; import { createHttpServer, createCoreContext } from '../../../http/test_utils'; import { coreMock } from '../../../mocks'; +import { dynamicConfigServiceMock } from '../../../config/dynamic_config_service.mock'; const coreId = Symbol('core'); @@ -63,7 +64,8 @@ describe('GET /api/saved_objects/{type}/{id}', () => { const router = httpSetup.createRouter('/api/saved_objects/'); registerGetRoute(router); - await server.start(); + const dynamicConfigService = dynamicConfigServiceMock.createInternalStartContract(); + await server.start({ dynamicConfigService }); }); afterEach(async () => { diff --git a/src/core/server/saved_objects/routes/integration_tests/import.test.ts b/src/core/server/saved_objects/routes/integration_tests/import.test.ts index 1c51234173ae..fde41390b505 100644 --- a/src/core/server/saved_objects/routes/integration_tests/import.test.ts +++ b/src/core/server/saved_objects/routes/integration_tests/import.test.ts @@ -36,6 +36,7 @@ import { savedObjectsClientMock } from '../../../../../core/server/mocks'; import { SavedObjectConfig } from '../../saved_objects_config'; import { setupServer, createExportableType } from '../test_utils'; import { SavedObjectsErrorHelpers } from '../..'; +import { dynamicConfigServiceMock } from '../../../config/dynamic_config_service.mock'; type SetupServerReturn = UnwrapPromise>; @@ -84,7 +85,8 @@ describe(`POST ${URL}`, () => { const router = httpSetup.createRouter('/internal/saved_objects/'); registerImportRoute(router, config); - await server.start(); + const dynamicConfigService = dynamicConfigServiceMock.createInternalStartContract(); + await server.start({ dynamicConfigService }); }); afterEach(async () => { diff --git a/src/core/server/saved_objects/routes/integration_tests/log_legacy_import.test.ts b/src/core/server/saved_objects/routes/integration_tests/log_legacy_import.test.ts index d79eae3e69b6..7f0ce4e20bd2 100644 --- a/src/core/server/saved_objects/routes/integration_tests/log_legacy_import.test.ts +++ b/src/core/server/saved_objects/routes/integration_tests/log_legacy_import.test.ts @@ -33,6 +33,7 @@ import { UnwrapPromise } from '@osd/utility-types'; import { registerLogLegacyImportRoute } from '../log_legacy_import'; import { loggingSystemMock } from '../../../logging/logging_system.mock'; import { setupServer } from '../test_utils'; +import { dynamicConfigServiceMock } from '../../../config/dynamic_config_service.mock'; type SetupServerReturn = UnwrapPromise>; @@ -48,7 +49,8 @@ describe('POST /api/saved_objects/_log_legacy_import', () => { const router = httpSetup.createRouter('/api/saved_objects/'); registerLogLegacyImportRoute(router, logger); - await server.start(); + const dynamicConfigService = dynamicConfigServiceMock.createInternalStartContract(); + await server.start({ dynamicConfigService }); }); afterEach(async () => { diff --git a/src/core/server/saved_objects/routes/integration_tests/resolve_import_errors.test.ts b/src/core/server/saved_objects/routes/integration_tests/resolve_import_errors.test.ts index c8559133f9cd..4fa22c85f794 100644 --- a/src/core/server/saved_objects/routes/integration_tests/resolve_import_errors.test.ts +++ b/src/core/server/saved_objects/routes/integration_tests/resolve_import_errors.test.ts @@ -35,6 +35,7 @@ import { registerResolveImportErrorsRoute } from '../resolve_import_errors'; import { savedObjectsClientMock } from '../../../../../core/server/mocks'; import { setupServer, createExportableType } from '../test_utils'; import { SavedObjectConfig } from '../../saved_objects_config'; +import { dynamicConfigServiceMock } from '../../../config/dynamic_config_service.mock'; type SetupServerReturn = UnwrapPromise>; @@ -89,7 +90,8 @@ describe(`POST ${URL}`, () => { const router = httpSetup.createRouter('/api/saved_objects/'); registerResolveImportErrorsRoute(router, config); - await server.start(); + const dynamicConfigService = dynamicConfigServiceMock.createInternalStartContract(); + await server.start({ dynamicConfigService }); }); afterEach(async () => { diff --git a/src/core/server/saved_objects/routes/integration_tests/update.test.ts b/src/core/server/saved_objects/routes/integration_tests/update.test.ts index 6b6c1795bbbf..7619649ddc8e 100644 --- a/src/core/server/saved_objects/routes/integration_tests/update.test.ts +++ b/src/core/server/saved_objects/routes/integration_tests/update.test.ts @@ -33,6 +33,7 @@ import { UnwrapPromise } from '@osd/utility-types'; import { registerUpdateRoute } from '../update'; import { savedObjectsClientMock } from '../../../../../core/server/mocks'; import { setupServer } from '../test_utils'; +import { dynamicConfigServiceMock } from '../../../config/dynamic_config_service.mock'; type SetupServerReturn = UnwrapPromise>; @@ -60,7 +61,8 @@ describe('PUT /api/saved_objects/{type}/{id?}', () => { const router = httpSetup.createRouter('/api/saved_objects/'); registerUpdateRoute(router); - await server.start(); + const dynamicConfigService = dynamicConfigServiceMock.createInternalStartContract(); + await server.start({ dynamicConfigService }); }); afterEach(async () => { diff --git a/src/core/server/server.test.mocks.ts b/src/core/server/server.test.mocks.ts index ef93122bc6c7..bb44485cf61a 100644 --- a/src/core/server/server.test.mocks.ts +++ b/src/core/server/server.test.mocks.ts @@ -117,3 +117,9 @@ export const mockAuditTrailService = auditTrailServiceMock.create(); jest.doMock('./audit_trail/audit_trail_service', () => ({ AuditTrailService: jest.fn(() => mockAuditTrailService), })); + +import { dynamicConfigServiceMock } from './config/dynamic_config_service.mock'; +export const mockDynamicConfigService = dynamicConfigServiceMock.create(); +jest.doMock('./config/dynamic_config_service', () => ({ + DynamicConfigService: jest.fn(() => mockDynamicConfigService), +})); diff --git a/src/core/server/server.test.ts b/src/core/server/server.test.ts index 71789d76c337..87739a4aa3f6 100644 --- a/src/core/server/server.test.ts +++ b/src/core/server/server.test.ts @@ -43,6 +43,7 @@ import { mockStatusService, mockLoggingService, mockAuditTrailService, + mockDynamicConfigService, } from './server.test.mocks'; import { BehaviorSubject } from 'rxjs'; @@ -84,6 +85,7 @@ test('sets up services on "setup"', async () => { expect(mockStatusService.setup).not.toHaveBeenCalled(); expect(mockLoggingService.setup).not.toHaveBeenCalled(); expect(mockAuditTrailService.setup).not.toHaveBeenCalled(); + expect(mockDynamicConfigService.setup).not.toHaveBeenCalled(); await server.setup(); @@ -98,6 +100,7 @@ test('sets up services on "setup"', async () => { expect(mockStatusService.setup).toHaveBeenCalledTimes(1); expect(mockLoggingService.setup).toHaveBeenCalledTimes(1); expect(mockAuditTrailService.setup).toHaveBeenCalledTimes(1); + expect(mockDynamicConfigService.setup).toHaveBeenCalledTimes(1); }); test('injects legacy dependency to context#setup()', async () => { @@ -140,6 +143,7 @@ test('runs services on "start"', async () => { expect(mockUiSettingsService.start).not.toHaveBeenCalled(); expect(mockMetricsService.start).not.toHaveBeenCalled(); expect(mockAuditTrailService.start).not.toHaveBeenCalled(); + expect(mockDynamicConfigService.start).not.toHaveBeenCalled(); await server.start(); @@ -149,6 +153,7 @@ test('runs services on "start"', async () => { expect(mockUiSettingsService.start).toHaveBeenCalledTimes(1); expect(mockMetricsService.start).toHaveBeenCalledTimes(1); expect(mockAuditTrailService.start).toHaveBeenCalledTimes(1); + expect(mockDynamicConfigService.start).toHaveBeenCalledTimes(1); }); test('does not fail on "setup" if there are unused paths detected', async () => { @@ -174,6 +179,7 @@ test('stops services on "stop"', async () => { expect(mockStatusService.stop).not.toHaveBeenCalled(); expect(mockLoggingService.stop).not.toHaveBeenCalled(); expect(mockAuditTrailService.stop).not.toHaveBeenCalled(); + expect(mockDynamicConfigService.stop).not.toHaveBeenCalled(); await server.stop(); @@ -205,6 +211,7 @@ test(`doesn't setup core services if config validation fails`, async () => { expect(mockMetricsService.setup).not.toHaveBeenCalled(); expect(mockStatusService.setup).not.toHaveBeenCalled(); expect(mockLoggingService.setup).not.toHaveBeenCalled(); + expect(mockDynamicConfigService.setup).not.toHaveBeenCalled(); }); test(`doesn't setup core services if legacy config validation fails`, async () => { @@ -227,4 +234,5 @@ test(`doesn't setup core services if legacy config validation fails`, async () = expect(mockMetricsService.setup).not.toHaveBeenCalled(); expect(mockStatusService.setup).not.toHaveBeenCalled(); expect(mockLoggingService.setup).not.toHaveBeenCalled(); + expect(mockDynamicConfigService.setup).not.toHaveBeenCalled(); }); diff --git a/src/core/server/server.ts b/src/core/server/server.ts index b76ec61cebd0..1b2e5f05679e 100644 --- a/src/core/server/server.ts +++ b/src/core/server/server.ts @@ -64,17 +64,20 @@ import { config as opensearchDashboardsConfig } from './opensearch_dashboards_co import { savedObjectsConfig, savedObjectsMigrationConfig } from './saved_objects'; import { config as uiSettingsConfig } from './ui_settings'; import { config as statusConfig } from './status'; +import { config as dynamicConfigServiceConfig } from './config'; import { ContextService } from './context'; import { RequestHandlerContext } from '.'; import { InternalCoreSetup, InternalCoreStart, ServiceConfigDescriptor } from './internal_types'; import { CoreUsageDataService } from './core_usage_data'; import { CoreRouteHandlerContext } from './core_route_handler_context'; +import { DynamicConfigService } from './config/dynamic_config_service'; const coreId = Symbol('core'); const rootConfigPath = ''; export class Server { public readonly configService: ConfigService; + public readonly dynamicConfigService: DynamicConfigService; private readonly capabilities: CapabilitiesService; private readonly context: ContextService; private readonly opensearch: OpenSearchService; @@ -110,8 +113,15 @@ export class Server { this.logger = this.loggingSystem.asLoggerFactory(); this.log = this.logger.get('server'); this.configService = new ConfigService(rawConfigProvider, env, this.logger); + this.dynamicConfigService = new DynamicConfigService(this.configService, env, this.logger); - const core = { coreId, configService: this.configService, env, logger: this.logger }; + const core = { + coreId, + configService: this.configService, + dynamicConfigService: this.dynamicConfigService, + env, + logger: this.logger, + }; this.context = new ContextService(core); this.http = new HttpService(core); this.rendering = new RenderingService(core); @@ -151,6 +161,9 @@ export class Server { await this.configService.validate(); await ensureValidConfiguration(this.configService, legacyConfigSetup); + // Once the configs have been validated, setup the dynamic config as schemas have also been verified + const dynamicConfigServiceSetup = await this.dynamicConfigService.setup(); + const contextServiceSetup = this.context.setup({ // We inject a fake "legacy plugin" with dependencies on every plugin so that legacy plugins: // 1) Can access context from any KP plugin @@ -168,6 +181,9 @@ export class Server { context: contextServiceSetup, }); + // Once http is setup, register routes and async local storage + await this.dynamicConfigService.registerRoutesAndHandlers({ http: httpSetup }); + const capabilitiesSetup = this.capabilities.setup({ http: httpSetup }); const opensearchServiceSetup = await this.opensearch.setup({ @@ -199,6 +215,7 @@ export class Server { http: httpSetup, status: statusSetup, uiPlugins, + dynamicConfig: dynamicConfigServiceSetup, }); const httpResourcesSetup = this.httpResources.setup({ @@ -229,6 +246,7 @@ export class Server { logging: loggingSetup, metrics: metricsSetup, security: securitySetup, + dynamicConfig: dynamicConfigServiceSetup, }; const pluginsSetup = await this.plugins.setup(coreSetup); @@ -256,6 +274,9 @@ export class Server { const opensearchStart = await this.opensearch.start({ auditTrail: auditTrailStart, }); + const dynamicConfigServiceStart = await this.dynamicConfigService.start({ + opensearch: opensearchStart, + }); const soStartSpan = startTransaction?.startSpan('saved_objects.migration', 'migration'); const savedObjectsStart = await this.savedObjects.start({ opensearch: opensearchStart, @@ -286,6 +307,7 @@ export class Server { auditTrail: auditTrailStart, coreUsageData: coreUsageDataStart, crossCompatibility: crossCompatibilityServiceStart, + dynamicConfig: dynamicConfigServiceStart, }; const pluginsStart = await this.plugins.start(this.coreStart); @@ -298,7 +320,7 @@ export class Server { plugins: mapToObject(pluginsStart.contracts), }); - await this.http.start(); + await this.http.start({ dynamicConfigService: dynamicConfigServiceStart }); await this.security.start(); @@ -349,6 +371,7 @@ export class Server { opsConfig, statusConfig, pidConfig, + dynamicConfigServiceConfig, ]; this.configService.addDeprecationProvider(rootConfigPath, coreDeprecationProvider); @@ -357,6 +380,7 @@ export class Server { this.configService.addDeprecationProvider(descriptor.path, descriptor.deprecations); } await this.configService.setSchema(descriptor.path, descriptor.schema); + this.dynamicConfigService.setSchema(descriptor.path, descriptor.schema); } } } diff --git a/src/core/server/status/routes/integration_tests/status.test.ts b/src/core/server/status/routes/integration_tests/status.test.ts index 1045ca939c05..f01bc748a289 100644 --- a/src/core/server/status/routes/integration_tests/status.test.ts +++ b/src/core/server/status/routes/integration_tests/status.test.ts @@ -42,6 +42,7 @@ import { HttpService, InternalHttpServiceSetup } from '../../../http'; import { registerStatusRoute } from '../status'; import { ServiceStatus, ServiceStatusLevels } from '../../types'; import { statusServiceMock } from '../../status_service.mock'; +import { dynamicConfigServiceMock } from '../../../config/dynamic_config_service.mock'; const coreId = Symbol('core'); @@ -100,7 +101,8 @@ describe('GET /api/status', () => { } }); - await server.start(); + const dynamicConfigService = dynamicConfigServiceMock.createInternalStartContract(); + await server.start({ dynamicConfigService }); }; afterEach(async () => { diff --git a/src/core/test_helpers/osd_server.ts b/src/core/test_helpers/osd_server.ts index 8f03ac949842..ff98a49f6999 100644 --- a/src/core/test_helpers/osd_server.ts +++ b/src/core/test_helpers/osd_server.ts @@ -61,6 +61,7 @@ const DEFAULTS_SETTINGS = { logging: { silent: true }, plugins: {}, migrations: { skip: true }, + dynamic_config_service: { enabled: false }, }; const DEFAULT_SETTINGS_WITH_CORE_PLUGINS = { diff --git a/src/plugins/csp_handler/server/plugin.ts b/src/plugins/csp_handler/server/plugin.ts index 9f4094262452..46dc8ef544cf 100644 --- a/src/plugins/csp_handler/server/plugin.ts +++ b/src/plugins/csp_handler/server/plugin.ts @@ -22,14 +22,17 @@ export class CspHandlerPlugin implements Plugin>; @@ -31,6 +32,7 @@ describe(`Fetch DataSource MetaData ${URL}`, () => { let dataSourceClient: ReturnType; let dataSourceServiceSetupMock: DataSourceServiceSetup; let authRegistryPromiseMock: Promise; + const dynamicConfigServiceStart = dynamicConfigServiceMock.createInternalStartContract(); const dataSourceAttr = { endpoint: 'https://test.com', auth: { @@ -186,7 +188,7 @@ describe(`Fetch DataSource MetaData ${URL}`, () => { customApiSchemaRegistryPromise ); - await server.start(); + await server.start({ dynamicConfigService: dynamicConfigServiceStart }); }); afterEach(async () => { diff --git a/src/plugins/data_source/server/routes/test_connection.test.ts b/src/plugins/data_source/server/routes/test_connection.test.ts index cbe35ef6562a..7a3fa66cb694 100644 --- a/src/plugins/data_source/server/routes/test_connection.test.ts +++ b/src/plugins/data_source/server/routes/test_connection.test.ts @@ -16,6 +16,7 @@ import { registerTestConnectionRoute } from './test_connection'; import { AuthType } from '../../common/data_sources'; // eslint-disable-next-line @osd/eslint/no-restricted-paths import { opensearchClientMock } from '../../../../../src/core/server/opensearch/client/mocks'; +import { dynamicConfigServiceMock } from '../../../../../src/core/server/mocks'; type SetupServerReturn = UnwrapPromise>; @@ -41,6 +42,7 @@ describe(`Test connection ${URL}`, () => { }, }, }; + const dynamicConfigServiceStart = dynamicConfigServiceMock.createInternalStartContract(); const dataSourceAttrMissingCredentialForNoAuth = { endpoint: 'https://test.com', @@ -175,7 +177,7 @@ describe(`Test connection ${URL}`, () => { customApiSchemaRegistryPromise ); - await server.start(); + await server.start({ dynamicConfigService: dynamicConfigServiceStart }); }); afterEach(async () => { diff --git a/src/plugins/usage_collection/server/routes/integration_tests/stats.test.ts b/src/plugins/usage_collection/server/routes/integration_tests/stats.test.ts index b6efd91674d1..705de44afb26 100644 --- a/src/plugins/usage_collection/server/routes/integration_tests/stats.test.ts +++ b/src/plugins/usage_collection/server/routes/integration_tests/stats.test.ts @@ -45,6 +45,7 @@ import { createHttpServer } from '../../../../../core/server/test_utils'; import { registerStatsRoute } from '../stats'; import supertest from 'supertest'; import { CollectorSet } from '../../collector'; +import { dynamicConfigServiceMock } from '../../../../../core/server/mocks'; type HttpService = ReturnType; type HttpSetup = UnwrapPromise>; @@ -54,6 +55,7 @@ describe('/api/stats', () => { let httpSetup: HttpSetup; let overallStatus$: BehaviorSubject; let metrics: MetricsServiceSetup; + const mockDynamicConfigService = dynamicConfigServiceMock.createInternalStartContract(); beforeEach(async () => { server = createHttpServer(); @@ -87,7 +89,7 @@ describe('/api/stats', () => { overallStatus$, }); - await server.start(); + await server.start({ dynamicConfigService: mockDynamicConfigService }); }); afterEach(async () => { diff --git a/src/plugins/workspace/server/integration_tests/duplicate.test.ts b/src/plugins/workspace/server/integration_tests/duplicate.test.ts index e994586c631c..777d9d4f224b 100644 --- a/src/plugins/workspace/server/integration_tests/duplicate.test.ts +++ b/src/plugins/workspace/server/integration_tests/duplicate.test.ts @@ -12,6 +12,7 @@ import { setupServer } from '../../../../core/server/test_utils'; import { registerDuplicateRoute } from '../routes/duplicate'; import { createListStream } from '../../../../core/server/utils/streams'; import Boom from '@hapi/boom'; +import { dynamicConfigServiceMock } from '../../../../core/server/mocks'; jest.mock('../../../../core/server/saved_objects/export', () => ({ exportSavedObjectsToStream: jest.fn(), @@ -84,6 +85,7 @@ describe(`duplicate saved objects among workspaces`, () => { attributes: { title: 'Look at my dashboard' }, references: [], }; + const mockDynamicConfigService = dynamicConfigServiceMock.createInternalStartContract(); beforeEach(async () => { ({ server, httpSetup, handlerContext } = await setupServer()); @@ -104,7 +106,7 @@ describe(`duplicate saved objects among workspaces`, () => { registerDuplicateRoute(router, logger.get(), clientMock, 10000); - await server.start(); + await server.start({ dynamicConfigService: mockDynamicConfigService }); }); afterEach(async () => {