Expose whitelisted config values to client-side plugin (#50641) (#51272)

* introduce PluginConfigDescriptor type

* inject client plugin configs in injectedMetadata

* expose client config in PluginInitializerContext

* add example implementation in testbed

* update generated doc

* only generates ui config entry for plugins exposing properties to client

* separate plugin configs from plugins

* restructure plugin services tests

* fix test/mocks due to plugin configs api changes

* add unit tests

* update migration guide

* update tsdoc

* fix typecheck

* use sync getter for config on client side instead of observable

* change type of exposeToBrowser prop

* updates generated doc

* fix doc and address nits
This commit is contained in:
Pierre Gayvallet 2019-11-21 14:40:51 +01:00 committed by GitHub
parent 92c31d1dd2
commit f0257a8de3
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
35 changed files with 1064 additions and 634 deletions

View file

@ -0,0 +1,13 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-public](./kibana-plugin-public.md) &gt; [PluginInitializerContext](./kibana-plugin-public.plugininitializercontext.md) &gt; [config](./kibana-plugin-public.plugininitializercontext.config.md)
## PluginInitializerContext.config property
<b>Signature:</b>
```typescript
readonly config: {
get: <T extends object = ConfigSchema>() => T;
};
```

View file

@ -9,13 +9,14 @@ The available core services passed to a `PluginInitializer`
<b>Signature:</b>
```typescript
export interface PluginInitializerContext
export interface PluginInitializerContext<ConfigSchema extends object = object>
```
## Properties
| Property | Type | Description |
| --- | --- | --- |
| [config](./kibana-plugin-public.plugininitializercontext.config.md) | <code>{</code><br/><code> get: &lt;T extends object = ConfigSchema&gt;() =&gt; T;</code><br/><code> }</code> | |
| [env](./kibana-plugin-public.plugininitializercontext.env.md) | <code>{</code><br/><code> mode: Readonly&lt;EnvironmentMode&gt;;</code><br/><code> packageInfo: Readonly&lt;PackageInfo&gt;;</code><br/><code> }</code> | |
| [opaqueId](./kibana-plugin-public.plugininitializercontext.opaqueid.md) | <code>PluginOpaqueId</code> | A symbol used to identify this plugin in the system. Needed when registering handlers or context providers. |

View file

@ -75,6 +75,7 @@ The plugin integrates with the core system via lifecycle events: `setup`<!-- -->
| [OnPreAuthToolkit](./kibana-plugin-server.onpreauthtoolkit.md) | A tool set defining an outcome of OnPreAuth interceptor for incoming request. |
| [PackageInfo](./kibana-plugin-server.packageinfo.md) | |
| [Plugin](./kibana-plugin-server.plugin.md) | The interface that should be returned by a <code>PluginInitializer</code>. |
| [PluginConfigDescriptor](./kibana-plugin-server.pluginconfigdescriptor.md) | Describes a plugin configuration schema and capabilities. |
| [PluginInitializerContext](./kibana-plugin-server.plugininitializercontext.md) | Context that's available to plugins during initialization stage. |
| [PluginManifest](./kibana-plugin-server.pluginmanifest.md) | Describes the set of required and optional properties plugin can define in its mandatory JSON manifest file. |
| [PluginsServiceSetup](./kibana-plugin-server.pluginsservicesetup.md) | |
@ -156,6 +157,7 @@ The plugin integrates with the core system via lifecycle events: `setup`<!-- -->
| [MutatingOperationRefreshSetting](./kibana-plugin-server.mutatingoperationrefreshsetting.md) | Elasticsearch Refresh setting for mutating operation |
| [OnPostAuthHandler](./kibana-plugin-server.onpostauthhandler.md) | See [OnPostAuthToolkit](./kibana-plugin-server.onpostauthtoolkit.md)<!-- -->. |
| [OnPreAuthHandler](./kibana-plugin-server.onpreauthhandler.md) | See [OnPreAuthToolkit](./kibana-plugin-server.onpreauthtoolkit.md)<!-- -->. |
| [PluginConfigSchema](./kibana-plugin-server.pluginconfigschema.md) | Dedicated type for plugin configuration schema. |
| [PluginInitializer](./kibana-plugin-server.plugininitializer.md) | The <code>plugin</code> export at the root of a plugin's <code>server</code> directory should conform to this interface. |
| [PluginName](./kibana-plugin-server.pluginname.md) | Dedicated type for plugin name/id that is supposed to make Map/Set/Arrays that use it as a key or value more obvious. |
| [PluginOpaqueId](./kibana-plugin-server.pluginopaqueid.md) | |

View file

@ -0,0 +1,15 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-server](./kibana-plugin-server.md) &gt; [PluginConfigDescriptor](./kibana-plugin-server.pluginconfigdescriptor.md) &gt; [exposeToBrowser](./kibana-plugin-server.pluginconfigdescriptor.exposetobrowser.md)
## PluginConfigDescriptor.exposeToBrowser property
List of configuration properties that will be available on the client-side plugin.
<b>Signature:</b>
```typescript
exposeToBrowser?: {
[P in keyof T]?: boolean;
};
```

View file

@ -0,0 +1,45 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-server](./kibana-plugin-server.md) &gt; [PluginConfigDescriptor](./kibana-plugin-server.pluginconfigdescriptor.md)
## PluginConfigDescriptor interface
Describes a plugin configuration schema and capabilities.
<b>Signature:</b>
```typescript
export interface PluginConfigDescriptor<T = any>
```
## Properties
| Property | Type | Description |
| --- | --- | --- |
| [exposeToBrowser](./kibana-plugin-server.pluginconfigdescriptor.exposetobrowser.md) | <code>{</code><br/><code> [P in keyof T]?: boolean;</code><br/><code> }</code> | List of configuration properties that will be available on the client-side plugin. |
| [schema](./kibana-plugin-server.pluginconfigdescriptor.schema.md) | <code>PluginConfigSchema&lt;T&gt;</code> | Schema to use to validate the plugin configuration.[PluginConfigSchema](./kibana-plugin-server.pluginconfigschema.md) |
## Example
```typescript
// my_plugin/server/index.ts
import { schema, TypeOf } from '@kbn/config-schema';
import { PluginConfigDescriptor } from 'kibana/server';
const configSchema = schema.object({
secret: schema.string({ defaultValue: 'Only on server' }),
uiProp: schema.string({ defaultValue: 'Accessible from client' }),
});
type ConfigType = TypeOf<typeof configSchema>;
export const config: PluginConfigDescriptor<ConfigType> = {
exposeToBrowser: {
uiProp: true,
},
schema: configSchema,
};
```

View file

@ -0,0 +1,15 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-server](./kibana-plugin-server.md) &gt; [PluginConfigDescriptor](./kibana-plugin-server.pluginconfigdescriptor.md) &gt; [schema](./kibana-plugin-server.pluginconfigdescriptor.schema.md)
## PluginConfigDescriptor.schema property
Schema to use to validate the plugin configuration.
[PluginConfigSchema](./kibana-plugin-server.pluginconfigschema.md)
<b>Signature:</b>
```typescript
schema: PluginConfigSchema<T>;
```

View file

@ -0,0 +1,13 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-server](./kibana-plugin-server.md) &gt; [PluginConfigSchema](./kibana-plugin-server.pluginconfigschema.md)
## PluginConfigSchema type
Dedicated type for plugin configuration schema.
<b>Signature:</b>
```typescript
export declare type PluginConfigSchema<T> = Type<T>;
```

View file

@ -16,5 +16,6 @@ export interface PluginsServiceSetup
| Property | Type | Description |
| --- | --- | --- |
| [contracts](./kibana-plugin-server.pluginsservicesetup.contracts.md) | <code>Map&lt;PluginName, unknown&gt;</code> | |
| [uiPluginConfigs](./kibana-plugin-server.pluginsservicesetup.uipluginconfigs.md) | <code>Map&lt;PluginName, Observable&lt;unknown&gt;&gt;</code> | |
| [uiPlugins](./kibana-plugin-server.pluginsservicesetup.uiplugins.md) | <code>{</code><br/><code> public: Map&lt;PluginName, DiscoveredPlugin&gt;;</code><br/><code> internal: Map&lt;PluginName, DiscoveredPluginInternal&gt;;</code><br/><code> }</code> | |

View file

@ -0,0 +1,11 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-server](./kibana-plugin-server.md) &gt; [PluginsServiceSetup](./kibana-plugin-server.pluginsservicesetup.md) &gt; [uiPluginConfigs](./kibana-plugin-server.pluginsservicesetup.uipluginconfigs.md)
## PluginsServiceSetup.uiPluginConfigs property
<b>Signature:</b>
```typescript
uiPluginConfigs: Map<PluginName, Observable<unknown>>;
```

0
src/core/MIGRATION.md Normal file
View file

View file

@ -22,5 +22,6 @@ export {
InjectedMetadataParams,
InjectedMetadataSetup,
InjectedMetadataStart,
InjectedPluginMetadata,
LegacyNavLink,
} from './injected_metadata_service';

View file

