[Core plugin system] Add dynamic contract resolving (#167113)

### Summary

Fix https://github.com/elastic/kibana/issues/166688

Implements dynamic contract resolving for plugins, allowing to retrieve
contracts after their respective lifecycle is completed, and therefore
working around cyclic dependencies.

In term of workflow execution, we're basically going from

<img width="842" alt="Screenshot 2023-09-27 at 08 09 27"
src="251637d1-ec97-4071-a445-2f59512ce187">
 
to:

<img width="1092" alt="Screenshot 2023-09-27 at 08 09 32"
src="de466cda-7e43-4fd3-81ec-4339d05d279d">

### API

This functionality is exposed by the now publicly exposed `plugins`
service contracts:

```ts
setup(core) {
  core.plugins.onSetup<{pluginA: SetupContractA, pluginB: SetupContractA}>('pluginA', 'pluginB')
      .then(({ pluginA, pluginB }) => {
        if(pluginA.found && pluginB.found) {
          // do something with pluginA.contract and pluginB.contract
        }
      });
}
```  

```ts
start(core) {
  core.plugins.onStart<{pluginA: StartContractA, pluginB: StartContractA}>('pluginA', 'pluginB')
      .then(({ pluginA, pluginB }) => {
        if(pluginA.found && pluginB.found) {
          // do something with pluginA.contract and pluginB.contract
        }
      });
}
```

**remark:** the `setup` contract exposed both `onSetup` and `onStart`,
while the `start` contract only exposed `onStart`. The intent is to
avoid fully disrupting the concept of lifecycle stages.

### Guardrails

To prevent developer from abusing this new API, or at least to add some
visibility on its adoption, plugins can only perforn dynamic contract
resolving against dependencies explicitly defined in their manifest:
- any required dependencies (*existing concept*)
- any optional dependencies (*existing concept*)
- any runtime dependencies (**new concept**)

Runtime dependencies must be specified using the new
`runtimePluginDependencies` field of a plugin's manifest.

```json
{
  "type": "plugin",
  "id": "@kbn/some-id",
  "owner": "@elastic/kibana-core",
  "plugin": {
    "id": "some-id",
    "...": "...",
    "runtimePluginDependencies" : ["someOtherPluginId"]
  }
}

```

Using the contract resolving API will throw at call time when trying to
resolve the contract for an undeclared dependency.

E.g this would throw at invocation time (not returning a rejected
promise - throw).

```ts
setup(core) {
  core.plugins.onSetup<{undeclaredDependency: SomeContract}>('undeclaredDependency');
}
```  

The reasoning behind throwing is that these errors should only occur
during the development process, and an hard fail is way more visible
than a promise rejection that should be more easily shallowed.

### Code reviews

This PR defines @elastic/kibana-core as codeowner of all `kibana.jsonc`
files in the `src/plugins` and `x-pack/plugins` directories, so that a
code review will be triggered whenever anyone changes something in any
manifest. The intent is to be able to monitor new usages of the feature,
via the addition of entries in the `runtimePluginDependencies` option of
the manifest.

### Remarks

Exposing this API, and therefore making possible cyclic dependencies
between plugins, opens the door to other questions.

For instance, cross-plugin type imports are not technically possible at
the moment, given that plugins are referencing each others via TS refs,
and refs forbid cyclic dependencies. Which means that to leverage this
to address cyclic dependency issues, the public types of **at least one
of the two** plugins will have to be extracted to a shared place (likely
a package).

Resolving, or trying to improve the developer experience around this
issue, is absolutely out of scope of the current PR (and the issue it
addresses).

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Pierre Gayvallet 2023-10-24 11:32:09 +02:00 committed by GitHub
parent b3c5646195
commit e398e7bc51
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
95 changed files with 2391 additions and 133 deletions

8
.github/CODEOWNERS vendored
View file

@ -226,6 +226,8 @@ test/plugin_functional/plugins/core_plugin_b @elastic/kibana-core
test/plugin_functional/plugins/core_plugin_chromeless @elastic/kibana-core
test/plugin_functional/plugins/core_plugin_deep_links @elastic/kibana-core
test/plugin_functional/plugins/core_plugin_deprecations @elastic/kibana-core
test/plugin_functional/plugins/core_dynamic_resolving_a @elastic/kibana-core
test/plugin_functional/plugins/core_dynamic_resolving_b @elastic/kibana-core
test/plugin_functional/plugins/core_plugin_execution_context @elastic/kibana-core
test/plugin_functional/plugins/core_plugin_helpmenu @elastic/kibana-core
test/node_roles_functional/plugins/core_plugin_initializer_context @elastic/kibana-core
@ -235,6 +237,8 @@ packages/core/plugins/core-plugins-base-server-internal @elastic/kibana-core
packages/core/plugins/core-plugins-browser @elastic/kibana-core
packages/core/plugins/core-plugins-browser-internal @elastic/kibana-core
packages/core/plugins/core-plugins-browser-mocks @elastic/kibana-core
packages/core/plugins/core-plugins-contracts-browser @elastic/kibana-core
packages/core/plugins/core-plugins-contracts-server @elastic/kibana-core
packages/core/plugins/core-plugins-server @elastic/kibana-core
packages/core/plugins/core-plugins-server-internal @elastic/kibana-core
packages/core/plugins/core-plugins-server-mocks @elastic/kibana-core
@ -1454,6 +1458,10 @@ packages/react @elastic/appex-sharedux
/packages/core/saved-objects/docs/openapi @elastic/platform-docs
/plugins/data_views/docs/openapi @elastic/platform-docs
# Plugin manifests
/src/plugins/**/kibana.jsonc @elastic/kibana-core
/x-pack/plugins/**/kibana.jsonc @elastic/kibana-core
####
## These rules are always last so they take ultimate priority over everything else
####

View file

@ -9,4 +9,4 @@
import { useKibana } from '@kbn/kibana-react-plugin/public';
import { BfetchDeps } from '../mount';
export const useDeps = () => useKibana().services as BfetchDeps;
export const useDeps = () => useKibana().services as unknown as BfetchDeps;

View file

@ -10,4 +10,4 @@ import { useKibana } from '@kbn/kibana-react-plugin/public';
import type { ResponseStreamDeps } from '../mount';
export const useDeps = () => useKibana().services as ResponseStreamDeps;
export const useDeps = () => useKibana().services as unknown as ResponseStreamDeps;

View file

@ -295,6 +295,8 @@
"@kbn/core-plugin-chromeless-plugin": "link:test/plugin_functional/plugins/core_plugin_chromeless",
"@kbn/core-plugin-deep-links-plugin": "link:test/plugin_functional/plugins/core_plugin_deep_links",
"@kbn/core-plugin-deprecations-plugin": "link:test/plugin_functional/plugins/core_plugin_deprecations",
"@kbn/core-plugin-dynamic-resolving-a": "link:test/plugin_functional/plugins/core_dynamic_resolving_a",
"@kbn/core-plugin-dynamic-resolving-b": "link:test/plugin_functional/plugins/core_dynamic_resolving_b",
"@kbn/core-plugin-execution-context-plugin": "link:test/plugin_functional/plugins/core_plugin_execution_context",
"@kbn/core-plugin-helpmenu-plugin": "link:test/plugin_functional/plugins/core_plugin_helpmenu",
"@kbn/core-plugin-initializer-context-plugin": "link:test/node_roles_functional/plugins/core_plugin_initializer_context",
@ -303,6 +305,8 @@
"@kbn/core-plugins-base-server-internal": "link:packages/core/plugins/core-plugins-base-server-internal",
"@kbn/core-plugins-browser": "link:packages/core/plugins/core-plugins-browser",
"@kbn/core-plugins-browser-internal": "link:packages/core/plugins/core-plugins-browser-internal",
"@kbn/core-plugins-contracts-browser": "link:packages/core/plugins/core-plugins-contracts-browser",
"@kbn/core-plugins-contracts-server": "link:packages/core/plugins/core-plugins-contracts-server",
"@kbn/core-plugins-server": "link:packages/core/plugins/core-plugins-server",
"@kbn/core-plugins-server-internal": "link:packages/core/plugins/core-plugins-server-internal",
"@kbn/core-preboot-server": "link:packages/core/preboot/core-preboot-server",

View file

@ -135,6 +135,7 @@ describe('CoreApp', () => {
optionalPlugins: [],
requiredBundles: [],
requiredPlugins: [],
runtimePluginDependencies: [],
});
});
it('calls `registerBundleRoutes` with the correct options', () => {

View file

@ -77,6 +77,12 @@ export interface DiscoveredPlugin {
*/
readonly requiredBundles: readonly PluginName[];
/**
* An optional list of plugin dependencies that can be resolved dynamically at runtime
* using the dynamic contract resolving capabilities from the plugin service.
*/
readonly runtimePluginDependencies: readonly PluginName[];
/**
* Specifies whether this plugin - and its required dependencies - will be enabled for anonymous pages (login page, status page when
* configured, etc.) Default is false.

View file

@ -11,7 +11,8 @@ import type { InternalApplicationSetup } from '@kbn/core-application-browser-int
import type { InternalInjectedMetadataSetup } from '@kbn/core-injected-metadata-browser-internal';
/** @internal */
export interface InternalCoreSetup extends Omit<CoreSetup, 'application' | 'getStartServices'> {
export interface InternalCoreSetup
extends Omit<CoreSetup, 'application' | 'plugins' | 'getStartServices'> {
application: InternalApplicationSetup;
injectedMetadata: InternalInjectedMetadataSetup;
}

View file

@ -11,7 +11,7 @@ import type { InternalApplicationStart } from '@kbn/core-application-browser-int
import type { InternalInjectedMetadataStart } from '@kbn/core-injected-metadata-browser-internal';
/** @internal */
export interface InternalCoreStart extends Omit<CoreStart, 'application'> {
export interface InternalCoreStart extends Omit<CoreStart, 'application' | 'plugins'> {
application: InternalApplicationStart;
injectedMetadata: InternalInjectedMetadataStart;
}

View file

@ -44,6 +44,10 @@ export function createCoreSetupMock({
settings: settingsServiceMock.createSetupContract(),
deprecations: deprecationsServiceMock.createSetupContract(),
theme: themeServiceMock.createSetupContract(),
plugins: {
onSetup: jest.fn(),
onStart: jest.fn(),
},
};
return mock;

View file

@ -40,6 +40,9 @@ export function createCoreStartMock({ basePath = '' } = {}) {
deprecations: deprecationsServiceMock.createStartContract(),
theme: themeServiceMock.createStartContract(),
fatalErrors: fatalErrorsServiceMock.createStartContract(),
plugins: {
onStart: jest.fn(),
},
};
return mock;

View file

@ -15,6 +15,7 @@ import type { IUiSettingsClient, SettingsStart } from '@kbn/core-ui-settings-bro
import type { NotificationsSetup } from '@kbn/core-notifications-browser';
import type { ApplicationSetup } from '@kbn/core-application-browser';
import type { CustomBrandingSetup } from '@kbn/core-custom-branding-browser';
import type { PluginsServiceSetup } from '@kbn/core-plugins-contracts-browser';
import type { CoreStart } from './core_start';
/**
@ -53,6 +54,8 @@ export interface CoreSetup<TPluginsStart extends object = object, TStart = unkno
executionContext: ExecutionContextSetup;
/** {@link ThemeServiceSetup} */
theme: ThemeServiceSetup;
/** {@link PluginsServiceSetup} */
plugins: PluginsServiceSetup;
/** {@link StartServicesAccessor} */
getStartServices: StartServicesAccessor<TPluginsStart, TStart>;
}

View file

@ -21,6 +21,7 @@ import type { NotificationsStart } from '@kbn/core-notifications-browser';
import type { ApplicationStart } from '@kbn/core-application-browser';
import type { ChromeStart } from '@kbn/core-chrome-browser';
import type { CustomBrandingStart } from '@kbn/core-custom-branding-browser';
import type { PluginsServiceStart } from '@kbn/core-plugins-contracts-browser';
/**
* Core services exposed to the `Plugin` start lifecycle
@ -68,4 +69,6 @@ export interface CoreStart {
deprecations: DeprecationsServiceStart;
/** {@link ThemeServiceStart} */
theme: ThemeServiceStart;
/** {@link PluginsServiceStart} */
plugins: PluginsServiceStart;
}

View file

@ -26,7 +26,8 @@
"@kbn/core-overlays-browser",
"@kbn/core-saved-objects-browser",
"@kbn/core-chrome-browser",
"@kbn/core-custom-branding-browser"
"@kbn/core-custom-branding-browser",
"@kbn/core-plugins-contracts-browser"
],
"exclude": [
"target/**/*",

View file

@ -70,6 +70,10 @@ export function createCoreSetupMock({
coreUsageData: {
registerUsageCounter: coreUsageDataServiceMock.createSetupContract().registerUsageCounter,
},
plugins: {
onSetup: jest.fn(),
onStart: jest.fn(),
},
getStartServices: jest
.fn<Promise<[ReturnType<typeof createCoreStartMock>, object, any]>, []>()
.mockResolvedValue([createCoreStartMock(), pluginStartDeps, pluginStartContract]),

View file

@ -33,6 +33,9 @@ export function createCoreStartMock() {
coreUsageData: coreUsageDataServiceMock.createStartContract(),
executionContext: executionContextServiceMock.createInternalStartContract(),
customBranding: customBrandingServiceMock.createStartContract(),
plugins: {
onStart: jest.fn(),
},
};
return mock;

View file

@ -24,6 +24,7 @@ import { UiSettingsServiceSetup } from '@kbn/core-ui-settings-server';
import { CoreUsageDataSetup } from '@kbn/core-usage-data-server';
import { CustomBrandingSetup } from '@kbn/core-custom-branding-server';
import { UserSettingsServiceSetup } from '@kbn/core-user-settings-server';
import { PluginsServiceSetup } from '@kbn/core-plugins-contracts-server';
import { CoreStart } from './core_start';
/**
@ -73,6 +74,8 @@ export interface CoreSetup<TPluginsStart extends object = object, TStart = unkno
getStartServices: StartServicesAccessor<TPluginsStart, TStart>;
/** @internal {@link CoreUsageDataSetup} */
coreUsageData: CoreUsageDataSetup;
/** {@link PluginsServiceSetup} */
plugins: PluginsServiceSetup;
}
/**

View file

@ -17,6 +17,7 @@ import { SavedObjectsServiceStart } from '@kbn/core-saved-objects-server';
import { UiSettingsServiceStart } from '@kbn/core-ui-settings-server';
import { CoreUsageDataStart } from '@kbn/core-usage-data-server';
import { CustomBrandingStart } from '@kbn/core-custom-branding-server';
import { PluginsServiceStart } from '@kbn/core-plugins-contracts-server';
/**
* Context passed to the plugins `start` method.
@ -46,4 +47,6 @@ export interface CoreStart {
uiSettings: UiSettingsServiceStart;
/** @internal {@link CoreUsageDataStart} */
coreUsageData: CoreUsageDataStart;
/** {@link PluginsServiceStart} */
plugins: PluginsServiceStart;
}

View file

@ -29,7 +29,8 @@
"@kbn/core-ui-settings-server",
"@kbn/core-usage-data-server",
"@kbn/core-custom-branding-server",
"@kbn/core-user-settings-server"
"@kbn/core-user-settings-server",
"@kbn/core-plugins-contracts-server"
],
"exclude": [
"target/**/*",

View file

@ -8,8 +8,8 @@
export { PluginsService } from './src';
export type {
PluginsServiceSetup,
PluginsServiceStart,
InternalPluginsServiceSetup,
InternalPluginsServiceStart,
PluginsServiceSetupDeps,
PluginsServiceStartDeps,
} from './src';

View file

@ -8,8 +8,8 @@
export { PluginsService } from './plugins_service';
export type {
PluginsServiceSetup,
PluginsServiceStart,
InternalPluginsServiceSetup,
InternalPluginsServiceStart,
PluginsServiceSetupDeps,
PluginsServiceStartDeps,
} from './plugins_service';

View file

@ -24,6 +24,7 @@ function createManifest(
requiredPlugins: required,
optionalPlugins: optional,
requiredBundles: [],
runtimePluginDependencies: [],
owner: {
name: 'foo',
},

View file

@ -32,6 +32,7 @@ export class PluginWrapper<
public readonly configPath: DiscoveredPlugin['configPath'];
public readonly requiredPlugins: DiscoveredPlugin['requiredPlugins'];
public readonly optionalPlugins: DiscoveredPlugin['optionalPlugins'];
public readonly runtimePluginDependencies: DiscoveredPlugin['runtimePluginDependencies'];
private instance?: Plugin<TSetup, TStart, TPluginsSetup, TPluginsStart>;
private readonly startDependencies$ = new Subject<[CoreStart, TPluginsStart, TStart]>();
@ -46,6 +47,7 @@ export class PluginWrapper<
this.configPath = discoveredPlugin.configPath;
this.requiredPlugins = discoveredPlugin.requiredPlugins;
this.optionalPlugins = discoveredPlugin.optionalPlugins;
this.runtimePluginDependencies = discoveredPlugin.runtimePluginDependencies;
}
/**

View file

@ -20,6 +20,7 @@ const createPluginManifest = (pluginName: string): DiscoveredPlugin => {
requiredPlugins: [],
optionalPlugins: [],
requiredBundles: [],
runtimePluginDependencies: [],
};
};

View file

@ -11,8 +11,9 @@ import type { CoreContext } from '@kbn/core-base-browser-internal';
import type { DiscoveredPlugin, PluginOpaqueId } from '@kbn/core-base-common';
import type { CoreSetup, CoreStart } from '@kbn/core-lifecycle-browser';
import type { PluginInitializerContext } from '@kbn/core-plugins-browser';
import { PluginWrapper } from './plugin';
import { PluginsServiceSetupDeps, PluginsServiceStartDeps } from './plugins_service';
import type { PluginWrapper } from './plugin';
import type { PluginsServiceSetupDeps, PluginsServiceStartDeps } from './plugins_service';
import type { IRuntimePluginContractResolver } from './plugin_contract_resolver';
/**
* Provides a plugin-specific context passed to the plugin's constructor. This is currently
@ -63,11 +64,15 @@ export function createPluginSetupContext<
TStart,
TPluginsSetup extends object,
TPluginsStart extends object
>(
coreContext: CoreContext,
deps: PluginsServiceSetupDeps,
plugin: PluginWrapper<TSetup, TStart, TPluginsSetup, TPluginsStart>
): CoreSetup {
>({
deps,
plugin,
runtimeResolver,
}: {
deps: PluginsServiceSetupDeps;
plugin: PluginWrapper<TSetup, TStart, TPluginsSetup, TPluginsStart>;
runtimeResolver: IRuntimePluginContractResolver;
}): CoreSetup {
return {
analytics: deps.analytics,
application: {
@ -82,6 +87,10 @@ export function createPluginSetupContext<
uiSettings: deps.uiSettings,
settings: deps.settings,
theme: deps.theme,
plugins: {
onSetup: (...dependencyNames) => runtimeResolver.onSetup(plugin.name, dependencyNames),
onStart: (...dependencyNames) => runtimeResolver.onStart(plugin.name, dependencyNames),
},
getStartServices: () => plugin.startDependencies,
};
}
@ -101,11 +110,15 @@ export function createPluginStartContext<
TStart,
TPluginsSetup extends object,
TPluginsStart extends object
>(
coreContext: CoreContext,
deps: PluginsServiceStartDeps,
plugin: PluginWrapper<TSetup, TStart, TPluginsSetup, TPluginsStart>
): CoreStart {
>({
deps,
plugin,
runtimeResolver,
}: {
deps: PluginsServiceStartDeps;
plugin: PluginWrapper<TSetup, TStart, TPluginsSetup, TPluginsStart>;
runtimeResolver: IRuntimePluginContractResolver;
}): CoreStart {
return {
analytics: deps.analytics,
application: {
@ -131,5 +144,8 @@ export function createPluginStartContext<
fatalErrors: deps.fatalErrors,
deprecations: deps.deprecations,
theme: deps.theme,
plugins: {
onStart: (...dependencyNames) => runtimeResolver.onStart(plugin.name, dependencyNames),
},
};
}

View file

@ -0,0 +1,342 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { RuntimePluginContractResolver } from './plugin_contract_resolver';
const nextTick = () => new Promise((resolve) => setTimeout(resolve, 1));
const fewTicks = () =>
nextTick()
.then(() => nextTick())
.then(() => nextTick());
const toMap = (record: Record<string, unknown>): Map<string, unknown> => {
return new Map(Object.entries(record));
};
const pluginAContract = Symbol();
describe('RuntimePluginContractResolver', () => {
const SOURCE_PLUGIN = 'sourcePlugin';
let resolver: RuntimePluginContractResolver;
beforeEach(() => {
resolver = new RuntimePluginContractResolver();
const dependencyMap = new Map<string, Set<string>>();
dependencyMap.set(SOURCE_PLUGIN, new Set(['pluginA', 'pluginB', 'pluginC']));
resolver.setDependencyMap(dependencyMap);
});
describe('setup contracts', () => {
it('throws if onSetup is called before setDependencyMap', () => {
resolver = new RuntimePluginContractResolver();
expect(() => resolver.onSetup(SOURCE_PLUGIN, ['pluginA'])).toThrowErrorMatchingInlineSnapshot(
`"onSetup cannot be called before setDependencyMap"`
);
});
it('throws if resolveSetupRequests is called multiple times', async () => {
resolver.resolveSetupRequests(
toMap({
pluginA: pluginAContract,
})
);
expect(() =>
resolver.resolveSetupRequests(
toMap({
pluginA: pluginAContract,
})
)
).toThrowErrorMatchingInlineSnapshot(`"resolveSetupRequests can only be called once"`);
});
it('resolves a single request', async () => {
const handler = jest.fn();
resolver.onSetup(SOURCE_PLUGIN, ['pluginA']).then((contracts) => handler(contracts));
await fewTicks();
expect(handler).not.toHaveBeenCalled();
resolver.resolveSetupRequests(
toMap({
pluginA: pluginAContract,
})
);
await fewTicks();
expect(handler).toHaveBeenCalledTimes(1);
expect(handler).toHaveBeenCalledWith({
pluginA: {
found: true,
contract: pluginAContract,
},
});
});
it('resolves multiple requests', async () => {
const handler1 = jest.fn();
const handler2 = jest.fn();
const handler3 = jest.fn();
resolver.onSetup(SOURCE_PLUGIN, ['pluginA']).then((contracts) => handler1(contracts));
resolver.onSetup(SOURCE_PLUGIN, ['pluginB']).then((contracts) => handler2(contracts));
resolver
.onSetup(SOURCE_PLUGIN, ['pluginA', 'pluginB'])
.then((contracts) => handler3(contracts));
await fewTicks();
expect(handler1).not.toHaveBeenCalled();
expect(handler2).not.toHaveBeenCalled();
expect(handler3).not.toHaveBeenCalled();
resolver.resolveSetupRequests(
toMap({
pluginA: pluginAContract,
})
);
await fewTicks();
expect(handler1).toHaveBeenCalledTimes(1);
expect(handler1).toHaveBeenCalledWith({
pluginA: {
found: true,
contract: pluginAContract,
},
});
expect(handler2).toHaveBeenCalledTimes(1);
expect(handler2).toHaveBeenCalledWith({
pluginB: {
found: false,
},
});
expect(handler3).toHaveBeenCalledTimes(1);
expect(handler3).toHaveBeenCalledWith({
pluginA: {
found: true,
contract: pluginAContract,
},
pluginB: {
found: false,
},
});
});
it('resolves requests instantly when called after resolveSetupRequests', async () => {
resolver.resolveSetupRequests(
toMap({
pluginA: pluginAContract,
})
);
const handler1 = jest.fn();
const handler2 = jest.fn();
resolver.onSetup(SOURCE_PLUGIN, ['pluginA']).then((contracts) => handler1(contracts));
resolver.onSetup(SOURCE_PLUGIN, ['pluginB']).then((contracts) => handler2(contracts));
await fewTicks();
expect(handler1).toHaveBeenCalledTimes(1);
expect(handler1).toHaveBeenCalledWith({
pluginA: {
found: true,
contract: pluginAContract,
},
});
expect(handler2).toHaveBeenCalledTimes(1);
expect(handler2).toHaveBeenCalledWith({
pluginB: {
found: false,
},
});
});
it('throws when requesting a contract not defined in the dependency map', async () => {
expect(() =>
resolver.onSetup(SOURCE_PLUGIN, ['undeclaredPlugin'])
).toThrowErrorMatchingInlineSnapshot(
`"Dynamic contract resolving requires the dependencies to be declared in the plugin manifest.Undeclared dependencies: undeclaredPlugin"`
);
});
it('throws when requesting a mixed defined/undefined dependencies', async () => {
expect(() =>
resolver.onSetup(SOURCE_PLUGIN, [
'pluginA',
'undeclaredPlugin1',
'pluginB',
'undeclaredPlugin2',
])
).toThrowErrorMatchingInlineSnapshot(
`"Dynamic contract resolving requires the dependencies to be declared in the plugin manifest.Undeclared dependencies: undeclaredPlugin1, undeclaredPlugin2"`
);
});
});
describe('start contracts', () => {
it('throws if onStart is called before setDependencyMap', () => {
resolver = new RuntimePluginContractResolver();
expect(() => resolver.onStart(SOURCE_PLUGIN, ['pluginA'])).toThrowErrorMatchingInlineSnapshot(
`"onStart cannot be called before setDependencyMap"`
);
});
it('throws if resolveStartRequests is called multiple times', async () => {
resolver.resolveStartRequests(
toMap({
pluginA: pluginAContract,
})
);
expect(() =>
resolver.resolveStartRequests(
toMap({
pluginA: pluginAContract,
})
)
).toThrowErrorMatchingInlineSnapshot(`"resolveStartRequests can only be called once"`);
});
it('resolves a single request', async () => {
const handler = jest.fn();
resolver.onStart(SOURCE_PLUGIN, ['pluginA']).then((contracts) => handler(contracts));
await fewTicks();
expect(handler).not.toHaveBeenCalled();
resolver.resolveStartRequests(
toMap({
pluginA: pluginAContract,
})
);
await fewTicks();
expect(handler).toHaveBeenCalledTimes(1);
expect(handler).toHaveBeenCalledWith({
pluginA: {
found: true,
contract: pluginAContract,
},
});
});
it('resolves multiple requests', async () => {
const handler1 = jest.fn();
const handler2 = jest.fn();
const handler3 = jest.fn();
resolver.onStart(SOURCE_PLUGIN, ['pluginA']).then((contracts) => handler1(contracts));
resolver.onStart(SOURCE_PLUGIN, ['pluginB']).then((contracts) => handler2(contracts));
resolver
.onStart(SOURCE_PLUGIN, ['pluginA', 'pluginB'])
.then((contracts) => handler3(contracts));
await fewTicks();
expect(handler1).not.toHaveBeenCalled();
expect(handler2).not.toHaveBeenCalled();
expect(handler3).not.toHaveBeenCalled();
resolver.resolveStartRequests(
toMap({
pluginA: pluginAContract,
})
);
await fewTicks();
expect(handler1).toHaveBeenCalledTimes(1);
expect(handler1).toHaveBeenCalledWith({
pluginA: {
found: true,
contract: pluginAContract,
},
});
expect(handler2).toHaveBeenCalledTimes(1);
expect(handler2).toHaveBeenCalledWith({
pluginB: {
found: false,
},
});
expect(handler3).toHaveBeenCalledTimes(1);
expect(handler3).toHaveBeenCalledWith({
pluginA: {
found: true,
contract: pluginAContract,
},
pluginB: {
found: false,
},
});
});
it('resolves requests instantly when called after resolveSetupRequests', async () => {
resolver.resolveStartRequests(
toMap({
pluginA: pluginAContract,
})
);
const handler1 = jest.fn();
const handler2 = jest.fn();
resolver.onStart(SOURCE_PLUGIN, ['pluginA']).then((contracts) => handler1(contracts));
resolver.onStart(SOURCE_PLUGIN, ['pluginB']).then((contracts) => handler2(contracts));
await fewTicks();
expect(handler1).toHaveBeenCalledTimes(1);
expect(handler1).toHaveBeenCalledWith({
pluginA: {
found: true,
contract: pluginAContract,
},
});
expect(handler2).toHaveBeenCalledTimes(1);
expect(handler2).toHaveBeenCalledWith({
pluginB: {
found: false,
},
});
});
it('throws when requesting a contract not defined in the dependency map', async () => {
expect(() =>
resolver.onStart(SOURCE_PLUGIN, ['undeclaredPlugin'])
).toThrowErrorMatchingInlineSnapshot(
`"Dynamic contract resolving requires the dependencies to be declared in the plugin manifest.Undeclared dependencies: undeclaredPlugin"`
);
});
it('throws when requesting a mixed defined/undefined dependencies', async () => {
expect(() =>
resolver.onStart(SOURCE_PLUGIN, [
'pluginA',
'undeclaredPlugin1',
'pluginB',
'undeclaredPlugin2',
])
).toThrowErrorMatchingInlineSnapshot(
`"Dynamic contract resolving requires the dependencies to be declared in the plugin manifest.Undeclared dependencies: undeclaredPlugin1, undeclaredPlugin2"`
);
});
});
});

View file

@ -0,0 +1,161 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import type { PublicMethodsOf } from '@kbn/utility-types';
import type { PluginName } from '@kbn/core-base-common';
import type {
PluginContractResolverResponse,
PluginContractMap,
PluginContractResolverResponseItem,
} from '@kbn/core-plugins-contracts-browser';
export type IRuntimePluginContractResolver = PublicMethodsOf<RuntimePluginContractResolver>;
export class RuntimePluginContractResolver {
private dependencyMap?: Map<PluginName, Set<PluginName>>;
private setupContracts?: Map<PluginName, unknown>;
private startContracts?: Map<PluginName, unknown>;
private readonly setupRequestQueue: PluginContractRequest[] = [];
private readonly startRequestQueue: PluginContractRequest[] = [];
setDependencyMap(depMap: Map<PluginName, Set<PluginName>>) {
this.dependencyMap = new Map(depMap.entries());
}
onSetup = <T extends PluginContractMap>(
pluginName: PluginName,
dependencyNames: Array<keyof T>
): Promise<PluginContractResolverResponse<T>> => {
if (!this.dependencyMap) {
throw new Error('onSetup cannot be called before setDependencyMap');
}
const dependencyList = this.dependencyMap.get(pluginName) ?? new Set();
const notDependencyPlugins = dependencyNames.filter(
(name) => !dependencyList.has(name as PluginName)
);
if (notDependencyPlugins.length) {
throw new Error(
'Dynamic contract resolving requires the dependencies to be declared in the plugin manifest.' +
`Undeclared dependencies: ${notDependencyPlugins.join(', ')}`
);
}
if (this.setupContracts) {
const response = createContractRequestResponse(
dependencyNames as PluginName[],
this.setupContracts
);
return Promise.resolve(response as PluginContractResolverResponse<T>);
} else {
const setupContractRequest = createPluginContractRequest<PluginContractResolverResponse<T>>(
dependencyNames as PluginName[]
);
this.setupRequestQueue.push(setupContractRequest as PluginContractRequest);
return setupContractRequest.contractPromise;
}
};
onStart = <T extends PluginContractMap>(
pluginName: PluginName,
dependencyNames: Array<keyof T>
): Promise<PluginContractResolverResponse<T>> => {
if (!this.dependencyMap) {
throw new Error('onStart cannot be called before setDependencyMap');
}
const dependencyList = this.dependencyMap.get(pluginName) ?? new Set();
const notDependencyPlugins = dependencyNames.filter(
(name) => !dependencyList.has(name as PluginName)
);
if (notDependencyPlugins.length) {
throw new Error(
'Dynamic contract resolving requires the dependencies to be declared in the plugin manifest.' +
`Undeclared dependencies: ${notDependencyPlugins.join(', ')}`
);
}
if (this.startContracts) {
const response = createContractRequestResponse(
dependencyNames as PluginName[],
this.startContracts
);
return Promise.resolve(response as PluginContractResolverResponse<T>);
} else {
const startContractRequest = createPluginContractRequest<PluginContractResolverResponse<T>>(
dependencyNames as PluginName[]
);
this.startRequestQueue.push(startContractRequest as PluginContractRequest);
return startContractRequest.contractPromise;
}
};
resolveSetupRequests(setupContracts: Map<PluginName, unknown>) {
if (this.setupContracts) {
throw new Error('resolveSetupRequests can only be called once');
}
this.setupContracts = setupContracts;
for (const setupRequest of this.setupRequestQueue) {
const response = createContractRequestResponse(setupRequest.pluginNames, setupContracts);
setupRequest.resolve(response);
}
}
resolveStartRequests(startContracts: Map<PluginName, unknown>) {
if (this.startContracts) {
throw new Error('resolveStartRequests can only be called once');
}
this.startContracts = startContracts;
for (const startRequest of this.startRequestQueue) {
const response = createContractRequestResponse(startRequest.pluginNames, startContracts);
startRequest.resolve(response);
}
}
}
interface PluginContractRequest<T = unknown> {
pluginNames: PluginName[];
contractPromise: Promise<T>;
resolve: (data?: T) => void;
}
const createPluginContractRequest = <T = unknown>(
pluginNames: PluginName[]
): PluginContractRequest<T> => {
let resolve!: (data?: T) => void;
const contractPromise = new Promise<any>((_resolve) => {
resolve = _resolve;
});
return {
pluginNames,
contractPromise,
resolve,
};
};
const createContractRequestResponse = <T extends PluginContractMap>(
pluginNames: PluginName[],
contracts: Map<string, unknown>
): PluginContractResolverResponse<T> => {
const response = {} as Record<string, unknown>;
for (const pluginName of pluginNames) {
const pluginResponse: PluginContractResolverResponseItem = contracts.has(pluginName)
? {
found: true,
contract: contracts.get(pluginName)!,
}
: { found: false };
response[pluginName] = pluginResponse;
}
return response as PluginContractResolverResponse<T>;
};

View file

@ -8,6 +8,7 @@
import type { PluginName } from '@kbn/core-base-common';
import type { Plugin } from '@kbn/core-plugins-browser';
import { createRuntimePluginContractResolverMock } from './test_helpers';
export type MockedPluginInitializer = jest.Mock<Plugin<unknown, unknown>>;
@ -20,3 +21,11 @@ export const mockPluginInitializerProvider: jest.Mock<MockedPluginInitializer, [
jest.mock('./plugin_reader', () => ({
read: mockPluginInitializerProvider,
}));
export const runtimeResolverMock = createRuntimePluginContractResolverMock();
jest.doMock('./plugin_contract_resolver', () => {
return {
RuntimePluginContractResolver: jest.fn().mockImplementation(() => runtimeResolverMock),
};
});

View file

@ -11,9 +11,10 @@ import { omit } from 'lodash';
import {
type MockedPluginInitializer,
mockPluginInitializerProvider,
runtimeResolverMock,
} from './plugins_service.test.mocks';
import { type PluginName, PluginType } from '@kbn/core-base-common';
import { type PluginName, type DiscoveredPlugin, PluginType } from '@kbn/core-base-common';
import { analyticsServiceMock } from '@kbn/core-analytics-browser-mocks';
import { docLinksServiceMock } from '@kbn/core-doc-links-browser-mocks';
import { executionContextServiceMock } from '@kbn/core-execution-context-browser-mocks';
@ -60,24 +61,24 @@ let mockStartContext: DeeplyMocked<CoreStart>;
function createManifest(
id: string,
{ required = [], optional = [] }: { required?: string[]; optional?: string[]; ui?: boolean } = {}
) {
): DiscoveredPlugin {
return {
id,
version: 'some-version',
type: PluginType.standard,
configPath: ['path'],
requiredPlugins: required,
optionalPlugins: optional,
requiredBundles: [],
owner: {
name: 'Core',
githubTeam: 'kibana-core',
},
runtimePluginDependencies: [],
};
}
describe('PluginsService', () => {
beforeEach(() => {
runtimeResolverMock.setDependencyMap.mockReset();
runtimeResolverMock.resolveSetupRequests.mockReset();
runtimeResolverMock.resolveStartRequests.mockReset();
plugins = [
{ id: 'pluginA', plugin: createManifest('pluginA') },
{ id: 'pluginB', plugin: createManifest('pluginB', { required: ['pluginA'] }) },
@ -101,6 +102,7 @@ describe('PluginsService', () => {
mockSetupContext = {
...omit(mockSetupDeps, 'injectedMetadata'),
application: expect.any(Object),
plugins: expect.any(Object),
getStartServices: expect.any(Function),
};
// @ts-expect-error this file was not being type checked properly in the past, error is legit
@ -124,6 +126,7 @@ describe('PluginsService', () => {
mockStartContext = {
...omit(mockStartDeps, 'injectedMetadata'),
application: expect.any(Object),
plugins: expect.any(Object),
chrome: omit(mockStartDeps.chrome, 'getComponent'),
};
@ -248,6 +251,28 @@ describe('PluginsService', () => {
expect(pluginDDeps).not.toHaveProperty('missing');
});
it('setups the runtimeResolver', async () => {
const pluginsService = new PluginsService(mockCoreContext, plugins);
await pluginsService.setup(mockSetupDeps);
expect(runtimeResolverMock.setDependencyMap).toHaveBeenCalledTimes(1);
expect(runtimeResolverMock.setDependencyMap).toHaveBeenCalledWith(expect.any(Map));
expect(runtimeResolverMock.resolveSetupRequests).toHaveBeenCalledTimes(1);
expect(runtimeResolverMock.resolveSetupRequests).toHaveBeenCalledWith(expect.any(Map));
expect(
Object.fromEntries([...runtimeResolverMock.resolveSetupRequests.mock.calls[0][0].entries()])
).toEqual({
pluginA: {
setupValue: 1,
},
pluginB: {
pluginAPlusB: 2,
},
pluginC: undefined,
});
});
it('returns plugin setup contracts', async () => {
const pluginsService = new PluginsService(mockCoreContext, plugins);
const { contracts } = await pluginsService.setup(mockSetupDeps);
@ -299,6 +324,26 @@ describe('PluginsService', () => {
expect(pluginDDeps).not.toHaveProperty('missing');
});
it('setups the runtimeResolver', async () => {
const pluginsService = new PluginsService(mockCoreContext, plugins);
await pluginsService.setup(mockSetupDeps);
await pluginsService.start(mockStartDeps);
expect(runtimeResolverMock.resolveStartRequests).toHaveBeenCalledTimes(1);
expect(runtimeResolverMock.resolveStartRequests).toHaveBeenCalledWith(expect.any(Map));
expect(
Object.fromEntries([...runtimeResolverMock.resolveStartRequests.mock.calls[0][0].entries()])
).toEqual({
pluginA: {
startValue: 2,
},
pluginB: {
pluginAPlusB: 3,
},
pluginC: undefined,
});
});
it('returns plugin start contracts', async () => {
const pluginsService = new PluginsService(mockCoreContext, plugins);
await pluginsService.setup(mockSetupDeps);

View file

@ -16,6 +16,7 @@ import {
createPluginSetupContext,
createPluginStartContext,
} from './plugin_context';
import { RuntimePluginContractResolver } from './plugin_contract_resolver';
/** @internal */
export type PluginsServiceSetupDeps = InternalCoreSetup;
@ -23,12 +24,12 @@ export type PluginsServiceSetupDeps = InternalCoreSetup;
export type PluginsServiceStartDeps = InternalCoreStart;
/** @internal */
export interface PluginsServiceSetup {
export interface InternalPluginsServiceSetup {
contracts: ReadonlyMap<string, unknown>;
}
/** @internal */
export interface PluginsServiceStart {
export interface InternalPluginsServiceStart {
contracts: ReadonlyMap<string, unknown>;
}
@ -38,7 +39,10 @@ export interface PluginsServiceStart {
*
* @internal
*/
export class PluginsService implements CoreService<PluginsServiceSetup, PluginsServiceStart> {
export class PluginsService
implements CoreService<InternalPluginsServiceSetup, InternalPluginsServiceStart>
{
private readonly runtimeResolver = new RuntimePluginContractResolver();
/** Plugin wrappers in topological order. */
private readonly plugins = new Map<PluginName, PluginWrapper<unknown, unknown>>();
private readonly pluginDependencies = new Map<PluginName, PluginName[]>();
@ -79,7 +83,10 @@ export class PluginsService implements CoreService<PluginsServiceSetup, PluginsS
);
}
public async setup(deps: PluginsServiceSetupDeps): Promise<PluginsServiceSetup> {
public async setup(deps: PluginsServiceSetupDeps): Promise<InternalPluginsServiceSetup> {
const runtimeDependencies = buildPluginRuntimeDependencyMap(this.plugins);
this.runtimeResolver.setDependencyMap(runtimeDependencies);
// Setup each plugin with required and optional plugin contracts
const contracts = new Map<string, unknown>();
for (const [pluginName, plugin] of this.plugins.entries()) {
@ -97,7 +104,11 @@ export class PluginsService implements CoreService<PluginsServiceSetup, PluginsS
);
const contract = plugin.setup(
createPluginSetupContext(this.coreContext, deps, plugin),
createPluginSetupContext({
deps,
plugin,
runtimeResolver: this.runtimeResolver,
}),
pluginDepContracts
);
@ -105,11 +116,13 @@ export class PluginsService implements CoreService<PluginsServiceSetup, PluginsS
this.satupPlugins.push(pluginName);
}
this.runtimeResolver.resolveSetupRequests(contracts);
// Expose setup contracts
return { contracts };
}
public async start(deps: PluginsServiceStartDeps): Promise<PluginsServiceStart> {
public async start(deps: PluginsServiceStartDeps): Promise<InternalPluginsServiceStart> {
// Setup each plugin with required and optional plugin contracts
const contracts = new Map<string, unknown>();
for (const [pluginName, plugin] of this.plugins.entries()) {
@ -127,13 +140,19 @@ export class PluginsService implements CoreService<PluginsServiceSetup, PluginsS
);
const contract = plugin.start(
createPluginStartContext(this.coreContext, deps, plugin),
createPluginStartContext({
deps,
plugin,
runtimeResolver: this.runtimeResolver,
}),
pluginDepContracts
);
contracts.set(pluginName, contract);
}
this.runtimeResolver.resolveStartRequests(contracts);
// Expose start contracts
return { contracts };
}
@ -145,3 +164,18 @@ export class PluginsService implements CoreService<PluginsServiceSetup, PluginsS
}
}
}
const buildPluginRuntimeDependencyMap = (
pluginMap: Map<PluginName, PluginWrapper>
): Map<PluginName, Set<PluginName>> => {
const runtimeDependencies = new Map<PluginName, Set<PluginName>>();
for (const [pluginName, pluginWrapper] of pluginMap.entries()) {
const pluginRuntimeDeps = new Set([
...pluginWrapper.optionalPlugins,
...pluginWrapper.requiredPlugins,
...pluginWrapper.runtimePluginDependencies,
]);
runtimeDependencies.set(pluginName, pluginRuntimeDeps);
}
return runtimeDependencies;
};

View file

@ -7,3 +7,4 @@
*/
export { createPluginInitializerContextMock } from './mocks';
export { createRuntimePluginContractResolverMock } from './plugin_contract_resolver.mock';

View file

@ -0,0 +1,20 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import type { IRuntimePluginContractResolver } from '../plugin_contract_resolver';
export const createRuntimePluginContractResolverMock =
(): jest.Mocked<IRuntimePluginContractResolver> => {
return {
setDependencyMap: jest.fn(),
onSetup: jest.fn(),
onStart: jest.fn(),
resolveSetupRequests: jest.fn(),
resolveStartRequests: jest.fn(),
};
};

View file

@ -35,6 +35,8 @@
"@kbn/core-http-browser-mocks",
"@kbn/core-saved-objects-browser-mocks",
"@kbn/core-deprecations-browser-mocks",
"@kbn/utility-types",
"@kbn/core-plugins-contracts-browser",
],
"exclude": [
"target/**/*",

View file

@ -9,23 +9,27 @@
import type { PublicMethodsOf } from '@kbn/utility-types';
import { loggerMock } from '@kbn/logging-mocks';
import type { PluginInitializerContext } from '@kbn/core-plugins-browser';
import type { PluginsService, PluginsServiceSetup } from '@kbn/core-plugins-browser-internal';
import type {
PluginsService,
InternalPluginsServiceSetup,
InternalPluginsServiceStart,
} from '@kbn/core-plugins-browser-internal';
import type { BuildFlavor } from '@kbn/config/src/types';
const createSetupContractMock = () => {
const setupContract: jest.Mocked<PluginsServiceSetup> = {
const createInternalSetupContractMock = () => {
const setupContract: jest.Mocked<InternalPluginsServiceSetup> = {
contracts: new Map(),
};
// we have to suppress type errors until decide how to mock es6 class
return setupContract as PluginsServiceSetup;
return setupContract as InternalPluginsServiceSetup;
};
const createStartContractMock = () => {
const startContract: jest.Mocked<PluginsServiceSetup> = {
const createInternalStartContractMock = () => {
const startContract: jest.Mocked<InternalPluginsServiceStart> = {
contracts: new Map(),
};
// we have to suppress type errors until decide how to mock es6 class
return startContract as PluginsServiceSetup;
return startContract as InternalPluginsServiceSetup;
};
const createPluginInitializerContextMock = (
@ -68,14 +72,14 @@ const createMock = () => {
stop: jest.fn(),
};
mocked.setup.mockResolvedValue(createSetupContractMock());
mocked.start.mockResolvedValue(createStartContractMock());
mocked.setup.mockResolvedValue(createInternalSetupContractMock());
mocked.start.mockResolvedValue(createInternalStartContractMock());
return mocked;
};
export const pluginsServiceMock = {
create: createMock,
createSetupContract: createSetupContractMock,
createStartContract: createStartContractMock,
createInternalSetupContract: createInternalSetupContractMock,
createInternalStartContract: createInternalStartContractMock,
createPluginInitializerContext: createPluginInitializerContextMock,
};

View file

@ -0,0 +1,6 @@
# @kbn/core-plugins-contracts-browser
This package contains the public types for core's browser-side plugins service's contracts.
The dedicated package was required to avoid a cyclic dependencies between the `plugins` and `lifecycle` domains.

View file

@ -0,0 +1,18 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
export type {
PluginsServiceSetup,
PluginsServiceStart,
PluginContractMap,
PluginContractResolver,
PluginContractResolverResponse,
PluginContractResolverResponseItem,
FoundPluginContractResolverResponseItem,
NotFoundPluginContractResolverResponseItem,
} from './src/contracts';

View file

@ -0,0 +1,13 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
module.exports = {
preset: '@kbn/test/jest_node',
rootDir: '../../../..',
roots: ['<rootDir>/packages/core/plugins/core-plugins-contracts-browser'],
};

View file

@ -0,0 +1,5 @@
{
"type": "shared-common",
"id": "@kbn/core-plugins-contracts-browser",
"owner": "@elastic/kibana-core"
}

View file

@ -0,0 +1,6 @@
{
"name": "@kbn/core-plugins-contracts-browser",
"private": true,
"version": "1.0.0",
"license": "SSPL-1.0 OR Elastic License 2.0"
}

View file

@ -0,0 +1,184 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import type { PluginName } from '@kbn/core-base-common';
/**
* Setup contract of Core's `plugins` service.
*
* @public
*/
export interface PluginsServiceSetup {
/**
* Returns a promise that will resolve with the requested plugin setup contracts once all plugins have been set up.
*
* If called when plugins are already setup, the returned promise will resolve instantly.
*
* The API can only be used to resolve required dependencies, optional dependencies, or dependencies explicitly
* defined as `runtimePluginDependencies` in the calling plugin's manifest, otherwise the API will throw at call time.
*
* **Important:** This API should only be used when trying to address cyclic dependency issues that can't easily
* be solved otherwise. This is meant to be a temporary workaround only supposed to be used until a better solution
* is made available.
* Therefore, by using this API, you implicitly agree to:
* - consider it as technical debt and open an issue to track the tech debt resolution
* - accept that this is only a temporary solution, and will comply to switching to the long term solution when asked by the Core team
*
* @remark The execution order is not guaranteed to be consistent. Only guarantee is that the returned promise will be
* resolved once all plugins are setup, and before Core's `start` is initiated.
*
* @example
* ```ts
* setup(core) {
* core.plugins.onSetup<{pluginA: SetupContractA, pluginB: SetupContractA}>('pluginA', 'pluginB')
* .then(({ pluginA, pluginB }) => {
* if(pluginA.found && pluginB.found) {
* // do something with pluginA.contract and pluginB.contract
* }
* });
* }
*
* @experimental
* ```
*/
onSetup: PluginContractResolver;
/**
* Returns a promise that will resolve with the requested plugin start contracts once all plugins have been started.
*
* If called when plugins are already started, the returned promise will resolve instantly.
*
* The API can only be used to resolve required dependencies, optional dependencies, or dependencies explicitly
* defined as `runtimePluginDependencies` in the calling plugin's manifest, otherwise the API will throw at call time.
*
* **Important:** This API should only be used when trying to address cyclic dependency issues that can't easily
* be solved otherwise. This is meant to be a temporary workaround only supposed to be used until a better solution
* is made available.
* Therefore, by using this API, you implicitly agree to:
* - consider it as technical debt and open an issue to track the tech debt resolution
* - accept that this is only a temporary solution, and will comply to switching to the long term solution when asked by the Core team
*
* @remark The execution order is not guaranteed to be consistent. Only guarantee is that the returned promise will be
* resolved once all plugins are started, and before Core's `start` lifecycle is resumed.
*
* @example
* ```ts
* setup(core) {
* core.plugins.onStart<{pluginA: StartContractA, pluginB: StartContractA}>('pluginA', 'pluginB')
* .then(({ pluginA, pluginB }) => {
* if(pluginA.found && pluginB.found) {
* // do something with pluginA.contract and pluginB.contract
* }
* });
* }
* ```
*
* @experimental
*/
onStart: PluginContractResolver;
}
/**
* Start contract of Core's `plugins` service.
*
* @public
*/
export interface PluginsServiceStart {
/**
* Returns a promise that will resolve with the requested plugin start contracts once all plugins have been started.
*
* If called when plugins are already started, the returned promise will resolve instantly.
*
* The API can only be used to resolve required dependencies, optional dependencies, or dependencies explicitly
* defined as `runtimePluginDependencies` in the calling plugin's manifest, otherwise the API will throw at call time.
*
* **Important:** This API should only be used when trying to address cyclic dependency issues that can't easily
* be solved otherwise. This is meant to be a temporary workaround only supposed to be used until a better solution
* is made available.
* Therefore, by using this API, you implicitly agree to:
* - consider it as technical debt and open an issue to track the tech debt resolution
* - accept that this is only a temporary solution, and will comply to switching to the long term solution when asked by the Core team
*
* @remark The execution order is not guaranteed to be consistent. Only guarantee is that the returned promise will be
* resolved once all plugins are started, and before Core's `start` lifecycle is resumed.
*
* @example
* ```ts
* start(core) {
* core.plugins.onStart<{pluginA: StartContractA, pluginB: StartContractA}>('pluginA', 'pluginB')
* .then(({ pluginA, pluginB }) => {
* if(pluginA.found && pluginB.found) {
* // do something with pluginA.contract and pluginB.contract
* }
* });
* }
* ```
*
* @experimental
*/
onStart: PluginContractResolver;
}
/**
* Contract resolver response for found plugins.
*
* @see {@link PluginContractResolverResponseItem}
* @public
*/
export interface FoundPluginContractResolverResponseItem<ContractType = unknown> {
found: true;
contract: ContractType;
}
/**
* Contract resolver response for not found plugins.
*
* @see {@link PluginContractResolverResponseItem}
* @public
*/
export interface NotFoundPluginContractResolverResponseItem {
found: false;
}
/**
* Contract resolver response.
*
* @see {@link PluginContractResolver}
* @public
*/
export type PluginContractResolverResponseItem<ContractType = unknown> =
| NotFoundPluginContractResolverResponseItem
| FoundPluginContractResolverResponseItem<ContractType>;
/**
* A record of plugin contracts.
*
* @see {@link PluginContractResolver}
* @public
*/
export type PluginContractMap = Record<PluginName, unknown>;
/**
* Response from a plugin contract resolver request.
*
* @see {@link PluginContractResolver}
* @public
*/
export type PluginContractResolverResponse<ContractMap extends PluginContractMap> = {
[Key in keyof ContractMap]: PluginContractResolverResponseItem<ContractMap[Key]>;
};
/**
* A plugin contract resolver, allowing to retrieve plugin contracts at runtime.
*
* Please refer to {@link PluginsServiceSetup} and {@link PluginsServiceStart} for more documentation and examples.
*
* @public
*/
export type PluginContractResolver = <T extends PluginContractMap>(
...pluginNames: Array<keyof T>
) => Promise<PluginContractResolverResponse<T>>;

View file

@ -0,0 +1,19 @@
{
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"outDir": "target/types",
"types": [
"jest",
"node"
]
},
"include": [
"**/*.ts",
],
"exclude": [
"target/**/*"
],
"kbn_references": [
"@kbn/core-base-common",
]
}

View file

@ -0,0 +1,5 @@
# @kbn/core-plugins-contracts-server
This package contains the public types for core's server-side plugins service's contracts.
The dedicated package was required to avoid a cyclic dependencies between the `plugins` and `lifecycle` domains.

View file

@ -0,0 +1,18 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
export type {
PluginsServiceSetup,
PluginsServiceStart,
PluginContractMap,
PluginContractResolver,
PluginContractResolverResponse,
PluginContractResolverResponseItem,
FoundPluginContractResolverResponseItem,
NotFoundPluginContractResolverResponseItem,
} from './src/contracts';

View file

@ -0,0 +1,13 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
module.exports = {
preset: '@kbn/test/jest_node',
rootDir: '../../../..',
roots: ['<rootDir>/packages/core/plugins/core-plugins-contracts-server'],
};

View file

@ -0,0 +1,5 @@
{
"type": "shared-common",
"id": "@kbn/core-plugins-contracts-server",
"owner": "@elastic/kibana-core"
}

View file

@ -0,0 +1,6 @@
{
"name": "@kbn/core-plugins-contracts-server",
"private": true,
"version": "1.0.0",
"license": "SSPL-1.0 OR Elastic License 2.0"
}

View file

@ -0,0 +1,184 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import type { PluginName } from '@kbn/core-base-common';
/**
* Setup contract of Core's `plugins` service.
*
* @public
*/
export interface PluginsServiceSetup {
/**
* Returns a promise that will resolve with the requested plugin setup contracts once all plugins have been set up.
*
* If called when plugins are already setup, the returned promise will resolve instantly.
*
* The API can only be used to resolve required dependencies, optional dependencies, or dependencies explicitly
* defined as `runtimePluginDependencies` in the calling plugin's manifest, otherwise the API will throw at call time.
*
* **Important:** This API should only be used when trying to address cyclic dependency issues that can't easily
* be solved otherwise. This is meant to be a temporary workaround only supposed to be used until a better solution
* is made available.
* Therefore, by using this API, you implicitly agree to:
* - consider it as technical debt and open an issue to track the tech debt resolution
* - accept that this is only a temporary solution, and will comply to switching to the long term solution when asked by the Core team
*
* @remark The execution order is not guaranteed to be consistent. Only guarantee is that the returned promise will be
* resolved once all plugins are started, and before Core's `start` lifecycle is resumed.
*
* @example
* ```ts
* setup(core) {
* core.plugins.onSetup<{pluginA: SetupContractA, pluginB: SetupContractA}>('pluginA', 'pluginB')
* .then(({ pluginA, pluginB }) => {
* if(pluginA.found && pluginB.found) {
* // do something with pluginA.contract and pluginB.contract
* }
* });
* }
*
* @experimental
* ```
*/
onSetup: PluginContractResolver;
/**
* Returns a promise that will resolve with the requested plugin start contracts once all plugins have been started.
*
* If called when plugins are already started, the returned promise will resolve instantly.
*
* The API can only be used to resolve required dependencies, optional dependencies, or dependencies explicitly
* defined as `runtimePluginDependencies` in the calling plugin's manifest, otherwise the API will throw at call time.
*
* **Important:** This API should only be used when trying to address cyclic dependency issues that can't easily
* be solved otherwise. This is meant to be a temporary workaround only supposed to be used until a better solution
* is made available.
* Therefore, by using this API, you implicitly agree to:
* - consider it as technical debt and open an issue to track the tech debt resolution
* - accept that this is only a temporary solution, and will comply to switching to the long term solution when asked by the Core team
*
* @remark The execution order is not guaranteed to be consistent. Only guarantee is that the returned promise will be
* resolved once all plugins are started, and before Core's `start` lifecycle is resumed.
*
* @example
* ```ts
* setup(core) {
* core.plugins.onStart<{pluginA: StartContractA, pluginB: StartContractA}>('pluginA', 'pluginB')
* .then(({ pluginA, pluginB }) => {
* if(pluginA.found && pluginB.found) {
* // do something with pluginA.contract and pluginB.contract
* }
* });
* }
*
* @experimental
* ```
*/
onStart: PluginContractResolver;
}
/**
* Start contract of Core's `plugins` service.
*
* @public
*/
export interface PluginsServiceStart {
/**
* Returns a promise that will resolve with the requested plugin start contracts once all plugins have been started.
*
* If called when plugins are already started, the returned promise will resolve instantly.
*
* The API can only be used to resolve required dependencies, optional dependencies, or dependencies explicitly
* defined as `runtimePluginDependencies` in the calling plugin's manifest, otherwise the API will throw at call time.
*
* **Important:** This API should only be used when trying to address cyclic dependency issues that can't easily
* be solved otherwise. This is meant to be a temporary workaround only supposed to be used until a better solution
* is made available.
* Therefore, by using this API, you implicitly agree to:
* - consider it as technical debt and open an issue to track the tech debt resolution
* - accept that this is only a temporary solution, and will comply to switching to the long term solution when asked by the Core team
*
* @remark The execution order is not guaranteed to be consistent. Only guarantee is that the returned promise will be
* resolved once all plugins are started, and before Core's `start` lifecycle is resumed.
*
* @example
* ```ts
* start(core) {
* core.plugins.onStart<{pluginA: StartContractA, pluginB: StartContractA}>('pluginA', 'pluginB')
* .then(({ pluginA, pluginB }) => {
* if(pluginA.found && pluginB.found) {
* // do something with pluginA.contract and pluginB.contract
* }
* });
* }
* ```
*
* @experimental
*/
onStart: PluginContractResolver;
}
/**
* Contract resolver response for found plugins.
*
* @see {@link PluginContractResolverResponseItem}
* @public
*/
export interface FoundPluginContractResolverResponseItem<ContractType = unknown> {
found: true;
contract: ContractType;
}
/**
* Contract resolver response for not found plugins.
*
* @see {@link PluginContractResolverResponseItem}
* @public
*/
export interface NotFoundPluginContractResolverResponseItem {
found: false;
}
/**
* Contract resolver response.
*
* @see {@link PluginContractResolver}
* @public
*/
export type PluginContractResolverResponseItem<ContractType = unknown> =
| NotFoundPluginContractResolverResponseItem
| FoundPluginContractResolverResponseItem<ContractType>;
/**
* A record of plugin contracts.
*
* @see {@link PluginContractResolver}
* @public
*/
export type PluginContractMap = Record<PluginName, unknown>;
/**
* Response from a plugin contract resolver request.
*
* @see {@link PluginContractResolver}
* @public
*/
export type PluginContractResolverResponse<ContractMap extends PluginContractMap> = {
[Key in keyof ContractMap]: PluginContractResolverResponseItem<ContractMap[Key]>;
};
/**
* A plugin contract resolver, allowing to retrieve plugin contracts at runtime.
*
* Please refer to {@link PluginsServiceSetup} and {@link PluginsServiceStart} for more documentation and examples.
*
* @public
*/
export type PluginContractResolver = <T extends PluginContractMap>(
...pluginNames: Array<keyof T>
) => Promise<PluginContractResolverResponse<T>>;

View file

@ -0,0 +1,19 @@
{
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"outDir": "target/types",
"types": [
"jest",
"node"
]
},
"include": [
"**/*.ts",
],
"exclude": [
"target/**/*"
],
"kbn_references": [
"@kbn/core-base-common",
]
}

View file

@ -8,8 +8,8 @@
export { PluginsService, PluginWrapper, config, isNewPlatformPlugin } from './src';
export type {
PluginsServiceSetup,
PluginsServiceStart,
InternalPluginsServiceSetup,
InternalPluginsServiceStart,
DiscoveredPlugins,
PluginDependencies,
} from './src';

View file

@ -32,6 +32,7 @@ const basic: PluginPackageManifest = {
optionalPlugins: ['someOtherPlugin'],
requiredBundles: ['someRequiresBundlePlugin'],
requiredPlugins: ['someRequiredPlugin'],
runtimePluginDependencies: ['someRuntimeDependencyPlugin'],
},
serviceFolders: ['foo', 'bar'],
};
@ -59,6 +60,9 @@ describe('pluginManifestFromPluginPackage()', () => {
"requiredPlugins": Array [
"someRequiredPlugin",
],
"runtimePluginDependencies": Array [
"someRuntimeDependencyPlugin",
],
"server": true,
"serviceFolders": Array [
"foo",

View file

@ -25,6 +25,7 @@ export function pluginManifestFromPluginPackage(
optionalPlugins: manifest.plugin.optionalPlugins ?? [],
requiredBundles: manifest.plugin.requiredBundles ?? [],
requiredPlugins: manifest.plugin.requiredPlugins ?? [],
runtimePluginDependencies: manifest.plugin.runtimePluginDependencies ?? [],
owner: {
name: manifest.owner.join(' & '),
},

View file

@ -388,6 +388,7 @@ test('set defaults for all missing optional fields', async () => {
optionalPlugins: [],
requiredPlugins: [],
requiredBundles: [],
runtimePluginDependencies: [],
server: true,
ui: false,
owner: { name: 'foo' },
@ -407,6 +408,7 @@ test('return all set optional fields as they are in manifest', async () => {
type: 'preboot',
requiredPlugins: ['some-required-plugin', 'some-required-plugin-2'],
optionalPlugins: ['some-optional-plugin'],
runtimePluginDependencies: ['runtime-plugin-dependency'],
ui: true,
owner: { name: 'foo' },
enabledOnAnonymousPages: true,
@ -424,6 +426,7 @@ test('return all set optional fields as they are in manifest', async () => {
optionalPlugins: ['some-optional-plugin'],
requiredBundles: [],
requiredPlugins: ['some-required-plugin', 'some-required-plugin-2'],
runtimePluginDependencies: ['runtime-plugin-dependency'],
server: false,
ui: true,
owner: { name: 'foo' },
@ -458,6 +461,7 @@ test('return manifest when plugin expected Kibana version matches actual version
optionalPlugins: [],
requiredPlugins: ['some-required-plugin'],
requiredBundles: [],
runtimePluginDependencies: [],
server: true,
ui: false,
owner: { name: 'foo' },
@ -491,6 +495,7 @@ test('return manifest when plugin expected Kibana version is `kibana`', async ()
optionalPlugins: [],
requiredPlugins: ['some-required-plugin'],
requiredBundles: [],
runtimePluginDependencies: [],
server: true,
ui: true,
owner: { name: 'foo' },

View file

@ -45,6 +45,7 @@ const KNOWN_MANIFEST_FIELDS = (() => {
configPath: true,
requiredPlugins: true,
optionalPlugins: true,
runtimePluginDependencies: true,
ui: true,
server: true,
extraPublicDirs: true,
@ -209,6 +210,9 @@ export async function parseManifest(
requiredPlugins: Array.isArray(manifest.requiredPlugins) ? manifest.requiredPlugins : [],
optionalPlugins: Array.isArray(manifest.optionalPlugins) ? manifest.optionalPlugins : [],
requiredBundles: Array.isArray(manifest.requiredBundles) ? manifest.requiredBundles : [],
runtimePluginDependencies: Array.isArray(manifest.runtimePluginDependencies)
? manifest.runtimePluginDependencies
: [],
ui: includesUiPlugin,
server: includesServerPlugin,
extraPublicDirs: manifest.extraPublicDirs,

View file

@ -8,8 +8,8 @@
export { PluginsService } from './plugins_service';
export type {
PluginsServiceSetup,
PluginsServiceStart,
InternalPluginsServiceSetup,
InternalPluginsServiceStart,
DiscoveredPlugins,
} from './plugins_service';
export { config } from './plugins_config';

View file

@ -18,9 +18,10 @@ import { loggingSystemMock } from '@kbn/core-logging-server-mocks';
import type { NodeInfo } from '@kbn/core-node-server';
import { nodeServiceMock } from '@kbn/core-node-server-mocks';
import type { PluginManifest } from '@kbn/core-plugins-server';
import { PluginWrapper } from './plugin';
import { PluginType } from '@kbn/core-base-common';
import { coreInternalLifecycleMock } from '@kbn/core-lifecycle-server-mocks';
import { createRuntimePluginContractResolverMock } from './test_helpers';
import { PluginWrapper } from './plugin';
import {
createPluginInitializerContext,
@ -57,6 +58,7 @@ function createPluginManifest(manifestProps: Partial<PluginManifest> = {}): Plug
requiredPlugins: ['some-required-dep'],
optionalPlugins: ['some-optional-dep'],
requiredBundles: [],
runtimePluginDependencies: ['some-runtime-dep'],
server: true,
ui: true,
owner: { name: 'Core' },
@ -72,6 +74,7 @@ let env: Env;
let coreContext: CoreContext;
let instanceInfo: InstanceInfo;
let nodeInfo: NodeInfo;
let runtimeResolver: ReturnType<typeof createRuntimePluginContractResolverMock>;
const setupDeps = coreInternalLifecycleMock.createInternalSetup();
@ -82,7 +85,7 @@ beforeEach(() => {
uuid: 'instance-uuid',
};
nodeInfo = nodeServiceMock.createInternalPrebootContract();
runtimeResolver = createRuntimePluginContractResolverMock();
coreContext = { coreId, env, logger, configService: configService as any };
});
@ -112,6 +115,7 @@ test('`constructor` correctly initializes plugin instance', () => {
expect(plugin.source).toBe('external'); // see below for test cases for non-external sources (OSS and X-Pack)
expect(plugin.requiredPlugins).toEqual(['some-required-dep']);
expect(plugin.optionalPlugins).toEqual(['some-optional-dep']);
expect(plugin.runtimePluginDependencies).toEqual(['some-runtime-dep']);
});
describe('`constructor` correctly sets non-external source', () => {
@ -170,7 +174,7 @@ test('`setup` fails if `plugin` initializer is not exported', () => {
});
expect(() =>
plugin.setup(createPluginSetupContext(coreContext, setupDeps, plugin), {})
plugin.setup(createPluginSetupContext({ deps: setupDeps, plugin, runtimeResolver }), {})
).toThrowErrorMatchingInlineSnapshot(
`"Plugin \\"some-plugin-id\\" does not export \\"plugin\\" definition (plugin-without-initializer-path)."`
);
@ -193,7 +197,7 @@ test('`setup` fails if plugin initializer is not a function', () => {
});
expect(() =>
plugin.setup(createPluginSetupContext(coreContext, setupDeps, plugin), {})
plugin.setup(createPluginSetupContext({ deps: setupDeps, plugin, runtimeResolver }), {})
).toThrowErrorMatchingInlineSnapshot(
`"Definition of plugin \\"some-plugin-id\\" should be a function (plugin-with-wrong-initializer-path)."`
);
@ -218,7 +222,7 @@ test('`setup` fails if initializer does not return object', () => {
mockPluginInitializer.mockReturnValue(null);
expect(() =>
plugin.setup(createPluginSetupContext(coreContext, setupDeps, plugin), {})
plugin.setup(createPluginSetupContext({ deps: setupDeps, plugin, runtimeResolver }), {})
).toThrowErrorMatchingInlineSnapshot(
`"Initializer for plugin \\"some-plugin-id\\" is expected to return plugin instance, but returned \\"null\\"."`
);
@ -244,7 +248,7 @@ test('`setup` fails if object returned from initializer does not define `setup`
mockPluginInitializer.mockReturnValue(mockPluginInstance);
expect(() =>
plugin.setup(createPluginSetupContext(coreContext, setupDeps, plugin), {})
plugin.setup(createPluginSetupContext({ deps: setupDeps, plugin, runtimeResolver }), {})
).toThrowErrorMatchingInlineSnapshot(
`"Instance of plugin \\"some-plugin-id\\" does not define \\"setup\\" function."`
);
@ -270,7 +274,7 @@ test('`setup` initializes plugin and calls appropriate lifecycle hook', async ()
const mockPluginInstance = { setup: jest.fn().mockResolvedValue({ contract: 'yes' }) };
mockPluginInitializer.mockReturnValue(mockPluginInstance);
const setupContext = createPluginSetupContext(coreContext, setupDeps, plugin);
const setupContext = createPluginSetupContext({ deps: setupDeps, plugin, runtimeResolver });
const setupDependencies = { 'some-required-dep': { contract: 'no' } };
await expect(plugin.setup(setupContext, setupDependencies)).resolves.toEqual({ contract: 'yes' });
@ -450,7 +454,7 @@ test('`stop` does nothing if plugin does not define `stop` function', async () =
});
mockPluginInitializer.mockReturnValue({ setup: jest.fn() });
await plugin.setup(createPluginSetupContext(coreContext, setupDeps, plugin), {});
await plugin.setup(createPluginSetupContext({ deps: setupDeps, plugin, runtimeResolver }), {});
await expect(plugin.stop()).resolves.toBeUndefined();
});
@ -473,7 +477,7 @@ test('`stop` calls `stop` defined by the plugin instance', async () => {
const mockPluginInstance = { setup: jest.fn(), stop: jest.fn() };
mockPluginInitializer.mockReturnValue(mockPluginInstance);
await plugin.setup(createPluginSetupContext(coreContext, setupDeps, plugin), {});
await plugin.setup(createPluginSetupContext({ deps: setupDeps, plugin, runtimeResolver }), {});
await expect(plugin.stop()).resolves.toBeUndefined();
expect(mockPluginInstance.stop).toHaveBeenCalledTimes(1);

View file

@ -47,6 +47,7 @@ export class PluginWrapper<
public readonly configPath: PluginManifest['configPath'];
public readonly requiredPlugins: PluginManifest['requiredPlugins'];
public readonly optionalPlugins: PluginManifest['optionalPlugins'];
public readonly runtimePluginDependencies: PluginManifest['runtimePluginDependencies'];
public readonly requiredBundles: PluginManifest['requiredBundles'];
public readonly includesServerPlugin: PluginManifest['server'];
public readonly includesUiPlugin: PluginManifest['ui'];
@ -81,6 +82,7 @@ export class PluginWrapper<
this.requiredPlugins = params.manifest.requiredPlugins;
this.optionalPlugins = params.manifest.optionalPlugins;
this.requiredBundles = params.manifest.requiredBundles;
this.runtimePluginDependencies = params.manifest.runtimePluginDependencies;
this.includesServerPlugin = params.manifest.server;
this.includesUiPlugin = params.manifest.ui;
}

View file

@ -40,6 +40,7 @@ function createPluginManifest(manifestProps: Partial<PluginManifest> = {}): Plug
requiredPlugins: ['some-required-dep'],
requiredBundles: [],
optionalPlugins: ['some-optional-dep'],
runtimePluginDependencies: [],
server: true,
ui: true,
owner: {
@ -237,7 +238,7 @@ describe('createPluginPrebootSetupContext', () => {
});
const corePreboot = coreInternalLifecycleMock.createInternalPreboot();
const prebootSetupContext = createPluginPrebootSetupContext(coreContext, corePreboot, plugin);
const prebootSetupContext = createPluginPrebootSetupContext({ deps: corePreboot, plugin });
const holdSetupPromise = Promise.resolve(undefined);
prebootSetupContext.preboot.holdSetupUntilResolved('some-reason', holdSetupPromise);

View file

@ -21,6 +21,7 @@ import {
PluginsServiceStartDeps,
} from './plugins_service';
import { getGlobalConfig, getGlobalConfig$ } from './legacy_config';
import type { IRuntimePluginContractResolver } from './plugin_contract_resolver';
/** @internal */
export interface InstanceInfo {
@ -128,11 +129,13 @@ export function createPluginInitializerContext({
* @param plugin The plugin we're building these values for.
* @internal
*/
export function createPluginPrebootSetupContext(
coreContext: CoreContext,
deps: PluginsServicePrebootSetupDeps,
plugin: PluginWrapper
): CorePreboot {
export function createPluginPrebootSetupContext({
deps,
plugin,
}: {
deps: PluginsServicePrebootSetupDeps;
plugin: PluginWrapper;
}): CorePreboot {
return {
analytics: {
optIn: deps.analytics.optIn,
@ -174,11 +177,15 @@ export function createPluginPrebootSetupContext(
* @param deps Dependencies that Plugins services gets during setup.
* @internal
*/
export function createPluginSetupContext<TPlugin, TPluginDependencies>(
coreContext: CoreContext,
deps: PluginsServiceSetupDeps,
plugin: PluginWrapper<TPlugin, TPluginDependencies>
): CoreSetup {
export function createPluginSetupContext<TPlugin, TPluginDependencies>({
deps,
plugin,
runtimeResolver,
}: {
deps: PluginsServiceSetupDeps;
plugin: PluginWrapper<TPlugin, TPluginDependencies>;
runtimeResolver: IRuntimePluginContractResolver;
}): CoreSetup {
const router = deps.http.createRouter('', plugin.opaqueId);
return {
@ -268,6 +275,10 @@ export function createPluginSetupContext<TPlugin, TPluginDependencies>(
coreUsageData: {
registerUsageCounter: deps.coreUsageData.registerUsageCounter,
},
plugins: {
onSetup: (...dependencyNames) => runtimeResolver.onSetup(plugin.name, dependencyNames),
onStart: (...dependencyNames) => runtimeResolver.onStart(plugin.name, dependencyNames),
},
};
}
@ -282,12 +293,16 @@ export function createPluginSetupContext<TPlugin, TPluginDependencies>(
* @param plugin The plugin we're building these values for.
* @param deps Dependencies that Plugins services gets during start.
* @internal
*/
export function createPluginStartContext<TPlugin, TPluginDependencies>(
coreContext: CoreContext,
deps: PluginsServiceStartDeps,
plugin: PluginWrapper<TPlugin, TPluginDependencies>
): CoreStart {
*/ //
export function createPluginStartContext<TPlugin, TPluginDependencies>({
plugin,
deps,
runtimeResolver,
}: {
deps: PluginsServiceStartDeps;
plugin: PluginWrapper<TPlugin, TPluginDependencies>;
runtimeResolver: IRuntimePluginContractResolver;
}): CoreStart {
return {
analytics: {
optIn: deps.analytics.optIn,
@ -332,5 +347,8 @@ export function createPluginStartContext<TPlugin, TPluginDependencies>(
globalAsScopedToClient: deps.uiSettings.globalAsScopedToClient,
},
coreUsageData: deps.coreUsageData,
plugins: {
onStart: (...dependencyNames) => runtimeResolver.onStart(plugin.name, dependencyNames),
},
};
}

View file

@ -0,0 +1,342 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { RuntimePluginContractResolver } from './plugin_contract_resolver';
const nextTick = () => new Promise((resolve) => setTimeout(resolve, 1));
const fewTicks = () =>
nextTick()
.then(() => nextTick())
.then(() => nextTick());
const toMap = (record: Record<string, unknown>): Map<string, unknown> => {
return new Map(Object.entries(record));
};
const pluginAContract = Symbol();
describe('RuntimePluginContractResolver', () => {
const SOURCE_PLUGIN = 'sourcePlugin';
let resolver: RuntimePluginContractResolver;
beforeEach(() => {
resolver = new RuntimePluginContractResolver();
const dependencyMap = new Map<string, Set<string>>();
dependencyMap.set(SOURCE_PLUGIN, new Set(['pluginA', 'pluginB', 'pluginC']));
resolver.setDependencyMap(dependencyMap);
});
describe('setup contracts', () => {
it('throws if onSetup is called before setDependencyMap', () => {
resolver = new RuntimePluginContractResolver();
expect(() => resolver.onSetup(SOURCE_PLUGIN, ['pluginA'])).toThrowErrorMatchingInlineSnapshot(
`"onSetup cannot be called before setDependencyMap"`
);
});
it('throws if resolveSetupRequests is called multiple times', async () => {
resolver.resolveSetupRequests(
toMap({
pluginA: pluginAContract,
})
);
expect(() =>
resolver.resolveSetupRequests(
toMap({
pluginA: pluginAContract,
})
)
).toThrowErrorMatchingInlineSnapshot(`"resolveSetupRequests can only be called once"`);
});
it('resolves a single request', async () => {
const handler = jest.fn();
resolver.onSetup(SOURCE_PLUGIN, ['pluginA']).then((contracts) => handler(contracts));
await fewTicks();
expect(handler).not.toHaveBeenCalled();
resolver.resolveSetupRequests(
toMap({
pluginA: pluginAContract,
})
);
await fewTicks();
expect(handler).toHaveBeenCalledTimes(1);
expect(handler).toHaveBeenCalledWith({
pluginA: {
found: true,
contract: pluginAContract,
},
});
});
it('resolves multiple requests', async () => {
const handler1 = jest.fn();
const handler2 = jest.fn();
const handler3 = jest.fn();
resolver.onSetup(SOURCE_PLUGIN, ['pluginA']).then((contracts) => handler1(contracts));
resolver.onSetup(SOURCE_PLUGIN, ['pluginB']).then((contracts) => handler2(contracts));
resolver
.onSetup(SOURCE_PLUGIN, ['pluginA', 'pluginB'])
.then((contracts) => handler3(contracts));
await fewTicks();
expect(handler1).not.toHaveBeenCalled();
expect(handler2).not.toHaveBeenCalled();
expect(handler3).not.toHaveBeenCalled();
resolver.resolveSetupRequests(
toMap({
pluginA: pluginAContract,
})
);
await fewTicks();
expect(handler1).toHaveBeenCalledTimes(1);
expect(handler1).toHaveBeenCalledWith({
pluginA: {
found: true,
contract: pluginAContract,
},
});
expect(handler2).toHaveBeenCalledTimes(1);
expect(handler2).toHaveBeenCalledWith({
pluginB: {
found: false,
},
});
expect(handler3).toHaveBeenCalledTimes(1);
expect(handler3).toHaveBeenCalledWith({
pluginA: {
found: true,
contract: pluginAContract,
},
pluginB: {
found: false,
},
});
});
it('resolves requests instantly when called after resolveSetupRequests', async () => {
resolver.resolveSetupRequests(
toMap({
pluginA: pluginAContract,
})
);
const handler1 = jest.fn();
const handler2 = jest.fn();
resolver.onSetup(SOURCE_PLUGIN, ['pluginA']).then((contracts) => handler1(contracts));
resolver.onSetup(SOURCE_PLUGIN, ['pluginB']).then((contracts) => handler2(contracts));
await fewTicks();
expect(handler1).toHaveBeenCalledTimes(1);
expect(handler1).toHaveBeenCalledWith({
pluginA: {
found: true,
contract: pluginAContract,
},
});
expect(handler2).toHaveBeenCalledTimes(1);
expect(handler2).toHaveBeenCalledWith({
pluginB: {
found: false,
},
});
});
it('throws when requesting a contract not defined in the dependency map', async () => {
expect(() =>
resolver.onSetup(SOURCE_PLUGIN, ['undeclaredPlugin'])
).toThrowErrorMatchingInlineSnapshot(
`"Dynamic contract resolving requires the dependencies to be declared in the plugin manifest.Undeclared dependencies: undeclaredPlugin"`
);
});
it('throws when requesting a mixed defined/undefined dependencies', async () => {
expect(() =>
resolver.onSetup(SOURCE_PLUGIN, [
'pluginA',
'undeclaredPlugin1',
'pluginB',
'undeclaredPlugin2',
])
).toThrowErrorMatchingInlineSnapshot(
`"Dynamic contract resolving requires the dependencies to be declared in the plugin manifest.Undeclared dependencies: undeclaredPlugin1, undeclaredPlugin2"`
);
});
});
describe('start contracts', () => {
it('throws if onStart is called before setDependencyMap', () => {
resolver = new RuntimePluginContractResolver();
expect(() => resolver.onStart(SOURCE_PLUGIN, ['pluginA'])).toThrowErrorMatchingInlineSnapshot(
`"onStart cannot be called before setDependencyMap"`
);
});
it('throws if resolveStartRequests is called multiple times', async () => {
resolver.resolveStartRequests(
toMap({
pluginA: pluginAContract,
})
);
expect(() =>
resolver.resolveStartRequests(
toMap({
pluginA: pluginAContract,
})
)
).toThrowErrorMatchingInlineSnapshot(`"resolveStartRequests can only be called once"`);
});
it('resolves a single request', async () => {
const handler = jest.fn();
resolver.onStart(SOURCE_PLUGIN, ['pluginA']).then((contracts) => handler(contracts));
await fewTicks();
expect(handler).not.toHaveBeenCalled();
resolver.resolveStartRequests(
toMap({
pluginA: pluginAContract,
})
);
await fewTicks();
expect(handler).toHaveBeenCalledTimes(1);
expect(handler).toHaveBeenCalledWith({
pluginA: {
found: true,
contract: pluginAContract,
},
});
});
it('resolves multiple requests', async () => {
const handler1 = jest.fn();
const handler2 = jest.fn();
const handler3 = jest.fn();
resolver.onStart(SOURCE_PLUGIN, ['pluginA']).then((contracts) => handler1(contracts));
resolver.onStart(SOURCE_PLUGIN, ['pluginB']).then((contracts) => handler2(contracts));
resolver
.onStart(SOURCE_PLUGIN, ['pluginA', 'pluginB'])
.then((contracts) => handler3(contracts));
await fewTicks();
expect(handler1).not.toHaveBeenCalled();
expect(handler2).not.toHaveBeenCalled();
expect(handler3).not.toHaveBeenCalled();
resolver.resolveStartRequests(
toMap({
pluginA: pluginAContract,
})
);
await fewTicks();
expect(handler1).toHaveBeenCalledTimes(1);
expect(handler1).toHaveBeenCalledWith({
pluginA: {
found: true,
contract: pluginAContract,
},
});
expect(handler2).toHaveBeenCalledTimes(1);
expect(handler2).toHaveBeenCalledWith({
pluginB: {
found: false,
},
});
expect(handler3).toHaveBeenCalledTimes(1);
expect(handler3).toHaveBeenCalledWith({
pluginA: {
found: true,
contract: pluginAContract,
},
pluginB: {
found: false,
},
});
});
it('resolves requests instantly when called after resolveSetupRequests', async () => {
resolver.resolveStartRequests(
toMap({
pluginA: pluginAContract,
})
);
const handler1 = jest.fn();
const handler2 = jest.fn();
resolver.onStart(SOURCE_PLUGIN, ['pluginA']).then((contracts) => handler1(contracts));
resolver.onStart(SOURCE_PLUGIN, ['pluginB']).then((contracts) => handler2(contracts));
await fewTicks();
expect(handler1).toHaveBeenCalledTimes(1);
expect(handler1).toHaveBeenCalledWith({
pluginA: {
found: true,
contract: pluginAContract,
},
});
expect(handler2).toHaveBeenCalledTimes(1);
expect(handler2).toHaveBeenCalledWith({
pluginB: {
found: false,
},
});
});
it('throws when requesting a contract not defined in the dependency map', async () => {
expect(() =>
resolver.onStart(SOURCE_PLUGIN, ['undeclaredPlugin'])
).toThrowErrorMatchingInlineSnapshot(
`"Dynamic contract resolving requires the dependencies to be declared in the plugin manifest.Undeclared dependencies: undeclaredPlugin"`
);
});
it('throws when requesting a mixed defined/undefined dependencies', async () => {
expect(() =>
resolver.onStart(SOURCE_PLUGIN, [
'pluginA',
'undeclaredPlugin1',
'pluginB',
'undeclaredPlugin2',
])
).toThrowErrorMatchingInlineSnapshot(
`"Dynamic contract resolving requires the dependencies to be declared in the plugin manifest.Undeclared dependencies: undeclaredPlugin1, undeclaredPlugin2"`
);
});
});
});

View file

@ -0,0 +1,161 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import type { PublicMethodsOf } from '@kbn/utility-types';
import type { PluginName } from '@kbn/core-base-common';
import type {
PluginContractResolverResponse,
PluginContractMap,
PluginContractResolverResponseItem,
} from '@kbn/core-plugins-contracts-server';
export type IRuntimePluginContractResolver = PublicMethodsOf<RuntimePluginContractResolver>;
export class RuntimePluginContractResolver {
private dependencyMap?: Map<PluginName, Set<PluginName>>;
private setupContracts?: Map<PluginName, unknown>;
private startContracts?: Map<PluginName, unknown>;
private readonly setupRequestQueue: PluginContractRequest[] = [];
private readonly startRequestQueue: PluginContractRequest[] = [];
setDependencyMap(depMap: Map<PluginName, Set<PluginName>>) {
this.dependencyMap = new Map(depMap.entries());
}
onSetup = <T extends PluginContractMap>(
pluginName: PluginName,
dependencyNames: Array<keyof T>
): Promise<PluginContractResolverResponse<T>> => {
if (!this.dependencyMap) {
throw new Error('onSetup cannot be called before setDependencyMap');
}
const dependencyList = this.dependencyMap.get(pluginName) ?? new Set();
const notDependencyPlugins = dependencyNames.filter(
(name) => !dependencyList.has(name as PluginName)
);
if (notDependencyPlugins.length) {
throw new Error(
'Dynamic contract resolving requires the dependencies to be declared in the plugin manifest.' +
`Undeclared dependencies: ${notDependencyPlugins.join(', ')}`
);
}
if (this.setupContracts) {
const response = createContractRequestResponse(
dependencyNames as PluginName[],
this.setupContracts
);
return Promise.resolve(response as PluginContractResolverResponse<T>);
} else {
const setupContractRequest = createPluginContractRequest<PluginContractResolverResponse<T>>(
dependencyNames as PluginName[]
);
this.setupRequestQueue.push(setupContractRequest as PluginContractRequest);
return setupContractRequest.contractPromise;
}
};
onStart = <T extends PluginContractMap>(
pluginName: PluginName,
dependencyNames: Array<keyof T>
): Promise<PluginContractResolverResponse<T>> => {
if (!this.dependencyMap) {
throw new Error('onStart cannot be called before setDependencyMap');
}
const dependencyList = this.dependencyMap.get(pluginName) ?? new Set();
const notDependencyPlugins = dependencyNames.filter(
(name) => !dependencyList.has(name as PluginName)
);
if (notDependencyPlugins.length) {
throw new Error(
'Dynamic contract resolving requires the dependencies to be declared in the plugin manifest.' +
`Undeclared dependencies: ${notDependencyPlugins.join(', ')}`
);
}
if (this.startContracts) {
const response = createContractRequestResponse(
dependencyNames as PluginName[],
this.startContracts
);
return Promise.resolve(response as PluginContractResolverResponse<T>);
} else {
const startContractRequest = createPluginContractRequest<PluginContractResolverResponse<T>>(
dependencyNames as PluginName[]
);
this.startRequestQueue.push(startContractRequest as PluginContractRequest);
return startContractRequest.contractPromise;
}
};
resolveSetupRequests(setupContracts: Map<PluginName, unknown>) {
if (this.setupContracts) {
throw new Error('resolveSetupRequests can only be called once');
}
this.setupContracts = setupContracts;
for (const setupRequest of this.setupRequestQueue) {
const response = createContractRequestResponse(setupRequest.pluginNames, setupContracts);
setupRequest.resolve(response);
}
}
resolveStartRequests(startContracts: Map<PluginName, unknown>) {
if (this.startContracts) {
throw new Error('resolveStartRequests can only be called once');
}
this.startContracts = startContracts;
for (const startRequest of this.startRequestQueue) {
const response = createContractRequestResponse(startRequest.pluginNames, startContracts);
startRequest.resolve(response);
}
}
}
interface PluginContractRequest<T = unknown> {
pluginNames: PluginName[];
contractPromise: Promise<T>;
resolve: (data?: T) => void;
}
const createPluginContractRequest = <T = unknown>(
pluginNames: PluginName[]
): PluginContractRequest<T> => {
let resolve!: (data?: T) => void;
const contractPromise = new Promise<any>((_resolve) => {
resolve = _resolve;
});
return {
pluginNames,
contractPromise,
resolve,
};
};
const createContractRequestResponse = <T extends PluginContractMap>(
pluginNames: PluginName[],
contracts: Map<string, unknown>
): PluginContractResolverResponse<T> => {
const response = {} as Record<string, unknown>;
for (const pluginName of pluginNames) {
const pluginResponse: PluginContractResolverResponseItem = contracts.has(pluginName)
? {
found: true,
contract: contracts.get(pluginName)!,
}
: { found: false };
response[pluginName] = pluginResponse;
}
return response as PluginContractResolverResponse<T>;
};

View file

@ -76,6 +76,7 @@ const createPlugin = (
requiredPlugins = [],
requiredBundles = [],
optionalPlugins = [],
runtimePluginDependencies = [],
kibanaVersion = '7.0.0',
configPath = [path],
server = true,
@ -88,6 +89,7 @@ const createPlugin = (
requiredPlugins?: string[];
requiredBundles?: string[];
optionalPlugins?: string[];
runtimePluginDependencies?: string[];
kibanaVersion?: string;
configPath?: ConfigPath;
server?: boolean;
@ -105,6 +107,7 @@ const createPlugin = (
requiredPlugins,
requiredBundles,
optionalPlugins,
runtimePluginDependencies,
server,
owner: {
name: 'Core',
@ -1018,6 +1021,7 @@ describe('PluginsService', () => {
requiredPlugins: [],
requiredBundles: [],
optionalPlugins: [],
runtimePluginDependencies: [],
},
];

View file

@ -43,7 +43,7 @@ export type DiscoveredPlugins = {
};
/** @internal */
export interface PluginsServiceSetup {
export interface InternalPluginsServiceSetup {
/** Indicates whether or not plugins were initialized. */
initialized: boolean;
/** Setup contracts returned by plugins. */
@ -51,7 +51,7 @@ export interface PluginsServiceSetup {
}
/** @internal */
export interface PluginsServiceStart {
export interface InternalPluginsServiceStart {
/** Start contracts returned by plugins. */
contracts: Map<PluginName, unknown>;
}
@ -72,7 +72,9 @@ export interface PluginsServiceDiscoverDeps {
}
/** @internal */
export class PluginsService implements CoreService<PluginsServiceSetup, PluginsServiceStart> {
export class PluginsService
implements CoreService<InternalPluginsServiceSetup, InternalPluginsServiceStart>
{
private readonly log: Logger;
private readonly prebootPluginsSystem: PluginsSystem<PluginType.preboot>;
private arePrebootPluginsStopped = false;

View file

@ -6,11 +6,22 @@
* Side Public License, v 1.
*/
import { createRuntimePluginContractResolverMock } from './test_helpers';
export const mockCreatePluginPrebootSetupContext = jest.fn();
export const mockCreatePluginSetupContext = jest.fn();
export const mockCreatePluginStartContext = jest.fn();
jest.mock('./plugin_context', () => ({
createPluginPrebootSetupContext: mockCreatePluginPrebootSetupContext,
createPluginSetupContext: mockCreatePluginSetupContext,
createPluginStartContext: mockCreatePluginStartContext,
}));
export const runtimeResolverMock = createRuntimePluginContractResolverMock();
jest.doMock('./plugin_contract_resolver', () => {
return {
RuntimePluginContractResolver: jest.fn().mockImplementation(() => runtimeResolverMock),
};
});

View file

@ -10,6 +10,7 @@ import {
mockCreatePluginPrebootSetupContext,
mockCreatePluginSetupContext,
mockCreatePluginStartContext,
runtimeResolverMock,
} from './plugins_system.test.mocks';
import { BehaviorSubject } from 'rxjs';
@ -52,6 +53,7 @@ function createPlugin(
type,
requiredPlugins: required,
optionalPlugins: optional,
runtimePluginDependencies: [],
requiredBundles: [],
server,
ui,
@ -73,6 +75,10 @@ let env: Env;
let coreContext: CoreContext;
beforeEach(() => {
runtimeResolverMock.setDependencyMap.mockReset();
runtimeResolverMock.resolveSetupRequests.mockReset();
runtimeResolverMock.resolveStartRequests.mockReset();
logger = loggingSystemMock.create();
env = Env.createDefault(REPO_ROOT, getEnvOptions());
@ -197,6 +203,31 @@ test('`setupPlugins` ignores missing optional dependency', async () => {
`);
});
test('`setupPlugins` setups the runtimeResolver', async () => {
const pluginA = createPlugin('pluginA', { required: [] });
const pluginB = createPlugin('pluginB', { required: ['pluginA'] });
jest.spyOn(pluginA, 'setup').mockReturnValue('contractA');
jest.spyOn(pluginB, 'setup').mockReturnValue('contractB');
pluginsSystem.addPlugin(pluginA);
pluginsSystem.addPlugin(pluginB);
await pluginsSystem.setupPlugins(setupDeps);
expect(runtimeResolverMock.setDependencyMap).toHaveBeenCalledTimes(1);
expect(runtimeResolverMock.setDependencyMap).toHaveBeenCalledWith(expect.any(Map));
expect(runtimeResolverMock.resolveSetupRequests).toHaveBeenCalledTimes(1);
expect(runtimeResolverMock.resolveSetupRequests).toHaveBeenCalledWith(expect.any(Map));
expect(
Object.fromEntries([...runtimeResolverMock.resolveSetupRequests.mock.calls[0][0].entries()])
).toEqual({
pluginA: 'contractA',
pluginB: 'contractB',
});
});
test('correctly orders plugins and returns exposed values for "setup" and "start"', async () => {
interface Contracts {
setup: Record<PluginName, unknown>;
@ -254,13 +285,9 @@ test('correctly orders plugins and returns exposed values for "setup" and "start
pluginsSystem.addPlugin(plugin);
});
mockCreatePluginSetupContext.mockImplementation((context, deps, plugin) =>
setupContextMap.get(plugin.name)
);
mockCreatePluginSetupContext.mockImplementation(({ plugin }) => setupContextMap.get(plugin.name));
mockCreatePluginStartContext.mockImplementation((context, deps, plugin) =>
startContextMap.get(plugin.name)
);
mockCreatePluginStartContext.mockImplementation(({ plugin }) => startContextMap.get(plugin.name));
expect([...(await pluginsSystem.setupPlugins(setupDeps))]).toMatchInlineSnapshot(`
Array [
@ -288,7 +315,11 @@ test('correctly orders plugins and returns exposed values for "setup" and "start
`);
for (const [plugin, deps] of plugins) {
expect(mockCreatePluginSetupContext).toHaveBeenCalledWith(coreContext, setupDeps, plugin);
expect(mockCreatePluginSetupContext).toHaveBeenCalledWith({
deps: setupDeps,
plugin,
runtimeResolver: expect.any(Object),
});
expect(plugin.setup).toHaveBeenCalledTimes(1);
expect(plugin.setup).toHaveBeenCalledWith(setupContextMap.get(plugin.name), deps.setup);
}
@ -319,7 +350,11 @@ test('correctly orders plugins and returns exposed values for "setup" and "start
`);
for (const [plugin, deps] of plugins) {
expect(mockCreatePluginStartContext).toHaveBeenCalledWith(coreContext, startDeps, plugin);
expect(mockCreatePluginStartContext).toHaveBeenCalledWith({
deps: startDeps,
plugin,
runtimeResolver: expect.any(Object),
});
expect(plugin.start).toHaveBeenCalledTimes(1);
expect(plugin.start).toHaveBeenCalledWith(startContextMap.get(plugin.name), deps.start);
}
@ -362,7 +397,7 @@ test('correctly orders preboot plugins and returns exposed values for "setup"',
prebootPluginSystem.addPlugin(plugin);
});
mockCreatePluginPrebootSetupContext.mockImplementation((context, deps, plugin) =>
mockCreatePluginPrebootSetupContext.mockImplementation(({ plugin }) =>
setupContextMap.get(plugin.name)
);
@ -392,11 +427,10 @@ test('correctly orders preboot plugins and returns exposed values for "setup"',
`);
for (const [plugin, deps] of plugins) {
expect(mockCreatePluginPrebootSetupContext).toHaveBeenCalledWith(
coreContext,
prebootDeps,
plugin
);
expect(mockCreatePluginPrebootSetupContext).toHaveBeenCalledWith({
deps: prebootDeps,
plugin,
});
expect(plugin.setup).toHaveBeenCalledTimes(1);
expect(plugin.setup).toHaveBeenCalledWith(setupContextMap.get(plugin.name), deps);
}
@ -426,17 +460,21 @@ test('`setupPlugins` only setups plugins that have server side', async () => {
]
`);
expect(mockCreatePluginSetupContext).toHaveBeenCalledWith(
coreContext,
setupDeps,
firstPluginToRun
);
expect(mockCreatePluginSetupContext).not.toHaveBeenCalledWith(coreContext, secondPluginNotToRun);
expect(mockCreatePluginSetupContext).toHaveBeenCalledWith(
coreContext,
setupDeps,
thirdPluginToRun
);
expect(mockCreatePluginSetupContext).toHaveBeenCalledWith({
deps: setupDeps,
plugin: firstPluginToRun,
runtimeResolver: expect.any(Object),
});
expect(mockCreatePluginSetupContext).not.toHaveBeenCalledWith({
deps: setupDeps,
plugin: secondPluginNotToRun,
runtimeResolver: expect.any(Object),
});
expect(mockCreatePluginSetupContext).toHaveBeenCalledWith({
deps: setupDeps,
plugin: thirdPluginToRun,
runtimeResolver: expect.any(Object),
});
expect(firstPluginToRun.setup).toHaveBeenCalledTimes(1);
expect(secondPluginNotToRun.setup).not.toHaveBeenCalled();
@ -627,6 +665,32 @@ describe('start', () => {
const log = logger.get.mock.results[0].value as jest.Mocked<Logger>;
expect(log.info).toHaveBeenCalledWith(`Starting [2] plugins: [order-1,order-0]`);
});
it('setups the runtimeResolver', async () => {
const pluginA = createPlugin('pluginA', { required: [] });
const pluginB = createPlugin('pluginB', { required: ['pluginA'] });
jest.spyOn(pluginA, 'setup').mockReturnValue({});
jest.spyOn(pluginB, 'setup').mockReturnValue({});
jest.spyOn(pluginA, 'start').mockReturnValue('contractA');
jest.spyOn(pluginB, 'start').mockReturnValue('contractB');
pluginsSystem.addPlugin(pluginA);
pluginsSystem.addPlugin(pluginB);
await pluginsSystem.setupPlugins(setupDeps);
await pluginsSystem.startPlugins(startDeps);
expect(runtimeResolverMock.resolveStartRequests).toHaveBeenCalledTimes(1);
expect(runtimeResolverMock.resolveStartRequests).toHaveBeenCalledWith(expect.any(Map));
expect(
Object.fromEntries([...runtimeResolverMock.resolveStartRequests.mock.calls[0][0].entries()])
).toEqual({
pluginA: 'contractA',
pluginB: 'contractB',
});
});
});
describe('asynchronous plugins', () => {

View file

@ -23,11 +23,13 @@ import type {
PluginsServiceSetupDeps,
PluginsServiceStartDeps,
} from './plugins_service';
import { RuntimePluginContractResolver } from './plugin_contract_resolver';
const Sec = 1000;
/** @internal */
export class PluginsSystem<T extends PluginType> {
private readonly runtimeResolver = new RuntimePluginContractResolver();
private readonly plugins = new Map<PluginName, PluginWrapper>();
private readonly log: Logger;
// `satup`, the past-tense version of the noun `setup`.
@ -90,6 +92,9 @@ export class PluginsSystem<T extends PluginType> {
return contracts;
}
const runtimeDependencies = buildPluginRuntimeDependencyMap(this.plugins);
this.runtimeResolver.setDependencyMap(runtimeDependencies);
const sortedPlugins = new Map(
[...this.getTopologicallySortedPluginNames()]
.map((pluginName) => [pluginName, this.plugins.get(pluginName)!] as [string, PluginWrapper])
@ -114,17 +119,16 @@ export class PluginsSystem<T extends PluginType> {
let pluginSetupContext;
if (this.type === PluginType.preboot) {
pluginSetupContext = createPluginPrebootSetupContext(
this.coreContext,
deps as PluginsServicePrebootSetupDeps,
plugin
);
pluginSetupContext = createPluginPrebootSetupContext({
deps: deps as PluginsServicePrebootSetupDeps,
plugin,
});
} else {
pluginSetupContext = createPluginSetupContext(
this.coreContext,
deps as PluginsServiceSetupDeps,
plugin
);
pluginSetupContext = createPluginSetupContext({
deps: deps as PluginsServiceSetupDeps,
plugin,
runtimeResolver: this.runtimeResolver,
});
}
let contract: unknown;
@ -155,6 +159,8 @@ export class PluginsSystem<T extends PluginType> {
this.satupPlugins.push(pluginName);
}
this.runtimeResolver.resolveSetupRequests(contracts);
return contracts;
}
@ -186,7 +192,7 @@ export class PluginsSystem<T extends PluginType> {
let contract: unknown;
const contractOrPromise = plugin.start(
createPluginStartContext(this.coreContext, deps, plugin),
createPluginStartContext({ deps, plugin, runtimeResolver: this.runtimeResolver }),
pluginDepContracts
);
if (isPromise(contractOrPromise)) {
@ -214,6 +220,8 @@ export class PluginsSystem<T extends PluginType> {
contracts.set(pluginName, contract);
}
this.runtimeResolver.resolveStartRequests(contracts);
return contracts;
}
@ -265,6 +273,7 @@ export class PluginsSystem<T extends PluginType> {
const uiPluginNames = [...this.getTopologicallySortedPluginNames().keys()].filter(
(pluginName) => this.plugins.get(pluginName)!.includesUiPlugin
);
const filterUiPlugins = (pluginName: string) => uiPluginNames.includes(pluginName);
const publicPlugins = new Map<PluginName, DiscoveredPlugin>(
uiPluginNames.map((pluginName) => {
const plugin = this.plugins.get(pluginName)!;
@ -274,12 +283,10 @@ export class PluginsSystem<T extends PluginType> {
id: pluginName,
type: plugin.manifest.type,
configPath: plugin.manifest.configPath,
requiredPlugins: plugin.manifest.requiredPlugins.filter((p) =>
uiPluginNames.includes(p)
),
optionalPlugins: plugin.manifest.optionalPlugins.filter((p) =>
uiPluginNames.includes(p)
),
requiredPlugins: plugin.manifest.requiredPlugins.filter(filterUiPlugins),
optionalPlugins: plugin.manifest.optionalPlugins.filter(filterUiPlugins),
runtimePluginDependencies:
plugin.manifest.runtimePluginDependencies.filter(filterUiPlugins),
requiredBundles: plugin.manifest.requiredBundles,
enabledOnAnonymousPages: plugin.manifest.enabledOnAnonymousPages,
},
@ -371,3 +378,18 @@ const buildReverseDependencyMap = (
}
return reverseMap;
};
const buildPluginRuntimeDependencyMap = (
pluginMap: Map<PluginName, PluginWrapper>
): Map<PluginName, Set<PluginName>> => {
const runtimeDependencies = new Map<PluginName, Set<PluginName>>();
for (const [pluginName, pluginWrapper] of pluginMap.entries()) {
const pluginRuntimeDeps = new Set([
...pluginWrapper.optionalPlugins,
...pluginWrapper.requiredPlugins,
...pluginWrapper.runtimePluginDependencies,
]);
runtimeDependencies.set(pluginName, pluginRuntimeDeps);
}
return runtimeDependencies;
};

View file

@ -7,3 +7,4 @@
*/
export { createCoreContextConfigServiceMock } from './create_core_context_config_service.mock';
export { createRuntimePluginContractResolverMock } from './plugin_contract_resolver.mock';

View file

@ -0,0 +1,20 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import type { IRuntimePluginContractResolver } from '../plugin_contract_resolver';
export const createRuntimePluginContractResolverMock =
(): jest.Mocked<IRuntimePluginContractResolver> => {
return {
setDependencyMap: jest.fn(),
onSetup: jest.fn(),
onStart: jest.fn(),
resolveSetupRequests: jest.fn(),
resolveStartRequests: jest.fn(),
};
};

View file

@ -38,6 +38,8 @@
"@kbn/core-node-server-internal",
"@kbn/core-plugins-base-server-internal",
"@kbn/repo-packages",
"@kbn/utility-types",
"@kbn/core-plugins-contracts-server",
],
"exclude": [
"target/**/*",

View file

@ -7,25 +7,48 @@
*/
import type { PublicMethodsOf } from '@kbn/utility-types';
import { PluginsService, type PluginsServiceSetup } from '@kbn/core-plugins-server-internal';
import type { PluginsServiceSetup, PluginsServiceStart } from '@kbn/core-plugins-contracts-server';
import {
PluginsService,
type InternalPluginsServiceSetup,
type InternalPluginsServiceStart,
} from '@kbn/core-plugins-server-internal';
type PluginsServiceMock = jest.Mocked<PublicMethodsOf<PluginsService>>;
const createSetupContractMock = (): PluginsServiceSetup => ({
const createInternalSetupContractMock = (): InternalPluginsServiceSetup => ({
contracts: new Map(),
initialized: true,
});
const createStartContractMock = () => ({ contracts: new Map() });
const createInternalStartContractMock = (): InternalPluginsServiceStart => ({
contracts: new Map(),
});
const createServiceMock = (): PluginsServiceMock => ({
discover: jest.fn(),
getExposedPluginConfigsToUsage: jest.fn(),
preboot: jest.fn(),
setup: jest.fn().mockResolvedValue(createSetupContractMock()),
start: jest.fn().mockResolvedValue(createStartContractMock()),
setup: jest.fn().mockResolvedValue(createInternalSetupContractMock()),
start: jest.fn().mockResolvedValue(createInternalStartContractMock()),
stop: jest.fn(),
});
const createSetupContractMock = () => {
const contract: jest.Mocked<PluginsServiceSetup> = {
onSetup: jest.fn(),
onStart: jest.fn(),
};
return contract;
};
const createStartContractMock = () => {
const contract: jest.Mocked<PluginsServiceStart> = {
onStart: jest.fn(),
};
return contract;
};
function createUiPlugins() {
return {
browserConfigs: new Map(),
@ -38,5 +61,7 @@ export const pluginServiceMock = {
create: createServiceMock,
createSetupContract: createSetupContractMock,
createStartContract: createStartContractMock,
createInternalSetupContract: createInternalSetupContractMock,
createInternalStartContract: createInternalStartContractMock,
createUiPlugins,
};

View file

@ -12,7 +12,8 @@
],
"kbn_references": [
"@kbn/utility-types",
"@kbn/core-plugins-server-internal"
"@kbn/core-plugins-server-internal",
"@kbn/core-plugins-contracts-server"
],
"exclude": [
"target/**/*",

View file

@ -215,6 +215,12 @@ export interface PluginManifest {
*/
readonly optionalPlugins: readonly PluginName[];
/**
* An optional list of plugin dependencies that can be resolved dynamically at runtime
* using the dynamic contract resolving capabilities from the plugin service.
*/
readonly runtimePluginDependencies: readonly string[];
/**
* Specifies whether plugin includes some client/browser specific functionality
* that should be included into client bundle via `public/ui_plugin.js` file.

View file

@ -30,6 +30,7 @@ const createUiPlugins = (pluginDeps: Record<string, string[]>) => {
type: PluginType.standard,
optionalPlugins: [],
requiredPlugins: [],
runtimePluginDependencies: [],
requiredBundles: deps,
});

View file

@ -71,6 +71,10 @@ function parseLegacyKibanaPlatformPlugin(manifestPath) {
optionalPlugins: isValidDepsDeclaration(manifest.optionalPlugins, 'optionalPlugins'),
requiredBundles: isValidDepsDeclaration(manifest.requiredBundles, 'requiredBundles'),
extraPublicDirs: isValidDepsDeclaration(manifest.extraPublicDirs, 'extraPublicDirs'),
runtimePluginDependencies: isValidDepsDeclaration(
manifest.runtimePluginDependencies,
'runtimePluginDependencies'
),
},
};
}

View file

@ -31,6 +31,7 @@ export interface LegacyKibanaPlatformPluginManifest {
serviceFolders: readonly string[];
requiredPlugins: readonly string[];
optionalPlugins: readonly string[];
runtimePluginDependencies?: readonly string[];
requiredBundles: readonly string[];
extraPublicDirs: readonly string[];
}

View file

@ -62,6 +62,7 @@ function validatePackageManifestPlugin(plugin, repoRoot, path) {
requiredPlugins,
optionalPlugins,
requiredBundles,
runtimePluginDependencies,
enabledOnAnonymousPages,
type,
__category__,
@ -103,6 +104,14 @@ function validatePackageManifestPlugin(plugin, repoRoot, path) {
);
}
if (runtimePluginDependencies !== undefined && !isArrOfIds(runtimePluginDependencies)) {
throw err(
`plugin.runtimePluginDependencies`,
runtimePluginDependencies,
`must be an array of strings in camel or snake case`
);
}
if (requiredBundles !== undefined && !isArrOfIds(requiredBundles)) {
throw err(
`plugin.requiredBundles`,
@ -154,6 +163,7 @@ function validatePackageManifestPlugin(plugin, repoRoot, path) {
requiredPlugins,
optionalPlugins,
requiredBundles,
runtimePluginDependencies,
enabledOnAnonymousPages,
extraPublicDirs,
[PLUGIN_CATEGORY]: __category__,

View file

@ -104,6 +104,7 @@ export interface PluginPackageManifest extends PackageManifestBaseFields {
requiredPlugins?: string[];
optionalPlugins?: string[];
requiredBundles?: string[];
runtimePluginDependencies?: string[];
enabledOnAnonymousPages?: boolean;
type?: 'preboot';
extraPublicDirs?: string[];

View file

@ -68,6 +68,16 @@ export type {
PluginInitializer,
PluginInitializerContext,
} from '@kbn/core-plugins-browser';
export type {
PluginsServiceSetup,
PluginsServiceStart,
PluginContractResolver,
PluginContractMap,
PluginContractResolverResponse,
PluginContractResolverResponseItem,
FoundPluginContractResolverResponseItem,
NotFoundPluginContractResolverResponseItem,
} from '@kbn/core-plugins-contracts-browser';
export type { PluginOpaqueId } from '@kbn/core-base-common';
export type { PackageInfo, EnvironmentMode } from '@kbn/config';

View file

@ -46,7 +46,10 @@ import { configSchema as elasticsearchConfigSchema } from '@kbn/core-elasticsear
import type { CapabilitiesSetup, CapabilitiesStart } from '@kbn/core-capabilities-server';
import type { RequestHandlerContext } from '@kbn/core-http-request-handler-context-server';
import type { HttpResources } from '@kbn/core-http-resources-server';
import type { PluginsServiceSetup, PluginsServiceStart } from '@kbn/core-plugins-server-internal';
import type {
InternalPluginsServiceSetup,
InternalPluginsServiceStart,
} from '@kbn/core-plugins-server-internal';
export { bootstrap } from '@kbn/core-root-server-internal';
@ -234,6 +237,16 @@ export type {
MakeUsageFromSchema,
ExposedToBrowserDescriptor,
} from '@kbn/core-plugins-server';
export type {
PluginsServiceSetup,
PluginsServiceStart,
NotFoundPluginContractResolverResponseItem,
FoundPluginContractResolverResponseItem,
PluginContractResolverResponseItem,
PluginContractMap,
PluginContractResolverResponse,
PluginContractResolver,
} from '@kbn/core-plugins-contracts-server';
export type { PluginName, DiscoveredPlugin } from '@kbn/core-base-common';
@ -472,8 +485,8 @@ export type {
ExecutionContextSetup,
ExecutionContextStart,
HttpResources,
PluginsServiceSetup,
PluginsServiceStart,
InternalPluginsServiceSetup,
InternalPluginsServiceStart,
};
/**

View file

@ -153,6 +153,8 @@
"@kbn/stdio-dev-helpers",
"@kbn/safer-lodash-set",
"@kbn/core-test-helpers-model-versions",
"@kbn/core-plugins-contracts-browser",
"@kbn/core-plugins-contracts-server",
],
"exclude": [
"target/**/*",

View file

@ -6,7 +6,8 @@
* Side Public License, v 1.
*/
import { CoreStart, ElasticsearchClient } from '@kbn/core/server';
import { ElasticsearchClient } from '@kbn/core/server';
import type { InternalCoreStart } from '@kbn/core-lifecycle-server-internal';
import {
createTestServers,
createRootWithCorePlugins,
@ -38,7 +39,7 @@ describe('FileService', () => {
let fileService: FileServiceStart;
let blobStorageService: BlobStorageService;
let esClient: ElasticsearchClient;
let coreStart: CoreStart;
let coreStart: InternalCoreStart;
let fileServiceFactory: FileServiceFactory;
let security: ReturnType<typeof securityMock.createSetup>;
let auditLogger: AuditLogger;
@ -93,11 +94,13 @@ describe('FileService', () => {
});
let disposables: File[] = [];
async function createDisposableFile<M = unknown>(args: CreateFileArgs<M>) {
const file = await fileService.create(args);
disposables.push(file);
return file;
}
afterEach(async () => {
await fileService.bulkDelete({ ids: disposables.map((d) => d.id) });
const { files } = await fileService.find({ kind: [fileKind] });
@ -326,6 +329,7 @@ describe('FileService', () => {
interface CustomMeta {
some: string;
}
it('updates files', async () => {
const file = await createDisposableFile<CustomMeta>({ fileKind, name: 'test' });
const updatableFields = {

View file

@ -33,6 +33,7 @@
"@kbn/core-saved-objects-server-mocks",
"@kbn/logging",
"@kbn/core-http-common",
"@kbn/core-lifecycle-server-internal",
],
"exclude": [
"target/**/*",

View file

@ -0,0 +1,14 @@
{
"type": "plugin",
"id": "@kbn/core-plugin-dynamic-resolving-a",
"owner": "@elastic/kibana-core",
"plugin": {
"id": "coreDynamicResolvingA",
"server": true,
"browser": false,
"configPath": [
"core_plugin_a"
],
"runtimePluginDependencies" : ["coreDynamicResolvingB"]
}
}

View file

@ -0,0 +1,14 @@
{
"name": "@kbn/core-plugin-dynamic-resolving-a",
"version": "1.0.0",
"main": "target/test/plugin_functional/plugins/core_dynamic_resolving_a",
"kibana": {
"version": "kibana",
"templateVersion": "1.0.0"
},
"license": "SSPL-1.0 OR Elastic License 2.0",
"scripts": {
"kbn": "node ../../../../scripts/kbn.js",
"build": "rm -rf './target' && ../../../../node_modules/.bin/tsc"
}
}

View file

@ -0,0 +1,11 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { CoreDynamicResolvingAPlugin } from './plugin';
export const plugin = () => new CoreDynamicResolvingAPlugin();

View file

@ -0,0 +1,71 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import type { Plugin, CoreSetup } from '@kbn/core/server';
interface GenericSetupContract {
someSetupAPI: () => string;
}
interface GenericStartContract {
someStartAPI: () => string;
}
export class CoreDynamicResolvingAPlugin implements Plugin {
public setup(core: CoreSetup, deps: {}) {
const router = core.http.createRouter();
router.get(
{
path: '/api/core_dynamic_resolving_a/test',
validate: false,
},
async (ctx, req, res) => {
return Promise.all([
core.plugins.onSetup<{
coreDynamicResolvingB: GenericSetupContract;
}>('coreDynamicResolvingB'),
core.plugins.onStart<{
coreDynamicResolvingB: GenericStartContract;
}>('coreDynamicResolvingB'),
]).then(
([
{ coreDynamicResolvingB: coreDynamicResolvingBSetup },
{ coreDynamicResolvingB: coreDynamicResolvingBStart },
]) => {
if (coreDynamicResolvingBSetup.found && coreDynamicResolvingBStart.found) {
return res.ok({
body: {
setup: coreDynamicResolvingBSetup.contract.someSetupAPI(),
start: coreDynamicResolvingBStart.contract.someStartAPI(),
},
});
} else {
return res.badRequest({
body: {
message: 'not found',
},
});
}
}
);
}
);
return {
someSetupAPI: () => 'pluginASetup',
};
}
public start() {
return {
someStartAPI: () => 'pluginAStart',
};
}
public stop() {}
}

View file

@ -0,0 +1,19 @@
{
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"outDir": "target/types"
},
"include": [
"index.ts",
"public/**/*.ts",
"public/**/*.tsx",
"server/**/*.ts",
"../../../../typings/**/*",
],
"exclude": [
"target/**/*",
],
"kbn_references": [
"@kbn/core"
]
}

View file

@ -0,0 +1,14 @@
{
"type": "plugin",
"id": "@kbn/core-plugin-dynamic-resolving-b",
"owner": "@elastic/kibana-core",
"plugin": {
"id": "coreDynamicResolvingB",
"server": true,
"browser": false,
"configPath": [
"core_plugin_b"
],
"runtimePluginDependencies" : ["coreDynamicResolvingA"]
}
}

View file

@ -0,0 +1,14 @@
{
"name": "@kbn/core-plugin-dynamic-resolving-b",
"version": "1.0.0",
"main": "target/test/plugin_functional/plugins/core_dynamic_resolving_b",
"kibana": {
"version": "kibana",
"templateVersion": "1.0.0"
},
"license": "SSPL-1.0 OR Elastic License 2.0",
"scripts": {
"kbn": "node ../../../../scripts/kbn.js",
"build": "rm -rf './target' && ../../../../node_modules/.bin/tsc"
}
}

View file

@ -0,0 +1,11 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { CoreDynamicResolvingBPlugin } from './plugin';
export const plugin = () => new CoreDynamicResolvingBPlugin();

View file

@ -0,0 +1,71 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import type { Plugin, CoreSetup } from '@kbn/core/server';
interface GenericSetupContract {
someSetupAPI: () => string;
}
interface GenericStartContract {
someStartAPI: () => string;
}
export class CoreDynamicResolvingBPlugin implements Plugin {
public setup(core: CoreSetup, deps: {}) {
const router = core.http.createRouter();
router.get(
{
path: '/api/core_dynamic_resolving_b/test',
validate: false,
},
async (ctx, req, res) => {
return Promise.all([
core.plugins.onSetup<{
coreDynamicResolvingA: GenericSetupContract;
}>('coreDynamicResolvingA'),
core.plugins.onStart<{
coreDynamicResolvingA: GenericStartContract;
}>('coreDynamicResolvingA'),
]).then(
([
{ coreDynamicResolvingA: coreDynamicResolvingASetup },
{ coreDynamicResolvingA: coreDynamicResolvingAStart },
]) => {
if (coreDynamicResolvingASetup.found && coreDynamicResolvingAStart.found) {
return res.ok({
body: {
setup: coreDynamicResolvingASetup.contract.someSetupAPI(),
start: coreDynamicResolvingAStart.contract.someStartAPI(),
},
});
} else {
return res.badRequest({
body: {
message: 'not found',
},
});
}
}
);
}
);
return {
someSetupAPI: () => 'pluginBSetup',
};
}
public start() {
return {
someStartAPI: () => 'pluginBStart',
};
}
public stop() {}
}

View file

@ -0,0 +1,19 @@
{
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"outDir": "target/types"
},
"include": [
"index.ts",
"public/**/*.ts",
"public/**/*.tsx",
"server/**/*.ts",
"../../../../typings/**/*",
],
"exclude": [
"target/**/*",
],
"kbn_references": [
"@kbn/core"
]
}

View file

@ -0,0 +1,39 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { PluginFunctionalProviderContext } from '../../services';
export default function ({ getService }: PluginFunctionalProviderContext) {
const supertest = getService('supertest');
describe('Dynamic plugin resolving', function describeIndexTests() {
it('Plugin A can dynamically resolve plugin B contracts', async () => {
await supertest
.get('/api/core_dynamic_resolving_a/test')
.set('kbn-xsrf', 'anything')
.send()
.expect(200)
.expect({
setup: 'pluginBSetup',
start: 'pluginBStart',
});
});
it('Plugin B can dynamically resolve plugin A contracts', async () => {
await supertest
.get('/api/core_dynamic_resolving_b/test')
.set('kbn-xsrf', 'anything')
.send()
.expect(200)
.expect({
setup: 'pluginASetup',
start: 'pluginAStart',
});
});
});
}

View file

@ -24,5 +24,6 @@ export default function ({ loadTestFile }: PluginFunctionalProviderContext) {
loadTestFile(require.resolve('./chrome_help_menu_links'));
loadTestFile(require.resolve('./history_block'));
loadTestFile(require.resolve('./http'));
loadTestFile(require.resolve('./dynamic_contract_resolving'));
});
}

View file

@ -446,6 +446,10 @@
"@kbn/core-plugin-deep-links-plugin/*": ["test/plugin_functional/plugins/core_plugin_deep_links/*"],
"@kbn/core-plugin-deprecations-plugin": ["test/plugin_functional/plugins/core_plugin_deprecations"],
"@kbn/core-plugin-deprecations-plugin/*": ["test/plugin_functional/plugins/core_plugin_deprecations/*"],
"@kbn/core-plugin-dynamic-resolving-a": ["test/plugin_functional/plugins/core_dynamic_resolving_a"],
"@kbn/core-plugin-dynamic-resolving-a/*": ["test/plugin_functional/plugins/core_dynamic_resolving_a/*"],
"@kbn/core-plugin-dynamic-resolving-b": ["test/plugin_functional/plugins/core_dynamic_resolving_b"],
"@kbn/core-plugin-dynamic-resolving-b/*": ["test/plugin_functional/plugins/core_dynamic_resolving_b/*"],
"@kbn/core-plugin-execution-context-plugin": ["test/plugin_functional/plugins/core_plugin_execution_context"],
"@kbn/core-plugin-execution-context-plugin/*": ["test/plugin_functional/plugins/core_plugin_execution_context/*"],
"@kbn/core-plugin-helpmenu-plugin": ["test/plugin_functional/plugins/core_plugin_helpmenu"],
@ -464,6 +468,10 @@
"@kbn/core-plugins-browser-internal/*": ["packages/core/plugins/core-plugins-browser-internal/*"],
"@kbn/core-plugins-browser-mocks": ["packages/core/plugins/core-plugins-browser-mocks"],
"@kbn/core-plugins-browser-mocks/*": ["packages/core/plugins/core-plugins-browser-mocks/*"],
"@kbn/core-plugins-contracts-browser": ["packages/core/plugins/core-plugins-contracts-browser"],
"@kbn/core-plugins-contracts-browser/*": ["packages/core/plugins/core-plugins-contracts-browser/*"],
"@kbn/core-plugins-contracts-server": ["packages/core/plugins/core-plugins-contracts-server"],
"@kbn/core-plugins-contracts-server/*": ["packages/core/plugins/core-plugins-contracts-server/*"],
"@kbn/core-plugins-server": ["packages/core/plugins/core-plugins-server"],
"@kbn/core-plugins-server/*": ["packages/core/plugins/core-plugins-server/*"],
"@kbn/core-plugins-server-internal": ["packages/core/plugins/core-plugins-server-internal"],

View file

@ -13,6 +13,7 @@ import { createBrowserHistory } from 'history';
import { I18nProvider } from '@kbn/i18n-react';
import type { PluginsServiceStart } from '@kbn/core/public';
import { CoreScopedHistory } from '@kbn/core/public';
import { getStorybookContextProvider } from '@kbn/custom-integrations-plugin/storybook';
@ -93,6 +94,7 @@ export const StorybookContext: React.FC<{ storyContext?: Parameters<DecoratorFn>
theme: {
theme$: EMPTY,
},
plugins: {} as unknown as PluginsServiceStart,
authz: {
fleet: {
all: true,

View file

@ -3792,6 +3792,14 @@
version "0.0.0"
uid ""
"@kbn/core-plugin-dynamic-resolving-a@link:test/plugin_functional/plugins/core_dynamic_resolving_a":
version "0.0.0"
uid ""
"@kbn/core-plugin-dynamic-resolving-b@link:test/plugin_functional/plugins/core_dynamic_resolving_b":
version "0.0.0"
uid ""
"@kbn/core-plugin-execution-context-plugin@link:test/plugin_functional/plugins/core_plugin_execution_context":
version "0.0.0"
uid ""
@ -3828,6 +3836,14 @@
version "0.0.0"
uid ""
"@kbn/core-plugins-contracts-browser@link:packages/core/plugins/core-plugins-contracts-browser":
version "0.0.0"
uid ""
"@kbn/core-plugins-contracts-server@link:packages/core/plugins/core-plugins-contracts-server":
version "0.0.0"
uid ""
"@kbn/core-plugins-server-internal@link:packages/core/plugins/core-plugins-server-internal":
version "0.0.0"
uid ""