mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 17:59:23 -04:00
[6.x] Introduce support for the server-side new platform plugins. (#26655)
This commit is contained in:
parent
1ab8e144df
commit
09cd080dba
45 changed files with 1811 additions and 422 deletions
|
@ -32,10 +32,11 @@ describe('cli invalid config support', function () {
|
|||
cwd: ROOT_DIR
|
||||
});
|
||||
|
||||
const logLines = stdout.toString('utf8')
|
||||
const [fatalLogLine] = stdout.toString('utf8')
|
||||
.split('\n')
|
||||
.filter(Boolean)
|
||||
.map(JSON.parse)
|
||||
.filter(line => line.tags.includes('fatal'))
|
||||
.map(obj => ({
|
||||
...obj,
|
||||
pid: '## PID ##',
|
||||
|
@ -45,9 +46,9 @@ describe('cli invalid config support', function () {
|
|||
|
||||
expect(error).toBe(undefined);
|
||||
expect(status).toBe(64);
|
||||
expect(logLines[0].message).toMatch('{ Error: "unknown.key", "other.unknown.key", "other.third", "some.flat.key", and "' +
|
||||
'some.array" settings were not applied. Check for spelling errors and ensure that expected plugins are installed.');
|
||||
expect(logLines[0].tags).toEqual(['fatal', 'root']);
|
||||
expect(logLines[0].type).toEqual('log');
|
||||
expect(fatalLogLine.message).toMatch('{ Error: "unknown.key", "other.unknown.key", "other.third", "some.flat.key", and "' +
|
||||
'some.array" settings were not applied. Check for spelling errors and ensure that expected plugins are installed.');
|
||||
expect(fatalLogLine.tags).toEqual(['fatal', 'root']);
|
||||
expect(fatalLogLine.type).toEqual('log');
|
||||
}, 20 * 1000);
|
||||
});
|
||||
|
|
|
@ -11,11 +11,7 @@ Object {
|
|||
"fatal": Array [],
|
||||
"info": Array [],
|
||||
"log": Array [],
|
||||
"trace": Array [
|
||||
Array [
|
||||
"some config paths are not handled by the core: [\\"some.path\\",\\"another.path\\"]",
|
||||
],
|
||||
],
|
||||
"trace": Array [],
|
||||
"warn": Array [],
|
||||
}
|
||||
`;
|
||||
|
|
|
@ -31,6 +31,11 @@ Env {
|
|||
"buildSha": "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX",
|
||||
"version": "v1",
|
||||
},
|
||||
"pluginSearchPaths": Array [
|
||||
"/test/cwd/src/plugins",
|
||||
"/test/cwd/plugins",
|
||||
"/test/cwd/../kibana-extra",
|
||||
],
|
||||
"staticFilesDir": "/test/cwd/ui",
|
||||
}
|
||||
`;
|
||||
|
@ -66,6 +71,11 @@ Env {
|
|||
"buildSha": "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX",
|
||||
"version": "v1",
|
||||
},
|
||||
"pluginSearchPaths": Array [
|
||||
"/test/cwd/src/plugins",
|
||||
"/test/cwd/plugins",
|
||||
"/test/cwd/../kibana-extra",
|
||||
],
|
||||
"staticFilesDir": "/test/cwd/ui",
|
||||
}
|
||||
`;
|
||||
|
@ -100,6 +110,11 @@ Env {
|
|||
"buildSha": "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX",
|
||||
"version": "some-version",
|
||||
},
|
||||
"pluginSearchPaths": Array [
|
||||
"/test/cwd/src/plugins",
|
||||
"/test/cwd/plugins",
|
||||
"/test/cwd/../kibana-extra",
|
||||
],
|
||||
"staticFilesDir": "/test/cwd/ui",
|
||||
}
|
||||
`;
|
||||
|
@ -134,6 +149,11 @@ Env {
|
|||
"buildSha": "feature-v1-build-sha",
|
||||
"version": "v1",
|
||||
},
|
||||
"pluginSearchPaths": Array [
|
||||
"/test/cwd/src/plugins",
|
||||
"/test/cwd/plugins",
|
||||
"/test/cwd/../kibana-extra",
|
||||
],
|
||||
"staticFilesDir": "/test/cwd/ui",
|
||||
}
|
||||
`;
|
||||
|
@ -168,6 +188,11 @@ Env {
|
|||
"buildSha": "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX",
|
||||
"version": "v1",
|
||||
},
|
||||
"pluginSearchPaths": Array [
|
||||
"/test/cwd/src/plugins",
|
||||
"/test/cwd/plugins",
|
||||
"/test/cwd/../kibana-extra",
|
||||
],
|
||||
"staticFilesDir": "/test/cwd/ui",
|
||||
}
|
||||
`;
|
||||
|
@ -202,6 +227,11 @@ Env {
|
|||
"buildSha": "feature-v1-build-sha",
|
||||
"version": "v1",
|
||||
},
|
||||
"pluginSearchPaths": Array [
|
||||
"/some/home/dir/src/plugins",
|
||||
"/some/home/dir/plugins",
|
||||
"/some/home/dir/../kibana-extra",
|
||||
],
|
||||
"staticFilesDir": "/some/home/dir/ui",
|
||||
}
|
||||
`;
|
||||
|
|
|
@ -19,8 +19,26 @@
|
|||
|
||||
export type ConfigPath = string | string[];
|
||||
|
||||
/**
|
||||
* Checks whether specified value can be considered as config path.
|
||||
* @param value Value to check.
|
||||
* @internal
|
||||
*/
|
||||
export function isConfigPath(value: unknown): value is ConfigPath {
|
||||
if (!value) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (typeof value === 'string') {
|
||||
return true;
|
||||
}
|
||||
|
||||
return Array.isArray(value) && value.every(segment => typeof segment === 'string');
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents config store.
|
||||
* @internal
|
||||
*/
|
||||
export interface Config {
|
||||
/**
|
||||
|
|
|
@ -25,6 +25,7 @@ import { distinctUntilChanged, first, map } from 'rxjs/operators';
|
|||
import { Config, ConfigPath, ConfigWithSchema, Env } from '.';
|
||||
import { Logger, LoggerFactory } from '../logging';
|
||||
|
||||
/** @internal */
|
||||
export class ConfigService {
|
||||
private readonly log: Logger;
|
||||
|
||||
|
@ -107,13 +108,20 @@ export class ConfigService {
|
|||
return true;
|
||||
}
|
||||
|
||||
public async getUnusedPaths(): Promise<string[]> {
|
||||
public async getUnusedPaths() {
|
||||
const config = await this.config$.pipe(first()).toPromise();
|
||||
const handledPaths = this.handledPaths.map(pathToString);
|
||||
|
||||
return config.getFlattenedPaths().filter(path => !isPathHandled(path, handledPaths));
|
||||
}
|
||||
|
||||
public async getUsedPaths() {
|
||||
const config = await this.config$.pipe(first()).toPromise();
|
||||
const handledPaths = this.handledPaths.map(pathToString);
|
||||
|
||||
return config.getFlattenedPaths().filter(path => isPathHandled(path, handledPaths));
|
||||
}
|
||||
|
||||
private createConfig<TSchema extends Type<any>, TConfig>(
|
||||
path: ConfigPath,
|
||||
config: Record<string, any>,
|
||||
|
|
|
@ -29,18 +29,20 @@ export interface PackageInfo {
|
|||
buildSha: string;
|
||||
}
|
||||
|
||||
interface EnvironmentMode {
|
||||
export interface EnvironmentMode {
|
||||
name: 'development' | 'production';
|
||||
dev: boolean;
|
||||
prod: boolean;
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
export interface EnvOptions {
|
||||
configs: string[];
|
||||
cliArgs: CliArgs;
|
||||
isDevClusterMaster: boolean;
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
export interface CliArgs {
|
||||
dev: boolean;
|
||||
envName?: string;
|
||||
|
@ -60,10 +62,16 @@ export class Env {
|
|||
return new Env(process.cwd(), options);
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
public readonly configDir: string;
|
||||
/** @internal */
|
||||
public readonly binDir: string;
|
||||
/** @internal */
|
||||
public readonly logDir: string;
|
||||
/** @internal */
|
||||
public readonly staticFilesDir: string;
|
||||
/** @internal */
|
||||
public readonly pluginSearchPaths: ReadonlyArray<string>;
|
||||
|
||||
/**
|
||||
* Information about Kibana package (version, build number etc.).
|
||||
|
@ -77,16 +85,19 @@ export class Env {
|
|||
|
||||
/**
|
||||
* Arguments provided through command line.
|
||||
* @internal
|
||||
*/
|
||||
public readonly cliArgs: Readonly<CliArgs>;
|
||||
|
||||
/**
|
||||
* Paths to the configuration files.
|
||||
* @internal
|
||||
*/
|
||||
public readonly configs: ReadonlyArray<string>;
|
||||
|
||||
/**
|
||||
* Indicates that this Kibana instance is run as development Node Cluster master.
|
||||
* @internal
|
||||
*/
|
||||
public readonly isDevClusterMaster: boolean;
|
||||
|
||||
|
@ -99,6 +110,12 @@ export class Env {
|
|||
this.logDir = resolve(this.homeDir, 'log');
|
||||
this.staticFilesDir = resolve(this.homeDir, 'ui');
|
||||
|
||||
this.pluginSearchPaths = [
|
||||
resolve(this.homeDir, 'src', 'plugins'),
|
||||
resolve(this.homeDir, 'plugins'),
|
||||
resolve(this.homeDir, '..', 'kibana-extra'),
|
||||
];
|
||||
|
||||
this.cliArgs = Object.freeze(options.cliArgs);
|
||||
this.configs = Object.freeze(options.configs);
|
||||
this.isDevClusterMaster = options.isDevClusterMaster;
|
||||
|
|
|
@ -17,10 +17,16 @@
|
|||
* under the License.
|
||||
*/
|
||||
|
||||
/** @internal */
|
||||
export { ConfigService } from './config_service';
|
||||
/** @internal */
|
||||
export { RawConfigService } from './raw_config_service';
|
||||
export { Config, ConfigPath } from './config';
|
||||
/** @internal */
|
||||
export { Config, ConfigPath, isConfigPath } from './config';
|
||||
/** @internal */
|
||||
export { ObjectToConfigAdapter } from './object_to_config_adapter';
|
||||
export { Env, CliArgs, PackageInfo } from './env';
|
||||
/** @internal */
|
||||
export { CliArgs } from './env';
|
||||
|
||||
export { Env, EnvironmentMode, PackageInfo } from './env';
|
||||
export { ConfigWithSchema } from './config_with_schema';
|
||||
|
|
|
@ -26,6 +26,7 @@ import { Config } from './config';
|
|||
import { ObjectToConfigAdapter } from './object_to_config_adapter';
|
||||
import { getConfigFromFiles } from './read_config';
|
||||
|
||||
/** @internal */
|
||||
export class RawConfigService {
|
||||
/**
|
||||
* The stream of configs read from the config file.
|
||||
|
|
|
@ -48,6 +48,7 @@ function merge(target: Record<string, any>, value: any, key?: string) {
|
|||
return target;
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
export const getConfigFromFiles = (configFiles: ReadonlyArray<string>) => {
|
||||
let mergedYaml = {};
|
||||
|
||||
|
|
|
@ -20,13 +20,17 @@
|
|||
import { Observable, Subscription } from 'rxjs';
|
||||
import { first } from 'rxjs/operators';
|
||||
|
||||
import { CoreService } from '../../types/core_service';
|
||||
import { CoreService } from '../../types';
|
||||
import { Logger, LoggerFactory } from '../logging';
|
||||
import { HttpConfig } from './http_config';
|
||||
import { HttpServer, HttpServerInfo } from './http_server';
|
||||
import { HttpsRedirectServer } from './https_redirect_server';
|
||||
import { Router } from './router';
|
||||
|
||||
/** @internal */
|
||||
export type HttpServiceStartContract = HttpServerInfo;
|
||||
|
||||
/** @internal */
|
||||
export class HttpService implements CoreService<HttpServerInfo> {
|
||||
private readonly httpServer: HttpServer;
|
||||
private readonly httpsRedirectServer: HttpsRedirectServer;
|
||||
|
|
|
@ -21,11 +21,11 @@ import { Observable } from 'rxjs';
|
|||
|
||||
import { LoggerFactory } from '../logging';
|
||||
import { HttpConfig } from './http_config';
|
||||
import { HttpService } from './http_service';
|
||||
import { HttpService, HttpServiceStartContract } from './http_service';
|
||||
import { Router } from './router';
|
||||
|
||||
export { Router, KibanaRequest } from './router';
|
||||
export { HttpService };
|
||||
export { HttpService, HttpServiceStartContract };
|
||||
export { HttpServerInfo } from './http_server';
|
||||
export { BasePathProxyServer } from './base_path_proxy_server';
|
||||
|
||||
|
|
|
@ -60,6 +60,9 @@ test('starts services on "start"', async () => {
|
|||
const mockHttpServiceStartContract = { something: true };
|
||||
mockHttpService.start.mockReturnValue(Promise.resolve(mockHttpServiceStartContract));
|
||||
|
||||
const mockPluginsServiceStartContract = new Map([['some-plugin', 'some-value']]);
|
||||
mockPluginsService.start.mockReturnValue(Promise.resolve(mockPluginsServiceStartContract));
|
||||
|
||||
const server = new Server(mockConfigService as any, logger, env);
|
||||
|
||||
expect(mockHttpService.start).not.toHaveBeenCalled();
|
||||
|
@ -71,7 +74,10 @@ test('starts services on "start"', async () => {
|
|||
expect(mockHttpService.start).toHaveBeenCalledTimes(1);
|
||||
expect(mockPluginsService.start).toHaveBeenCalledTimes(1);
|
||||
expect(mockLegacyService.start).toHaveBeenCalledTimes(1);
|
||||
expect(mockLegacyService.start).toHaveBeenCalledWith(mockHttpServiceStartContract);
|
||||
expect(mockLegacyService.start).toHaveBeenCalledWith({
|
||||
http: mockHttpServiceStartContract,
|
||||
plugins: mockPluginsServiceStartContract,
|
||||
});
|
||||
});
|
||||
|
||||
test('does not fail on "start" if there are unused paths detected', async () => {
|
||||
|
@ -93,7 +99,7 @@ test('does not start http service is `autoListen:false`', async () => {
|
|||
|
||||
expect(mockHttpService.start).not.toHaveBeenCalled();
|
||||
expect(mockLegacyService.start).toHaveBeenCalledTimes(1);
|
||||
expect(mockLegacyService.start).toHaveBeenCalledWith(undefined);
|
||||
expect(mockLegacyService.start).toHaveBeenCalledWith({});
|
||||
});
|
||||
|
||||
test('does not start http service if process is dev cluster master', async () => {
|
||||
|
@ -109,7 +115,7 @@ test('does not start http service if process is dev cluster master', async () =>
|
|||
|
||||
expect(mockHttpService.start).not.toHaveBeenCalled();
|
||||
expect(mockLegacyService.start).toHaveBeenCalledTimes(1);
|
||||
expect(mockLegacyService.start).toHaveBeenCalledWith(undefined);
|
||||
expect(mockLegacyService.start).toHaveBeenCalledWith({});
|
||||
});
|
||||
|
||||
test('stops services on "stop"', async () => {
|
||||
|
|
|
@ -33,16 +33,14 @@ export class Server {
|
|||
private readonly legacy: LegacyCompatModule;
|
||||
private readonly log: Logger;
|
||||
|
||||
constructor(
|
||||
private readonly configService: ConfigService,
|
||||
logger: LoggerFactory,
|
||||
private readonly env: Env
|
||||
) {
|
||||
constructor(configService: ConfigService, logger: LoggerFactory, private readonly env: Env) {
|
||||
this.log = logger.get('server');
|
||||
|
||||
this.http = new HttpModule(configService.atPath('server', HttpConfig), logger);
|
||||
this.plugins = new PluginsModule(configService, logger, env);
|
||||
this.legacy = new LegacyCompatModule(configService, logger, env);
|
||||
|
||||
const core = { env, configService, logger };
|
||||
this.plugins = new PluginsModule(core);
|
||||
this.legacy = new LegacyCompatModule(core);
|
||||
}
|
||||
|
||||
public async start() {
|
||||
|
@ -52,23 +50,18 @@ export class Server {
|
|||
// 1. If `server.autoListen` is explicitly set to `false`.
|
||||
// 2. When the process is run as dev cluster master in which case cluster manager
|
||||
// will fork a dedicated process where http service will be started instead.
|
||||
let httpServerInfo: HttpServerInfo | undefined;
|
||||
let httpStartContract: HttpServerInfo | undefined;
|
||||
const httpConfig = await this.http.config$.pipe(first()).toPromise();
|
||||
if (!this.env.isDevClusterMaster && httpConfig.autoListen) {
|
||||
httpServerInfo = await this.http.service.start();
|
||||
httpStartContract = await this.http.service.start();
|
||||
}
|
||||
|
||||
await this.plugins.service.start();
|
||||
await this.legacy.service.start(httpServerInfo);
|
||||
const pluginsStartContract = await this.plugins.service.start();
|
||||
|
||||
const unhandledConfigPaths = await this.configService.getUnusedPaths();
|
||||
if (unhandledConfigPaths.length > 0) {
|
||||
// We don't throw here since unhandled paths are verified by the "legacy"
|
||||
// Kibana right now, but this will eventually change.
|
||||
this.log.trace(
|
||||
`some config paths are not handled by the core: ${JSON.stringify(unhandledConfigPaths)}`
|
||||
);
|
||||
}
|
||||
await this.legacy.service.start({
|
||||
http: httpStartContract,
|
||||
plugins: pluginsStartContract,
|
||||
});
|
||||
}
|
||||
|
||||
public async stop() {
|
||||
|
|
|
@ -34,6 +34,7 @@ interface LegacyLoggingConfig {
|
|||
/**
|
||||
* Represents adapter between config provided by legacy platform and `Config`
|
||||
* supported by the current platform.
|
||||
* @internal
|
||||
*/
|
||||
export class LegacyObjectToConfigAdapter extends ObjectToConfigAdapter {
|
||||
private static transformLogging(configValue: LegacyLoggingConfig = {}) {
|
||||
|
@ -86,6 +87,14 @@ export class LegacyObjectToConfigAdapter extends ObjectToConfigAdapter {
|
|||
return configValue;
|
||||
}
|
||||
|
||||
private static transformPlugins(configValue: Record<string, any>) {
|
||||
// This property is the only one we use from the existing `plugins` config node
|
||||
// since `scanDirs` and `paths` aren't respected by new platform plugin discovery.
|
||||
return {
|
||||
initialize: configValue.initialize,
|
||||
};
|
||||
}
|
||||
|
||||
public get(configPath: ConfigPath) {
|
||||
const configValue = super.get(configPath);
|
||||
switch (configPath) {
|
||||
|
@ -93,6 +102,8 @@ export class LegacyObjectToConfigAdapter extends ObjectToConfigAdapter {
|
|||
return LegacyObjectToConfigAdapter.transformLogging(configValue);
|
||||
case 'server':
|
||||
return LegacyObjectToConfigAdapter.transformServer(configValue);
|
||||
case 'plugins':
|
||||
return LegacyObjectToConfigAdapter.transformPlugins(configValue);
|
||||
default:
|
||||
return configValue;
|
||||
}
|
||||
|
|
|
@ -17,17 +17,19 @@
|
|||
* under the License.
|
||||
*/
|
||||
|
||||
import { ConfigService, Env } from '../config';
|
||||
import { LoggerFactory } from '../logging';
|
||||
import { CoreContext } from '../../types';
|
||||
import { LegacyService } from './legacy_service';
|
||||
|
||||
/** @internal */
|
||||
export { LegacyObjectToConfigAdapter } from './config/legacy_object_to_config_adapter';
|
||||
/** @internal */
|
||||
export { LegacyService } from './legacy_service';
|
||||
|
||||
/** @internal */
|
||||
export class LegacyCompatModule {
|
||||
public readonly service: LegacyService;
|
||||
|
||||
constructor(private readonly configService: ConfigService, logger: LoggerFactory, env: Env) {
|
||||
this.service = new LegacyService(env, logger, this.configService);
|
||||
constructor(coreContext: CoreContext) {
|
||||
this.service = new LegacyService(coreContext);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -32,6 +32,7 @@ import MockKbnServer from '../../../server/kbn_server';
|
|||
import { Config, ConfigService, Env, ObjectToConfigAdapter } from '../config';
|
||||
import { getEnvOptions } from '../config/__mocks__/env';
|
||||
import { logger } from '../logging/__mocks__';
|
||||
import { PluginsServiceStartContract } from '../plugins/plugins_service';
|
||||
import { LegacyPlatformProxy } from './legacy_platform_proxy';
|
||||
|
||||
const MockLegacyPlatformProxy: jest.Mock<LegacyPlatformProxy> = LegacyPlatformProxy as any;
|
||||
|
@ -39,16 +40,19 @@ const MockLegacyPlatformProxy: jest.Mock<LegacyPlatformProxy> = LegacyPlatformPr
|
|||
let legacyService: LegacyService;
|
||||
let configService: jest.Mocked<ConfigService>;
|
||||
let env: Env;
|
||||
let mockHttpServerInfo: any;
|
||||
let config$: BehaviorSubject<Config>;
|
||||
let startDeps: { http: any; plugins: PluginsServiceStartContract };
|
||||
beforeEach(() => {
|
||||
env = Env.createDefault(getEnvOptions());
|
||||
|
||||
MockKbnServer.prototype.ready = jest.fn().mockReturnValue(Promise.resolve());
|
||||
|
||||
mockHttpServerInfo = {
|
||||
server: { listener: { addListener: jest.fn() }, route: jest.fn() },
|
||||
options: { someOption: 'foo', someAnotherOption: 'bar' },
|
||||
startDeps = {
|
||||
http: {
|
||||
server: { listener: { addListener: jest.fn() }, route: jest.fn() },
|
||||
options: { someOption: 'foo', someAnotherOption: 'bar' },
|
||||
},
|
||||
plugins: new Map([['plugin-id', 'plugin-value']]),
|
||||
};
|
||||
|
||||
config$ = new BehaviorSubject<Config>(
|
||||
|
@ -60,8 +64,9 @@ beforeEach(() => {
|
|||
configService = {
|
||||
getConfig$: jest.fn().mockReturnValue(config$),
|
||||
atPath: jest.fn().mockReturnValue(new BehaviorSubject({})),
|
||||
getUsedPaths: jest.fn().mockReturnValue(['foo.bar']),
|
||||
} as any;
|
||||
legacyService = new LegacyService(env, logger, configService);
|
||||
legacyService = new LegacyService({ env, logger, configService });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
|
@ -73,9 +78,9 @@ afterEach(() => {
|
|||
|
||||
describe('once LegacyService is started with connection info', () => {
|
||||
test('register proxy route.', async () => {
|
||||
await legacyService.start(mockHttpServerInfo);
|
||||
await legacyService.start(startDeps);
|
||||
|
||||
expect(mockHttpServerInfo.server.route.mock.calls).toMatchSnapshot('proxy route options');
|
||||
expect(startDeps.http.server.route.mock.calls).toMatchSnapshot('proxy route options');
|
||||
});
|
||||
|
||||
test('proxy route responds with `503` if `kbnServer` is not ready yet.', async () => {
|
||||
|
@ -89,7 +94,7 @@ describe('once LegacyService is started with connection info', () => {
|
|||
|
||||
// Wait until listen is called and proxy route is registered, but don't allow
|
||||
// listen to complete and make kbnServer available.
|
||||
const legacyStartPromise = legacyService.start(mockHttpServerInfo);
|
||||
const legacyStartPromise = legacyService.start(startDeps);
|
||||
await kbnServerListen$.pipe(first()).toPromise();
|
||||
|
||||
const mockResponse: any = {
|
||||
|
@ -102,7 +107,7 @@ describe('once LegacyService is started with connection info', () => {
|
|||
};
|
||||
const mockRequest = { raw: { req: { a: 1 }, res: { b: 2 } } };
|
||||
|
||||
const [[{ handler }]] = mockHttpServerInfo.server.route.mock.calls;
|
||||
const [[{ handler }]] = startDeps.http.server.route.mock.calls;
|
||||
const response503 = await handler(mockRequest, mockResponseToolkit);
|
||||
|
||||
expect(response503).toBe(mockResponse);
|
||||
|
@ -137,7 +142,7 @@ describe('once LegacyService is started with connection info', () => {
|
|||
test('creates legacy kbnServer and calls `listen`.', async () => {
|
||||
configService.atPath.mockReturnValue(new BehaviorSubject({ autoListen: true }));
|
||||
|
||||
await legacyService.start(mockHttpServerInfo);
|
||||
await legacyService.start(startDeps);
|
||||
|
||||
expect(MockKbnServer).toHaveBeenCalledTimes(1);
|
||||
expect(MockKbnServer).toHaveBeenCalledWith(
|
||||
|
@ -148,6 +153,8 @@ describe('once LegacyService is started with connection info', () => {
|
|||
someAnotherOption: 'bar',
|
||||
someOption: 'foo',
|
||||
},
|
||||
handledConfigPaths: ['foo.bar'],
|
||||
plugins: startDeps.plugins,
|
||||
}
|
||||
);
|
||||
|
||||
|
@ -159,7 +166,7 @@ describe('once LegacyService is started with connection info', () => {
|
|||
test('creates legacy kbnServer but does not call `listen` if `autoListen: false`.', async () => {
|
||||
configService.atPath.mockReturnValue(new BehaviorSubject({ autoListen: false }));
|
||||
|
||||
await legacyService.start(mockHttpServerInfo);
|
||||
await legacyService.start(startDeps);
|
||||
|
||||
expect(MockKbnServer).toHaveBeenCalledTimes(1);
|
||||
expect(MockKbnServer).toHaveBeenCalledWith(
|
||||
|
@ -170,6 +177,8 @@ describe('once LegacyService is started with connection info', () => {
|
|||
someAnotherOption: 'bar',
|
||||
someOption: 'foo',
|
||||
},
|
||||
handledConfigPaths: ['foo.bar'],
|
||||
plugins: startDeps.plugins,
|
||||
}
|
||||
);
|
||||
|
||||
|
@ -183,7 +192,7 @@ describe('once LegacyService is started with connection info', () => {
|
|||
configService.atPath.mockReturnValue(new BehaviorSubject({ autoListen: true }));
|
||||
MockKbnServer.prototype.listen.mockRejectedValue(new Error('something failed'));
|
||||
|
||||
await expect(legacyService.start(mockHttpServerInfo)).rejects.toThrowErrorMatchingSnapshot();
|
||||
await expect(legacyService.start(startDeps)).rejects.toThrowErrorMatchingSnapshot();
|
||||
|
||||
const [mockKbnServer] = MockKbnServer.mock.instances;
|
||||
expect(mockKbnServer.listen).toHaveBeenCalled();
|
||||
|
@ -193,14 +202,14 @@ describe('once LegacyService is started with connection info', () => {
|
|||
test('throws if fails to retrieve initial config.', async () => {
|
||||
configService.getConfig$.mockReturnValue(throwError(new Error('something failed')));
|
||||
|
||||
await expect(legacyService.start(mockHttpServerInfo)).rejects.toThrowErrorMatchingSnapshot();
|
||||
await expect(legacyService.start(startDeps)).rejects.toThrowErrorMatchingSnapshot();
|
||||
|
||||
expect(MockKbnServer).not.toHaveBeenCalled();
|
||||
expect(MockClusterManager).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('reconfigures logging configuration if new config is received.', async () => {
|
||||
await legacyService.start(mockHttpServerInfo);
|
||||
await legacyService.start(startDeps);
|
||||
|
||||
const [mockKbnServer] = MockKbnServer.mock.instances;
|
||||
expect(mockKbnServer.applyLoggingConfiguration).not.toHaveBeenCalled();
|
||||
|
@ -213,7 +222,7 @@ describe('once LegacyService is started with connection info', () => {
|
|||
});
|
||||
|
||||
test('logs error if re-configuring fails.', async () => {
|
||||
await legacyService.start(mockHttpServerInfo);
|
||||
await legacyService.start(startDeps);
|
||||
|
||||
const [mockKbnServer] = MockKbnServer.mock.instances;
|
||||
expect(mockKbnServer.applyLoggingConfiguration).not.toHaveBeenCalled();
|
||||
|
@ -230,7 +239,7 @@ describe('once LegacyService is started with connection info', () => {
|
|||
});
|
||||
|
||||
test('logs error if config service fails.', async () => {
|
||||
await legacyService.start(mockHttpServerInfo);
|
||||
await legacyService.start(startDeps);
|
||||
|
||||
const [mockKbnServer] = MockKbnServer.mock.instances;
|
||||
expect(mockKbnServer.applyLoggingConfiguration).not.toHaveBeenCalled();
|
||||
|
@ -247,9 +256,9 @@ describe('once LegacyService is started with connection info', () => {
|
|||
const mockResponseToolkit = { response: jest.fn(), abandon: Symbol('abandon') };
|
||||
const mockRequest = { raw: { req: { a: 1 }, res: { b: 2 } } };
|
||||
|
||||
await legacyService.start(mockHttpServerInfo);
|
||||
await legacyService.start(startDeps);
|
||||
|
||||
const [[{ handler }]] = mockHttpServerInfo.server.route.mock.calls;
|
||||
const [[{ handler }]] = startDeps.http.server.route.mock.calls;
|
||||
const response = await handler(mockRequest, mockResponseToolkit);
|
||||
|
||||
expect(response).toBe(mockResponseToolkit.abandon);
|
||||
|
@ -267,14 +276,18 @@ describe('once LegacyService is started with connection info', () => {
|
|||
});
|
||||
|
||||
describe('once LegacyService is started without connection info', () => {
|
||||
beforeEach(async () => await legacyService.start());
|
||||
beforeEach(async () => await legacyService.start({ plugins: startDeps.plugins }));
|
||||
|
||||
test('creates legacy kbnServer with `autoListen: false`.', () => {
|
||||
expect(mockHttpServerInfo.server.route).not.toHaveBeenCalled();
|
||||
expect(startDeps.http.server.route).not.toHaveBeenCalled();
|
||||
expect(MockKbnServer).toHaveBeenCalledTimes(1);
|
||||
expect(MockKbnServer).toHaveBeenCalledWith(
|
||||
{ server: { autoListen: true } },
|
||||
{ serverOptions: { autoListen: false } }
|
||||
{
|
||||
serverOptions: { autoListen: false },
|
||||
handledConfigPaths: ['foo.bar'],
|
||||
plugins: startDeps.plugins,
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
|
@ -300,18 +313,18 @@ describe('once LegacyService is started in `devClusterMaster` mode', () => {
|
|||
});
|
||||
|
||||
test('creates ClusterManager without base path proxy.', async () => {
|
||||
const devClusterLegacyService = new LegacyService(
|
||||
Env.createDefault(
|
||||
const devClusterLegacyService = new LegacyService({
|
||||
env: Env.createDefault(
|
||||
getEnvOptions({
|
||||
cliArgs: { silent: true, basePath: false },
|
||||
isDevClusterMaster: true,
|
||||
})
|
||||
),
|
||||
logger,
|
||||
configService
|
||||
);
|
||||
configService,
|
||||
});
|
||||
|
||||
await devClusterLegacyService.start();
|
||||
await devClusterLegacyService.start({ plugins: new Map() });
|
||||
|
||||
expect(MockClusterManager.create.mock.calls).toMatchSnapshot(
|
||||
'cluster manager without base path proxy'
|
||||
|
@ -319,18 +332,18 @@ describe('once LegacyService is started in `devClusterMaster` mode', () => {
|
|||
});
|
||||
|
||||
test('creates ClusterManager with base path proxy.', async () => {
|
||||
const devClusterLegacyService = new LegacyService(
|
||||
Env.createDefault(
|
||||
const devClusterLegacyService = new LegacyService({
|
||||
env: Env.createDefault(
|
||||
getEnvOptions({
|
||||
cliArgs: { quiet: true, basePath: true },
|
||||
isDevClusterMaster: true,
|
||||
})
|
||||
),
|
||||
logger,
|
||||
configService
|
||||
);
|
||||
configService,
|
||||
});
|
||||
|
||||
await devClusterLegacyService.start();
|
||||
await devClusterLegacyService.start({ plugins: new Map() });
|
||||
|
||||
expect(MockClusterManager.create.mock.calls).toMatchSnapshot(
|
||||
'cluster manager with base path proxy'
|
||||
|
|
|
@ -20,11 +20,12 @@
|
|||
import { Server as HapiServer } from 'hapi';
|
||||
import { combineLatest, ConnectableObservable, EMPTY, Subscription } from 'rxjs';
|
||||
import { first, map, mergeMap, publishReplay, tap } from 'rxjs/operators';
|
||||
import { CoreService } from '../../types/core_service';
|
||||
import { Config, ConfigService, Env } from '../config';
|
||||
import { CoreContext, CoreService } from '../../types';
|
||||
import { Config } from '../config';
|
||||
import { DevConfig } from '../dev';
|
||||
import { BasePathProxyServer, HttpConfig, HttpServerInfo } from '../http';
|
||||
import { Logger, LoggerFactory } from '../logging';
|
||||
import { BasePathProxyServer, HttpConfig, HttpServiceStartContract } from '../http';
|
||||
import { Logger } from '../logging';
|
||||
import { PluginsServiceStartContract } from '../plugins/plugins_service';
|
||||
import { LegacyPlatformProxy } from './legacy_platform_proxy';
|
||||
|
||||
interface LegacyKbnServer {
|
||||
|
@ -34,23 +35,25 @@ interface LegacyKbnServer {
|
|||
close: () => Promise<void>;
|
||||
}
|
||||
|
||||
interface Deps {
|
||||
http?: HttpServiceStartContract;
|
||||
plugins: PluginsServiceStartContract;
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
export class LegacyService implements CoreService {
|
||||
private readonly log: Logger;
|
||||
private kbnServer?: LegacyKbnServer;
|
||||
private configSubscription?: Subscription;
|
||||
|
||||
constructor(
|
||||
private readonly env: Env,
|
||||
private readonly logger: LoggerFactory,
|
||||
private readonly configService: ConfigService
|
||||
) {
|
||||
this.log = logger.get('legacy', 'service');
|
||||
constructor(private readonly coreContext: CoreContext) {
|
||||
this.log = coreContext.logger.get('legacy-service');
|
||||
}
|
||||
|
||||
public async start(httpServerInfo?: HttpServerInfo) {
|
||||
public async start(deps: Deps) {
|
||||
this.log.debug('starting legacy service');
|
||||
|
||||
const update$ = this.configService.getConfig$().pipe(
|
||||
const update$ = this.coreContext.configService.getConfig$().pipe(
|
||||
tap(config => {
|
||||
if (this.kbnServer !== undefined) {
|
||||
this.kbnServer.applyLoggingConfiguration(config.toRaw());
|
||||
|
@ -67,12 +70,12 @@ export class LegacyService implements CoreService {
|
|||
.pipe(
|
||||
first(),
|
||||
mergeMap(async config => {
|
||||
if (this.env.isDevClusterMaster) {
|
||||
if (this.coreContext.env.isDevClusterMaster) {
|
||||
await this.createClusterManager(config);
|
||||
return;
|
||||
}
|
||||
|
||||
return await this.createKbnServer(config, httpServerInfo);
|
||||
return await this.createKbnServer(config, deps);
|
||||
})
|
||||
)
|
||||
.toPromise();
|
||||
|
@ -93,26 +96,27 @@ export class LegacyService implements CoreService {
|
|||
}
|
||||
|
||||
private async createClusterManager(config: Config) {
|
||||
const basePathProxy$ = this.env.cliArgs.basePath
|
||||
const basePathProxy$ = this.coreContext.env.cliArgs.basePath
|
||||
? combineLatest(
|
||||
this.configService.atPath('dev', DevConfig),
|
||||
this.configService.atPath('server', HttpConfig)
|
||||
this.coreContext.configService.atPath('dev', DevConfig),
|
||||
this.coreContext.configService.atPath('server', HttpConfig)
|
||||
).pipe(
|
||||
first(),
|
||||
map(([devConfig, httpConfig]) => {
|
||||
return new BasePathProxyServer(this.logger.get('server'), httpConfig, devConfig);
|
||||
})
|
||||
map(
|
||||
([devConfig, httpConfig]) =>
|
||||
new BasePathProxyServer(this.coreContext.logger.get('server'), httpConfig, devConfig)
|
||||
)
|
||||
)
|
||||
: EMPTY;
|
||||
|
||||
require('../../../cli/cluster/cluster_manager').create(
|
||||
this.env.cliArgs,
|
||||
this.coreContext.env.cliArgs,
|
||||
config.toRaw(),
|
||||
await basePathProxy$.toPromise()
|
||||
);
|
||||
}
|
||||
|
||||
private async createKbnServer(config: Config, httpServerInfo?: HttpServerInfo) {
|
||||
private async createKbnServer(config: Config, deps: Deps) {
|
||||
const KbnServer = require('../../../server/kbn_server');
|
||||
const kbnServer: LegacyKbnServer = new KbnServer(config.toRaw(), {
|
||||
// If core HTTP service is run we'll receive internal server reference and
|
||||
|
@ -121,15 +125,17 @@ export class LegacyService implements CoreService {
|
|||
// managed by ClusterManager or optimizer) then we won't have that info,
|
||||
// so we can't start "legacy" server either.
|
||||
serverOptions:
|
||||
httpServerInfo !== undefined
|
||||
deps.http !== undefined
|
||||
? {
|
||||
...httpServerInfo.options,
|
||||
listener: this.setupProxyListener(httpServerInfo.server),
|
||||
...deps.http.options,
|
||||
listener: this.setupProxyListener(deps.http.server),
|
||||
}
|
||||
: { autoListen: false },
|
||||
handledConfigPaths: await this.coreContext.configService.getUsedPaths(),
|
||||
plugins: deps.plugins,
|
||||
});
|
||||
|
||||
const httpConfig = await this.configService
|
||||
const httpConfig = await this.coreContext.configService
|
||||
.atPath('server', HttpConfig)
|
||||
.pipe(first())
|
||||
.toPromise();
|
||||
|
@ -150,7 +156,7 @@ export class LegacyService implements CoreService {
|
|||
|
||||
private setupProxyListener(server: HapiServer) {
|
||||
const legacyProxy = new LegacyPlatformProxy(
|
||||
this.logger.get('legacy', 'proxy'),
|
||||
this.coreContext.logger.get('legacy-proxy'),
|
||||
server.listener
|
||||
);
|
||||
|
||||
|
|
|
@ -87,7 +87,7 @@ Object {
|
|||
Object {
|
||||
"data": "some-message",
|
||||
"tags": Array [
|
||||
"trace",
|
||||
"debug",
|
||||
"some-context",
|
||||
"sub-context",
|
||||
"important",
|
||||
|
|
|
@ -23,6 +23,7 @@ import Podium from 'podium';
|
|||
import { Config, transformDeprecations } from '../../../../server/config';
|
||||
// @ts-ignore: implicit any for JS file
|
||||
import { setupLogging } from '../../../../server/logging';
|
||||
import { LogLevel } from '../../logging/log_level';
|
||||
import { LogRecord } from '../../logging/log_record';
|
||||
|
||||
interface PluginRegisterParams {
|
||||
|
@ -35,11 +36,29 @@ interface PluginRegisterParams {
|
|||
options: Record<string, any>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts core log level to a one that's known to the legacy platform.
|
||||
* @param level Log level from the core.
|
||||
*/
|
||||
function getLegacyLogLevel(level: LogLevel) {
|
||||
const logLevel = level.id.toLowerCase();
|
||||
if (logLevel === 'warn') {
|
||||
return 'warning';
|
||||
}
|
||||
|
||||
if (logLevel === 'trace') {
|
||||
return 'debug';
|
||||
}
|
||||
|
||||
return logLevel;
|
||||
}
|
||||
|
||||
/**
|
||||
* The "legacy" Kibana uses Hapi server + even-better plugin to log, so we should
|
||||
* use the same approach here to make log records generated by the core to look the
|
||||
* same as the rest of the records generated by the "legacy" Kibana. But to reduce
|
||||
* overhead of having full blown Hapi server instance we create our own "light" version.
|
||||
* @internal
|
||||
*/
|
||||
export class LegacyLoggingServer {
|
||||
public connections = [];
|
||||
|
@ -73,7 +92,7 @@ export class LegacyLoggingServer {
|
|||
public log({ level, context, message, error, timestamp, meta = {} }: LogRecord) {
|
||||
this.events.emit('log', {
|
||||
data: error || message,
|
||||
tags: [level.id.toLowerCase(), ...context.split('.'), ...(meta.tags || [])],
|
||||
tags: [getLegacyLogLevel(level), ...context.split('.'), ...(meta.tags || [])],
|
||||
timestamp: timestamp.getTime(),
|
||||
});
|
||||
}
|
||||
|
@ -90,7 +109,7 @@ export class LegacyLoggingServer {
|
|||
if (eventName === 'onPostStop') {
|
||||
this.onPostStopCallback = callback;
|
||||
}
|
||||
// We don't care about any others the plugin resgisters
|
||||
// We don't care about any others the plugin registers
|
||||
}
|
||||
|
||||
public expose() {
|
||||
|
|
|
@ -17,5 +17,9 @@
|
|||
* under the License.
|
||||
*/
|
||||
|
||||
export { PluginDiscoveryErrorType } from './plugin_discovery_error';
|
||||
/** @internal */
|
||||
export { PluginDiscoveryError, PluginDiscoveryErrorType } from './plugin_discovery_error';
|
||||
/** @internal */
|
||||
export { isNewPlatformPlugin } from './plugin_manifest_parser';
|
||||
/** @internal */
|
||||
export { discover } from './plugins_discovery';
|
||||
|
|
|
@ -26,42 +26,53 @@ jest.mock('fs', () => ({
|
|||
stat: mockStat,
|
||||
}));
|
||||
|
||||
const mockPackage = new Proxy({ raw: {} as any }, { get: (obj, prop) => obj.raw[prop] });
|
||||
jest.mock('../../../../utils/package_json', () => ({ pkg: mockPackage }));
|
||||
|
||||
import { resolve } from 'path';
|
||||
import { map, toArray } from 'rxjs/operators';
|
||||
import { BehaviorSubject } from 'rxjs';
|
||||
import { first, map, toArray } from 'rxjs/operators';
|
||||
import { Config, ConfigService, Env, ObjectToConfigAdapter } from '../../config';
|
||||
import { getEnvOptions } from '../../config/__mocks__/env';
|
||||
import { logger } from '../../logging/__mocks__';
|
||||
import { Plugin } from '../plugin';
|
||||
import { PluginsConfig } from '../plugins_config';
|
||||
import { discover } from './plugins_discovery';
|
||||
|
||||
const TEST_PATHS = {
|
||||
scanDirs: {
|
||||
nonEmpty: resolve('scan', 'non-empty'),
|
||||
nonEmpty2: resolve('scan', 'non-empty-2'),
|
||||
nonExistent: resolve('scan', 'non-existent'),
|
||||
empty: resolve('scan', 'empty'),
|
||||
},
|
||||
paths: {
|
||||
existentDir: resolve('path', 'existent-dir'),
|
||||
existentDir2: resolve('path', 'existent-dir-2'),
|
||||
nonDir: resolve('path', 'non-dir'),
|
||||
nonExistent: resolve('path', 'non-existent'),
|
||||
},
|
||||
const TEST_PLUGIN_SEARCH_PATHS = {
|
||||
nonEmptySrcPlugins: resolve(process.cwd(), 'src', 'plugins'),
|
||||
emptyPlugins: resolve(process.cwd(), 'plugins'),
|
||||
nonExistentKibanaExtra: resolve(process.cwd(), '..', 'kibana-extra'),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mockReaddir.mockImplementation((path, cb) => {
|
||||
if (path === TEST_PATHS.scanDirs.nonEmpty) {
|
||||
cb(null, ['1', '2-no-manifest', '3', '4-incomplete-manifest']);
|
||||
} else if (path === TEST_PATHS.scanDirs.nonEmpty2) {
|
||||
cb(null, ['5-invalid-manifest', '6', '7-non-dir', '8-incompatible-manifest']);
|
||||
} else if (path === TEST_PATHS.scanDirs.nonExistent) {
|
||||
if (path === TEST_PLUGIN_SEARCH_PATHS.nonEmptySrcPlugins) {
|
||||
cb(null, [
|
||||
'1',
|
||||
'2-no-manifest',
|
||||
'3',
|
||||
'4-incomplete-manifest',
|
||||
'5-invalid-manifest',
|
||||
'6',
|
||||
'7-non-dir',
|
||||
'8-incompatible-manifest',
|
||||
'9-inaccessible-dir',
|
||||
]);
|
||||
} else if (path === TEST_PLUGIN_SEARCH_PATHS.nonExistentKibanaExtra) {
|
||||
cb(new Error('ENOENT'));
|
||||
} else {
|
||||
cb(null, []);
|
||||
}
|
||||
});
|
||||
|
||||
mockStat.mockImplementation((path, cb) =>
|
||||
cb(null, { isDirectory: () => !path.includes('non-dir') })
|
||||
);
|
||||
|
||||
mockStat.mockImplementation((path, cb) => {
|
||||
if (path.includes('non-existent')) {
|
||||
cb(new Error('ENOENT'));
|
||||
if (path.includes('9-inaccessible-dir')) {
|
||||
cb(new Error(`ENOENT (disappeared between "readdir" and "stat").`));
|
||||
} else {
|
||||
cb(null, { isDirectory: () => !path.includes('non-dir') });
|
||||
}
|
||||
|
@ -77,7 +88,19 @@ beforeEach(() => {
|
|||
} else if (path.includes('incompatible-manifest')) {
|
||||
cb(null, Buffer.from(JSON.stringify({ id: 'plugin', version: '1' })));
|
||||
} else {
|
||||
cb(null, Buffer.from(JSON.stringify({ id: 'plugin', version: '1', kibanaVersion: '1.2.3' })));
|
||||
cb(
|
||||
null,
|
||||
Buffer.from(
|
||||
JSON.stringify({
|
||||
id: 'plugin',
|
||||
configPath: ['core', 'config'],
|
||||
version: '1',
|
||||
kibanaVersion: '1.2.3',
|
||||
requiredPlugins: ['a', 'b'],
|
||||
optionalPlugins: ['c', 'd'],
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
@ -86,41 +109,44 @@ afterEach(() => {
|
|||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
test('properly scans folders and paths', async () => {
|
||||
const { plugin$, error$ } = discover(
|
||||
{
|
||||
initialize: true,
|
||||
scanDirs: Object.values(TEST_PATHS.scanDirs),
|
||||
paths: Object.values(TEST_PATHS.paths),
|
||||
test('properly iterates through plugin search locations', async () => {
|
||||
mockPackage.raw = {
|
||||
branch: 'master',
|
||||
version: '1.2.3',
|
||||
build: {
|
||||
distributable: true,
|
||||
number: 1,
|
||||
sha: '',
|
||||
},
|
||||
{
|
||||
branch: 'master',
|
||||
buildNum: 1,
|
||||
buildSha: '',
|
||||
version: '1.2.3',
|
||||
},
|
||||
logger.get()
|
||||
};
|
||||
|
||||
const env = Env.createDefault(getEnvOptions());
|
||||
const configService = new ConfigService(
|
||||
new BehaviorSubject<Config>(new ObjectToConfigAdapter({})),
|
||||
env,
|
||||
logger
|
||||
);
|
||||
|
||||
await expect(plugin$.pipe(toArray()).toPromise()).resolves.toEqual(
|
||||
[
|
||||
resolve(TEST_PATHS.scanDirs.nonEmpty, '1'),
|
||||
resolve(TEST_PATHS.scanDirs.nonEmpty, '3'),
|
||||
resolve(TEST_PATHS.scanDirs.nonEmpty2, '6'),
|
||||
resolve(TEST_PATHS.paths.existentDir),
|
||||
resolve(TEST_PATHS.paths.existentDir2),
|
||||
].map(path => ({
|
||||
manifest: {
|
||||
id: 'plugin',
|
||||
version: '1',
|
||||
kibanaVersion: '1.2.3',
|
||||
optionalPlugins: [],
|
||||
requiredPlugins: [],
|
||||
ui: false,
|
||||
},
|
||||
path,
|
||||
}))
|
||||
);
|
||||
const pluginsConfig = await configService
|
||||
.atPath('plugins', PluginsConfig)
|
||||
.pipe(first())
|
||||
.toPromise();
|
||||
const { plugin$, error$ } = discover(pluginsConfig, { configService, env, logger });
|
||||
|
||||
const plugins = await plugin$.pipe(toArray()).toPromise();
|
||||
expect(plugins).toHaveLength(3);
|
||||
|
||||
for (const path of [
|
||||
resolve(TEST_PLUGIN_SEARCH_PATHS.nonEmptySrcPlugins, '1'),
|
||||
resolve(TEST_PLUGIN_SEARCH_PATHS.nonEmptySrcPlugins, '3'),
|
||||
resolve(TEST_PLUGIN_SEARCH_PATHS.nonEmptySrcPlugins, '6'),
|
||||
]) {
|
||||
const discoveredPlugin = plugins.find(plugin => plugin.path === path)!;
|
||||
expect(discoveredPlugin).toBeInstanceOf(Plugin);
|
||||
expect(discoveredPlugin.configPath).toEqual(['core', 'config']);
|
||||
expect(discoveredPlugin.requiredDependencies).toEqual(['a', 'b']);
|
||||
expect(discoveredPlugin.optionalDependencies).toEqual(['c', 'd']);
|
||||
}
|
||||
|
||||
await expect(
|
||||
error$
|
||||
|
@ -130,28 +156,28 @@ test('properly scans folders and paths', async () => {
|
|||
)
|
||||
.toPromise()
|
||||
).resolves.toEqual([
|
||||
`Error: ENOENT (invalid-scan-dir, ${resolve(TEST_PATHS.scanDirs.nonExistent)})`,
|
||||
`Error: ${resolve(TEST_PATHS.paths.nonDir)} is not a directory. (invalid-plugin-dir, ${resolve(
|
||||
TEST_PATHS.paths.nonDir
|
||||
`Error: ENOENT (disappeared between "readdir" and "stat"). (invalid-plugin-path, ${resolve(
|
||||
TEST_PLUGIN_SEARCH_PATHS.nonEmptySrcPlugins,
|
||||
'9-inaccessible-dir'
|
||||
)})`,
|
||||
`Error: ENOENT (invalid-plugin-dir, ${resolve(TEST_PATHS.paths.nonExistent)})`,
|
||||
`Error: ENOENT (invalid-search-path, ${TEST_PLUGIN_SEARCH_PATHS.nonExistentKibanaExtra})`,
|
||||
`Error: ENOENT (missing-manifest, ${resolve(
|
||||
TEST_PATHS.scanDirs.nonEmpty,
|
||||
TEST_PLUGIN_SEARCH_PATHS.nonEmptySrcPlugins,
|
||||
'2-no-manifest',
|
||||
'kibana.json'
|
||||
)})`,
|
||||
`Error: Plugin manifest must contain an "id" property. (invalid-manifest, ${resolve(
|
||||
TEST_PATHS.scanDirs.nonEmpty,
|
||||
TEST_PLUGIN_SEARCH_PATHS.nonEmptySrcPlugins,
|
||||
'4-incomplete-manifest',
|
||||
'kibana.json'
|
||||
)})`,
|
||||
`Error: Unexpected token o in JSON at position 1 (invalid-manifest, ${resolve(
|
||||
TEST_PATHS.scanDirs.nonEmpty2,
|
||||
TEST_PLUGIN_SEARCH_PATHS.nonEmptySrcPlugins,
|
||||
'5-invalid-manifest',
|
||||
'kibana.json'
|
||||
)})`,
|
||||
`Error: Plugin "plugin" is only compatible with Kibana version "1", but used Kibana version is "1.2.3". (incompatible-version, ${resolve(
|
||||
TEST_PATHS.scanDirs.nonEmpty2,
|
||||
TEST_PLUGIN_SEARCH_PATHS.nonEmptySrcPlugins,
|
||||
'8-incompatible-manifest',
|
||||
'kibana.json'
|
||||
)})`,
|
||||
|
|
|
@ -17,25 +17,27 @@
|
|||
* under the License.
|
||||
*/
|
||||
|
||||
/** @internal */
|
||||
export enum PluginDiscoveryErrorType {
|
||||
IncompatibleVersion = 'incompatible-version',
|
||||
InvalidScanDirectory = 'invalid-scan-dir',
|
||||
InvalidPluginDirectory = 'invalid-plugin-dir',
|
||||
InvalidSearchPath = 'invalid-search-path',
|
||||
InvalidPluginPath = 'invalid-plugin-path',
|
||||
InvalidManifest = 'invalid-manifest',
|
||||
MissingManifest = 'missing-manifest',
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
export class PluginDiscoveryError extends Error {
|
||||
public static incompatibleVersion(path: string, cause: Error) {
|
||||
return new PluginDiscoveryError(PluginDiscoveryErrorType.IncompatibleVersion, path, cause);
|
||||
}
|
||||
|
||||
public static invalidScanDirectory(path: string, cause: Error) {
|
||||
return new PluginDiscoveryError(PluginDiscoveryErrorType.InvalidScanDirectory, path, cause);
|
||||
public static invalidSearchPath(path: string, cause: Error) {
|
||||
return new PluginDiscoveryError(PluginDiscoveryErrorType.InvalidSearchPath, path, cause);
|
||||
}
|
||||
|
||||
public static invalidPluginDirectory(path: string, cause: Error) {
|
||||
return new PluginDiscoveryError(PluginDiscoveryErrorType.InvalidPluginDirectory, path, cause);
|
||||
public static invalidPluginPath(path: string, cause: Error) {
|
||||
return new PluginDiscoveryError(PluginDiscoveryErrorType.InvalidPluginPath, path, cause);
|
||||
}
|
||||
|
||||
public static invalidManifest(path: string, cause: Error) {
|
||||
|
|
|
@ -20,7 +20,8 @@
|
|||
import { PluginDiscoveryErrorType } from './plugin_discovery_error';
|
||||
|
||||
const mockReadFile = jest.fn();
|
||||
jest.mock('fs', () => ({ readFile: mockReadFile }));
|
||||
const mockStat = jest.fn();
|
||||
jest.mock('fs', () => ({ readFile: mockReadFile, stat: mockStat }));
|
||||
|
||||
import { resolve } from 'path';
|
||||
import { parseManifest } from './plugin_manifest_parser';
|
||||
|
@ -110,6 +111,48 @@ test('return error when plugin expected Kibana version is lower than actual vers
|
|||
});
|
||||
});
|
||||
|
||||
test('return error when plugin expected Kibana version cannot be interpreted as semver', async () => {
|
||||
mockReadFile.mockImplementation((path, cb) => {
|
||||
cb(
|
||||
null,
|
||||
Buffer.from(JSON.stringify({ id: 'some-id', version: '1.0.0', kibanaVersion: 'non-sem-ver' }))
|
||||
);
|
||||
});
|
||||
|
||||
await expect(parseManifest(pluginPath, packageInfo)).rejects.toMatchObject({
|
||||
message: `Plugin "some-id" is only compatible with Kibana version "non-sem-ver", but used Kibana version is "7.0.0-alpha1". (incompatible-version, ${pluginManifestPath})`,
|
||||
type: PluginDiscoveryErrorType.IncompatibleVersion,
|
||||
path: pluginManifestPath,
|
||||
});
|
||||
});
|
||||
|
||||
test('return error when plugin config path is not a string', async () => {
|
||||
mockReadFile.mockImplementation((path, cb) => {
|
||||
cb(null, Buffer.from(JSON.stringify({ id: 'some-id', version: '7.0.0', configPath: 2 })));
|
||||
});
|
||||
|
||||
await expect(parseManifest(pluginPath, packageInfo)).rejects.toMatchObject({
|
||||
message: `The "configPath" in plugin manifest for "some-id" should either be a string or an array of strings. (invalid-manifest, ${pluginManifestPath})`,
|
||||
type: PluginDiscoveryErrorType.InvalidManifest,
|
||||
path: pluginManifestPath,
|
||||
});
|
||||
});
|
||||
|
||||
test('return error when plugin config path is an array that contains non-string values', async () => {
|
||||
mockReadFile.mockImplementation((path, cb) => {
|
||||
cb(
|
||||
null,
|
||||
Buffer.from(JSON.stringify({ id: 'some-id', version: '7.0.0', configPath: ['config', 2] }))
|
||||
);
|
||||
});
|
||||
|
||||
await expect(parseManifest(pluginPath, packageInfo)).rejects.toMatchObject({
|
||||
message: `The "configPath" in plugin manifest for "some-id" should either be a string or an array of strings. (invalid-manifest, ${pluginManifestPath})`,
|
||||
type: PluginDiscoveryErrorType.InvalidManifest,
|
||||
path: pluginManifestPath,
|
||||
});
|
||||
});
|
||||
|
||||
test('return error when plugin expected Kibana version is higher than actual version', async () => {
|
||||
mockReadFile.mockImplementation((path, cb) => {
|
||||
cb(null, Buffer.from(JSON.stringify({ id: 'some-id', version: '7.0.1' })));
|
||||
|
@ -129,6 +172,7 @@ test('set defaults for all missing optional fields', async () => {
|
|||
|
||||
await expect(parseManifest(pluginPath, packageInfo)).resolves.toEqual({
|
||||
id: 'some-id',
|
||||
configPath: 'some-id',
|
||||
version: '7.0.0',
|
||||
kibanaVersion: '7.0.0',
|
||||
optionalPlugins: [],
|
||||
|
@ -144,6 +188,7 @@ test('return all set optional fields as they are in manifest', async () => {
|
|||
Buffer.from(
|
||||
JSON.stringify({
|
||||
id: 'some-id',
|
||||
configPath: ['some', 'path'],
|
||||
version: 'some-version',
|
||||
kibanaVersion: '7.0.0',
|
||||
requiredPlugins: ['some-required-plugin', 'some-required-plugin-2'],
|
||||
|
@ -156,6 +201,7 @@ test('return all set optional fields as they are in manifest', async () => {
|
|||
|
||||
await expect(parseManifest(pluginPath, packageInfo)).resolves.toEqual({
|
||||
id: 'some-id',
|
||||
configPath: ['some', 'path'],
|
||||
version: 'some-version',
|
||||
kibanaVersion: '7.0.0',
|
||||
optionalPlugins: ['some-optional-plugin'],
|
||||
|
@ -171,6 +217,7 @@ test('return manifest when plugin expected Kibana version matches actual version
|
|||
Buffer.from(
|
||||
JSON.stringify({
|
||||
id: 'some-id',
|
||||
configPath: 'some-path',
|
||||
version: 'some-version',
|
||||
kibanaVersion: '7.0.0-alpha2',
|
||||
requiredPlugins: ['some-required-plugin'],
|
||||
|
@ -181,6 +228,7 @@ test('return manifest when plugin expected Kibana version matches actual version
|
|||
|
||||
await expect(parseManifest(pluginPath, packageInfo)).resolves.toEqual({
|
||||
id: 'some-id',
|
||||
configPath: 'some-path',
|
||||
version: 'some-version',
|
||||
kibanaVersion: '7.0.0-alpha2',
|
||||
optionalPlugins: [],
|
||||
|
@ -206,6 +254,7 @@ test('return manifest when plugin expected Kibana version is `kibana`', async ()
|
|||
|
||||
await expect(parseManifest(pluginPath, packageInfo)).resolves.toEqual({
|
||||
id: 'some-id',
|
||||
configPath: 'some-id',
|
||||
version: 'some-version',
|
||||
kibanaVersion: 'kibana',
|
||||
optionalPlugins: [],
|
||||
|
|
|
@ -17,53 +17,16 @@
|
|||
* under the License.
|
||||
*/
|
||||
|
||||
import { readFile } from 'fs';
|
||||
import { readFile, stat } from 'fs';
|
||||
import { resolve } from 'path';
|
||||
import { coerce } from 'semver';
|
||||
import { promisify } from 'util';
|
||||
import { PackageInfo } from '../../config';
|
||||
import { isConfigPath, PackageInfo } from '../../config';
|
||||
import { PluginManifest } from '../plugin';
|
||||
import { PluginDiscoveryError } from './plugin_discovery_error';
|
||||
|
||||
const fsReadFileAsync = promisify(readFile);
|
||||
|
||||
/**
|
||||
* Describes the set of required and optional properties plugin can define in its
|
||||
* mandatory JSON manifest file.
|
||||
*/
|
||||
export interface PluginManifest {
|
||||
/**
|
||||
* Identifier of the plugin.
|
||||
*/
|
||||
readonly id: string;
|
||||
|
||||
/**
|
||||
* Version of the plugin.
|
||||
*/
|
||||
readonly version: string;
|
||||
|
||||
/**
|
||||
* The version of Kibana the plugin is compatible with, defaults to "version".
|
||||
*/
|
||||
readonly kibanaVersion: string;
|
||||
|
||||
/**
|
||||
* An optional list of the other plugins that **must be** installed and enabled
|
||||
* for this plugin to function properly.
|
||||
*/
|
||||
readonly requiredPlugins: ReadonlyArray<string>;
|
||||
|
||||
/**
|
||||
* An optional list of the other plugins that if installed and enabled **may be**
|
||||
* leveraged by this plugin for some additional functionality but otherwise are
|
||||
* not required for this plugin to work properly.
|
||||
*/
|
||||
readonly optionalPlugins: ReadonlyArray<string>;
|
||||
|
||||
/**
|
||||
* Specifies whether plugin includes some client/browser specific functionality
|
||||
* that should be included into client bundle via `public/ui_plugin.js` file.
|
||||
*/
|
||||
readonly ui: boolean;
|
||||
}
|
||||
const fsStatAsync = promisify(stat);
|
||||
|
||||
/**
|
||||
* Name of the JSON manifest file that should be located in the plugin directory.
|
||||
|
@ -75,18 +38,13 @@ const MANIFEST_FILE_NAME = 'kibana.json';
|
|||
*/
|
||||
const ALWAYS_COMPATIBLE_VERSION = 'kibana';
|
||||
|
||||
/**
|
||||
* Regular expression used to extract semantic version part from the plugin or
|
||||
* kibana version, e.g. `1.2.3` ---> `1.2.3` and `7.0.0-alpha1` ---> `7.0.0`.
|
||||
*/
|
||||
const SEM_VER_REGEX = /\d+\.\d+\.\d+/;
|
||||
|
||||
/**
|
||||
* Tries to load and parse the plugin manifest file located at the provided plugin
|
||||
* directory path and produces an error result if it fails to do so or plugin manifest
|
||||
* isn't valid.
|
||||
* @param pluginPath Path to the plugin directory where manifest should be loaded from.
|
||||
* @param packageInfo Kibana package info.
|
||||
* @internal
|
||||
*/
|
||||
export async function parseManifest(pluginPath: string, packageInfo: PackageInfo) {
|
||||
const manifestPath = resolve(pluginPath, MANIFEST_FILE_NAME);
|
||||
|
@ -126,6 +84,17 @@ export async function parseManifest(pluginPath: string, packageInfo: PackageInfo
|
|||
);
|
||||
}
|
||||
|
||||
if (manifest.configPath !== undefined && !isConfigPath(manifest.configPath)) {
|
||||
throw PluginDiscoveryError.invalidManifest(
|
||||
manifestPath,
|
||||
new Error(
|
||||
`The "configPath" in plugin manifest for "${
|
||||
manifest.id
|
||||
}" should either be a string or an array of strings.`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const expectedKibanaVersion =
|
||||
typeof manifest.kibanaVersion === 'string' && manifest.kibanaVersion
|
||||
? manifest.kibanaVersion
|
||||
|
@ -147,12 +116,28 @@ export async function parseManifest(pluginPath: string, packageInfo: PackageInfo
|
|||
id: manifest.id,
|
||||
version: manifest.version,
|
||||
kibanaVersion: expectedKibanaVersion,
|
||||
configPath: manifest.configPath || manifest.id,
|
||||
requiredPlugins: Array.isArray(manifest.requiredPlugins) ? manifest.requiredPlugins : [],
|
||||
optionalPlugins: Array.isArray(manifest.optionalPlugins) ? manifest.optionalPlugins : [],
|
||||
ui: typeof manifest.ui === 'boolean' ? manifest.ui : false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether specified folder contains Kibana new platform plugin. It's only
|
||||
* intended to be used by the legacy systems when they need to check whether specific
|
||||
* plugin path is handled by the core plugin system or not.
|
||||
* @param pluginPath Path to the plugin.
|
||||
* @internal
|
||||
*/
|
||||
export async function isNewPlatformPlugin(pluginPath: string) {
|
||||
try {
|
||||
return (await fsStatAsync(resolve(pluginPath, MANIFEST_FILE_NAME))).isFile();
|
||||
} catch (err) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether plugin expected Kibana version is compatible with the used Kibana version.
|
||||
* @param expectedKibanaVersion Kibana version expected by the plugin.
|
||||
|
@ -163,14 +148,16 @@ function isVersionCompatible(expectedKibanaVersion: string, actualKibanaVersion:
|
|||
return true;
|
||||
}
|
||||
|
||||
return extractSemVer(actualKibanaVersion) === extractSemVer(expectedKibanaVersion);
|
||||
}
|
||||
const coercedActualKibanaVersion = coerce(actualKibanaVersion);
|
||||
if (coercedActualKibanaVersion == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tries to extract semantic version part from the full version string.
|
||||
* @param version
|
||||
*/
|
||||
function extractSemVer(version: string) {
|
||||
const semVerMatch = version.match(SEM_VER_REGEX);
|
||||
return semVerMatch === null ? version : semVerMatch[0];
|
||||
const coercedExpectedKibanaVersion = coerce(expectedKibanaVersion);
|
||||
if (coercedExpectedKibanaVersion == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Compare coerced versions, e.g. `1.2.3` ---> `1.2.3` and `7.0.0-alpha1` ---> `7.0.0`.
|
||||
return coercedActualKibanaVersion.compare(coercedExpectedKibanaVersion) === 0;
|
||||
}
|
||||
|
|
|
@ -19,22 +19,19 @@
|
|||
|
||||
import { readdir, stat } from 'fs';
|
||||
import { resolve } from 'path';
|
||||
import { bindNodeCallback, from, merge, Observable, throwError } from 'rxjs';
|
||||
import { catchError, map, mergeMap, shareReplay } from 'rxjs/operators';
|
||||
import { PackageInfo } from '../../config';
|
||||
import { bindNodeCallback, from } from 'rxjs';
|
||||
import { catchError, filter, map, mergeMap, shareReplay } from 'rxjs/operators';
|
||||
import { CoreContext } from '../../../types';
|
||||
import { Logger } from '../../logging';
|
||||
import { Plugin } from '../plugin';
|
||||
import { createPluginInitializerContext } from '../plugin_context';
|
||||
import { PluginsConfig } from '../plugins_config';
|
||||
import { PluginDiscoveryError } from './plugin_discovery_error';
|
||||
import { parseManifest, PluginManifest } from './plugin_manifest_parser';
|
||||
import { parseManifest } from './plugin_manifest_parser';
|
||||
|
||||
const fsReadDir$ = bindNodeCallback(readdir);
|
||||
const fsStat$ = bindNodeCallback(stat);
|
||||
|
||||
interface DiscoveryResult {
|
||||
plugin?: { path: string; manifest: PluginManifest };
|
||||
error?: PluginDiscoveryError;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tries to discover all possible plugins based on the provided plugin config.
|
||||
* Discovery result consists of two separate streams, the one (`plugin$`) is
|
||||
|
@ -42,43 +39,39 @@ interface DiscoveryResult {
|
|||
* all the errors that occurred during discovery process.
|
||||
*
|
||||
* @param config Plugin config instance.
|
||||
* @param packageInfo Kibana package info.
|
||||
* @param log Plugin discovery logger instance.
|
||||
* @param coreContext Kibana core values.
|
||||
* @internal
|
||||
*/
|
||||
export function discover(config: PluginsConfig, packageInfo: PackageInfo, log: Logger) {
|
||||
export function discover(config: PluginsConfig, coreContext: CoreContext) {
|
||||
const log = coreContext.logger.get('plugins-discovery');
|
||||
log.debug('Discovering plugins...');
|
||||
|
||||
const discoveryResults$ = merge(
|
||||
processScanDirs$(config.scanDirs, log),
|
||||
processPaths$(config.paths, log)
|
||||
).pipe(
|
||||
const discoveryResults$ = processPluginSearchPaths$(config.pluginSearchPaths, log).pipe(
|
||||
mergeMap(pluginPathOrError => {
|
||||
return typeof pluginPathOrError === 'string'
|
||||
? createPlugin$(pluginPathOrError, packageInfo, log)
|
||||
? createPlugin$(pluginPathOrError, log, coreContext)
|
||||
: [pluginPathOrError];
|
||||
}),
|
||||
shareReplay()
|
||||
);
|
||||
|
||||
return {
|
||||
plugin$: discoveryResults$.pipe(
|
||||
mergeMap(entry => (entry.plugin !== undefined ? [entry.plugin] : []))
|
||||
),
|
||||
plugin$: discoveryResults$.pipe(filter((entry): entry is Plugin => entry instanceof Plugin)),
|
||||
error$: discoveryResults$.pipe(
|
||||
mergeMap(entry => (entry.error !== undefined ? [entry.error] : []))
|
||||
filter((entry): entry is PluginDiscoveryError => !(entry instanceof Plugin))
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Iterates over every entry in `scanDirs` and returns a merged stream of all
|
||||
* Iterates over every plugin search path and returns a merged stream of all
|
||||
* sub-directories. If directory cannot be read or it's impossible to get stat
|
||||
* for any of the nested entries then error is added into the stream instead.
|
||||
* @param scanDirs List of the top-level directories to process.
|
||||
* @param pluginDirs List of the top-level directories to process.
|
||||
* @param log Plugin discovery logger instance.
|
||||
*/
|
||||
function processScanDirs$(scanDirs: string[], log: Logger) {
|
||||
return from(scanDirs).pipe(
|
||||
function processPluginSearchPaths$(pluginDirs: ReadonlyArray<string>, log: Logger) {
|
||||
return from(pluginDirs).pipe(
|
||||
mergeMap(dir => {
|
||||
log.debug(`Scanning "${dir}" for plugin sub-directories...`);
|
||||
|
||||
|
@ -90,36 +83,10 @@ function processScanDirs$(scanDirs: string[], log: Logger) {
|
|||
// these directories may contain files (e.g. `README.md` or `package.json`).
|
||||
// We shouldn't silently ignore the entries we couldn't get stat for though.
|
||||
mergeMap(pathStat => (pathStat.isDirectory() ? [path] : [])),
|
||||
catchError(err => [wrapError(PluginDiscoveryError.invalidPluginDirectory(path, err))])
|
||||
catchError(err => [PluginDiscoveryError.invalidPluginPath(path, err)])
|
||||
)
|
||||
),
|
||||
catchError(err => [wrapError(PluginDiscoveryError.invalidScanDirectory(dir, err))])
|
||||
);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Iterates over every entry in `paths` and returns a stream of all paths that
|
||||
* are directories. If path is not a directory or it's impossible to get stat
|
||||
* for this path then error is added into the stream instead.
|
||||
* @param paths List of paths to process.
|
||||
* @param log Plugin discovery logger instance.
|
||||
*/
|
||||
function processPaths$(paths: string[], log: Logger) {
|
||||
return from(paths).pipe(
|
||||
mergeMap(path => {
|
||||
log.debug(`Including "${path}" into the plugin path list.`);
|
||||
|
||||
return fsStat$(path).pipe(
|
||||
// Since every path is specifically provided we should treat non-directory
|
||||
// entries as mistakes we should report of.
|
||||
mergeMap(pathStat => {
|
||||
return pathStat.isDirectory()
|
||||
? [path]
|
||||
: throwError(new Error(`${path} is not a directory.`));
|
||||
}),
|
||||
catchError(err => [wrapError(PluginDiscoveryError.invalidPluginDirectory(path, err))])
|
||||
catchError(err => [PluginDiscoveryError.invalidSearchPath(dir, err)])
|
||||
);
|
||||
})
|
||||
);
|
||||
|
@ -130,27 +97,15 @@ function processPaths$(paths: string[], log: Logger) {
|
|||
* directory path and produces an error result if it fails to do so or plugin manifest
|
||||
* isn't valid.
|
||||
* @param path Path to the plugin directory where manifest should be loaded from.
|
||||
* @param packageInfo Kibana package info.
|
||||
* @param log Plugin discovery logger instance.
|
||||
* @param coreContext Kibana core context.
|
||||
*/
|
||||
function createPlugin$(
|
||||
path: string,
|
||||
packageInfo: PackageInfo,
|
||||
log: Logger
|
||||
): Observable<DiscoveryResult> {
|
||||
return from(parseManifest(path, packageInfo)).pipe(
|
||||
function createPlugin$(path: string, log: Logger, coreContext: CoreContext) {
|
||||
return from(parseManifest(path, coreContext.env.packageInfo)).pipe(
|
||||
map(manifest => {
|
||||
log.debug(`Successfully discovered plugin "${manifest.id}" at "${path}"`);
|
||||
return { plugin: { path, manifest } };
|
||||
return new Plugin(path, manifest, createPluginInitializerContext(coreContext, manifest));
|
||||
}),
|
||||
catchError(err => [wrapError(err)])
|
||||
catchError(err => [err])
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps `PluginDiscoveryError` into `DiscoveryResult` entry.
|
||||
* @param error Instance of the `PluginDiscoveryError` error.
|
||||
*/
|
||||
function wrapError(error: PluginDiscoveryError): DiscoveryResult {
|
||||
return { error };
|
||||
}
|
||||
|
|
|
@ -17,14 +17,19 @@
|
|||
* under the License.
|
||||
*/
|
||||
|
||||
import { ConfigService, Env } from '../config';
|
||||
import { LoggerFactory } from '../logging';
|
||||
import { CoreContext } from '../../types';
|
||||
import { PluginsService } from './plugins_service';
|
||||
|
||||
/** @internal */
|
||||
export { isNewPlatformPlugin } from './discovery';
|
||||
export { PluginInitializerContext, PluginStartContext } from './plugin_context';
|
||||
export { PluginName } from './plugin';
|
||||
|
||||
/** @internal */
|
||||
export class PluginsModule {
|
||||
public readonly service: PluginsService;
|
||||
|
||||
constructor(private readonly configService: ConfigService, logger: LoggerFactory, env: Env) {
|
||||
this.service = new PluginsService(env, logger, this.configService);
|
||||
constructor(coreContext: CoreContext) {
|
||||
this.service = new PluginsService(coreContext);
|
||||
}
|
||||
}
|
||||
|
|
215
src/core/server/plugins/plugin.test.ts
Normal file
215
src/core/server/plugins/plugin.test.ts
Normal file
|
@ -0,0 +1,215 @@
|
|||
/*
|
||||
* Licensed to Elasticsearch B.V. under one or more contributor
|
||||
* license agreements. See the NOTICE file distributed with
|
||||
* this work for additional information regarding copyright
|
||||
* ownership. Elasticsearch B.V. licenses this file to you under
|
||||
* the Apache License, Version 2.0 (the "License"); you may
|
||||
* not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import { BehaviorSubject } from 'rxjs';
|
||||
import { CoreContext } from '../../types';
|
||||
import { Config, ConfigService, Env, ObjectToConfigAdapter } from '../config';
|
||||
import { getEnvOptions } from '../config/__mocks__/env';
|
||||
import { logger } from '../logging/__mocks__';
|
||||
import { Plugin, PluginManifest } from './plugin';
|
||||
import { createPluginInitializerContext, createPluginStartContext } from './plugin_context';
|
||||
|
||||
const mockPluginInitializer = jest.fn();
|
||||
jest.mock('plugin-with-initializer-path', () => ({ plugin: mockPluginInitializer }), {
|
||||
virtual: true,
|
||||
});
|
||||
jest.mock('plugin-without-initializer-path', () => ({}), {
|
||||
virtual: true,
|
||||
});
|
||||
jest.mock('plugin-with-wrong-initializer-path', () => ({ plugin: {} }), {
|
||||
virtual: true,
|
||||
});
|
||||
|
||||
function createPluginManifest(manifestProps: Partial<PluginManifest> = {}): PluginManifest {
|
||||
return {
|
||||
id: 'some-plugin-id',
|
||||
version: 'some-version',
|
||||
configPath: 'path',
|
||||
kibanaVersion: '7.0.0',
|
||||
requiredPlugins: ['some-required-dep'],
|
||||
optionalPlugins: ['some-optional-dep'],
|
||||
ui: true,
|
||||
...manifestProps,
|
||||
};
|
||||
}
|
||||
|
||||
let configService: ConfigService;
|
||||
let env: Env;
|
||||
let coreContext: CoreContext;
|
||||
beforeEach(() => {
|
||||
env = Env.createDefault(getEnvOptions());
|
||||
|
||||
configService = new ConfigService(
|
||||
new BehaviorSubject<Config>(new ObjectToConfigAdapter({ plugins: { initialize: true } })),
|
||||
env,
|
||||
logger
|
||||
);
|
||||
|
||||
coreContext = { env, logger, configService };
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
test('`constructor` correctly initializes plugin instance', () => {
|
||||
const manifest = createPluginManifest();
|
||||
const plugin = new Plugin(
|
||||
'some-plugin-path',
|
||||
manifest,
|
||||
createPluginInitializerContext(coreContext, manifest)
|
||||
);
|
||||
|
||||
expect(plugin.name).toBe('some-plugin-id');
|
||||
expect(plugin.configPath).toBe('path');
|
||||
expect(plugin.path).toBe('some-plugin-path');
|
||||
expect(plugin.requiredDependencies).toEqual(['some-required-dep']);
|
||||
expect(plugin.optionalDependencies).toEqual(['some-optional-dep']);
|
||||
});
|
||||
|
||||
test('`start` fails if `plugin` initializer is not exported', async () => {
|
||||
const manifest = createPluginManifest();
|
||||
const plugin = new Plugin(
|
||||
'plugin-without-initializer-path',
|
||||
manifest,
|
||||
createPluginInitializerContext(coreContext, manifest)
|
||||
);
|
||||
|
||||
await expect(
|
||||
plugin.start(createPluginStartContext(coreContext, plugin), {})
|
||||
).rejects.toMatchInlineSnapshot(
|
||||
`[Error: Plugin "some-plugin-id" does not export "plugin" definition (plugin-without-initializer-path).]`
|
||||
);
|
||||
});
|
||||
|
||||
test('`start` fails if plugin initializer is not a function', async () => {
|
||||
const manifest = createPluginManifest();
|
||||
const plugin = new Plugin(
|
||||
'plugin-with-wrong-initializer-path',
|
||||
manifest,
|
||||
createPluginInitializerContext(coreContext, manifest)
|
||||
);
|
||||
|
||||
await expect(
|
||||
plugin.start(createPluginStartContext(coreContext, plugin), {})
|
||||
).rejects.toMatchInlineSnapshot(
|
||||
`[Error: Definition of plugin "some-plugin-id" should be a function (plugin-with-wrong-initializer-path).]`
|
||||
);
|
||||
});
|
||||
|
||||
test('`start` fails if initializer does not return object', async () => {
|
||||
const manifest = createPluginManifest();
|
||||
const plugin = new Plugin(
|
||||
'plugin-with-initializer-path',
|
||||
manifest,
|
||||
createPluginInitializerContext(coreContext, manifest)
|
||||
);
|
||||
|
||||
mockPluginInitializer.mockReturnValue(null);
|
||||
|
||||
await expect(
|
||||
plugin.start(createPluginStartContext(coreContext, plugin), {})
|
||||
).rejects.toMatchInlineSnapshot(
|
||||
`[Error: Initializer for plugin "some-plugin-id" is expected to return plugin instance, but returned "null".]`
|
||||
);
|
||||
});
|
||||
|
||||
test('`start` fails if object returned from initializer does not define `start` function', async () => {
|
||||
const manifest = createPluginManifest();
|
||||
const plugin = new Plugin(
|
||||
'plugin-with-initializer-path',
|
||||
manifest,
|
||||
createPluginInitializerContext(coreContext, manifest)
|
||||
);
|
||||
|
||||
const mockPluginInstance = { run: jest.fn() };
|
||||
mockPluginInitializer.mockReturnValue(mockPluginInstance);
|
||||
|
||||
await expect(
|
||||
plugin.start(createPluginStartContext(coreContext, plugin), {})
|
||||
).rejects.toMatchInlineSnapshot(
|
||||
`[Error: Instance of plugin "some-plugin-id" does not define "start" function.]`
|
||||
);
|
||||
});
|
||||
|
||||
test('`start` initializes plugin and calls appropriate lifecycle hook', async () => {
|
||||
const manifest = createPluginManifest();
|
||||
const initializerContext = createPluginInitializerContext(coreContext, manifest);
|
||||
const plugin = new Plugin('plugin-with-initializer-path', manifest, initializerContext);
|
||||
|
||||
const mockPluginInstance = { start: jest.fn().mockResolvedValue({ contract: 'yes' }) };
|
||||
mockPluginInitializer.mockReturnValue(mockPluginInstance);
|
||||
|
||||
const startContext = createPluginStartContext(coreContext, plugin);
|
||||
const startDependencies = { 'some-required-dep': { contract: 'no' } };
|
||||
await expect(plugin.start(startContext, startDependencies)).resolves.toEqual({ contract: 'yes' });
|
||||
|
||||
expect(mockPluginInitializer).toHaveBeenCalledTimes(1);
|
||||
expect(mockPluginInitializer).toHaveBeenCalledWith(initializerContext);
|
||||
|
||||
expect(mockPluginInstance.start).toHaveBeenCalledTimes(1);
|
||||
expect(mockPluginInstance.start).toHaveBeenCalledWith(startContext, startDependencies);
|
||||
});
|
||||
|
||||
test('`stop` fails if plugin is not started', async () => {
|
||||
const manifest = createPluginManifest();
|
||||
const plugin = new Plugin(
|
||||
'plugin-with-initializer-path',
|
||||
manifest,
|
||||
createPluginInitializerContext(coreContext, manifest)
|
||||
);
|
||||
|
||||
const mockPluginInstance = { start: jest.fn(), stop: jest.fn() };
|
||||
mockPluginInitializer.mockReturnValue(mockPluginInstance);
|
||||
|
||||
await expect(plugin.stop()).rejects.toMatchInlineSnapshot(
|
||||
`[Error: Plugin "some-plugin-id" can't be stopped since it isn't started.]`
|
||||
);
|
||||
expect(mockPluginInstance.stop).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('`stop` does nothing if plugin does not define `stop` function', async () => {
|
||||
const manifest = createPluginManifest();
|
||||
const plugin = new Plugin(
|
||||
'plugin-with-initializer-path',
|
||||
manifest,
|
||||
createPluginInitializerContext(coreContext, manifest)
|
||||
);
|
||||
|
||||
mockPluginInitializer.mockReturnValue({ start: jest.fn() });
|
||||
await plugin.start(createPluginStartContext(coreContext, plugin), {});
|
||||
|
||||
await expect(plugin.stop()).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
test('`stop` calls `stop` defined by the plugin instance', async () => {
|
||||
const manifest = createPluginManifest();
|
||||
const plugin = new Plugin(
|
||||
'plugin-with-initializer-path',
|
||||
manifest,
|
||||
createPluginInitializerContext(coreContext, manifest)
|
||||
);
|
||||
|
||||
const mockPluginInstance = { start: jest.fn(), stop: jest.fn() };
|
||||
mockPluginInitializer.mockReturnValue(mockPluginInstance);
|
||||
await plugin.start(createPluginStartContext(coreContext, plugin), {});
|
||||
|
||||
await expect(plugin.stop()).resolves.toBeUndefined();
|
||||
expect(mockPluginInstance.stop).toHaveBeenCalledTimes(1);
|
||||
});
|
176
src/core/server/plugins/plugin.ts
Normal file
176
src/core/server/plugins/plugin.ts
Normal file
|
@ -0,0 +1,176 @@
|
|||
/*
|
||||
* Licensed to Elasticsearch B.V. under one or more contributor
|
||||
* license agreements. See the NOTICE file distributed with
|
||||
* this work for additional information regarding copyright
|
||||
* ownership. Elasticsearch B.V. licenses this file to you under
|
||||
* the Apache License, Version 2.0 (the "License"); you may
|
||||
* not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import typeDetect from 'type-detect';
|
||||
import { ConfigPath } from '../config';
|
||||
import { Logger } from '../logging';
|
||||
import { PluginInitializerContext, PluginStartContext } from './plugin_context';
|
||||
|
||||
/**
|
||||
* Dedicated type for plugin name/id that is supposed to make Map/Set/Arrays
|
||||
* that use it as a key or value more obvious.
|
||||
*/
|
||||
export type PluginName = string;
|
||||
|
||||
/**
|
||||
* Describes the set of required and optional properties plugin can define in its
|
||||
* mandatory JSON manifest file.
|
||||
* @internal
|
||||
*/
|
||||
export interface PluginManifest {
|
||||
/**
|
||||
* Identifier of the plugin.
|
||||
*/
|
||||
readonly id: PluginName;
|
||||
|
||||
/**
|
||||
* Version of the plugin.
|
||||
*/
|
||||
readonly version: string;
|
||||
|
||||
/**
|
||||
* The version of Kibana the plugin is compatible with, defaults to "version".
|
||||
*/
|
||||
readonly kibanaVersion: string;
|
||||
|
||||
/**
|
||||
* Root configuration path used by the plugin, defaults to "id".
|
||||
*/
|
||||
readonly configPath: ConfigPath;
|
||||
|
||||
/**
|
||||
* An optional list of the other plugins that **must be** installed and enabled
|
||||
* for this plugin to function properly.
|
||||
*/
|
||||
readonly requiredPlugins: ReadonlyArray<PluginName>;
|
||||
|
||||
/**
|
||||
* An optional list of the other plugins that if installed and enabled **may be**
|
||||
* leveraged by this plugin for some additional functionality but otherwise are
|
||||
* not required for this plugin to work properly.
|
||||
*/
|
||||
readonly optionalPlugins: ReadonlyArray<PluginName>;
|
||||
|
||||
/**
|
||||
* Specifies whether plugin includes some client/browser specific functionality
|
||||
* that should be included into client bundle via `public/ui_plugin.js` file.
|
||||
*/
|
||||
readonly ui: boolean;
|
||||
}
|
||||
|
||||
type PluginInitializer<TExposedContract, TDependencies extends Record<PluginName, unknown>> = (
|
||||
coreContext: PluginInitializerContext
|
||||
) => {
|
||||
start: (pluginStartContext: PluginStartContext, dependencies: TDependencies) => TExposedContract;
|
||||
stop?: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Lightweight wrapper around discovered plugin that is responsible for instantiating
|
||||
* plugin and dispatching proper context and dependencies into plugin's lifecycle hooks.
|
||||
* @internal
|
||||
*/
|
||||
export class Plugin<
|
||||
TStartContract = unknown,
|
||||
TDependencies extends Record<PluginName, unknown> = Record<PluginName, unknown>
|
||||
> {
|
||||
public readonly name: PluginManifest['id'];
|
||||
public readonly configPath: PluginManifest['configPath'];
|
||||
public readonly requiredDependencies: PluginManifest['requiredPlugins'];
|
||||
public readonly optionalDependencies: PluginManifest['optionalPlugins'];
|
||||
|
||||
private readonly log: Logger;
|
||||
|
||||
private instance?: ReturnType<PluginInitializer<TStartContract, TDependencies>>;
|
||||
|
||||
constructor(
|
||||
public readonly path: string,
|
||||
private readonly manifest: PluginManifest,
|
||||
private readonly initializerContext: PluginInitializerContext
|
||||
) {
|
||||
this.log = initializerContext.logger.get();
|
||||
this.name = manifest.id;
|
||||
this.configPath = manifest.configPath;
|
||||
this.requiredDependencies = manifest.requiredPlugins;
|
||||
this.optionalDependencies = manifest.optionalPlugins;
|
||||
}
|
||||
|
||||
/**
|
||||
* Instantiates plugin and calls `start` function exposed by the plugin initializer.
|
||||
* @param startContext Context that consists of various core services tailored specifically
|
||||
* for the `start` lifecycle event.
|
||||
* @param dependencies The dictionary where the key is the dependency name and the value
|
||||
* is the contract returned by the dependency's `start` function.
|
||||
*/
|
||||
public async start(startContext: PluginStartContext, dependencies: TDependencies) {
|
||||
this.instance = this.createPluginInstance();
|
||||
|
||||
this.log.info('Starting plugin');
|
||||
|
||||
return await this.instance.start(startContext, dependencies);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calls optional `stop` function exposed by the plugin initializer.
|
||||
*/
|
||||
public async stop() {
|
||||
if (this.instance === undefined) {
|
||||
throw new Error(`Plugin "${this.name}" can't be stopped since it isn't started.`);
|
||||
}
|
||||
|
||||
this.log.info('Stopping plugin');
|
||||
|
||||
if (typeof this.instance.stop === 'function') {
|
||||
await this.instance.stop();
|
||||
}
|
||||
|
||||
this.instance = undefined;
|
||||
}
|
||||
|
||||
private createPluginInstance() {
|
||||
this.log.debug('Initializing plugin');
|
||||
|
||||
const pluginDefinition = require(this.path);
|
||||
if (!('plugin' in pluginDefinition)) {
|
||||
throw new Error(`Plugin "${this.name}" does not export "plugin" definition (${this.path}).`);
|
||||
}
|
||||
|
||||
const { plugin: initializer } = pluginDefinition as {
|
||||
plugin: PluginInitializer<TStartContract, TDependencies>;
|
||||
};
|
||||
if (!initializer || typeof initializer !== 'function') {
|
||||
throw new Error(`Definition of plugin "${this.name}" should be a function (${this.path}).`);
|
||||
}
|
||||
|
||||
const instance = initializer(this.initializerContext);
|
||||
if (!instance || typeof instance !== 'object') {
|
||||
throw new Error(
|
||||
`Initializer for plugin "${
|
||||
this.manifest.id
|
||||
}" is expected to return plugin instance, but returned "${typeDetect(instance)}".`
|
||||
);
|
||||
}
|
||||
|
||||
if (typeof instance.start !== 'function') {
|
||||
throw new Error(`Instance of plugin "${this.name}" does not define "start" function.`);
|
||||
}
|
||||
|
||||
return instance;
|
||||
}
|
||||
}
|
114
src/core/server/plugins/plugin_context.ts
Normal file
114
src/core/server/plugins/plugin_context.ts
Normal file
|
@ -0,0 +1,114 @@
|
|||
/*
|
||||
* Licensed to Elasticsearch B.V. under one or more contributor
|
||||
* license agreements. See the NOTICE file distributed with
|
||||
* this work for additional information regarding copyright
|
||||
* ownership. Elasticsearch B.V. licenses this file to you under
|
||||
* the Apache License, Version 2.0 (the "License"); you may
|
||||
* not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import { Type } from '@kbn/config-schema';
|
||||
import { Observable } from 'rxjs';
|
||||
import { CoreContext } from '../../types';
|
||||
import { ConfigWithSchema, EnvironmentMode } from '../config';
|
||||
import { LoggerFactory } from '../logging';
|
||||
import { Plugin, PluginManifest } from './plugin';
|
||||
|
||||
export interface PluginInitializerContext {
|
||||
env: { mode: EnvironmentMode };
|
||||
logger: LoggerFactory;
|
||||
config: {
|
||||
create: <Schema extends Type<any>, Config>(
|
||||
ConfigClass: ConfigWithSchema<Schema, Config>
|
||||
) => Observable<Config>;
|
||||
createIfExists: <Schema extends Type<any>, Config>(
|
||||
ConfigClass: ConfigWithSchema<Schema, Config>
|
||||
) => Observable<Config | undefined>;
|
||||
};
|
||||
}
|
||||
|
||||
// tslint:disable no-empty-interface
|
||||
export interface PluginStartContext {}
|
||||
|
||||
/**
|
||||
* This returns a facade for `CoreContext` that will be exposed to the plugin initializer.
|
||||
* This facade should be safe to use across entire plugin lifespan.
|
||||
*
|
||||
* This is called for each plugin when it's created, so each plugin gets its own
|
||||
* version of these values.
|
||||
*
|
||||
* We should aim to be restrictive and specific in the APIs that we expose.
|
||||
*
|
||||
* @param coreContext Kibana core context
|
||||
* @param pluginManifest The manifest of the plugin we're building these values for.
|
||||
* @internal
|
||||
*/
|
||||
export function createPluginInitializerContext(
|
||||
coreContext: CoreContext,
|
||||
pluginManifest: PluginManifest
|
||||
): PluginInitializerContext {
|
||||
return {
|
||||
/**
|
||||
* Environment information that is safe to expose to plugins and may be beneficial for them.
|
||||
*/
|
||||
env: { mode: coreContext.env.mode },
|
||||
|
||||
/**
|
||||
* Plugin-scoped logger
|
||||
*/
|
||||
logger: {
|
||||
get(...contextParts) {
|
||||
return coreContext.logger.get('plugins', pluginManifest.id, ...contextParts);
|
||||
},
|
||||
},
|
||||
|
||||
/**
|
||||
* Core configuration functionality, enables fetching a subset of the config.
|
||||
*/
|
||||
config: {
|
||||
/**
|
||||
* Reads the subset of the config at the `configPath` defined in the plugin
|
||||
* manifest and validates it against the schema in the static `schema` on
|
||||
* the given `ConfigClass`.
|
||||
* @param ConfigClass A class (not an instance of a class) that contains a
|
||||
* static `schema` that we validate the config at the given `path` against.
|
||||
*/
|
||||
create(ConfigClass) {
|
||||
return coreContext.configService.atPath(pluginManifest.configPath, ConfigClass);
|
||||
},
|
||||
createIfExists(ConfigClass) {
|
||||
return coreContext.configService.optionalAtPath(pluginManifest.configPath, ConfigClass);
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* This returns a facade for `CoreContext` that will be exposed to the plugin `start` method.
|
||||
* This facade should be safe to use only within `start` itself.
|
||||
*
|
||||
* This is called for each plugin when it's started, so each plugin gets its own
|
||||
* version of these values.
|
||||
*
|
||||
* We should aim to be restrictive and specific in the APIs that we expose.
|
||||
*
|
||||
* @param coreContext Kibana core context
|
||||
* @param plugin The plugin we're building these values for.
|
||||
* @internal
|
||||
*/
|
||||
export function createPluginStartContext<TPluginContract, TPluginDependencies>(
|
||||
coreContext: CoreContext,
|
||||
plugin: Plugin<TPluginContract, TPluginDependencies>
|
||||
): PluginStartContext {
|
||||
return {};
|
||||
}
|
|
@ -18,15 +18,10 @@
|
|||
*/
|
||||
|
||||
import { schema, TypeOf } from '@kbn/config-schema';
|
||||
import { Env } from '../config';
|
||||
|
||||
const pluginsSchema = schema.object({
|
||||
initialize: schema.boolean({ defaultValue: true }),
|
||||
scanDirs: schema.arrayOf(schema.string(), {
|
||||
defaultValue: [],
|
||||
}),
|
||||
paths: schema.arrayOf(schema.string(), {
|
||||
defaultValue: [],
|
||||
}),
|
||||
});
|
||||
|
||||
type PluginsConfigType = TypeOf<typeof pluginsSchema>;
|
||||
|
@ -43,16 +38,10 @@ export class PluginsConfig {
|
|||
/**
|
||||
* Defines directories that we should scan for the plugin subdirectories.
|
||||
*/
|
||||
public readonly scanDirs: string[];
|
||||
public readonly pluginSearchPaths: ReadonlyArray<string>;
|
||||
|
||||
/**
|
||||
* Defines direct paths to specific plugin directories that we should initialize.
|
||||
*/
|
||||
public readonly paths: string[];
|
||||
|
||||
constructor(config: PluginsConfigType) {
|
||||
constructor(config: PluginsConfigType, env: Env) {
|
||||
this.initialize = config.initialize;
|
||||
this.scanDirs = config.scanDirs;
|
||||
this.paths = config.paths;
|
||||
this.pluginSearchPaths = env.pluginSearchPaths;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -23,17 +23,25 @@ jest.mock('../../../utils/package_json', () => ({ pkg: mockPackage }));
|
|||
const mockDiscover = jest.fn();
|
||||
jest.mock('./discovery/plugins_discovery', () => ({ discover: mockDiscover }));
|
||||
|
||||
jest.mock('./plugins_system');
|
||||
|
||||
import { resolve } from 'path';
|
||||
import { BehaviorSubject, from } from 'rxjs';
|
||||
|
||||
import { Config, ConfigService, Env, ObjectToConfigAdapter } from '../config';
|
||||
import { getEnvOptions } from '../config/__mocks__/env';
|
||||
import { logger } from '../logging/__mocks__';
|
||||
import { PluginDiscoveryError } from './discovery/plugin_discovery_error';
|
||||
import { PluginDiscoveryError } from './discovery';
|
||||
import { Plugin } from './plugin';
|
||||
import { PluginsService } from './plugins_service';
|
||||
import { PluginsSystem } from './plugins_system';
|
||||
|
||||
const MockPluginsSystem: jest.Mock<PluginsSystem> = PluginsSystem as any;
|
||||
|
||||
let pluginsService: PluginsService;
|
||||
let configService: ConfigService;
|
||||
let env: Env;
|
||||
let mockPluginSystem: jest.Mocked<PluginsSystem>;
|
||||
beforeEach(() => {
|
||||
mockPackage.raw = {
|
||||
branch: 'feature-v1',
|
||||
|
@ -48,99 +56,254 @@ beforeEach(() => {
|
|||
env = Env.createDefault(getEnvOptions());
|
||||
|
||||
configService = new ConfigService(
|
||||
new BehaviorSubject<Config>(
|
||||
new ObjectToConfigAdapter({
|
||||
plugins: {
|
||||
initialize: true,
|
||||
scanDirs: ['one', 'two'],
|
||||
paths: ['three', 'four'],
|
||||
},
|
||||
})
|
||||
),
|
||||
new BehaviorSubject<Config>(new ObjectToConfigAdapter({ plugins: { initialize: true } })),
|
||||
env,
|
||||
logger
|
||||
);
|
||||
pluginsService = new PluginsService(env, logger, configService);
|
||||
pluginsService = new PluginsService({ env, logger, configService });
|
||||
|
||||
[mockPluginSystem] = MockPluginsSystem.mock.instances as any;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
test('properly invokes `discover` on `start`.', async () => {
|
||||
test('`start` 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.start()).rejects.toMatchInlineSnapshot(`
|
||||
[Error: Failed to initialize plugins:
|
||||
Invalid JSON (invalid-manifest, path-1)]
|
||||
`);
|
||||
expect(logger.mockCollect().error).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
Array [
|
||||
[Error: Invalid JSON (invalid-manifest, path-1)],
|
||||
],
|
||||
]
|
||||
`);
|
||||
});
|
||||
|
||||
test('`start` throws if plugin required Kibana version is incompatible with the current version', async () => {
|
||||
mockDiscover.mockReturnValue({
|
||||
error$: from([
|
||||
PluginDiscoveryError.invalidManifest('path-1', new Error('Invalid JSON')),
|
||||
PluginDiscoveryError.missingManifest('path-2', new Error('No manifest')),
|
||||
PluginDiscoveryError.invalidScanDirectory('dir-1', new Error('No dir')),
|
||||
PluginDiscoveryError.incompatibleVersion('path-3', new Error('Incompatible version')),
|
||||
]),
|
||||
plugin$: from([]),
|
||||
});
|
||||
|
||||
await expect(pluginsService.start()).rejects.toMatchInlineSnapshot(`
|
||||
[Error: Failed to initialize plugins:
|
||||
Incompatible version (incompatible-version, path-3)]
|
||||
`);
|
||||
expect(logger.mockCollect().error).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
Array [
|
||||
[Error: Incompatible version (incompatible-version, path-3)],
|
||||
],
|
||||
]
|
||||
`);
|
||||
});
|
||||
|
||||
test('`start` throws if discovered plugins with conflicting names', async () => {
|
||||
mockDiscover.mockReturnValue({
|
||||
error$: from([]),
|
||||
plugin$: from([
|
||||
{
|
||||
path: 'path-4',
|
||||
manifest: {
|
||||
id: 'some-id',
|
||||
new Plugin(
|
||||
'path-4',
|
||||
{
|
||||
id: 'conflicting-id',
|
||||
version: 'some-version',
|
||||
configPath: 'path',
|
||||
kibanaVersion: '7.0.0',
|
||||
requiredPlugins: ['some-required-plugin', 'some-required-plugin-2'],
|
||||
optionalPlugins: ['some-optional-plugin'],
|
||||
ui: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'path-5',
|
||||
manifest: {
|
||||
id: 'some-other-id',
|
||||
{ logger } as any
|
||||
),
|
||||
new Plugin(
|
||||
'path-5',
|
||||
{
|
||||
id: 'conflicting-id',
|
||||
version: 'some-other-version',
|
||||
configPath: ['plugin', 'path'],
|
||||
kibanaVersion: '7.0.0',
|
||||
requiredPlugins: ['some-required-plugin'],
|
||||
optionalPlugins: [],
|
||||
ui: false,
|
||||
},
|
||||
},
|
||||
{ logger } as any
|
||||
),
|
||||
]),
|
||||
});
|
||||
|
||||
await pluginsService.start();
|
||||
await expect(pluginsService.start()).rejects.toMatchInlineSnapshot(
|
||||
`[Error: Plugin with id "conflicting-id" is already registered!]`
|
||||
);
|
||||
|
||||
expect(mockPluginSystem.addPlugin).not.toHaveBeenCalled();
|
||||
expect(mockPluginSystem.startPlugins).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('`start` properly detects plugins that should be disabled.', async () => {
|
||||
jest
|
||||
.spyOn(configService, 'isEnabledAtPath')
|
||||
.mockImplementation(path => Promise.resolve(!path.includes('disabled')));
|
||||
|
||||
mockPluginSystem.startPlugins.mockResolvedValue(new Map());
|
||||
|
||||
mockDiscover.mockReturnValue({
|
||||
error$: from([]),
|
||||
plugin$: from([
|
||||
new Plugin(
|
||||
'path-1',
|
||||
{
|
||||
id: 'explicitly-disabled-plugin',
|
||||
version: 'some-version',
|
||||
configPath: 'path-1-disabled',
|
||||
kibanaVersion: '7.0.0',
|
||||
requiredPlugins: [],
|
||||
optionalPlugins: [],
|
||||
ui: true,
|
||||
},
|
||||
{ logger } as any
|
||||
),
|
||||
new Plugin(
|
||||
'path-2',
|
||||
{
|
||||
id: 'plugin-with-missing-required-deps',
|
||||
version: 'some-version',
|
||||
configPath: 'path-2',
|
||||
kibanaVersion: '7.0.0',
|
||||
requiredPlugins: ['missing-plugin'],
|
||||
optionalPlugins: [],
|
||||
ui: true,
|
||||
},
|
||||
{ logger } as any
|
||||
),
|
||||
new Plugin(
|
||||
'path-3',
|
||||
{
|
||||
id: 'plugin-with-disabled-transitive-dep',
|
||||
version: 'some-version',
|
||||
configPath: 'path-3',
|
||||
kibanaVersion: '7.0.0',
|
||||
requiredPlugins: ['another-explicitly-disabled-plugin'],
|
||||
optionalPlugins: [],
|
||||
ui: true,
|
||||
},
|
||||
{ logger } as any
|
||||
),
|
||||
new Plugin(
|
||||
'path-4',
|
||||
{
|
||||
id: 'another-explicitly-disabled-plugin',
|
||||
version: 'some-version',
|
||||
configPath: 'path-4-disabled',
|
||||
kibanaVersion: '7.0.0',
|
||||
requiredPlugins: [],
|
||||
optionalPlugins: [],
|
||||
ui: true,
|
||||
},
|
||||
{ logger } as any
|
||||
),
|
||||
]),
|
||||
});
|
||||
|
||||
expect(await pluginsService.start()).toBeInstanceOf(Map);
|
||||
expect(mockPluginSystem.addPlugin).not.toHaveBeenCalled();
|
||||
expect(mockPluginSystem.startPlugins).toHaveBeenCalledTimes(1);
|
||||
|
||||
expect(logger.mockCollect().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('`start` properly invokes `discover` and ignores non-critical errors.', async () => {
|
||||
const firstPlugin = new Plugin(
|
||||
'path-1',
|
||||
{
|
||||
id: 'some-id',
|
||||
version: 'some-version',
|
||||
configPath: 'path',
|
||||
kibanaVersion: '7.0.0',
|
||||
requiredPlugins: ['some-other-id'],
|
||||
optionalPlugins: ['missing-optional-dep'],
|
||||
ui: true,
|
||||
},
|
||||
{ logger } as any
|
||||
);
|
||||
|
||||
const secondPlugin = new Plugin(
|
||||
'path-2',
|
||||
{
|
||||
id: 'some-other-id',
|
||||
version: 'some-other-version',
|
||||
configPath: ['plugin', 'path'],
|
||||
kibanaVersion: '7.0.0',
|
||||
requiredPlugins: [],
|
||||
optionalPlugins: [],
|
||||
ui: false,
|
||||
},
|
||||
{ logger } as any
|
||||
);
|
||||
|
||||
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]),
|
||||
});
|
||||
|
||||
const pluginContracts = new Map();
|
||||
mockPluginSystem.startPlugins.mockResolvedValue(pluginContracts);
|
||||
|
||||
const startContract = await pluginsService.start();
|
||||
|
||||
expect(startContract).toBe(pluginContracts);
|
||||
expect(mockPluginSystem.addPlugin).toHaveBeenCalledTimes(2);
|
||||
expect(mockPluginSystem.addPlugin).toHaveBeenCalledWith(firstPlugin);
|
||||
expect(mockPluginSystem.addPlugin).toHaveBeenCalledWith(secondPlugin);
|
||||
|
||||
expect(mockDiscover).toHaveBeenCalledTimes(1);
|
||||
expect(mockDiscover).toHaveBeenCalledWith(
|
||||
{ initialize: true, paths: ['three', 'four'], scanDirs: ['one', 'two'] },
|
||||
{ branch: 'feature-v1', buildNum: 100, buildSha: 'feature-v1-build-sha', version: 'v1' },
|
||||
expect.objectContaining({
|
||||
debug: expect.any(Function),
|
||||
error: expect.any(Function),
|
||||
info: expect.any(Function),
|
||||
})
|
||||
{
|
||||
initialize: true,
|
||||
pluginSearchPaths: [
|
||||
resolve(process.cwd(), 'src', 'plugins'),
|
||||
resolve(process.cwd(), 'plugins'),
|
||||
resolve(process.cwd(), '..', 'kibana-extra'),
|
||||
],
|
||||
},
|
||||
{ env, logger, configService }
|
||||
);
|
||||
|
||||
expect(logger.mockCollect()).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"debug": Array [
|
||||
Array [
|
||||
"starting plugins service",
|
||||
],
|
||||
Array [
|
||||
"Marking config path as handled: plugins",
|
||||
],
|
||||
Array [
|
||||
"Discovered 2 plugins.",
|
||||
],
|
||||
],
|
||||
"error": Array [
|
||||
Array [
|
||||
[Error: Invalid JSON (invalid-manifest, path-1)],
|
||||
],
|
||||
Array [
|
||||
[Error: Incompatible version (incompatible-version, path-3)],
|
||||
],
|
||||
],
|
||||
"fatal": Array [],
|
||||
"info": Array [],
|
||||
"log": Array [],
|
||||
"trace": Array [],
|
||||
"warn": Array [],
|
||||
}
|
||||
`);
|
||||
const logs = logger.mockCollect();
|
||||
expect(logs.info).toHaveLength(0);
|
||||
expect(logs.error).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('`stop` stops plugins system', async () => {
|
||||
await pluginsService.stop();
|
||||
expect(mockPluginSystem.stopPlugins).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
|
|
@ -17,27 +17,54 @@
|
|||
* under the License.
|
||||
*/
|
||||
|
||||
import { filter, first, map, tap, toArray } from 'rxjs/operators';
|
||||
import { CoreService } from '../../types/core_service';
|
||||
import { ConfigService, Env } from '../config';
|
||||
import { Logger, LoggerFactory } from '../logging';
|
||||
import { discover, PluginDiscoveryErrorType } from './discovery';
|
||||
import { Observable } from 'rxjs';
|
||||
import { filter, first, mergeMap, tap, toArray } from 'rxjs/operators';
|
||||
import { CoreContext, CoreService } from '../../types';
|
||||
import { Logger } from '../logging';
|
||||
import { discover, PluginDiscoveryError, PluginDiscoveryErrorType } from './discovery';
|
||||
import { Plugin, PluginName } from './plugin';
|
||||
import { PluginsConfig } from './plugins_config';
|
||||
import { PluginsSystem } from './plugins_system';
|
||||
|
||||
export class PluginsService implements CoreService {
|
||||
/** @internal */
|
||||
export type PluginsServiceStartContract = Map<PluginName, unknown>;
|
||||
|
||||
/** @internal */
|
||||
export class PluginsService implements CoreService<PluginsServiceStartContract> {
|
||||
private readonly log: Logger;
|
||||
private readonly pluginsSystem: PluginsSystem;
|
||||
|
||||
constructor(
|
||||
private readonly env: Env,
|
||||
private readonly logger: LoggerFactory,
|
||||
private readonly configService: ConfigService
|
||||
) {
|
||||
this.log = logger.get('plugins', 'service');
|
||||
constructor(private readonly coreContext: CoreContext) {
|
||||
this.log = coreContext.logger.get('plugins-service');
|
||||
this.pluginsSystem = new PluginsSystem(coreContext);
|
||||
}
|
||||
|
||||
public async start() {
|
||||
this.log.debug('starting plugins service');
|
||||
this.log.debug('Starting plugins service');
|
||||
|
||||
const config = await this.coreContext.configService
|
||||
.atPath('plugins', PluginsConfig)
|
||||
.pipe(first())
|
||||
.toPromise();
|
||||
|
||||
const { error$, plugin$ } = discover(config, this.coreContext);
|
||||
await this.handleDiscoveryErrors(error$);
|
||||
await this.handleDiscoveredPlugins(plugin$);
|
||||
|
||||
if (!config.initialize || this.coreContext.env.isDevClusterMaster) {
|
||||
this.log.info('Plugin initialization disabled.');
|
||||
return new Map();
|
||||
}
|
||||
|
||||
return await this.pluginsSystem.startPlugins();
|
||||
}
|
||||
|
||||
public async stop() {
|
||||
this.log.debug('Stopping plugins service');
|
||||
await this.pluginsSystem.stopPlugins();
|
||||
}
|
||||
|
||||
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
|
||||
// platform and let legacy platform to handle it.
|
||||
|
@ -46,32 +73,65 @@ export class PluginsService implements CoreService {
|
|||
PluginDiscoveryErrorType.InvalidManifest,
|
||||
];
|
||||
|
||||
const { error$, plugin$ } = await this.configService
|
||||
.atPath('plugins', PluginsConfig)
|
||||
.pipe(
|
||||
first(),
|
||||
map(config =>
|
||||
discover(config, this.env.packageInfo, this.logger.get('plugins', 'discovery'))
|
||||
)
|
||||
)
|
||||
.toPromise();
|
||||
|
||||
await error$
|
||||
const errors = await error$
|
||||
.pipe(
|
||||
filter(error => errorTypesToReport.includes(error.type)),
|
||||
tap(invalidManifestError => this.log.error(invalidManifestError))
|
||||
tap(pluginError => this.log.error(pluginError)),
|
||||
toArray()
|
||||
)
|
||||
.toPromise();
|
||||
if (errors.length > 0) {
|
||||
throw new Error(
|
||||
`Failed to initialize plugins:${errors.map(err => `\n\t${err.message}`).join('')}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async handleDiscoveredPlugins(plugin$: Observable<Plugin>) {
|
||||
const pluginEnableStatuses = new Map<PluginName, { plugin: Plugin; isEnabled: boolean }>();
|
||||
await plugin$
|
||||
.pipe(
|
||||
toArray(),
|
||||
tap(plugins => this.log.debug(`Discovered ${plugins.length} plugins.`))
|
||||
mergeMap(async plugin => {
|
||||
const isEnabled = await this.coreContext.configService.isEnabledAtPath(plugin.configPath);
|
||||
|
||||
if (pluginEnableStatuses.has(plugin.name)) {
|
||||
throw new Error(`Plugin with id "${plugin.name}" is already registered!`);
|
||||
}
|
||||
|
||||
pluginEnableStatuses.set(plugin.name, {
|
||||
plugin,
|
||||
isEnabled,
|
||||
});
|
||||
})
|
||||
)
|
||||
.toPromise();
|
||||
|
||||
for (const [pluginName, { plugin, isEnabled }] of pluginEnableStatuses) {
|
||||
if (this.shouldEnablePlugin(pluginName, pluginEnableStatuses)) {
|
||||
this.pluginsSystem.addPlugin(plugin);
|
||||
} else if (isEnabled) {
|
||||
this.log.info(
|
||||
`Plugin "${pluginName}" has been disabled since some of its direct or transitive dependencies are missing or disabled.`
|
||||
);
|
||||
} else {
|
||||
this.log.info(`Plugin "${pluginName}" is disabled.`);
|
||||
}
|
||||
}
|
||||
|
||||
this.log.debug(`Discovered ${pluginEnableStatuses.size} plugins.`);
|
||||
}
|
||||
|
||||
public async stop() {
|
||||
this.log.debug('stopping plugins service');
|
||||
private shouldEnablePlugin(
|
||||
pluginName: PluginName,
|
||||
pluginEnableStatuses: Map<PluginName, { plugin: Plugin; isEnabled: boolean }>
|
||||
): boolean {
|
||||
const pluginInfo = pluginEnableStatuses.get(pluginName);
|
||||
return (
|
||||
pluginInfo !== undefined &&
|
||||
pluginInfo.isEnabled &&
|
||||
pluginInfo.plugin.requiredDependencies.every(dependencyName =>
|
||||
this.shouldEnablePlugin(dependencyName, pluginEnableStatuses)
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
183
src/core/server/plugins/plugins_system.test.ts
Normal file
183
src/core/server/plugins/plugins_system.test.ts
Normal file
|
@ -0,0 +1,183 @@
|
|||
/*
|
||||
* Licensed to Elasticsearch B.V. under one or more contributor
|
||||
* license agreements. See the NOTICE file distributed with
|
||||
* this work for additional information regarding copyright
|
||||
* ownership. Elasticsearch B.V. licenses this file to you under
|
||||
* the Apache License, Version 2.0 (the "License"); you may
|
||||
* not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import { CoreContext } from '../../types';
|
||||
|
||||
const mockCreatePluginStartContext = jest.fn();
|
||||
jest.mock('./plugin_context', () => ({
|
||||
createPluginStartContext: mockCreatePluginStartContext,
|
||||
}));
|
||||
|
||||
import { BehaviorSubject } from 'rxjs';
|
||||
import { Config, ConfigService, Env, ObjectToConfigAdapter } from '../config';
|
||||
import { getEnvOptions } from '../config/__mocks__/env';
|
||||
import { logger } from '../logging/__mocks__';
|
||||
import { Plugin, PluginName } from './plugin';
|
||||
import { PluginsSystem } from './plugins_system';
|
||||
|
||||
function createPlugin(
|
||||
id: string,
|
||||
{ required = [], optional = [] }: { required?: string[]; optional?: string[] } = {}
|
||||
) {
|
||||
return new Plugin(
|
||||
'some-path',
|
||||
{
|
||||
id,
|
||||
version: 'some-version',
|
||||
configPath: 'path',
|
||||
kibanaVersion: '7.0.0',
|
||||
requiredPlugins: required,
|
||||
optionalPlugins: optional,
|
||||
ui: true,
|
||||
},
|
||||
{ logger } as any
|
||||
);
|
||||
}
|
||||
|
||||
let pluginsSystem: PluginsSystem;
|
||||
let configService: ConfigService;
|
||||
let env: Env;
|
||||
let coreContext: CoreContext;
|
||||
beforeEach(() => {
|
||||
env = Env.createDefault(getEnvOptions());
|
||||
|
||||
configService = new ConfigService(
|
||||
new BehaviorSubject<Config>(new ObjectToConfigAdapter({ plugins: { initialize: true } })),
|
||||
env,
|
||||
logger
|
||||
);
|
||||
|
||||
coreContext = { env, logger, configService };
|
||||
|
||||
pluginsSystem = new PluginsSystem(coreContext);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
test('can be started even without plugins', async () => {
|
||||
const pluginsContracts = await pluginsSystem.startPlugins();
|
||||
|
||||
expect(pluginsContracts).toBeInstanceOf(Map);
|
||||
expect(pluginsContracts.size).toBe(0);
|
||||
});
|
||||
|
||||
test('`startPlugins` throws plugin has missing required dependency', async () => {
|
||||
pluginsSystem.addPlugin(createPlugin('some-id', { required: ['missing-dep'] }));
|
||||
|
||||
await expect(pluginsSystem.startPlugins()).rejects.toMatchInlineSnapshot(
|
||||
`[Error: Topological ordering of plugins did not complete, these edges could not be ordered: [["some-id",{}]]]`
|
||||
);
|
||||
});
|
||||
|
||||
test('`startPlugins` throws if plugins have circular required dependency', async () => {
|
||||
pluginsSystem.addPlugin(createPlugin('no-dep'));
|
||||
pluginsSystem.addPlugin(createPlugin('depends-on-1', { required: ['depends-on-2'] }));
|
||||
pluginsSystem.addPlugin(createPlugin('depends-on-2', { required: ['depends-on-1'] }));
|
||||
|
||||
await expect(pluginsSystem.startPlugins()).rejects.toMatchInlineSnapshot(
|
||||
`[Error: Topological ordering of plugins did not complete, these edges could not be ordered: [["depends-on-1",{}],["depends-on-2",{}]]]`
|
||||
);
|
||||
});
|
||||
|
||||
test('`startPlugins` throws if plugins have circular optional dependency', async () => {
|
||||
pluginsSystem.addPlugin(createPlugin('no-dep'));
|
||||
pluginsSystem.addPlugin(createPlugin('depends-on-1', { optional: ['depends-on-2'] }));
|
||||
pluginsSystem.addPlugin(createPlugin('depends-on-2', { optional: ['depends-on-1'] }));
|
||||
|
||||
await expect(pluginsSystem.startPlugins()).rejects.toMatchInlineSnapshot(
|
||||
`[Error: Topological ordering of plugins did not complete, these edges could not be ordered: [["depends-on-1",{}],["depends-on-2",{}]]]`
|
||||
);
|
||||
});
|
||||
|
||||
test('`startPlugins` ignores missing optional dependency', async () => {
|
||||
const plugin = createPlugin('some-id', { optional: ['missing-dep'] });
|
||||
jest.spyOn(plugin, 'start').mockResolvedValue('test');
|
||||
|
||||
pluginsSystem.addPlugin(plugin);
|
||||
|
||||
expect([...(await pluginsSystem.startPlugins())]).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
Array [
|
||||
"some-id",
|
||||
"test",
|
||||
],
|
||||
]
|
||||
`);
|
||||
});
|
||||
|
||||
test('`startPlugins` correctly orders plugins and returns exposed values', async () => {
|
||||
const plugins = new Map([
|
||||
[createPlugin('order-4', { required: ['order-2'] }), { 'order-2': 'added-as-2' }],
|
||||
[createPlugin('order-0'), {}],
|
||||
[
|
||||
createPlugin('order-2', { required: ['order-1'], optional: ['order-0'] }),
|
||||
{ 'order-1': 'added-as-3', 'order-0': 'added-as-1' },
|
||||
],
|
||||
[createPlugin('order-1', { required: ['order-0'] }), { 'order-0': 'added-as-1' }],
|
||||
[
|
||||
createPlugin('order-3', { required: ['order-2'], optional: ['missing-dep'] }),
|
||||
{ 'order-2': 'added-as-2' },
|
||||
],
|
||||
] as Array<[Plugin, Record<PluginName, unknown>]>);
|
||||
|
||||
const startContextMap = new Map();
|
||||
|
||||
[...plugins.keys()].forEach((plugin, index) => {
|
||||
jest.spyOn(plugin, 'start').mockResolvedValue(`added-as-${index}`);
|
||||
|
||||
startContextMap.set(plugin.name, `start-for-${plugin.name}`);
|
||||
|
||||
pluginsSystem.addPlugin(plugin);
|
||||
});
|
||||
|
||||
mockCreatePluginStartContext.mockImplementation((_, plugin) => startContextMap.get(plugin.name));
|
||||
|
||||
expect([...(await pluginsSystem.startPlugins())]).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
Array [
|
||||
"order-0",
|
||||
"added-as-1",
|
||||
],
|
||||
Array [
|
||||
"order-1",
|
||||
"added-as-3",
|
||||
],
|
||||
Array [
|
||||
"order-2",
|
||||
"added-as-2",
|
||||
],
|
||||
Array [
|
||||
"order-3",
|
||||
"added-as-4",
|
||||
],
|
||||
Array [
|
||||
"order-4",
|
||||
"added-as-0",
|
||||
],
|
||||
]
|
||||
`);
|
||||
|
||||
for (const [plugin, deps] of plugins) {
|
||||
expect(mockCreatePluginStartContext).toHaveBeenCalledWith(coreContext, plugin);
|
||||
expect(plugin.start).toHaveBeenCalledTimes(1);
|
||||
expect(plugin.start).toHaveBeenCalledWith(startContextMap.get(plugin.name), deps);
|
||||
}
|
||||
});
|
153
src/core/server/plugins/plugins_system.ts
Normal file
153
src/core/server/plugins/plugins_system.ts
Normal file
|
@ -0,0 +1,153 @@
|
|||
/*
|
||||
* Licensed to Elasticsearch B.V. under one or more contributor
|
||||
* license agreements. See the NOTICE file distributed with
|
||||
* this work for additional information regarding copyright
|
||||
* ownership. Elasticsearch B.V. licenses this file to you under
|
||||
* the Apache License, Version 2.0 (the "License"); you may
|
||||
* not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import { CoreContext } from '../../types';
|
||||
import { Logger } from '../logging';
|
||||
import { Plugin, PluginName } from './plugin';
|
||||
import { createPluginStartContext } from './plugin_context';
|
||||
|
||||
/** @internal */
|
||||
export class PluginsSystem {
|
||||
private readonly plugins = new Map<PluginName, Plugin>();
|
||||
private readonly log: Logger;
|
||||
private readonly startedPlugins: PluginName[] = [];
|
||||
|
||||
constructor(private readonly coreContext: CoreContext) {
|
||||
this.log = coreContext.logger.get('plugins-system');
|
||||
}
|
||||
|
||||
public addPlugin(plugin: Plugin) {
|
||||
this.plugins.set(plugin.name, plugin);
|
||||
}
|
||||
|
||||
public async startPlugins() {
|
||||
const exposedValues = new Map<PluginName, unknown>();
|
||||
if (this.plugins.size === 0) {
|
||||
return exposedValues;
|
||||
}
|
||||
|
||||
const sortedPlugins = this.getTopologicallySortedPluginNames();
|
||||
this.log.info(`Starting [${this.plugins.size}] plugins: [${[...sortedPlugins]}]`);
|
||||
|
||||
for (const pluginName of sortedPlugins) {
|
||||
this.log.debug(`Starting plugin "${pluginName}"...`);
|
||||
|
||||
const plugin = this.plugins.get(pluginName)!;
|
||||
const exposedDependencyValues = [
|
||||
...plugin.requiredDependencies,
|
||||
...plugin.optionalDependencies,
|
||||
].reduce(
|
||||
(dependencies, dependencyName) => {
|
||||
dependencies[dependencyName] = exposedValues.get(dependencyName);
|
||||
return dependencies;
|
||||
},
|
||||
{} as Record<PluginName, unknown>
|
||||
);
|
||||
|
||||
exposedValues.set(
|
||||
pluginName,
|
||||
await plugin.start(
|
||||
createPluginStartContext(this.coreContext, plugin),
|
||||
exposedDependencyValues
|
||||
)
|
||||
);
|
||||
|
||||
this.startedPlugins.push(pluginName);
|
||||
}
|
||||
|
||||
return exposedValues;
|
||||
}
|
||||
|
||||
public async stopPlugins() {
|
||||
if (this.plugins.size === 0 || this.startedPlugins.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.log.info(`Stopping all plugins.`);
|
||||
|
||||
// Stop plugins in the reverse order of when they were started.
|
||||
while (this.startedPlugins.length > 0) {
|
||||
const pluginName = this.startedPlugins.pop()!;
|
||||
|
||||
this.log.debug(`Stopping plugin "${pluginName}"...`);
|
||||
await this.plugins.get(pluginName)!.stop();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets topologically sorted plugin names that are registered with the plugin system.
|
||||
* Ordering is possible if and only if the plugins graph has no directed cycles,
|
||||
* that is, if it is a directed acyclic graph (DAG). If plugins cannot be ordered
|
||||
* an error is thrown.
|
||||
*
|
||||
* Uses Kahn's Algorithm to sort the graph.
|
||||
*/
|
||||
private getTopologicallySortedPluginNames() {
|
||||
// We clone plugins so we can remove handled nodes while we perform the
|
||||
// topological ordering. If the cloned graph is _not_ empty at the end, we
|
||||
// know we were not able to topologically order the graph. We exclude optional
|
||||
// dependencies that are not present in the plugins graph.
|
||||
const pluginsDependenciesGraph = new Map(
|
||||
[...this.plugins.entries()].map(([pluginName, plugin]) => {
|
||||
return [
|
||||
pluginName,
|
||||
new Set([
|
||||
...plugin.requiredDependencies,
|
||||
...plugin.optionalDependencies.filter(dependency => this.plugins.has(dependency)),
|
||||
]),
|
||||
] as [PluginName, Set<PluginName>];
|
||||
})
|
||||
);
|
||||
|
||||
// First, find a list of "start nodes" which have no outgoing edges. At least
|
||||
// one such node must exist in a non-empty acyclic graph.
|
||||
const pluginsWithAllDependenciesSorted = [...pluginsDependenciesGraph.keys()].filter(
|
||||
pluginName => pluginsDependenciesGraph.get(pluginName)!.size === 0
|
||||
);
|
||||
|
||||
const sortedPluginNames = new Set<PluginName>();
|
||||
while (pluginsWithAllDependenciesSorted.length > 0) {
|
||||
const sortedPluginName = pluginsWithAllDependenciesSorted.pop()!;
|
||||
|
||||
// We know this plugin has all its dependencies sorted, so we can remove it
|
||||
// and include into the final result.
|
||||
pluginsDependenciesGraph.delete(sortedPluginName);
|
||||
sortedPluginNames.add(sortedPluginName);
|
||||
|
||||
// Go through the rest of the plugins and remove `sortedPluginName` from their
|
||||
// unsorted dependencies.
|
||||
for (const [pluginName, dependencies] of pluginsDependenciesGraph) {
|
||||
// If we managed delete `sortedPluginName` from dependencies let's check
|
||||
// whether it was the last one and we can mark plugin as sorted.
|
||||
if (dependencies.delete(sortedPluginName) && dependencies.size === 0) {
|
||||
pluginsWithAllDependenciesSorted.push(pluginName);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (pluginsDependenciesGraph.size > 0) {
|
||||
const edgesLeft = JSON.stringify([...pluginsDependenciesGraph.entries()]);
|
||||
throw new Error(
|
||||
`Topological ordering of plugins did not complete, these edges could not be ordered: ${edgesLeft}`
|
||||
);
|
||||
}
|
||||
|
||||
return sortedPluginNames;
|
||||
}
|
||||
}
|
32
src/core/types/core_context.ts
Normal file
32
src/core/types/core_context.ts
Normal file
|
@ -0,0 +1,32 @@
|
|||
/*
|
||||
* Licensed to Elasticsearch B.V. under one or more contributor
|
||||
* license agreements. See the NOTICE file distributed with
|
||||
* this work for additional information regarding copyright
|
||||
* ownership. Elasticsearch B.V. licenses this file to you under
|
||||
* the Apache License, Version 2.0 (the "License"); you may
|
||||
* not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import { ConfigService, Env } from '../server/config';
|
||||
import { LoggerFactory } from '../server/logging';
|
||||
|
||||
/**
|
||||
* Groups all main Kibana's core modules/systems/services that are consumed in a
|
||||
* variety of places within the core itself.
|
||||
* @internal
|
||||
*/
|
||||
export interface CoreContext {
|
||||
env: Env;
|
||||
configService: ConfigService;
|
||||
logger: LoggerFactory;
|
||||
}
|
|
@ -17,7 +17,8 @@
|
|||
* under the License.
|
||||
*/
|
||||
|
||||
/** @internal */
|
||||
export interface CoreService<TStartContract = void> {
|
||||
start(): Promise<TStartContract>;
|
||||
start(...params: any[]): Promise<TStartContract>;
|
||||
stop(): Promise<void>;
|
||||
}
|
||||
|
|
21
src/core/types/index.ts
Normal file
21
src/core/types/index.ts
Normal file
|
@ -0,0 +1,21 @@
|
|||
/*
|
||||
* Licensed to Elasticsearch B.V. under one or more contributor
|
||||
* license agreements. See the NOTICE file distributed with
|
||||
* this work for additional information regarding copyright
|
||||
* ownership. Elasticsearch B.V. licenses this file to you under
|
||||
* the Apache License, Version 2.0 (the "License"); you may
|
||||
* not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
export { CoreContext } from './core_context';
|
||||
export { CoreService } from './core_service';
|
|
@ -35,6 +35,7 @@ export const CopySourceTask = {
|
|||
'!src/legacy/core_plugins/tests_bundle/**',
|
||||
'!src/legacy/core_plugins/testbed/**',
|
||||
'!src/legacy/core_plugins/console/public/tests/**',
|
||||
'!src/plugins/testbed/**',
|
||||
'!src/cli/cluster/**',
|
||||
'!src/es_archiver/**',
|
||||
'!src/functional_test_runner/**',
|
||||
|
|
|
@ -19,9 +19,10 @@
|
|||
|
||||
import { readFileSync } from 'fs';
|
||||
import * as Rx from 'rxjs';
|
||||
import { map, catchError } from 'rxjs/operators';
|
||||
import { map, mergeMap, catchError } from 'rxjs/operators';
|
||||
import { resolve } from 'path';
|
||||
import { createInvalidPackError } from '../errors';
|
||||
import { isNewPlatformPlugin } from '../../core/server/plugins';
|
||||
|
||||
import { isDirectory } from './lib';
|
||||
|
||||
|
@ -51,7 +52,10 @@ async function createPackageJsonAtPath(path) {
|
|||
}
|
||||
|
||||
export const createPackageJsonAtPath$ = (path) => (
|
||||
Rx.defer(() => createPackageJsonAtPath(path)).pipe(
|
||||
// If plugin directory contains manifest file, we should skip it since it
|
||||
// should have been handled by the core plugin system already.
|
||||
Rx.defer(() => isNewPlatformPlugin(path)).pipe(
|
||||
mergeMap(isNewPlatformPlugin => isNewPlatformPlugin ? [] : createPackageJsonAtPath(path)),
|
||||
map(packageJson => ({ packageJson })),
|
||||
catchError(error => [{ error }])
|
||||
)
|
||||
|
|
33
src/plugins/testbed/config.ts
Normal file
33
src/plugins/testbed/config.ts
Normal file
|
@ -0,0 +1,33 @@
|
|||
/*
|
||||
* Licensed to Elasticsearch B.V. under one or more contributor
|
||||
* license agreements. See the NOTICE file distributed with
|
||||
* this work for additional information regarding copyright
|
||||
* ownership. Elasticsearch B.V. licenses this file to you under
|
||||
* the Apache License, Version 2.0 (the "License"); you may
|
||||
* not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import { schema, TypeOf } from '@kbn/config-schema';
|
||||
|
||||
/** @internal */
|
||||
export class TestBedConfig {
|
||||
public static schema = schema.object({
|
||||
secret: schema.string({ defaultValue: 'Not really a secret :/' }),
|
||||
});
|
||||
|
||||
public readonly secret: string;
|
||||
|
||||
constructor(config: TypeOf<typeof TestBedConfig['schema']>) {
|
||||
this.secret = config.secret;
|
||||
}
|
||||
}
|
54
src/plugins/testbed/index.ts
Normal file
54
src/plugins/testbed/index.ts
Normal file
|
@ -0,0 +1,54 @@
|
|||
/*
|
||||
* Licensed to Elasticsearch B.V. under one or more contributor
|
||||
* license agreements. See the NOTICE file distributed with
|
||||
* this work for additional information regarding copyright
|
||||
* ownership. Elasticsearch B.V. licenses this file to you under
|
||||
* the Apache License, Version 2.0 (the "License"); you may
|
||||
* not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import { first } from 'rxjs/operators';
|
||||
import { Logger, PluginInitializerContext, PluginName, PluginStartContext } from '../../../';
|
||||
import { TestBedConfig } from './config';
|
||||
|
||||
class Plugin {
|
||||
private readonly log: Logger;
|
||||
|
||||
constructor(private readonly initializerContext: PluginInitializerContext) {
|
||||
this.log = this.initializerContext.logger.get();
|
||||
}
|
||||
|
||||
public async start(startContext: PluginStartContext, deps: Record<PluginName, unknown>) {
|
||||
this.log.debug(
|
||||
`Starting TestBed with core contract [${Object.keys(startContext)}] and deps [${Object.keys(
|
||||
deps
|
||||
)}]`
|
||||
);
|
||||
|
||||
const config = await this.initializerContext.config
|
||||
.create(TestBedConfig)
|
||||
.pipe(first())
|
||||
.toPromise();
|
||||
|
||||
this.log.debug(`I've got value from my config: ${config.secret}`);
|
||||
|
||||
return { secret: config.secret };
|
||||
}
|
||||
|
||||
public stop() {
|
||||
this.log.debug(`Stopping TestBed`);
|
||||
}
|
||||
}
|
||||
|
||||
export const plugin = (initializerContext: PluginInitializerContext) =>
|
||||
new Plugin(initializerContext);
|
6
src/plugins/testbed/kibana.json
Normal file
6
src/plugins/testbed/kibana.json
Normal file
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"id": "testbed",
|
||||
"version": "0.0.1",
|
||||
"kibanaVersion": "kibana",
|
||||
"configPath": ["core", "testbed"]
|
||||
}
|
|
@ -27,7 +27,13 @@ const getFlattenedKeys = object => (
|
|||
Object.keys(getFlattenedObject(object))
|
||||
);
|
||||
|
||||
async function getUnusedConfigKeys(plugins, disabledPluginSpecs, rawSettings, configValues) {
|
||||
async function getUnusedConfigKeys(
|
||||
coreHandledConfigPaths,
|
||||
plugins,
|
||||
disabledPluginSpecs,
|
||||
rawSettings,
|
||||
configValues
|
||||
) {
|
||||
// transform deprecated settings
|
||||
const transforms = [
|
||||
transformDeprecations,
|
||||
|
@ -50,7 +56,12 @@ async function getUnusedConfigKeys(plugins, disabledPluginSpecs, rawSettings, co
|
|||
inputKeys[inputKeys.indexOf('env')] = 'env.name';
|
||||
}
|
||||
|
||||
return difference(inputKeys, appliedKeys);
|
||||
// Filter out keys that are marked as used in the core (e.g. by new core plugins).
|
||||
return difference(inputKeys, appliedKeys).filter(
|
||||
unusedConfigKey => !coreHandledConfigPaths.some(
|
||||
usedInCoreConfigKey => unusedConfigKey.startsWith(usedInCoreConfigKey)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export default async function (kbnServer, server, config) {
|
||||
|
@ -60,6 +71,7 @@ export default async function (kbnServer, server, config) {
|
|||
});
|
||||
|
||||
const unusedKeys = await getUnusedConfigKeys(
|
||||
kbnServer.core.handledConfigPaths,
|
||||
kbnServer.plugins,
|
||||
kbnServer.disabledPluginSpecs,
|
||||
kbnServer.settings,
|
||||
|
|
|
@ -46,6 +46,7 @@ describe('server/config completeMixin()', function () {
|
|||
};
|
||||
|
||||
const kbnServer = {
|
||||
core: { handledConfigPaths: [] },
|
||||
settings,
|
||||
server,
|
||||
config,
|
||||
|
|
|
@ -27,22 +27,32 @@
|
|||
* in one of the known plugin locations (kibana/plugins/* or kibana-extra/*):
|
||||
*
|
||||
* ```ts
|
||||
* import { KibanaPlugin } from '../../kibana';
|
||||
* import { Logger, PluginInitializerContext, PluginStartContext } from '../../kibana';
|
||||
*
|
||||
* export interface SomePluginContract {
|
||||
* setValue: (val: string) => void;
|
||||
* }
|
||||
*
|
||||
* class SomePlugin extends KibanaPlugin<SomePluginContract> {
|
||||
* start(core) {
|
||||
* let value = 'Hello World!';
|
||||
* class Plugin {
|
||||
* private readonly log: Logger;
|
||||
*
|
||||
* const router = core.http.createAndRegisterRouter('/some-path');
|
||||
* router.get('/some-value', (req, res) => res.ok(value));
|
||||
* constructor(private readonly initializerContext: PluginInitializerContext) {
|
||||
* this.log = initializerContext.logger.get();
|
||||
* }
|
||||
*
|
||||
* return { setValue: (val: string) => { value = val; } };
|
||||
* start(startContext: PluginStartContext, deps: Record<string, any>) {
|
||||
* this.log.info('Hello from plugin!');
|
||||
*
|
||||
* let value = 'Hello World!';
|
||||
*
|
||||
* const router = startContext.http.createAndRegisterRouter('/some-path');
|
||||
* router.get('/some-value', (req, res) => res.ok(value));
|
||||
*
|
||||
* return { setValue: (val: string) => { value = val; } };
|
||||
* }
|
||||
* }
|
||||
*
|
||||
* export plugin = (initializerContext: PluginInitializerContext) => new Plugin(initializerContext));
|
||||
* ```
|
||||
*
|
||||
* **NOTE:** If the code is not needed in plugins, we can add a `at_internal` JSDoc
|
||||
|
@ -51,3 +61,4 @@
|
|||
*/
|
||||
|
||||
export { Logger, LoggerFactory } from './core/server/logging';
|
||||
export { PluginInitializerContext, PluginName, PluginStartContext } from './core/server/plugins';
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue