From c7b900bc1a6363901d8d73d2bb9cbf60d21c2872 Mon Sep 17 00:00:00 2001 From: Pierre Gayvallet Date: Thu, 28 Jan 2021 13:51:46 +0100 Subject: [PATCH] add synchronous config access API (#88981) (#89542) * add synchronous config accessor * add `config.get` to plugin context and add tsdoc * remove useless markAsHandled calls * fix mocks * update generated docs * fix unit tests * add sync accessor for legacy config --- ...-server.plugininitializercontext.config.md | 5 +- ...-server.plugininitializercontext.logger.md | 21 +++ ...in-core-server.plugininitializercontext.md | 4 +- .../kbn-config/src/config_service.mock.ts | 3 +- .../kbn-config/src/config_service.test.ts | 112 ++++++++------ packages/kbn-config/src/config_service.ts | 63 ++++---- .../legacy/legacy_object_to_config_adapter.ts | 2 +- src/core/server/mocks.ts | 7 +- src/core/server/plugins/legacy_config.test.ts | 82 ++++++++++ src/core/server/plugins/legacy_config.ts | 69 +++++++++ .../server/plugins/plugin_context.test.ts | 142 ++++++++++++------ src/core/server/plugins/plugin_context.ts | 50 +----- src/core/server/plugins/plugins_service.ts | 5 +- src/core/server/plugins/types.ts | 90 ++++++++++- src/core/server/root/index.ts | 2 +- src/core/server/server.api.md | 7 +- src/core/server/server.ts | 4 +- 17 files changed, 478 insertions(+), 190 deletions(-) create mode 100644 src/core/server/plugins/legacy_config.test.ts create mode 100644 src/core/server/plugins/legacy_config.ts diff --git a/docs/development/core/server/kibana-plugin-core-server.plugininitializercontext.config.md b/docs/development/core/server/kibana-plugin-core-server.plugininitializercontext.config.md index 4ab0cb74f809f..3b5754eb4fa39 100644 --- a/docs/development/core/server/kibana-plugin-core-server.plugininitializercontext.config.md +++ b/docs/development/core/server/kibana-plugin-core-server.plugininitializercontext.config.md @@ -4,14 +4,17 @@ ## PluginInitializerContext.config property +Accessors for the plugin's configuration + Signature: ```typescript config: { legacy: { globalConfig$: Observable; + get: () => SharedGlobalConfig; }; create: () => Observable; - createIfExists: () => Observable; + get: () => T; }; ``` diff --git a/docs/development/core/server/kibana-plugin-core-server.plugininitializercontext.logger.md b/docs/development/core/server/kibana-plugin-core-server.plugininitializercontext.logger.md index 106fdaad9bc22..e5de046eccf1d 100644 --- a/docs/development/core/server/kibana-plugin-core-server.plugininitializercontext.logger.md +++ b/docs/development/core/server/kibana-plugin-core-server.plugininitializercontext.logger.md @@ -4,8 +4,29 @@ ## PluginInitializerContext.logger property + instance already bound to the plugin's logging context + Signature: ```typescript logger: LoggerFactory; ``` + +## Example + + +```typescript +// plugins/my-plugin/server/plugin.ts +// "id: myPlugin" in `plugins/my-plugin/kibana.yaml` + +export class MyPlugin implements Plugin { + constructor(private readonly initContext: PluginInitializerContext) { + this.logger = initContext.logger.get(); + // `logger` context: `plugins.myPlugin` + this.mySubLogger = initContext.logger.get('sub'); // or this.logger.get('sub'); + // `mySubLogger` context: `plugins.myPlugin.sub` + } +} + +``` + diff --git a/docs/development/core/server/kibana-plugin-core-server.plugininitializercontext.md b/docs/development/core/server/kibana-plugin-core-server.plugininitializercontext.md index 18760170afa1f..90a19d53bd5e1 100644 --- a/docs/development/core/server/kibana-plugin-core-server.plugininitializercontext.md +++ b/docs/development/core/server/kibana-plugin-core-server.plugininitializercontext.md @@ -16,8 +16,8 @@ export interface PluginInitializerContext | Property | Type | Description | | --- | --- | --- | -| [config](./kibana-plugin-core-server.plugininitializercontext.config.md) | {
legacy: {
globalConfig$: Observable<SharedGlobalConfig>;
};
create: <T = ConfigSchema>() => Observable<T>;
createIfExists: <T = ConfigSchema>() => Observable<T | undefined>;
} | | +| [config](./kibana-plugin-core-server.plugininitializercontext.config.md) | {
legacy: {
globalConfig$: Observable<SharedGlobalConfig>;
get: () => SharedGlobalConfig;
};
create: <T = ConfigSchema>() => Observable<T>;
get: <T = ConfigSchema>() => T;
} | Accessors for the plugin's configuration | | [env](./kibana-plugin-core-server.plugininitializercontext.env.md) | {
mode: EnvironmentMode;
packageInfo: Readonly<PackageInfo>;
instanceUuid: string;
} | | -| [logger](./kibana-plugin-core-server.plugininitializercontext.logger.md) | LoggerFactory | | +| [logger](./kibana-plugin-core-server.plugininitializercontext.logger.md) | LoggerFactory | instance already bound to the plugin's logging context | | [opaqueId](./kibana-plugin-core-server.plugininitializercontext.opaqueid.md) | PluginOpaqueId | | diff --git a/packages/kbn-config/src/config_service.mock.ts b/packages/kbn-config/src/config_service.mock.ts index cd6f399ddcce2..59f788767004c 100644 --- a/packages/kbn-config/src/config_service.mock.ts +++ b/packages/kbn-config/src/config_service.mock.ts @@ -17,8 +17,8 @@ const createConfigServiceMock = ({ }: { atPath?: Record; getConfig$?: Record } = {}) => { const mocked: jest.Mocked = { atPath: jest.fn(), + atPathSync: jest.fn(), getConfig$: jest.fn(), - optionalAtPath: jest.fn(), getUsedPaths: jest.fn(), getUnusedPaths: jest.fn(), isEnabledAtPath: jest.fn(), @@ -27,6 +27,7 @@ const createConfigServiceMock = ({ validate: jest.fn(), }; mocked.atPath.mockReturnValue(new BehaviorSubject(atPath)); + mocked.atPathSync.mockReturnValue(atPath); mocked.getConfig$.mockReturnValue(new BehaviorSubject(new ObjectToConfigAdapter(getConfig$))); mocked.getUsedPaths.mockResolvedValue([]); mocked.getUnusedPaths.mockResolvedValue([]); diff --git a/packages/kbn-config/src/config_service.test.ts b/packages/kbn-config/src/config_service.test.ts index 96d1f794a691c..e55916d7d348c 100644 --- a/packages/kbn-config/src/config_service.test.ts +++ b/packages/kbn-config/src/config_service.test.ts @@ -105,27 +105,6 @@ test('re-validate config when updated', async () => { `); }); -test("returns undefined if fetching optional config at a path that doesn't exist", async () => { - const rawConfig = getRawConfigProvider({}); - const configService = new ConfigService(rawConfig, defaultEnv, logger); - - const value$ = configService.optionalAtPath('unique-name'); - const value = await value$.pipe(first()).toPromise(); - - expect(value).toBeUndefined(); -}); - -test('returns observable config at optional path if it exists', async () => { - const rawConfig = getRawConfigProvider({ value: 'bar' }); - const configService = new ConfigService(rawConfig, defaultEnv, logger); - await configService.setSchema('value', schema.string()); - - const value$ = configService.optionalAtPath('value'); - const value: any = await value$.pipe(first()).toPromise(); - - expect(value).toBe('bar'); -}); - test("does not push new configs when reloading if config at path hasn't changed", async () => { const rawConfig$ = new BehaviorSubject>({ key: 'value' }); const rawConfigProvider = rawConfigServiceMock.create({ rawConfig$ }); @@ -209,34 +188,38 @@ test('flags schema paths as handled when registering a schema', async () => { test('tracks unhandled paths', async () => { const initialConfig = { - bar: { - deep1: { - key: '123', - }, - deep2: { - key: '321', - }, + service: { + string: 'str', + number: 42, }, - foo: 'value', - quux: { - deep1: { - key: 'hello', - }, - deep2: { - key: 'world', - }, + plugin: { + foo: 'bar', + }, + unknown: { + hello: 'dolly', + number: 9000, }, }; const rawConfigProvider = rawConfigServiceMock.create({ rawConfig: initialConfig }); const configService = new ConfigService(rawConfigProvider, defaultEnv, logger); - - configService.atPath('foo'); - configService.atPath(['bar', 'deep2']); + await configService.setSchema( + 'service', + schema.object({ + string: schema.string(), + number: schema.number(), + }) + ); + await configService.setSchema( + 'plugin', + schema.object({ + foo: schema.string(), + }) + ); const unused = await configService.getUnusedPaths(); - expect(unused).toEqual(['bar.deep1.key', 'quux.deep1.key', 'quux.deep2.key']); + expect(unused).toEqual(['unknown.hello', 'unknown.number']); }); test('correctly passes context', async () => { @@ -339,22 +322,18 @@ test('does not throw if schema does not define "enabled" schema', async () => { const rawConfigProvider = rawConfigServiceMock.create({ rawConfig: initialConfig }); const configService = new ConfigService(rawConfigProvider, defaultEnv, logger); - await expect( + expect( configService.setSchema( 'pid', schema.object({ file: schema.string(), }) ) - ).resolves.toBeUndefined(); + ).toBeUndefined(); const value$ = configService.atPath('pid'); const value: any = await value$.pipe(first()).toPromise(); expect(value.enabled).toBe(undefined); - - const valueOptional$ = configService.optionalAtPath('pid'); - const valueOptional: any = await valueOptional$.pipe(first()).toPromise(); - expect(valueOptional.enabled).toBe(undefined); }); test('treats config as enabled if config path is not present in config', async () => { @@ -457,3 +436,44 @@ test('logs deprecation warning during validation', async () => { ] `); }); + +describe('atPathSync', () => { + test('returns the value at path', async () => { + const rawConfig = getRawConfigProvider({ key: 'foo' }); + const configService = new ConfigService(rawConfig, defaultEnv, logger); + const stringSchema = schema.string(); + await configService.setSchema('key', stringSchema); + + await configService.validate(); + + const value = configService.atPathSync('key'); + expect(value).toBe('foo'); + }); + + test('throws if called before `validate`', async () => { + const rawConfig = getRawConfigProvider({ key: 'foo' }); + const configService = new ConfigService(rawConfig, defaultEnv, logger); + const stringSchema = schema.string(); + await configService.setSchema('key', stringSchema); + + expect(() => configService.atPathSync('key')).toThrowErrorMatchingInlineSnapshot( + `"\`atPathSync\` called before config was validated"` + ); + }); + + test('returns the last config value', async () => { + const rawConfig$ = new BehaviorSubject>({ key: 'value' }); + const rawConfigProvider = rawConfigServiceMock.create({ rawConfig$ }); + + const configService = new ConfigService(rawConfigProvider, defaultEnv, logger); + await configService.setSchema('key', schema.string()); + + await configService.validate(); + + expect(configService.atPathSync('key')).toEqual('value'); + + rawConfig$.next({ key: 'new-value' }); + + expect(configService.atPathSync('key')).toEqual('new-value'); + }); +}); diff --git a/packages/kbn-config/src/config_service.ts b/packages/kbn-config/src/config_service.ts index 9518279f35766..929735ffc15f2 100644 --- a/packages/kbn-config/src/config_service.ts +++ b/packages/kbn-config/src/config_service.ts @@ -10,7 +10,7 @@ import type { PublicMethodsOf } from '@kbn/utility-types'; import { Type } from '@kbn/config-schema'; import { isEqual } from 'lodash'; import { BehaviorSubject, combineLatest, Observable } from 'rxjs'; -import { distinctUntilChanged, first, map, shareReplay, take } from 'rxjs/operators'; +import { distinctUntilChanged, first, map, shareReplay, take, tap } from 'rxjs/operators'; import { Logger, LoggerFactory } from '@kbn/logging'; import { Config, ConfigPath, Env } from '.'; @@ -32,13 +32,15 @@ export class ConfigService { private readonly log: Logger; private readonly deprecationLog: Logger; + private validated = false; private readonly config$: Observable; + private lastConfig?: Config; /** * Whenever a config if read at a path, we mark that path as 'handled'. We can * then list all unhandled config paths when the startup process is completed. */ - private readonly handledPaths: ConfigPath[] = []; + private readonly handledPaths: Set = new Set(); private readonly schemas = new Map>(); private readonly deprecations = new BehaviorSubject([]); @@ -55,6 +57,9 @@ export class ConfigService { const migrated = applyDeprecations(rawConfig, deprecations); return new LegacyObjectToConfigAdapter(migrated); }), + tap((config) => { + this.lastConfig = config; + }), shareReplay(1) ); } @@ -62,7 +67,7 @@ export class ConfigService { /** * Set config schema for a path and performs its validation */ - public async setSchema(path: ConfigPath, schema: Type) { + public setSchema(path: ConfigPath, schema: Type) { const namespace = pathToString(path); if (this.schemas.has(namespace)) { throw new Error(`Validation schema for [${path}] was already registered.`); @@ -94,15 +99,16 @@ export class ConfigService { public async validate() { const namespaces = [...this.schemas.keys()]; for (let i = 0; i < namespaces.length; i++) { - await this.validateConfigAtPath(namespaces[i]).pipe(first()).toPromise(); + await this.getValidatedConfigAtPath$(namespaces[i]).pipe(first()).toPromise(); } await this.logDeprecation(); + this.validated = true; } /** * Returns the full config object observable. This is not intended for - * "normal use", but for features that _need_ access to the full object. + * "normal use", but for internal features that _need_ access to the full object. */ public getConfig$() { return this.config$; @@ -110,27 +116,26 @@ export class ConfigService { /** * Reads the subset of the config at the specified `path` and validates it - * against the static `schema` on the given `ConfigClass`. + * against its registered schema. * * @param path - The path to the desired subset of the config. */ public atPath(path: ConfigPath) { - return this.validateConfigAtPath(path) as Observable; + return this.getValidatedConfigAtPath$(path) as Observable; } /** - * Same as `atPath`, but returns `undefined` if there is no config at the - * specified path. + * Similar to {@link atPath}, but return the last emitted value synchronously instead of an + * observable. * - * {@link ConfigService.atPath} + * @param path - The path to the desired subset of the config. */ - public optionalAtPath(path: ConfigPath) { - return this.getDistinctConfig(path).pipe( - map((config) => { - if (config === undefined) return undefined; - return this.validateAtPath(path, config) as TSchema; - }) - ); + public atPathSync(path: ConfigPath) { + if (!this.validated) { + throw new Error('`atPathSync` called before config was validated'); + } + const configAtPath = this.lastConfig!.get(path); + return this.validateAtPath(path, configAtPath) as TSchema; } public async isEnabledAtPath(path: ConfigPath) { @@ -144,10 +149,7 @@ export class ConfigService { const config = await this.config$.pipe(first()).toPromise(); // if plugin hasn't got a config schema, we try to read "enabled" directly - const isEnabled = - validatedConfig && validatedConfig.enabled !== undefined - ? validatedConfig.enabled - : config.get(enabledPath); + const isEnabled = validatedConfig?.enabled ?? config.get(enabledPath); // not declared. consider that plugin is enabled by default if (isEnabled === undefined) { @@ -170,15 +172,13 @@ export class ConfigService { public async getUnusedPaths() { const config = await this.config$.pipe(first()).toPromise(); - const handledPaths = this.handledPaths.map(pathToString); - + const handledPaths = [...this.handledPaths.values()].map(pathToString); return config.getFlattenedPaths().filter((path) => !isPathHandled(path, handledPaths)); } public async getUsedPaths() { const config = await this.config$.pipe(first()).toPromise(); - const handledPaths = this.handledPaths.map(pathToString); - + const handledPaths = [...this.handledPaths.values()].map(pathToString); return config.getFlattenedPaths().filter((path) => isPathHandled(path, handledPaths)); } @@ -210,22 +210,17 @@ export class ConfigService { ); } - private validateConfigAtPath(path: ConfigPath) { - return this.getDistinctConfig(path).pipe(map((config) => this.validateAtPath(path, config))); - } - - private getDistinctConfig(path: ConfigPath) { - this.markAsHandled(path); - + private getValidatedConfigAtPath$(path: ConfigPath) { return this.config$.pipe( map((config) => config.get(path)), - distinctUntilChanged(isEqual) + distinctUntilChanged(isEqual), + map((config) => this.validateAtPath(path, config)) ); } private markAsHandled(path: ConfigPath) { this.log.debug(`Marking config path as handled: ${path}`); - this.handledPaths.push(path); + this.handledPaths.add(path); } } diff --git a/packages/kbn-config/src/legacy/legacy_object_to_config_adapter.ts b/packages/kbn-config/src/legacy/legacy_object_to_config_adapter.ts index c037c5f0308c8..c12a147fddddc 100644 --- a/packages/kbn-config/src/legacy/legacy_object_to_config_adapter.ts +++ b/packages/kbn-config/src/legacy/legacy_object_to_config_adapter.ts @@ -84,7 +84,7 @@ export class LegacyObjectToConfigAdapter extends ObjectToConfigAdapter { }; } - private static transformPlugins(configValue: LegacyVars) { + private static transformPlugins(configValue: LegacyVars = {}) { // These properties are the only ones we use from the existing `plugins` config node // since `scanDirs` isn't respected by new platform plugin discovery. return { diff --git a/src/core/server/mocks.ts b/src/core/server/mocks.ts index 2d053300273fb..b86e2e4c6dedb 100644 --- a/src/core/server/mocks.ts +++ b/src/core/server/mocks.ts @@ -69,9 +69,12 @@ export function pluginInitializerContextConfigMock(config: T) { }; const mock: jest.Mocked['config']> = { - legacy: { globalConfig$: of(globalConfig) }, + legacy: { + globalConfig$: of(globalConfig), + get: () => globalConfig, + }, create: jest.fn().mockReturnValue(of(config)), - createIfExists: jest.fn().mockReturnValue(of(config)), + get: jest.fn().mockReturnValue(config), }; return mock; diff --git a/src/core/server/plugins/legacy_config.test.ts b/src/core/server/plugins/legacy_config.test.ts new file mode 100644 index 0000000000000..fd8234d72bd17 --- /dev/null +++ b/src/core/server/plugins/legacy_config.test.ts @@ -0,0 +1,82 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +import { take } from 'rxjs/operators'; +import { ConfigService, Env } from '@kbn/config'; +import { getEnvOptions, rawConfigServiceMock } from '../config/mocks'; +import { getGlobalConfig, getGlobalConfig$ } from './legacy_config'; +import { REPO_ROOT } from '@kbn/utils'; +import { loggingSystemMock } from '../logging/logging_system.mock'; +import { duration } from 'moment'; +import { fromRoot } from '../utils'; +import { ByteSizeValue } from '@kbn/config-schema'; +import { Server } from '../server'; + +describe('Legacy config', () => { + let env: Env; + let logger: ReturnType; + + beforeEach(() => { + env = Env.createDefault(REPO_ROOT, getEnvOptions()); + logger = loggingSystemMock.create(); + }); + + const createConfigService = (rawConfig: Record = {}): ConfigService => { + const rawConfigService = rawConfigServiceMock.create({ rawConfig }); + const server = new Server(rawConfigService, env, logger); + server.setupCoreConfig(); + return server.configService; + }; + + describe('getGlobalConfig', () => { + it('should return the global config', async () => { + const configService = createConfigService(); + await configService.validate(); + + const legacyConfig = getGlobalConfig(configService); + + expect(legacyConfig).toStrictEqual({ + kibana: { + index: '.kibana', + autocompleteTerminateAfter: duration(100000), + autocompleteTimeout: duration(1000), + }, + elasticsearch: { + shardTimeout: duration(30, 's'), + requestTimeout: duration(30, 's'), + pingTimeout: duration(30, 's'), + }, + path: { data: fromRoot('data') }, + savedObjects: { maxImportPayloadBytes: new ByteSizeValue(26214400) }, + }); + }); + }); + + describe('getGlobalConfig$', () => { + it('should return an observable for the global config', async () => { + const configService = createConfigService(); + + const legacyConfig = await getGlobalConfig$(configService).pipe(take(1)).toPromise(); + + expect(legacyConfig).toStrictEqual({ + kibana: { + index: '.kibana', + autocompleteTerminateAfter: duration(100000), + autocompleteTimeout: duration(1000), + }, + elasticsearch: { + shardTimeout: duration(30, 's'), + requestTimeout: duration(30, 's'), + pingTimeout: duration(30, 's'), + }, + path: { data: fromRoot('data') }, + savedObjects: { maxImportPayloadBytes: new ByteSizeValue(26214400) }, + }); + }); + }); +}); diff --git a/src/core/server/plugins/legacy_config.ts b/src/core/server/plugins/legacy_config.ts new file mode 100644 index 0000000000000..748a1e3190640 --- /dev/null +++ b/src/core/server/plugins/legacy_config.ts @@ -0,0 +1,69 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +import { map, shareReplay } from 'rxjs/operators'; +import { combineLatest, Observable } from 'rxjs'; +import { PathConfigType, config as pathConfig } from '@kbn/utils'; +import { pick, deepFreeze } from '@kbn/std'; +import { IConfigService } from '@kbn/config'; + +import { SharedGlobalConfig, SharedGlobalConfigKeys } from './types'; +import { KibanaConfigType, config as kibanaConfig } from '../kibana_config'; +import { + ElasticsearchConfigType, + config as elasticsearchConfig, +} from '../elasticsearch/elasticsearch_config'; +import { SavedObjectsConfigType, savedObjectsConfig } from '../saved_objects/saved_objects_config'; + +const createGlobalConfig = ({ + kibana, + elasticsearch, + path, + savedObjects, +}: { + kibana: KibanaConfigType; + elasticsearch: ElasticsearchConfigType; + path: PathConfigType; + savedObjects: SavedObjectsConfigType; +}): SharedGlobalConfig => { + return deepFreeze({ + kibana: pick(kibana, SharedGlobalConfigKeys.kibana), + elasticsearch: pick(elasticsearch, SharedGlobalConfigKeys.elasticsearch), + path: pick(path, SharedGlobalConfigKeys.path), + savedObjects: pick(savedObjects, SharedGlobalConfigKeys.savedObjects), + }); +}; + +export const getGlobalConfig = (configService: IConfigService): SharedGlobalConfig => { + return createGlobalConfig({ + kibana: configService.atPathSync(kibanaConfig.path), + elasticsearch: configService.atPathSync(elasticsearchConfig.path), + path: configService.atPathSync(pathConfig.path), + savedObjects: configService.atPathSync(savedObjectsConfig.path), + }); +}; + +export const getGlobalConfig$ = (configService: IConfigService): Observable => { + return combineLatest([ + configService.atPath(kibanaConfig.path), + configService.atPath(elasticsearchConfig.path), + configService.atPath(pathConfig.path), + configService.atPath(savedObjectsConfig.path), + ]).pipe( + map( + ([kibana, elasticsearch, path, savedObjects]) => + createGlobalConfig({ + kibana, + elasticsearch, + path, + savedObjects, + }), + shareReplay(1) + ) + ); +}; diff --git a/src/core/server/plugins/plugin_context.test.ts b/src/core/server/plugins/plugin_context.test.ts index 3d212bc555828..c71102df9929b 100644 --- a/src/core/server/plugins/plugin_context.test.ts +++ b/src/core/server/plugins/plugin_context.test.ts @@ -17,15 +17,8 @@ import { rawConfigServiceMock, getEnvOptions } from '../config/mocks'; import { PluginManifest } from './types'; import { Server } from '../server'; import { fromRoot } from '../utils'; -import { ByteSizeValue } from '@kbn/config-schema'; - -const logger = loggingSystemMock.create(); - -let coreId: symbol; -let env: Env; -let coreContext: CoreContext; -let server: Server; -let instanceInfo: InstanceInfo; +import { schema, ByteSizeValue } from '@kbn/config-schema'; +import { ConfigService } from '@kbn/config'; function createPluginManifest(manifestProps: Partial = {}): PluginManifest { return { @@ -43,61 +36,112 @@ function createPluginManifest(manifestProps: Partial = {}): Plug } describe('createPluginInitializerContext', () => { + let logger: ReturnType; + let coreId: symbol; + let opaqueId: symbol; + let env: Env; + let coreContext: CoreContext; + let server: Server; + let instanceInfo: InstanceInfo; + beforeEach(async () => { + logger = loggingSystemMock.create(); coreId = Symbol('core'); + opaqueId = Symbol(); instanceInfo = { uuid: 'instance-uuid', }; env = Env.createDefault(REPO_ROOT, getEnvOptions()); const config$ = rawConfigServiceMock.create({ rawConfig: {} }); server = new Server(config$, env, logger); - await server.setupCoreConfig(); + server.setupCoreConfig(); coreContext = { coreId, env, logger, configService: server.configService }; }); - it('should return a globalConfig handler in the context', async () => { - const manifest = createPluginManifest(); - const opaqueId = Symbol(); - const pluginInitializerContext = createPluginInitializerContext( - coreContext, - opaqueId, - manifest, - instanceInfo - ); + describe('context.config', () => { + it('config.get() should return the plugin config synchronously', async () => { + const config$ = rawConfigServiceMock.create({ + rawConfig: { + plugin: { + foo: 'bar', + answer: 42, + }, + }, + }); + + const configService = new ConfigService(config$, env, logger); + configService.setSchema( + 'plugin', + schema.object({ + foo: schema.string(), + answer: schema.number(), + }) + ); + await configService.validate(); - expect(pluginInitializerContext.config.legacy.globalConfig$).toBeDefined(); + coreContext = { coreId, env, logger, configService }; - const configObject = await pluginInitializerContext.config.legacy.globalConfig$ - .pipe(first()) - .toPromise(); - expect(configObject).toStrictEqual({ - kibana: { - index: '.kibana', - autocompleteTerminateAfter: duration(100000), - autocompleteTimeout: duration(1000), - }, - elasticsearch: { - shardTimeout: duration(30, 's'), - requestTimeout: duration(30, 's'), - pingTimeout: duration(30, 's'), - }, - path: { data: fromRoot('data') }, - savedObjects: { maxImportPayloadBytes: new ByteSizeValue(26214400) }, + const manifest = createPluginManifest({ + configPath: 'plugin', + }); + + const pluginInitializerContext = createPluginInitializerContext( + coreContext, + opaqueId, + manifest, + instanceInfo + ); + + expect(pluginInitializerContext.config.get()).toEqual({ + foo: 'bar', + answer: 42, + }); + }); + + it('config.globalConfig$ should be an observable for the global config', async () => { + const manifest = createPluginManifest(); + const pluginInitializerContext = createPluginInitializerContext( + coreContext, + opaqueId, + manifest, + instanceInfo + ); + + expect(pluginInitializerContext.config.legacy.globalConfig$).toBeDefined(); + + const configObject = await pluginInitializerContext.config.legacy.globalConfig$ + .pipe(first()) + .toPromise(); + expect(configObject).toStrictEqual({ + kibana: { + index: '.kibana', + autocompleteTerminateAfter: duration(100000), + autocompleteTimeout: duration(1000), + }, + elasticsearch: { + shardTimeout: duration(30, 's'), + requestTimeout: duration(30, 's'), + pingTimeout: duration(30, 's'), + }, + path: { data: fromRoot('data') }, + savedObjects: { maxImportPayloadBytes: new ByteSizeValue(26214400) }, + }); }); }); - it('allow to access the provided instance uuid', () => { - const manifest = createPluginManifest(); - const opaqueId = Symbol(); - instanceInfo = { - uuid: 'kibana-uuid', - }; - const pluginInitializerContext = createPluginInitializerContext( - coreContext, - opaqueId, - manifest, - instanceInfo - ); - expect(pluginInitializerContext.env.instanceUuid).toBe('kibana-uuid'); + describe('context.env', () => { + it('should expose the correct instance uuid', () => { + const manifest = createPluginManifest(); + instanceInfo = { + uuid: 'kibana-uuid', + }; + const pluginInitializerContext = createPluginInitializerContext( + coreContext, + opaqueId, + manifest, + instanceInfo + ); + expect(pluginInitializerContext.env.instanceUuid).toBe('kibana-uuid'); + }); }); }); diff --git a/src/core/server/plugins/plugin_context.ts b/src/core/server/plugins/plugin_context.ts index 5b0e2ee21a887..3b7dc70b9c054 100644 --- a/src/core/server/plugins/plugin_context.ts +++ b/src/core/server/plugins/plugin_context.ts @@ -6,27 +6,14 @@ * Public License, v 1. */ -import { map, shareReplay } from 'rxjs/operators'; -import { combineLatest } from 'rxjs'; -import { PathConfigType, config as pathConfig } from '@kbn/utils'; -import { pick, deepFreeze } from '@kbn/std'; +import { shareReplay } from 'rxjs/operators'; import type { RequestHandlerContext } from 'src/core/server'; import { CoreContext } from '../core_context'; import { PluginWrapper } from './plugin'; import { PluginsServiceSetupDeps, PluginsServiceStartDeps } from './plugins_service'; -import { - PluginInitializerContext, - PluginManifest, - PluginOpaqueId, - SharedGlobalConfigKeys, -} from './types'; -import { KibanaConfigType, config as kibanaConfig } from '../kibana_config'; -import { - ElasticsearchConfigType, - config as elasticsearchConfig, -} from '../elasticsearch/elasticsearch_config'; +import { PluginInitializerContext, PluginManifest, PluginOpaqueId } from './types'; import { IRouter, RequestHandlerContextProvider } from '../http'; -import { SavedObjectsConfigType, savedObjectsConfig } from '../saved_objects/saved_objects_config'; +import { getGlobalConfig, getGlobalConfig$ } from './legacy_config'; import { CoreSetup, CoreStart } from '..'; export interface InstanceInfo { @@ -78,40 +65,19 @@ export function createPluginInitializerContext( */ config: { legacy: { - /** - * Global configuration - * Note: naming not final here, it will be renamed in a near future (https://github.com/elastic/kibana/issues/46240) - * @deprecated - */ - globalConfig$: combineLatest([ - coreContext.configService.atPath(kibanaConfig.path), - coreContext.configService.atPath(elasticsearchConfig.path), - coreContext.configService.atPath(pathConfig.path), - coreContext.configService.atPath(savedObjectsConfig.path), - ]).pipe( - map(([kibana, elasticsearch, path, savedObjects]) => - deepFreeze({ - kibana: pick(kibana, SharedGlobalConfigKeys.kibana), - elasticsearch: pick(elasticsearch, SharedGlobalConfigKeys.elasticsearch), - path: pick(path, SharedGlobalConfigKeys.path), - savedObjects: pick(savedObjects, SharedGlobalConfigKeys.savedObjects), - }) - ) - ), + globalConfig$: getGlobalConfig$(coreContext.configService), + get: () => getGlobalConfig(coreContext.configService), }, /** * Reads the subset of the config at the `configPath` defined in the plugin - * manifest and validates it against the schema in the static `schema` on - * the given `ConfigClass`. - * @param ConfigClass A class (not an instance of a class) that contains a - * static `schema` that we validate the config at the given `path` against. + * manifest. */ create() { return coreContext.configService.atPath(pluginManifest.configPath).pipe(shareReplay(1)); }, - createIfExists() { - return coreContext.configService.optionalAtPath(pluginManifest.configPath); + get() { + return coreContext.configService.atPathSync(pluginManifest.configPath); }, }, }; diff --git a/src/core/server/plugins/plugins_service.ts b/src/core/server/plugins/plugins_service.ts index 9a1403bda3bca..dd2831f77f537 100644 --- a/src/core/server/plugins/plugins_service.ts +++ b/src/core/server/plugins/plugins_service.ts @@ -219,10 +219,7 @@ export class PluginsService implements CoreService { packageInfo: Readonly; instanceUuid: string; }; + /** + * {@link LoggerFactory | logger factory} instance already bound to the plugin's logging context + * + * @example + * ```typescript + * // plugins/my-plugin/server/plugin.ts + * // "id: myPlugin" in `plugins/my-plugin/kibana.yaml` + * + * export class MyPlugin implements Plugin { + * constructor(private readonly initContext: PluginInitializerContext) { + * this.logger = initContext.logger.get(); + * // `logger` context: `plugins.myPlugin` + * this.mySubLogger = initContext.logger.get('sub'); // or this.logger.get('sub'); + * // `mySubLogger` context: `plugins.myPlugin.sub` + * } + * } + * ``` + */ logger: LoggerFactory; + /** + * Accessors for the plugin's configuration + */ config: { - legacy: { globalConfig$: Observable }; + /** + * Provide access to Kibana legacy configuration values. + * + * @remarks Naming not final here, it may be renamed in a near future + * @deprecated Accessing configuration values outside of the plugin's config scope is highly discouraged + */ + legacy: { + globalConfig$: Observable; + get: () => SharedGlobalConfig; + }; + /** + * Return an observable of the plugin's configuration + * + * @example + * ```typescript + * // plugins/my-plugin/server/plugin.ts + * + * export class MyPlugin implements Plugin { + * constructor(private readonly initContext: PluginInitializerContext) {} + * setup(core) { + * this.configSub = this.initContext.config.create().subscribe((config) => { + * this.myService.reconfigure(config); + * }); + * } + * stop() { + * this.configSub.unsubscribe(); + * } + * ``` + * + * @example + * ```typescript + * // plugins/my-plugin/server/plugin.ts + * + * export class MyPlugin implements Plugin { + * constructor(private readonly initContext: PluginInitializerContext) {} + * async setup(core) { + * this.config = await this.initContext.config.create().pipe(take(1)).toPromise(); + * } + * stop() { + * this.configSub.unsubscribe(); + * } + * ``` + * + * @remarks The underlying observable has a replay effect, meaning that awaiting for the first emission + * will be resolved at next tick, without risks to delay any asynchronous code's workflow. + */ create: () => Observable; - createIfExists: () => Observable; + /** + * Return the current value of the plugin's configuration synchronously. + * + * @example + * ```typescript + * // plugins/my-plugin/server/plugin.ts + * + * export class MyPlugin implements Plugin { + * constructor(private readonly initContext: PluginInitializerContext) {} + * setup(core) { + * const config = this.initContext.config.get(); + * // do something with the config + * } + * } + * ``` + * + * @remarks This should only be used when synchronous access is an absolute necessity, such + * as during the plugin's setup or start lifecycle. For all other usages, + * {@link create} should be used instead. + */ + get: () => T; }; } diff --git a/src/core/server/root/index.ts b/src/core/server/root/index.ts index 17cb209897c25..a918580392caa 100644 --- a/src/core/server/root/index.ts +++ b/src/core/server/root/index.ts @@ -36,7 +36,7 @@ export class Root { public async setup() { try { - await this.server.setupCoreConfig(); + this.server.setupCoreConfig(); await this.setupLogging(); this.log.debug('setting up root'); return await this.server.setup(); diff --git a/src/core/server/server.api.md b/src/core/server/server.api.md index 0b058011267eb..0a138917717fc 100644 --- a/src/core/server/server.api.md +++ b/src/core/server/server.api.md @@ -1840,13 +1840,13 @@ export type PluginInitializer { - // (undocumented) config: { legacy: { globalConfig$: Observable; + get: () => SharedGlobalConfig; }; create: () => Observable; - createIfExists: () => Observable; + get: () => T; }; // (undocumented) env: { @@ -1854,7 +1854,7 @@ export interface PluginInitializerContext { packageInfo: Readonly; instanceUuid: string; }; - // (undocumented) + // Warning: (ae-unresolved-link) The @link reference could not be resolved: Reexported declarations are not supported logger: LoggerFactory; // (undocumented) opaqueId: PluginOpaqueId; @@ -3137,5 +3137,6 @@ export const validBodyOutput: readonly ["data", "stream"]; // src/core/server/plugins/types.ts:263:3 - (ae-forgotten-export) The symbol "KibanaConfigType" needs to be exported by the entry point index.d.ts // src/core/server/plugins/types.ts:263:3 - (ae-forgotten-export) The symbol "SharedGlobalConfigKeys" needs to be exported by the entry point index.d.ts // src/core/server/plugins/types.ts:266:3 - (ae-forgotten-export) The symbol "SavedObjectsConfigType" needs to be exported by the entry point index.d.ts +// src/core/server/plugins/types.ts:371:5 - (ae-unresolved-link) The @link reference could not be resolved: The package "kibana" does not have an export "create" ``` diff --git a/src/core/server/server.ts b/src/core/server/server.ts index 60f3f90428d40..cc1087a422e39 100644 --- a/src/core/server/server.ts +++ b/src/core/server/server.ts @@ -300,7 +300,7 @@ export class Server { ); } - public async setupCoreConfig() { + public setupCoreConfig() { const configDescriptors: Array> = [ pathConfig, cspConfig, @@ -325,7 +325,7 @@ export class Server { if (descriptor.deprecations) { this.configService.addDeprecationProvider(descriptor.path, descriptor.deprecations); } - await this.configService.setSchema(descriptor.path, descriptor.schema); + this.configService.setSchema(descriptor.path, descriptor.schema); } } }