diff --git a/docs/development/core/server/kibana-plugin-server.coresetup.getstartservices.md b/docs/development/core/server/kibana-plugin-server.coresetup.getstartservices.md
new file mode 100644
index 00000000000000..b05d28899f9d23
--- /dev/null
+++ b/docs/development/core/server/kibana-plugin-server.coresetup.getstartservices.md
@@ -0,0 +1,17 @@
+
+
+[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [CoreSetup](./kibana-plugin-server.coresetup.md) > [getStartServices](./kibana-plugin-server.coresetup.getstartservices.md)
+
+## CoreSetup.getStartServices() method
+
+Allows plugins to get access to APIs available in start inside async handlers. Promise will not resolve until Core and plugin dependencies have completed `start`. This should only be used inside handlers registered during `setup` that will only be executed after `start` lifecycle.
+
+Signature:
+
+```typescript
+getStartServices(): Promise<[CoreStart, TPluginsStart]>;
+```
+Returns:
+
+`Promise<[CoreStart, TPluginsStart]>`
+
diff --git a/docs/development/core/server/kibana-plugin-server.coresetup.md b/docs/development/core/server/kibana-plugin-server.coresetup.md
index 3f7f5b727ee804..c36d649837e8a8 100644
--- a/docs/development/core/server/kibana-plugin-server.coresetup.md
+++ b/docs/development/core/server/kibana-plugin-server.coresetup.md
@@ -1,26 +1,32 @@
-
-
-[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [CoreSetup](./kibana-plugin-server.coresetup.md)
-
-## CoreSetup interface
-
-Context passed to the plugins `setup` method.
-
-Signature:
-
-```typescript
-export interface CoreSetup
-```
-
-## Properties
-
-| Property | Type | Description |
-| --- | --- | --- |
-| [capabilities](./kibana-plugin-server.coresetup.capabilities.md) | CapabilitiesSetup
| [CapabilitiesSetup](./kibana-plugin-server.capabilitiessetup.md) |
-| [context](./kibana-plugin-server.coresetup.context.md) | ContextSetup
| [ContextSetup](./kibana-plugin-server.contextsetup.md) |
-| [elasticsearch](./kibana-plugin-server.coresetup.elasticsearch.md) | ElasticsearchServiceSetup
| [ElasticsearchServiceSetup](./kibana-plugin-server.elasticsearchservicesetup.md) |
-| [http](./kibana-plugin-server.coresetup.http.md) | HttpServiceSetup
| [HttpServiceSetup](./kibana-plugin-server.httpservicesetup.md) |
-| [savedObjects](./kibana-plugin-server.coresetup.savedobjects.md) | SavedObjectsServiceSetup
| [SavedObjectsServiceSetup](./kibana-plugin-server.savedobjectsservicesetup.md) |
-| [uiSettings](./kibana-plugin-server.coresetup.uisettings.md) | UiSettingsServiceSetup
| [UiSettingsServiceSetup](./kibana-plugin-server.uisettingsservicesetup.md) |
-| [uuid](./kibana-plugin-server.coresetup.uuid.md) | UuidServiceSetup
| [UuidServiceSetup](./kibana-plugin-server.uuidservicesetup.md) |
-
+
+
+[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [CoreSetup](./kibana-plugin-server.coresetup.md)
+
+## CoreSetup interface
+
+Context passed to the plugins `setup` method.
+
+Signature:
+
+```typescript
+export interface CoreSetup
+```
+
+## Properties
+
+| Property | Type | Description |
+| --- | --- | --- |
+| [capabilities](./kibana-plugin-server.coresetup.capabilities.md) | CapabilitiesSetup
| [CapabilitiesSetup](./kibana-plugin-server.capabilitiessetup.md) |
+| [context](./kibana-plugin-server.coresetup.context.md) | ContextSetup
| [ContextSetup](./kibana-plugin-server.contextsetup.md) |
+| [elasticsearch](./kibana-plugin-server.coresetup.elasticsearch.md) | ElasticsearchServiceSetup
| [ElasticsearchServiceSetup](./kibana-plugin-server.elasticsearchservicesetup.md) |
+| [http](./kibana-plugin-server.coresetup.http.md) | HttpServiceSetup
| [HttpServiceSetup](./kibana-plugin-server.httpservicesetup.md) |
+| [savedObjects](./kibana-plugin-server.coresetup.savedobjects.md) | SavedObjectsServiceSetup
| [SavedObjectsServiceSetup](./kibana-plugin-server.savedobjectsservicesetup.md) |
+| [uiSettings](./kibana-plugin-server.coresetup.uisettings.md) | UiSettingsServiceSetup
| [UiSettingsServiceSetup](./kibana-plugin-server.uisettingsservicesetup.md) |
+| [uuid](./kibana-plugin-server.coresetup.uuid.md) | UuidServiceSetup
| [UuidServiceSetup](./kibana-plugin-server.uuidservicesetup.md) |
+
+## Methods
+
+| Method | Description |
+| --- | --- |
+| [getStartServices()](./kibana-plugin-server.coresetup.getstartservices.md) | Allows plugins to get access to APIs available in start inside async handlers. Promise will not resolve until Core and plugin dependencies have completed start
. This should only be used inside handlers registered during setup
that will only be executed after start
lifecycle. |
+
diff --git a/src/core/server/index.ts b/src/core/server/index.ts
index 50d291b1736406..3f67b9a656bb79 100644
--- a/src/core/server/index.ts
+++ b/src/core/server/index.ts
@@ -283,7 +283,7 @@ export interface RequestHandlerContext {
*
* @public
*/
-export interface CoreSetup {
+export interface CoreSetup {
/** {@link CapabilitiesSetup} */
capabilities: CapabilitiesSetup;
/** {@link ContextSetup} */
@@ -298,6 +298,13 @@ export interface CoreSetup {
uiSettings: UiSettingsServiceSetup;
/** {@link UuidServiceSetup} */
uuid: UuidServiceSetup;
+ /**
+ * Allows plugins to get access to APIs available in start inside async handlers.
+ * Promise will not resolve until Core and plugin dependencies have completed `start`.
+ * This should only be used inside handlers registered during `setup` that will only be executed
+ * after `start` lifecycle.
+ */
+ getStartServices(): Promise<[CoreStart, TPluginsStart]>;
}
/**
diff --git a/src/core/server/legacy/legacy_service.ts b/src/core/server/legacy/legacy_service.ts
index ffcbf1662ee85e..07cc9330330545 100644
--- a/src/core/server/legacy/legacy_service.ts
+++ b/src/core/server/legacy/legacy_service.ts
@@ -256,6 +256,12 @@ export class LegacyService implements CoreService {
startDeps: LegacyServiceStartDeps,
legacyPlugins: LegacyPlugins
) {
+ const coreStart: CoreStart = {
+ capabilities: startDeps.core.capabilities,
+ savedObjects: { getScopedClient: startDeps.core.savedObjects.getScopedClient },
+ uiSettings: { asScopedToClient: startDeps.core.uiSettings.asScopedToClient },
+ };
+
const coreSetup: CoreSetup = {
capabilities: setupDeps.core.capabilities,
context: setupDeps.core.context,
@@ -291,11 +297,7 @@ export class LegacyService implements CoreService {
uuid: {
getInstanceUuid: setupDeps.core.uuid.getInstanceUuid,
},
- };
- const coreStart: CoreStart = {
- capabilities: startDeps.core.capabilities,
- savedObjects: { getScopedClient: startDeps.core.savedObjects.getScopedClient },
- uiSettings: { asScopedToClient: startDeps.core.uiSettings.asScopedToClient },
+ getStartServices: () => Promise.resolve([coreStart, startDeps.plugins]),
};
// eslint-disable-next-line @typescript-eslint/no-var-requires
diff --git a/src/core/server/mocks.ts b/src/core/server/mocks.ts
index c7082d46313ae3..c0a8973d98a548 100644
--- a/src/core/server/mocks.ts
+++ b/src/core/server/mocks.ts
@@ -86,6 +86,8 @@ function pluginInitializerContextMock(config: T = {} as T) {
return mock;
}
+type CoreSetupMockType = MockedKeys & jest.Mocked>;
+
function createCoreSetupMock() {
const httpService = httpServiceMock.createSetupContract();
const httpMock: jest.Mocked = {
@@ -105,7 +107,7 @@ function createCoreSetupMock() {
const uiSettingsMock = {
register: uiSettingsServiceMock.createSetupContract().register,
};
- const mock: MockedKeys = {
+ const mock: CoreSetupMockType = {
capabilities: capabilitiesServiceMock.createSetupContract(),
context: contextServiceMock.createSetupContract(),
elasticsearch: elasticsearchServiceMock.createSetup(),
@@ -113,6 +115,9 @@ function createCoreSetupMock() {
savedObjects: savedObjectsServiceMock.createSetupContract(),
uiSettings: uiSettingsMock,
uuid: uuidServiceMock.createSetupContract(),
+ getStartServices: jest
+ .fn, object]>, []>()
+ .mockResolvedValue([createCoreStartMock(), {}]),
};
return mock;
diff --git a/src/core/server/plugins/integration_tests/plugins_service.test.mocks.ts b/src/core/server/plugins/integration_tests/plugins_service.test.mocks.ts
new file mode 100644
index 00000000000000..d81a7eb5db4ae6
--- /dev/null
+++ b/src/core/server/plugins/integration_tests/plugins_service.test.mocks.ts
@@ -0,0 +1,27 @@
+/*
+ * Licensed to Elasticsearch B.V. under one or more contributor
+ * license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright
+ * ownership. Elasticsearch B.V. licenses this file to you under
+ * the Apache License, Version 2.0 (the "License"); you may
+ * not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+export const mockPackage = new Proxy(
+ { raw: { __dirname: '/tmp' } as any },
+ { get: (obj, prop) => obj.raw[prop] }
+);
+jest.mock('../../../../core/server/utils/package_json', () => ({ pkg: mockPackage }));
+
+export const mockDiscover = jest.fn();
+jest.mock('../discovery/plugins_discovery', () => ({ discover: mockDiscover }));
diff --git a/src/core/server/plugins/integration_tests/plugins_service.test.ts b/src/core/server/plugins/integration_tests/plugins_service.test.ts
new file mode 100644
index 00000000000000..d5531478f03c54
--- /dev/null
+++ b/src/core/server/plugins/integration_tests/plugins_service.test.ts
@@ -0,0 +1,167 @@
+/*
+ * Licensed to Elasticsearch B.V. under one or more contributor
+ * license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright
+ * ownership. Elasticsearch B.V. licenses this file to you under
+ * the Apache License, Version 2.0 (the "License"); you may
+ * not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { mockPackage, mockDiscover } from './plugins_service.test.mocks';
+
+import { join } from 'path';
+
+import { PluginsService } from '../plugins_service';
+import { ConfigPath, ConfigService, Env } from '../../config';
+import { getEnvOptions } from '../../config/__mocks__/env';
+import { BehaviorSubject, from } from 'rxjs';
+import { rawConfigServiceMock } from '../../config/raw_config_service.mock';
+import { config } from '../plugins_config';
+import { loggingServiceMock } from '../../logging/logging_service.mock';
+import { coreMock } from '../../mocks';
+import { Plugin } from '../types';
+import { PluginWrapper } from '../plugin';
+
+describe('PluginsService', () => {
+ const logger = loggingServiceMock.create();
+ let pluginsService: PluginsService;
+
+ const createPlugin = (
+ id: string,
+ {
+ path = id,
+ disabled = false,
+ version = 'some-version',
+ requiredPlugins = [],
+ optionalPlugins = [],
+ kibanaVersion = '7.0.0',
+ configPath = [path],
+ server = true,
+ ui = true,
+ }: {
+ path?: string;
+ disabled?: boolean;
+ version?: string;
+ requiredPlugins?: string[];
+ optionalPlugins?: string[];
+ kibanaVersion?: string;
+ configPath?: ConfigPath;
+ server?: boolean;
+ ui?: boolean;
+ }
+ ): PluginWrapper => {
+ return new PluginWrapper({
+ path,
+ manifest: {
+ id,
+ version,
+ configPath: `${configPath}${disabled ? '-disabled' : ''}`,
+ kibanaVersion,
+ requiredPlugins,
+ optionalPlugins,
+ server,
+ ui,
+ },
+ opaqueId: Symbol(id),
+ initializerContext: { logger } as any,
+ });
+ };
+
+ beforeEach(async () => {
+ mockPackage.raw = {
+ branch: 'feature-v1',
+ version: 'v1',
+ build: {
+ distributable: true,
+ number: 100,
+ sha: 'feature-v1-build-sha',
+ },
+ };
+
+ const env = Env.createDefault(getEnvOptions());
+ const config$ = new BehaviorSubject>({
+ plugins: {
+ initialize: true,
+ },
+ });
+ const rawConfigService = rawConfigServiceMock.create({ rawConfig$: config$ });
+ const configService = new ConfigService(rawConfigService, env, logger);
+ await configService.setSchema(config.path, config.schema);
+
+ pluginsService = new PluginsService({
+ coreId: Symbol('core'),
+ env,
+ logger,
+ configService,
+ });
+ });
+
+ it("properly resolves `getStartServices` in plugin's lifecycle", async () => {
+ expect.assertions(5);
+
+ const pluginPath = 'plugin-path';
+
+ mockDiscover.mockReturnValue({
+ error$: from([]),
+ plugin$: from([
+ createPlugin('plugin-id', {
+ path: pluginPath,
+ configPath: 'path',
+ }),
+ ]),
+ });
+
+ let startDependenciesResolved = false;
+ let contextFromStart: any = null;
+ let contextFromStartService: any = null;
+
+ const pluginInitializer = () =>
+ ({
+ setup: async (coreSetup, deps) => {
+ coreSetup.getStartServices().then(([core, plugins]) => {
+ startDependenciesResolved = true;
+ contextFromStartService = { core, plugins };
+ });
+ },
+ start: async (core, plugins) => {
+ contextFromStart = { core, plugins };
+ await new Promise(resolve => setTimeout(resolve, 10));
+ expect(startDependenciesResolved).toBe(false);
+ },
+ } as Plugin);
+
+ jest.doMock(
+ join(pluginPath, 'server'),
+ () => ({
+ plugin: pluginInitializer,
+ }),
+ {
+ virtual: true,
+ }
+ );
+
+ await pluginsService.discover();
+
+ const setupDeps = coreMock.createInternalSetup();
+ await pluginsService.setup(setupDeps);
+
+ expect(startDependenciesResolved).toBe(false);
+
+ const startDeps = coreMock.createInternalStart();
+ await pluginsService.start(startDeps);
+
+ expect(startDependenciesResolved).toBe(true);
+ expect(contextFromStart!.core).toEqual(contextFromStartService!.core);
+ expect(contextFromStart!.plugins).toEqual(contextFromStartService!.plugins);
+ });
+});
diff --git a/src/core/server/plugins/plugin.test.ts b/src/core/server/plugins/plugin.test.ts
index 10259b718577c9..6875302f88a9da 100644
--- a/src/core/server/plugins/plugin.test.ts
+++ b/src/core/server/plugins/plugin.test.ts
@@ -237,6 +237,43 @@ test('`start` calls plugin.start with context and dependencies', async () => {
expect(mockPluginInstance.start).toHaveBeenCalledWith(context, deps);
});
+test("`start` resolves `startDependencies` Promise after plugin's start", async () => {
+ expect.assertions(2);
+
+ const manifest = createPluginManifest();
+ const opaqueId = Symbol();
+ const plugin = new PluginWrapper({
+ path: 'plugin-with-initializer-path',
+ manifest,
+ opaqueId,
+ initializerContext: createPluginInitializerContext(coreContext, opaqueId, manifest),
+ });
+ const startContext = { any: 'thing' } as any;
+ const pluginDeps = { someDep: 'value' };
+
+ let startDependenciesResolved = false;
+
+ const mockPluginInstance = {
+ setup: jest.fn(),
+ start: async () => {
+ // delay to ensure startDependencies is not resolved until after the plugin instance's start resolves.
+ await new Promise(resolve => setTimeout(resolve, 10));
+ expect(startDependenciesResolved).toBe(false);
+ },
+ };
+ mockPluginInitializer.mockReturnValue(mockPluginInstance);
+
+ await plugin.setup({} as any, {} as any);
+
+ const startDependenciesCheck = plugin.startDependencies.then(resolvedStartDeps => {
+ startDependenciesResolved = true;
+ expect(resolvedStartDeps).toEqual([startContext, pluginDeps]);
+ });
+
+ await plugin.start(startContext, pluginDeps);
+ await startDependenciesCheck;
+});
+
test('`stop` fails if plugin is not set up', async () => {
const manifest = createPluginManifest();
const opaqueId = Symbol();
diff --git a/src/core/server/plugins/plugin.ts b/src/core/server/plugins/plugin.ts
index c0b484515ccce2..d6c774f6fc41c7 100644
--- a/src/core/server/plugins/plugin.ts
+++ b/src/core/server/plugins/plugin.ts
@@ -19,7 +19,8 @@
import { join } from 'path';
import typeDetect from 'type-detect';
-
+import { Subject } from 'rxjs';
+import { first } from 'rxjs/operators';
import { Type } from '@kbn/config-schema';
import { Logger } from '../logging';
@@ -60,6 +61,9 @@ export class PluginWrapper<
private instance?: Plugin;
+ private readonly startDependencies$ = new Subject<[CoreStart, TPluginsStart]>();
+ public readonly startDependencies = this.startDependencies$.pipe(first()).toPromise();
+
constructor(
public readonly params: {
readonly path: string;
@@ -88,12 +92,12 @@ export class PluginWrapper<
* @param plugins The dictionary where the key is the dependency name and the value
* is the contract returned by the dependency's `setup` function.
*/
- public async setup(setupContext: CoreSetup, plugins: TPluginsSetup) {
+ public async setup(setupContext: CoreSetup, plugins: TPluginsSetup) {
this.instance = this.createPluginInstance();
this.log.info('Setting up plugin');
- return await this.instance.setup(setupContext, plugins);
+ return this.instance.setup(setupContext, plugins);
}
/**
@@ -108,7 +112,9 @@ export class PluginWrapper<
throw new Error(`Plugin "${this.name}" can't be started since it isn't set up.`);
}
- return await this.instance.start(startContext, plugins);
+ const startContract = await this.instance.start(startContext, plugins);
+ this.startDependencies$.next([startContext, plugins]);
+ return startContract;
}
/**
diff --git a/src/core/server/plugins/plugin_context.ts b/src/core/server/plugins/plugin_context.ts
index 6d82a8d3ec6cf1..f266172cb4bd9e 100644
--- a/src/core/server/plugins/plugin_context.ts
+++ b/src/core/server/plugins/plugin_context.ts
@@ -176,6 +176,7 @@ export function createPluginSetupContext(
uuid: {
getInstanceUuid: deps.uuid.getInstanceUuid,
},
+ getStartServices: () => plugin.startDependencies,
};
}
diff --git a/src/core/server/server.api.md b/src/core/server/server.api.md
index 7e3fc2eb660011..6e41a4aefba302 100644
--- a/src/core/server/server.api.md
+++ b/src/core/server/server.api.md
@@ -552,13 +552,14 @@ export interface ContextSetup {
export type CoreId = symbol;
// @public
-export interface CoreSetup {
+export interface CoreSetup {
// (undocumented)
capabilities: CapabilitiesSetup;
// (undocumented)
context: ContextSetup;
// (undocumented)
elasticsearch: ElasticsearchServiceSetup;
+ getStartServices(): Promise<[CoreStart, TPluginsStart]>;
// (undocumented)
http: HttpServiceSetup;
// (undocumented)
diff --git a/x-pack/plugins/security/server/plugin.test.ts b/x-pack/plugins/security/server/plugin.test.ts
index 5e32a0e90198a6..56aad4ece3e958 100644
--- a/x-pack/plugins/security/server/plugin.test.ts
+++ b/x-pack/plugins/security/server/plugin.test.ts
@@ -6,7 +6,7 @@
import { of } from 'rxjs';
import { ByteSizeValue } from '@kbn/config-schema';
-import { ICustomClusterClient, CoreSetup } from '../../../../src/core/server';
+import { ICustomClusterClient } from '../../../../src/core/server';
import { elasticsearchClientPlugin } from './elasticsearch_client_plugin';
import { Plugin, PluginSetupDependencies } from './plugin';
@@ -14,7 +14,7 @@ import { coreMock, elasticsearchServiceMock } from '../../../../src/core/server/
describe('Security Plugin', () => {
let plugin: Plugin;
- let mockCoreSetup: MockedKeys;
+ let mockCoreSetup: ReturnType;
let mockClusterClient: jest.Mocked;
let mockDependencies: PluginSetupDependencies;
beforeEach(() => {