@ -69,7 +69,7 @@ describe('setup.getPlugins()', () => {
const injectedMetadata = new InjectedMetadataService({
injectedMetadata: {
uiPlugins: [
{ id: 'plugin-1', plugin: {} },
{ id: 'plugin-1', plugin: {}, config: { clientProp: 'clientValue' } },
{ id: 'plugin-2', plugin: {} },
],
},
@ -77,7 +77,7 @@ describe('setup.getPlugins()', () => {
const plugins = injectedMetadata.setup().getPlugins();
expect(plugins).toEqual([
{ id: 'plugin-1', plugin: {} },
{ id: 'plugin-1', plugin: {}, config: { clientProp: 'clientValue' } },
{ id: 'plugin-2', plugin: {} },
]);
});

View file

@ -38,6 +38,14 @@ export interface LegacyNavLink {
euiIconType?: string;
}
export interface InjectedPluginMetadata {
id: PluginName;
plugin: DiscoveredPlugin;
config?: {
[key: string]: unknown;
};
}
/** @internal */
export interface InjectedMetadataParams {
injectedMetadata: {
@ -55,10 +63,7 @@ export interface InjectedMetadataParams {
mode: Readonly<EnvironmentMode>;
packageInfo: Readonly<PackageInfo>;
};
uiPlugins: Array<{
id: PluginName;
plugin: DiscoveredPlugin;
}>;
uiPlugins: InjectedPluginMetadata[];
capabilities: Capabilities;
legacyMode: boolean;
legacyMetadata: {
@ -165,10 +170,7 @@ export interface InjectedMetadataSetup {
/**
* An array of frontend plugins in topological order.
*/
getPlugins: () => Array<{
id: string;
plugin: DiscoveredPlugin;
}>;
getPlugins: () => InjectedPluginMetadata[];
/** Indicates whether or not we are rendering a known legacy app. */
getLegacyMode: () => boolean;
getLegacyMetadata: () => {

View file

@ -92,6 +92,9 @@ function pluginInitializerContextMock() {
dist: false,
},
},
config: {
get: <T>() => ({} as T),
},
};
return mock;

View file

@ -18,7 +18,6 @@
*/
import { omit } from 'lodash';
import { DiscoveredPlugin } from '../../server';
import { PluginOpaqueId, PackageInfo, EnvironmentMode } from '../../server/types';
import { CoreContext } from '../core_system';
@ -31,7 +30,7 @@ import { CoreSetup, CoreStart } from '../';
*
* @public
*/
export interface PluginInitializerContext {
export interface PluginInitializerContext<ConfigSchema extends object = object> {
/**
* A symbol used to identify this plugin in the system. Needed when registering handlers or context providers.
*/
@ -40,6 +39,9 @@ export interface PluginInitializerContext {
mode: Readonly<EnvironmentMode>;
packageInfo: Readonly<PackageInfo>;
};
readonly config: {
get: <T extends object = ConfigSchema>() => T;
};
}
/**
@ -47,17 +49,27 @@ export interface PluginInitializerContext {
* empty but should provide static services in the future, such as config and logging.
*
* @param coreContext
* @param pluginManinfest
* @param opaqueId
* @param pluginManifest
* @param pluginConfig
* @internal
*/
export function createPluginInitializerContext(
coreContext: CoreContext,
opaqueId: PluginOpaqueId,
pluginManifest: DiscoveredPlugin
pluginManifest: DiscoveredPlugin,
pluginConfig: {
[key: string]: unknown;
}
): PluginInitializerContext {
return {
opaqueId,
env: coreContext.env,
config: {
get<T>() {
return (pluginConfig as unknown) as T;
},
},
};
}

View file

@ -25,13 +25,14 @@ import {
mockPluginInitializerProvider,
} from './plugins_service.test.mocks';
import { PluginName, DiscoveredPlugin } from 'src/core/server';
import { PluginName } from 'src/core/server';
import { coreMock } from '../mocks';
import {
PluginsService,
PluginsServiceStartDeps,
PluginsServiceSetupDeps,
} from './plugins_service';
import { InjectedPluginMetadata } from '../injected_metadata';
import { notificationServiceMock } from '../notifications/notifications_service.mock';
import { applicationServiceMock } from '../application/application_service.mock';
import { i18nServiceMock } from '../i18n/i18n_service.mock';
@ -41,7 +42,7 @@ import { fatalErrorsServiceMock } from '../fatal_errors/fatal_errors_service.moc
import { uiSettingsServiceMock } from '../ui_settings/ui_settings_service.mock';
import { injectedMetadataServiceMock } from '../injected_metadata/injected_metadata_service.mock';
import { httpServiceMock } from '../http/http_service.mock';
import { CoreSetup, CoreStart } from '..';
import { CoreSetup, CoreStart, PluginInitializerContext } from '..';
import { docLinksServiceMock } from '../doc_links/doc_links_service.mock';
import { savedObjectsMock } from '../saved_objects/saved_objects_service.mock';
import { contextServiceMock } from '../context/context_service.mock';
@ -52,7 +53,7 @@ mockPluginInitializerProvider.mockImplementation(
pluginName => mockPluginInitializers.get(pluginName)!
);
let plugins: Array<{ id: string; plugin: DiscoveredPlugin }>;
let plugins: InjectedPluginMetadata[];
type DeeplyMocked<T> = { [P in keyof T]: jest.Mocked<T[P]> };
@ -62,83 +63,6 @@ let mockSetupContext: DeeplyMocked<CoreSetup>;
let mockStartDeps: DeeplyMocked<PluginsServiceStartDeps>;
let mockStartContext: DeeplyMocked<CoreStart>;
beforeEach(() => {
plugins = [
{ id: 'pluginA', plugin: createManifest('pluginA') },
{ id: 'pluginB', plugin: createManifest('pluginB', { required: ['pluginA'] }) },
{
id: 'pluginC',
plugin: createManifest('pluginC', { required: ['pluginA'], optional: ['nonexist'] }),
},
];
mockSetupDeps = {
application: applicationServiceMock.createInternalSetupContract(),
context: contextServiceMock.createSetupContract(),
fatalErrors: fatalErrorsServiceMock.createSetupContract(),
http: httpServiceMock.createSetupContract(),
injectedMetadata: pick(injectedMetadataServiceMock.createStartContract(), 'getInjectedVar'),
notifications: notificationServiceMock.createSetupContract(),
uiSettings: uiSettingsServiceMock.createSetupContract(),
};
mockSetupContext = {
...mockSetupDeps,
application: expect.any(Object),
};
mockStartDeps = {
application: applicationServiceMock.createInternalStartContract(),
docLinks: docLinksServiceMock.createStartContract(),
http: httpServiceMock.createStartContract(),
chrome: chromeServiceMock.createStartContract(),
i18n: i18nServiceMock.createStartContract(),
injectedMetadata: pick(injectedMetadataServiceMock.createStartContract(), 'getInjectedVar'),
notifications: notificationServiceMock.createStartContract(),
overlays: overlayServiceMock.createStartContract(),
uiSettings: uiSettingsServiceMock.createStartContract(),
savedObjects: savedObjectsMock.createStartContract(),
};
mockStartContext = {
...mockStartDeps,
application: expect.any(Object),
chrome: omit(mockStartDeps.chrome, 'getComponent'),
};
// Reset these for each test.
mockPluginInitializers = new Map<PluginName, MockedPluginInitializer>(([
[
'pluginA',
jest.fn(() => ({
setup: jest.fn(() => ({ setupValue: 1 })),
start: jest.fn(() => ({ startValue: 2 })),
stop: jest.fn(),
})),
],
[
'pluginB',
jest.fn(() => ({
setup: jest.fn((core, deps: any) => ({
pluginAPlusB: deps.pluginA.setupValue + 1,
})),
start: jest.fn((core, deps: any) => ({
pluginAPlusB: deps.pluginA.startValue + 1,
})),
stop: jest.fn(),
})),
],
[
'pluginC',
jest.fn(() => ({
setup: jest.fn(),
start: jest.fn(),
stop: jest.fn(),
})),
],
] as unknown) as [[PluginName, any]]);
});
afterEach(() => {
mockLoadPluginBundle.mockClear();
});
function createManifest(
id: string,
{ required = [], optional = [] }: { required?: string[]; optional?: string[]; ui?: boolean } = {}
@ -152,9 +76,88 @@ function createManifest(
};
}
test('`PluginsService.getOpaqueIds` returns dependency tree of symbols', () => {
const pluginsService = new PluginsService(mockCoreContext, plugins);
expect(pluginsService.getOpaqueIds()).toMatchInlineSnapshot(`
describe('PluginsService', () => {
beforeEach(() => {
plugins = [
{ id: 'pluginA', plugin: createManifest('pluginA') },
{ id: 'pluginB', plugin: createManifest('pluginB', { required: ['pluginA'] }) },
{
id: 'pluginC',
plugin: createManifest('pluginC', { required: ['pluginA'], optional: ['nonexist'] }),
},
];
mockSetupDeps = {
application: applicationServiceMock.createInternalSetupContract(),
context: contextServiceMock.createSetupContract(),
fatalErrors: fatalErrorsServiceMock.createSetupContract(),
http: httpServiceMock.createSetupContract(),
injectedMetadata: pick(injectedMetadataServiceMock.createStartContract(), 'getInjectedVar'),
notifications: notificationServiceMock.createSetupContract(),
uiSettings: uiSettingsServiceMock.createSetupContract(),
};
mockSetupContext = {
...mockSetupDeps,
application: expect.any(Object),
};
mockStartDeps = {
application: applicationServiceMock.createInternalStartContract(),
docLinks: docLinksServiceMock.createStartContract(),
http: httpServiceMock.createStartContract(),
chrome: chromeServiceMock.createStartContract(),
i18n: i18nServiceMock.createStartContract(),
injectedMetadata: pick(injectedMetadataServiceMock.createStartContract(), 'getInjectedVar'),
notifications: notificationServiceMock.createStartContract(),
overlays: overlayServiceMock.createStartContract(),
uiSettings: uiSettingsServiceMock.createStartContract(),
savedObjects: savedObjectsMock.createStartContract(),
};
mockStartContext = {
...mockStartDeps,
application: expect.any(Object),
chrome: omit(mockStartDeps.chrome, 'getComponent'),
};
// Reset these for each test.
mockPluginInitializers = new Map<PluginName, MockedPluginInitializer>(([
[
'pluginA',
jest.fn(() => ({
setup: jest.fn(() => ({ setupValue: 1 })),
start: jest.fn(() => ({ startValue: 2 })),
stop: jest.fn(),
})),
],
[
'pluginB',
jest.fn(() => ({
setup: jest.fn((core, deps: any) => ({
pluginAPlusB: deps.pluginA.setupValue + 1,
})),
start: jest.fn((core, deps: any) => ({
pluginAPlusB: deps.pluginA.startValue + 1,
})),
stop: jest.fn(),
})),
],
[
'pluginC',
jest.fn(() => ({
setup: jest.fn(),
start: jest.fn(),
stop: jest.fn(),
})),
],
] as unknown) as [[PluginName, any]]);
});
afterEach(() => {
mockLoadPluginBundle.mockClear();
});
describe('#getOpaqueIds()', () => {
it('returns dependency tree of symbols', () => {
const pluginsService = new PluginsService(mockCoreContext, plugins);
expect(pluginsService.getOpaqueIds()).toMatchInlineSnapshot(`
Map {
Symbol(pluginA) => Array [],
Symbol(pluginB) => Array [
@ -165,152 +168,184 @@ test('`PluginsService.getOpaqueIds` returns dependency tree of symbols', () => {
],
}
`);
});
test('`PluginsService.setup` fails if any bundle cannot be loaded', async () => {
mockLoadPluginBundle.mockRejectedValueOnce(new Error('Could not load bundle'));
const pluginsService = new PluginsService(mockCoreContext, plugins);
await expect(pluginsService.setup(mockSetupDeps)).rejects.toThrowErrorMatchingInlineSnapshot(
`"Could not load bundle"`
);
});
test('`PluginsService.setup` fails if any plugin instance does not have a setup function', async () => {
mockPluginInitializers.set('pluginA', (() => ({})) as any);
const pluginsService = new PluginsService(mockCoreContext, plugins);
await expect(pluginsService.setup(mockSetupDeps)).rejects.toThrowErrorMatchingInlineSnapshot(
`"Instance of plugin \\"pluginA\\" does not define \\"setup\\" function."`
);
});
test('`PluginsService.setup` calls loadPluginBundles with http and plugins', async () => {
const pluginsService = new PluginsService(mockCoreContext, plugins);
await pluginsService.setup(mockSetupDeps);
expect(mockLoadPluginBundle).toHaveBeenCalledTimes(3);
expect(mockLoadPluginBundle).toHaveBeenCalledWith(mockSetupDeps.http.basePath.prepend, 'pluginA');
expect(mockLoadPluginBundle).toHaveBeenCalledWith(mockSetupDeps.http.basePath.prepend, 'pluginB');
expect(mockLoadPluginBundle).toHaveBeenCalledWith(mockSetupDeps.http.basePath.prepend, 'pluginC');
});
test('`PluginsService.setup` initalizes plugins with PluginIntitializerContext', async () => {
const pluginsService = new PluginsService(mockCoreContext, plugins);
await pluginsService.setup(mockSetupDeps);
expect(mockPluginInitializers.get('pluginA')).toHaveBeenCalledWith(expect.any(Object));
expect(mockPluginInitializers.get('pluginB')).toHaveBeenCalledWith(expect.any(Object));
expect(mockPluginInitializers.get('pluginC')).toHaveBeenCalledWith(expect.any(Object));
});
test('`PluginsService.setup` exposes dependent setup contracts to plugins', async () => {
const pluginsService = new PluginsService(mockCoreContext, plugins);
await pluginsService.setup(mockSetupDeps);
const pluginAInstance = mockPluginInitializers.get('pluginA')!.mock.results[0].value;
const pluginBInstance = mockPluginInitializers.get('pluginB')!.mock.results[0].value;
const pluginCInstance = mockPluginInitializers.get('pluginC')!.mock.results[0].value;
expect(pluginAInstance.setup).toHaveBeenCalledWith(mockSetupContext, {});
expect(pluginBInstance.setup).toHaveBeenCalledWith(mockSetupContext, {
pluginA: { setupValue: 1 },
});
});
// Does not supply value for `nonexist` optional dep
expect(pluginCInstance.setup).toHaveBeenCalledWith(mockSetupContext, {
pluginA: { setupValue: 1 },
describe('#setup()', () => {
it('fails if any bundle cannot be loaded', async () => {
mockLoadPluginBundle.mockRejectedValueOnce(new Error('Could not load bundle'));
const pluginsService = new PluginsService(mockCoreContext, plugins);
await expect(pluginsService.setup(mockSetupDeps)).rejects.toThrowErrorMatchingInlineSnapshot(
`"Could not load bundle"`
);
});
it('fails if any plugin instance does not have a setup function', async () => {
mockPluginInitializers.set('pluginA', (() => ({})) as any);
const pluginsService = new PluginsService(mockCoreContext, plugins);
await expect(pluginsService.setup(mockSetupDeps)).rejects.toThrowErrorMatchingInlineSnapshot(
`"Instance of plugin \\"pluginA\\" does not define \\"setup\\" function."`
);
});
it('calls loadPluginBundles with http and plugins', async () => {
const pluginsService = new PluginsService(mockCoreContext, plugins);
await pluginsService.setup(mockSetupDeps);
expect(mockLoadPluginBundle).toHaveBeenCalledTimes(3);
expect(mockLoadPluginBundle).toHaveBeenCalledWith(
mockSetupDeps.http.basePath.prepend,
'pluginA'
);
expect(mockLoadPluginBundle).toHaveBeenCalledWith(
mockSetupDeps.http.basePath.prepend,
'pluginB'
);
expect(mockLoadPluginBundle).toHaveBeenCalledWith(
mockSetupDeps.http.basePath.prepend,
'pluginC'
);
});
it('initializes plugins with PluginInitializerContext', async () => {
const pluginsService = new PluginsService(mockCoreContext, plugins);
await pluginsService.setup(mockSetupDeps);
expect(mockPluginInitializers.get('pluginA')).toHaveBeenCalledWith(expect.any(Object));
expect(mockPluginInitializers.get('pluginB')).toHaveBeenCalledWith(expect.any(Object));
expect(mockPluginInitializers.get('pluginC')).toHaveBeenCalledWith(expect.any(Object));
});
it('initializes plugins with associated client configuration', async () => {
const pluginConfig = {
clientProperty: 'some value',
};
plugins[0].config = pluginConfig;
const pluginsService = new PluginsService(mockCoreContext, plugins);
await pluginsService.setup(mockSetupDeps);
const initializerContext = mockPluginInitializers.get('pluginA')!.mock
.calls[0][0] as PluginInitializerContext;
const config = initializerContext.config.get();
expect(config).toMatchObject(pluginConfig);
});
it('exposes dependent setup contracts to plugins', async () => {
const pluginsService = new PluginsService(mockCoreContext, plugins);
await pluginsService.setup(mockSetupDeps);
const pluginAInstance = mockPluginInitializers.get('pluginA')!.mock.results[0].value;
const pluginBInstance = mockPluginInitializers.get('pluginB')!.mock.results[0].value;
const pluginCInstance = mockPluginInitializers.get('pluginC')!.mock.results[0].value;
expect(pluginAInstance.setup).toHaveBeenCalledWith(mockSetupContext, {});
expect(pluginBInstance.setup).toHaveBeenCalledWith(mockSetupContext, {
pluginA: { setupValue: 1 },
});
// Does not supply value for `nonexist` optional dep
expect(pluginCInstance.setup).toHaveBeenCalledWith(mockSetupContext, {
pluginA: { setupValue: 1 },
});
});
it('does not set missing dependent setup contracts', async () => {
plugins = [{ id: 'pluginD', plugin: createManifest('pluginD', { optional: ['missing'] }) }];
mockPluginInitializers.set(
'pluginD',
jest.fn(() => ({
setup: jest.fn(),
start: jest.fn(),
})) as any
);
const pluginsService = new PluginsService(mockCoreContext, plugins);
await pluginsService.setup(mockSetupDeps);
// If a dependency is missing it should not be in the deps at all, not even as undefined.
const pluginDInstance = mockPluginInitializers.get('pluginD')!.mock.results[0].value;
expect(pluginDInstance.setup).toHaveBeenCalledWith(mockSetupContext, {});
const pluginDDeps = pluginDInstance.setup.mock.calls[0][1];
expect(pluginDDeps).not.toHaveProperty('missing');
});
it('returns plugin setup contracts', async () => {
const pluginsService = new PluginsService(mockCoreContext, plugins);
const { contracts } = await pluginsService.setup(mockSetupDeps);
// Verify that plugin contracts were available
expect((contracts.get('pluginA')! as any).setupValue).toEqual(1);
expect((contracts.get('pluginB')! as any).pluginAPlusB).toEqual(2);
});
});
describe('#start()', () => {
it('exposes dependent start contracts to plugins', async () => {
const pluginsService = new PluginsService(mockCoreContext, plugins);
await pluginsService.setup(mockSetupDeps);
await pluginsService.start(mockStartDeps);
const pluginAInstance = mockPluginInitializers.get('pluginA')!.mock.results[0].value;
const pluginBInstance = mockPluginInitializers.get('pluginB')!.mock.results[0].value;
const pluginCInstance = mockPluginInitializers.get('pluginC')!.mock.results[0].value;
expect(pluginAInstance.start).toHaveBeenCalledWith(mockStartContext, {});
expect(pluginBInstance.start).toHaveBeenCalledWith(mockStartContext, {
pluginA: { startValue: 2 },
});
// Does not supply value for `nonexist` optional dep
expect(pluginCInstance.start).toHaveBeenCalledWith(mockStartContext, {
pluginA: { startValue: 2 },
});
});
it('does not set missing dependent start contracts', async () => {
plugins = [{ id: 'pluginD', plugin: createManifest('pluginD', { optional: ['missing'] }) }];
mockPluginInitializers.set(
'pluginD',
jest.fn(() => ({
setup: jest.fn(),
start: jest.fn(),
})) as any
);
const pluginsService = new PluginsService(mockCoreContext, plugins);
await pluginsService.setup(mockSetupDeps);
await pluginsService.start(mockStartDeps);
// If a dependency is missing it should not be in the deps at all, not even as undefined.
const pluginDInstance = mockPluginInitializers.get('pluginD')!.mock.results[0].value;
expect(pluginDInstance.start).toHaveBeenCalledWith(mockStartContext, {});
const pluginDDeps = pluginDInstance.start.mock.calls[0][1];
expect(pluginDDeps).not.toHaveProperty('missing');
});
it('returns plugin start contracts', async () => {
const pluginsService = new PluginsService(mockCoreContext, plugins);
await pluginsService.setup(mockSetupDeps);
const { contracts } = await pluginsService.start(mockStartDeps);
// Verify that plugin contracts were available
expect((contracts.get('pluginA')! as any).startValue).toEqual(2);
expect((contracts.get('pluginB')! as any).pluginAPlusB).toEqual(3);
});
});
describe('#stop()', () => {
it('calls the stop function on each plugin', async () => {
const pluginsService = new PluginsService(mockCoreContext, plugins);
await pluginsService.setup(mockSetupDeps);
const pluginAInstance = mockPluginInitializers.get('pluginA')!.mock.results[0].value;
const pluginBInstance = mockPluginInitializers.get('pluginB')!.mock.results[0].value;
const pluginCInstance = mockPluginInitializers.get('pluginC')!.mock.results[0].value;
await pluginsService.stop();
expect(pluginAInstance.stop).toHaveBeenCalled();
expect(pluginBInstance.stop).toHaveBeenCalled();
expect(pluginCInstance.stop).toHaveBeenCalled();
});
});
});
test('`PluginsService.setup` does not set missing dependent setup contracts', async () => {
plugins = [{ id: 'pluginD', plugin: createManifest('pluginD', { optional: ['missing'] }) }];
mockPluginInitializers.set(
'pluginD',
jest.fn(() => ({
setup: jest.fn(),
start: jest.fn(),
})) as any
);
const pluginsService = new PluginsService(mockCoreContext, plugins);
await pluginsService.setup(mockSetupDeps);
// If a dependency is missing it should not be in the deps at all, not even as undefined.
const pluginDInstance = mockPluginInitializers.get('pluginD')!.mock.results[0].value;
expect(pluginDInstance.setup).toHaveBeenCalledWith(mockSetupContext, {});
const pluginDDeps = pluginDInstance.setup.mock.calls[0][1];
expect(pluginDDeps).not.toHaveProperty('missing');
});
test('`PluginsService.setup` returns plugin setup contracts', async () => {
const pluginsService = new PluginsService(mockCoreContext, plugins);
const { contracts } = await pluginsService.setup(mockSetupDeps);
// Verify that plugin contracts were available
expect((contracts.get('pluginA')! as any).setupValue).toEqual(1);
expect((contracts.get('pluginB')! as any).pluginAPlusB).toEqual(2);
});
test('`PluginsService.start` exposes dependent start contracts to plugins', async () => {
const pluginsService = new PluginsService(mockCoreContext, plugins);
await pluginsService.setup(mockSetupDeps);
await pluginsService.start(mockStartDeps);
const pluginAInstance = mockPluginInitializers.get('pluginA')!.mock.results[0].value;
const pluginBInstance = mockPluginInitializers.get('pluginB')!.mock.results[0].value;
const pluginCInstance = mockPluginInitializers.get('pluginC')!.mock.results[0].value;
expect(pluginAInstance.start).toHaveBeenCalledWith(mockStartContext, {});
expect(pluginBInstance.start).toHaveBeenCalledWith(mockStartContext, {
pluginA: { startValue: 2 },
});
// Does not supply value for `nonexist` optional dep
expect(pluginCInstance.start).toHaveBeenCalledWith(mockStartContext, {
pluginA: { startValue: 2 },
});
});
test('`PluginsService.start` does not set missing dependent start contracts', async () => {
plugins = [{ id: 'pluginD', plugin: createManifest('pluginD', { optional: ['missing'] }) }];
mockPluginInitializers.set(
'pluginD',
jest.fn(() => ({
setup: jest.fn(),
start: jest.fn(),
})) as any
);
const pluginsService = new PluginsService(mockCoreContext, plugins);
await pluginsService.setup(mockSetupDeps);
await pluginsService.start(mockStartDeps);
// If a dependency is missing it should not be in the deps at all, not even as undefined.
const pluginDInstance = mockPluginInitializers.get('pluginD')!.mock.results[0].value;
expect(pluginDInstance.start).toHaveBeenCalledWith(mockStartContext, {});
const pluginDDeps = pluginDInstance.start.mock.calls[0][1];
expect(pluginDDeps).not.toHaveProperty('missing');
});
test('`PluginsService.start` returns plugin start contracts', async () => {
const pluginsService = new PluginsService(mockCoreContext, plugins);
await pluginsService.setup(mockSetupDeps);
const { contracts } = await pluginsService.start(mockStartDeps);
// Verify that plugin contracts were available
expect((contracts.get('pluginA')! as any).startValue).toEqual(2);
expect((contracts.get('pluginB')! as any).pluginAPlusB).toEqual(3);
});
test('`PluginService.stop` calls the stop function on each plugin', async () => {
const pluginsService = new PluginsService(mockCoreContext, plugins);
await pluginsService.setup(mockSetupDeps);
const pluginAInstance = mockPluginInitializers.get('pluginA')!.mock.results[0].value;
const pluginBInstance = mockPluginInitializers.get('pluginB')!.mock.results[0].value;
const pluginCInstance = mockPluginInitializers.get('pluginC')!.mock.results[0].value;
await pluginsService.stop();
expect(pluginAInstance.stop).toHaveBeenCalled();
expect(pluginBInstance.stop).toHaveBeenCalled();
expect(pluginCInstance.stop).toHaveBeenCalled();
});

View file

@ -17,7 +17,7 @@
* under the License.
*/
import { DiscoveredPlugin, PluginName, PluginOpaqueId } from '../../server';
import { PluginName, PluginOpaqueId } from '../../server';
import { CoreService } from '../../types';
import { CoreContext } from '../core_system';
import { PluginWrapper } from './plugin';
@ -27,6 +27,7 @@ import {
createPluginStartContext,
} from './plugin_context';
import { InternalCoreSetup, InternalCoreStart } from '../core_system';
import { InjectedPluginMetadata } from '../injected_metadata';
/** @internal */
export type PluginsServiceSetupDeps = InternalCoreSetup;
@ -55,15 +56,12 @@ export class PluginsService implements CoreService<PluginsServiceSetup, PluginsS
private readonly satupPlugins: PluginName[] = [];
constructor(
private readonly coreContext: CoreContext,
plugins: Array<{ id: PluginName; plugin: DiscoveredPlugin }>
) {
constructor(private readonly coreContext: CoreContext, plugins: InjectedPluginMetadata[]) {
// Generate opaque ids
const opaqueIds = new Map<PluginName, PluginOpaqueId>(plugins.map(p => [p.id, Symbol(p.id)]));
// Setup dependency map and plugin wrappers
plugins.forEach(({ id, plugin }) => {
plugins.forEach(({ id, plugin, config = {} }) => {
// Setup map of dependencies
this.pluginDependencies.set(id, [
...plugin.requiredPlugins,
@ -76,7 +74,7 @@ export class PluginsService implements CoreService<PluginsServiceSetup, PluginsS
new PluginWrapper(
plugin,
opaqueIds.get(id)!,
createPluginInitializerContext(this.coreContext, opaqueIds.get(id)!, plugin)
createPluginInitializerContext(this.coreContext, opaqueIds.get(id)!, plugin, config)
)
);
});

View file

@ -695,7 +695,11 @@ export interface Plugin<TSetup = void, TStart = void, TPluginsSetup extends obje
export type PluginInitializer<TSetup, TStart, TPluginsSetup extends object = object, TPluginsStart extends object = object> = (core: PluginInitializerContext) => Plugin<TSetup, TStart, TPluginsSetup, TPluginsStart>;
// @public
export interface PluginInitializerContext {
export interface PluginInitializerContext<ConfigSchema extends object = object> {
// (undocumented)
readonly config: {
get: <T extends object = ConfigSchema>() => T;
};
// (undocumented)
readonly env: {
mode: Readonly<EnvironmentMode>;

View file

@ -123,6 +123,8 @@ export { Logger, LoggerFactory, LogMeta, LogRecord, LogLevel } from './logging';
export {
DiscoveredPlugin,
Plugin,
PluginConfigDescriptor,
PluginConfigSchema,
PluginInitializer,
PluginInitializerContext,
PluginManifest,

View file

@ -86,6 +86,7 @@ beforeEach(() => {
public: new Map([['plugin-id', {} as DiscoveredPlugin]]),
internal: new Map([['plugin-id', {} as DiscoveredPluginInternal]]),
},
uiPluginConfigs: new Map(),
},
},
plugins: { 'plugin-id': 'plugin-value' },

View file

@ -278,6 +278,7 @@ export class LegacyService implements CoreService<LegacyServiceSetup> {
hapiServer: setupDeps.core.http.server,
kibanaMigrator: startDeps.core.savedObjects.migrator,
uiPlugins: setupDeps.core.plugins.uiPlugins,
uiPluginConfigs: setupDeps.core.plugins.uiPluginConfigs,
elasticsearch: setupDeps.core.elasticsearch,
uiSettings: setupDeps.core.uiSettings,
savedObjectsClientProvider: startDeps.core.savedObjects.clientProvider,

View file

@ -291,12 +291,13 @@ test('`stop` calls `stop` defined by the plugin instance', async () => {
describe('#getConfigSchema()', () => {
it('reads config schema from plugin', () => {
const pluginSchema = schema.any();
const configDescriptor = {
schema: pluginSchema,
};
jest.doMock(
'plugin-with-schema/server',
() => ({
config: {
schema: pluginSchema,
},
config: configDescriptor,
}),
{ virtual: true }
);
@ -309,7 +310,7 @@ describe('#getConfigSchema()', () => {
initializerContext: createPluginInitializerContext(coreContext, opaqueId, manifest),
});
expect(plugin.getConfigSchema()).toBe(pluginSchema);
expect(plugin.getConfigDescriptor()).toBe(configDescriptor);
});
it('returns null if config definition not specified', () => {
@ -322,7 +323,7 @@ describe('#getConfigSchema()', () => {
opaqueId,
initializerContext: createPluginInitializerContext(coreContext, opaqueId, manifest),
});
expect(plugin.getConfigSchema()).toBe(null);
expect(plugin.getConfigDescriptor()).toBe(null);
});
it('returns null for plugins without a server part', () => {
@ -334,7 +335,7 @@ describe('#getConfigSchema()', () => {
opaqueId,
initializerContext: createPluginInitializerContext(coreContext, opaqueId, manifest),
});
expect(plugin.getConfigSchema()).toBe(null);
expect(plugin.getConfigDescriptor()).toBe(null);
});
it('throws if plugin contains invalid schema', () => {
@ -357,7 +358,7 @@ describe('#getConfigSchema()', () => {
opaqueId,
initializerContext: createPluginInitializerContext(coreContext, opaqueId, manifest),
});
expect(() => plugin.getConfigSchema()).toThrowErrorMatchingInlineSnapshot(
expect(() => plugin.getConfigDescriptor()).toThrowErrorMatchingInlineSnapshot(
`"Configuration schema expected to be an instance of Type"`
);
});

View file

@ -27,9 +27,9 @@ import {
Plugin,
PluginInitializerContext,
PluginManifest,
PluginConfigSchema,
PluginInitializer,
PluginOpaqueId,
PluginConfigDescriptor,
} from './types';
import { CoreSetup, CoreStart } from '..';
@ -128,7 +128,7 @@ export class PluginWrapper<
this.instance = undefined;
}
public getConfigSchema(): PluginConfigSchema {
public getConfigDescriptor(): PluginConfigDescriptor | null {
if (!this.manifest.server) {
return null;
}
@ -141,10 +141,11 @@ export class PluginWrapper<
return null;
}
if (!(pluginDefinition.config.schema instanceof Type)) {
const configDescriptor = pluginDefinition.config;
if (!(configDescriptor.schema instanceof Type)) {
throw new Error('Configuration schema expected to be an instance of Type');
}
return pluginDefinition.config.schema;
return configDescriptor;
}
private createPluginInstance() {

View file

@ -33,6 +33,7 @@ const createServiceMock = () => {
public: new Map(),
internal: new Map(),
},
uiPluginConfigs: new Map(),
});
mocked.start.mockResolvedValue({ contracts: new Map() });
return mocked;

View file

@ -32,6 +32,8 @@ import { PluginWrapper } from './plugin';
import { PluginsService } from './plugins_service';
import { PluginsSystem } from './plugins_system';
import { config } from './plugins_config';
import { take } from 'rxjs/operators';
import { DiscoveredPluginInternal } from './types';
const MockPluginsSystem: jest.Mock<PluginsSystem> = PluginsSystem as any;
@ -90,301 +92,398 @@ const createPlugin = (
});
};
beforeEach(async () => {
mockPackage.raw = {
branch: 'feature-v1',
version: 'v1',
build: {
distributable: true,
number: 100,
sha: 'feature-v1-build-sha',
},
};
coreId = Symbol('core');
env = Env.createDefault(getEnvOptions());
configService = new ConfigService(
new BehaviorSubject<Config>(new ObjectToConfigAdapter({ plugins: { initialize: true } })),
env,
logger
);
await configService.setSchema(config.path, config.schema);
pluginsService = new PluginsService({ coreId, env, logger, configService });
[mockPluginSystem] = MockPluginsSystem.mock.instances as any;
});
afterEach(() => {
jest.clearAllMocks();
});
test('`discover` throws if plugin has an invalid manifest', async () => {
mockDiscover.mockReturnValue({
error$: from([PluginDiscoveryError.invalidManifest('path-1', new Error('Invalid JSON'))]),
plugin$: from([]),
});
await expect(pluginsService.discover()).rejects.toMatchInlineSnapshot(`
[Error: Failed to initialize plugins:
Invalid JSON (invalid-manifest, path-1)]
`);
expect(loggingServiceMock.collect(logger).error).toMatchInlineSnapshot(`
Array [
Array [
[Error: Invalid JSON (invalid-manifest, path-1)],
],
]
`);
});
test('`discover` throws if plugin required Kibana version is incompatible with the current version', async () => {
mockDiscover.mockReturnValue({
error$: from([
PluginDiscoveryError.incompatibleVersion('path-3', new Error('Incompatible version')),
]),
plugin$: from([]),
});
await expect(pluginsService.discover()).rejects.toMatchInlineSnapshot(`
[Error: Failed to initialize plugins:
Incompatible version (incompatible-version, path-3)]
`);
expect(loggingServiceMock.collect(logger).error).toMatchInlineSnapshot(`
Array [
Array [
[Error: Incompatible version (incompatible-version, path-3)],
],
]
`);
});
test('`discover` throws if discovered plugins with conflicting names', async () => {
mockDiscover.mockReturnValue({
error$: from([]),
plugin$: from([
createPlugin('conflicting-id', {
path: 'path-4',
version: 'some-version',
configPath: 'path',
requiredPlugins: ['some-required-plugin', 'some-required-plugin-2'],
optionalPlugins: ['some-optional-plugin'],
}),
createPlugin('conflicting-id', {
path: 'path-4',
version: 'some-version',
configPath: 'path',
requiredPlugins: ['some-required-plugin', 'some-required-plugin-2'],
optionalPlugins: ['some-optional-plugin'],
}),
]),
});
await expect(pluginsService.discover()).rejects.toMatchInlineSnapshot(
`[Error: Plugin with id "conflicting-id" is already registered!]`
);
expect(mockPluginSystem.addPlugin).not.toHaveBeenCalled();
expect(mockPluginSystem.setupPlugins).not.toHaveBeenCalled();
});
test('`discover` properly detects plugins that should be disabled.', async () => {
jest
.spyOn(configService, 'isEnabledAtPath')
.mockImplementation(path => Promise.resolve(!path.includes('disabled')));
mockPluginSystem.setupPlugins.mockResolvedValue(new Map());
mockPluginSystem.uiPlugins.mockReturnValue({ public: new Map(), internal: new Map() });
mockDiscover.mockReturnValue({
error$: from([]),
plugin$: from([
createPlugin('explicitly-disabled-plugin', {
disabled: true,
path: 'path-1',
configPath: 'path-1',
}),
createPlugin('plugin-with-missing-required-deps', {
path: 'path-2',
configPath: 'path-2',
requiredPlugins: ['missing-plugin'],
}),
createPlugin('plugin-with-disabled-transitive-dep', {
path: 'path-3',
configPath: 'path-3',
requiredPlugins: ['another-explicitly-disabled-plugin'],
}),
createPlugin('another-explicitly-disabled-plugin', {
disabled: true,
path: 'path-4',
configPath: 'path-4-disabled',
}),
]),
});
await pluginsService.discover();
const setup = await pluginsService.setup(setupDeps);
expect(setup.contracts).toBeInstanceOf(Map);
expect(setup.uiPlugins.public).toBeInstanceOf(Map);
expect(setup.uiPlugins.internal).toBeInstanceOf(Map);
expect(mockPluginSystem.addPlugin).not.toHaveBeenCalled();
expect(mockPluginSystem.setupPlugins).toHaveBeenCalledTimes(1);
expect(mockPluginSystem.setupPlugins).toHaveBeenCalledWith(setupDeps);
expect(loggingServiceMock.collect(logger).info).toMatchInlineSnapshot(`
Array [
Array [
"Plugin \\"explicitly-disabled-plugin\\" is disabled.",
],
Array [
"Plugin \\"plugin-with-missing-required-deps\\" has been disabled since some of its direct or transitive dependencies are missing or disabled.",
],
Array [
"Plugin \\"plugin-with-disabled-transitive-dep\\" has been disabled since some of its direct or transitive dependencies are missing or disabled.",
],
Array [
"Plugin \\"another-explicitly-disabled-plugin\\" is disabled.",
],
]
`);
});
test('`discover` does not throw in case of mutual plugin dependencies', async () => {
const firstPlugin = createPlugin('first-plugin', {
path: 'path-1',
requiredPlugins: ['second-plugin'],
});
const secondPlugin = createPlugin('second-plugin', {
path: 'path-2',
requiredPlugins: ['first-plugin'],
});
mockDiscover.mockReturnValue({
error$: from([]),
plugin$: from([firstPlugin, secondPlugin]),
});
await expect(pluginsService.discover()).resolves.toBeUndefined();
expect(mockDiscover).toHaveBeenCalledTimes(1);
expect(mockPluginSystem.addPlugin).toHaveBeenCalledTimes(2);
expect(mockPluginSystem.addPlugin).toHaveBeenCalledWith(firstPlugin);
expect(mockPluginSystem.addPlugin).toHaveBeenCalledWith(secondPlugin);
});
test('`discover` does not throw in case of cyclic plugin dependencies', async () => {
const firstPlugin = createPlugin('first-plugin', {
path: 'path-1',
requiredPlugins: ['second-plugin'],
});
const secondPlugin = createPlugin('second-plugin', {
path: 'path-2',
requiredPlugins: ['third-plugin', 'last-plugin'],
});
const thirdPlugin = createPlugin('third-plugin', {
path: 'path-3',
requiredPlugins: ['last-plugin', 'first-plugin'],
});
const lastPlugin = createPlugin('last-plugin', {
path: 'path-4',
requiredPlugins: ['first-plugin'],
});
const missingDepsPlugin = createPlugin('missing-deps-plugin', {
path: 'path-5',
requiredPlugins: ['not-a-plugin'],
});
mockDiscover.mockReturnValue({
error$: from([]),
plugin$: from([firstPlugin, secondPlugin, thirdPlugin, lastPlugin, missingDepsPlugin]),
});
await expect(pluginsService.discover()).resolves.toBeUndefined();
expect(mockDiscover).toHaveBeenCalledTimes(1);
expect(mockPluginSystem.addPlugin).toHaveBeenCalledTimes(4);
expect(mockPluginSystem.addPlugin).toHaveBeenCalledWith(firstPlugin);
expect(mockPluginSystem.addPlugin).toHaveBeenCalledWith(secondPlugin);
expect(mockPluginSystem.addPlugin).toHaveBeenCalledWith(thirdPlugin);
expect(mockPluginSystem.addPlugin).toHaveBeenCalledWith(lastPlugin);
});
test('`discover` properly invokes plugin discovery and ignores non-critical errors.', async () => {
const firstPlugin = createPlugin('some-id', {
path: 'path-1',
configPath: 'path',
requiredPlugins: ['some-other-id'],
optionalPlugins: ['missing-optional-dep'],
});
const secondPlugin = createPlugin('some-other-id', {
path: 'path-2',
version: 'some-other-version',
configPath: ['plugin', 'path'],
});
mockDiscover.mockReturnValue({
error$: from([
PluginDiscoveryError.missingManifest('path-2', new Error('No manifest')),
PluginDiscoveryError.invalidSearchPath('dir-1', new Error('No dir')),
PluginDiscoveryError.invalidPluginPath('path4-1', new Error('No path')),
]),
plugin$: from([firstPlugin, secondPlugin]),
});
await pluginsService.discover();
expect(mockPluginSystem.addPlugin).toHaveBeenCalledTimes(2);
expect(mockPluginSystem.addPlugin).toHaveBeenCalledWith(firstPlugin);
expect(mockPluginSystem.addPlugin).toHaveBeenCalledWith(secondPlugin);
expect(mockDiscover).toHaveBeenCalledTimes(1);
expect(mockDiscover).toHaveBeenCalledWith(
{
additionalPluginPaths: [],
initialize: true,
pluginSearchPaths: [
resolve(process.cwd(), 'src', 'plugins'),
resolve(process.cwd(), 'x-pack', 'plugins'),
resolve(process.cwd(), 'plugins'),
resolve(process.cwd(), '..', 'kibana-extra'),
],
},
{ coreId, env, logger, configService }
);
const logs = loggingServiceMock.collect(logger);
expect(logs.info).toHaveLength(0);
expect(logs.error).toHaveLength(0);
});
test('`stop` stops plugins system', async () => {
await pluginsService.stop();
expect(mockPluginSystem.stopPlugins).toHaveBeenCalledTimes(1);
});
test('`discover` registers plugin config schema in config service', async () => {
const configSchema = schema.string();
jest.spyOn(configService, 'setSchema').mockImplementation(() => Promise.resolve());
jest.doMock(
join('path-with-schema', 'server'),
() => ({
config: {
schema: configSchema,
describe('PluginsService', () => {
beforeEach(async () => {
mockPackage.raw = {
branch: 'feature-v1',
version: 'v1',
build: {
distributable: true,
number: 100,
sha: 'feature-v1-build-sha',
},
}),
{
virtual: true,
}
);
mockDiscover.mockReturnValue({
error$: from([]),
plugin$: from([
createPlugin('some-id', {
path: 'path-with-schema',
configPath: 'path',
}),
]),
};
coreId = Symbol('core');
env = Env.createDefault(getEnvOptions());
configService = new ConfigService(
new BehaviorSubject<Config>(new ObjectToConfigAdapter({ plugins: { initialize: true } })),
env,
logger
);
await configService.setSchema(config.path, config.schema);
pluginsService = new PluginsService({ coreId, env, logger, configService });
[mockPluginSystem] = MockPluginsSystem.mock.instances as any;
});
afterEach(() => {
jest.clearAllMocks();
});
describe('#discover()', () => {
it('throws if plugin has an invalid manifest', async () => {
mockDiscover.mockReturnValue({
error$: from([PluginDiscoveryError.invalidManifest('path-1', new Error('Invalid JSON'))]),
plugin$: from([]),
});
await expect(pluginsService.discover()).rejects.toMatchInlineSnapshot(`
[Error: Failed to initialize plugins:
Invalid JSON (invalid-manifest, path-1)]
`);
expect(loggingServiceMock.collect(logger).error).toMatchInlineSnapshot(`
Array [
Array [
[Error: Invalid JSON (invalid-manifest, path-1)],
],
]
`);
});
it('throws if plugin required Kibana version is incompatible with the current version', async () => {
mockDiscover.mockReturnValue({
error$: from([
PluginDiscoveryError.incompatibleVersion('path-3', new Error('Incompatible version')),
]),
plugin$: from([]),
});
await expect(pluginsService.discover()).rejects.toMatchInlineSnapshot(`
[Error: Failed to initialize plugins:
Incompatible version (incompatible-version, path-3)]
`);
expect(loggingServiceMock.collect(logger).error).toMatchInlineSnapshot(`
Array [
Array [
[Error: Incompatible version (incompatible-version, path-3)],
],
]
`);
});
it('throws if discovered plugins with conflicting names', async () => {
mockDiscover.mockReturnValue({
error$: from([]),
plugin$: from([
createPlugin('conflicting-id', {
path: 'path-4',
version: 'some-version',
configPath: 'path',
requiredPlugins: ['some-required-plugin', 'some-required-plugin-2'],
optionalPlugins: ['some-optional-plugin'],
}),
createPlugin('conflicting-id', {
path: 'path-4',
version: 'some-version',
configPath: 'path',
requiredPlugins: ['some-required-plugin', 'some-required-plugin-2'],
optionalPlugins: ['some-optional-plugin'],
}),
]),
});
await expect(pluginsService.discover()).rejects.toMatchInlineSnapshot(
`[Error: Plugin with id "conflicting-id" is already registered!]`
);
expect(mockPluginSystem.addPlugin).not.toHaveBeenCalled();
expect(mockPluginSystem.setupPlugins).not.toHaveBeenCalled();
});
it('properly detects plugins that should be disabled.', async () => {
jest
.spyOn(configService, 'isEnabledAtPath')
.mockImplementation(path => Promise.resolve(!path.includes('disabled')));
mockPluginSystem.setupPlugins.mockResolvedValue(new Map());
mockPluginSystem.uiPlugins.mockReturnValue({ public: new Map(), internal: new Map() });
mockDiscover.mockReturnValue({
error$: from([]),
plugin$: from([
createPlugin('explicitly-disabled-plugin', {
disabled: true,
path: 'path-1',
configPath: 'path-1',
}),
createPlugin('plugin-with-missing-required-deps', {
path: 'path-2',
configPath: 'path-2',
requiredPlugins: ['missing-plugin'],
}),
createPlugin('plugin-with-disabled-transitive-dep', {
path: 'path-3',
configPath: 'path-3',
requiredPlugins: ['another-explicitly-disabled-plugin'],
}),
createPlugin('another-explicitly-disabled-plugin', {
disabled: true,
path: 'path-4',
configPath: 'path-4-disabled',
}),
]),
});
await pluginsService.discover();
const setup = await pluginsService.setup(setupDeps);
expect(setup.contracts).toBeInstanceOf(Map);
expect(setup.uiPlugins.public).toBeInstanceOf(Map);
expect(setup.uiPlugins.internal).toBeInstanceOf(Map);
expect(mockPluginSystem.addPlugin).not.toHaveBeenCalled();
expect(mockPluginSystem.setupPlugins).toHaveBeenCalledTimes(1);
expect(mockPluginSystem.setupPlugins).toHaveBeenCalledWith(setupDeps);
expect(loggingServiceMock.collect(logger).info).toMatchInlineSnapshot(`
Array [
Array [
"Plugin \\"explicitly-disabled-plugin\\" is disabled.",
],
Array [
"Plugin \\"plugin-with-missing-required-deps\\" has been disabled since some of its direct or transitive dependencies are missing or disabled.",
],
Array [
"Plugin \\"plugin-with-disabled-transitive-dep\\" has been disabled since some of its direct or transitive dependencies are missing or disabled.",
],
Array [
"Plugin \\"another-explicitly-disabled-plugin\\" is disabled.",
],
]
`);
});
it('does not throw in case of mutual plugin dependencies', async () => {
const firstPlugin = createPlugin('first-plugin', {
path: 'path-1',
requiredPlugins: ['second-plugin'],
});
const secondPlugin = createPlugin('second-plugin', {
path: 'path-2',
requiredPlugins: ['first-plugin'],
});
mockDiscover.mockReturnValue({
error$: from([]),
plugin$: from([firstPlugin, secondPlugin]),
});
await expect(pluginsService.discover()).resolves.toBeUndefined();
expect(mockDiscover).toHaveBeenCalledTimes(1);
expect(mockPluginSystem.addPlugin).toHaveBeenCalledTimes(2);
expect(mockPluginSystem.addPlugin).toHaveBeenCalledWith(firstPlugin);
expect(mockPluginSystem.addPlugin).toHaveBeenCalledWith(secondPlugin);
});
it('does not throw in case of cyclic plugin dependencies', async () => {
const firstPlugin = createPlugin('first-plugin', {
path: 'path-1',
requiredPlugins: ['second-plugin'],
});
const secondPlugin = createPlugin('second-plugin', {
path: 'path-2',
requiredPlugins: ['third-plugin', 'last-plugin'],
});
const thirdPlugin = createPlugin('third-plugin', {
path: 'path-3',
requiredPlugins: ['last-plugin', 'first-plugin'],
});
const lastPlugin = createPlugin('last-plugin', {
path: 'path-4',
requiredPlugins: ['first-plugin'],
});
const missingDepsPlugin = createPlugin('missing-deps-plugin', {
path: 'path-5',
requiredPlugins: ['not-a-plugin'],
});
mockDiscover.mockReturnValue({
error$: from([]),
plugin$: from([firstPlugin, secondPlugin, thirdPlugin, lastPlugin, missingDepsPlugin]),
});
await expect(pluginsService.discover()).resolves.toBeUndefined();
expect(mockDiscover).toHaveBeenCalledTimes(1);
expect(mockPluginSystem.addPlugin).toHaveBeenCalledTimes(4);
expect(mockPluginSystem.addPlugin).toHaveBeenCalledWith(firstPlugin);
expect(mockPluginSystem.addPlugin).toHaveBeenCalledWith(secondPlugin);
expect(mockPluginSystem.addPlugin).toHaveBeenCalledWith(thirdPlugin);
expect(mockPluginSystem.addPlugin).toHaveBeenCalledWith(lastPlugin);
});
it('properly invokes plugin discovery and ignores non-critical errors.', async () => {
const firstPlugin = createPlugin('some-id', {
path: 'path-1',
configPath: 'path',
requiredPlugins: ['some-other-id'],
optionalPlugins: ['missing-optional-dep'],
});
const secondPlugin = createPlugin('some-other-id', {
path: 'path-2',
version: 'some-other-version',
configPath: ['plugin', 'path'],
});
mockDiscover.mockReturnValue({
error$: from([
PluginDiscoveryError.missingManifest('path-2', new Error('No manifest')),
PluginDiscoveryError.invalidSearchPath('dir-1', new Error('No dir')),
PluginDiscoveryError.invalidPluginPath('path4-1', new Error('No path')),
]),
plugin$: from([firstPlugin, secondPlugin]),
});
await pluginsService.discover();
expect(mockPluginSystem.addPlugin).toHaveBeenCalledTimes(2);
expect(mockPluginSystem.addPlugin).toHaveBeenCalledWith(firstPlugin);
expect(mockPluginSystem.addPlugin).toHaveBeenCalledWith(secondPlugin);
expect(mockDiscover).toHaveBeenCalledTimes(1);
expect(mockDiscover).toHaveBeenCalledWith(
{
additionalPluginPaths: [],
initialize: true,
pluginSearchPaths: [
resolve(process.cwd(), 'src', 'plugins'),
resolve(process.cwd(), 'x-pack', 'plugins'),
resolve(process.cwd(), 'plugins'),
resolve(process.cwd(), '..', 'kibana-extra'),
],
},
{ coreId, env, logger, configService }
);
const logs = loggingServiceMock.collect(logger);
expect(logs.info).toHaveLength(0);
expect(logs.error).toHaveLength(0);
});
it('registers plugin config schema in config service', async () => {
const configSchema = schema.string();
jest.spyOn(configService, 'setSchema').mockImplementation(() => Promise.resolve());
jest.doMock(
join('path-with-schema', 'server'),
() => ({
config: {
schema: configSchema,
},
}),
{
virtual: true,
}
);
mockDiscover.mockReturnValue({
error$: from([]),
plugin$: from([
createPlugin('some-id', {
path: 'path-with-schema',
configPath: 'path',
}),
]),
});
await pluginsService.discover();
expect(configService.setSchema).toBeCalledWith('path', configSchema);
});
});
describe('#generateUiPluginsConfigs()', () => {
const pluginToDiscoveredEntry = (plugin: PluginWrapper): [string, DiscoveredPluginInternal] => [
plugin.name,
{
id: plugin.name,
path: plugin.path,
configPath: plugin.manifest.configPath,
requiredPlugins: [],
optionalPlugins: [],
},
];
it('properly generates client configs for plugins according to `exposeToBrowser`', async () => {
jest.doMock(
join('plugin-with-expose', 'server'),
() => ({
config: {
exposeToBrowser: {
sharedProp: true,
},
schema: schema.object({
serverProp: schema.string({ defaultValue: 'serverProp default value' }),
sharedProp: schema.string({ defaultValue: 'sharedProp default value' }),
}),
},
}),
{
virtual: true,
}
);
const plugin = createPlugin('plugin-with-expose', {
path: 'plugin-with-expose',
configPath: 'path',
});
mockDiscover.mockReturnValue({
error$: from([]),
plugin$: from([plugin]),
});
mockPluginSystem.uiPlugins.mockReturnValue({
public: new Map([pluginToDiscoveredEntry(plugin)]),
internal: new Map([pluginToDiscoveredEntry(plugin)]),
});
await pluginsService.discover();
const { uiPluginConfigs } = await pluginsService.setup(setupDeps);
const uiConfig$ = uiPluginConfigs.get('plugin-with-expose');
expect(uiConfig$).toBeDefined();
const uiConfig = await uiConfig$!.pipe(take(1)).toPromise();
expect(uiConfig).toMatchInlineSnapshot(`
Object {
"sharedProp": "sharedProp default value",
}
`);
});
it('does not generate config for plugins not exposing to client', async () => {
jest.doMock(
join('plugin-without-expose', 'server'),
() => ({
config: {
schema: schema.object({
serverProp: schema.string({ defaultValue: 'serverProp default value' }),
}),
},
}),
{
virtual: true,
}
);
const plugin = createPlugin('plugin-without-expose', {
path: 'plugin-without-expose',
configPath: 'path',
});
mockDiscover.mockReturnValue({
error$: from([]),
plugin$: from([plugin]),
});
mockPluginSystem.uiPlugins.mockReturnValue({
public: new Map([pluginToDiscoveredEntry(plugin)]),
internal: new Map([pluginToDiscoveredEntry(plugin)]),
});
await pluginsService.discover();
const { uiPluginConfigs } = await pluginsService.setup(setupDeps);
expect([...uiPluginConfigs.entries()]).toHaveLength(0);
});
});
describe('#stop()', () => {
it('`stop` stops plugins system', async () => {
await pluginsService.stop();
expect(mockPluginSystem.stopPlugins).toHaveBeenCalledTimes(1);
});
});
await pluginsService.discover();
expect(configService.setSchema).toBeCalledWith('path', configSchema);
});

View file

@ -25,10 +25,17 @@ import { CoreContext } from '../core_context';
import { Logger } from '../logging';
import { discover, PluginDiscoveryError, PluginDiscoveryErrorType } from './discovery';
import { PluginWrapper } from './plugin';
import { DiscoveredPlugin, DiscoveredPluginInternal, PluginName } from './types';
import {
DiscoveredPlugin,
DiscoveredPluginInternal,
PluginConfigDescriptor,
PluginName,
} from './types';
import { PluginsConfig, PluginsConfigType } from './plugins_config';
import { PluginsSystem } from './plugins_system';
import { InternalCoreSetup } from '../internal_types';
import { IConfigService } from '../config';
import { pick } from '../../utils';
/** @public */
export interface PluginsServiceSetup {
@ -37,6 +44,7 @@ export interface PluginsServiceSetup {
public: Map<PluginName, DiscoveredPlugin>;
internal: Map<PluginName, DiscoveredPluginInternal>;
};
uiPluginConfigs: Map<PluginName, Observable<unknown>>;
}
/** @public */
@ -54,11 +62,14 @@ export interface PluginsServiceStartDeps {} // eslint-disable-line @typescript-e
export class PluginsService implements CoreService<PluginsServiceSetup, PluginsServiceStart> {
private readonly log: Logger;
private readonly pluginsSystem: PluginsSystem;
private readonly configService: IConfigService;
private readonly config$: Observable<PluginsConfig>;
private readonly pluginConfigDescriptors = new Map<PluginName, PluginConfigDescriptor>();
constructor(private readonly coreContext: CoreContext) {
this.log = coreContext.logger.get('plugins-service');
this.pluginsSystem = new PluginsSystem(coreContext);
this.configService = coreContext.configService;
this.config$ = coreContext.configService
.atPath<PluginsConfigType>('plugins')
.pipe(map(rawConfig => new PluginsConfig(rawConfig, coreContext.env)));
@ -82,17 +93,18 @@ export class PluginsService implements CoreService<PluginsServiceSetup, PluginsS
const config = await this.config$.pipe(first()).toPromise();
let contracts = new Map<PluginName, unknown>();
if (!config.initialize || this.coreContext.env.isDevClusterMaster) {
this.log.info('Plugin initialization disabled.');
return {
contracts: new Map(),
uiPlugins: this.pluginsSystem.uiPlugins(),
};
} else {
contracts = await this.pluginsSystem.setupPlugins(deps);
}
const uiPlugins = this.pluginsSystem.uiPlugins();
return {
contracts: await this.pluginsSystem.setupPlugins(deps),
uiPlugins: this.pluginsSystem.uiPlugins(),
contracts,
uiPlugins,
uiPluginConfigs: this.generateUiPluginsConfigs(uiPlugins.public),
};
}
@ -107,6 +119,38 @@ export class PluginsService implements CoreService<PluginsServiceSetup, PluginsS
await this.pluginsSystem.stopPlugins();
}
private generateUiPluginsConfigs(
uiPlugins: Map<string, DiscoveredPlugin>
): Map<PluginName, Observable<unknown>> {
return new Map(
[...uiPlugins]
.filter(([pluginId, _]) => {
const configDescriptor = this.pluginConfigDescriptors.get(pluginId);
return (
configDescriptor &&
configDescriptor.exposeToBrowser &&
Object.values(configDescriptor?.exposeToBrowser).some(exposed => exposed)
);
})
.map(([pluginId, plugin]) => {
const configDescriptor = this.pluginConfigDescriptors.get(pluginId)!;
return [
pluginId,
this.configService.atPath(plugin.configPath).pipe(
map((config: any) =>
pick(
config || {},
Object.entries(configDescriptor.exposeToBrowser!)
.filter(([_, exposed]) => exposed)
.map(([key, _]) => key)
)
)
),
];
})
);
}
private async handleDiscoveryErrors(error$: Observable<PluginDiscoveryError>) {
// At this stage we report only errors that can occur when new platform plugin
// manifest is present, otherwise we can't be sure that the plugin is for the new
@ -138,9 +182,13 @@ export class PluginsService implements CoreService<PluginsServiceSetup, PluginsS
await plugin$
.pipe(
mergeMap(async plugin => {
const schema = plugin.getConfigSchema();
if (schema) {
await this.coreContext.configService.setSchema(plugin.configPath, schema);
const configDescriptor = plugin.getConfigDescriptor();
if (configDescriptor) {
this.pluginConfigDescriptors.set(plugin.name, configDescriptor);
await this.coreContext.configService.setSchema(
plugin.configPath,
configDescriptor.schema
);
}
const isEnabled = await this.coreContext.configService.isEnabledAtPath(plugin.configPath);

View file

@ -24,7 +24,51 @@ import { ConfigPath, EnvironmentMode, PackageInfo } from '../config';
import { LoggerFactory } from '../logging';
import { CoreSetup, CoreStart } from '..';
export type PluginConfigSchema = Type<unknown> | null;
/**
* Dedicated type for plugin configuration schema.
*
* @public
*/
export type PluginConfigSchema<T> = Type<T>;
/**
* Describes a plugin configuration schema and capabilities.
*
* @example
* ```typescript
* // my_plugin/server/index.ts
* import { schema, TypeOf } from '@kbn/config-schema';
* import { PluginConfigDescriptor } from 'kibana/server';
*
* const configSchema = schema.object({
* secret: schema.string({ defaultValue: 'Only on server' }),
* uiProp: schema.string({ defaultValue: 'Accessible from client' }),
* });
*
* type ConfigType = TypeOf<typeof configSchema>;
*
* export const config: PluginConfigDescriptor<ConfigType> = {
* exposeToBrowser: {
* uiProp: true,
* },
* schema: configSchema,
* };
* ```
*
* @public
*/
export interface PluginConfigDescriptor<T = any> {
/**
* List of configuration properties that will be available on the client-side plugin.
*/
exposeToBrowser?: { [P in keyof T]?: boolean };
/**
* Schema to use to validate the plugin configuration.
*
* {@link PluginConfigSchema}
*/
schema: PluginConfigSchema<T>;
}
/**
* Dedicated type for plugin name/id that is supposed to make Map/Set/Arrays

View file

@ -959,6 +959,17 @@ export interface Plugin<TSetup = void, TStart = void, TPluginsSetup extends obje
stop?(): void;
}
// @public
export interface PluginConfigDescriptor<T = any> {
exposeToBrowser?: {
[P in keyof T]?: boolean;
};
schema: PluginConfigSchema<T>;
}
// @public
export type PluginConfigSchema<T> = Type<T>;
// @public
export type PluginInitializer<TSetup, TStart, TPluginsSetup extends object = object, TPluginsStart extends object = object> = (core: PluginInitializerContext) => Plugin<TSetup, TStart, TPluginsSetup, TPluginsStart>;
@ -1003,6 +1014,8 @@ export interface PluginsServiceSetup {
// (undocumented)
contracts: Map<PluginName, unknown>;
// (undocumented)
uiPluginConfigs: Map<PluginName, Observable<unknown>>;
// (undocumented)
uiPlugins: {
public: Map<PluginName, DiscoveredPlugin>;
internal: Map<PluginName, DiscoveredPluginInternal>;
@ -1615,6 +1628,6 @@ export interface UserProvidedValues<T extends SavedObjectAttribute = any> {
// Warnings were encountered during analysis:
//
// src/core/server/http/router/response.ts:316:3 - (ae-forgotten-export) The symbol "KibanaResponse" needs to be exported by the entry point index.d.ts
// src/core/server/plugins/plugins_service.ts:38:5 - (ae-forgotten-export) The symbol "DiscoveredPluginInternal" needs to be exported by the entry point index.d.ts
// src/core/server/plugins/plugins_service.ts:45:5 - (ae-forgotten-export) The symbol "DiscoveredPluginInternal" needs to be exported by the entry point index.d.ts
```

View file

@ -107,6 +107,7 @@ export default class KbnServer {
__internals: {
hapiServer: LegacyServiceSetupDeps['core']['http']['server'];
uiPlugins: LegacyServiceSetupDeps['core']['plugins']['uiPlugins'];
uiPluginConfigs: LegacyServiceSetupDeps['core']['plugins']['uiPluginConfigs'];
elasticsearch: LegacyServiceSetupDeps['core']['elasticsearch'];
uiSettings: LegacyServiceSetupDeps['core']['uiSettings'];
kibanaMigrator: LegacyServiceStartDeps['core']['savedObjects']['migrator'];

View file

@ -17,6 +17,7 @@
* under the License.
*/
import { take } from 'rxjs/operators';
import { createHash } from 'crypto';
import { props, reduce as reduceAsync } from 'bluebird';
import Boom from 'boom';
@ -42,21 +43,31 @@ export function uiRenderMixin(kbnServer, server, config) {
let defaultInjectedVars = {};
kbnServer.afterPluginsInit(() => {
const { defaultInjectedVarProviders = [] } = kbnServer.uiExports;
defaultInjectedVars = defaultInjectedVarProviders
.reduce((allDefaults, { fn, pluginSpec }) => (
defaultInjectedVars = defaultInjectedVarProviders.reduce(
(allDefaults, { fn, pluginSpec }) =>
mergeVariables(
allDefaults,
fn(kbnServer.server, pluginSpec.readConfigValue(kbnServer.config, []))
)
), {});
),
{}
);
});
// render all views from ./views
server.setupViews(resolve(__dirname, 'views'));
server.exposeStaticDir('/node_modules/@elastic/eui/dist/{path*}', fromRoot('node_modules/@elastic/eui/dist'));
server.exposeStaticDir('/node_modules/@kbn/ui-framework/dist/{path*}', fromRoot('node_modules/@kbn/ui-framework/dist'));
server.exposeStaticDir('/node_modules/@elastic/charts/dist/{path*}', fromRoot('node_modules/@elastic/charts/dist'));
server.exposeStaticDir(
'/node_modules/@elastic/eui/dist/{path*}',
fromRoot('node_modules/@elastic/eui/dist')
);
server.exposeStaticDir(
'/node_modules/@kbn/ui-framework/dist/{path*}',
fromRoot('node_modules/@kbn/ui-framework/dist')
);
server.exposeStaticDir(
'/node_modules/@elastic/charts/dist/{path*}',
fromRoot('node_modules/@elastic/charts/dist')
);
const translationsCache = { translations: null, hash: null };
server.route({
@ -80,11 +91,12 @@ export function uiRenderMixin(kbnServer, server, config) {
.digest('hex');
}
return h.response(translationsCache.translations)
return h
.response(translationsCache.translations)
.header('cache-control', 'must-revalidate')
.header('content-type', 'application/json')
.etag(translationsCache.hash);
}
},
});
// register the bootstrap.js route after plugins are initialized so that we can
@ -105,42 +117,38 @@ export function uiRenderMixin(kbnServer, server, config) {
const isCore = !app;
const uiSettings = request.getUiSettingsService();
const darkMode = !authEnabled || request.auth.isAuthenticated
? await uiSettings.get('theme:darkMode')
: false;
const darkMode =
!authEnabled || request.auth.isAuthenticated
? await uiSettings.get('theme:darkMode')
: false;
const basePath = config.get('server.basePath');
const regularBundlePath = `${basePath}/bundles`;
const dllBundlePath = `${basePath}/built_assets/dlls`;
const styleSheetPaths = [
`${dllBundlePath}/vendors.style.dll.css`,
...(
darkMode ?
[
`${basePath}/node_modules/@elastic/eui/dist/eui_theme_dark.css`,
`${basePath}/node_modules/@kbn/ui-framework/dist/kui_dark.css`,
`${basePath}/node_modules/@elastic/charts/dist/theme_only_dark.css`,
] : [
`${basePath}/node_modules/@elastic/eui/dist/eui_theme_light.css`,
`${basePath}/node_modules/@kbn/ui-framework/dist/kui_light.css`,
`${basePath}/node_modules/@elastic/charts/dist/theme_only_light.css`,
]
),
...(darkMode
? [
`${basePath}/node_modules/@elastic/eui/dist/eui_theme_dark.css`,
`${basePath}/node_modules/@kbn/ui-framework/dist/kui_dark.css`,
`${basePath}/node_modules/@elastic/charts/dist/theme_only_dark.css`,
]
: [
`${basePath}/node_modules/@elastic/eui/dist/eui_theme_light.css`,
`${basePath}/node_modules/@kbn/ui-framework/dist/kui_light.css`,
`${basePath}/node_modules/@elastic/charts/dist/theme_only_light.css`,
]),
`${regularBundlePath}/${darkMode ? 'dark' : 'light'}_theme.style.css`,
`${regularBundlePath}/commons.style.css`,
...(
!isCore ? [`${regularBundlePath}/${app.getId()}.style.css`] : []
),
...(!isCore ? [`${regularBundlePath}/${app.getId()}.style.css`] : []),
...kbnServer.uiExports.styleSheetPaths
.filter(path => (
path.theme === '*' || path.theme === (darkMode ? 'dark' : 'light')
))
.map(path => (
.filter(path => path.theme === '*' || path.theme === (darkMode ? 'dark' : 'light'))
.map(path =>
path.localPath.endsWith('.scss')
? `${basePath}/built_assets/css/${path.publicPath}`
: `${basePath}/${path.publicPath}`
))
.reverse()
)
.reverse(),
];
const bootstrap = new AppBootstrap({
@ -149,17 +157,18 @@ export function uiRenderMixin(kbnServer, server, config) {
regularBundlePath,
dllBundlePath,
styleSheetPaths,
}
},
});
const body = await bootstrap.getJsFile();
const etag = await bootstrap.getJsFileHash();
return h.response(body)
return h
.response(body)
.header('cache-control', 'must-revalidate')
.header('content-type', 'application/javascript')
.etag(etag);
}
},
});
});
@ -179,14 +188,14 @@ export function uiRenderMixin(kbnServer, server, config) {
} catch (err) {
throw Boom.boomify(err);
}
}
},
});
async function getUiSettings({ request, includeUserProvidedConfig }) {
const uiSettings = request.getUiSettingsService();
return props({
defaults: uiSettings.getRegistered(),
user: includeUserProvidedConfig && uiSettings.getUserProvided()
user: includeUserProvidedConfig && uiSettings.getUserProvided(),
});
}
@ -206,7 +215,12 @@ export function uiRenderMixin(kbnServer, server, config) {
};
}
async function renderApp({ app, h, includeUserProvidedConfig = true, injectedVarsOverrides = {} }) {
async function renderApp({
app,
h,
includeUserProvidedConfig = true,
injectedVarsOverrides = {},
}) {
const request = h.request;
const basePath = request.getBasePath();
const uiSettings = await getUiSettings({ request, includeUserProvidedConfig });
@ -215,14 +229,22 @@ export function uiRenderMixin(kbnServer, server, config) {
const legacyMetadata = getLegacyKibanaPayload({
app,
basePath,
uiSettings
uiSettings,
});
// Get the list of new platform plugins.
// Convert the Map into an array of objects so it is JSON serializable and order is preserved.
const uiPlugins = [
...kbnServer.newPlatform.__internals.uiPlugins.public.entries()
].map(([id, plugin]) => ({ id, plugin }));
const uiPluginConfigs = kbnServer.newPlatform.__internals.uiPluginConfigs;
const uiPlugins = await Promise.all([
...kbnServer.newPlatform.__internals.uiPlugins.public.entries(),
].map(async ([id, plugin]) => {
const config$ = uiPluginConfigs.get(id);
if (config$) {
return { id, plugin, config: await config$.pipe(take(1)).toPromise() };
} else {
return { id, plugin, config: {} };
}
}));
const response = h.view('ui_app', {
strictCsp: config.get('csp.strict'),
@ -250,8 +272,8 @@ export function uiRenderMixin(kbnServer, server, config) {
mergeVariables(
injectedVarsOverrides,
app ? await server.getInjectedUiAppVars(app.getId()) : {},
defaultInjectedVars,
),
defaultInjectedVars
)
),
uiPlugins,

View file

@ -17,8 +17,9 @@
* under the License.
*/
import { PluginInitializer } from 'kibana/public';
import { PluginInitializer, PluginInitializerContext } from 'kibana/public';
import { TestbedPlugin, TestbedPluginSetup, TestbedPluginStart } from './plugin';
export const plugin: PluginInitializer<TestbedPluginSetup, TestbedPluginStart> = () =>
new TestbedPlugin();
export const plugin: PluginInitializer<TestbedPluginSetup, TestbedPluginStart> = (
initializerContext: PluginInitializerContext
) => new TestbedPlugin(initializerContext);

View file

@ -17,12 +17,20 @@
* under the License.
*/
import { Plugin, CoreSetup } from 'kibana/public';
import { Plugin, CoreSetup, PluginInitializerContext } from 'kibana/public';
interface ConfigType {
uiProp: string;
}
export class TestbedPlugin implements Plugin<TestbedPluginSetup, TestbedPluginStart> {
public setup(core: CoreSetup, deps: {}) {
constructor(private readonly initializerContext: PluginInitializerContext) {}
public async setup(core: CoreSetup, deps: {}) {
const config = this.initializerContext.config.get<ConfigType>();
// eslint-disable-next-line no-console
console.log(`Testbed plugin set up`);
console.log(`Testbed plugin set up. uiProp: '${config.uiProp}'`);
return {
foo: 'bar',
};

View file

@ -20,16 +20,29 @@
import { map, mergeMap } from 'rxjs/operators';
import { schema, TypeOf } from '@kbn/config-schema';
import { CoreSetup, CoreStart, Logger, PluginInitializerContext, PluginName } from 'kibana/server';
import {
CoreSetup,
CoreStart,
Logger,
PluginInitializerContext,
PluginConfigDescriptor,
PluginName,
} from 'kibana/server';
export const config = {
schema: schema.object({
secret: schema.string({ defaultValue: 'Not really a secret :/' }),
}),
const configSchema = schema.object({
secret: schema.string({ defaultValue: 'Not really a secret :/' }),
uiProp: schema.string({ defaultValue: 'Accessible from client' }),
});
type ConfigType = TypeOf<typeof configSchema>;
export const config: PluginConfigDescriptor = {
exposeToBrowser: {
uiProp: true,
},
schema: configSchema,
};
type ConfigType = TypeOf<typeof config.schema>;
class Plugin {
private readonly log: Logger;

View file

@ -8,8 +8,8 @@ import chrome from 'ui/chrome';
import { npStart } from 'ui/new_platform';
import { Plugin } from './plugin';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
new Plugin({ opaqueId: Symbol('siem'), env: {} as any }, chrome).start(
npStart.core,
npStart.plugins
);
new Plugin(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
{ opaqueId: Symbol('siem'), env: {} as any, config: { get: () => ({} as any) } },
chrome
).start(npStart.core, npStart.plugins);

View file

@ -8,4 +8,7 @@ import chrome from 'ui/chrome';
import { npStart } from 'ui/new_platform';
import { Plugin } from './plugin';
new Plugin({ opaqueId: Symbol('uptime'), env: {} as any }, chrome).start(npStart);
new Plugin(
{ opaqueId: Symbol('uptime'), env: {} as any, config: { get: () => ({} as any) } },
chrome
).start(npStart);