diff --git a/src/core/public/injected_metadata/injected_metadata_service.mock.ts b/src/core/public/injected_metadata/injected_metadata_service.mock.ts index 3e25fdaca532e..6c9fc5e317333 100644 --- a/src/core/public/injected_metadata/injected_metadata_service.mock.ts +++ b/src/core/public/injected_metadata/injected_metadata_service.mock.ts @@ -24,6 +24,7 @@ const createStartContractMock = () => { getKibanaVersion: jest.fn(), getCspConfig: jest.fn(), getLegacyMetadata: jest.fn(), + getPlugins: jest.fn(), getInjectedVar: jest.fn(), getInjectedVars: jest.fn(), }; @@ -35,6 +36,7 @@ const createStartContractMock = () => { user: { legacyInjectedUiSettingUserValues: true }, }, } as any); + startContract.getPlugins.mockReturnValue([]); return startContract; }; 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 ee4f317f67519..48aa21cbbcc82 100644 --- a/src/core/public/injected_metadata/injected_metadata_service.test.ts +++ b/src/core/public/injected_metadata/injected_metadata_service.test.ts @@ -17,6 +17,7 @@ * under the License. */ +import { DiscoveredPlugin } from '../../server'; import { InjectedMetadataService } from './injected_metadata_service'; describe('#getKibanaVersion', () => { @@ -76,6 +77,43 @@ describe('start.getCspConfig()', () => { }); }); +describe('start.getPlugins()', () => { + it('returns injectedMetadata.uiPlugins', () => { + const injectedMetadata = new InjectedMetadataService({ + injectedMetadata: { + uiPlugins: [{ id: 'plugin-1', plugin: {} }, { id: 'plugin-2', plugin: {} }], + }, + } as any); + + const plugins = injectedMetadata.start().getPlugins(); + expect(plugins).toEqual([{ id: 'plugin-1', plugin: {} }, { id: 'plugin-2', plugin: {} }]); + }); + + it('returns frozen version of uiPlugins', () => { + const injectedMetadata = new InjectedMetadataService({ + injectedMetadata: { + uiPlugins: [{ id: 'plugin-1', plugin: {} }, { id: 'plugin-2', plugin: {} }], + }, + } as any); + + const plugins = injectedMetadata.start().getPlugins(); + expect(() => { + plugins.pop(); + }).toThrowError(); + expect(() => { + plugins.push({ id: 'new-plugin', plugin: {} as DiscoveredPlugin }); + }).toThrowError(); + expect(() => { + // @ts-ignore TS knows this shouldn't be possible + plugins[0].name = 'changed'; + }).toThrowError(); + expect(() => { + // @ts-ignore TS knows this shouldn't be possible + plugins[0].newProp = 'changed'; + }).toThrowError(); + }); +}); + describe('start.getLegacyMetadata()', () => { it('returns injectedMetadata.legacyMetadata', () => { const injectedMetadata = new InjectedMetadataService({ diff --git a/src/core/public/injected_metadata/injected_metadata_service.ts b/src/core/public/injected_metadata/injected_metadata_service.ts index 3f0ab943c5a74..f7e05b7df6b8b 100644 --- a/src/core/public/injected_metadata/injected_metadata_service.ts +++ b/src/core/public/injected_metadata/injected_metadata_service.ts @@ -18,6 +18,7 @@ */ import { get } from 'lodash'; +import { DiscoveredPlugin, PluginName } from '../../server'; import { UiSettingsState } from '../ui_settings'; import { deepFreeze } from './deep_freeze'; @@ -32,6 +33,10 @@ export interface InjectedMetadataParams { vars: { [key: string]: unknown; }; + uiPlugins: Array<{ + id: PluginName; + plugin: DiscoveredPlugin; + }>; legacyMetadata: { app: unknown; translations: unknown; @@ -79,6 +84,10 @@ export class InjectedMetadataService { return this.state.csp; }, + getPlugins: () => { + return this.state.uiPlugins; + }, + getLegacyMetadata: () => { return this.state.legacyMetadata; }, diff --git a/src/core/server/index.ts b/src/core/server/index.ts index 2be8bb760cc9b..b1c147d853013 100644 --- a/src/core/server/index.ts +++ b/src/core/server/index.ts @@ -20,4 +20,9 @@ export { bootstrap } from './bootstrap'; export { CallAPIOptions, ClusterClient } from './elasticsearch'; export { Logger, LoggerFactory } from './logging'; -export { PluginInitializerContext, PluginName, PluginStartContext } from './plugins'; +export { + DiscoveredPlugin, + PluginInitializerContext, + PluginName, + PluginStartContext, +} from './plugins'; diff --git a/src/core/server/legacy/legacy_service.test.ts b/src/core/server/legacy/legacy_service.test.ts index b862a369a8f4f..277cb50cfeac9 100644 --- a/src/core/server/legacy/legacy_service.test.ts +++ b/src/core/server/legacy/legacy_service.test.ts @@ -33,6 +33,7 @@ import { getEnvOptions } from '../config/__mocks__/env'; import { configServiceMock } from '../config/config_service.mock'; import { ElasticsearchServiceStart } from '../elasticsearch'; import { loggingServiceMock } from '../logging/logging_service.mock'; +import { DiscoveredPlugin, DiscoveredPluginInternal } from '../plugins'; import { PluginsServiceStart } from '../plugins/plugins_service'; import { LegacyPlatformProxy } from './legacy_platform_proxy'; @@ -62,7 +63,13 @@ beforeEach(() => { server: { listener: { addListener: jest.fn() }, route: jest.fn() }, options: { someOption: 'foo', someAnotherOption: 'bar' }, }, - plugins: new Map([['plugin-id', 'plugin-value']]), + plugins: { + contracts: new Map([['plugin-id', 'plugin-value']]), + uiPlugins: { + public: new Map([['plugin-id', {} as DiscoveredPlugin]]), + internal: new Map([['plugin-id', {} as DiscoveredPluginInternal]]), + }, + }, }; config$ = new BehaviorSubject( @@ -341,7 +348,7 @@ describe('once LegacyService is started in `devClusterMaster` mode', () => { await devClusterLegacyService.start({ elasticsearch: startDeps.elasticsearch, - plugins: new Map(), + plugins: { contracts: new Map(), uiPlugins: { public: new Map(), internal: new Map() } }, }); expect(MockClusterManager.create.mock.calls).toMatchSnapshot( @@ -363,7 +370,7 @@ describe('once LegacyService is started in `devClusterMaster` mode', () => { await devClusterLegacyService.start({ elasticsearch: startDeps.elasticsearch, - plugins: new Map(), + plugins: { contracts: new Map(), uiPlugins: { public: new Map(), internal: new Map() } }, }); expect(MockClusterManager.create.mock.calls).toMatchSnapshot( diff --git a/src/core/server/plugins/index.ts b/src/core/server/plugins/index.ts index a66f24c0790a6..e8b3ddf411899 100644 --- a/src/core/server/plugins/index.ts +++ b/src/core/server/plugins/index.ts @@ -22,8 +22,10 @@ import { PluginsService } from './plugins_service'; /** @internal */ export { isNewPlatformPlugin } from './discovery'; -export { PluginInitializerContext, PluginStartContext } from './plugin_context'; +/** @internal */ +export { DiscoveredPlugin, DiscoveredPluginInternal } from './plugin'; export { PluginName } from './plugin'; +export { PluginInitializerContext, PluginStartContext } from './plugin_context'; /** @internal */ export class PluginsModule { diff --git a/src/core/server/plugins/plugin.test.ts b/src/core/server/plugins/plugin.test.ts index fa3c89f6ee918..d0c8c0ef7d681 100644 --- a/src/core/server/plugins/plugin.test.ts +++ b/src/core/server/plugins/plugin.test.ts @@ -24,6 +24,7 @@ import { getEnvOptions } from '../config/__mocks__/env'; import { CoreContext } from '../core_context'; import { elasticsearchServiceMock } from '../elasticsearch/elasticsearch_service.mock'; import { loggingServiceMock } from '../logging/logging_service.mock'; + import { Plugin, PluginManifest } from './plugin'; import { createPluginInitializerContext, createPluginStartContext } from './plugin_context'; diff --git a/src/core/server/plugins/plugin.ts b/src/core/server/plugins/plugin.ts index 02670846e8fa4..c3f3ec271e3b6 100644 --- a/src/core/server/plugins/plugin.ts +++ b/src/core/server/plugins/plugin.ts @@ -80,6 +80,48 @@ export interface PluginManifest { readonly server: boolean; } +/** + * Small container object used to expose information about discovered plugins that may + * or may not have been started. + * @internal + */ +export interface DiscoveredPlugin { + /** + * Identifier of the plugin. + */ + readonly id: PluginName; + + /** + * Root configuration path used by the plugin, defaults to "id". + */ + readonly configPath: ConfigPath; + + /** + * An optional list of the other plugins that **must be** installed and enabled + * for this plugin to function properly. + */ + readonly requiredPlugins: ReadonlyArray; + + /** + * An optional list of the other plugins that if installed and enabled **may be** + * leveraged by this plugin for some additional functionality but otherwise are + * not required for this plugin to work properly. + */ + readonly optionalPlugins: ReadonlyArray; +} + +/** + * An extended `DiscoveredPlugin` that exposes more sensitive information. Should never + * be exposed to client-side code. + * @internal + */ +export interface DiscoveredPluginInternal extends DiscoveredPlugin { + /** + * Path on the filesystem where plugin was loaded from. + */ + readonly path: string; +} + type PluginInitializer> = ( coreContext: PluginInitializerContext ) => { @@ -109,7 +151,7 @@ export class Plugin< constructor( public readonly path: string, - private readonly manifest: PluginManifest, + public readonly manifest: PluginManifest, private readonly initializerContext: PluginInitializerContext ) { this.log = initializerContext.logger.get(); diff --git a/src/core/server/plugins/plugins_service.test.ts b/src/core/server/plugins/plugins_service.test.ts index 7b8a300492b95..f6b42f2901464 100644 --- a/src/core/server/plugins/plugins_service.test.ts +++ b/src/core/server/plugins/plugins_service.test.ts @@ -161,6 +161,7 @@ test('`start` properly detects plugins that should be disabled.', async () => { .mockImplementation(path => Promise.resolve(!path.includes('disabled'))); mockPluginSystem.startPlugins.mockResolvedValue(new Map()); + mockPluginSystem.uiPlugins.mockReturnValue({ public: new Map(), internal: new Map() }); mockDiscover.mockReturnValue({ error$: from([]), @@ -224,7 +225,11 @@ test('`start` properly detects plugins that should be disabled.', async () => { ]), }); - expect(await pluginsService.start(startDeps)).toBeInstanceOf(Map); + const start = await pluginsService.start(startDeps); + + expect(start.contracts).toBeInstanceOf(Map); + expect(start.uiPlugins.public).toBeInstanceOf(Map); + expect(start.uiPlugins.internal).toBeInstanceOf(Map); expect(mockPluginSystem.addPlugin).not.toHaveBeenCalled(); expect(mockPluginSystem.startPlugins).toHaveBeenCalledTimes(1); expect(mockPluginSystem.startPlugins).toHaveBeenCalledWith(startDeps); @@ -287,12 +292,15 @@ test('`start` properly invokes `discover` and ignores non-critical errors.', asy plugin$: from([firstPlugin, secondPlugin]), }); - const pluginsStart = new Map(); - mockPluginSystem.startPlugins.mockResolvedValue(pluginsStart); + const contracts = new Map(); + const discoveredPlugins = { public: new Map(), internal: new Map() }; + mockPluginSystem.startPlugins.mockResolvedValue(contracts); + mockPluginSystem.uiPlugins.mockReturnValue(discoveredPlugins); const start = await pluginsService.start(startDeps); - expect(start).toBe(pluginsStart); + expect(start.contracts).toBe(contracts); + expect(start.uiPlugins).toBe(discoveredPlugins); expect(mockPluginSystem.addPlugin).toHaveBeenCalledTimes(2); expect(mockPluginSystem.addPlugin).toHaveBeenCalledWith(firstPlugin); expect(mockPluginSystem.addPlugin).toHaveBeenCalledWith(secondPlugin); diff --git a/src/core/server/plugins/plugins_service.ts b/src/core/server/plugins/plugins_service.ts index 3b571b0b34dce..de4301fb359ec 100644 --- a/src/core/server/plugins/plugins_service.ts +++ b/src/core/server/plugins/plugins_service.ts @@ -24,12 +24,18 @@ import { CoreContext } from '../core_context'; import { ElasticsearchServiceStart } from '../elasticsearch'; import { Logger } from '../logging'; import { discover, PluginDiscoveryError, PluginDiscoveryErrorType } from './discovery'; -import { Plugin, PluginName } from './plugin'; +import { DiscoveredPlugin, DiscoveredPluginInternal, Plugin, PluginName } from './plugin'; import { PluginsConfig } from './plugins_config'; import { PluginsSystem } from './plugins_system'; /** @internal */ -export type PluginsServiceStart = Map; +export interface PluginsServiceStart { + contracts: Map; + uiPlugins: { + public: Map; + internal: Map; + }; +} /** @internal */ export interface PluginsServiceStartDeps { @@ -60,10 +66,16 @@ export class PluginsService implements CoreService { if (!config.initialize || this.coreContext.env.isDevClusterMaster) { this.log.info('Plugin initialization disabled.'); - return new Map(); + return { + contracts: new Map(), + uiPlugins: this.pluginsSystem.uiPlugins(), + }; } - return await this.pluginsSystem.startPlugins(deps); + return { + contracts: await this.pluginsSystem.startPlugins(deps), + uiPlugins: this.pluginsSystem.uiPlugins(), + }; } public async stop() { diff --git a/src/core/server/plugins/plugins_system.test.ts b/src/core/server/plugins/plugins_system.test.ts index b2aa20e541b7b..7a0f4a0008664 100644 --- a/src/core/server/plugins/plugins_system.test.ts +++ b/src/core/server/plugins/plugins_system.test.ts @@ -232,3 +232,42 @@ Array [ expect(secondPluginNotToRun.start).not.toHaveBeenCalled(); expect(thirdPluginToRun.start).toHaveBeenCalledTimes(1); }); + +test('`uiPlugins` returns empty Maps before plugins are added', async () => { + expect(pluginsSystem.uiPlugins()).toMatchInlineSnapshot(` +Object { + "internal": Map {}, + "public": Map {}, +} +`); +}); + +test('`uiPlugins` returns ordered Maps of all plugin manifests', async () => { + const plugins = new Map([ + [createPlugin('order-4', { required: ['order-2'] }), { 'order-2': 'added-as-2' }], + [createPlugin('order-0'), {}], + [ + createPlugin('order-2', { required: ['order-1'], optional: ['order-0'] }), + { 'order-1': 'added-as-3', 'order-0': 'added-as-1' }, + ], + [createPlugin('order-1', { required: ['order-0'] }), { 'order-0': 'added-as-1' }], + [ + createPlugin('order-3', { required: ['order-2'], optional: ['missing-dep'] }), + { 'order-2': 'added-as-2' }, + ], + ] as Array<[Plugin, Record]>); + + [...plugins.keys()].forEach(plugin => { + pluginsSystem.addPlugin(plugin); + }); + + expect([...pluginsSystem.uiPlugins().internal.keys()]).toMatchInlineSnapshot(` +Array [ + "order-0", + "order-1", + "order-2", + "order-3", + "order-4", +] +`); +}); diff --git a/src/core/server/plugins/plugins_system.ts b/src/core/server/plugins/plugins_system.ts index 229f3955d1e65..61cd43e333500 100644 --- a/src/core/server/plugins/plugins_system.ts +++ b/src/core/server/plugins/plugins_system.ts @@ -17,9 +17,11 @@ * under the License. */ +import { pick } from 'lodash'; + import { CoreContext } from '../core_context'; import { Logger } from '../logging'; -import { Plugin, PluginName } from './plugin'; +import { DiscoveredPlugin, DiscoveredPluginInternal, Plugin, PluginName } from './plugin'; import { createPluginStartContext } from './plugin_context'; import { PluginsServiceStartDeps } from './plugins_service'; @@ -95,6 +97,41 @@ export class PluginsSystem { } } + /** + * Get a Map of all discovered UI plugins in topological order. + */ + public uiPlugins() { + const internal = new Map( + [...this.getTopologicallySortedPluginNames().keys()] + .filter(pluginName => this.plugins.get(pluginName)!.includesUiPlugin) + .map(pluginName => { + const plugin = this.plugins.get(pluginName)!; + return [ + pluginName, + { + id: pluginName, + path: plugin.path, + configPath: plugin.manifest.configPath, + requiredPlugins: plugin.manifest.requiredPlugins, + optionalPlugins: plugin.manifest.optionalPlugins, + }, + ] as [PluginName, DiscoveredPluginInternal]; + }) + ); + + const publicPlugins = new Map( + [...internal.entries()].map( + ([pluginName, plugin]) => + [ + pluginName, + pick(plugin, ['id', 'configPath', 'requiredPlugins', 'optionalPlugins']), + ] as [PluginName, DiscoveredPlugin] + ) + ); + + return { public: publicPlugins, internal }; + } + /** * Gets topologically sorted plugin names that are registered with the plugin system. * Ordering is possible if and only if the plugins graph has no directed cycles, diff --git a/src/core/types/index.ts b/src/core/types/index.ts index dfed05f32c049..3aae17e6ea594 100644 --- a/src/core/types/index.ts +++ b/src/core/types/index.ts @@ -20,8 +20,5 @@ /** * Use * syntax so that these exports do not break when internal * types are stripped. - * - * No imports in this directory can import from ./server or ./public - * or else builds will not work correctly for both NodeJS and Webpack. */ export * from './core_service'; diff --git a/src/legacy/ui/ui_render/ui_render_mixin.js b/src/legacy/ui/ui_render/ui_render_mixin.js index a0d45403a82b6..de17e8d09472a 100644 --- a/src/legacy/ui/ui_render/ui_render_mixin.js +++ b/src/legacy/ui/ui_render/ui_render_mixin.js @@ -213,6 +213,12 @@ export function uiRenderMixin(kbnServer, server, config) { injectedVarsOverrides }); + // 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.start.plugins.uiPlugins.public.entries() + ].map(([id, plugin]) => ({ id, plugin })); + const nonce = await generateCSPNonce(); const response = h.view('ui_app', { @@ -243,6 +249,8 @@ export function uiRenderMixin(kbnServer, server, config) { ), ), + uiPlugins, + legacyMetadata, }, });