From 832e80cc0ab5282353382704d0153a12a99477a5 Mon Sep 17 00:00:00 2001 From: Pierre Gayvallet Date: Thu, 21 Nov 2019 12:37:23 +0100 Subject: [PATCH] Expose whitelisted config values to client-side plugin (#50641) * introduce PluginConfigDescriptor type * inject client plugin configs in injectedMetadata * expose client config in PluginInitializerContext * add example implementation in testbed * update generated doc * only generates ui config entry for plugins exposing properties to client * separate plugin configs from plugins * restructure plugin services tests * fix test/mocks due to plugin configs api changes * add unit tests * update migration guide * update tsdoc * fix typecheck * use sync getter for config on client side instead of observable * change type of exposeToBrowser prop * updates generated doc * fix doc and address nits --- ...-public.plugininitializercontext.config.md | 13 + ...-plugin-public.plugininitializercontext.md | 3 +- .../core/server/kibana-plugin-server.md | 2 + ....pluginconfigdescriptor.exposetobrowser.md | 15 + ...na-plugin-server.pluginconfigdescriptor.md | 45 ++ ...in-server.pluginconfigdescriptor.schema.md | 15 + ...kibana-plugin-server.pluginconfigschema.md | 13 + ...ibana-plugin-server.pluginsservicesetup.md | 1 + ...ver.pluginsservicesetup.uipluginconfigs.md | 11 + src/core/MIGRATION.md | 0 src/core/public/injected_metadata/index.ts | 1 + .../injected_metadata_service.test.ts | 4 +- .../injected_metadata_service.ts | 18 +- src/core/public/mocks.ts | 3 + src/core/public/plugins/plugin_context.ts | 20 +- .../public/plugins/plugins_service.test.ts | 481 +++++++------ src/core/public/plugins/plugins_service.ts | 12 +- src/core/public/public.api.md | 6 +- src/core/server/index.ts | 2 + src/core/server/legacy/legacy_service.test.ts | 1 + src/core/server/legacy/legacy_service.ts | 1 + src/core/server/plugins/plugin.test.ts | 15 +- src/core/server/plugins/plugin.ts | 9 +- .../server/plugins/plugins_service.mock.ts | 1 + .../server/plugins/plugins_service.test.ts | 653 ++++++++++-------- src/core/server/plugins/plugins_service.ts | 68 +- src/core/server/plugins/types.ts | 46 +- src/core/server/server.api.md | 15 +- src/legacy/server/kbn_server.d.ts | 1 + src/legacy/ui/ui_render/ui_render_mixin.js | 112 +-- src/plugins/testbed/public/index.ts | 7 +- src/plugins/testbed/public/plugin.ts | 14 +- src/plugins/testbed/server/index.ts | 27 +- .../legacy/plugins/siem/public/apps/index.ts | 10 +- .../plugins/uptime/public/apps/index.ts | 5 +- 35 files changed, 1040 insertions(+), 610 deletions(-) create mode 100644 docs/development/core/public/kibana-plugin-public.plugininitializercontext.config.md create mode 100644 docs/development/core/server/kibana-plugin-server.pluginconfigdescriptor.exposetobrowser.md create mode 100644 docs/development/core/server/kibana-plugin-server.pluginconfigdescriptor.md create mode 100644 docs/development/core/server/kibana-plugin-server.pluginconfigdescriptor.schema.md create mode 100644 docs/development/core/server/kibana-plugin-server.pluginconfigschema.md create mode 100644 docs/development/core/server/kibana-plugin-server.pluginsservicesetup.uipluginconfigs.md create mode 100644 src/core/MIGRATION.md diff --git a/docs/development/core/public/kibana-plugin-public.plugininitializercontext.config.md b/docs/development/core/public/kibana-plugin-public.plugininitializercontext.config.md new file mode 100644 index 00000000000000..28141c9e137499 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-public.plugininitializercontext.config.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-public](./kibana-plugin-public.md) > [PluginInitializerContext](./kibana-plugin-public.plugininitializercontext.md) > [config](./kibana-plugin-public.plugininitializercontext.config.md) + +## PluginInitializerContext.config property + +Signature: + +```typescript +readonly config: { + get: () => T; + }; +``` diff --git a/docs/development/core/public/kibana-plugin-public.plugininitializercontext.md b/docs/development/core/public/kibana-plugin-public.plugininitializercontext.md index 87c39a502040d8..64eaabb28646de 100644 --- a/docs/development/core/public/kibana-plugin-public.plugininitializercontext.md +++ b/docs/development/core/public/kibana-plugin-public.plugininitializercontext.md @@ -9,13 +9,14 @@ The available core services passed to a `PluginInitializer` Signature: ```typescript -export interface PluginInitializerContext +export interface PluginInitializerContext ``` ## Properties | Property | Type | Description | | --- | --- | --- | +| [config](./kibana-plugin-public.plugininitializercontext.config.md) | {
get: <T extends object = ConfigSchema>() => T;
} | | | [env](./kibana-plugin-public.plugininitializercontext.env.md) | {
mode: Readonly<EnvironmentMode>;
packageInfo: Readonly<PackageInfo>;
} | | | [opaqueId](./kibana-plugin-public.plugininitializercontext.opaqueid.md) | PluginOpaqueId | A symbol used to identify this plugin in the system. Needed when registering handlers or context providers. | diff --git a/docs/development/core/server/kibana-plugin-server.md b/docs/development/core/server/kibana-plugin-server.md index 9907750b8742f0..c6ab8502acbd23 100644 --- a/docs/development/core/server/kibana-plugin-server.md +++ b/docs/development/core/server/kibana-plugin-server.md @@ -75,6 +75,7 @@ The plugin integrates with the core system via lifecycle events: `setup` | [OnPreAuthToolkit](./kibana-plugin-server.onpreauthtoolkit.md) | A tool set defining an outcome of OnPreAuth interceptor for incoming request. | | [PackageInfo](./kibana-plugin-server.packageinfo.md) | | | [Plugin](./kibana-plugin-server.plugin.md) | The interface that should be returned by a PluginInitializer. | +| [PluginConfigDescriptor](./kibana-plugin-server.pluginconfigdescriptor.md) | Describes a plugin configuration schema and capabilities. | | [PluginInitializerContext](./kibana-plugin-server.plugininitializercontext.md) | Context that's available to plugins during initialization stage. | | [PluginManifest](./kibana-plugin-server.pluginmanifest.md) | Describes the set of required and optional properties plugin can define in its mandatory JSON manifest file. | | [PluginsServiceSetup](./kibana-plugin-server.pluginsservicesetup.md) | | @@ -156,6 +157,7 @@ The plugin integrates with the core system via lifecycle events: `setup` | [MutatingOperationRefreshSetting](./kibana-plugin-server.mutatingoperationrefreshsetting.md) | Elasticsearch Refresh setting for mutating operation | | [OnPostAuthHandler](./kibana-plugin-server.onpostauthhandler.md) | See [OnPostAuthToolkit](./kibana-plugin-server.onpostauthtoolkit.md). | | [OnPreAuthHandler](./kibana-plugin-server.onpreauthhandler.md) | See [OnPreAuthToolkit](./kibana-plugin-server.onpreauthtoolkit.md). | +| [PluginConfigSchema](./kibana-plugin-server.pluginconfigschema.md) | Dedicated type for plugin configuration schema. | | [PluginInitializer](./kibana-plugin-server.plugininitializer.md) | The plugin export at the root of a plugin's server directory should conform to this interface. | | [PluginName](./kibana-plugin-server.pluginname.md) | Dedicated type for plugin name/id that is supposed to make Map/Set/Arrays that use it as a key or value more obvious. | | [PluginOpaqueId](./kibana-plugin-server.pluginopaqueid.md) | | diff --git a/docs/development/core/server/kibana-plugin-server.pluginconfigdescriptor.exposetobrowser.md b/docs/development/core/server/kibana-plugin-server.pluginconfigdescriptor.exposetobrowser.md new file mode 100644 index 00000000000000..d62b2457e9d9ac --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.pluginconfigdescriptor.exposetobrowser.md @@ -0,0 +1,15 @@ + + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [PluginConfigDescriptor](./kibana-plugin-server.pluginconfigdescriptor.md) > [exposeToBrowser](./kibana-plugin-server.pluginconfigdescriptor.exposetobrowser.md) + +## PluginConfigDescriptor.exposeToBrowser property + +List of configuration properties that will be available on the client-side plugin. + +Signature: + +```typescript +exposeToBrowser?: { + [P in keyof T]?: boolean; + }; +``` diff --git a/docs/development/core/server/kibana-plugin-server.pluginconfigdescriptor.md b/docs/development/core/server/kibana-plugin-server.pluginconfigdescriptor.md new file mode 100644 index 00000000000000..41fdcfe5df45d4 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.pluginconfigdescriptor.md @@ -0,0 +1,45 @@ + + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [PluginConfigDescriptor](./kibana-plugin-server.pluginconfigdescriptor.md) + +## PluginConfigDescriptor interface + +Describes a plugin configuration schema and capabilities. + +Signature: + +```typescript +export interface PluginConfigDescriptor +``` + +## Properties + +| Property | Type | Description | +| --- | --- | --- | +| [exposeToBrowser](./kibana-plugin-server.pluginconfigdescriptor.exposetobrowser.md) | {
[P in keyof T]?: boolean;
} | List of configuration properties that will be available on the client-side plugin. | +| [schema](./kibana-plugin-server.pluginconfigdescriptor.schema.md) | PluginConfigSchema<T> | Schema to use to validate the plugin configuration.[PluginConfigSchema](./kibana-plugin-server.pluginconfigschema.md) | + +## Example + + +```typescript +// my_plugin/server/index.ts +import { schema, TypeOf } from '@kbn/config-schema'; +import { PluginConfigDescriptor } from 'kibana/server'; + +const configSchema = schema.object({ + secret: schema.string({ defaultValue: 'Only on server' }), + uiProp: schema.string({ defaultValue: 'Accessible from client' }), +}); + +type ConfigType = TypeOf; + +export const config: PluginConfigDescriptor = { + exposeToBrowser: { + uiProp: true, + }, + schema: configSchema, +}; + +``` + diff --git a/docs/development/core/server/kibana-plugin-server.pluginconfigdescriptor.schema.md b/docs/development/core/server/kibana-plugin-server.pluginconfigdescriptor.schema.md new file mode 100644 index 00000000000000..c4845d52ff2122 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.pluginconfigdescriptor.schema.md @@ -0,0 +1,15 @@ + + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [PluginConfigDescriptor](./kibana-plugin-server.pluginconfigdescriptor.md) > [schema](./kibana-plugin-server.pluginconfigdescriptor.schema.md) + +## PluginConfigDescriptor.schema property + +Schema to use to validate the plugin configuration. + +[PluginConfigSchema](./kibana-plugin-server.pluginconfigschema.md) + +Signature: + +```typescript +schema: PluginConfigSchema; +``` diff --git a/docs/development/core/server/kibana-plugin-server.pluginconfigschema.md b/docs/development/core/server/kibana-plugin-server.pluginconfigschema.md new file mode 100644 index 00000000000000..6528798ec8e01f --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.pluginconfigschema.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [PluginConfigSchema](./kibana-plugin-server.pluginconfigschema.md) + +## PluginConfigSchema type + +Dedicated type for plugin configuration schema. + +Signature: + +```typescript +export declare type PluginConfigSchema = Type; +``` diff --git a/docs/development/core/server/kibana-plugin-server.pluginsservicesetup.md b/docs/development/core/server/kibana-plugin-server.pluginsservicesetup.md index 2b3ff9a2cd4199..36d803ddea6188 100644 --- a/docs/development/core/server/kibana-plugin-server.pluginsservicesetup.md +++ b/docs/development/core/server/kibana-plugin-server.pluginsservicesetup.md @@ -16,5 +16,6 @@ export interface PluginsServiceSetup | Property | Type | Description | | --- | --- | --- | | [contracts](./kibana-plugin-server.pluginsservicesetup.contracts.md) | Map<PluginName, unknown> | | +| [uiPluginConfigs](./kibana-plugin-server.pluginsservicesetup.uipluginconfigs.md) | Map<PluginName, Observable<unknown>> | | | [uiPlugins](./kibana-plugin-server.pluginsservicesetup.uiplugins.md) | {
public: Map<PluginName, DiscoveredPlugin>;
internal: Map<PluginName, DiscoveredPluginInternal>;
} | | diff --git a/docs/development/core/server/kibana-plugin-server.pluginsservicesetup.uipluginconfigs.md b/docs/development/core/server/kibana-plugin-server.pluginsservicesetup.uipluginconfigs.md new file mode 100644 index 00000000000000..4bd57b873043e8 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.pluginsservicesetup.uipluginconfigs.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [PluginsServiceSetup](./kibana-plugin-server.pluginsservicesetup.md) > [uiPluginConfigs](./kibana-plugin-server.pluginsservicesetup.uipluginconfigs.md) + +## PluginsServiceSetup.uiPluginConfigs property + +Signature: + +```typescript +uiPluginConfigs: Map>; +``` diff --git a/src/core/MIGRATION.md b/src/core/MIGRATION.md new file mode 100644 index 00000000000000..e69de29bb2d1d6 diff --git a/src/core/public/injected_metadata/index.ts b/src/core/public/injected_metadata/index.ts index dac9d5cea3565b..cebd0f017de698 100644 --- a/src/core/public/injected_metadata/index.ts +++ b/src/core/public/injected_metadata/index.ts @@ -22,5 +22,6 @@ export { InjectedMetadataParams, InjectedMetadataSetup, InjectedMetadataStart, + InjectedPluginMetadata, LegacyNavLink, } from './injected_metadata_service'; diff --git a/src/core/public/injected_metadata/injected_metadata_service.test.ts b/src/core/public/injected_metadata/injected_metadata_service.test.ts index 1110097c1c92bd..cf4b72114d5ac2 100644 --- a/src/core/public/injected_metadata/injected_metadata_service.test.ts +++ b/src/core/public/injected_metadata/injected_metadata_service.test.ts @@ -69,7 +69,7 @@ describe('setup.getPlugins()', () => { const injectedMetadata = new InjectedMetadataService({ injectedMetadata: { uiPlugins: [ - { id: 'plugin-1', plugin: {} }, + { id: 'plugin-1', plugin: {}, config: { clientProp: 'clientValue' } }, { id: 'plugin-2', plugin: {} }, ], }, @@ -77,7 +77,7 @@ describe('setup.getPlugins()', () => { const plugins = injectedMetadata.setup().getPlugins(); expect(plugins).toEqual([ - { id: 'plugin-1', plugin: {} }, + { id: 'plugin-1', plugin: {}, config: { clientProp: 'clientValue' } }, { id: 'plugin-2', plugin: {} }, ]); }); diff --git a/src/core/public/injected_metadata/injected_metadata_service.ts b/src/core/public/injected_metadata/injected_metadata_service.ts index a5342aaa48b723..002f83d9feac47 100644 --- a/src/core/public/injected_metadata/injected_metadata_service.ts +++ b/src/core/public/injected_metadata/injected_metadata_service.ts @@ -38,6 +38,14 @@ export interface LegacyNavLink { euiIconType?: string; } +export interface InjectedPluginMetadata { + id: PluginName; + plugin: DiscoveredPlugin; + config?: { + [key: string]: unknown; + }; +} + /** @internal */ export interface InjectedMetadataParams { injectedMetadata: { @@ -55,10 +63,7 @@ export interface InjectedMetadataParams { mode: Readonly; packageInfo: Readonly; }; - uiPlugins: Array<{ - id: PluginName; - plugin: DiscoveredPlugin; - }>; + uiPlugins: InjectedPluginMetadata[]; capabilities: Capabilities; legacyMode: boolean; legacyMetadata: { @@ -165,10 +170,7 @@ export interface InjectedMetadataSetup { /** * An array of frontend plugins in topological order. */ - getPlugins: () => Array<{ - id: string; - plugin: DiscoveredPlugin; - }>; + getPlugins: () => InjectedPluginMetadata[]; /** Indicates whether or not we are rendering a known legacy app. */ getLegacyMode: () => boolean; getLegacyMetadata: () => { diff --git a/src/core/public/mocks.ts b/src/core/public/mocks.ts index afd0825ec986c7..695f0454f8b65b 100644 --- a/src/core/public/mocks.ts +++ b/src/core/public/mocks.ts @@ -92,6 +92,9 @@ function pluginInitializerContextMock() { dist: false, }, }, + config: { + get: () => ({} as T), + }, }; return mock; diff --git a/src/core/public/plugins/plugin_context.ts b/src/core/public/plugins/plugin_context.ts index eae45654fce18c..f77ddd8f2f6967 100644 --- a/src/core/public/plugins/plugin_context.ts +++ b/src/core/public/plugins/plugin_context.ts @@ -18,7 +18,6 @@ */ import { omit } from 'lodash'; - import { DiscoveredPlugin } from '../../server'; import { PluginOpaqueId, PackageInfo, EnvironmentMode } from '../../server/types'; import { CoreContext } from '../core_system'; @@ -31,7 +30,7 @@ import { CoreSetup, CoreStart } from '../'; * * @public */ -export interface PluginInitializerContext { +export interface PluginInitializerContext { /** * A symbol used to identify this plugin in the system. Needed when registering handlers or context providers. */ @@ -40,6 +39,9 @@ export interface PluginInitializerContext { mode: Readonly; packageInfo: Readonly; }; + readonly config: { + get: () => T; + }; } /** @@ -47,17 +49,27 @@ export interface PluginInitializerContext { * empty but should provide static services in the future, such as config and logging. * * @param coreContext - * @param pluginManinfest + * @param opaqueId + * @param pluginManifest + * @param pluginConfig * @internal */ export function createPluginInitializerContext( coreContext: CoreContext, opaqueId: PluginOpaqueId, - pluginManifest: DiscoveredPlugin + pluginManifest: DiscoveredPlugin, + pluginConfig: { + [key: string]: unknown; + } ): PluginInitializerContext { return { opaqueId, env: coreContext.env, + config: { + get() { + return (pluginConfig as unknown) as T; + }, + }, }; } diff --git a/src/core/public/plugins/plugins_service.test.ts b/src/core/public/plugins/plugins_service.test.ts index 0d8887774e900f..2983d7583cb493 100644 --- a/src/core/public/plugins/plugins_service.test.ts +++ b/src/core/public/plugins/plugins_service.test.ts @@ -25,13 +25,14 @@ import { mockPluginInitializerProvider, } from './plugins_service.test.mocks'; -import { PluginName, DiscoveredPlugin } from 'src/core/server'; +import { PluginName } from 'src/core/server'; import { coreMock } from '../mocks'; import { PluginsService, PluginsServiceStartDeps, PluginsServiceSetupDeps, } from './plugins_service'; +import { InjectedPluginMetadata } from '../injected_metadata'; import { notificationServiceMock } from '../notifications/notifications_service.mock'; import { applicationServiceMock } from '../application/application_service.mock'; import { i18nServiceMock } from '../i18n/i18n_service.mock'; @@ -41,7 +42,7 @@ import { fatalErrorsServiceMock } from '../fatal_errors/fatal_errors_service.moc import { uiSettingsServiceMock } from '../ui_settings/ui_settings_service.mock'; import { injectedMetadataServiceMock } from '../injected_metadata/injected_metadata_service.mock'; import { httpServiceMock } from '../http/http_service.mock'; -import { CoreSetup, CoreStart } from '..'; +import { CoreSetup, CoreStart, PluginInitializerContext } from '..'; import { docLinksServiceMock } from '../doc_links/doc_links_service.mock'; import { savedObjectsMock } from '../saved_objects/saved_objects_service.mock'; import { contextServiceMock } from '../context/context_service.mock'; @@ -52,7 +53,7 @@ mockPluginInitializerProvider.mockImplementation( pluginName => mockPluginInitializers.get(pluginName)! ); -let plugins: Array<{ id: string; plugin: DiscoveredPlugin }>; +let plugins: InjectedPluginMetadata[]; type DeeplyMocked = { [P in keyof T]: jest.Mocked }; @@ -62,83 +63,6 @@ let mockSetupContext: DeeplyMocked; let mockStartDeps: DeeplyMocked; let mockStartContext: DeeplyMocked; -beforeEach(() => { - plugins = [ - { id: 'pluginA', plugin: createManifest('pluginA') }, - { id: 'pluginB', plugin: createManifest('pluginB', { required: ['pluginA'] }) }, - { - id: 'pluginC', - plugin: createManifest('pluginC', { required: ['pluginA'], optional: ['nonexist'] }), - }, - ]; - mockSetupDeps = { - application: applicationServiceMock.createInternalSetupContract(), - context: contextServiceMock.createSetupContract(), - fatalErrors: fatalErrorsServiceMock.createSetupContract(), - http: httpServiceMock.createSetupContract(), - injectedMetadata: pick(injectedMetadataServiceMock.createStartContract(), 'getInjectedVar'), - notifications: notificationServiceMock.createSetupContract(), - uiSettings: uiSettingsServiceMock.createSetupContract(), - }; - mockSetupContext = { - ...mockSetupDeps, - application: expect.any(Object), - }; - mockStartDeps = { - application: applicationServiceMock.createInternalStartContract(), - docLinks: docLinksServiceMock.createStartContract(), - http: httpServiceMock.createStartContract(), - chrome: chromeServiceMock.createStartContract(), - i18n: i18nServiceMock.createStartContract(), - injectedMetadata: pick(injectedMetadataServiceMock.createStartContract(), 'getInjectedVar'), - notifications: notificationServiceMock.createStartContract(), - overlays: overlayServiceMock.createStartContract(), - uiSettings: uiSettingsServiceMock.createStartContract(), - savedObjects: savedObjectsMock.createStartContract(), - }; - mockStartContext = { - ...mockStartDeps, - application: expect.any(Object), - chrome: omit(mockStartDeps.chrome, 'getComponent'), - }; - - // Reset these for each test. - mockPluginInitializers = new Map(([ - [ - 'pluginA', - jest.fn(() => ({ - setup: jest.fn(() => ({ setupValue: 1 })), - start: jest.fn(() => ({ startValue: 2 })), - stop: jest.fn(), - })), - ], - [ - 'pluginB', - jest.fn(() => ({ - setup: jest.fn((core, deps: any) => ({ - pluginAPlusB: deps.pluginA.setupValue + 1, - })), - start: jest.fn((core, deps: any) => ({ - pluginAPlusB: deps.pluginA.startValue + 1, - })), - stop: jest.fn(), - })), - ], - [ - 'pluginC', - jest.fn(() => ({ - setup: jest.fn(), - start: jest.fn(), - stop: jest.fn(), - })), - ], - ] as unknown) as [[PluginName, any]]); -}); - -afterEach(() => { - mockLoadPluginBundle.mockClear(); -}); - function createManifest( id: string, { required = [], optional = [] }: { required?: string[]; optional?: string[]; ui?: boolean } = {} @@ -152,9 +76,88 @@ function createManifest( }; } -test('`PluginsService.getOpaqueIds` returns dependency tree of symbols', () => { - const pluginsService = new PluginsService(mockCoreContext, plugins); - expect(pluginsService.getOpaqueIds()).toMatchInlineSnapshot(` +describe('PluginsService', () => { + beforeEach(() => { + plugins = [ + { id: 'pluginA', plugin: createManifest('pluginA') }, + { id: 'pluginB', plugin: createManifest('pluginB', { required: ['pluginA'] }) }, + { + id: 'pluginC', + plugin: createManifest('pluginC', { required: ['pluginA'], optional: ['nonexist'] }), + }, + ]; + mockSetupDeps = { + application: applicationServiceMock.createInternalSetupContract(), + context: contextServiceMock.createSetupContract(), + fatalErrors: fatalErrorsServiceMock.createSetupContract(), + http: httpServiceMock.createSetupContract(), + injectedMetadata: pick(injectedMetadataServiceMock.createStartContract(), 'getInjectedVar'), + notifications: notificationServiceMock.createSetupContract(), + uiSettings: uiSettingsServiceMock.createSetupContract(), + }; + mockSetupContext = { + ...mockSetupDeps, + application: expect.any(Object), + }; + mockStartDeps = { + application: applicationServiceMock.createInternalStartContract(), + docLinks: docLinksServiceMock.createStartContract(), + http: httpServiceMock.createStartContract(), + chrome: chromeServiceMock.createStartContract(), + i18n: i18nServiceMock.createStartContract(), + injectedMetadata: pick(injectedMetadataServiceMock.createStartContract(), 'getInjectedVar'), + notifications: notificationServiceMock.createStartContract(), + overlays: overlayServiceMock.createStartContract(), + uiSettings: uiSettingsServiceMock.createStartContract(), + savedObjects: savedObjectsMock.createStartContract(), + }; + mockStartContext = { + ...mockStartDeps, + application: expect.any(Object), + chrome: omit(mockStartDeps.chrome, 'getComponent'), + }; + + // Reset these for each test. + mockPluginInitializers = new Map(([ + [ + 'pluginA', + jest.fn(() => ({ + setup: jest.fn(() => ({ setupValue: 1 })), + start: jest.fn(() => ({ startValue: 2 })), + stop: jest.fn(), + })), + ], + [ + 'pluginB', + jest.fn(() => ({ + setup: jest.fn((core, deps: any) => ({ + pluginAPlusB: deps.pluginA.setupValue + 1, + })), + start: jest.fn((core, deps: any) => ({ + pluginAPlusB: deps.pluginA.startValue + 1, + })), + stop: jest.fn(), + })), + ], + [ + 'pluginC', + jest.fn(() => ({ + setup: jest.fn(), + start: jest.fn(), + stop: jest.fn(), + })), + ], + ] as unknown) as [[PluginName, any]]); + }); + + afterEach(() => { + mockLoadPluginBundle.mockClear(); + }); + + describe('#getOpaqueIds()', () => { + it('returns dependency tree of symbols', () => { + const pluginsService = new PluginsService(mockCoreContext, plugins); + expect(pluginsService.getOpaqueIds()).toMatchInlineSnapshot(` Map { Symbol(pluginA) => Array [], Symbol(pluginB) => Array [ @@ -165,152 +168,184 @@ test('`PluginsService.getOpaqueIds` returns dependency tree of symbols', () => { ], } `); -}); - -test('`PluginsService.setup` fails if any bundle cannot be loaded', async () => { - mockLoadPluginBundle.mockRejectedValueOnce(new Error('Could not load bundle')); - - const pluginsService = new PluginsService(mockCoreContext, plugins); - await expect(pluginsService.setup(mockSetupDeps)).rejects.toThrowErrorMatchingInlineSnapshot( - `"Could not load bundle"` - ); -}); - -test('`PluginsService.setup` fails if any plugin instance does not have a setup function', async () => { - mockPluginInitializers.set('pluginA', (() => ({})) as any); - const pluginsService = new PluginsService(mockCoreContext, plugins); - await expect(pluginsService.setup(mockSetupDeps)).rejects.toThrowErrorMatchingInlineSnapshot( - `"Instance of plugin \\"pluginA\\" does not define \\"setup\\" function."` - ); -}); - -test('`PluginsService.setup` calls loadPluginBundles with http and plugins', async () => { - const pluginsService = new PluginsService(mockCoreContext, plugins); - await pluginsService.setup(mockSetupDeps); - - expect(mockLoadPluginBundle).toHaveBeenCalledTimes(3); - expect(mockLoadPluginBundle).toHaveBeenCalledWith(mockSetupDeps.http.basePath.prepend, 'pluginA'); - expect(mockLoadPluginBundle).toHaveBeenCalledWith(mockSetupDeps.http.basePath.prepend, 'pluginB'); - expect(mockLoadPluginBundle).toHaveBeenCalledWith(mockSetupDeps.http.basePath.prepend, 'pluginC'); -}); - -test('`PluginsService.setup` initalizes plugins with PluginIntitializerContext', async () => { - const pluginsService = new PluginsService(mockCoreContext, plugins); - await pluginsService.setup(mockSetupDeps); - - expect(mockPluginInitializers.get('pluginA')).toHaveBeenCalledWith(expect.any(Object)); - expect(mockPluginInitializers.get('pluginB')).toHaveBeenCalledWith(expect.any(Object)); - expect(mockPluginInitializers.get('pluginC')).toHaveBeenCalledWith(expect.any(Object)); -}); - -test('`PluginsService.setup` exposes dependent setup contracts to plugins', async () => { - const pluginsService = new PluginsService(mockCoreContext, plugins); - await pluginsService.setup(mockSetupDeps); - - const pluginAInstance = mockPluginInitializers.get('pluginA')!.mock.results[0].value; - const pluginBInstance = mockPluginInitializers.get('pluginB')!.mock.results[0].value; - const pluginCInstance = mockPluginInitializers.get('pluginC')!.mock.results[0].value; - - expect(pluginAInstance.setup).toHaveBeenCalledWith(mockSetupContext, {}); - expect(pluginBInstance.setup).toHaveBeenCalledWith(mockSetupContext, { - pluginA: { setupValue: 1 }, - }); - // Does not supply value for `nonexist` optional dep - expect(pluginCInstance.setup).toHaveBeenCalledWith(mockSetupContext, { - pluginA: { setupValue: 1 }, + }); }); -}); - -test('`PluginsService.setup` does not set missing dependent setup contracts', async () => { - plugins = [{ id: 'pluginD', plugin: createManifest('pluginD', { optional: ['missing'] }) }]; - mockPluginInitializers.set( - 'pluginD', - jest.fn(() => ({ - setup: jest.fn(), - start: jest.fn(), - })) as any - ); - - const pluginsService = new PluginsService(mockCoreContext, plugins); - await pluginsService.setup(mockSetupDeps); - - // If a dependency is missing it should not be in the deps at all, not even as undefined. - const pluginDInstance = mockPluginInitializers.get('pluginD')!.mock.results[0].value; - expect(pluginDInstance.setup).toHaveBeenCalledWith(mockSetupContext, {}); - const pluginDDeps = pluginDInstance.setup.mock.calls[0][1]; - expect(pluginDDeps).not.toHaveProperty('missing'); -}); -test('`PluginsService.setup` returns plugin setup contracts', async () => { - const pluginsService = new PluginsService(mockCoreContext, plugins); - const { contracts } = await pluginsService.setup(mockSetupDeps); - - // Verify that plugin contracts were available - expect((contracts.get('pluginA')! as any).setupValue).toEqual(1); - expect((contracts.get('pluginB')! as any).pluginAPlusB).toEqual(2); -}); - -test('`PluginsService.start` exposes dependent start contracts to plugins', async () => { - const pluginsService = new PluginsService(mockCoreContext, plugins); - await pluginsService.setup(mockSetupDeps); - await pluginsService.start(mockStartDeps); - - const pluginAInstance = mockPluginInitializers.get('pluginA')!.mock.results[0].value; - const pluginBInstance = mockPluginInitializers.get('pluginB')!.mock.results[0].value; - const pluginCInstance = mockPluginInitializers.get('pluginC')!.mock.results[0].value; - - expect(pluginAInstance.start).toHaveBeenCalledWith(mockStartContext, {}); - expect(pluginBInstance.start).toHaveBeenCalledWith(mockStartContext, { - pluginA: { startValue: 2 }, - }); - // Does not supply value for `nonexist` optional dep - expect(pluginCInstance.start).toHaveBeenCalledWith(mockStartContext, { - pluginA: { startValue: 2 }, + describe('#setup()', () => { + it('fails if any bundle cannot be loaded', async () => { + mockLoadPluginBundle.mockRejectedValueOnce(new Error('Could not load bundle')); + + const pluginsService = new PluginsService(mockCoreContext, plugins); + await expect(pluginsService.setup(mockSetupDeps)).rejects.toThrowErrorMatchingInlineSnapshot( + `"Could not load bundle"` + ); + }); + + it('fails if any plugin instance does not have a setup function', async () => { + mockPluginInitializers.set('pluginA', (() => ({})) as any); + const pluginsService = new PluginsService(mockCoreContext, plugins); + await expect(pluginsService.setup(mockSetupDeps)).rejects.toThrowErrorMatchingInlineSnapshot( + `"Instance of plugin \\"pluginA\\" does not define \\"setup\\" function."` + ); + }); + + it('calls loadPluginBundles with http and plugins', async () => { + const pluginsService = new PluginsService(mockCoreContext, plugins); + await pluginsService.setup(mockSetupDeps); + + expect(mockLoadPluginBundle).toHaveBeenCalledTimes(3); + expect(mockLoadPluginBundle).toHaveBeenCalledWith( + mockSetupDeps.http.basePath.prepend, + 'pluginA' + ); + expect(mockLoadPluginBundle).toHaveBeenCalledWith( + mockSetupDeps.http.basePath.prepend, + 'pluginB' + ); + expect(mockLoadPluginBundle).toHaveBeenCalledWith( + mockSetupDeps.http.basePath.prepend, + 'pluginC' + ); + }); + + it('initializes plugins with PluginInitializerContext', async () => { + const pluginsService = new PluginsService(mockCoreContext, plugins); + await pluginsService.setup(mockSetupDeps); + + expect(mockPluginInitializers.get('pluginA')).toHaveBeenCalledWith(expect.any(Object)); + expect(mockPluginInitializers.get('pluginB')).toHaveBeenCalledWith(expect.any(Object)); + expect(mockPluginInitializers.get('pluginC')).toHaveBeenCalledWith(expect.any(Object)); + }); + + it('initializes plugins with associated client configuration', async () => { + const pluginConfig = { + clientProperty: 'some value', + }; + plugins[0].config = pluginConfig; + + const pluginsService = new PluginsService(mockCoreContext, plugins); + await pluginsService.setup(mockSetupDeps); + + const initializerContext = mockPluginInitializers.get('pluginA')!.mock + .calls[0][0] as PluginInitializerContext; + const config = initializerContext.config.get(); + expect(config).toMatchObject(pluginConfig); + }); + + it('exposes dependent setup contracts to plugins', async () => { + const pluginsService = new PluginsService(mockCoreContext, plugins); + await pluginsService.setup(mockSetupDeps); + + const pluginAInstance = mockPluginInitializers.get('pluginA')!.mock.results[0].value; + const pluginBInstance = mockPluginInitializers.get('pluginB')!.mock.results[0].value; + const pluginCInstance = mockPluginInitializers.get('pluginC')!.mock.results[0].value; + + expect(pluginAInstance.setup).toHaveBeenCalledWith(mockSetupContext, {}); + expect(pluginBInstance.setup).toHaveBeenCalledWith(mockSetupContext, { + pluginA: { setupValue: 1 }, + }); + // Does not supply value for `nonexist` optional dep + expect(pluginCInstance.setup).toHaveBeenCalledWith(mockSetupContext, { + pluginA: { setupValue: 1 }, + }); + }); + + it('does not set missing dependent setup contracts', async () => { + plugins = [{ id: 'pluginD', plugin: createManifest('pluginD', { optional: ['missing'] }) }]; + mockPluginInitializers.set( + 'pluginD', + jest.fn(() => ({ + setup: jest.fn(), + start: jest.fn(), + })) as any + ); + + const pluginsService = new PluginsService(mockCoreContext, plugins); + await pluginsService.setup(mockSetupDeps); + + // If a dependency is missing it should not be in the deps at all, not even as undefined. + const pluginDInstance = mockPluginInitializers.get('pluginD')!.mock.results[0].value; + expect(pluginDInstance.setup).toHaveBeenCalledWith(mockSetupContext, {}); + const pluginDDeps = pluginDInstance.setup.mock.calls[0][1]; + expect(pluginDDeps).not.toHaveProperty('missing'); + }); + + it('returns plugin setup contracts', async () => { + const pluginsService = new PluginsService(mockCoreContext, plugins); + const { contracts } = await pluginsService.setup(mockSetupDeps); + + // Verify that plugin contracts were available + expect((contracts.get('pluginA')! as any).setupValue).toEqual(1); + expect((contracts.get('pluginB')! as any).pluginAPlusB).toEqual(2); + }); }); -}); -test('`PluginsService.start` does not set missing dependent start contracts', async () => { - plugins = [{ id: 'pluginD', plugin: createManifest('pluginD', { optional: ['missing'] }) }]; - mockPluginInitializers.set( - 'pluginD', - jest.fn(() => ({ - setup: jest.fn(), - start: jest.fn(), - })) as any - ); - - const pluginsService = new PluginsService(mockCoreContext, plugins); - await pluginsService.setup(mockSetupDeps); - await pluginsService.start(mockStartDeps); - - // If a dependency is missing it should not be in the deps at all, not even as undefined. - const pluginDInstance = mockPluginInitializers.get('pluginD')!.mock.results[0].value; - expect(pluginDInstance.start).toHaveBeenCalledWith(mockStartContext, {}); - const pluginDDeps = pluginDInstance.start.mock.calls[0][1]; - expect(pluginDDeps).not.toHaveProperty('missing'); -}); - -test('`PluginsService.start` returns plugin start contracts', async () => { - const pluginsService = new PluginsService(mockCoreContext, plugins); - await pluginsService.setup(mockSetupDeps); - const { contracts } = await pluginsService.start(mockStartDeps); - - // Verify that plugin contracts were available - expect((contracts.get('pluginA')! as any).startValue).toEqual(2); - expect((contracts.get('pluginB')! as any).pluginAPlusB).toEqual(3); -}); + describe('#start()', () => { + it('exposes dependent start contracts to plugins', async () => { + const pluginsService = new PluginsService(mockCoreContext, plugins); + await pluginsService.setup(mockSetupDeps); + await pluginsService.start(mockStartDeps); + + const pluginAInstance = mockPluginInitializers.get('pluginA')!.mock.results[0].value; + const pluginBInstance = mockPluginInitializers.get('pluginB')!.mock.results[0].value; + const pluginCInstance = mockPluginInitializers.get('pluginC')!.mock.results[0].value; + + expect(pluginAInstance.start).toHaveBeenCalledWith(mockStartContext, {}); + expect(pluginBInstance.start).toHaveBeenCalledWith(mockStartContext, { + pluginA: { startValue: 2 }, + }); + // Does not supply value for `nonexist` optional dep + expect(pluginCInstance.start).toHaveBeenCalledWith(mockStartContext, { + pluginA: { startValue: 2 }, + }); + }); + + it('does not set missing dependent start contracts', async () => { + plugins = [{ id: 'pluginD', plugin: createManifest('pluginD', { optional: ['missing'] }) }]; + mockPluginInitializers.set( + 'pluginD', + jest.fn(() => ({ + setup: jest.fn(), + start: jest.fn(), + })) as any + ); + + const pluginsService = new PluginsService(mockCoreContext, plugins); + await pluginsService.setup(mockSetupDeps); + await pluginsService.start(mockStartDeps); + + // If a dependency is missing it should not be in the deps at all, not even as undefined. + const pluginDInstance = mockPluginInitializers.get('pluginD')!.mock.results[0].value; + expect(pluginDInstance.start).toHaveBeenCalledWith(mockStartContext, {}); + const pluginDDeps = pluginDInstance.start.mock.calls[0][1]; + expect(pluginDDeps).not.toHaveProperty('missing'); + }); + + it('returns plugin start contracts', async () => { + const pluginsService = new PluginsService(mockCoreContext, plugins); + await pluginsService.setup(mockSetupDeps); + const { contracts } = await pluginsService.start(mockStartDeps); + + // Verify that plugin contracts were available + expect((contracts.get('pluginA')! as any).startValue).toEqual(2); + expect((contracts.get('pluginB')! as any).pluginAPlusB).toEqual(3); + }); + }); -test('`PluginService.stop` calls the stop function on each plugin', async () => { - const pluginsService = new PluginsService(mockCoreContext, plugins); - await pluginsService.setup(mockSetupDeps); + describe('#stop()', () => { + it('calls the stop function on each plugin', async () => { + const pluginsService = new PluginsService(mockCoreContext, plugins); + await pluginsService.setup(mockSetupDeps); - const pluginAInstance = mockPluginInitializers.get('pluginA')!.mock.results[0].value; - const pluginBInstance = mockPluginInitializers.get('pluginB')!.mock.results[0].value; - const pluginCInstance = mockPluginInitializers.get('pluginC')!.mock.results[0].value; + const pluginAInstance = mockPluginInitializers.get('pluginA')!.mock.results[0].value; + const pluginBInstance = mockPluginInitializers.get('pluginB')!.mock.results[0].value; + const pluginCInstance = mockPluginInitializers.get('pluginC')!.mock.results[0].value; - await pluginsService.stop(); + await pluginsService.stop(); - expect(pluginAInstance.stop).toHaveBeenCalled(); - expect(pluginBInstance.stop).toHaveBeenCalled(); - expect(pluginCInstance.stop).toHaveBeenCalled(); + expect(pluginAInstance.stop).toHaveBeenCalled(); + expect(pluginBInstance.stop).toHaveBeenCalled(); + expect(pluginCInstance.stop).toHaveBeenCalled(); + }); + }); }); diff --git a/src/core/public/plugins/plugins_service.ts b/src/core/public/plugins/plugins_service.ts index 1ab9d7f2fa9b29..c1939a33976474 100644 --- a/src/core/public/plugins/plugins_service.ts +++ b/src/core/public/plugins/plugins_service.ts @@ -17,7 +17,7 @@ * under the License. */ -import { DiscoveredPlugin, PluginName, PluginOpaqueId } from '../../server'; +import { PluginName, PluginOpaqueId } from '../../server'; import { CoreService } from '../../types'; import { CoreContext } from '../core_system'; import { PluginWrapper } from './plugin'; @@ -27,6 +27,7 @@ import { createPluginStartContext, } from './plugin_context'; import { InternalCoreSetup, InternalCoreStart } from '../core_system'; +import { InjectedPluginMetadata } from '../injected_metadata'; /** @internal */ export type PluginsServiceSetupDeps = InternalCoreSetup; @@ -55,15 +56,12 @@ export class PluginsService implements CoreService - ) { + constructor(private readonly coreContext: CoreContext, plugins: InjectedPluginMetadata[]) { // Generate opaque ids const opaqueIds = new Map(plugins.map(p => [p.id, Symbol(p.id)])); // Setup dependency map and plugin wrappers - plugins.forEach(({ id, plugin }) => { + plugins.forEach(({ id, plugin, config = {} }) => { // Setup map of dependencies this.pluginDependencies.set(id, [ ...plugin.requiredPlugins, @@ -76,7 +74,7 @@ export class PluginsService implements CoreService = (core: PluginInitializerContext) => Plugin; // @public -export interface PluginInitializerContext { +export interface PluginInitializerContext { + // (undocumented) + readonly config: { + get: () => T; + }; // (undocumented) readonly env: { mode: Readonly; diff --git a/src/core/server/index.ts b/src/core/server/index.ts index 2a5631ad1c3801..987e4e64f9d5b8 100644 --- a/src/core/server/index.ts +++ b/src/core/server/index.ts @@ -123,6 +123,8 @@ export { Logger, LoggerFactory, LogMeta, LogRecord, LogLevel } from './logging'; export { DiscoveredPlugin, Plugin, + PluginConfigDescriptor, + PluginConfigSchema, PluginInitializer, PluginInitializerContext, PluginManifest, diff --git a/src/core/server/legacy/legacy_service.test.ts b/src/core/server/legacy/legacy_service.test.ts index e2aefd846d9788..1240518422e2fb 100644 --- a/src/core/server/legacy/legacy_service.test.ts +++ b/src/core/server/legacy/legacy_service.test.ts @@ -86,6 +86,7 @@ beforeEach(() => { public: new Map([['plugin-id', {} as DiscoveredPlugin]]), internal: new Map([['plugin-id', {} as DiscoveredPluginInternal]]), }, + uiPluginConfigs: new Map(), }, }, plugins: { 'plugin-id': 'plugin-value' }, diff --git a/src/core/server/legacy/legacy_service.ts b/src/core/server/legacy/legacy_service.ts index 99963ad9ce3e89..e86e6cde6e927d 100644 --- a/src/core/server/legacy/legacy_service.ts +++ b/src/core/server/legacy/legacy_service.ts @@ -278,6 +278,7 @@ export class LegacyService implements CoreService { hapiServer: setupDeps.core.http.server, kibanaMigrator: startDeps.core.savedObjects.migrator, uiPlugins: setupDeps.core.plugins.uiPlugins, + uiPluginConfigs: setupDeps.core.plugins.uiPluginConfigs, elasticsearch: setupDeps.core.elasticsearch, uiSettings: setupDeps.core.uiSettings, savedObjectsClientProvider: startDeps.core.savedObjects.clientProvider, diff --git a/src/core/server/plugins/plugin.test.ts b/src/core/server/plugins/plugin.test.ts index e457f01a1941c3..6aab03a01675d5 100644 --- a/src/core/server/plugins/plugin.test.ts +++ b/src/core/server/plugins/plugin.test.ts @@ -291,12 +291,13 @@ test('`stop` calls `stop` defined by the plugin instance', async () => { describe('#getConfigSchema()', () => { it('reads config schema from plugin', () => { const pluginSchema = schema.any(); + const configDescriptor = { + schema: pluginSchema, + }; jest.doMock( 'plugin-with-schema/server', () => ({ - config: { - schema: pluginSchema, - }, + config: configDescriptor, }), { virtual: true } ); @@ -309,7 +310,7 @@ describe('#getConfigSchema()', () => { initializerContext: createPluginInitializerContext(coreContext, opaqueId, manifest), }); - expect(plugin.getConfigSchema()).toBe(pluginSchema); + expect(plugin.getConfigDescriptor()).toBe(configDescriptor); }); it('returns null if config definition not specified', () => { @@ -322,7 +323,7 @@ describe('#getConfigSchema()', () => { opaqueId, initializerContext: createPluginInitializerContext(coreContext, opaqueId, manifest), }); - expect(plugin.getConfigSchema()).toBe(null); + expect(plugin.getConfigDescriptor()).toBe(null); }); it('returns null for plugins without a server part', () => { @@ -334,7 +335,7 @@ describe('#getConfigSchema()', () => { opaqueId, initializerContext: createPluginInitializerContext(coreContext, opaqueId, manifest), }); - expect(plugin.getConfigSchema()).toBe(null); + expect(plugin.getConfigDescriptor()).toBe(null); }); it('throws if plugin contains invalid schema', () => { @@ -357,7 +358,7 @@ describe('#getConfigSchema()', () => { opaqueId, initializerContext: createPluginInitializerContext(coreContext, opaqueId, manifest), }); - expect(() => plugin.getConfigSchema()).toThrowErrorMatchingInlineSnapshot( + expect(() => plugin.getConfigDescriptor()).toThrowErrorMatchingInlineSnapshot( `"Configuration schema expected to be an instance of Type"` ); }); diff --git a/src/core/server/plugins/plugin.ts b/src/core/server/plugins/plugin.ts index ff61d8033a4848..c0b484515ccce2 100644 --- a/src/core/server/plugins/plugin.ts +++ b/src/core/server/plugins/plugin.ts @@ -27,9 +27,9 @@ import { Plugin, PluginInitializerContext, PluginManifest, - PluginConfigSchema, PluginInitializer, PluginOpaqueId, + PluginConfigDescriptor, } from './types'; import { CoreSetup, CoreStart } from '..'; @@ -128,7 +128,7 @@ export class PluginWrapper< this.instance = undefined; } - public getConfigSchema(): PluginConfigSchema { + public getConfigDescriptor(): PluginConfigDescriptor | null { if (!this.manifest.server) { return null; } @@ -141,10 +141,11 @@ export class PluginWrapper< return null; } - if (!(pluginDefinition.config.schema instanceof Type)) { + const configDescriptor = pluginDefinition.config; + if (!(configDescriptor.schema instanceof Type)) { throw new Error('Configuration schema expected to be an instance of Type'); } - return pluginDefinition.config.schema; + return configDescriptor; } private createPluginInstance() { diff --git a/src/core/server/plugins/plugins_service.mock.ts b/src/core/server/plugins/plugins_service.mock.ts index c8b6bed044fd7e..e3be8fbb983090 100644 --- a/src/core/server/plugins/plugins_service.mock.ts +++ b/src/core/server/plugins/plugins_service.mock.ts @@ -33,6 +33,7 @@ const createServiceMock = () => { public: new Map(), internal: new Map(), }, + uiPluginConfigs: new Map(), }); mocked.start.mockResolvedValue({ contracts: new Map() }); return mocked; diff --git a/src/core/server/plugins/plugins_service.test.ts b/src/core/server/plugins/plugins_service.test.ts index 0b3bc0759463c1..da6d1d5a010e7a 100644 --- a/src/core/server/plugins/plugins_service.test.ts +++ b/src/core/server/plugins/plugins_service.test.ts @@ -32,6 +32,8 @@ import { PluginWrapper } from './plugin'; import { PluginsService } from './plugins_service'; import { PluginsSystem } from './plugins_system'; import { config } from './plugins_config'; +import { take } from 'rxjs/operators'; +import { DiscoveredPluginInternal } from './types'; const MockPluginsSystem: jest.Mock = PluginsSystem as any; @@ -90,301 +92,398 @@ const createPlugin = ( }); }; -beforeEach(async () => { - mockPackage.raw = { - branch: 'feature-v1', - version: 'v1', - build: { - distributable: true, - number: 100, - sha: 'feature-v1-build-sha', - }, - }; - - coreId = Symbol('core'); - env = Env.createDefault(getEnvOptions()); - - configService = new ConfigService( - new BehaviorSubject(new ObjectToConfigAdapter({ plugins: { initialize: true } })), - env, - logger - ); - await configService.setSchema(config.path, config.schema); - pluginsService = new PluginsService({ coreId, env, logger, configService }); +describe('PluginsService', () => { + beforeEach(async () => { + mockPackage.raw = { + branch: 'feature-v1', + version: 'v1', + build: { + distributable: true, + number: 100, + sha: 'feature-v1-build-sha', + }, + }; - [mockPluginSystem] = MockPluginsSystem.mock.instances as any; -}); + coreId = Symbol('core'); + env = Env.createDefault(getEnvOptions()); -afterEach(() => { - jest.clearAllMocks(); -}); + configService = new ConfigService( + new BehaviorSubject(new ObjectToConfigAdapter({ plugins: { initialize: true } })), + env, + logger + ); + await configService.setSchema(config.path, config.schema); + pluginsService = new PluginsService({ coreId, env, logger, configService }); -test('`discover` throws if plugin has an invalid manifest', async () => { - mockDiscover.mockReturnValue({ - error$: from([PluginDiscoveryError.invalidManifest('path-1', new Error('Invalid JSON'))]), - plugin$: from([]), + [mockPluginSystem] = MockPluginsSystem.mock.instances as any; }); - await expect(pluginsService.discover()).rejects.toMatchInlineSnapshot(` -[Error: Failed to initialize plugins: - Invalid JSON (invalid-manifest, path-1)] -`); - expect(loggingServiceMock.collect(logger).error).toMatchInlineSnapshot(` -Array [ - Array [ - [Error: Invalid JSON (invalid-manifest, path-1)], - ], -] -`); -}); - -test('`discover` throws if plugin required Kibana version is incompatible with the current version', async () => { - mockDiscover.mockReturnValue({ - error$: from([ - PluginDiscoveryError.incompatibleVersion('path-3', new Error('Incompatible version')), - ]), - plugin$: from([]), + afterEach(() => { + jest.clearAllMocks(); }); - await expect(pluginsService.discover()).rejects.toMatchInlineSnapshot(` -[Error: Failed to initialize plugins: - Incompatible version (incompatible-version, path-3)] -`); - expect(loggingServiceMock.collect(logger).error).toMatchInlineSnapshot(` -Array [ - Array [ - [Error: Incompatible version (incompatible-version, path-3)], - ], -] -`); -}); - -test('`discover` throws if discovered plugins with conflicting names', async () => { - mockDiscover.mockReturnValue({ - error$: from([]), - plugin$: from([ - createPlugin('conflicting-id', { - path: 'path-4', - version: 'some-version', - configPath: 'path', - requiredPlugins: ['some-required-plugin', 'some-required-plugin-2'], - optionalPlugins: ['some-optional-plugin'], - }), - createPlugin('conflicting-id', { - path: 'path-4', - version: 'some-version', - configPath: 'path', - requiredPlugins: ['some-required-plugin', 'some-required-plugin-2'], - optionalPlugins: ['some-optional-plugin'], - }), - ]), - }); - - await expect(pluginsService.discover()).rejects.toMatchInlineSnapshot( - `[Error: Plugin with id "conflicting-id" is already registered!]` - ); + describe('#discover()', () => { + it('throws if plugin has an invalid manifest', async () => { + mockDiscover.mockReturnValue({ + error$: from([PluginDiscoveryError.invalidManifest('path-1', new Error('Invalid JSON'))]), + plugin$: from([]), + }); + + await expect(pluginsService.discover()).rejects.toMatchInlineSnapshot(` + [Error: Failed to initialize plugins: + Invalid JSON (invalid-manifest, path-1)] + `); + expect(loggingServiceMock.collect(logger).error).toMatchInlineSnapshot(` + Array [ + Array [ + [Error: Invalid JSON (invalid-manifest, path-1)], + ], + ] + `); + }); + + it('throws if plugin required Kibana version is incompatible with the current version', async () => { + mockDiscover.mockReturnValue({ + error$: from([ + PluginDiscoveryError.incompatibleVersion('path-3', new Error('Incompatible version')), + ]), + plugin$: from([]), + }); + + await expect(pluginsService.discover()).rejects.toMatchInlineSnapshot(` + [Error: Failed to initialize plugins: + Incompatible version (incompatible-version, path-3)] + `); + expect(loggingServiceMock.collect(logger).error).toMatchInlineSnapshot(` + Array [ + Array [ + [Error: Incompatible version (incompatible-version, path-3)], + ], + ] + `); + }); + + it('throws if discovered plugins with conflicting names', async () => { + mockDiscover.mockReturnValue({ + error$: from([]), + plugin$: from([ + createPlugin('conflicting-id', { + path: 'path-4', + version: 'some-version', + configPath: 'path', + requiredPlugins: ['some-required-plugin', 'some-required-plugin-2'], + optionalPlugins: ['some-optional-plugin'], + }), + createPlugin('conflicting-id', { + path: 'path-4', + version: 'some-version', + configPath: 'path', + requiredPlugins: ['some-required-plugin', 'some-required-plugin-2'], + optionalPlugins: ['some-optional-plugin'], + }), + ]), + }); + + await expect(pluginsService.discover()).rejects.toMatchInlineSnapshot( + `[Error: Plugin with id "conflicting-id" is already registered!]` + ); + + expect(mockPluginSystem.addPlugin).not.toHaveBeenCalled(); + expect(mockPluginSystem.setupPlugins).not.toHaveBeenCalled(); + }); + + it('properly detects plugins that should be disabled.', async () => { + jest + .spyOn(configService, 'isEnabledAtPath') + .mockImplementation(path => Promise.resolve(!path.includes('disabled'))); + + mockPluginSystem.setupPlugins.mockResolvedValue(new Map()); + mockPluginSystem.uiPlugins.mockReturnValue({ public: new Map(), internal: new Map() }); + + mockDiscover.mockReturnValue({ + error$: from([]), + plugin$: from([ + createPlugin('explicitly-disabled-plugin', { + disabled: true, + path: 'path-1', + configPath: 'path-1', + }), + createPlugin('plugin-with-missing-required-deps', { + path: 'path-2', + configPath: 'path-2', + requiredPlugins: ['missing-plugin'], + }), + createPlugin('plugin-with-disabled-transitive-dep', { + path: 'path-3', + configPath: 'path-3', + requiredPlugins: ['another-explicitly-disabled-plugin'], + }), + createPlugin('another-explicitly-disabled-plugin', { + disabled: true, + path: 'path-4', + configPath: 'path-4-disabled', + }), + ]), + }); + + await pluginsService.discover(); + const setup = await pluginsService.setup(setupDeps); + + expect(setup.contracts).toBeInstanceOf(Map); + expect(setup.uiPlugins.public).toBeInstanceOf(Map); + expect(setup.uiPlugins.internal).toBeInstanceOf(Map); + expect(mockPluginSystem.addPlugin).not.toHaveBeenCalled(); + expect(mockPluginSystem.setupPlugins).toHaveBeenCalledTimes(1); + expect(mockPluginSystem.setupPlugins).toHaveBeenCalledWith(setupDeps); + + expect(loggingServiceMock.collect(logger).info).toMatchInlineSnapshot(` + Array [ + Array [ + "Plugin \\"explicitly-disabled-plugin\\" is disabled.", + ], + Array [ + "Plugin \\"plugin-with-missing-required-deps\\" has been disabled since some of its direct or transitive dependencies are missing or disabled.", + ], + Array [ + "Plugin \\"plugin-with-disabled-transitive-dep\\" has been disabled since some of its direct or transitive dependencies are missing or disabled.", + ], + Array [ + "Plugin \\"another-explicitly-disabled-plugin\\" is disabled.", + ], + ] + `); + }); + + it('does not throw in case of mutual plugin dependencies', async () => { + const firstPlugin = createPlugin('first-plugin', { + path: 'path-1', + requiredPlugins: ['second-plugin'], + }); + const secondPlugin = createPlugin('second-plugin', { + path: 'path-2', + requiredPlugins: ['first-plugin'], + }); - expect(mockPluginSystem.addPlugin).not.toHaveBeenCalled(); - expect(mockPluginSystem.setupPlugins).not.toHaveBeenCalled(); -}); + mockDiscover.mockReturnValue({ + error$: from([]), + plugin$: from([firstPlugin, secondPlugin]), + }); -test('`discover` properly detects plugins that should be disabled.', async () => { - jest - .spyOn(configService, 'isEnabledAtPath') - .mockImplementation(path => Promise.resolve(!path.includes('disabled'))); + await expect(pluginsService.discover()).resolves.toBeUndefined(); - mockPluginSystem.setupPlugins.mockResolvedValue(new Map()); - mockPluginSystem.uiPlugins.mockReturnValue({ public: new Map(), internal: new Map() }); + expect(mockDiscover).toHaveBeenCalledTimes(1); + expect(mockPluginSystem.addPlugin).toHaveBeenCalledTimes(2); + expect(mockPluginSystem.addPlugin).toHaveBeenCalledWith(firstPlugin); + expect(mockPluginSystem.addPlugin).toHaveBeenCalledWith(secondPlugin); + }); - mockDiscover.mockReturnValue({ - error$: from([]), - plugin$: from([ - createPlugin('explicitly-disabled-plugin', { - disabled: true, + it('does not throw in case of cyclic plugin dependencies', async () => { + const firstPlugin = createPlugin('first-plugin', { path: 'path-1', - configPath: 'path-1', - }), - createPlugin('plugin-with-missing-required-deps', { + requiredPlugins: ['second-plugin'], + }); + const secondPlugin = createPlugin('second-plugin', { path: 'path-2', - configPath: 'path-2', - requiredPlugins: ['missing-plugin'], - }), - createPlugin('plugin-with-disabled-transitive-dep', { + requiredPlugins: ['third-plugin', 'last-plugin'], + }); + const thirdPlugin = createPlugin('third-plugin', { path: 'path-3', - configPath: 'path-3', - requiredPlugins: ['another-explicitly-disabled-plugin'], - }), - createPlugin('another-explicitly-disabled-plugin', { - disabled: true, + requiredPlugins: ['last-plugin', 'first-plugin'], + }); + const lastPlugin = createPlugin('last-plugin', { path: 'path-4', - configPath: 'path-4-disabled', - }), - ]), - }); - - await pluginsService.discover(); - const setup = await pluginsService.setup(setupDeps); - - expect(setup.contracts).toBeInstanceOf(Map); - expect(setup.uiPlugins.public).toBeInstanceOf(Map); - expect(setup.uiPlugins.internal).toBeInstanceOf(Map); - expect(mockPluginSystem.addPlugin).not.toHaveBeenCalled(); - expect(mockPluginSystem.setupPlugins).toHaveBeenCalledTimes(1); - expect(mockPluginSystem.setupPlugins).toHaveBeenCalledWith(setupDeps); - - expect(loggingServiceMock.collect(logger).info).toMatchInlineSnapshot(` -Array [ - Array [ - "Plugin \\"explicitly-disabled-plugin\\" is disabled.", - ], - Array [ - "Plugin \\"plugin-with-missing-required-deps\\" has been disabled since some of its direct or transitive dependencies are missing or disabled.", - ], - Array [ - "Plugin \\"plugin-with-disabled-transitive-dep\\" has been disabled since some of its direct or transitive dependencies are missing or disabled.", - ], - Array [ - "Plugin \\"another-explicitly-disabled-plugin\\" is disabled.", - ], -] -`); -}); - -test('`discover` does not throw in case of mutual plugin dependencies', async () => { - const firstPlugin = createPlugin('first-plugin', { - path: 'path-1', - requiredPlugins: ['second-plugin'], - }); - const secondPlugin = createPlugin('second-plugin', { - path: 'path-2', - requiredPlugins: ['first-plugin'], - }); - - mockDiscover.mockReturnValue({ - error$: from([]), - plugin$: from([firstPlugin, secondPlugin]), - }); - - await expect(pluginsService.discover()).resolves.toBeUndefined(); - - expect(mockDiscover).toHaveBeenCalledTimes(1); - expect(mockPluginSystem.addPlugin).toHaveBeenCalledTimes(2); - expect(mockPluginSystem.addPlugin).toHaveBeenCalledWith(firstPlugin); - expect(mockPluginSystem.addPlugin).toHaveBeenCalledWith(secondPlugin); -}); - -test('`discover` does not throw in case of cyclic plugin dependencies', async () => { - const firstPlugin = createPlugin('first-plugin', { - path: 'path-1', - requiredPlugins: ['second-plugin'], - }); - const secondPlugin = createPlugin('second-plugin', { - path: 'path-2', - requiredPlugins: ['third-plugin', 'last-plugin'], - }); - const thirdPlugin = createPlugin('third-plugin', { - path: 'path-3', - requiredPlugins: ['last-plugin', 'first-plugin'], - }); - const lastPlugin = createPlugin('last-plugin', { - path: 'path-4', - requiredPlugins: ['first-plugin'], - }); - const missingDepsPlugin = createPlugin('missing-deps-plugin', { - path: 'path-5', - requiredPlugins: ['not-a-plugin'], - }); - - mockDiscover.mockReturnValue({ - error$: from([]), - plugin$: from([firstPlugin, secondPlugin, thirdPlugin, lastPlugin, missingDepsPlugin]), - }); - - await expect(pluginsService.discover()).resolves.toBeUndefined(); - - expect(mockDiscover).toHaveBeenCalledTimes(1); - expect(mockPluginSystem.addPlugin).toHaveBeenCalledTimes(4); - expect(mockPluginSystem.addPlugin).toHaveBeenCalledWith(firstPlugin); - expect(mockPluginSystem.addPlugin).toHaveBeenCalledWith(secondPlugin); - expect(mockPluginSystem.addPlugin).toHaveBeenCalledWith(thirdPlugin); - expect(mockPluginSystem.addPlugin).toHaveBeenCalledWith(lastPlugin); -}); - -test('`discover` properly invokes plugin discovery and ignores non-critical errors.', async () => { - const firstPlugin = createPlugin('some-id', { - path: 'path-1', - configPath: 'path', - requiredPlugins: ['some-other-id'], - optionalPlugins: ['missing-optional-dep'], - }); - const secondPlugin = createPlugin('some-other-id', { - path: 'path-2', - version: 'some-other-version', - configPath: ['plugin', 'path'], - }); - - mockDiscover.mockReturnValue({ - error$: from([ - PluginDiscoveryError.missingManifest('path-2', new Error('No manifest')), - PluginDiscoveryError.invalidSearchPath('dir-1', new Error('No dir')), - PluginDiscoveryError.invalidPluginPath('path4-1', new Error('No path')), - ]), - plugin$: from([firstPlugin, secondPlugin]), + requiredPlugins: ['first-plugin'], + }); + const missingDepsPlugin = createPlugin('missing-deps-plugin', { + path: 'path-5', + requiredPlugins: ['not-a-plugin'], + }); + + mockDiscover.mockReturnValue({ + error$: from([]), + plugin$: from([firstPlugin, secondPlugin, thirdPlugin, lastPlugin, missingDepsPlugin]), + }); + + await expect(pluginsService.discover()).resolves.toBeUndefined(); + + expect(mockDiscover).toHaveBeenCalledTimes(1); + expect(mockPluginSystem.addPlugin).toHaveBeenCalledTimes(4); + expect(mockPluginSystem.addPlugin).toHaveBeenCalledWith(firstPlugin); + expect(mockPluginSystem.addPlugin).toHaveBeenCalledWith(secondPlugin); + expect(mockPluginSystem.addPlugin).toHaveBeenCalledWith(thirdPlugin); + expect(mockPluginSystem.addPlugin).toHaveBeenCalledWith(lastPlugin); + }); + + it('properly invokes plugin discovery and ignores non-critical errors.', async () => { + const firstPlugin = createPlugin('some-id', { + path: 'path-1', + configPath: 'path', + requiredPlugins: ['some-other-id'], + optionalPlugins: ['missing-optional-dep'], + }); + const secondPlugin = createPlugin('some-other-id', { + path: 'path-2', + version: 'some-other-version', + configPath: ['plugin', 'path'], + }); + + mockDiscover.mockReturnValue({ + error$: from([ + PluginDiscoveryError.missingManifest('path-2', new Error('No manifest')), + PluginDiscoveryError.invalidSearchPath('dir-1', new Error('No dir')), + PluginDiscoveryError.invalidPluginPath('path4-1', new Error('No path')), + ]), + plugin$: from([firstPlugin, secondPlugin]), + }); + + await pluginsService.discover(); + expect(mockPluginSystem.addPlugin).toHaveBeenCalledTimes(2); + expect(mockPluginSystem.addPlugin).toHaveBeenCalledWith(firstPlugin); + expect(mockPluginSystem.addPlugin).toHaveBeenCalledWith(secondPlugin); + + expect(mockDiscover).toHaveBeenCalledTimes(1); + expect(mockDiscover).toHaveBeenCalledWith( + { + additionalPluginPaths: [], + initialize: true, + pluginSearchPaths: [ + resolve(process.cwd(), 'src', 'plugins'), + resolve(process.cwd(), 'x-pack', 'plugins'), + resolve(process.cwd(), 'plugins'), + resolve(process.cwd(), '..', 'kibana-extra'), + ], + }, + { coreId, env, logger, configService } + ); + + const logs = loggingServiceMock.collect(logger); + expect(logs.info).toHaveLength(0); + expect(logs.error).toHaveLength(0); + }); + + it('registers plugin config schema in config service', async () => { + const configSchema = schema.string(); + jest.spyOn(configService, 'setSchema').mockImplementation(() => Promise.resolve()); + jest.doMock( + join('path-with-schema', 'server'), + () => ({ + config: { + schema: configSchema, + }, + }), + { + virtual: true, + } + ); + mockDiscover.mockReturnValue({ + error$: from([]), + plugin$: from([ + createPlugin('some-id', { + path: 'path-with-schema', + configPath: 'path', + }), + ]), + }); + await pluginsService.discover(); + expect(configService.setSchema).toBeCalledWith('path', configSchema); + }); }); - await pluginsService.discover(); - expect(mockPluginSystem.addPlugin).toHaveBeenCalledTimes(2); - expect(mockPluginSystem.addPlugin).toHaveBeenCalledWith(firstPlugin); - expect(mockPluginSystem.addPlugin).toHaveBeenCalledWith(secondPlugin); - - expect(mockDiscover).toHaveBeenCalledTimes(1); - expect(mockDiscover).toHaveBeenCalledWith( - { - additionalPluginPaths: [], - initialize: true, - pluginSearchPaths: [ - resolve(process.cwd(), 'src', 'plugins'), - resolve(process.cwd(), 'x-pack', 'plugins'), - resolve(process.cwd(), 'plugins'), - resolve(process.cwd(), '..', 'kibana-extra'), - ], - }, - { coreId, env, logger, configService } - ); - - const logs = loggingServiceMock.collect(logger); - expect(logs.info).toHaveLength(0); - expect(logs.error).toHaveLength(0); -}); - -test('`stop` stops plugins system', async () => { - await pluginsService.stop(); - expect(mockPluginSystem.stopPlugins).toHaveBeenCalledTimes(1); -}); - -test('`discover` registers plugin config schema in config service', async () => { - const configSchema = schema.string(); - jest.spyOn(configService, 'setSchema').mockImplementation(() => Promise.resolve()); - jest.doMock( - join('path-with-schema', 'server'), - () => ({ - config: { - schema: configSchema, + describe('#generateUiPluginsConfigs()', () => { + const pluginToDiscoveredEntry = (plugin: PluginWrapper): [string, DiscoveredPluginInternal] => [ + plugin.name, + { + id: plugin.name, + path: plugin.path, + configPath: plugin.manifest.configPath, + requiredPlugins: [], + optionalPlugins: [], }, - }), - { - virtual: true, - } - ); - mockDiscover.mockReturnValue({ - error$: from([]), - plugin$: from([ - createPlugin('some-id', { - path: 'path-with-schema', + ]; + + it('properly generates client configs for plugins according to `exposeToBrowser`', async () => { + jest.doMock( + join('plugin-with-expose', 'server'), + () => ({ + config: { + exposeToBrowser: { + sharedProp: true, + }, + schema: schema.object({ + serverProp: schema.string({ defaultValue: 'serverProp default value' }), + sharedProp: schema.string({ defaultValue: 'sharedProp default value' }), + }), + }, + }), + { + virtual: true, + } + ); + const plugin = createPlugin('plugin-with-expose', { + path: 'plugin-with-expose', configPath: 'path', - }), - ]), + }); + mockDiscover.mockReturnValue({ + error$: from([]), + plugin$: from([plugin]), + }); + mockPluginSystem.uiPlugins.mockReturnValue({ + public: new Map([pluginToDiscoveredEntry(plugin)]), + internal: new Map([pluginToDiscoveredEntry(plugin)]), + }); + + await pluginsService.discover(); + const { uiPluginConfigs } = await pluginsService.setup(setupDeps); + + const uiConfig$ = uiPluginConfigs.get('plugin-with-expose'); + expect(uiConfig$).toBeDefined(); + + const uiConfig = await uiConfig$!.pipe(take(1)).toPromise(); + expect(uiConfig).toMatchInlineSnapshot(` + Object { + "sharedProp": "sharedProp default value", + } + `); + }); + + it('does not generate config for plugins not exposing to client', async () => { + jest.doMock( + join('plugin-without-expose', 'server'), + () => ({ + config: { + schema: schema.object({ + serverProp: schema.string({ defaultValue: 'serverProp default value' }), + }), + }, + }), + { + virtual: true, + } + ); + const plugin = createPlugin('plugin-without-expose', { + path: 'plugin-without-expose', + configPath: 'path', + }); + mockDiscover.mockReturnValue({ + error$: from([]), + plugin$: from([plugin]), + }); + mockPluginSystem.uiPlugins.mockReturnValue({ + public: new Map([pluginToDiscoveredEntry(plugin)]), + internal: new Map([pluginToDiscoveredEntry(plugin)]), + }); + + await pluginsService.discover(); + const { uiPluginConfigs } = await pluginsService.setup(setupDeps); + + expect([...uiPluginConfigs.entries()]).toHaveLength(0); + }); + }); + + describe('#stop()', () => { + it('`stop` stops plugins system', async () => { + await pluginsService.stop(); + expect(mockPluginSystem.stopPlugins).toHaveBeenCalledTimes(1); + }); }); - await pluginsService.discover(); - expect(configService.setSchema).toBeCalledWith('path', configSchema); }); diff --git a/src/core/server/plugins/plugins_service.ts b/src/core/server/plugins/plugins_service.ts index 38fe519567a635..79c9489a8b4c00 100644 --- a/src/core/server/plugins/plugins_service.ts +++ b/src/core/server/plugins/plugins_service.ts @@ -25,10 +25,17 @@ import { CoreContext } from '../core_context'; import { Logger } from '../logging'; import { discover, PluginDiscoveryError, PluginDiscoveryErrorType } from './discovery'; import { PluginWrapper } from './plugin'; -import { DiscoveredPlugin, DiscoveredPluginInternal, PluginName } from './types'; +import { + DiscoveredPlugin, + DiscoveredPluginInternal, + PluginConfigDescriptor, + PluginName, +} from './types'; import { PluginsConfig, PluginsConfigType } from './plugins_config'; import { PluginsSystem } from './plugins_system'; import { InternalCoreSetup } from '../internal_types'; +import { IConfigService } from '../config'; +import { pick } from '../../utils'; /** @public */ export interface PluginsServiceSetup { @@ -37,6 +44,7 @@ export interface PluginsServiceSetup { public: Map; internal: Map; }; + uiPluginConfigs: Map>; } /** @public */ @@ -54,11 +62,14 @@ export interface PluginsServiceStartDeps {} // eslint-disable-line @typescript-e export class PluginsService implements CoreService { private readonly log: Logger; private readonly pluginsSystem: PluginsSystem; + private readonly configService: IConfigService; private readonly config$: Observable; + private readonly pluginConfigDescriptors = new Map(); constructor(private readonly coreContext: CoreContext) { this.log = coreContext.logger.get('plugins-service'); this.pluginsSystem = new PluginsSystem(coreContext); + this.configService = coreContext.configService; this.config$ = coreContext.configService .atPath('plugins') .pipe(map(rawConfig => new PluginsConfig(rawConfig, coreContext.env))); @@ -82,17 +93,18 @@ export class PluginsService implements CoreService(); if (!config.initialize || this.coreContext.env.isDevClusterMaster) { this.log.info('Plugin initialization disabled.'); - return { - contracts: new Map(), - uiPlugins: this.pluginsSystem.uiPlugins(), - }; + } else { + contracts = await this.pluginsSystem.setupPlugins(deps); } + const uiPlugins = this.pluginsSystem.uiPlugins(); return { - contracts: await this.pluginsSystem.setupPlugins(deps), - uiPlugins: this.pluginsSystem.uiPlugins(), + contracts, + uiPlugins, + uiPluginConfigs: this.generateUiPluginsConfigs(uiPlugins.public), }; } @@ -107,6 +119,38 @@ export class PluginsService implements CoreService + ): Map> { + return new Map( + [...uiPlugins] + .filter(([pluginId, _]) => { + const configDescriptor = this.pluginConfigDescriptors.get(pluginId); + return ( + configDescriptor && + configDescriptor.exposeToBrowser && + Object.values(configDescriptor?.exposeToBrowser).some(exposed => exposed) + ); + }) + .map(([pluginId, plugin]) => { + const configDescriptor = this.pluginConfigDescriptors.get(pluginId)!; + return [ + pluginId, + this.configService.atPath(plugin.configPath).pipe( + map((config: any) => + pick( + config || {}, + Object.entries(configDescriptor.exposeToBrowser!) + .filter(([_, exposed]) => exposed) + .map(([key, _]) => key) + ) + ) + ), + ]; + }) + ); + } + private async handleDiscoveryErrors(error$: Observable) { // At this stage we report only errors that can occur when new platform plugin // manifest is present, otherwise we can't be sure that the plugin is for the new @@ -138,9 +182,13 @@ export class PluginsService implements CoreService { - const schema = plugin.getConfigSchema(); - if (schema) { - await this.coreContext.configService.setSchema(plugin.configPath, schema); + const configDescriptor = plugin.getConfigDescriptor(); + if (configDescriptor) { + this.pluginConfigDescriptors.set(plugin.name, configDescriptor); + await this.coreContext.configService.setSchema( + plugin.configPath, + configDescriptor.schema + ); } const isEnabled = await this.coreContext.configService.isEnabledAtPath(plugin.configPath); diff --git a/src/core/server/plugins/types.ts b/src/core/server/plugins/types.ts index 9a3e922b3cb896..17704ce687b92d 100644 --- a/src/core/server/plugins/types.ts +++ b/src/core/server/plugins/types.ts @@ -24,7 +24,51 @@ import { ConfigPath, EnvironmentMode, PackageInfo } from '../config'; import { LoggerFactory } from '../logging'; import { CoreSetup, CoreStart } from '..'; -export type PluginConfigSchema = Type | null; +/** + * Dedicated type for plugin configuration schema. + * + * @public + */ +export type PluginConfigSchema = Type; + +/** + * Describes a plugin configuration schema and capabilities. + * + * @example + * ```typescript + * // my_plugin/server/index.ts + * import { schema, TypeOf } from '@kbn/config-schema'; + * import { PluginConfigDescriptor } from 'kibana/server'; + * + * const configSchema = schema.object({ + * secret: schema.string({ defaultValue: 'Only on server' }), + * uiProp: schema.string({ defaultValue: 'Accessible from client' }), + * }); + * + * type ConfigType = TypeOf; + * + * export const config: PluginConfigDescriptor = { + * exposeToBrowser: { + * uiProp: true, + * }, + * schema: configSchema, + * }; + * ``` + * + * @public + */ +export interface PluginConfigDescriptor { + /** + * List of configuration properties that will be available on the client-side plugin. + */ + exposeToBrowser?: { [P in keyof T]?: boolean }; + /** + * Schema to use to validate the plugin configuration. + * + * {@link PluginConfigSchema} + */ + schema: PluginConfigSchema; +} /** * Dedicated type for plugin name/id that is supposed to make Map/Set/Arrays diff --git a/src/core/server/server.api.md b/src/core/server/server.api.md index 97a04a4a4efaba..7ecb9053a4bcff 100644 --- a/src/core/server/server.api.md +++ b/src/core/server/server.api.md @@ -959,6 +959,17 @@ export interface Plugin { + exposeToBrowser?: { + [P in keyof T]?: boolean; + }; + schema: PluginConfigSchema; +} + +// @public +export type PluginConfigSchema = Type; + // @public export type PluginInitializer = (core: PluginInitializerContext) => Plugin; @@ -1003,6 +1014,8 @@ export interface PluginsServiceSetup { // (undocumented) contracts: Map; // (undocumented) + uiPluginConfigs: Map>; + // (undocumented) uiPlugins: { public: Map; internal: Map; @@ -1615,6 +1628,6 @@ export interface UserProvidedValues { // Warnings were encountered during analysis: // // src/core/server/http/router/response.ts:316:3 - (ae-forgotten-export) The symbol "KibanaResponse" needs to be exported by the entry point index.d.ts -// src/core/server/plugins/plugins_service.ts:38:5 - (ae-forgotten-export) The symbol "DiscoveredPluginInternal" needs to be exported by the entry point index.d.ts +// src/core/server/plugins/plugins_service.ts:45:5 - (ae-forgotten-export) The symbol "DiscoveredPluginInternal" needs to be exported by the entry point index.d.ts ``` diff --git a/src/legacy/server/kbn_server.d.ts b/src/legacy/server/kbn_server.d.ts index 448a418abcb8a7..2bff1d707c951e 100644 --- a/src/legacy/server/kbn_server.d.ts +++ b/src/legacy/server/kbn_server.d.ts @@ -107,6 +107,7 @@ export default class KbnServer { __internals: { hapiServer: LegacyServiceSetupDeps['core']['http']['server']; uiPlugins: LegacyServiceSetupDeps['core']['plugins']['uiPlugins']; + uiPluginConfigs: LegacyServiceSetupDeps['core']['plugins']['uiPluginConfigs']; elasticsearch: LegacyServiceSetupDeps['core']['elasticsearch']; uiSettings: LegacyServiceSetupDeps['core']['uiSettings']; kibanaMigrator: LegacyServiceStartDeps['core']['savedObjects']['migrator']; diff --git a/src/legacy/ui/ui_render/ui_render_mixin.js b/src/legacy/ui/ui_render/ui_render_mixin.js index 0d05ea259d1a13..c0885cd5d3d13f 100644 --- a/src/legacy/ui/ui_render/ui_render_mixin.js +++ b/src/legacy/ui/ui_render/ui_render_mixin.js @@ -17,6 +17,7 @@ * under the License. */ +import { take } from 'rxjs/operators'; import { createHash } from 'crypto'; import { props, reduce as reduceAsync } from 'bluebird'; import Boom from 'boom'; @@ -42,21 +43,31 @@ export function uiRenderMixin(kbnServer, server, config) { let defaultInjectedVars = {}; kbnServer.afterPluginsInit(() => { const { defaultInjectedVarProviders = [] } = kbnServer.uiExports; - defaultInjectedVars = defaultInjectedVarProviders - .reduce((allDefaults, { fn, pluginSpec }) => ( + defaultInjectedVars = defaultInjectedVarProviders.reduce( + (allDefaults, { fn, pluginSpec }) => mergeVariables( allDefaults, fn(kbnServer.server, pluginSpec.readConfigValue(kbnServer.config, [])) - ) - ), {}); + ), + {} + ); }); // render all views from ./views server.setupViews(resolve(__dirname, 'views')); - server.exposeStaticDir('/node_modules/@elastic/eui/dist/{path*}', fromRoot('node_modules/@elastic/eui/dist')); - server.exposeStaticDir('/node_modules/@kbn/ui-framework/dist/{path*}', fromRoot('node_modules/@kbn/ui-framework/dist')); - server.exposeStaticDir('/node_modules/@elastic/charts/dist/{path*}', fromRoot('node_modules/@elastic/charts/dist')); + server.exposeStaticDir( + '/node_modules/@elastic/eui/dist/{path*}', + fromRoot('node_modules/@elastic/eui/dist') + ); + server.exposeStaticDir( + '/node_modules/@kbn/ui-framework/dist/{path*}', + fromRoot('node_modules/@kbn/ui-framework/dist') + ); + server.exposeStaticDir( + '/node_modules/@elastic/charts/dist/{path*}', + fromRoot('node_modules/@elastic/charts/dist') + ); const translationsCache = { translations: null, hash: null }; server.route({ @@ -80,11 +91,12 @@ export function uiRenderMixin(kbnServer, server, config) { .digest('hex'); } - return h.response(translationsCache.translations) + return h + .response(translationsCache.translations) .header('cache-control', 'must-revalidate') .header('content-type', 'application/json') .etag(translationsCache.hash); - } + }, }); // register the bootstrap.js route after plugins are initialized so that we can @@ -105,42 +117,38 @@ export function uiRenderMixin(kbnServer, server, config) { const isCore = !app; const uiSettings = request.getUiSettingsService(); - const darkMode = !authEnabled || request.auth.isAuthenticated - ? await uiSettings.get('theme:darkMode') - : false; + const darkMode = + !authEnabled || request.auth.isAuthenticated + ? await uiSettings.get('theme:darkMode') + : false; const basePath = config.get('server.basePath'); const regularBundlePath = `${basePath}/bundles`; const dllBundlePath = `${basePath}/built_assets/dlls`; const styleSheetPaths = [ `${dllBundlePath}/vendors.style.dll.css`, - ...( - darkMode ? - [ - `${basePath}/node_modules/@elastic/eui/dist/eui_theme_dark.css`, - `${basePath}/node_modules/@kbn/ui-framework/dist/kui_dark.css`, - `${basePath}/node_modules/@elastic/charts/dist/theme_only_dark.css`, - ] : [ - `${basePath}/node_modules/@elastic/eui/dist/eui_theme_light.css`, - `${basePath}/node_modules/@kbn/ui-framework/dist/kui_light.css`, - `${basePath}/node_modules/@elastic/charts/dist/theme_only_light.css`, - ] - ), + ...(darkMode + ? [ + `${basePath}/node_modules/@elastic/eui/dist/eui_theme_dark.css`, + `${basePath}/node_modules/@kbn/ui-framework/dist/kui_dark.css`, + `${basePath}/node_modules/@elastic/charts/dist/theme_only_dark.css`, + ] + : [ + `${basePath}/node_modules/@elastic/eui/dist/eui_theme_light.css`, + `${basePath}/node_modules/@kbn/ui-framework/dist/kui_light.css`, + `${basePath}/node_modules/@elastic/charts/dist/theme_only_light.css`, + ]), `${regularBundlePath}/${darkMode ? 'dark' : 'light'}_theme.style.css`, `${regularBundlePath}/commons.style.css`, - ...( - !isCore ? [`${regularBundlePath}/${app.getId()}.style.css`] : [] - ), + ...(!isCore ? [`${regularBundlePath}/${app.getId()}.style.css`] : []), ...kbnServer.uiExports.styleSheetPaths - .filter(path => ( - path.theme === '*' || path.theme === (darkMode ? 'dark' : 'light') - )) - .map(path => ( + .filter(path => path.theme === '*' || path.theme === (darkMode ? 'dark' : 'light')) + .map(path => path.localPath.endsWith('.scss') ? `${basePath}/built_assets/css/${path.publicPath}` : `${basePath}/${path.publicPath}` - )) - .reverse() + ) + .reverse(), ]; const bootstrap = new AppBootstrap({ @@ -149,17 +157,18 @@ export function uiRenderMixin(kbnServer, server, config) { regularBundlePath, dllBundlePath, styleSheetPaths, - } + }, }); const body = await bootstrap.getJsFile(); const etag = await bootstrap.getJsFileHash(); - return h.response(body) + return h + .response(body) .header('cache-control', 'must-revalidate') .header('content-type', 'application/javascript') .etag(etag); - } + }, }); }); @@ -179,14 +188,14 @@ export function uiRenderMixin(kbnServer, server, config) { } catch (err) { throw Boom.boomify(err); } - } + }, }); async function getUiSettings({ request, includeUserProvidedConfig }) { const uiSettings = request.getUiSettingsService(); return props({ defaults: uiSettings.getRegistered(), - user: includeUserProvidedConfig && uiSettings.getUserProvided() + user: includeUserProvidedConfig && uiSettings.getUserProvided(), }); } @@ -206,7 +215,12 @@ export function uiRenderMixin(kbnServer, server, config) { }; } - async function renderApp({ app, h, includeUserProvidedConfig = true, injectedVarsOverrides = {} }) { + async function renderApp({ + app, + h, + includeUserProvidedConfig = true, + injectedVarsOverrides = {}, + }) { const request = h.request; const basePath = request.getBasePath(); const uiSettings = await getUiSettings({ request, includeUserProvidedConfig }); @@ -215,14 +229,22 @@ export function uiRenderMixin(kbnServer, server, config) { const legacyMetadata = getLegacyKibanaPayload({ app, basePath, - uiSettings + uiSettings, }); // Get the list of new platform plugins. // Convert the Map into an array of objects so it is JSON serializable and order is preserved. - const uiPlugins = [ - ...kbnServer.newPlatform.__internals.uiPlugins.public.entries() - ].map(([id, plugin]) => ({ id, plugin })); + const uiPluginConfigs = kbnServer.newPlatform.__internals.uiPluginConfigs; + const uiPlugins = await Promise.all([ + ...kbnServer.newPlatform.__internals.uiPlugins.public.entries(), + ].map(async ([id, plugin]) => { + const config$ = uiPluginConfigs.get(id); + if (config$) { + return { id, plugin, config: await config$.pipe(take(1)).toPromise() }; + } else { + return { id, plugin, config: {} }; + } + })); const response = h.view('ui_app', { strictCsp: config.get('csp.strict'), @@ -250,8 +272,8 @@ export function uiRenderMixin(kbnServer, server, config) { mergeVariables( injectedVarsOverrides, app ? await server.getInjectedUiAppVars(app.getId()) : {}, - defaultInjectedVars, - ), + defaultInjectedVars + ) ), uiPlugins, diff --git a/src/plugins/testbed/public/index.ts b/src/plugins/testbed/public/index.ts index 44eea308a31d9e..601db10f6f8bb0 100644 --- a/src/plugins/testbed/public/index.ts +++ b/src/plugins/testbed/public/index.ts @@ -17,8 +17,9 @@ * under the License. */ -import { PluginInitializer } from 'kibana/public'; +import { PluginInitializer, PluginInitializerContext } from 'kibana/public'; import { TestbedPlugin, TestbedPluginSetup, TestbedPluginStart } from './plugin'; -export const plugin: PluginInitializer = () => - new TestbedPlugin(); +export const plugin: PluginInitializer = ( + initializerContext: PluginInitializerContext +) => new TestbedPlugin(initializerContext); diff --git a/src/plugins/testbed/public/plugin.ts b/src/plugins/testbed/public/plugin.ts index bf51dbf0b8e78a..8c70485d9ee8b3 100644 --- a/src/plugins/testbed/public/plugin.ts +++ b/src/plugins/testbed/public/plugin.ts @@ -17,12 +17,20 @@ * under the License. */ -import { Plugin, CoreSetup } from 'kibana/public'; +import { Plugin, CoreSetup, PluginInitializerContext } from 'kibana/public'; + +interface ConfigType { + uiProp: string; +} export class TestbedPlugin implements Plugin { - public setup(core: CoreSetup, deps: {}) { + constructor(private readonly initializerContext: PluginInitializerContext) {} + + public async setup(core: CoreSetup, deps: {}) { + const config = this.initializerContext.config.get(); + // eslint-disable-next-line no-console - console.log(`Testbed plugin set up`); + console.log(`Testbed plugin set up. uiProp: '${config.uiProp}'`); return { foo: 'bar', }; diff --git a/src/plugins/testbed/server/index.ts b/src/plugins/testbed/server/index.ts index 4dd22d3dce1efc..07fda4eb98727e 100644 --- a/src/plugins/testbed/server/index.ts +++ b/src/plugins/testbed/server/index.ts @@ -20,15 +20,28 @@ import { map, mergeMap } from 'rxjs/operators'; import { schema, TypeOf } from '@kbn/config-schema'; -import { CoreSetup, CoreStart, Logger, PluginInitializerContext, PluginName } from 'kibana/server'; +import { + CoreSetup, + CoreStart, + Logger, + PluginInitializerContext, + PluginConfigDescriptor, + PluginName, +} from 'kibana/server'; -export const config = { - schema: schema.object({ - secret: schema.string({ defaultValue: 'Not really a secret :/' }), - }), -}; +const configSchema = schema.object({ + secret: schema.string({ defaultValue: 'Not really a secret :/' }), + uiProp: schema.string({ defaultValue: 'Accessible from client' }), +}); + +type ConfigType = TypeOf; -type ConfigType = TypeOf; +export const config: PluginConfigDescriptor = { + exposeToBrowser: { + uiProp: true, + }, + schema: configSchema, +}; class Plugin { private readonly log: Logger; diff --git a/x-pack/legacy/plugins/siem/public/apps/index.ts b/x-pack/legacy/plugins/siem/public/apps/index.ts index b71c4fe699860e..73f9b65ba35460 100644 --- a/x-pack/legacy/plugins/siem/public/apps/index.ts +++ b/x-pack/legacy/plugins/siem/public/apps/index.ts @@ -8,8 +8,8 @@ import chrome from 'ui/chrome'; import { npStart } from 'ui/new_platform'; import { Plugin } from './plugin'; -// eslint-disable-next-line @typescript-eslint/no-explicit-any -new Plugin({ opaqueId: Symbol('siem'), env: {} as any }, chrome).start( - npStart.core, - npStart.plugins -); +new Plugin( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + { opaqueId: Symbol('siem'), env: {} as any, config: { get: () => ({} as any) } }, + chrome +).start(npStart.core, npStart.plugins); diff --git a/x-pack/legacy/plugins/uptime/public/apps/index.ts b/x-pack/legacy/plugins/uptime/public/apps/index.ts index 3b328b3ff23260..53a74022778f4c 100644 --- a/x-pack/legacy/plugins/uptime/public/apps/index.ts +++ b/x-pack/legacy/plugins/uptime/public/apps/index.ts @@ -8,4 +8,7 @@ import chrome from 'ui/chrome'; import { npStart } from 'ui/new_platform'; import { Plugin } from './plugin'; -new Plugin({ opaqueId: Symbol('uptime'), env: {} as any }, chrome).start(npStart); +new Plugin( + { opaqueId: Symbol('uptime'), env: {} as any, config: { get: () => ({} as any) } }, + chrome +).start(npStart);