Revamp core environment class to support upcoming core<-->legacy bootstrap inversion. (#21885)

This commit is contained in:
Aleh Zasypkin 2018-08-23 22:31:32 +02:00 committed by GitHub
parent 35abc069e8
commit 60ae2f55ba
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 478 additions and 472 deletions

View file

@ -21,33 +21,14 @@
import { EnvOptions } from '../../env';
interface MockEnvOptions {
config?: string;
kbnServer?: any;
mode?: EnvOptions['mode']['name'];
packageInfo?: Partial<EnvOptions['packageInfo']>;
}
export function getEnvOptions({
config,
kbnServer,
mode = 'development',
packageInfo = {},
}: MockEnvOptions = {}): EnvOptions {
export function getEnvOptions(options: Partial<EnvOptions> = {}): EnvOptions {
return {
config,
kbnServer,
mode: {
dev: mode === 'development',
name: mode,
prod: mode === 'production',
},
packageInfo: {
branch: 'some-branch',
buildNum: 1,
buildSha: 'some-sha-256',
version: 'some-version',
...packageInfo,
configs: options.configs || [],
cliArgs: {
dev: true,
...(options.cliArgs || {}),
},
isDevClusterMaster:
options.isDevClusterMaster !== undefined ? options.isDevClusterMaster : false,
};
}

View file

@ -0,0 +1,149 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`correctly creates default environment in dev mode.: env properties 1`] = `
Env {
"binDir": "/test/cwd/bin",
"cliArgs": Object {
"dev": true,
"someArg": 1,
"someOtherArg": "2",
},
"configDir": "/test/cwd/config",
"configs": Array [
"/test/cwd/config/kibana.yml",
],
"corePluginsDir": "/test/cwd/core_plugins",
"homeDir": "/test/cwd",
"isDevClusterMaster": true,
"legacy": EventEmitter {
"_events": Object {},
"_eventsCount": 0,
"_maxListeners": undefined,
"domain": null,
},
"logDir": "/test/cwd/log",
"mode": Object {
"dev": true,
"name": "development",
"prod": false,
},
"packageInfo": Object {
"branch": "some-branch",
"buildNum": 9007199254740991,
"buildSha": "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX",
"version": "some-version",
},
"staticFilesDir": "/test/cwd/ui",
}
`;
exports[`correctly creates default environment in prod distributable mode.: env properties 1`] = `
Env {
"binDir": "/test/cwd/bin",
"cliArgs": Object {
"dev": false,
"someArg": 1,
"someOtherArg": "2",
},
"configDir": "/test/cwd/config",
"configs": Array [
"/some/other/path/some-kibana.yml",
],
"corePluginsDir": "/test/cwd/core_plugins",
"homeDir": "/test/cwd",
"isDevClusterMaster": false,
"legacy": EventEmitter {
"_events": Object {},
"_eventsCount": 0,
"_maxListeners": undefined,
"domain": null,
},
"logDir": "/test/cwd/log",
"mode": Object {
"dev": false,
"name": "production",
"prod": true,
},
"packageInfo": Object {
"branch": "feature-v1",
"buildNum": 100,
"buildSha": "feature-v1-build-sha",
"version": "v1",
},
"staticFilesDir": "/test/cwd/ui",
}
`;
exports[`correctly creates default environment in prod non-distributable mode.: env properties 1`] = `
Env {
"binDir": "/test/cwd/bin",
"cliArgs": Object {
"dev": false,
"someArg": 1,
"someOtherArg": "2",
},
"configDir": "/test/cwd/config",
"configs": Array [
"/some/other/path/some-kibana.yml",
],
"corePluginsDir": "/test/cwd/core_plugins",
"homeDir": "/test/cwd",
"isDevClusterMaster": false,
"legacy": EventEmitter {
"_events": Object {},
"_eventsCount": 0,
"_maxListeners": undefined,
"domain": null,
},
"logDir": "/test/cwd/log",
"mode": Object {
"dev": false,
"name": "production",
"prod": true,
},
"packageInfo": Object {
"branch": "feature-v1",
"buildNum": 9007199254740991,
"buildSha": "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX",
"version": "v1",
},
"staticFilesDir": "/test/cwd/ui",
}
`;
exports[`correctly creates environment with constructor.: env properties 1`] = `
Env {
"binDir": "/some/home/dir/bin",
"cliArgs": Object {
"dev": false,
"someArg": 1,
"someOtherArg": "2",
},
"configDir": "/some/home/dir/config",
"configs": Array [
"/some/other/path/some-kibana.yml",
],
"corePluginsDir": "/some/home/dir/core_plugins",
"homeDir": "/some/home/dir",
"isDevClusterMaster": false,
"legacy": EventEmitter {
"_events": Object {},
"_eventsCount": 0,
"_maxListeners": undefined,
"domain": null,
},
"logDir": "/some/home/dir/log",
"mode": Object {
"dev": false,
"name": "production",
"prod": true,
},
"packageInfo": Object {
"branch": "feature-v1",
"buildNum": 100,
"buildSha": "feature-v1-build-sha",
"version": "v1",
},
"staticFilesDir": "/some/home/dir/ui",
}
`;

View file

@ -18,8 +18,13 @@
*/
/* tslint:disable max-classes-per-file */
import { BehaviorSubject } from 'rxjs';
import { first } from 'rxjs/operators';
const mockPackage = new Proxy({ raw: {} as any }, { get: (obj, prop) => obj.raw[prop] });
jest.mock('../../../../utils/package_json', () => ({ pkg: mockPackage }));
import { schema, Type, TypeOf } from '../schema';
import { ConfigService, ObjectToRawConfigAdapter } from '..';
@ -161,21 +166,19 @@ test('tracks unhandled paths', async () => {
});
test('correctly passes context', async () => {
mockPackage.raw = {
branch: 'feature-v1',
version: 'v1',
build: {
distributable: true,
number: 100,
sha: 'feature-v1-build-sha',
},
};
const env = new Env('/kibana', getEnvOptions());
const config$ = new BehaviorSubject(new ObjectToRawConfigAdapter({ foo: {} }));
const env = new Env(
'/kibana',
getEnvOptions({
mode: 'development',
packageInfo: {
branch: 'feature-v1',
buildNum: 100,
buildSha: 'feature-v1-build-sha',
version: 'v1',
},
})
);
const configService = new ConfigService(config$, env, logger);
const configs = configService.atPath(
'foo',

View file

@ -29,76 +29,82 @@ jest.mock('path', () => ({
},
}));
const mockPackage = new Proxy({ raw: {} as any }, { get: (obj, prop) => obj.raw[prop] });
jest.mock('../../../../utils/package_json', () => ({ pkg: mockPackage }));
import { Env } from '../env';
import { getEnvOptions } from './__mocks__/env';
test('correctly creates default environment with empty options.', () => {
const envOptions = getEnvOptions();
const defaultEnv = Env.createDefault(envOptions);
test('correctly creates default environment in dev mode.', () => {
mockPackage.raw = {
branch: 'some-branch',
version: 'some-version',
};
expect(defaultEnv.homeDir).toEqual('/test/cwd');
expect(defaultEnv.configDir).toEqual('/test/cwd/config');
expect(defaultEnv.corePluginsDir).toEqual('/test/cwd/core_plugins');
expect(defaultEnv.binDir).toEqual('/test/cwd/bin');
expect(defaultEnv.logDir).toEqual('/test/cwd/log');
expect(defaultEnv.staticFilesDir).toEqual('/test/cwd/ui');
const defaultEnv = Env.createDefault({
cliArgs: { dev: true, someArg: 1, someOtherArg: '2' },
configs: ['/test/cwd/config/kibana.yml'],
isDevClusterMaster: true,
});
expect(defaultEnv.getConfigFile()).toEqual('/test/cwd/config/kibana.yml');
expect(defaultEnv.getLegacyKbnServer()).toBeUndefined();
expect(defaultEnv.getMode()).toEqual(envOptions.mode);
expect(defaultEnv.getPackageInfo()).toEqual(envOptions.packageInfo);
expect(defaultEnv).toMatchSnapshot('env properties');
});
test('correctly creates default environment with options overrides.', () => {
const mockEnvOptions = getEnvOptions({
config: '/some/other/path/some-kibana.yml',
kbnServer: {},
mode: 'production',
packageInfo: {
branch: 'feature-v1',
buildNum: 100,
buildSha: 'feature-v1-build-sha',
version: 'v1',
test('correctly creates default environment in prod distributable mode.', () => {
mockPackage.raw = {
branch: 'feature-v1',
version: 'v1',
build: {
distributable: true,
number: 100,
sha: 'feature-v1-build-sha',
},
};
const defaultEnv = Env.createDefault({
cliArgs: { dev: false, someArg: 1, someOtherArg: '2' },
configs: ['/some/other/path/some-kibana.yml'],
isDevClusterMaster: false,
});
const defaultEnv = Env.createDefault(mockEnvOptions);
expect(defaultEnv.homeDir).toEqual('/test/cwd');
expect(defaultEnv.configDir).toEqual('/test/cwd/config');
expect(defaultEnv.corePluginsDir).toEqual('/test/cwd/core_plugins');
expect(defaultEnv.binDir).toEqual('/test/cwd/bin');
expect(defaultEnv.logDir).toEqual('/test/cwd/log');
expect(defaultEnv.staticFilesDir).toEqual('/test/cwd/ui');
expect(defaultEnv).toMatchSnapshot('env properties');
});
expect(defaultEnv.getConfigFile()).toEqual(mockEnvOptions.config);
expect(defaultEnv.getLegacyKbnServer()).toBe(mockEnvOptions.kbnServer);
expect(defaultEnv.getMode()).toEqual(mockEnvOptions.mode);
expect(defaultEnv.getPackageInfo()).toEqual(mockEnvOptions.packageInfo);
test('correctly creates default environment in prod non-distributable mode.', () => {
mockPackage.raw = {
branch: 'feature-v1',
version: 'v1',
build: {
distributable: false,
number: 100,
sha: 'feature-v1-build-sha',
},
};
const defaultEnv = Env.createDefault({
cliArgs: { dev: false, someArg: 1, someOtherArg: '2' },
configs: ['/some/other/path/some-kibana.yml'],
isDevClusterMaster: false,
});
expect(defaultEnv).toMatchSnapshot('env properties');
});
test('correctly creates environment with constructor.', () => {
const mockEnvOptions = getEnvOptions({
config: '/some/other/path/some-kibana.yml',
mode: 'production',
packageInfo: {
branch: 'feature-v1',
buildNum: 100,
buildSha: 'feature-v1-build-sha',
version: 'v1',
mockPackage.raw = {
branch: 'feature-v1',
version: 'v1',
build: {
distributable: true,
number: 100,
sha: 'feature-v1-build-sha',
},
};
const env = new Env('/some/home/dir', {
cliArgs: { dev: false, someArg: 1, someOtherArg: '2' },
configs: ['/some/other/path/some-kibana.yml'],
isDevClusterMaster: false,
});
const defaultEnv = new Env('/some/home/dir', mockEnvOptions);
expect(defaultEnv.homeDir).toEqual('/some/home/dir');
expect(defaultEnv.configDir).toEqual('/some/home/dir/config');
expect(defaultEnv.corePluginsDir).toEqual('/some/home/dir/core_plugins');
expect(defaultEnv.binDir).toEqual('/some/home/dir/bin');
expect(defaultEnv.logDir).toEqual('/some/home/dir/log');
expect(defaultEnv.staticFilesDir).toEqual('/some/home/dir/ui');
expect(defaultEnv.getConfigFile()).toEqual(mockEnvOptions.config);
expect(defaultEnv.getLegacyKbnServer()).toBeUndefined();
expect(defaultEnv.getMode()).toEqual(mockEnvOptions.mode);
expect(defaultEnv.getPackageInfo()).toEqual(mockEnvOptions.packageInfo);
expect(env).toMatchSnapshot('env properties');
});

View file

@ -138,13 +138,12 @@ export class ConfigService {
);
}
const environmentMode = this.env.getMode();
const config = ConfigClass.schema.validate(
rawConfig,
{
dev: environmentMode.dev,
prod: environmentMode.prod,
...this.env.getPackageInfo(),
dev: this.env.mode.dev,
prod: this.env.mode.prod,
...this.env.packageInfo,
},
namespace
);

View file

@ -17,10 +17,11 @@
* under the License.
*/
import { EventEmitter } from 'events';
import { resolve } from 'path';
import process from 'process';
import { LegacyKbnServer } from '../legacy_compat';
import { pkg } from '../../../utils/package_json';
interface PackageInfo {
version: string;
@ -36,11 +37,9 @@ interface EnvironmentMode {
}
export interface EnvOptions {
config?: string;
kbnServer?: any;
packageInfo: PackageInfo;
mode: EnvironmentMode;
[key: string]: any;
configs: string[];
cliArgs: Record<string, any>;
isDevClusterMaster: boolean;
}
export class Env {
@ -57,44 +56,64 @@ export class Env {
public readonly logDir: string;
public readonly staticFilesDir: string;
/**
* Information about Kibana package (version, build number etc.).
*/
public readonly packageInfo: Readonly<PackageInfo>;
/**
* Mode Kibana currently run in (development or production).
*/
public readonly mode: Readonly<EnvironmentMode>;
/**
* @internal
*/
constructor(readonly homeDir: string, private readonly options: EnvOptions) {
public readonly legacy: EventEmitter;
/**
* Arguments provided through command line.
*/
public readonly cliArgs: Readonly<Record<string, any>>;
/**
* Paths to the configuration files.
*/
public readonly configs: ReadonlyArray<string>;
/**
* Indicates that this Kibana instance is run as development Node Cluster master.
*/
public readonly isDevClusterMaster: boolean;
/**
* @internal
*/
constructor(readonly homeDir: string, options: EnvOptions) {
this.configDir = resolve(this.homeDir, 'config');
this.corePluginsDir = resolve(this.homeDir, 'core_plugins');
this.binDir = resolve(this.homeDir, 'bin');
this.logDir = resolve(this.homeDir, 'log');
this.staticFilesDir = resolve(this.homeDir, 'ui');
}
public getConfigFile() {
const defaultConfigFile = this.getDefaultConfigFile();
return this.options.config === undefined ? defaultConfigFile : this.options.config;
}
this.cliArgs = Object.freeze(options.cliArgs);
this.configs = Object.freeze(options.configs);
this.isDevClusterMaster = options.isDevClusterMaster;
/**
* @internal
*/
public getLegacyKbnServer(): LegacyKbnServer | undefined {
return this.options.kbnServer;
}
this.mode = Object.freeze<EnvironmentMode>({
dev: this.cliArgs.dev,
name: this.cliArgs.dev ? 'development' : 'production',
prod: !this.cliArgs.dev,
});
/**
* Gets information about Kibana package (version, build number etc.).
*/
public getPackageInfo() {
return this.options.packageInfo;
}
const isKibanaDistributable = pkg.build && pkg.build.distributable === true;
this.packageInfo = Object.freeze({
branch: pkg.branch,
buildNum: isKibanaDistributable ? pkg.build.number : Number.MAX_SAFE_INTEGER,
buildSha: isKibanaDistributable ? pkg.build.sha : 'XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX',
version: pkg.version,
});
/**
* Gets mode Kibana currently run in (development or production).
*/
public getMode() {
return this.options.mode;
}
private getDefaultConfigFile() {
return resolve(this.configDir, 'kibana.yml');
this.legacy = new EventEmitter();
}
}

View file

@ -0,0 +1,22 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`broadcasts server and connection options to the legacy "channel" 1`] = `
Object {
"host": "127.0.0.1",
"port": 12345,
"routes": Object {
"cors": undefined,
"payload": Object {
"maxBytes": 1024,
},
"validate": Object {
"options": Object {
"abortEarly": false,
},
},
},
"state": Object {
"strictHeader": false,
},
}
`;

View file

@ -24,7 +24,6 @@ jest.mock('fs', () => ({
}));
import Chance from 'chance';
import http from 'http';
import supertest from 'supertest';
import { Env } from '../../config';
@ -36,6 +35,7 @@ import { Router } from '../router';
const chance = new Chance();
let env: Env;
let server: HttpServer;
let config: HttpConfig;
@ -51,7 +51,8 @@ beforeEach(() => {
ssl: {},
} as HttpConfig;
server = new HttpServer(logger.get(), new Env('/kibana', getEnvOptions()));
env = new Env('/kibana', getEnvOptions());
server = new HttpServer(logger.get(), env);
});
afterEach(async () => {
@ -563,99 +564,21 @@ describe('with defined `redirectHttpFromPort`', () => {
});
});
describe('when run within legacy platform', () => {
let newPlatformProxyListenerMock: any;
beforeEach(() => {
newPlatformProxyListenerMock = {
bind: jest.fn(),
proxy: jest.fn(),
};
test('broadcasts server and connection options to the legacy "channel"', async () => {
const onConnectionListener = jest.fn();
env.legacy.on('connection', onConnectionListener);
const kbnServerMock = {
newPlatformProxyListener: newPlatformProxyListenerMock,
};
expect(onConnectionListener).not.toHaveBeenCalled();
server = new HttpServer(
logger.get(),
new Env('/kibana', getEnvOptions({ kbnServer: kbnServerMock }))
);
const router = new Router('/new');
router.get({ path: '/', validate: false }, async (req, res) => {
return res.ok({ key: 'new-platform' });
});
server.registerRouter(router);
newPlatformProxyListenerMock.proxy.mockImplementation(
(req: http.IncomingMessage, res: http.ServerResponse) => {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ key: `legacy-platform:${req.url}` }));
}
);
await server.start({
...config,
port: 12345,
});
test('binds proxy listener to server.', async () => {
expect(newPlatformProxyListenerMock.bind).not.toHaveBeenCalled();
expect(onConnectionListener).toHaveBeenCalledTimes(1);
await server.start(config);
expect(newPlatformProxyListenerMock.bind).toHaveBeenCalledTimes(1);
expect(newPlatformProxyListenerMock.bind).toHaveBeenCalledWith(
expect.any((http as any).Server)
);
expect(newPlatformProxyListenerMock.bind.mock.calls[0][0]).toBe(getServerListener(server));
});
test('forwards request to legacy platform if new one cannot handle it', async () => {
await server.start(config);
await supertest(getServerListener(server))
.get('/legacy')
.expect(200)
.then(res => {
expect(res.body).toEqual({ key: 'legacy-platform:/legacy' });
expect(newPlatformProxyListenerMock.proxy).toHaveBeenCalledTimes(1);
expect(newPlatformProxyListenerMock.proxy).toHaveBeenCalledWith(
expect.any((http as any).IncomingMessage),
expect.any((http as any).ServerResponse)
);
});
});
test('forwards request to legacy platform and rewrites base path if needed', async () => {
await server.start({
...config,
basePath: '/bar',
rewriteBasePath: true,
});
await supertest(getServerListener(server))
.get('/legacy')
.expect(404);
await supertest(getServerListener(server))
.get('/bar/legacy')
.expect(200)
.then(res => {
expect(res.body).toEqual({ key: 'legacy-platform:/legacy' });
expect(newPlatformProxyListenerMock.proxy).toHaveBeenCalledTimes(1);
expect(newPlatformProxyListenerMock.proxy).toHaveBeenCalledWith(
expect.any((http as any).IncomingMessage),
expect.any((http as any).ServerResponse)
);
});
});
test('do not forward request to legacy platform if new one can handle it', async () => {
await server.start(config);
await supertest(getServerListener(server))
.get('/new/')
.expect(200)
.then(res => {
expect(res.body).toEqual({ key: 'new-platform' });
expect(newPlatformProxyListenerMock.proxy).not.toHaveBeenCalled();
});
});
const [[{ options, server: rawServer }]] = onConnectionListener.mock.calls;
expect(rawServer).toBeDefined();
expect(rawServer).toBe((server as any).server);
expect(options).toMatchSnapshot();
});

View file

@ -45,7 +45,10 @@ export class HttpServer {
}
public async start(config: HttpConfig) {
this.server = createServer(getServerOptions(config));
this.log.debug('starting http server');
const serverOptions = getServerOptions(config);
this.server = createServer(serverOptions);
this.setupBasePathRewrite(this.server, config);
@ -59,32 +62,13 @@ export class HttpServer {
}
}
const legacyKbnServer = this.env.getLegacyKbnServer();
if (legacyKbnServer !== undefined) {
legacyKbnServer.newPlatformProxyListener.bind(this.server.listener);
// We register Kibana proxy middleware right before we start server to allow
// all new platform plugins register their routes, so that `legacyKbnServer`
// handles only requests that aren't handled by the new platform.
this.server.route({
handler: ({ raw: { req, res } }, responseToolkit) => {
legacyKbnServer.newPlatformProxyListener.proxy(req, res);
return responseToolkit.abandon;
},
method: '*',
options: {
payload: {
output: 'stream',
parse: false,
timeout: false,
// Having such a large value here will allow legacy routes to override
// maximum allowed payload size set in the core http server if needed.
maxBytes: Number.MAX_SAFE_INTEGER,
},
},
path: '/{p*}',
});
}
// Notify legacy compatibility layer about HTTP(S) connection providing server
// instance with connection options so that we can properly bridge core and
// the "legacy" Kibana internally.
this.env.legacy.emit('connection', {
options: serverOptions,
server: this.server,
});
await this.server.start();
@ -96,12 +80,13 @@ export class HttpServer {
}
public async stop() {
this.log.info('stopping http server');
if (this.server !== undefined) {
await this.server.stop();
this.server = undefined;
if (this.server === undefined) {
return;
}
this.log.debug('stopping http server');
await this.server.stop();
this.server = undefined;
}
private setupBasePathRewrite(server: Server, config: HttpConfig) {

View file

@ -1,3 +1,21 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`correctly unbinds from the previous server. 1`] = `"Unhandled \\"error\\" event. (Error: Some error)"`;
exports[`correctly binds to the server.: proxy route options 1`] = `
Array [
Array [
Object {
"handler": [Function],
"method": "*",
"options": Object {
"payload": Object {
"maxBytes": 9007199254740991,
"output": "stream",
"parse": false,
"timeout": false,
},
},
"path": "/{p*}",
},
],
]
`;

View file

@ -1,31 +0,0 @@
/*
* 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 { LegacyKbnServer } from '..';
test('correctly returns `newPlatformProxyListener`.', () => {
const rawKbnServer = {
newPlatform: {
proxyListener: {},
},
};
const legacyKbnServer = new LegacyKbnServer(rawKbnServer);
expect(legacyKbnServer.newPlatformProxyListener).toBe(rawKbnServer.newPlatform.proxyListener);
});

View file

@ -17,131 +17,74 @@
* under the License.
*/
import { EventEmitter } from 'events';
import { IncomingMessage, ServerResponse } from 'http';
class MockNetServer extends EventEmitter {
public address() {
return { port: 1234, family: 'test-family', address: 'test-address' };
}
public getConnections(callback: (error: Error | null, count: number) => void) {
callback(null, 100500);
}
}
function mockNetServer() {
return new MockNetServer();
}
jest.mock('net', () => ({
createServer: jest.fn(() => mockNetServer()),
}));
import { createServer } from 'net';
import { Server as HapiServer } from 'hapi-latest';
import { Server } from 'net';
import { LegacyPlatformProxifier } from '..';
import { Env } from '../../config';
import { getEnvOptions } from '../../config/__tests__/__mocks__/env';
import { logger } from '../../logging/__mocks__';
let server: jest.Mocked<Server>;
let mockHapiServer: jest.Mocked<HapiServer>;
let root: any;
let proxifier: LegacyPlatformProxifier;
beforeEach(() => {
server = {
addListener: jest.fn(),
address: jest
.fn()
.mockReturnValue({ port: 1234, family: 'test-family', address: 'test-address' }),
getConnections: jest.fn(),
} as any;
mockHapiServer = { listener: server, route: jest.fn() } as any;
root = {
logger: {
get: jest.fn(() => ({
debug: jest.fn(),
info: jest.fn(),
})),
},
logger,
shutdown: jest.fn(),
start: jest.fn(),
} as any;
proxifier = new LegacyPlatformProxifier(root);
const env = new Env('/kibana', getEnvOptions());
proxifier = new LegacyPlatformProxifier(root, env);
env.legacy.emit('connection', {
server: mockHapiServer,
options: { someOption: 'foo', someAnotherOption: 'bar' },
});
});
test('correctly binds to the server.', () => {
const server = createServer();
jest.spyOn(server, 'addListener');
proxifier.bind(server);
expect(server.addListener).toHaveBeenCalledTimes(4);
for (const eventName of ['listening', 'error', 'clientError', 'connection']) {
expect(mockHapiServer.route.mock.calls).toMatchSnapshot('proxy route options');
expect(server.addListener).toHaveBeenCalledTimes(6);
for (const eventName of ['clientError', 'close', 'connection', 'error', 'listening', 'upgrade']) {
expect(server.addListener).toHaveBeenCalledWith(eventName, expect.any(Function));
}
});
test('correctly binds to the server and redirects its events.', () => {
const server = createServer();
proxifier.bind(server);
test('correctly redirects server events.', () => {
for (const eventName of ['clientError', 'close', 'connection', 'error', 'listening', 'upgrade']) {
expect(server.addListener).toHaveBeenCalledWith(eventName, expect.any(Function));
const eventsAndListeners = new Map(
['listening', 'error', 'clientError', 'connection'].map(eventName => {
const listener = jest.fn();
proxifier.addListener(eventName, listener);
return [eventName, listener] as [string, () => void];
})
);
for (const [eventName, listener] of eventsAndListeners) {
expect(listener).not.toHaveBeenCalled();
const listener = jest.fn();
proxifier.addListener(eventName, listener);
// Emit several events, to make sure that server is not being listened with `once`.
server.emit(eventName, 1, 2, 3, 4);
server.emit(eventName, 5, 6, 7, 8);
const [, serverListener] = server.addListener.mock.calls.find(
([serverEventName]) => serverEventName === eventName
)!;
serverListener(1, 2, 3, 4);
serverListener(5, 6, 7, 8);
expect(listener).toHaveBeenCalledTimes(2);
expect(listener).toHaveBeenCalledWith(1, 2, 3, 4);
expect(listener).toHaveBeenCalledWith(5, 6, 7, 8);
}
});
test('correctly unbinds from the previous server.', () => {
const previousServer = createServer();
proxifier.bind(previousServer);
const currentServer = createServer();
proxifier.bind(currentServer);
const eventsAndListeners = new Map(
['listening', 'error', 'clientError', 'connection'].map(eventName => {
const listener = jest.fn();
proxifier.addListener(eventName, listener);
return [eventName, listener] as [string, () => void];
})
);
// Any events from the previous server should not be forwarded.
for (const [eventName, listener] of eventsAndListeners) {
// `error` event is a special case in node, if `error` is emitted, but
// there is no listener for it error will be thrown.
if (eventName === 'error') {
expect(() =>
previousServer.emit(eventName, new Error('Some error'))
).toThrowErrorMatchingSnapshot();
} else {
previousServer.emit(eventName, 1, 2, 3, 4);
}
expect(listener).not.toHaveBeenCalled();
}
// Only events from the last server should be forwarded.
for (const [eventName, listener] of eventsAndListeners) {
expect(listener).not.toHaveBeenCalled();
currentServer.emit(eventName, 1, 2, 3, 4);
expect(listener).toHaveBeenCalledTimes(1);
expect(listener).toHaveBeenCalledWith(1, 2, 3, 4);
proxifier.removeListener(eventName, listener);
}
});
test('returns `address` from the underlying server.', () => {
expect(proxifier.address()).toBeUndefined();
proxifier.bind(createServer());
expect(proxifier.address()).toEqual({
address: 'test-address',
family: 'test-family',
@ -168,33 +111,35 @@ test('`close` shuts down the `root`.', async () => {
});
test('returns connection count from the underlying server.', () => {
server.getConnections.mockImplementation(callback => callback(null, 0));
const onGetConnectionsComplete = jest.fn();
proxifier.getConnections(onGetConnectionsComplete);
expect(onGetConnectionsComplete).toHaveBeenCalledTimes(1);
expect(onGetConnectionsComplete).toHaveBeenCalledWith(null, 0);
onGetConnectionsComplete.mockReset();
proxifier.bind(createServer());
server.getConnections.mockImplementation(callback => callback(null, 100500));
proxifier.getConnections(onGetConnectionsComplete);
expect(onGetConnectionsComplete).toHaveBeenCalledTimes(1);
expect(onGetConnectionsComplete).toHaveBeenCalledWith(null, 100500);
});
test('correctly proxies request and response objects.', () => {
test('proxy route abandons request processing and forwards it to the legacy Kibana', async () => {
const mockResponseToolkit = { response: jest.fn(), abandon: Symbol('abandon') };
const mockRequest = { raw: { req: { a: 1 }, res: { b: 2 } } };
const onRequest = jest.fn();
proxifier.addListener('request', onRequest);
const request = {} as IncomingMessage;
const response = {} as ServerResponse;
proxifier.proxy(request, response);
const [[{ handler }]] = mockHapiServer.route.mock.calls;
const response = await handler(mockRequest, mockResponseToolkit);
expect(response).toBe(mockResponseToolkit.abandon);
expect(mockResponseToolkit.response).not.toHaveBeenCalled();
// Make sure request hasn't been passed to the legacy platform.
expect(onRequest).toHaveBeenCalledTimes(1);
expect(onRequest).toHaveBeenCalledWith(request, response);
// Check that exactly same objects were passed as event arguments.
expect(onRequest.mock.calls[0][0]).toBe(request);
expect(onRequest.mock.calls[0][1]).toBe(response);
expect(onRequest).toHaveBeenCalledWith(mockRequest.raw.req, mockRequest.raw.res);
});

View file

@ -19,24 +19,18 @@
import { BehaviorSubject } from 'rxjs';
import { map } from 'rxjs/operators';
/** @internal */
export { LegacyPlatformProxifier } from './legacy_platform_proxifier';
/** @internal */
export { LegacyConfigToRawConfigAdapter, LegacyConfig } from './legacy_platform_config';
/** @internal */
export { LegacyKbnServer } from './legacy_kbn_server';
import {
LegacyConfig,
LegacyConfigToRawConfigAdapter,
LegacyKbnServer,
LegacyPlatformProxifier,
} from '.';
import { LegacyConfig, LegacyConfigToRawConfigAdapter, LegacyPlatformProxifier } from '.';
import { Env } from '../config';
import { Root } from '../root';
import { BasePathProxyRoot } from '../root/base_path_proxy_root';
function initEnvironment(rawKbnServer: any) {
function initEnvironment(rawKbnServer: any, isDevClusterMaster = false) {
const config: LegacyConfig = rawKbnServer.config;
const legacyConfig$ = new BehaviorSubject(config);
@ -45,12 +39,12 @@ function initEnvironment(rawKbnServer: any) {
);
const env = Env.createDefault({
kbnServer: new LegacyKbnServer(rawKbnServer),
// The defaults for the following parameters are retrieved by the legacy
// platform from the command line or from `package.json` and stored in the
// config, so we can borrow these parameters and avoid double parsing.
mode: config.get('env'),
packageInfo: config.get('pkg'),
// The core doesn't work with configs yet, everything is provided by the
// "legacy" Kibana, so we can have empty array here.
configs: [],
// `dev` is the only CLI argument we currently use.
cliArgs: { dev: config.get('env.dev') },
isDevClusterMaster,
});
return {
@ -71,12 +65,12 @@ export const injectIntoKbnServer = (rawKbnServer: any) => {
rawKbnServer.newPlatform = {
// Custom HTTP Listener that will be used within legacy platform by HapiJS server.
proxyListener: new LegacyPlatformProxifier(new Root(config$, env)),
proxyListener: new LegacyPlatformProxifier(new Root(config$, env), env),
updateConfig,
};
};
export const createBasePathProxy = (rawKbnServer: any) => {
const { env, config$ } = initEnvironment(rawKbnServer);
const { env, config$ } = initEnvironment(rawKbnServer, true /*isDevClusterMaster*/);
return new BasePathProxyRoot(config$, env);
};

View file

@ -1,34 +0,0 @@
/*
* 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.
*/
/**
* Represents a wrapper around legacy `kbnServer` instance that exposes only
* a subset of `kbnServer` APIs used by the new platform.
* @internal
*/
export class LegacyKbnServer {
constructor(private readonly rawKbnServer: any) {}
/**
* Custom HTTP Listener used by HapiJS server in the legacy platform.
*/
get newPlatformProxyListener() {
return this.rawKbnServer.newPlatform.proxyListener;
}
}

View file

@ -18,16 +18,29 @@
*/
import { EventEmitter } from 'events';
import { IncomingMessage, ServerResponse } from 'http';
import { Server } from 'net';
import { Server as HapiServer, ServerOptions as HapiServerOptions } from 'hapi-latest';
import { Env } from '../config';
import { Logger } from '../logging';
import { Root } from '../root';
interface ConnectionInfo {
server: HapiServer;
options: HapiServerOptions;
}
/**
* List of the server events to be forwarded to the legacy platform.
*/
const ServerEventsToForward = ['listening', 'error', 'clientError', 'connection'];
const ServerEventsToForward = [
'clientError',
'close',
'connection',
'error',
'listening',
'upgrade',
];
/**
* Represents "proxy" between legacy and current platform.
@ -38,7 +51,7 @@ export class LegacyPlatformProxifier extends EventEmitter {
private readonly log: Logger;
private server?: Server;
constructor(private readonly root: Root) {
constructor(private readonly root: Root, private readonly env: Env) {
super();
this.log = root.logger.get('legacy-platform-proxifier');
@ -56,6 +69,14 @@ export class LegacyPlatformProxifier extends EventEmitter {
] as [string, (...args: any[]) => void];
})
);
// Once core HTTP service is ready it broadcasts the internal server it relies on
// and server options that were used to create that server so that we can properly
// bridge with the "legacy" Kibana. If server isn't run (e.g. if process is managed
// by ClusterManager or optimizer) then this event will never fire.
this.env.legacy.once('connection', (connectionInfo: ConnectionInfo) =>
this.onConnection(connectionInfo)
);
}
/**
@ -116,31 +137,36 @@ export class LegacyPlatformProxifier extends EventEmitter {
}
}
/**
* Binds Http/Https server to the LegacyPlatformProxifier.
* @param server Server to bind to.
*/
public bind(server: Server) {
const oldServer = this.server;
this.server = server;
private onConnection({ server }: ConnectionInfo) {
this.server = server.listener;
for (const [eventName, eventHandler] of this.eventHandlers) {
if (oldServer !== undefined) {
oldServer.removeListener(eventName, eventHandler);
}
this.server.addListener(eventName, eventHandler);
}
}
/**
* Forwards request and response objects to the legacy platform.
* This method is used whenever new platform doesn't know how to handle the request.
* @param request Native Node request object instance.
* @param response Native Node response object instance.
*/
public proxy(request: IncomingMessage, response: ServerResponse) {
this.log.debug(`Request will be handled by proxy ${request.method}:${request.url}.`);
this.emit('request', request, response);
// We register Kibana proxy middleware right before we start server to allow
// all new platform plugins register their routes, so that `legacyProxy`
// handles only requests that aren't handled by the new platform.
server.route({
path: '/{p*}',
method: '*',
options: {
payload: {
output: 'stream',
parse: false,
timeout: false,
// Having such a large value here will allow legacy routes to override
// maximum allowed payload size set in the core http server if needed.
maxBytes: Number.MAX_SAFE_INTEGER,
},
},
handler: async ({ raw: { req, res } }, responseToolkit) => {
this.log.trace(`Request will be handled by proxy ${req.method}:${req.url}.`);
// Forward request and response objects to the legacy platform. This method
// is used whenever new platform doesn't know how to handle the request.
this.emit('request', req, res);
return responseToolkit.abandon;
},
});
}
}

View file

@ -22,5 +22,6 @@ import { dirname } from 'path';
export const pkg = {
__filename: require.resolve('../../package.json'),
__dirname: dirname(require.resolve('../../package.json')),
...require('../../package.json')
// tslint:disable no-var-requires
...require('../../package.json'),
};