[6.x] Introduce support for the server-side new platform plugins. (#26655)

This commit is contained in:
Aleh Zasypkin 2018-12-04 22:59:33 +01:00 committed by GitHub
parent 1ab8e144df
commit 09cd080dba
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
45 changed files with 1811 additions and 422 deletions

View file

@ -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);
});

View file

@ -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 [],
}
`;

View file

@ -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",
}
`;

View file

@ -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 {
/**

View file

@ -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>,

View file

@ -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;

View file

@ -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';

View file

@ -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.

View 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 = {};

View file

@ -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;

View file

@ -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';

View file

@ -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 () => {

View file

@ -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() {

View file

@ -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;
}

View file

@ -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);
}
}

View file

@ -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'

View file

@ -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
);

View file

@ -87,7 +87,7 @@ Object {
Object {
"data": "some-message",
"tags": Array [
"trace",
"debug",
"some-context",
"sub-context",
"important",

View file

@ -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() {

View file

@ -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';

View file

@ -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'
)})`,

View file

@ -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) {

View file

@ -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: [],

View file

@ -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;
}

View file

@ -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 };
}

View file

@ -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);
}
}

View 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);
});

View 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;
}
}

View 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 {};
}

View file

@ -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;
}
}

View file

@ -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);
});

View file

@ -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)
)
);
}
}

View 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);
}
});

View 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;
}
}

View 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;
}

View file

@ -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
View 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';

View file

@ -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/**',

View file

@ -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 }])
)

View 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;
}
}

View 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);

View file

@ -0,0 +1,6 @@
{
"id": "testbed",
"version": "0.0.1",
"kibanaVersion": "kibana",
"configPath": ["core", "testbed"]
}

View file

@ -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,

View file

@ -46,6 +46,7 @@ describe('server/config completeMixin()', function () {
};
const kbnServer = {
core: { handledConfigPaths: [] },
settings,
server,
config,

View file

@ -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';