diff --git a/package.json b/package.json index d1451ba2553fe9a..a8b9a6a4717dec4 100644 --- a/package.json +++ b/package.json @@ -240,7 +240,7 @@ "@types/redux-actions": "^2.2.1", "@types/sinon": "^5.0.0", "@types/strip-ansi": "^3.0.0", - "@types/supertest": "^2.0.4", + "@types/supertest": "^2.0.5", "@types/type-detect": "^4.0.1", "angular-mocks": "1.4.7", "babel-eslint": "8.1.2", @@ -318,8 +318,8 @@ "simple-git": "1.37.0", "sinon": "^5.0.7", "strip-ansi": "^3.0.1", - "supertest": "3.0.0", - "supertest-as-promised": "4.0.2", + "supertest": "^3.1.0", + "supertest-as-promised": "^4.0.2", "tree-kill": "^1.1.0", "ts-jest": "^22.4.6", "ts-loader": "^3.5.0", diff --git a/src/cli/cluster/_mock_cluster_fork.js b/src/cli/cluster/__mocks__/cluster.js similarity index 75% rename from src/cli/cluster/_mock_cluster_fork.js rename to src/cli/cluster/__mocks__/cluster.js index 4312f6a85c53ad7..14efc4b6f015042 100644 --- a/src/cli/cluster/_mock_cluster_fork.js +++ b/src/cli/cluster/__mocks__/cluster.js @@ -16,15 +16,14 @@ * specific language governing permissions and limitations * under the License. */ +/* eslint-env jest */ import EventEmitter from 'events'; import { assign, random } from 'lodash'; -import sinon from 'sinon'; -import cluster from 'cluster'; import { delay } from 'bluebird'; -export default class MockClusterFork extends EventEmitter { - constructor() { +class MockClusterFork extends EventEmitter { + constructor(cluster) { super(); let dead = true; @@ -35,7 +34,7 @@ export default class MockClusterFork extends EventEmitter { assign(this, { process: { - kill: sinon.spy(() => { + kill: jest.fn(() => { (async () => { await wait(); this.emit('disconnect'); @@ -46,13 +45,13 @@ export default class MockClusterFork extends EventEmitter { })(); }), }, - isDead: sinon.spy(() => dead), - send: sinon.stub() + isDead: jest.fn(() => dead), + send: jest.fn() }); - sinon.spy(this, 'on'); - sinon.spy(this, 'removeListener'); - sinon.spy(this, 'emit'); + jest.spyOn(this, 'on'); + jest.spyOn(this, 'removeListener'); + jest.spyOn(this, 'emit'); (async () => { await wait(); @@ -61,3 +60,12 @@ export default class MockClusterFork extends EventEmitter { })(); } } + +class MockCluster extends EventEmitter { + fork = jest.fn(() => new MockClusterFork(this)); + setupMaster = jest.fn(); +} + +export function mockCluster() { + return new MockCluster(); +} diff --git a/src/cli/cluster/cluster_manager.js b/src/cli/cluster/cluster_manager.js index 0a514138b09f2d2..5f7f3da35fcb8c4 100644 --- a/src/cli/cluster/cluster_manager.js +++ b/src/cli/cluster/cluster_manager.js @@ -19,31 +19,31 @@ import { resolve } from 'path'; import { debounce, invoke, bindAll, once, uniq } from 'lodash'; +import { fromEvent, race } from 'rxjs'; +import { first } from 'rxjs/operators'; import Log from '../log'; import Worker from './worker'; import { Config } from '../../server/config/config'; import { transformDeprecations } from '../../server/config/transform_deprecations'; -import { configureBasePathProxy } from './configure_base_path_proxy'; process.env.kbnWorkerType = 'managr'; export default class ClusterManager { - static async create(opts = {}, settings = {}) { - const transformedSettings = transformDeprecations(settings); - const config = Config.withDefaultSchema(transformedSettings); - - const basePathProxy = opts.basePath - ? await configureBasePathProxy(config) - : undefined; - - return new ClusterManager(opts, config, basePathProxy); + static create(opts, settings = {}, basePathProxy) { + return new ClusterManager( + opts, + Config.withDefaultSchema(transformDeprecations(settings)), + basePathProxy + ); } constructor(opts, config, basePathProxy) { this.log = new Log(opts.quiet, opts.silent); this.addedCount = 0; this.inReplMode = !!opts.repl; + this.basePathProxy = basePathProxy; + this.config = config; const serverArgv = []; const optimizerArgv = [ @@ -51,17 +51,15 @@ export default class ClusterManager { '--server.autoListen=false', ]; - if (basePathProxy) { - this.basePathProxy = basePathProxy; - + if (this.basePathProxy) { optimizerArgv.push( - `--server.basePath=${this.basePathProxy.getBasePath()}`, + `--server.basePath=${this.basePathProxy.basePath}`, '--server.rewriteBasePath=true', ); serverArgv.push( - `--server.port=${this.basePathProxy.getTargetPort()}`, - `--server.basePath=${this.basePathProxy.getBasePath()}`, + `--server.port=${this.basePathProxy.targetPort}`, + `--server.basePath=${this.basePathProxy.basePath}`, '--server.rewriteBasePath=true', ); } @@ -82,12 +80,6 @@ export default class ClusterManager { }) ]; - if (basePathProxy) { - // Pass server worker to the basepath proxy so that it can hold off the - // proxying until server worker is ready. - this.basePathProxy.serverWorker = this.server; - } - // broker messages between workers this.workers.forEach((worker) => { worker.on('broadcast', (msg) => { @@ -130,7 +122,10 @@ export default class ClusterManager { this.setupManualRestart(); invoke(this.workers, 'start'); if (this.basePathProxy) { - this.basePathProxy.start(); + this.basePathProxy.start({ + blockUntil: this.blockUntil.bind(this), + shouldRedirectFromOldBasePath: this.shouldRedirectFromOldBasePath.bind(this), + }); } } @@ -222,4 +217,23 @@ export default class ClusterManager { this.log.bad('failed to watch files!\n', err.stack); process.exit(1); // eslint-disable-line no-process-exit } + + shouldRedirectFromOldBasePath(path) { + const isApp = path.startsWith('app/'); + const isKnownShortPath = ['login', 'logout', 'status'].includes(path); + + return isApp || isKnownShortPath; + } + + blockUntil() { + // Wait until `server` worker either crashes or starts to listen. + if (this.server.listening || this.server.crashed) { + return Promise.resolve(); + } + + return race( + fromEvent(this.server, 'listening'), + fromEvent(this.server, 'crashed') + ).pipe(first()).toPromise(); + } } diff --git a/src/cli/cluster/cluster_manager.test.js b/src/cli/cluster/cluster_manager.test.js index b80ee62da29c315..558ac86721f6d35 100644 --- a/src/cli/cluster/cluster_manager.test.js +++ b/src/cli/cluster/cluster_manager.test.js @@ -17,36 +17,43 @@ * under the License. */ -import sinon from 'sinon'; +import { mockCluster }from './__mocks__/cluster'; +jest.mock('cluster', () => mockCluster()); +jest.mock('readline', () => ({ + createInterface: jest.fn(() => ({ + on: jest.fn(), + prompt: jest.fn(), + setPrompt: jest.fn(), + })), +})); + import cluster from 'cluster'; import { sample } from 'lodash'; import ClusterManager from './cluster_manager'; import Worker from './worker'; -describe('CLI cluster manager', function () { - const sandbox = sinon.createSandbox(); - - beforeEach(function () { - sandbox.stub(cluster, 'fork').callsFake(() => { +describe('CLI cluster manager', () => { + beforeEach(() => { + cluster.fork.mockImplementation(() => { return { process: { - kill: sinon.stub(), + kill: jest.fn(), }, - isDead: sinon.stub().returns(false), - removeListener: sinon.stub(), - on: sinon.stub(), - send: sinon.stub() + isDead: jest.fn().mockReturnValue(false), + removeListener: jest.fn(), + addListener: jest.fn(), + send: jest.fn() }; }); }); - afterEach(function () { - sandbox.restore(); + afterEach(() => { + cluster.fork.mockReset(); }); - it('has two workers', async function () { - const manager = await ClusterManager.create({}); + test('has two workers', () => { + const manager = ClusterManager.create({}); expect(manager.workers).toHaveLength(2); for (const worker of manager.workers) expect(worker).toBeInstanceOf(Worker); @@ -55,8 +62,8 @@ describe('CLI cluster manager', function () { expect(manager.server).toBeInstanceOf(Worker); }); - it('delivers broadcast messages to other workers', async function () { - const manager = await ClusterManager.create({}); + test('delivers broadcast messages to other workers', () => { + const manager = ClusterManager.create({}); for (const worker of manager.workers) { Worker.prototype.start.call(worker);// bypass the debounced start method @@ -69,10 +76,111 @@ describe('CLI cluster manager', function () { messenger.emit('broadcast', football); for (const worker of manager.workers) { if (worker === messenger) { - expect(worker.fork.send.callCount).toBe(0); + expect(worker.fork.send).not.toHaveBeenCalled(); } else { - expect(worker.fork.send.firstCall.args[0]).toBe(football); + expect(worker.fork.send).toHaveBeenCalledTimes(1); + expect(worker.fork.send).toHaveBeenCalledWith(football); } } }); + + describe('interaction with BasePathProxy', () => { + test('correctly configures `BasePathProxy`.', async () => { + const basePathProxyMock = { start: jest.fn() }; + + ClusterManager.create({}, {}, basePathProxyMock); + + expect(basePathProxyMock.start).toHaveBeenCalledWith({ + shouldRedirectFromOldBasePath: expect.any(Function), + blockUntil: expect.any(Function), + }); + }); + + describe('proxy is configured with the correct `shouldRedirectFromOldBasePath` and `blockUntil` functions.', () => { + let clusterManager; + let shouldRedirectFromOldBasePath; + let blockUntil; + beforeEach(async () => { + const basePathProxyMock = { start: jest.fn() }; + + clusterManager = ClusterManager.create({}, {}, basePathProxyMock); + + jest.spyOn(clusterManager.server, 'addListener'); + jest.spyOn(clusterManager.server, 'removeListener'); + + [[{ blockUntil, shouldRedirectFromOldBasePath }]] = basePathProxyMock.start.mock.calls; + }); + + test('`shouldRedirectFromOldBasePath()` returns `false` for unknown paths.', () => { + expect(shouldRedirectFromOldBasePath('')).toBe(false); + expect(shouldRedirectFromOldBasePath('some-path/')).toBe(false); + expect(shouldRedirectFromOldBasePath('some-other-path')).toBe(false); + }); + + test('`shouldRedirectFromOldBasePath()` returns `true` for `app` and other known paths.', () => { + expect(shouldRedirectFromOldBasePath('app/')).toBe(true); + expect(shouldRedirectFromOldBasePath('login')).toBe(true); + expect(shouldRedirectFromOldBasePath('logout')).toBe(true); + expect(shouldRedirectFromOldBasePath('status')).toBe(true); + }); + + test('`blockUntil()` resolves immediately if worker has already crashed.', async () => { + clusterManager.server.crashed = true; + + await expect(blockUntil()).resolves.not.toBeDefined(); + expect(clusterManager.server.addListener).not.toHaveBeenCalled(); + expect(clusterManager.server.removeListener).not.toHaveBeenCalled(); + }); + + test('`blockUntil()` resolves immediately if worker is already listening.', async () => { + clusterManager.server.listening = true; + + await expect(blockUntil()).resolves.not.toBeDefined(); + expect(clusterManager.server.addListener).not.toHaveBeenCalled(); + expect(clusterManager.server.removeListener).not.toHaveBeenCalled(); + }); + + test('`blockUntil()` resolves when worker crashes.', async () => { + const blockUntilPromise = blockUntil(); + + expect(clusterManager.server.addListener).toHaveBeenCalledTimes(2); + expect(clusterManager.server.addListener).toHaveBeenCalledWith( + 'crashed', + expect.any(Function) + ); + + const [, [eventName, onCrashed]] = clusterManager.server.addListener.mock.calls; + // Check event name to make sure we call the right callback, + // in Jest 23 we could use `toHaveBeenNthCalledWith` instead. + expect(eventName).toBe('crashed'); + expect(clusterManager.server.removeListener).not.toHaveBeenCalled(); + + onCrashed(); + await expect(blockUntilPromise).resolves.not.toBeDefined(); + + expect(clusterManager.server.removeListener).toHaveBeenCalledTimes(2); + }); + + test('`blockUntil()` resolves when worker starts listening.', async () => { + const blockUntilPromise = blockUntil(); + + expect(clusterManager.server.addListener).toHaveBeenCalledTimes(2); + expect(clusterManager.server.addListener).toHaveBeenCalledWith( + 'listening', + expect.any(Function) + ); + + const [[eventName, onListening]] = clusterManager.server.addListener.mock.calls; + // Check event name to make sure we call the right callback, + // in Jest 23 we could use `toHaveBeenNthCalledWith` instead. + expect(eventName).toBe('listening'); + expect(clusterManager.server.removeListener).not.toHaveBeenCalled(); + + onListening(); + await expect(blockUntilPromise).resolves.not.toBeDefined(); + + expect(clusterManager.server.removeListener).toHaveBeenCalledTimes(2); + }); + }); + }); }); diff --git a/src/cli/cluster/configure_base_path_proxy.js b/src/cli/cluster/configure_base_path_proxy.js deleted file mode 100644 index 477b10053d1e661..000000000000000 --- a/src/cli/cluster/configure_base_path_proxy.js +++ /dev/null @@ -1,64 +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 { Server } from 'hapi'; -import { createBasePathProxy } from '../../core'; -import { setupLogging } from '../../server/logging'; - -export async function configureBasePathProxy(config) { - // New platform forwards all logs to the legacy platform so we need HapiJS server - // here just for logging purposes and nothing else. - const server = new Server(); - setupLogging(server, config); - - const basePathProxy = createBasePathProxy({ server, config }); - - await basePathProxy.configure({ - shouldRedirectFromOldBasePath: path => { - const isApp = path.startsWith('app/'); - const isKnownShortPath = ['login', 'logout', 'status'].includes(path); - - return isApp || isKnownShortPath; - }, - - blockUntil: () => { - // Wait until `serverWorker either crashes or starts to listen. - // The `serverWorker` property should be set by the ClusterManager - // once it creates the worker. - const serverWorker = basePathProxy.serverWorker; - if (serverWorker.listening || serverWorker.crashed) { - return Promise.resolve(); - } - - return new Promise(resolve => { - const done = () => { - serverWorker.removeListener('listening', done); - serverWorker.removeListener('crashed', done); - - resolve(); - }; - - serverWorker.on('listening', done); - serverWorker.on('crashed', done); - }); - }, - }); - - return basePathProxy; -} diff --git a/src/cli/cluster/configure_base_path_proxy.test.js b/src/cli/cluster/configure_base_path_proxy.test.js deleted file mode 100644 index 01cbaf0bcc9008f..000000000000000 --- a/src/cli/cluster/configure_base_path_proxy.test.js +++ /dev/null @@ -1,163 +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. - */ - -jest.mock('../../core', () => ({ - createBasePathProxy: jest.fn(), -})); - -jest.mock('../../server/logging', () => ({ - setupLogging: jest.fn(), -})); - -import { Server } from 'hapi'; -import { createBasePathProxy as createBasePathProxyMock } from '../../core'; -import { setupLogging as setupLoggingMock } from '../../server/logging'; -import { configureBasePathProxy } from './configure_base_path_proxy'; - -describe('configureBasePathProxy()', () => { - it('returns `BasePathProxy` instance.', async () => { - const basePathProxyMock = { configure: jest.fn() }; - createBasePathProxyMock.mockReturnValue(basePathProxyMock); - - const basePathProxy = await configureBasePathProxy({}); - - expect(basePathProxy).toBe(basePathProxyMock); - }); - - it('correctly configures `BasePathProxy`.', async () => { - const configMock = {}; - const basePathProxyMock = { configure: jest.fn() }; - createBasePathProxyMock.mockReturnValue(basePathProxyMock); - - await configureBasePathProxy(configMock); - - // Check that logging is configured with the right parameters. - expect(setupLoggingMock).toHaveBeenCalledWith( - expect.any(Server), - configMock - ); - - const [[server]] = setupLoggingMock.mock.calls; - expect(createBasePathProxyMock).toHaveBeenCalledWith({ - config: configMock, - server, - }); - - expect(basePathProxyMock.configure).toHaveBeenCalledWith({ - shouldRedirectFromOldBasePath: expect.any(Function), - blockUntil: expect.any(Function), - }); - }); - - describe('configured with the correct `shouldRedirectFromOldBasePath` and `blockUntil` functions.', async () => { - let serverWorkerMock; - let shouldRedirectFromOldBasePath; - let blockUntil; - beforeEach(async () => { - serverWorkerMock = { - listening: false, - crashed: false, - on: jest.fn(), - removeListener: jest.fn(), - }; - - const basePathProxyMock = { - configure: jest.fn(), - serverWorker: serverWorkerMock, - }; - - createBasePathProxyMock.mockReturnValue(basePathProxyMock); - - await configureBasePathProxy({}); - - [[{ blockUntil, shouldRedirectFromOldBasePath }]] = basePathProxyMock.configure.mock.calls; - }); - - it('`shouldRedirectFromOldBasePath()` returns `false` for unknown paths.', async () => { - expect(shouldRedirectFromOldBasePath('')).toBe(false); - expect(shouldRedirectFromOldBasePath('some-path/')).toBe(false); - expect(shouldRedirectFromOldBasePath('some-other-path')).toBe(false); - }); - - it('`shouldRedirectFromOldBasePath()` returns `true` for `app` and other known paths.', async () => { - expect(shouldRedirectFromOldBasePath('app/')).toBe(true); - expect(shouldRedirectFromOldBasePath('login')).toBe(true); - expect(shouldRedirectFromOldBasePath('logout')).toBe(true); - expect(shouldRedirectFromOldBasePath('status')).toBe(true); - }); - - it('`blockUntil()` resolves immediately if worker has already crashed.', async () => { - serverWorkerMock.crashed = true; - - await expect(blockUntil()).resolves.not.toBeDefined(); - expect(serverWorkerMock.on).not.toHaveBeenCalled(); - expect(serverWorkerMock.removeListener).not.toHaveBeenCalled(); - }); - - it('`blockUntil()` resolves immediately if worker is already listening.', async () => { - serverWorkerMock.listening = true; - - await expect(blockUntil()).resolves.not.toBeDefined(); - expect(serverWorkerMock.on).not.toHaveBeenCalled(); - expect(serverWorkerMock.removeListener).not.toHaveBeenCalled(); - }); - - it('`blockUntil()` resolves when worker crashes.', async () => { - const blockUntilPromise = blockUntil(); - - expect(serverWorkerMock.on).toHaveBeenCalledTimes(2); - expect(serverWorkerMock.on).toHaveBeenCalledWith( - 'crashed', - expect.any(Function) - ); - - const [, [eventName, onCrashed]] = serverWorkerMock.on.mock.calls; - // Check event name to make sure we call the right callback, - // in Jest 23 we could use `toHaveBeenNthCalledWith` instead. - expect(eventName).toBe('crashed'); - expect(serverWorkerMock.removeListener).not.toHaveBeenCalled(); - - onCrashed(); - await expect(blockUntilPromise).resolves.not.toBeDefined(); - - expect(serverWorkerMock.removeListener).toHaveBeenCalledTimes(2); - }); - - it('`blockUntil()` resolves when worker starts listening.', async () => { - const blockUntilPromise = blockUntil(); - - expect(serverWorkerMock.on).toHaveBeenCalledTimes(2); - expect(serverWorkerMock.on).toHaveBeenCalledWith( - 'listening', - expect.any(Function) - ); - - const [[eventName, onListening]] = serverWorkerMock.on.mock.calls; - // Check event name to make sure we call the right callback, - // in Jest 23 we could use `toHaveBeenNthCalledWith` instead. - expect(eventName).toBe('listening'); - expect(serverWorkerMock.removeListener).not.toHaveBeenCalled(); - - onListening(); - await expect(blockUntilPromise).resolves.not.toBeDefined(); - - expect(serverWorkerMock.removeListener).toHaveBeenCalledTimes(2); - }); - }); -}); diff --git a/src/cli/cluster/worker.test.js b/src/cli/cluster/worker.test.js index c166956bcbf348e..b92a20865b1c17b 100644 --- a/src/cli/cluster/worker.test.js +++ b/src/cli/cluster/worker.test.js @@ -17,26 +17,25 @@ * under the License. */ -import sinon from 'sinon'; +import { mockCluster }from './__mocks__/cluster'; +jest.mock('cluster', () => mockCluster()); + import cluster from 'cluster'; -import { findIndex } from 'lodash'; -import MockClusterFork from './_mock_cluster_fork'; import Worker from './worker'; import Log from '../log'; const workersToShutdown = []; function assertListenerAdded(emitter, event) { - sinon.assert.calledWith(emitter.on, event); + expect(emitter.on).toHaveBeenCalledWith(event, expect.any(Function)); } function assertListenerRemoved(emitter, event) { - sinon.assert.calledWith( - emitter.removeListener, - event, - emitter.on.args[findIndex(emitter.on.args, { 0: event })][1] - ); + const [, onEventListener] = emitter.on.mock.calls.find(([eventName]) => { + return eventName === event; + }); + expect(emitter.removeListener).toHaveBeenCalledWith(event, onEventListener); } function setup(opts = {}) { @@ -50,81 +49,75 @@ function setup(opts = {}) { return worker; } -describe('CLI cluster manager', function () { - const sandbox = sinon.createSandbox(); - - beforeEach(function () { - sandbox.stub(cluster, 'fork').callsFake(() => new MockClusterFork()); - }); +describe('CLI cluster manager', () => { + afterEach(async () => { + cluster.fork.mockClear(); - afterEach(async function () { - sandbox.restore(); - - for (const worker of workersToShutdown) { - await worker.shutdown(); + while(workersToShutdown.length > 0) { + await workersToShutdown.pop().shutdown(); } }); - describe('#onChange', function () { - describe('opts.watch = true', function () { - it('restarts the fork', function () { + describe('#onChange', () => { + describe('opts.watch = true', () => { + test('restarts the fork', () => { const worker = setup({ watch: true }); - sinon.stub(worker, 'start'); + jest.spyOn(worker, 'start').mockImplementation(() => {}); worker.onChange('/some/path'); expect(worker.changes).toEqual(['/some/path']); - sinon.assert.calledOnce(worker.start); + expect(worker.start).toHaveBeenCalledTimes(1); }); }); - describe('opts.watch = false', function () { - it('does not restart the fork', function () { + describe('opts.watch = false', () => { + test('does not restart the fork', () => { const worker = setup({ watch: false }); - sinon.stub(worker, 'start'); + jest.spyOn(worker, 'start').mockImplementation(() => {}); worker.onChange('/some/path'); expect(worker.changes).toEqual([]); - sinon.assert.notCalled(worker.start); + expect(worker.start).not.toHaveBeenCalled(); }); }); }); - describe('#shutdown', function () { - describe('after starting()', function () { - it('kills the worker and unbinds from message, online, and disconnect events', async function () { + describe('#shutdown', () => { + describe('after starting()', () => { + test('kills the worker and unbinds from message, online, and disconnect events', async () => { const worker = setup(); await worker.start(); expect(worker).toHaveProperty('online', true); const fork = worker.fork; - sinon.assert.notCalled(fork.process.kill); + expect(fork.process.kill).not.toHaveBeenCalled(); assertListenerAdded(fork, 'message'); assertListenerAdded(fork, 'online'); assertListenerAdded(fork, 'disconnect'); worker.shutdown(); - sinon.assert.calledOnce(fork.process.kill); + expect(fork.process.kill).toHaveBeenCalledTimes(1); assertListenerRemoved(fork, 'message'); assertListenerRemoved(fork, 'online'); assertListenerRemoved(fork, 'disconnect'); }); }); - describe('before being started', function () { - it('does nothing', function () { + describe('before being started', () => { + test('does nothing', () => { const worker = setup(); worker.shutdown(); }); }); }); - describe('#parseIncomingMessage()', function () { - describe('on a started worker', function () { - it(`is bound to fork's message event`, async function () { + describe('#parseIncomingMessage()', () => { + describe('on a started worker', () => { + test(`is bound to fork's message event`, async () => { const worker = setup(); await worker.start(); - sinon.assert.calledWith(worker.fork.on, 'message'); + expect(worker.fork.on).toHaveBeenCalledWith('message', expect.any(Function)); }); }); - describe('do after', function () { - it('ignores non-array messages', function () { + describe('do after', () => { + test('ignores non-array messages', () => { const worker = setup(); worker.parseIncomingMessage('some string thing'); worker.parseIncomingMessage(0); @@ -134,39 +127,39 @@ describe('CLI cluster manager', function () { worker.parseIncomingMessage(/weird/); }); - it('calls #onMessage with message parts', function () { + test('calls #onMessage with message parts', () => { const worker = setup(); - const stub = sinon.stub(worker, 'onMessage'); + jest.spyOn(worker, 'onMessage').mockImplementation(() => {}); worker.parseIncomingMessage([10, 100, 1000, 10000]); - sinon.assert.calledWith(stub, 10, 100, 1000, 10000); + expect(worker.onMessage).toHaveBeenCalledWith(10, 100, 1000, 10000); }); }); }); - describe('#onMessage', function () { - describe('when sent WORKER_BROADCAST message', function () { - it('emits the data to be broadcasted', function () { + describe('#onMessage', () => { + describe('when sent WORKER_BROADCAST message', () => { + test('emits the data to be broadcasted', () => { const worker = setup(); const data = {}; - const stub = sinon.stub(worker, 'emit'); + jest.spyOn(worker, 'emit').mockImplementation(() => {}); worker.onMessage('WORKER_BROADCAST', data); - sinon.assert.calledWithExactly(stub, 'broadcast', data); + expect(worker.emit).toHaveBeenCalledWith('broadcast', data); }); }); - describe('when sent WORKER_LISTENING message', function () { - it('sets the listening flag and emits the listening event', function () { + describe('when sent WORKER_LISTENING message', () => { + test('sets the listening flag and emits the listening event', () => { const worker = setup(); - const stub = sinon.stub(worker, 'emit'); + jest.spyOn(worker, 'emit').mockImplementation(() => {}); expect(worker).toHaveProperty('listening', false); worker.onMessage('WORKER_LISTENING'); expect(worker).toHaveProperty('listening', true); - sinon.assert.calledWithExactly(stub, 'listening'); + expect(worker.emit).toHaveBeenCalledWith('listening'); }); }); - describe('when passed an unknown message', function () { - it('does nothing', function () { + describe('when passed an unknown message', () => { + test('does nothing', () => { const worker = setup(); worker.onMessage('asdlfkajsdfahsdfiohuasdofihsdoif'); worker.onMessage({}); @@ -175,46 +168,46 @@ describe('CLI cluster manager', function () { }); }); - describe('#start', function () { - describe('when not started', function () { - // TODO This test is flaky, see https://github.com/elastic/kibana/issues/15888 - it.skip('creates a fork and waits for it to come online', async function () { + describe('#start', () => { + describe('when not started', () => { + test('creates a fork and waits for it to come online', async () => { const worker = setup(); - sinon.spy(worker, 'on'); + jest.spyOn(worker, 'on'); await worker.start(); - sinon.assert.calledOnce(cluster.fork); - sinon.assert.calledWith(worker.on, 'fork:online'); + expect(cluster.fork).toHaveBeenCalledTimes(1); + expect(worker.on).toHaveBeenCalledWith('fork:online', expect.any(Function)); }); - // TODO This test is flaky, see https://github.com/elastic/kibana/issues/15888 - it.skip('listens for cluster and process "exit" events', async function () { + test('listens for cluster and process "exit" events', async () => { const worker = setup(); - sinon.spy(process, 'on'); - sinon.spy(cluster, 'on'); + jest.spyOn(process, 'on'); + jest.spyOn(cluster, 'on'); await worker.start(); - sinon.assert.calledOnce(cluster.on); - sinon.assert.calledWith(cluster.on, 'exit'); - sinon.assert.calledOnce(process.on); - sinon.assert.calledWith(process.on, 'exit'); + expect(cluster.on).toHaveBeenCalledTimes(1); + expect(cluster.on).toHaveBeenCalledWith('exit', expect.any(Function)); + expect(process.on).toHaveBeenCalledTimes(1); + expect(process.on).toHaveBeenCalledWith('exit', expect.any(Function)); }); }); - describe('when already started', function () { - it('calls shutdown and waits for the graceful shutdown to cause a restart', async function () { + describe('when already started', () => { + test('calls shutdown and waits for the graceful shutdown to cause a restart', async () => { const worker = setup(); await worker.start(); - sinon.spy(worker, 'shutdown'); - sinon.spy(worker, 'on'); + + jest.spyOn(worker, 'shutdown'); + jest.spyOn(worker, 'on'); worker.start(); - sinon.assert.calledOnce(worker.shutdown); - sinon.assert.calledWith(worker.on, 'online'); + + expect(worker.shutdown).toHaveBeenCalledTimes(1); + expect(worker.on).toHaveBeenCalledWith('online', expect.any(Function)); }); }); }); diff --git a/src/cli/color.js b/src/cli/color.js index b678376ef7c2479..a02fb551c418187 100644 --- a/src/cli/color.js +++ b/src/cli/color.js @@ -17,9 +17,8 @@ * under the License. */ -import _ from 'lodash'; import chalk from 'chalk'; -export const green = _.flow(chalk.black, chalk.bgGreen); -export const red = _.flow(chalk.white, chalk.bgRed); -export const yellow = _.flow(chalk.black, chalk.bgYellow); +export const green = chalk.black.bgGreen; +export const red = chalk.white.bgRed; +export const yellow = chalk.black.bgYellow; diff --git a/src/cli/serve/integration_tests/__snapshots__/invalid_config.test.js.snap b/src/cli/serve/integration_tests/__snapshots__/invalid_config.test.js.snap index 0e702ed6123bd1e..47b98f740af588e 100644 --- a/src/cli/serve/integration_tests/__snapshots__/invalid_config.test.js.snap +++ b/src/cli/serve/integration_tests/__snapshots__/invalid_config.test.js.snap @@ -4,12 +4,15 @@ exports[`cli invalid config support exits with statusCode 64 and logs a single l Array [ Object { "@timestamp": "## @timestamp ##", + "error": "## Error with stack trace ##", + "level": "fatal", "message": "\\"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.", "pid": "## PID ##", "tags": Array [ "fatal", + "root", ], - "type": "log", + "type": "error", }, ] `; diff --git a/src/cli/serve/integration_tests/invalid_config.test.js b/src/cli/serve/integration_tests/invalid_config.test.js index 335fb1dbcaf9f6c..495bfbeaa939e35 100644 --- a/src/cli/serve/integration_tests/invalid_config.test.js +++ b/src/cli/serve/integration_tests/invalid_config.test.js @@ -39,7 +39,8 @@ describe('cli invalid config support', function () { .map(obj => ({ ...obj, pid: '## PID ##', - '@timestamp': '## @timestamp ##' + '@timestamp': '## @timestamp ##', + error: '## Error with stack trace ##', })); expect(error).toBe(undefined); diff --git a/src/cli/serve/serve.js b/src/cli/serve/serve.js index 08495566d845ed9..736943de775c21c 100644 --- a/src/cli/serve/serve.js +++ b/src/cli/serve/serve.js @@ -19,20 +19,15 @@ import _ from 'lodash'; import { statSync, lstatSync, realpathSync } from 'fs'; -import { isWorker } from 'cluster'; import { resolve } from 'path'; import { fromRoot } from '../../utils'; import { getConfig } from '../../server/path'; -import { Config } from '../../server/config/config'; -import { getConfigFromFiles } from '../../core/server/config'; +import { bootstrap } from '../../core/server'; import { readKeystore } from './read_keystore'; -import { transformDeprecations } from '../../server/config/transform_deprecations'; import { DEV_SSL_CERT_PATH, DEV_SSL_KEY_PATH } from '../dev_ssl'; -const { startRepl } = canRequire('../repl') ? require('../repl') : { }; - function canRequire(path) { try { require.resolve(path); @@ -60,6 +55,9 @@ function isSymlinkTo(link, dest) { const CLUSTER_MANAGER_PATH = resolve(__dirname, '../cluster/cluster_manager'); const CAN_CLUSTER = canRequire(CLUSTER_MANAGER_PATH); +const REPL_PATH = resolve(__dirname, '../repl'); +const CAN_REPL = canRequire(REPL_PATH); + // xpack is installed in both dev and the distributable, it's optional if // install is a link to the source, not an actual install const XPACK_INSTALLED_DIR = resolve(__dirname, '../../../node_modules/x-pack'); @@ -79,12 +77,11 @@ const configPathCollector = pathCollector(); const pluginDirCollector = pathCollector(); const pluginPathCollector = pathCollector(); -function readServerSettings(opts, extraCliOptions) { - const settings = getConfigFromFiles([].concat(opts.config || [])); - const set = _.partial(_.set, settings); - const get = _.partial(_.get, settings); - const has = _.partial(_.has, settings); - const merge = _.partial(_.merge, settings); +function applyConfigOverrides(rawConfig, opts, extraCliOptions) { + const set = _.partial(_.set, rawConfig); + const get = _.partial(_.get, rawConfig); + const has = _.partial(_.has, rawConfig); + const merge = _.partial(_.merge, rawConfig); if (opts.dev) { set('env', 'development'); @@ -133,7 +130,7 @@ function readServerSettings(opts, extraCliOptions) { merge(extraCliOptions); merge(readKeystore(get('path.data'))); - return settings; + return rawConfig; } export default function (program) { @@ -175,7 +172,7 @@ export default function (program) { ) .option('--plugins ', 'an alias for --plugin-dir', pluginDirCollector); - if (!!startRepl) { + if (CAN_REPL) { command.option('--repl', 'Run the server with a REPL prompt and access to the server object'); } @@ -205,81 +202,16 @@ export default function (program) { } } - const getCurrentSettings = () => readServerSettings(opts, this.getUnknownOptions()); - const settings = getCurrentSettings(); - - if (CAN_CLUSTER && opts.dev && !isWorker) { - // stop processing the action and handoff to cluster manager - const ClusterManager = require(CLUSTER_MANAGER_PATH); - await ClusterManager.create(opts, settings); - return; - } - - let kbnServer = {}; - const KbnServer = require('../../server/kbn_server'); - try { - kbnServer = new KbnServer(settings); - if (shouldStartRepl(opts)) { - startRepl(kbnServer); - } - await kbnServer.ready(); - } catch (error) { - const { server } = kbnServer; - - switch (error.code) { - case 'EADDRINUSE': - logFatal(`Port ${error.port} is already in use. Another instance of Kibana may be running!`, server); - break; - - case 'InvalidConfig': - logFatal(error.message, server); - break; - - default: - logFatal(error, server); - break; - } - - kbnServer.close(); - const exitCode = error.processExitCode == null ? 1 : error.processExitCode; - // eslint-disable-next-line no-process-exit - process.exit(exitCode); - } - - process.on('SIGHUP', async function reloadConfig() { - const settings = transformDeprecations(getCurrentSettings()); - const config = new Config(kbnServer.config.getSchema(), settings); - - kbnServer.server.log(['info', 'config'], 'Reloading logging configuration due to SIGHUP.'); - await kbnServer.applyLoggingConfiguration(config); - kbnServer.server.log(['info', 'config'], 'Reloaded logging configuration due to SIGHUP.'); - - // If new platform config subscription is active, let's notify it with the updated config. - if (kbnServer.newPlatform) { - kbnServer.newPlatform.updateConfig(config.get()); + const unknownOptions = this.getUnknownOptions(); + await bootstrap( + { ...opts, ...unknownOptions }, + rawConfig => applyConfigOverrides(rawConfig, opts, unknownOptions), + { + isClusterModeSupported: CAN_CLUSTER, + isOssModeSupported: XPACK_OPTIONAL, + isXPackInstalled: XPACK_INSTALLED, + isReplModeSupported: CAN_REPL, } - }); - - return kbnServer; + ); }); } - -function shouldStartRepl(opts) { - if (opts.repl && !startRepl) { - throw new Error('Kibana REPL mode can only be run in development mode.'); - } - - // The kbnWorkerType check is necessary to prevent the repl - // from being started multiple times in different processes. - // We only want one REPL. - return opts.repl && process.env.kbnWorkerType === 'server'; -} - -function logFatal(message, server) { - if (server) { - server.log(['fatal'], message); - } - - // It's possible for the Hapi logger to not be setup - console.error('FATAL', message); -} diff --git a/src/core/index.ts b/src/core/index.ts deleted file mode 100644 index 326d08e0ec43f0a..000000000000000 --- a/src/core/index.ts +++ /dev/null @@ -1,20 +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. - */ - -export { injectIntoKbnServer, createBasePathProxy } from './server/legacy_compat'; diff --git a/src/core/server/__snapshots__/index.test.ts.snap b/src/core/server/__snapshots__/index.test.ts.snap new file mode 100644 index 000000000000000..8c3022a07d074b6 --- /dev/null +++ b/src/core/server/__snapshots__/index.test.ts.snap @@ -0,0 +1,21 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`does not fail on "start" if there are unused paths detected: unused paths logs 1`] = ` +Object { + "debug": Array [ + Array [ + "starting server", + ], + ], + "error": Array [], + "fatal": Array [], + "info": Array [], + "log": Array [], + "trace": Array [ + Array [ + "some config paths are not handled by the core: [\\"some.path\\",\\"another.path\\"]", + ], + ], + "warn": Array [], +} +`; diff --git a/src/core/server/bootstrap.ts b/src/core/server/bootstrap.ts new file mode 100644 index 000000000000000..a6092806a3b8bf3 --- /dev/null +++ b/src/core/server/bootstrap.ts @@ -0,0 +1,105 @@ +/* + * 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 chalk from 'chalk'; +import { isMaster } from 'cluster'; +import { Env, RawConfigService } from './config'; +import { LegacyObjectToRawConfigAdapter } from './legacy_compat'; +import { Root } from './root'; + +interface KibanaFeatures { + // If we can access `cluster_manager.js` that means we can run Kibana in a so called cluster + // mode when Kibana is run as a "worker" process together with optimizer "worker" process. + isClusterModeSupported: boolean; + + // X-Pack is installed in both dev and the distributable, it's optional if + // install is a link to the source, not an actual install. + isOssModeSupported: boolean; + + // If we can access `repl/` that means we can run Kibana in REPL mode. + isReplModeSupported: boolean; + + // X-Pack is considered as installed if it's available in `node_modules` folder and it + // looks the same for both dev and the distributable. + isXPackInstalled: boolean; +} + +export async function bootstrap( + cliArgs: Record, + applyConfigOverrides: (config: Record) => Record, + features: KibanaFeatures +) { + if (cliArgs.repl && !features.isReplModeSupported) { + onRootShutdown('Kibana REPL mode can only be run in development mode.'); + } + + const env = Env.createDefault({ + configs: [].concat(cliArgs.config || []), + cliArgs, + isDevClusterMaster: isMaster && cliArgs.dev && features.isClusterModeSupported, + }); + + const rawConfigService = new RawConfigService( + env.configs, + rawValue => new LegacyObjectToRawConfigAdapter(applyConfigOverrides(rawValue)) + ); + + rawConfigService.loadConfig(); + + const root = new Root(rawConfigService.getConfig$(), env, onRootShutdown); + + function shutdown(reason?: Error) { + rawConfigService.stop(); + return root.shutdown(reason); + } + + try { + await root.start(); + } catch (err) { + await shutdown(err); + } + + process.on('SIGHUP', () => { + const cliLogger = root.logger.get('cli'); + cliLogger.info('Reloading logging configuration due to SIGHUP.', { tags: ['config'] }); + + try { + rawConfigService.reloadConfig(); + } catch (err) { + return shutdown(err); + } + + cliLogger.info('Reloaded logging configuration due to SIGHUP.', { tags: ['config'] }); + }); + + process.on('SIGINT', () => shutdown()); + process.on('SIGTERM', () => shutdown()); +} + +function onRootShutdown(reason?: any) { + if (reason !== undefined) { + // There is a chance that logger wasn't configured properly and error that + // that forced root to shut down could go unnoticed. To prevent this we always + // mirror such fatal errors in standard output with `console.error`. + // tslint:disable no-console + console.error(`\n${chalk.white.bgRed(' FATAL ')} ${reason}\n`); + } + + process.exit(reason === undefined ? 0 : (reason as any).processExitCode || 1); +} diff --git a/src/core/server/config/__tests__/__snapshots__/env.test.ts.snap b/src/core/server/config/__tests__/__snapshots__/env.test.ts.snap index db2917da5406f03..9eabc78da514bfc 100644 --- a/src/core/server/config/__tests__/__snapshots__/env.test.ts.snap +++ b/src/core/server/config/__tests__/__snapshots__/env.test.ts.snap @@ -1,5 +1,71 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP +exports[`correctly creates default environment if \`--env.name\` is supplied.: dev env properties 1`] = ` +Env { + "binDir": "/test/cwd/bin", + "cliArgs": Object { + "env": Object { + "name": "development", + }, + "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, + "logDir": "/test/cwd/log", + "mode": Object { + "dev": true, + "name": "development", + "prod": false, + }, + "packageInfo": Object { + "branch": "feature-v1", + "buildNum": 9007199254740991, + "buildSha": "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", + "version": "v1", + }, + "staticFilesDir": "/test/cwd/ui", +} +`; + +exports[`correctly creates default environment if \`--env.name\` is supplied.: prod env properties 1`] = ` +Env { + "binDir": "/test/cwd/bin", + "cliArgs": Object { + "env": Object { + "name": "production", + }, + "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, + "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 default environment in dev mode.: env properties 1`] = ` Env { "binDir": "/test/cwd/bin", @@ -15,12 +81,6 @@ Env { "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, @@ -52,12 +112,6 @@ Env { "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, @@ -89,12 +143,6 @@ Env { "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, @@ -126,12 +174,6 @@ Env { "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, diff --git a/src/core/server/config/__tests__/env.test.ts b/src/core/server/config/__tests__/env.test.ts index 26163c82c84642c..3f82a9625588754 100644 --- a/src/core/server/config/__tests__/env.test.ts +++ b/src/core/server/config/__tests__/env.test.ts @@ -89,6 +89,33 @@ test('correctly creates default environment in prod non-distributable mode.', () expect(defaultEnv).toMatchSnapshot('env properties'); }); +test('correctly creates default environment if `--env.name` is supplied.', () => { + mockPackage.raw = { + branch: 'feature-v1', + version: 'v1', + build: { + distributable: false, + number: 100, + sha: 'feature-v1-build-sha', + }, + }; + + const defaultDevEnv = Env.createDefault({ + cliArgs: { someArg: 1, someOtherArg: '2', env: { name: 'development' } }, + configs: ['/some/other/path/some-kibana.yml'], + isDevClusterMaster: false, + }); + + const defaultProdEnv = Env.createDefault({ + cliArgs: { someArg: 1, someOtherArg: '2', env: { name: 'production' } }, + configs: ['/some/other/path/some-kibana.yml'], + isDevClusterMaster: false, + }); + + expect(defaultDevEnv).toMatchSnapshot('dev env properties'); + expect(defaultProdEnv).toMatchSnapshot('prod env properties'); +}); + test('correctly creates environment with constructor.', () => { mockPackage.raw = { branch: 'feature-v1', diff --git a/src/core/server/config/env.ts b/src/core/server/config/env.ts index 56d6c1ae94a0cae..097c3d04391a438 100644 --- a/src/core/server/config/env.ts +++ b/src/core/server/config/env.ts @@ -17,7 +17,6 @@ * under the License. */ -import { EventEmitter } from 'events'; import { resolve } from 'path'; import process from 'process'; @@ -66,11 +65,6 @@ export class Env { */ public readonly mode: Readonly; - /** - * @internal - */ - public readonly legacy: EventEmitter; - /** * Arguments provided through command line. */ @@ -100,10 +94,11 @@ export class Env { this.configs = Object.freeze(options.configs); this.isDevClusterMaster = options.isDevClusterMaster; + const isDevMode = this.cliArgs.dev || (this.cliArgs.env || {}).name === 'development'; this.mode = Object.freeze({ - dev: this.cliArgs.dev, - name: this.cliArgs.dev ? 'development' : 'production', - prod: !this.cliArgs.dev, + dev: isDevMode, + name: isDevMode ? 'development' : 'production', + prod: !isDevMode, }); const isKibanaDistributable = pkg.build && pkg.build.distributable === true; @@ -113,7 +108,5 @@ export class Env { buildSha: isKibanaDistributable ? pkg.build.sha : 'XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX', version: pkg.version, }); - - this.legacy = new EventEmitter(); } } diff --git a/src/core/server/http/__tests__/__snapshots__/http_config.test.ts.snap b/src/core/server/http/__tests__/__snapshots__/http_config.test.ts.snap index 4201e4f774892e2..6a207f89c060498 100644 --- a/src/core/server/http/__tests__/__snapshots__/http_config.test.ts.snap +++ b/src/core/server/http/__tests__/__snapshots__/http_config.test.ts.snap @@ -2,6 +2,7 @@ exports[`has defaults for config 1`] = ` Object { + "autoListen": true, "cors": false, "host": "localhost", "maxPayload": ByteSizeValue { diff --git a/src/core/server/http/__tests__/__snapshots__/http_server.test.ts.snap b/src/core/server/http/__tests__/__snapshots__/http_server.test.ts.snap index 3060d7b46896033..8e868e803602fa7 100644 --- a/src/core/server/http/__tests__/__snapshots__/http_server.test.ts.snap +++ b/src/core/server/http/__tests__/__snapshots__/http_server.test.ts.snap @@ -1,6 +1,6 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`broadcasts server and connection options to the legacy "channel" 1`] = ` +exports[`returns server and connection options on start 1`] = ` Object { "host": "127.0.0.1", "port": 12345, diff --git a/src/core/server/http/__tests__/http_server.test.ts b/src/core/server/http/__tests__/http_server.test.ts index 7f49d153163a9bd..42f93a13e1c8047 100644 --- a/src/core/server/http/__tests__/http_server.test.ts +++ b/src/core/server/http/__tests__/http_server.test.ts @@ -17,7 +17,7 @@ * under the License. */ -import { getEnvOptions } from '../../config/__tests__/__mocks__/env'; +import { Server } from 'http'; jest.mock('fs', () => ({ readFileSync: jest.fn(), @@ -26,7 +26,6 @@ jest.mock('fs', () => ({ import Chance from 'chance'; import supertest from 'supertest'; -import { Env } from '../../config'; import { ByteSizeValue } from '../../config/schema'; import { logger } from '../../logging/__mocks__'; import { HttpConfig } from '../http_config'; @@ -35,14 +34,9 @@ import { Router } from '../router'; const chance = new Chance(); -let env: Env; let server: HttpServer; let config: HttpConfig; -function getServerListener(httpServer: HttpServer) { - return (httpServer as any).server.listener; -} - beforeEach(() => { config = { host: '127.0.0.1', @@ -51,8 +45,7 @@ beforeEach(() => { ssl: {}, } as HttpConfig; - env = new Env('/kibana', getEnvOptions()); - server = new HttpServer(logger.get(), env); + server = new HttpServer(logger.get()); }); afterEach(async () => { @@ -77,9 +70,9 @@ test('200 OK with body', async () => { server.registerRouter(router); - await server.start(config); + const { server: innerServer } = await server.start(config); - await supertest(getServerListener(server)) + await supertest(innerServer.listener) .get('/foo/') .expect(200) .then(res => { @@ -96,9 +89,9 @@ test('202 Accepted with body', async () => { server.registerRouter(router); - await server.start(config); + const { server: innerServer } = await server.start(config); - await supertest(getServerListener(server)) + await supertest(innerServer.listener) .get('/foo/') .expect(202) .then(res => { @@ -115,9 +108,9 @@ test('204 No content', async () => { server.registerRouter(router); - await server.start(config); + const { server: innerServer } = await server.start(config); - await supertest(getServerListener(server)) + await supertest(innerServer.listener) .get('/foo/') .expect(204) .then(res => { @@ -136,9 +129,9 @@ test('400 Bad request with error', async () => { server.registerRouter(router); - await server.start(config); + const { server: innerServer } = await server.start(config); - await supertest(getServerListener(server)) + await supertest(innerServer.listener) .get('/foo/') .expect(400) .then(res => { @@ -165,9 +158,9 @@ test('valid params', async () => { server.registerRouter(router); - await server.start(config); + const { server: innerServer } = await server.start(config); - await supertest(getServerListener(server)) + await supertest(innerServer.listener) .get('/foo/some-string') .expect(200) .then(res => { @@ -194,9 +187,9 @@ test('invalid params', async () => { server.registerRouter(router); - await server.start(config); + const { server: innerServer } = await server.start(config); - await supertest(getServerListener(server)) + await supertest(innerServer.listener) .get('/foo/some-string') .expect(400) .then(res => { @@ -226,9 +219,9 @@ test('valid query', async () => { server.registerRouter(router); - await server.start(config); + const { server: innerServer } = await server.start(config); - await supertest(getServerListener(server)) + await supertest(innerServer.listener) .get('/foo/?bar=test&quux=123') .expect(200) .then(res => { @@ -255,9 +248,9 @@ test('invalid query', async () => { server.registerRouter(router); - await server.start(config); + const { server: innerServer } = await server.start(config); - await supertest(getServerListener(server)) + await supertest(innerServer.listener) .get('/foo/?bar=test') .expect(400) .then(res => { @@ -287,9 +280,9 @@ test('valid body', async () => { server.registerRouter(router); - await server.start(config); + const { server: innerServer } = await server.start(config); - await supertest(getServerListener(server)) + await supertest(innerServer.listener) .post('/foo/') .send({ bar: 'test', @@ -320,9 +313,9 @@ test('invalid body', async () => { server.registerRouter(router); - await server.start(config); + const { server: innerServer } = await server.start(config); - await supertest(getServerListener(server)) + await supertest(innerServer.listener) .post('/foo/') .send({ bar: 'test' }) .expect(400) @@ -352,9 +345,9 @@ test('handles putting', async () => { server.registerRouter(router); - await server.start(config); + const { server: innerServer } = await server.start(config); - await supertest(getServerListener(server)) + await supertest(innerServer.listener) .put('/foo/') .send({ key: 'new value' }) .expect(200) @@ -382,9 +375,9 @@ test('handles deleting', async () => { server.registerRouter(router); - await server.start(config); + const { server: innerServer } = await server.start(config); - await supertest(getServerListener(server)) + await supertest(innerServer.listener) .delete('/foo/3') .expect(200) .then(res => { @@ -407,9 +400,9 @@ test('filtered headers', async () => { server.registerRouter(router); - await server.start(config); + const { server: innerServer } = await server.start(config); - await supertest(getServerListener(server)) + await supertest(innerServer.listener) .get('/foo/?bar=quux') .set('x-kibana-foo', 'bar') .set('x-kibana-bar', 'quux'); @@ -422,6 +415,7 @@ test('filtered headers', async () => { describe('with `basepath: /bar` and `rewriteBasePath: false`', () => { let configWithBasePath: HttpConfig; + let innerServerListener: Server; beforeEach(async () => { configWithBasePath = { @@ -438,29 +432,30 @@ describe('with `basepath: /bar` and `rewriteBasePath: false`', () => { server.registerRouter(router); - await server.start(configWithBasePath); + const { server: innerServer } = await server.start(configWithBasePath); + innerServerListener = innerServer.listener; }); test('/bar => 404', async () => { - await supertest(getServerListener(server)) + await supertest(innerServerListener) .get('/bar') .expect(404); }); test('/bar/ => 404', async () => { - await supertest(getServerListener(server)) + await supertest(innerServerListener) .get('/bar/') .expect(404); }); test('/bar/foo => 404', async () => { - await supertest(getServerListener(server)) + await supertest(innerServerListener) .get('/bar/foo') .expect(404); }); test('/ => /', async () => { - await supertest(getServerListener(server)) + await supertest(innerServerListener) .get('/') .expect(200) .then(res => { @@ -469,7 +464,7 @@ describe('with `basepath: /bar` and `rewriteBasePath: false`', () => { }); test('/foo => /foo', async () => { - await supertest(getServerListener(server)) + await supertest(innerServerListener) .get('/foo') .expect(200) .then(res => { @@ -480,6 +475,7 @@ describe('with `basepath: /bar` and `rewriteBasePath: false`', () => { describe('with `basepath: /bar` and `rewriteBasePath: true`', () => { let configWithBasePath: HttpConfig; + let innerServerListener: Server; beforeEach(async () => { configWithBasePath = { @@ -496,11 +492,12 @@ describe('with `basepath: /bar` and `rewriteBasePath: true`', () => { server.registerRouter(router); - await server.start(configWithBasePath); + const { server: innerServer } = await server.start(configWithBasePath); + innerServerListener = innerServer.listener; }); test('/bar => /', async () => { - await supertest(getServerListener(server)) + await supertest(innerServerListener) .get('/bar') .expect(200) .then(res => { @@ -509,7 +506,7 @@ describe('with `basepath: /bar` and `rewriteBasePath: true`', () => { }); test('/bar/ => /', async () => { - await supertest(getServerListener(server)) + await supertest(innerServerListener) .get('/bar/') .expect(200) .then(res => { @@ -518,7 +515,7 @@ describe('with `basepath: /bar` and `rewriteBasePath: true`', () => { }); test('/bar/foo => /foo', async () => { - await supertest(getServerListener(server)) + await supertest(innerServerListener) .get('/bar/foo') .expect(200) .then(res => { @@ -527,13 +524,13 @@ describe('with `basepath: /bar` and `rewriteBasePath: true`', () => { }); test('/ => 404', async () => { - await supertest(getServerListener(server)) + await supertest(innerServerListener) .get('/') .expect(404); }); test('/foo => 404', async () => { - await supertest(getServerListener(server)) + await supertest(innerServerListener) .get('/foo') .expect(404); }); @@ -564,21 +561,13 @@ describe('with defined `redirectHttpFromPort`', () => { }); }); -test('broadcasts server and connection options to the legacy "channel"', async () => { - const onConnectionListener = jest.fn(); - env.legacy.on('connection', onConnectionListener); - - expect(onConnectionListener).not.toHaveBeenCalled(); - - await server.start({ +test('returns server and connection options on start', async () => { + const { server: innerServer, options } = await server.start({ ...config, port: 12345, }); - expect(onConnectionListener).toHaveBeenCalledTimes(1); - - const [[{ options, server: rawServer }]] = onConnectionListener.mock.calls; - expect(rawServer).toBeDefined(); - expect(rawServer).toBe((server as any).server); + expect(innerServer).toBeDefined(); + expect(innerServer).toBe((server as any).server); expect(options).toMatchSnapshot(); }); diff --git a/src/core/server/http/__tests__/http_service.test.ts b/src/core/server/http/__tests__/http_service.test.ts index 0cacad881746863..1c6d2598481177c 100644 --- a/src/core/server/http/__tests__/http_service.test.ts +++ b/src/core/server/http/__tests__/http_service.test.ts @@ -17,8 +17,6 @@ * under the License. */ -import { getEnvOptions } from '../../config/__tests__/__mocks__/env'; - const mockHttpServer = jest.fn(); jest.mock('../http_server', () => ({ @@ -27,8 +25,6 @@ jest.mock('../http_server', () => ({ import { noop } from 'lodash'; import { BehaviorSubject } from 'rxjs'; - -import { Env } from '../../config'; import { logger } from '../../logging/__mocks__'; import { HttpConfig } from '../http_config'; import { HttpService } from '../http_service'; @@ -55,11 +51,7 @@ test('creates and starts http server', async () => { }; mockHttpServer.mockImplementation(() => httpServer); - const service = new HttpService( - config$.asObservable(), - logger, - new Env('/kibana', getEnvOptions()) - ); + const service = new HttpService(config$.asObservable(), logger); expect(mockHttpServer.mock.instances.length).toBe(1); expect(httpServer.start).not.toHaveBeenCalled(); @@ -81,11 +73,7 @@ test('logs error if already started', async () => { }; mockHttpServer.mockImplementation(() => httpServer); - const service = new HttpService( - config$.asObservable(), - logger, - new Env('/kibana', getEnvOptions()) - ); + const service = new HttpService(config$.asObservable(), logger); await service.start(); @@ -104,11 +92,7 @@ test('stops http server', async () => { }; mockHttpServer.mockImplementation(() => httpServer); - const service = new HttpService( - config$.asObservable(), - logger, - new Env('/kibana', getEnvOptions()) - ); + const service = new HttpService(config$.asObservable(), logger); await service.start(); @@ -132,11 +116,7 @@ test('register route handler', () => { }; mockHttpServer.mockImplementation(() => httpServer); - const service = new HttpService( - config$.asObservable(), - logger, - new Env('/kibana', getEnvOptions()) - ); + const service = new HttpService(config$.asObservable(), logger); const router = new Router('/foo'); service.registerRouter(router); @@ -159,11 +139,7 @@ test('throws if registering route handler after http server is started', () => { }; mockHttpServer.mockImplementation(() => httpServer); - const service = new HttpService( - config$.asObservable(), - logger, - new Env('/kibana', getEnvOptions()) - ); + const service = new HttpService(config$.asObservable(), logger); const router = new Router('/foo'); service.registerRouter(router); @@ -171,3 +147,20 @@ test('throws if registering route handler after http server is started', () => { expect(httpServer.registerRouter).toHaveBeenCalledTimes(0); expect(logger.mockCollect()).toMatchSnapshot(); }); + +test('returns http server contract on start', async () => { + const httpServerContract = { + server: {}, + options: { someOption: true }, + }; + + mockHttpServer.mockImplementation(() => ({ + isListening: () => false, + start: jest.fn().mockReturnValue(httpServerContract), + stop: noop, + })); + + const service = new HttpService(new BehaviorSubject({ ssl: {} } as HttpConfig), logger); + + expect(await service.start()).toBe(httpServerContract); +}); diff --git a/src/core/server/http/base_path_proxy_server.ts b/src/core/server/http/base_path_proxy_server.ts index f4a9b59b77b10d8..b0c2144d7189a6e 100644 --- a/src/core/server/http/base_path_proxy_server.ts +++ b/src/core/server/http/base_path_proxy_server.ts @@ -29,8 +29,6 @@ import { createServer, getServerOptions } from './http_tools'; const alphabet = 'abcdefghijklmnopqrztuvwxyz'.split(''); export interface BasePathProxyServerOptions { - httpConfig: HttpConfig; - devConfig: DevConfig; shouldRedirectFromOldBasePath: (path: string) => boolean; blockUntil: () => Promise; } @@ -40,34 +38,38 @@ export class BasePathProxyServer { private httpsAgent?: HttpsAgent; get basePath() { - return this.options.httpConfig.basePath; + return this.httpConfig.basePath; } get targetPort() { - return this.options.devConfig.basePathProxyTargetPort; + return this.devConfig.basePathProxyTargetPort; } - constructor(private readonly log: Logger, private readonly options: BasePathProxyServerOptions) { + constructor( + private readonly log: Logger, + private readonly httpConfig: HttpConfig, + private readonly devConfig: DevConfig + ) { const ONE_GIGABYTE = 1024 * 1024 * 1024; - options.httpConfig.maxPayload = new ByteSizeValue(ONE_GIGABYTE); + httpConfig.maxPayload = new ByteSizeValue(ONE_GIGABYTE); - if (!options.httpConfig.basePath) { - options.httpConfig.basePath = `/${sample(alphabet, 3).join('')}`; + if (!httpConfig.basePath) { + httpConfig.basePath = `/${sample(alphabet, 3).join('')}`; } } - public async start() { - const { httpConfig } = this.options; + public async start(options: Readonly) { + this.log.debug('starting basepath proxy server'); - const options = getServerOptions(httpConfig); - this.server = createServer(options); + const serverOptions = getServerOptions(this.httpConfig); + this.server = createServer(serverOptions); // Register hapi plugin that adds proxying functionality. It can be configured // through the route configuration object (see { handler: { proxy: ... } }). await this.server.register({ plugin: require('h2o2-latest') }); - if (httpConfig.ssl.enabled) { - const tlsOptions = options.tls as TlsOptions; + if (this.httpConfig.ssl.enabled) { + const tlsOptions = serverOptions.tls as TlsOptions; this.httpsAgent = new HttpsAgent({ ca: tlsOptions.ca, cert: tlsOptions.cert, @@ -77,40 +79,42 @@ export class BasePathProxyServer { }); } - this.setupRoutes(); + this.setupRoutes(options); + + await this.server.start(); this.log.info( - `starting basepath proxy server at ${this.server.info.uri}${httpConfig.basePath}` + `basepath proxy server running at ${this.server.info.uri}${this.httpConfig.basePath}` ); - - await this.server.start(); } public async stop() { - this.log.info('stopping basepath proxy server'); - - if (this.server !== undefined) { - await this.server.stop(); - this.server = undefined; + if (this.server === undefined) { + return; } + this.log.debug('stopping basepath proxy server'); + await this.server.stop(); + this.server = undefined; + if (this.httpsAgent !== undefined) { this.httpsAgent.destroy(); this.httpsAgent = undefined; } } - private setupRoutes() { + private setupRoutes({ + blockUntil, + shouldRedirectFromOldBasePath, + }: Readonly) { if (this.server === undefined) { throw new Error(`Routes cannot be set up since server is not initialized.`); } - const { httpConfig, devConfig, blockUntil, shouldRedirectFromOldBasePath } = this.options; - // Always redirect from root URL to the URL with basepath. this.server.route({ handler: (request, responseToolkit) => { - return responseToolkit.redirect(httpConfig.basePath); + return responseToolkit.redirect(this.httpConfig.basePath); }, method: 'GET', path: '/', @@ -122,7 +126,7 @@ export class BasePathProxyServer { agent: this.httpsAgent, host: this.server.info.host, passThrough: true, - port: devConfig.basePathProxyTargetPort, + port: this.devConfig.basePathProxyTargetPort, protocol: this.server.info.protocol, xforward: true, }, @@ -138,7 +142,7 @@ export class BasePathProxyServer { }, ], }, - path: `${httpConfig.basePath}/{kbnPath*}`, + path: `${this.httpConfig.basePath}/{kbnPath*}`, }); // It may happen that basepath has changed, but user still uses the old one, @@ -152,7 +156,7 @@ export class BasePathProxyServer { const isBasepathLike = oldBasePath.length === 3; return isGet && isBasepathLike && shouldRedirectFromOldBasePath(kbnPath) - ? responseToolkit.redirect(`${httpConfig.basePath}/${kbnPath}`) + ? responseToolkit.redirect(`${this.httpConfig.basePath}/${kbnPath}`) : responseToolkit.response('Not Found').code(404); }, method: '*', diff --git a/src/core/server/http/http_config.ts b/src/core/server/http/http_config.ts index bef8baf7941476e..750b5b07f5a3b7b 100644 --- a/src/core/server/http/http_config.ts +++ b/src/core/server/http/http_config.ts @@ -29,6 +29,7 @@ const match = (regex: RegExp, errorMsg: string) => (str: string) => const createHttpSchema = schema.object( { + autoListen: schema.boolean({ defaultValue: true }), basePath: schema.maybe( schema.string({ validate: match(validBasePathRegex, "must start with a slash, don't end with one"), @@ -91,6 +92,7 @@ export class HttpConfig { */ public static schema = createHttpSchema; + public autoListen: boolean; public host: string; public port: number; public cors: boolean | { origin: string[] }; @@ -104,6 +106,7 @@ export class HttpConfig { * @internal */ constructor(config: HttpConfigType, env: Env) { + this.autoListen = config.autoListen; this.host = config.host; this.port = config.port; this.cors = config.cors; diff --git a/src/core/server/http/http_server.ts b/src/core/server/http/http_server.ts index 21cde147b8ea249..478d7be187f1df5 100644 --- a/src/core/server/http/http_server.ts +++ b/src/core/server/http/http_server.ts @@ -17,20 +17,24 @@ * under the License. */ -import { Server } from 'hapi-latest'; +import { Server, ServerOptions } from 'hapi-latest'; import { modifyUrl } from '../../utils'; -import { Env } from '../config'; import { Logger } from '../logging'; import { HttpConfig } from './http_config'; import { createServer, getServerOptions } from './http_tools'; import { Router } from './router'; +export interface HttpServerInfo { + server: Server; + options: ServerOptions; +} + export class HttpServer { private server?: Server; private registeredRouters: Set = new Set(); - constructor(private readonly log: Logger, private readonly env: Env) {} + constructor(private readonly log: Logger) {} public isListening() { return this.server !== undefined && this.server.listener.listening; @@ -62,21 +66,19 @@ export class HttpServer { } } - // 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(); - this.log.info( - `Server running at ${this.server.info.uri}${config.rewriteBasePath ? config.basePath : ''}`, - // The "legacy" Kibana will output log records with `listening` tag even if `quiet` logging mode is enabled. - { tags: ['listening'] } + this.log.debug( + `http server running at ${this.server.info.uri}${ + config.rewriteBasePath ? config.basePath : '' + }` ); + + // 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. Once this bridge isn't needed anymore + // we shouldn't return anything from this server. + return { server: this.server, options: serverOptions }; } public async stop() { diff --git a/src/core/server/http/http_service.ts b/src/core/server/http/http_service.ts index 3caae18e857b348..6972dfffbb1dd1c 100644 --- a/src/core/server/http/http_service.ts +++ b/src/core/server/http/http_service.ts @@ -21,24 +21,23 @@ import { Observable, Subscription } from 'rxjs'; import { first } from 'rxjs/operators'; import { CoreService } from '../../types/core_service'; -import { Env } from '../config'; import { Logger, LoggerFactory } from '../logging'; import { HttpConfig } from './http_config'; -import { HttpServer } from './http_server'; +import { HttpServer, HttpServerInfo } from './http_server'; import { HttpsRedirectServer } from './https_redirect_server'; import { Router } from './router'; -export class HttpService implements CoreService { +export class HttpService implements CoreService { private readonly httpServer: HttpServer; private readonly httpsRedirectServer: HttpsRedirectServer; private configSubscription?: Subscription; private readonly log: Logger; - constructor(private readonly config$: Observable, logger: LoggerFactory, env: Env) { + constructor(private readonly config$: Observable, logger: LoggerFactory) { this.log = logger.get('http'); - this.httpServer = new HttpServer(logger.get('http', 'server'), env); + this.httpServer = new HttpServer(logger.get('http', 'server')); this.httpsRedirectServer = new HttpsRedirectServer(logger.get('http', 'redirect', 'server')); } @@ -61,7 +60,7 @@ export class HttpService implements CoreService { await this.httpsRedirectServer.start(config); } - await this.httpServer.start(config); + return await this.httpServer.start(config); } public async stop() { diff --git a/src/core/server/http/index.ts b/src/core/server/http/index.ts index e636fcd801eb53f..3fd37150834169d 100644 --- a/src/core/server/http/index.ts +++ b/src/core/server/http/index.ts @@ -19,20 +19,26 @@ import { Observable } from 'rxjs'; -import { Env } from '../config'; import { LoggerFactory } from '../logging'; import { HttpConfig } from './http_config'; import { HttpService } from './http_service'; +import { Router } from './router'; export { Router, KibanaRequest } from './router'; export { HttpService }; +export { HttpServerInfo } from './http_server'; +export { BasePathProxyServer } from './base_path_proxy_server'; export { HttpConfig }; export class HttpModule { public readonly service: HttpService; - constructor(readonly config$: Observable, logger: LoggerFactory, env: Env) { - this.service = new HttpService(this.config$, logger, env); + constructor(readonly config$: Observable, logger: LoggerFactory) { + this.service = new HttpService(this.config$, logger); + + const router = new Router('/core'); + router.get({ path: '/', validate: false }, async (req, res) => res.ok({ version: '0.0.1' })); + this.service.registerRouter(router); } } diff --git a/src/core/server/index.test.ts b/src/core/server/index.test.ts new file mode 100644 index 000000000000000..8a83d8d500b8149 --- /dev/null +++ b/src/core/server/index.test.ts @@ -0,0 +1,121 @@ +/* + * 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. + */ + +const mockHttpService = { start: jest.fn(), stop: jest.fn(), registerRouter: jest.fn() }; +jest.mock('./http/http_service', () => ({ + HttpService: jest.fn(() => mockHttpService), +})); + +const mockLegacyService = { start: jest.fn(), stop: jest.fn() }; +jest.mock('./legacy_compat/legacy_service', () => ({ + LegacyService: jest.fn(() => mockLegacyService), +})); + +import { BehaviorSubject } from 'rxjs'; +import { Server } from '.'; +import { Env } from './config'; +import { getEnvOptions } from './config/__tests__/__mocks__/env'; +import { logger } from './logging/__mocks__'; + +const mockConfigService = { atPath: jest.fn(), getUnusedPaths: jest.fn().mockReturnValue([]) }; +const env = new Env('.', getEnvOptions()); + +beforeEach(() => { + mockConfigService.atPath.mockReturnValue(new BehaviorSubject({ autoListen: true })); +}); + +afterEach(() => { + logger.mockClear(); + mockConfigService.atPath.mockReset(); + mockHttpService.start.mockReset(); + mockHttpService.stop.mockReset(); + mockLegacyService.start.mockReset(); + mockLegacyService.stop.mockReset(); +}); + +test('starts services on "start"', async () => { + const mockHttpServiceStartContract = { something: true }; + mockHttpService.start.mockReturnValue(Promise.resolve(mockHttpServiceStartContract)); + + const server = new Server(mockConfigService as any, logger, env); + + expect(mockHttpService.start).not.toHaveBeenCalled(); + expect(mockLegacyService.start).not.toHaveBeenCalled(); + + await server.start(); + + expect(mockHttpService.start).toHaveBeenCalledTimes(1); + expect(mockLegacyService.start).toHaveBeenCalledTimes(1); + expect(mockLegacyService.start).toHaveBeenCalledWith(mockHttpServiceStartContract); +}); + +test('does not fail on "start" if there are unused paths detected', async () => { + mockConfigService.getUnusedPaths.mockReturnValue(['some.path', 'another.path']); + + const server = new Server(mockConfigService as any, logger, env); + await expect(server.start()).resolves.toBeUndefined(); + expect(logger.mockCollect()).toMatchSnapshot('unused paths logs'); +}); + +test('does not start http service is `autoListen:false`', async () => { + mockConfigService.atPath.mockReturnValue(new BehaviorSubject({ autoListen: false })); + + const server = new Server(mockConfigService as any, logger, env); + + expect(mockLegacyService.start).not.toHaveBeenCalled(); + + await server.start(); + + expect(mockHttpService.start).not.toHaveBeenCalled(); + expect(mockLegacyService.start).toHaveBeenCalledTimes(1); + expect(mockLegacyService.start).toHaveBeenCalledWith(undefined); +}); + +test('does not start http service if process is dev cluster master', async () => { + const server = new Server( + mockConfigService as any, + logger, + new Env('.', getEnvOptions({ isDevClusterMaster: true })) + ); + + expect(mockLegacyService.start).not.toHaveBeenCalled(); + + await server.start(); + + expect(mockHttpService.start).not.toHaveBeenCalled(); + expect(mockLegacyService.start).toHaveBeenCalledTimes(1); + expect(mockLegacyService.start).toHaveBeenCalledWith(undefined); +}); + +test('stops services on "stop"', async () => { + const mockHttpServiceStartContract = { something: true }; + mockHttpService.start.mockReturnValue(Promise.resolve(mockHttpServiceStartContract)); + + const server = new Server(mockConfigService as any, logger, env); + + await server.start(); + + expect(mockHttpService.stop).not.toHaveBeenCalled(); + expect(mockLegacyService.stop).not.toHaveBeenCalled(); + + await server.stop(); + + expect(mockHttpService.stop).toHaveBeenCalledTimes(1); + expect(mockLegacyService.stop).toHaveBeenCalledTimes(1); +}); diff --git a/src/core/server/index.ts b/src/core/server/index.ts index 7d55670239f5e95..ac645b22800417e 100644 --- a/src/core/server/index.ts +++ b/src/core/server/index.ts @@ -17,29 +17,44 @@ * under the License. */ +export { bootstrap } from './bootstrap'; + +import { first } from 'rxjs/operators'; import { ConfigService, Env } from './config'; -import { HttpConfig, HttpModule, Router } from './http'; +import { HttpConfig, HttpModule, HttpServerInfo } from './http'; +import { LegacyCompatModule } from './legacy_compat'; import { Logger, LoggerFactory } from './logging'; export class Server { private readonly http: HttpModule; + private readonly legacy: LegacyCompatModule; private readonly log: Logger; - constructor(private readonly configService: ConfigService, logger: LoggerFactory, env: Env) { + constructor( + private readonly configService: ConfigService, + logger: LoggerFactory, + private readonly env: Env + ) { this.log = logger.get('server'); - const httpConfig$ = configService.atPath('server', HttpConfig); - this.http = new HttpModule(httpConfig$, logger, env); + this.http = new HttpModule(configService.atPath('server', HttpConfig), logger); + this.legacy = new LegacyCompatModule(configService, logger, env); } public async start() { - this.log.debug('starting server :tada:'); + this.log.debug('starting server'); - const router = new Router('/core'); - router.get({ path: '/', validate: false }, async (req, res) => res.ok({ version: '0.0.1' })); - this.http.service.registerRouter(router); + // We shouldn't start http service in two cases: + // 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; + const httpConfig = await this.http.config$.pipe(first()).toPromise(); + if (!this.env.isDevClusterMaster && httpConfig.autoListen) { + httpServerInfo = await this.http.service.start(); + } - await this.http.service.start(); + await this.legacy.service.start(httpServerInfo); const unhandledConfigPaths = await this.configService.getUnusedPaths(); if (unhandledConfigPaths.length > 0) { @@ -54,6 +69,7 @@ export class Server { public async stop() { this.log.debug('stopping server'); + await this.legacy.service.stop(); await this.http.service.stop(); } } diff --git a/src/core/server/legacy_compat/__tests__/__snapshots__/legacy_platform_proxifier.test.ts.snap b/src/core/server/legacy_compat/__tests__/__snapshots__/legacy_platform_proxifier.test.ts.snap deleted file mode 100644 index eb58ca8cbc5fdbc..000000000000000 --- a/src/core/server/legacy_compat/__tests__/__snapshots__/legacy_platform_proxifier.test.ts.snap +++ /dev/null @@ -1,21 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -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*}", - }, - ], -] -`; diff --git a/src/core/server/legacy_compat/__tests__/__snapshots__/legacy_service.test.ts.snap b/src/core/server/legacy_compat/__tests__/__snapshots__/legacy_service.test.ts.snap new file mode 100644 index 000000000000000..30feda098978c7f --- /dev/null +++ b/src/core/server/legacy_compat/__tests__/__snapshots__/legacy_service.test.ts.snap @@ -0,0 +1,131 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`once LegacyService is started in \`devClusterMaster\` mode creates ClusterManager with base path proxy.: cluster manager with base path proxy 1`] = ` +Array [ + Array [ + Object { + "arg1": 1, + "arg2": "2", + "basePath": true, + "dev": true, + }, + Object { + "server": Object { + "autoListen": true, + }, + }, + BasePathProxyServer { + "devConfig": Object { + "basePathProxyTargetPort": 100500, + }, + "httpConfig": Object { + "basePath": "/abc", + "maxPayload": ByteSizeValue { + "valueInBytes": 1073741824, + }, + }, + "log": Object { + "debug": [MockFunction] { + "calls": Array [ + Array [ + "starting legacy service", + ], + ], + }, + "error": [MockFunction], + "fatal": [MockFunction], + "info": [MockFunction], + "log": [MockFunction], + "trace": [MockFunction], + "warn": [MockFunction], + }, + }, + ], +] +`; + +exports[`once LegacyService is started in \`devClusterMaster\` mode creates ClusterManager without base path proxy.: cluster manager without base path proxy 1`] = ` +Array [ + Array [ + Object { + "arg1": 1, + "arg2": "2", + "basePath": false, + "dev": true, + }, + Object { + "server": Object { + "autoListen": true, + }, + }, + undefined, + ], +] +`; + +exports[`once LegacyService is started with connection info creates legacy kbnServer and closes it if \`listen\` fails. 1`] = `"something failed"`; + +exports[`once LegacyService is started with connection info proxy route responds with \`503\` if \`kbnServer\` is not ready yet.: 503 response 1`] = ` +Object { + "body": Array [ + Array [ + "Kibana server is not ready yet", + ], + ], + "code": Array [ + Array [ + 503, + ], + ], + "header": Array [ + Array [ + "Retry-After", + "30", + ], + ], +} +`; + +exports[`once LegacyService is started with connection info reconfigures logging configuration if new config is received.: applyLoggingConfiguration params 1`] = ` +Array [ + Array [ + Object { + "logging": Object { + "verbose": true, + }, + }, + ], +] +`; + +exports[`once LegacyService is started with connection info register proxy route.: proxy route options 1`] = ` +Array [ + Array [ + Object { + "handler": [Function], + "method": "*", + "options": Object { + "payload": Object { + "maxBytes": 9007199254740991, + "output": "stream", + "parse": false, + "timeout": false, + }, + }, + "path": "/{p*}", + }, + ], +] +`; + +exports[`once LegacyService is started without connection info reconfigures logging configuration if new config is received.: applyLoggingConfiguration params 1`] = ` +Array [ + Array [ + Object { + "logging": Object { + "verbose": true, + }, + }, + ], +] +`; diff --git a/src/core/server/legacy_compat/__tests__/legacy_platform_proxifier.test.ts b/src/core/server/legacy_compat/__tests__/legacy_platform_proxy.test.ts similarity index 51% rename from src/core/server/legacy_compat/__tests__/legacy_platform_proxifier.test.ts rename to src/core/server/legacy_compat/__tests__/legacy_platform_proxy.test.ts index 27db835a0ecf3aa..8330bbb8d74db57 100644 --- a/src/core/server/legacy_compat/__tests__/legacy_platform_proxifier.test.ts +++ b/src/core/server/legacy_compat/__tests__/legacy_platform_proxy.test.ts @@ -17,17 +17,12 @@ * under the License. */ -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__'; + +import { LegacyPlatformProxy } from '../legacy_platform_proxy'; let server: jest.Mocked; -let mockHapiServer: jest.Mocked; -let root: any; -let proxifier: LegacyPlatformProxifier; +let proxy: LegacyPlatformProxy; beforeEach(() => { server = { addListener: jest.fn(), @@ -36,29 +31,7 @@ beforeEach(() => { .mockReturnValue({ port: 1234, family: 'test-family', address: 'test-address' }), getConnections: jest.fn(), } as any; - - mockHapiServer = { listener: server, route: jest.fn() } as any; - - root = { - logger, - shutdown: jest.fn(), - start: jest.fn(), - } as any; - - 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.', () => { - 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)); - } + proxy = new LegacyPlatformProxy({ debug: jest.fn() } as any, server); }); test('correctly redirects server events.', () => { @@ -66,7 +39,7 @@ test('correctly redirects server events.', () => { expect(server.addListener).toHaveBeenCalledWith(eventName, expect.any(Function)); const listener = jest.fn(); - proxifier.addListener(eventName, listener); + proxy.addListener(eventName, listener); // Emit several events, to make sure that server is not being listened with `once`. const [, serverListener] = server.addListener.mock.calls.find( @@ -78,68 +51,47 @@ test('correctly redirects server events.', () => { expect(listener).toHaveBeenCalledTimes(2); expect(listener).toHaveBeenCalledWith(1, 2, 3, 4); - expect(listener).toHaveBeenCalledWith(5, 6, 7, 8); - proxifier.removeListener(eventName, listener); + proxy.removeListener(eventName, listener); } }); test('returns `address` from the underlying server.', () => { - expect(proxifier.address()).toEqual({ + expect(proxy.address()).toEqual({ address: 'test-address', family: 'test-family', port: 1234, }); }); -test('`listen` starts the `root`.', async () => { +test('`listen` calls callback immediately.', async () => { const onListenComplete = jest.fn(); - await proxifier.listen(1234, 'host-1', onListenComplete); + await proxy.listen(1234, 'host-1', onListenComplete); - expect(root.start).toHaveBeenCalledTimes(1); expect(onListenComplete).toHaveBeenCalledTimes(1); }); -test('`close` shuts down the `root`.', async () => { +test('`close` calls callback immediately.', async () => { const onCloseComplete = jest.fn(); - await proxifier.close(onCloseComplete); + await proxy.close(onCloseComplete); - expect(root.shutdown).toHaveBeenCalledTimes(1); expect(onCloseComplete).toHaveBeenCalledTimes(1); }); test('returns connection count from the underlying server.', () => { server.getConnections.mockImplementation(callback => callback(null, 0)); const onGetConnectionsComplete = jest.fn(); - proxifier.getConnections(onGetConnectionsComplete); + proxy.getConnections(onGetConnectionsComplete); expect(onGetConnectionsComplete).toHaveBeenCalledTimes(1); expect(onGetConnectionsComplete).toHaveBeenCalledWith(null, 0); onGetConnectionsComplete.mockReset(); server.getConnections.mockImplementation(callback => callback(null, 100500)); - proxifier.getConnections(onGetConnectionsComplete); + proxy.getConnections(onGetConnectionsComplete); expect(onGetConnectionsComplete).toHaveBeenCalledTimes(1); expect(onGetConnectionsComplete).toHaveBeenCalledWith(null, 100500); }); - -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 [[{ 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(mockRequest.raw.req, mockRequest.raw.res); -}); diff --git a/src/core/server/legacy_compat/__tests__/legacy_service.test.ts b/src/core/server/legacy_compat/__tests__/legacy_service.test.ts new file mode 100644 index 000000000000000..7486ad0ba4e0cd7 --- /dev/null +++ b/src/core/server/legacy_compat/__tests__/legacy_service.test.ts @@ -0,0 +1,296 @@ +/* + * 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, Subject } from 'rxjs'; + +jest.mock('../legacy_platform_proxy'); +jest.mock('../../../../server/kbn_server'); +jest.mock('../../../../cli/cluster/cluster_manager'); + +import { first } from 'rxjs/operators'; +// @ts-ignore: implicit any for JS file +import MockClusterManager from '../../../../cli/cluster/cluster_manager'; +// @ts-ignore: implicit any for JS file +import MockKbnServer from '../../../../server/kbn_server'; +import { ConfigService, Env, ObjectToRawConfigAdapter, RawConfig } from '../../config'; +import { getEnvOptions } from '../../config/__tests__/__mocks__/env'; +import { logger } from '../../logging/__mocks__'; +import { LegacyPlatformProxy } from '../legacy_platform_proxy'; +import { LegacyService } from '../legacy_service'; + +const MockLegacyPlatformProxy: jest.Mock = LegacyPlatformProxy as any; + +let legacyService: LegacyService; +let configService: jest.Mocked; +let env: Env; +let mockHttpServerInfo: any; +let rawConfig$: BehaviorSubject; +beforeEach(() => { + env = Env.createDefault(getEnvOptions()); + + mockHttpServerInfo = { + server: { listener: { addListener: jest.fn() }, route: jest.fn() }, + options: { someOption: 'foo', someAnotherOption: 'bar' }, + }; + + rawConfig$ = new BehaviorSubject( + new ObjectToRawConfigAdapter({ + server: { autoListen: true }, + }) + ); + + configService = { + getConfig$: jest.fn().mockReturnValue(rawConfig$), + atPath: jest.fn().mockReturnValue(new BehaviorSubject({})), + } as any; + legacyService = new LegacyService(env, logger, configService); +}); + +afterEach(() => { + MockLegacyPlatformProxy.mockClear(); + MockKbnServer.mockClear(); + MockClusterManager.create.mockClear(); + logger.mockClear(); +}); + +describe('once LegacyService is started with connection info', () => { + test('register proxy route.', async () => { + await legacyService.start(mockHttpServerInfo); + + expect(mockHttpServerInfo.server.route.mock.calls).toMatchSnapshot('proxy route options'); + }); + + test('proxy route responds with `503` if `kbnServer` is not ready yet.', async () => { + configService.atPath.mockReturnValue(new BehaviorSubject({ autoListen: true })); + + const kbnServerListen$ = new Subject(); + MockKbnServer.prototype.listen = jest.fn(() => { + kbnServerListen$.next(); + return kbnServerListen$.toPromise(); + }); + + // 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); + await kbnServerListen$.pipe(first()).toPromise(); + + const mockResponse: any = { + code: jest.fn().mockImplementation(() => mockResponse), + header: jest.fn().mockImplementation(() => mockResponse), + }; + const mockResponseToolkit = { + response: jest.fn().mockReturnValue(mockResponse), + abandon: Symbol('abandon'), + }; + const mockRequest = { raw: { req: { a: 1 }, res: { b: 2 } } }; + + const [[{ handler }]] = mockHttpServerInfo.server.route.mock.calls; + const response503 = await handler(mockRequest, mockResponseToolkit); + + expect(response503).toBe(mockResponse); + expect({ + body: mockResponseToolkit.response.mock.calls, + code: mockResponse.code.mock.calls, + header: mockResponse.header.mock.calls, + }).toMatchSnapshot('503 response'); + + // Make sure request hasn't been passed to the legacy platform. + const [mockedLegacyPlatformProxy] = MockLegacyPlatformProxy.mock.instances; + expect(mockedLegacyPlatformProxy.emit).not.toHaveBeenCalled(); + + // Now wait until kibana is ready and try to request once again. + kbnServerListen$.complete(); + await legacyStartPromise; + mockResponseToolkit.response.mockClear(); + + const responseProxy = await handler(mockRequest, mockResponseToolkit); + expect(responseProxy).toBe(mockResponseToolkit.abandon); + expect(mockResponseToolkit.response).not.toHaveBeenCalled(); + + // Make sure request has been passed to the legacy platform. + expect(mockedLegacyPlatformProxy.emit).toHaveBeenCalledTimes(1); + expect(mockedLegacyPlatformProxy.emit).toHaveBeenCalledWith( + 'request', + mockRequest.raw.req, + mockRequest.raw.res + ); + }); + + test('creates legacy kbnServer and calls `listen`.', async () => { + configService.atPath.mockReturnValue(new BehaviorSubject({ autoListen: true })); + + await legacyService.start(mockHttpServerInfo); + + expect(MockKbnServer).toHaveBeenCalledTimes(1); + expect(MockKbnServer).toHaveBeenCalledWith( + { server: { autoListen: true } }, + { + serverOptions: { + listener: expect.any(LegacyPlatformProxy), + someAnotherOption: 'bar', + someOption: 'foo', + }, + } + ); + + const [mockKbnServer] = MockKbnServer.mock.instances; + expect(mockKbnServer.listen).toHaveBeenCalledTimes(1); + expect(mockKbnServer.close).not.toHaveBeenCalled(); + }); + + test('creates legacy kbnServer but does not call `listen` if `autoListen: false`.', async () => { + configService.atPath.mockReturnValue(new BehaviorSubject({ autoListen: false })); + + await legacyService.start(mockHttpServerInfo); + + expect(MockKbnServer).toHaveBeenCalledTimes(1); + expect(MockKbnServer).toHaveBeenCalledWith( + { server: { autoListen: true } }, + { + serverOptions: { + listener: expect.any(LegacyPlatformProxy), + someAnotherOption: 'bar', + someOption: 'foo', + }, + } + ); + + const [mockKbnServer] = MockKbnServer.mock.instances; + expect(mockKbnServer.listen).not.toHaveBeenCalled(); + expect(mockKbnServer.close).not.toHaveBeenCalled(); + }); + + test('creates legacy kbnServer and closes it if `listen` fails.', async () => { + configService.atPath.mockReturnValue(new BehaviorSubject({ autoListen: true })); + MockKbnServer.prototype.listen.mockRejectedValue(new Error('something failed')); + + await expect(legacyService.start(mockHttpServerInfo)).rejects.toThrowErrorMatchingSnapshot(); + + const [mockKbnServer] = MockKbnServer.mock.instances; + expect(mockKbnServer.listen).toHaveBeenCalled(); + expect(mockKbnServer.close).toHaveBeenCalled(); + }); + + test('reconfigures logging configuration if new config is received.', async () => { + await legacyService.start(mockHttpServerInfo); + + const [mockKbnServer] = MockKbnServer.mock.instances; + expect(mockKbnServer.applyLoggingConfiguration).not.toHaveBeenCalled(); + + rawConfig$.next(new ObjectToRawConfigAdapter({ logging: { verbose: true } })); + + expect(mockKbnServer.applyLoggingConfiguration.mock.calls).toMatchSnapshot( + `applyLoggingConfiguration params` + ); + }); + + 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 } } }; + + await legacyService.start(mockHttpServerInfo); + + const [[{ handler }]] = mockHttpServerInfo.server.route.mock.calls; + const response = await handler(mockRequest, mockResponseToolkit); + + expect(response).toBe(mockResponseToolkit.abandon); + expect(mockResponseToolkit.response).not.toHaveBeenCalled(); + + // Make sure request has been passed to the legacy platform. + const [mockedLegacyPlatformProxy] = MockLegacyPlatformProxy.mock.instances; + expect(mockedLegacyPlatformProxy.emit).toHaveBeenCalledTimes(1); + expect(mockedLegacyPlatformProxy.emit).toHaveBeenCalledWith( + 'request', + mockRequest.raw.req, + mockRequest.raw.res + ); + }); +}); + +describe('once LegacyService is started without connection info', () => { + beforeEach(async () => await legacyService.start()); + + test('creates legacy kbnServer with `autoListen: false`.', () => { + expect(mockHttpServerInfo.server.route).not.toHaveBeenCalled(); + expect(MockKbnServer).toHaveBeenCalledTimes(1); + expect(MockKbnServer).toHaveBeenCalledWith( + { server: { autoListen: true } }, + { serverOptions: { autoListen: false } } + ); + }); + + test('reconfigures logging configuration if new config is received.', async () => { + const [mockKbnServer] = MockKbnServer.mock.instances; + expect(mockKbnServer.applyLoggingConfiguration).not.toHaveBeenCalled(); + + rawConfig$.next(new ObjectToRawConfigAdapter({ logging: { verbose: true } })); + + expect(mockKbnServer.applyLoggingConfiguration.mock.calls).toMatchSnapshot( + `applyLoggingConfiguration params` + ); + }); +}); + +describe('once LegacyService is started in `devClusterMaster` mode', () => { + beforeEach(() => { + configService.atPath.mockImplementation(path => { + return new BehaviorSubject( + path === 'dev' ? { basePathProxyTargetPort: 100500 } : { basePath: '/abc' } + ); + }); + }); + + test('creates ClusterManager without base path proxy.', async () => { + const devClusterLegacyService = new LegacyService( + Env.createDefault( + getEnvOptions({ + cliArgs: { arg1: 1, arg2: '2', basePath: false }, + isDevClusterMaster: true, + }) + ), + logger, + configService + ); + + await devClusterLegacyService.start(); + + expect(MockClusterManager.create.mock.calls).toMatchSnapshot( + 'cluster manager without base path proxy' + ); + }); + + test('creates ClusterManager with base path proxy.', async () => { + const devClusterLegacyService = new LegacyService( + Env.createDefault( + getEnvOptions({ + cliArgs: { arg1: 1, arg2: '2', basePath: true }, + isDevClusterMaster: true, + }) + ), + logger, + configService + ); + + await devClusterLegacyService.start(); + + expect(MockClusterManager.create.mock.calls).toMatchSnapshot( + 'cluster manager with base path proxy' + ); + }); +}); diff --git a/src/core/server/legacy_compat/config/__tests__/__snapshots__/legacy_object_to_raw_config_adapter.test.ts.snap b/src/core/server/legacy_compat/config/__tests__/__snapshots__/legacy_object_to_raw_config_adapter.test.ts.snap index d03398e173e407d..af2bfff0abfe390 100644 --- a/src/core/server/legacy_compat/config/__tests__/__snapshots__/legacy_object_to_raw_config_adapter.test.ts.snap +++ b/src/core/server/legacy_compat/config/__tests__/__snapshots__/legacy_object_to_raw_config_adapter.test.ts.snap @@ -2,6 +2,7 @@ exports[`#get correctly handles server config. 1`] = ` Object { + "autoListen": true, "basePath": "/abc", "cors": false, "host": "host", diff --git a/src/core/server/legacy_compat/config/legacy_object_to_raw_config_adapter.ts b/src/core/server/legacy_compat/config/legacy_object_to_raw_config_adapter.ts index d751239099622d8..991dabba43e65a9 100644 --- a/src/core/server/legacy_compat/config/legacy_object_to_raw_config_adapter.ts +++ b/src/core/server/legacy_compat/config/legacy_object_to_raw_config_adapter.ts @@ -59,6 +59,7 @@ export class LegacyObjectToRawConfigAdapter extends ObjectToRawConfigAdapter { // TODO: New platform uses just a subset of `server` config from the legacy platform, // new values will be exposed once we need them (eg. customResponseHeaders or xsrf). return { + autoListen: configValue.autoListen, basePath: configValue.basePath, cors: configValue.cors, host: configValue.host, diff --git a/src/core/server/legacy_compat/index.ts b/src/core/server/legacy_compat/index.ts index d3b4dd9fdf3b246..f8e62d71856dfbf 100644 --- a/src/core/server/legacy_compat/index.ts +++ b/src/core/server/legacy_compat/index.ts @@ -17,56 +17,17 @@ * under the License. */ -import { BehaviorSubject } from 'rxjs'; -import { map } from 'rxjs/operators'; +import { ConfigService, Env } from '../config'; +import { LoggerFactory } from '../logging'; +import { LegacyService } from './legacy_service'; -/** @internal */ -export { LegacyPlatformProxifier } from './legacy_platform_proxifier'; -/** @internal */ export { LegacyObjectToRawConfigAdapter } from './config/legacy_object_to_raw_config_adapter'; +export { LegacyService } from './legacy_service'; -import { LegacyObjectToRawConfigAdapter, LegacyPlatformProxifier } from '.'; -import { Env } from '../config'; -import { Root } from '../root'; -import { BasePathProxyRoot } from '../root/base_path_proxy_root'; +export class LegacyCompatModule { + public readonly service: LegacyService; -function initEnvironment(rawKbnServer: any, isDevClusterMaster = false) { - const env = Env.createDefault({ - // 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: rawKbnServer.config.get('env.dev') }, - isDevClusterMaster, - }); - - const legacyConfig$ = new BehaviorSubject>(rawKbnServer.config.get()); - return { - config$: legacyConfig$.pipe( - map(legacyConfig => new LegacyObjectToRawConfigAdapter(legacyConfig)) - ), - env, - // Propagates legacy config updates to the new platform. - updateConfig(legacyConfig: Record) { - legacyConfig$.next(legacyConfig); - }, - }; + constructor(private readonly configService: ConfigService, logger: LoggerFactory, env: Env) { + this.service = new LegacyService(env, logger, this.configService); + } } - -/** - * @internal - */ -export const injectIntoKbnServer = (rawKbnServer: any) => { - const { env, config$, updateConfig } = initEnvironment(rawKbnServer); - - rawKbnServer.newPlatform = { - // Custom HTTP Listener that will be used within legacy platform by HapiJS server. - proxyListener: new LegacyPlatformProxifier(new Root(config$, env), env), - updateConfig, - }; -}; - -export const createBasePathProxy = (rawKbnServer: any) => { - const { env, config$ } = initEnvironment(rawKbnServer, true /*isDevClusterMaster*/); - return new BasePathProxyRoot(config$, env); -}; diff --git a/src/core/server/legacy_compat/legacy_platform_proxifier.ts b/src/core/server/legacy_compat/legacy_platform_proxifier.ts deleted file mode 100644 index 8baa156266ef034..000000000000000 --- a/src/core/server/legacy_compat/legacy_platform_proxifier.ts +++ /dev/null @@ -1,172 +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 { EventEmitter } from 'events'; -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 = [ - 'clientError', - 'close', - 'connection', - 'error', - 'listening', - 'upgrade', -]; - -/** - * Represents "proxy" between legacy and current platform. - * @internal - */ -export class LegacyPlatformProxifier extends EventEmitter { - private readonly eventHandlers: Map void>; - private readonly log: Logger; - private server?: Server; - - constructor(private readonly root: Root, private readonly env: Env) { - super(); - - this.log = root.logger.get('legacy-platform-proxifier'); - - // HapiJS expects that the following events will be generated by `listener`, see: - // https://github.com/hapijs/hapi/blob/v14.2.0/lib/connection.js. - this.eventHandlers = new Map( - ServerEventsToForward.map(eventName => { - return [ - eventName, - (...args: any[]) => { - this.log.debug(`Event is being forwarded: ${eventName}`); - this.emit(eventName, ...args); - }, - ] 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) - ); - } - - /** - * Neither new nor legacy platform should use this method directly. - */ - public address() { - return this.server && this.server.address(); - } - - /** - * Neither new nor legacy platform should use this method directly. - */ - public async listen(port: number, host: string, callback?: (error?: Error) => void) { - this.log.debug(`"listen" has been called (${host}:${port}).`); - - let error: Error | undefined; - try { - await this.root.start(); - } catch (err) { - error = err; - this.emit('error', err); - } - - if (callback !== undefined) { - callback(error); - } - } - - /** - * Neither new nor legacy platform should use this method directly. - */ - public async close(callback?: (error?: Error) => void) { - this.log.debug('"close" has been called.'); - - let error: Error | undefined; - try { - await this.root.shutdown(); - } catch (err) { - error = err; - this.emit('error', err); - } - - if (callback !== undefined) { - callback(error); - } - } - - /** - * Neither new nor legacy platform should use this method directly. - */ - public getConnections(callback: (error: Error | null, count?: number) => void) { - // This method is used by `even-better` (before we start platform). - // It seems that the latest version of parent `good` doesn't use this anymore. - if (this.server) { - this.server.getConnections(callback); - } else { - callback(null, 0); - } - } - - private onConnection({ server }: ConnectionInfo) { - this.server = server.listener; - - for (const [eventName, eventHandler] of this.eventHandlers) { - this.server.addListener(eventName, eventHandler); - } - - // 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; - }, - }); - } -} diff --git a/src/core/server/legacy_compat/legacy_platform_proxy.ts b/src/core/server/legacy_compat/legacy_platform_proxy.ts new file mode 100644 index 000000000000000..e91d661e3023881 --- /dev/null +++ b/src/core/server/legacy_compat/legacy_platform_proxy.ts @@ -0,0 +1,107 @@ +/* + * 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 { EventEmitter } from 'events'; +import { Server } from 'net'; + +import { Logger } from '../logging'; + +/** + * List of the server events to be forwarded to the legacy platform. + */ +const ServerEventsToForward = [ + 'clientError', + 'close', + 'connection', + 'error', + 'listening', + 'upgrade', +]; + +/** + * Represents "proxy" between legacy and current platform. + * @internal + */ +export class LegacyPlatformProxy extends EventEmitter { + private readonly eventHandlers: Map void>; + + constructor(private readonly log: Logger, private readonly server: Server) { + super(); + + // HapiJS expects that the following events will be generated by `listener`, see: + // https://github.com/hapijs/hapi/blob/v14.2.0/lib/connection.js. + this.eventHandlers = new Map( + ServerEventsToForward.map(eventName => { + return [ + eventName, + (...args: any[]) => { + this.log.debug(`Event is being forwarded: ${eventName}`); + this.emit(eventName, ...args); + }, + ] as [string, (...args: any[]) => void]; + }) + ); + + for (const [eventName, eventHandler] of this.eventHandlers) { + this.server.addListener(eventName, eventHandler); + } + } + + /** + * Neither new nor legacy platform should use this method directly. + */ + public address() { + this.log.debug('"address" has been called.'); + + return this.server.address(); + } + + /** + * Neither new nor legacy platform should use this method directly. + */ + public listen(port: number, host: string, callback?: (error?: Error) => void) { + this.log.debug(`"listen" has been called (${host}:${port}).`); + + if (callback !== undefined) { + callback(); + } + } + + /** + * Neither new nor legacy platform should use this method directly. + */ + public close(callback?: (error?: Error) => void) { + this.log.debug('"close" has been called.'); + + if (callback !== undefined) { + callback(); + } + } + + /** + * Neither new nor legacy platform should use this method directly. + */ + public getConnections(callback: (error: Error | null, count?: number) => void) { + this.log.debug('"getConnections" has been called.'); + + // This method is used by `even-better` (before we start platform). + // It seems that the latest version of parent `good` doesn't use this anymore. + this.server.getConnections(callback); + } +} diff --git a/src/core/server/legacy_compat/legacy_service.ts b/src/core/server/legacy_compat/legacy_service.ts new file mode 100644 index 000000000000000..8abda68d5798066 --- /dev/null +++ b/src/core/server/legacy_compat/legacy_service.ts @@ -0,0 +1,197 @@ +/* + * 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 { Server as HapiServer } from 'hapi-latest'; +import { combineLatest, Subscription } from 'rxjs'; +import { first } from 'rxjs/operators'; +import { CoreService } from '../../types/core_service'; +import { ConfigService, Env, RawConfig } from '../config'; +import { DevConfig } from '../dev'; +import { BasePathProxyServer, HttpConfig, HttpServerInfo } from '../http'; +import { Logger, LoggerFactory } from '../logging'; +import { LegacyPlatformProxy } from './legacy_platform_proxy'; + +interface LegacyKbnServer { + applyLoggingConfiguration: (settings: Readonly>) => void; + listen: () => Promise; + close: () => Promise; +} + +export class LegacyService implements CoreService { + private readonly log: Logger; + private kbnServer?: LegacyKbnServer; + private rawConfigSubscription?: Subscription; + + constructor( + private readonly env: Env, + private readonly logger: LoggerFactory, + private readonly configService: ConfigService + ) { + this.log = logger.get('legacy', 'service'); + } + + public async start(httpServerInfo?: HttpServerInfo) { + this.log.debug('starting legacy service'); + + this.rawConfigSubscription = this.configService.getConfig$().subscribe({ + next: config => { + if (this.kbnServer === undefined) { + return; + } + + try { + this.kbnServer.applyLoggingConfiguration(config.toRaw()); + } catch (err) { + this.log.error(err); + } + }, + error: err => this.log.error(err), + }); + + const rawConfig = await this.configService + .getConfig$() + .pipe(first()) + .toPromise(); + + if (this.env.isDevClusterMaster) { + return this.createClusterManager(rawConfig); + } + + this.kbnServer = await this.createKbnServer(rawConfig, httpServerInfo); + } + + public async stop() { + this.log.debug('stopping legacy service'); + + if (this.rawConfigSubscription !== undefined) { + this.rawConfigSubscription.unsubscribe(); + this.rawConfigSubscription = undefined; + } + + if (this.kbnServer !== undefined) { + await this.kbnServer.close(); + this.kbnServer = undefined; + } + } + + private async createClusterManager(rawConfig: RawConfig) { + const [devConfig, httpConfig] = await combineLatest( + this.configService.atPath('dev', DevConfig), + this.configService.atPath('server', HttpConfig) + ) + .pipe(first()) + .toPromise(); + + require('../../../cli/cluster/cluster_manager').create( + this.env.cliArgs, + rawConfig.toRaw(), + this.env.cliArgs.basePath + ? new BasePathProxyServer(this.logger.get('server'), httpConfig, devConfig) + : undefined + ); + } + + private async createKbnServer(rawConfig: RawConfig, httpServerInfo?: HttpServerInfo) { + const httpConfig = await this.configService + .atPath('server', HttpConfig) + .pipe(first()) + .toPromise(); + + const KbnServer = require('../../../server/kbn_server'); + const kbnServer: LegacyKbnServer = new KbnServer(rawConfig.toRaw(), { + // If core HTTP service is run we'll receive internal server reference and + // 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 we won't have that info, + // so we can't start "legacy" server either. + serverOptions: + httpServerInfo !== undefined + ? { + ...httpServerInfo.options, + listener: this.setupProxyListener(httpServerInfo.server), + } + : { autoListen: false }, + }); + + // The kbnWorkerType check is necessary to prevent the repl + // from being started multiple times in different processes. + // We only want one REPL. + if (this.env.cliArgs.repl && process.env.kbnWorkerType === 'server') { + require('../../../cli/repl').startRepl(kbnServer); + } + + if (httpConfig.autoListen) { + try { + await kbnServer.listen(); + } catch (err) { + await kbnServer.close(); + throw err; + } + } + + return kbnServer; + } + + private setupProxyListener(server: HapiServer) { + const legacyProxy = new LegacyPlatformProxy( + this.logger.get('legacy', 'proxy'), + server.listener + ); + + // 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) => { + if (this.kbnServer === undefined) { + this.log.debug(`Kibana server is not ready yet ${req.method}:${req.url}.`); + + // If legacy server is not ready yet (e.g. it's still in optimization phase), + // we should let client know that and ask to retry after 30 seconds. + return responseToolkit + .response('Kibana server is not ready yet') + .code(503) + .header('Retry-After', '30'); + } + + 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. + legacyProxy.emit('request', req, res); + + return responseToolkit.abandon; + }, + }); + + return legacyProxy; + } +} diff --git a/src/core/server/logging/logging_service.ts b/src/core/server/logging/logging_service.ts index 90ee9524381dee6..966bd74a0df4166 100644 --- a/src/core/server/logging/logging_service.ts +++ b/src/core/server/logging/logging_service.ts @@ -71,7 +71,7 @@ export class LoggingService implements LoggerFactory { this.appenders.set(appenderKey, Appenders.create(appenderConfig)); } - for (const [loggerKey, loggerAdapter] of this.loggers.entries()) { + for (const [loggerKey, loggerAdapter] of this.loggers) { loggerAdapter.updateLogger(this.createLogger(loggerKey, config)); } diff --git a/src/core/server/root/base_path_proxy_root.ts b/src/core/server/root/base_path_proxy_root.ts deleted file mode 100644 index 80ab7d1c606770a..000000000000000 --- a/src/core/server/root/base_path_proxy_root.ts +++ /dev/null @@ -1,80 +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 { first } from 'rxjs/operators'; - -import { Root } from '.'; -import { DevConfig } from '../dev'; -import { HttpConfig } from '../http'; -import { BasePathProxyServer, BasePathProxyServerOptions } from '../http/base_path_proxy_server'; - -/** - * Top-level entry point to start BasePathProxy server. - */ -export class BasePathProxyRoot extends Root { - private basePathProxy?: BasePathProxyServer; - - public async configure({ - blockUntil, - shouldRedirectFromOldBasePath, - }: Pick) { - const [devConfig, httpConfig] = await Promise.all([ - this.configService - .atPath('dev', DevConfig) - .pipe(first()) - .toPromise(), - this.configService - .atPath('server', HttpConfig) - .pipe(first()) - .toPromise(), - ]); - - this.basePathProxy = new BasePathProxyServer(this.logger.get('server'), { - blockUntil, - devConfig, - httpConfig, - shouldRedirectFromOldBasePath, - }); - } - - public getBasePath() { - return this.getBasePathProxy().basePath; - } - - public getTargetPort() { - return this.getBasePathProxy().targetPort; - } - - protected async startServer() { - return this.getBasePathProxy().start(); - } - - protected async stopServer() { - await this.getBasePathProxy().stop(); - this.basePathProxy = undefined; - } - - private getBasePathProxy() { - if (this.basePathProxy === undefined) { - throw new Error('BasePathProxyRoot is not configured!'); - } - - return this.basePathProxy; - } -} diff --git a/src/core/server/root/index.ts b/src/core/server/root/index.ts index 37f9e070807ec2d..2e2170d47eea7ee 100644 --- a/src/core/server/root/index.ts +++ b/src/core/server/root/index.ts @@ -22,34 +22,30 @@ import { catchError, first, map, publishReplay } from 'rxjs/operators'; import { Server } from '..'; import { ConfigService, Env, RawConfig } from '../config'; - import { Logger, LoggerFactory, LoggingConfig, LoggingService } from '../logging'; -export type OnShutdown = (reason?: Error) => void; - /** * Top-level entry point to kick off the app and start the Kibana server. */ export class Root { public readonly logger: LoggerFactory; - protected readonly configService: ConfigService; + private readonly configService: ConfigService; private readonly log: Logger; - private server?: Server; + private readonly server: Server; private readonly loggingService: LoggingService; private loggingConfigSubscription?: Subscription; constructor( rawConfig$: Observable, private readonly env: Env, - private readonly onShutdown: OnShutdown = () => { - // noop - } + private readonly onShutdown?: (reason?: Error | string) => void ) { this.loggingService = new LoggingService(); this.logger = this.loggingService.asLoggerFactory(); - this.log = this.logger.get('root'); + this.configService = new ConfigService(rawConfig$, env, this.logger); + this.server = new Server(this.configService, this.logger, this.env); } public async start() { @@ -57,40 +53,37 @@ export class Root { try { await this.setupLogging(); - await this.startServer(); + await this.server.start(); } catch (e) { await this.shutdown(e); throw e; } } - public async shutdown(reason?: Error) { + public async shutdown(reason?: any) { this.log.debug('shutting root down'); - await this.stopServer(); + if (reason) { + if (reason.code === 'EADDRINUSE' && Number.isInteger(reason.port)) { + reason = new Error( + `Port ${reason.port} is already in use. Another instance of Kibana may be running!` + ); + } + + this.log.fatal(reason); + } + + await this.server.stop(); if (this.loggingConfigSubscription !== undefined) { this.loggingConfigSubscription.unsubscribe(); this.loggingConfigSubscription = undefined; } - await this.loggingService.stop(); - this.onShutdown(reason); - } - - protected async startServer() { - this.server = new Server(this.configService, this.logger, this.env); - return this.server.start(); - } - - protected async stopServer() { - if (this.server === undefined) { - return; + if (this.onShutdown !== undefined) { + this.onShutdown(reason); } - - await this.server.stop(); - this.server = undefined; } private async setupLogging() { diff --git a/src/core/types/core_service.ts b/src/core/types/core_service.ts index b6031e0deb7bae9..8a8ac92b93cccd8 100644 --- a/src/core/types/core_service.ts +++ b/src/core/types/core_service.ts @@ -17,7 +17,7 @@ * under the License. */ -export interface CoreService { - start(): Promise; +export interface CoreService { + start(): Promise; stop(): Promise; } diff --git a/src/core_plugins/kibana/server/lib/__tests__/manage_uuid.js b/src/core_plugins/kibana/server/lib/__tests__/manage_uuid.js index dc0663d4aabe52c..04188d874d298be 100644 --- a/src/core_plugins/kibana/server/lib/__tests__/manage_uuid.js +++ b/src/core_plugins/kibana/server/lib/__tests__/manage_uuid.js @@ -19,21 +19,22 @@ import expect from 'expect.js'; import sinon from 'sinon'; -import * as kbnTestServer from '../../../../../test_utils/kbn_server.js'; +import * as kbnTestServer from '../../../../../test_utils/kbn_server'; import manageUuid from '../manage_uuid'; describe('core_plugins/kibana/server/lib', function () { describe('manage_uuid', function () { const testUuid = 'c4add484-0cba-4e05-86fe-4baa112d9e53'; + let root; let kbnServer; let config; before(async function () { this.timeout(60000); // sometimes waiting for server takes longer than 10 - kbnServer = kbnTestServer.createServerWithCorePlugins(); - - await kbnServer.ready(); + root = kbnTestServer.createRootWithCorePlugins(); + await root.start(); + kbnServer = kbnTestServer.getKbnServer(root); }); // clear uuid stuff from previous test runs @@ -42,9 +43,7 @@ describe('core_plugins/kibana/server/lib', function () { config = kbnServer.server.config(); }); - after(async function () { - await kbnServer.close(); - }); + after(async () => await root.shutdown()); it('ensure config uuid is validated as a guid', async function () { config.set('server.uuid', testUuid); diff --git a/src/functional_test_runner/__tests__/lib/index.js b/src/functional_test_runner/__tests__/lib/index.js deleted file mode 100644 index a92d22e2738bb12..000000000000000 --- a/src/functional_test_runner/__tests__/lib/index.js +++ /dev/null @@ -1,20 +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. - */ - -export { startupKibana } from './kibana'; diff --git a/src/functional_test_runner/__tests__/lib/kibana.js b/src/functional_test_runner/__tests__/lib/kibana.js deleted file mode 100644 index df046e34b26589b..000000000000000 --- a/src/functional_test_runner/__tests__/lib/kibana.js +++ /dev/null @@ -1,36 +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 { createServerWithCorePlugins } from '../../../test_utils/kbn_server'; - -export async function startupKibana({ port, esUrl }) { - const server = createServerWithCorePlugins({ - server: { - port, - autoListen: true, - }, - - elasticsearch: { - url: esUrl - } - }); - - await server.ready(); - return server; -} diff --git a/src/server/config/__tests__/deprecation_warnings.js b/src/server/config/__tests__/deprecation_warnings.js index 4e90708456b253e..9935a2e4bddbff2 100644 --- a/src/server/config/__tests__/deprecation_warnings.js +++ b/src/server/config/__tests__/deprecation_warnings.js @@ -40,7 +40,8 @@ describe('config/deprecation warnings mixin', function () { env: { CREATE_SERVER_OPTS: JSON.stringify({ logging: { - quiet: false + quiet: false, + silent: false }, uiSettings: { enabled: true diff --git a/src/server/config/__tests__/fixtures/run_kbn_server_startup.js b/src/server/config/__tests__/fixtures/run_kbn_server_startup.js index 46eb6b4661f49f2..d6622cf69ddb008 100644 --- a/src/server/config/__tests__/fixtures/run_kbn_server_startup.js +++ b/src/server/config/__tests__/fixtures/run_kbn_server_startup.js @@ -17,18 +17,18 @@ * under the License. */ -import { createServer } from '../../../../test_utils/kbn_server'; +import { createRoot } from '../../../../test_utils/kbn_server'; (async function run() { - const server = createServer(JSON.parse(process.env.CREATE_SERVER_OPTS)); + const root = createRoot(JSON.parse(process.env.CREATE_SERVER_OPTS)); // We just need the server to run through startup so that it will // log the deprecation messages. Once it has started up we close it // to allow the process to exit naturally try { - await server.ready(); + await root.start(); } finally { - await server.close(); + await root.shutdown(); } }()); diff --git a/src/server/http/__snapshots__/max_payload_size.test.js.snap b/src/server/http/__snapshots__/max_payload_size.test.js.snap deleted file mode 100644 index 12e9ab278e1fb8f..000000000000000 --- a/src/server/http/__snapshots__/max_payload_size.test.js.snap +++ /dev/null @@ -1,3 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`fails with 400 if payload size is larger than default and route config allows 1`] = `"{\\"statusCode\\":400,\\"error\\":\\"Bad Request\\",\\"message\\":\\"Payload content length greater than maximum allowed: 200\\"}"`; diff --git a/src/server/http/__snapshots__/xsrf.test.js.snap b/src/server/http/__snapshots__/xsrf.test.js.snap deleted file mode 100644 index 2113d27927dce94..000000000000000 --- a/src/server/http/__snapshots__/xsrf.test.js.snap +++ /dev/null @@ -1,7 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`xsrf request filter destructiveMethod: DELETE rejects requests without either an xsrf or version header: DELETE reject response 1`] = `"{\\"statusCode\\":400,\\"error\\":\\"Bad Request\\",\\"message\\":\\"Request must contain a kbn-xsrf header.\\"}"`; - -exports[`xsrf request filter destructiveMethod: POST rejects requests without either an xsrf or version header: POST reject response 1`] = `"{\\"statusCode\\":400,\\"error\\":\\"Bad Request\\",\\"message\\":\\"Request must contain a kbn-xsrf header.\\"}"`; - -exports[`xsrf request filter destructiveMethod: PUT rejects requests without either an xsrf or version header: PUT reject response 1`] = `"{\\"statusCode\\":400,\\"error\\":\\"Bad Request\\",\\"message\\":\\"Request must contain a kbn-xsrf header.\\"}"`; diff --git a/src/server/http/index.js b/src/server/http/index.js index 3b16cec484c3055..7012b095a865859 100644 --- a/src/server/http/index.js +++ b/src/server/http/index.js @@ -30,35 +30,7 @@ export default async function (kbnServer, server, config) { kbnServer.server = new Hapi.Server(); server = kbnServer.server; - // Note that all connection options configured here should be exactly the same - // as in `getServerOptions()` in the new platform (see `src/core/server/http/http_tools`). - // - // The only exception is `tls` property: TLS is entirely handled by the new - // platform and we don't have to duplicate all TLS related settings here, we just need - // to indicate to Hapi connection that TLS is used so that it can use correct protocol - // name in `server.info` and `request.connection.info` that are used throughout Kibana. - // - // Any change SHOULD BE applied in both places. - server.connection({ - host: config.get('server.host'), - port: config.get('server.port'), - tls: config.get('server.ssl.enabled'), - listener: kbnServer.newPlatform.proxyListener, - state: { - strictHeader: false, - }, - routes: { - cors: config.get('server.cors'), - payload: { - maxBytes: config.get('server.maxPayloadBytes'), - }, - validate: { - options: { - abortEarly: false, - }, - }, - }, - }); + server.connection(kbnServer.core.serverOptions); registerHapiPlugins(server); diff --git a/src/server/http/max_payload_size.test.js b/src/server/http/max_payload_size.test.js index 499ce43b8d09a50..d82c4a5c324d446 100644 --- a/src/server/http/max_payload_size.test.js +++ b/src/server/http/max_payload_size.test.js @@ -19,52 +19,34 @@ import * as kbnTestServer from '../../test_utils/kbn_server'; -let kbnServer; -async function makeServer({ maxPayloadBytesDefault, maxPayloadBytesRoute }) { - kbnServer = kbnTestServer.createServer({ - server: { maxPayloadBytes: maxPayloadBytesDefault } - }); +let root; +beforeAll(async () => { + root = kbnTestServer.createRoot({ server: { maxPayloadBytes: 100 } }); - await kbnServer.ready(); + await root.start(); - kbnServer.server.route({ + kbnTestServer.getKbnServer(root).server.route({ path: '/payload_size_check/test/route', method: 'POST', - config: { payload: { maxBytes: maxPayloadBytesRoute } }, - handler: function (req, reply) { - reply(null, req.payload.data.slice(0, 5)); - } + config: { payload: { maxBytes: 200 } }, + handler: (req, reply) => reply(null, req.payload.data.slice(0, 5)), }); -} - -async function makeRequest(opts) { - return await kbnTestServer.makeRequest(kbnServer, opts); -} +}, 30000); -afterEach(async () => await kbnServer.close()); +afterAll(async () => await root.shutdown()); test('accepts payload with a size larger than default but smaller than route config allows', async () => { - await makeServer({ maxPayloadBytesDefault: 100, maxPayloadBytesRoute: 200 }); - - const resp = await makeRequest({ - url: '/payload_size_check/test/route', - method: 'POST', - payload: { data: Array(150).fill('+').join('') }, - }); - - expect(resp.statusCode).toBe(200); - expect(resp.payload).toBe('+++++'); + await kbnTestServer.request.post(root, '/payload_size_check/test/route') + .send({ data: Array(150).fill('+').join('') }) + .expect(200, '+++++'); }); test('fails with 400 if payload size is larger than default and route config allows', async () => { - await makeServer({ maxPayloadBytesDefault: 100, maxPayloadBytesRoute: 200 }); - - const resp = await makeRequest({ - url: '/payload_size_check/test/route', - method: 'POST', - payload: { data: Array(250).fill('+').join('') }, - }); - - expect(resp.statusCode).toBe(400); - expect(resp.payload).toMatchSnapshot(); + await kbnTestServer.request.post(root, '/payload_size_check/test/route') + .send({ data: Array(250).fill('+').join('') }) + .expect(400, { + statusCode: 400, + error: 'Bad Request', + message: 'Payload content length greater than maximum allowed: 200' + }); }); diff --git a/src/server/http/setup_connection.js b/src/server/http/setup_connection.js deleted file mode 100644 index e69de29bb2d1d64..000000000000000 diff --git a/src/server/http/version_check.test.js b/src/server/http/version_check.test.js index e5257f814e8ae93..6d853ff66f79139 100644 --- a/src/server/http/version_check.test.js +++ b/src/server/http/version_check.test.js @@ -26,63 +26,40 @@ const versionHeader = 'kbn-version'; const version = require(src('../package.json')).version; describe('version_check request filter', function () { - async function makeRequest(kbnServer, opts) { - return await kbnTestServer.makeRequest(kbnServer, opts); - } + let root; + beforeAll(async () => { + root = kbnTestServer.createRoot(); - async function makeServer() { - const kbnServer = kbnTestServer.createServer(); + await root.start(); - await kbnServer.ready(); - - kbnServer.server.route({ + kbnTestServer.getKbnServer(root).server.route({ path: '/version_check/test/route', method: 'GET', handler: function (req, reply) { reply(null, 'ok'); } }); + }, 30000); - return kbnServer; - } - - let kbnServer; - beforeEach(async () => kbnServer = await makeServer()); - afterEach(async () => await kbnServer.close()); + afterAll(async () => await root.shutdown()); it('accepts requests with the correct version passed in the version header', async function () { - const resp = await makeRequest(kbnServer, { - url: '/version_check/test/route', - method: 'GET', - headers: { - [versionHeader]: version, - }, - }); - - expect(resp.statusCode).toBe(200); - expect(resp.payload).toBe('ok'); + await kbnTestServer.request + .get(root, '/version_check/test/route') + .set(versionHeader, version) + .expect(200, 'ok'); }); it('rejects requests with an incorrect version passed in the version header', async function () { - const resp = await makeRequest(kbnServer, { - url: '/version_check/test/route', - method: 'GET', - headers: { - [versionHeader]: `invalid:${version}`, - }, - }); - - expect(resp.statusCode).toBe(400); - expect(resp.payload).toMatch(/"Browser client is out of date/); + await kbnTestServer.request + .get(root, '/version_check/test/route') + .set(versionHeader, `invalid:${version}`) + .expect(400, /"Browser client is out of date/); }); it('accepts requests that do not include a version header', async function () { - const resp = await makeRequest(kbnServer, { - url: '/version_check/test/route', - method: 'GET' - }); - - expect(resp.statusCode).toBe(200); - expect(resp.payload).toBe('ok'); + await kbnTestServer.request + .get(root, '/version_check/test/route') + .expect(200, 'ok'); }); }); diff --git a/src/server/http/xsrf.test.js b/src/server/http/xsrf.test.js index 2fc6dba4703efc8..1eedf6b8268672c 100644 --- a/src/server/http/xsrf.test.js +++ b/src/server/http/xsrf.test.js @@ -29,23 +29,18 @@ const testPath = '/xsrf/test/route'; const whitelistedTestPath = '/xsrf/test/route/whitelisted'; const actualVersion = require(src('../package.json')).version; -describe('xsrf request filter', function () { - async function inject(kbnServer, opts) { - return await kbnTestServer.makeRequest(kbnServer, opts); - } - - const makeServer = async function () { - const kbnServer = kbnTestServer.createServer({ +describe('xsrf request filter', () => { + let root; + beforeAll(async () => { + root = kbnTestServer.createRoot({ server: { - xsrf: { - disableProtection: false, - whitelist: [whitelistedTestPath] - } + xsrf: { disableProtection: false, whitelist: [whitelistedTestPath] } } }); - await kbnServer.ready(); + await root.start(); + const kbnServer = kbnTestServer.getKbnServer(root); kbnServer.server.route({ path: testPath, method: 'GET', @@ -81,117 +76,68 @@ describe('xsrf request filter', function () { reply(null, 'ok'); } }); + }, 30000); - return kbnServer; - }; - - let kbnServer; - beforeEach(async () => { - kbnServer = await makeServer(); - }); - - afterEach(async () => { - await kbnServer.close(); - }); + afterAll(async () => await root.shutdown()); describe(`nonDestructiveMethod: GET`, function () { it('accepts requests without a token', async function () { - const resp = await inject(kbnServer, { - url: testPath, - method: 'GET' - }); - - expect(resp.statusCode).toBe(200); - expect(resp.payload).toBe('ok'); + await kbnTestServer.request + .get(root, testPath) + .expect(200, 'ok'); }); it('accepts requests with the xsrf header', async function () { - const resp = await inject(kbnServer, { - url: testPath, - method: 'GET', - headers: { - [xsrfHeader]: 'anything', - }, - }); - - expect(resp.statusCode).toBe(200); - expect(resp.payload).toBe('ok'); + await kbnTestServer.request + .get(root, testPath) + .set(xsrfHeader, 'anything') + .expect(200, 'ok'); }); }); describe(`nonDestructiveMethod: HEAD`, function () { it('accepts requests without a token', async function () { - const resp = await inject(kbnServer, { - url: testPath, - method: 'HEAD' - }); - - expect(resp.statusCode).toBe(200); - expect(resp.payload).toHaveLength(0); + await kbnTestServer.request + .head(root, testPath) + .expect(200, undefined); }); it('accepts requests with the xsrf header', async function () { - const resp = await inject(kbnServer, { - url: testPath, - method: 'HEAD', - headers: { - [xsrfHeader]: 'anything', - }, - }); - - expect(resp.statusCode).toBe(200); - expect(resp.payload).toHaveLength(0); + await kbnTestServer.request + .head(root, testPath) + .set(xsrfHeader, 'anything') + .expect(200, undefined); }); }); for (const method of destructiveMethods) { describe(`destructiveMethod: ${method}`, function () { // eslint-disable-line no-loop-func it('accepts requests with the xsrf header', async function () { - const resp = await inject(kbnServer, { - url: testPath, - method: method, - headers: { - [xsrfHeader]: 'anything', - }, - }); - - expect(resp.statusCode).toBe(200); - expect(resp.payload).toBe('ok'); + await kbnTestServer.request[method.toLowerCase()](root, testPath) + .set(xsrfHeader, 'anything') + .expect(200, 'ok'); }); // this is still valid for existing csrf protection support // it does not actually do any validation on the version value itself it('accepts requests with the version header', async function () { - const resp = await inject(kbnServer, { - url: testPath, - method: method, - headers: { - [versionHeader]: actualVersion, - }, - }); - - expect(resp.statusCode).toBe(200); - expect(resp.payload).toBe('ok'); + await kbnTestServer.request[method.toLowerCase()](root, testPath) + .set(versionHeader, actualVersion) + .expect(200, 'ok'); }); it('rejects requests without either an xsrf or version header', async function () { - const resp = await inject(kbnServer, { - url: testPath, - method: method - }); - - expect(resp.statusCode).toBe(400); - expect(resp.result).toMatchSnapshot(`${method} reject response`); + await kbnTestServer.request[method.toLowerCase()](root, testPath) + .expect(400, { + statusCode: 400, + error: 'Bad Request', + message: 'Request must contain a kbn-xsrf header.' + }); }); it('accepts whitelisted requests without either an xsrf or version header', async function () { - const resp = await inject(kbnServer, { - url: whitelistedTestPath, - method: method - }); - - expect(resp.statusCode).toBe(200); - expect(resp.payload).toBe('ok'); + await kbnTestServer.request[method.toLowerCase()](root, whitelistedTestPath) + .expect(200, 'ok'); }); }); } diff --git a/src/server/kbn_server.js b/src/server/kbn_server.js index 7279a8f407b1100..4f4334d764ddbfc 100644 --- a/src/server/kbn_server.js +++ b/src/server/kbn_server.js @@ -21,6 +21,7 @@ import { constant, once, compact, flatten } from 'lodash'; import { fromNode } from 'bluebird'; import { isWorker } from 'cluster'; import { fromRoot, pkg } from '../utils'; +import { Config } from './config'; import loggingConfiguration from './logging/configuration'; import configSetupMixin from './config/setup'; import httpMixin from './http'; @@ -30,6 +31,7 @@ import { usageMixin } from './usage'; import { statusMixin } from './status'; import pidMixin from './pid'; import { configDeprecationWarningsMixin } from './config/deprecation_warnings'; +import { transformDeprecations } from './config/transform_deprecations'; import configCompleteMixin from './config/complete'; import optimizeMixin from '../optimize'; import * as Plugins from './plugins'; @@ -41,27 +43,26 @@ import { urlShorteningMixin } from './url_shortening'; import { serverExtensionsMixin } from './server_extensions'; import { uiMixin } from '../ui'; import { sassMixin } from './sass'; -import { injectIntoKbnServer as newPlatformMixin } from '../core'; import { i18nMixin } from './i18n'; const rootDir = fromRoot('.'); export default class KbnServer { - constructor(settings) { + constructor(settings, core) { this.name = pkg.name; this.version = pkg.version; this.build = pkg.build || false; this.rootDir = rootDir; this.settings = settings || {}; + this.core = core; + this.ready = constant(this.mixin( Plugins.waitForInitSetupMixin, // sets this.config, reads this.settings configSetupMixin, - newPlatformMixin, - // sets this.server httpMixin, @@ -111,13 +112,6 @@ export default class KbnServer { // notify any deferred setup logic that plugins have initialized Plugins.waitForInitResolveMixin, - - () => { - if (this.config.get('server.autoListen')) { - this.ready = constant(Promise.resolve()); - return this.listen(); - } - } )); this.listen = once(this.listen); @@ -148,14 +142,17 @@ export default class KbnServer { async listen() { await this.ready(); - const { server } = this; - await fromNode(cb => server.start(cb)); - if (isWorker) { // help parent process know when we are ready process.send(['WORKER_LISTENING']); } + const { server, config } = this; + server.log(['listening', 'info'], `Server running at ${server.info.uri}${ + config.get('server.rewriteBasePath') + ? config.get('server.basePath') + : '' + }`); return server; } @@ -171,7 +168,12 @@ export default class KbnServer { return await this.server.inject(opts); } - async applyLoggingConfiguration(config) { + applyLoggingConfiguration(settings) { + const config = new Config( + this.config.getSchema(), + transformDeprecations(settings) + ); + const loggingOptions = loggingConfiguration(config); const subset = { ops: config.get('ops'), diff --git a/src/test_utils/base_auth.js b/src/test_utils/base_auth.js deleted file mode 100644 index 270ed7563e7c10c..000000000000000 --- a/src/test_utils/base_auth.js +++ /dev/null @@ -1,23 +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. - */ - -export function header(user, pass) { - const encoded = new Buffer(`${user}:${pass}`).toString('base64'); - return `Basic ${encoded}`; -} diff --git a/src/test_utils/kbn_server.js b/src/test_utils/kbn_server.js deleted file mode 100644 index 9c7d55208c4bb8c..000000000000000 --- a/src/test_utils/kbn_server.js +++ /dev/null @@ -1,106 +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 { resolve } from 'path'; -import { defaultsDeep, set } from 'lodash'; -import { header as basicAuthHeader } from './base_auth'; -import { esTestConfig, kibanaTestUser, kibanaServerTestUser } from '@kbn/test'; -import KbnServer from '../../src/server/kbn_server'; - -const DEFAULTS_SETTINGS = { - server: { - autoListen: true, - // Use the ephemeral port to make sure that tests use the first available - // port and aren't affected by the timing issues in test environment. - port: 0, - xsrf: { - disableProtection: true - } - }, - logging: { - quiet: true - }, - plugins: {}, - optimize: { - enabled: false - }, -}; - -const DEFAULT_SETTINGS_WITH_CORE_PLUGINS = { - plugins: { - scanDirs: [ - resolve(__dirname, '../core_plugins'), - ], - }, - elasticsearch: { - url: esTestConfig.getUrl(), - username: kibanaServerTestUser.username, - password: kibanaServerTestUser.password - }, -}; - -/** - * Creates an instance of KbnServer with default configuration - * tailored for unit tests - * - * @param {Object} [settings={}] Any config overrides for this instance - * @return {KbnServer} - */ -export function createServer(settings = {}) { - return new KbnServer(defaultsDeep({}, settings, DEFAULTS_SETTINGS)); -} - -/** - * Creates an instance of KbnServer, including all of the core plugins, - * with default configuration tailored for unit tests - * - * @param {Object} [settings={}] - * @return {KbnServer} - */ -export function createServerWithCorePlugins(settings = {}) { - return new KbnServer(defaultsDeep({}, settings, DEFAULT_SETTINGS_WITH_CORE_PLUGINS, DEFAULTS_SETTINGS)); -} - -/** - * Creates request configuration with a basic auth header - */ -export function authOptions() { - const { username, password } = kibanaTestUser; - const authHeader = basicAuthHeader(username, password); - return set({}, 'headers.Authorization', authHeader); -} - -/** - * Makes a request with test headers via hapi server inject() - * - * The given options are decorated with default testing options, so it's - * recommended to use this function instead of using inject() directly whenever - * possible throughout the tests. - * - * @param {KbnServer} kbnServer - * @param {object} options Any additional options or overrides for inject() - */ -export async function makeRequest(kbnServer, options) { - // Since all requests to Kibana hit core http server first and only after that - // are proxied to the "legacy" Kibana we should inject requests through the top - // level Hapi server used by the core. - return await kbnServer.newPlatform.proxyListener.root.server.http.service.httpServer.server.inject( - defaultsDeep({}, authOptions(), options) - ); -} diff --git a/src/test_utils/kbn_server.ts b/src/test_utils/kbn_server.ts new file mode 100644 index 000000000000000..c8a88241994a49b --- /dev/null +++ b/src/test_utils/kbn_server.ts @@ -0,0 +1,115 @@ +/* + * 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. + */ + +// @ts-ignore: implicit any for JS file +import { esTestConfig, kibanaServerTestUser, kibanaTestUser } from '@kbn/test'; +import { defaultsDeep } from 'lodash'; +import { resolve } from 'path'; +import { BehaviorSubject } from 'rxjs'; +import supertest from 'supertest'; +import { Env } from '../core/server/config'; +import { LegacyObjectToRawConfigAdapter } from '../core/server/legacy_compat'; +import { Root } from '../core/server/root'; + +type HttpMethod = 'delete' | 'get' | 'head' | 'post' | 'put'; + +const DEFAULTS_SETTINGS = { + server: { + autoListen: true, + // Use the ephemeral port to make sure that tests use the first available + // port and aren't affected by the timing issues in test environment. + port: 0, + xsrf: { disableProtection: true }, + }, + logging: { silent: true }, + plugins: {}, + optimize: { enabled: false }, +}; + +const DEFAULT_SETTINGS_WITH_CORE_PLUGINS = { + plugins: { scanDirs: [resolve(__dirname, '../core_plugins')] }, + elasticsearch: { + url: esTestConfig.getUrl(), + username: kibanaServerTestUser.username, + password: kibanaServerTestUser.password, + }, +}; + +export function createRootWithSettings(...settings: Array>) { + return new Root( + new BehaviorSubject( + new LegacyObjectToRawConfigAdapter(defaultsDeep({}, ...settings, DEFAULTS_SETTINGS)) + ), + Env.createDefault({ configs: [], cliArgs: {}, isDevClusterMaster: false }) + ); +} + +/** + * Returns supertest request attached to the core's internal native Node server. + * @param root + * @param method + * @param path + */ +function getSupertest(root: Root, method: HttpMethod, path: string) { + const testUserCredentials = new Buffer(`${kibanaTestUser.username}:${kibanaTestUser.password}`); + return supertest((root as any).server.http.service.httpServer.server.listener) + [method](path) + .set('Authorization', `Basic ${testUserCredentials.toString('base64')}`); +} + +/** + * Creates an instance of Root with default configuration + * tailored for unit tests. + * + * @param {Object} [settings={}] Any config overrides for this instance. + * @returns {Root} + */ +export function createRoot(settings = {}) { + return createRootWithSettings(settings); +} + +/** + * Creates an instance of Root, including all of the core plugins, + * with default configuration tailored for unit tests. + * + * @param {Object} [settings={}] + * @returns {Root} + */ +export function createRootWithCorePlugins(settings = {}) { + return createRootWithSettings(settings, DEFAULT_SETTINGS_WITH_CORE_PLUGINS); +} + +/** + * Returns `kbnServer` instance used in the "legacy" Kibana. + * @param root + */ +export function getKbnServer(root: Root) { + return (root as any).server.legacy.service.kbnServer; +} + +export const request: Record< + HttpMethod, + (root: Root, path: string) => ReturnType +> = { + delete: (root, path) => getSupertest(root, 'delete', path), + get: (root, path) => getSupertest(root, 'get', path), + head: (root, path) => getSupertest(root, 'head', path), + post: (root, path) => getSupertest(root, 'post', path), + put: (root, path) => getSupertest(root, 'put', path), +}; diff --git a/src/ui/__tests__/ui_exports_replace_injected_vars.js b/src/ui/__tests__/ui_exports_replace_injected_vars.js index b7762ef104b901a..5cb05ac1dbeeb78 100644 --- a/src/ui/__tests__/ui_exports_replace_injected_vars.js +++ b/src/ui/__tests__/ui_exports_replace_injected_vars.js @@ -25,10 +25,10 @@ import sinon from 'sinon'; import cheerio from 'cheerio'; import { noop } from 'lodash'; -import KbnServer from '../../server/kbn_server'; +import { createRoot, getKbnServer, request } from '../../test_utils/kbn_server'; const getInjectedVarsFromResponse = (resp) => { - const $ = cheerio.load(resp.payload); + const $ = cheerio.load(resp.text); const data = $('kbn-injected-metadata').attr('data'); return JSON.parse(data).legacyMetadata.vars; }; @@ -45,45 +45,46 @@ const injectReplacer = (kbnServer, replacer) => { }; describe('UiExports', function () { - describe('#replaceInjectedVars', function () { + let root; + let kbnServer; + before(async () => { this.slow(2000); - this.timeout(10000); - - let kbnServer; - beforeEach(async () => { - kbnServer = new KbnServer({ - server: { port: 0 }, // pick a random open port - logging: { silent: true }, // no logs - optimize: { enabled: false }, - plugins: { - paths: [resolve(__dirname, './fixtures/test_app')] // inject an app so we can hit /app/{id} - }, - }); + this.timeout(30000); - await kbnServer.ready(); - - // TODO: hopefully we can add better support for something - // like this in the new platform - kbnServer.server._requestor._decorations.getUiSettingsService = { - apply: undefined, - method() { - return { - getDefaults: noop, - getUserProvided: noop - }; - } - }; + root = root = createRoot({ + // inject an app so we can hit /app/{id} + plugins: { paths: [resolve(__dirname, './fixtures/test_app')] }, }); - afterEach(async () => { - await kbnServer.close(); - kbnServer = null; - }); + await root.start(); + + kbnServer = getKbnServer(root); + + // TODO: hopefully we can add better support for something + // like this in the new platform + kbnServer.server._requestor._decorations.getUiSettingsService = { + apply: undefined, + method: () => ({ getDefaults: noop, getUserProvided: noop }) + }; + }); + + after(async () => await root.shutdown()); + let originalInjectedVarsReplacers; + beforeEach(() => { + originalInjectedVarsReplacers = kbnServer.uiExports.injectedVarsReplacers; + }); + + afterEach(() => { + kbnServer.uiExports.injectedVarsReplacers = originalInjectedVarsReplacers; + }); + + describe('#replaceInjectedVars', function () { it('allows sync replacing of injected vars', async () => { injectReplacer(kbnServer, () => ({ a: 1 })); - const resp = await kbnServer.inject('/app/test_app'); + const resp = await request.get(root, '/app/test_app') + .expect(200); const injectedVars = getInjectedVarsFromResponse(resp); expect(injectedVars).to.eql({ a: 1 }); @@ -98,7 +99,8 @@ describe('UiExports', function () { }; }); - const resp = await kbnServer.inject('/app/test_app'); + const resp = await request.get(root, '/app/test_app') + .expect(200); const injectedVars = getInjectedVarsFromResponse(resp); expect(injectedVars).to.eql({ @@ -111,7 +113,8 @@ describe('UiExports', function () { injectReplacer(kbnServer, () => ({ foo: 'bar' })); injectReplacer(kbnServer, stub); - await kbnServer.inject('/app/test_app'); + await await request.get(root, '/app/test_app') + .expect(200); sinon.assert.calledOnce(stub); expect(stub.firstCall.args[0]).to.eql({ foo: 'bar' }); // originalInjectedVars @@ -126,7 +129,8 @@ describe('UiExports', function () { injectReplacer(kbnServer, orig => ({ name: orig.name + 'a' })); injectReplacer(kbnServer, orig => ({ name: orig.name + 'm' })); - const resp = await kbnServer.inject('/app/test_app'); + const resp = await request.get(root, '/app/test_app') + .expect(200); const injectedVars = getInjectedVarsFromResponse(resp); expect(injectedVars).to.eql({ name: 'sam' }); @@ -138,15 +142,17 @@ describe('UiExports', function () { throw new Error('replacer failed'); }); - const resp = await kbnServer.inject('/app/test_app'); - expect(resp).to.have.property('statusCode', 500); + await request.get(root, '/app/test_app') + .expect(500); }); it('starts off with the injected vars for the app merged with the default injected vars', async () => { const stub = sinon.stub(); injectReplacer(kbnServer, stub); - await kbnServer.inject('/app/test_app'); + await request.get(root, '/app/test_app') + .expect(200); + sinon.assert.calledOnce(stub); expect(stub.firstCall.args[0]).to.eql({ from_defaults: true, from_test_app: true }); }); diff --git a/src/ui/field_formats/__tests__/field_formats_mixin.js b/src/ui/field_formats/__tests__/field_formats_mixin.js index 58c61962c2414e5..3159705f3d8ed80 100644 --- a/src/ui/field_formats/__tests__/field_formats_mixin.js +++ b/src/ui/field_formats/__tests__/field_formats_mixin.js @@ -22,31 +22,31 @@ import sinon from 'sinon'; import { FieldFormat } from '../field_format'; import * as FieldFormatsServiceNS from '../field_formats_service'; -import { createServer } from '../../../test_utils/kbn_server'; +import { fieldFormatsMixin } from '../field_formats_mixin'; describe('server.registerFieldFormat(createFormat)', () => { const sandbox = sinon.createSandbox(); - let kbnServer; + let registerFieldFormat; + let fieldFormatServiceFactory; + const serverMock = { decorate() {} }; beforeEach(async () => { - kbnServer = createServer(); - await kbnServer.ready(); + sandbox.stub(serverMock); + await fieldFormatsMixin({}, serverMock); + [[,, fieldFormatServiceFactory], [,, registerFieldFormat]] = serverMock.decorate.args; }); - afterEach(async () => { - sandbox.restore(); - await kbnServer.close(); - }); + afterEach(() => sandbox.restore()); it('throws if createFormat is not a function', () => { - expect(() => kbnServer.server.registerFieldFormat()).to.throwError(error => { + expect(() => registerFieldFormat()).to.throwError(error => { expect(error.message).to.match(/createFormat is not a function/i); }); }); it('calls the createFormat() function with the FieldFormat class', () => { const createFormat = sinon.stub(); - kbnServer.server.registerFieldFormat(createFormat); + registerFieldFormat(createFormat); sinon.assert.calledOnce(createFormat); sinon.assert.calledWithExactly(createFormat, sinon.match.same(FieldFormat)); }); @@ -61,9 +61,9 @@ describe('server.registerFieldFormat(createFormat)', () => { class FooFormat { static id = 'foo' } - kbnServer.server.registerFieldFormat(() => FooFormat); + registerFieldFormat(() => FooFormat); - const fieldFormats = await kbnServer.server.fieldFormatServiceFactory({ + const fieldFormats = await fieldFormatServiceFactory({ getAll: () => ({}), getDefaults: () => ({}) }); diff --git a/src/ui/ui_settings/create_or_upgrade_saved_config/__tests__/create_or_upgrade_integration.js b/src/ui/ui_settings/create_or_upgrade_saved_config/__tests__/create_or_upgrade_integration.js index f83c4ba8805d6c1..48d794b22e6583c 100644 --- a/src/ui/ui_settings/create_or_upgrade_saved_config/__tests__/create_or_upgrade_integration.js +++ b/src/ui/ui_settings/create_or_upgrade_saved_config/__tests__/create_or_upgrade_integration.js @@ -21,12 +21,13 @@ import sinon from 'sinon'; import expect from 'expect.js'; import { createEsTestCluster } from '@kbn/test'; -import { createServerWithCorePlugins } from '../../../../test_utils/kbn_server'; +import { createRootWithCorePlugins, getKbnServer } from '../../../../test_utils/kbn_server'; import { ToolingLog } from '@kbn/dev-utils'; import { createOrUpgradeSavedConfig } from '../create_or_upgrade_saved_config'; describe('createOrUpgradeSavedConfig()', () => { let savedObjectsClient; + let root; let kbnServer; const cleanup = []; @@ -48,10 +49,13 @@ describe('createOrUpgradeSavedConfig()', () => { await es.start(); - kbnServer = createServerWithCorePlugins(); - await kbnServer.ready(); + root = createRootWithCorePlugins(); + await root.start(); + kbnServer = getKbnServer(root); + cleanup.push(async () => { - await kbnServer.close(); + await root.shutdown(); + root = null; kbnServer = null; savedObjectsClient = null; }); diff --git a/src/ui/ui_settings/routes/__tests__/lib/servers.js b/src/ui/ui_settings/routes/__tests__/lib/servers.js index f27afb54c808f73..ee992a5752fd984 100644 --- a/src/ui/ui_settings/routes/__tests__/lib/servers.js +++ b/src/ui/ui_settings/routes/__tests__/lib/servers.js @@ -21,6 +21,7 @@ import { createEsTestCluster } from '@kbn/test'; import { ToolingLog } from '@kbn/dev-utils'; import * as kbnTestServer from '../../../../../test_utils/kbn_server'; +let root; let kbnServer; let services; let es; @@ -41,14 +42,16 @@ export async function startServers() { log.indent(-4); await es.start(); - kbnServer = kbnTestServer.createServerWithCorePlugins({ + root = kbnTestServer.createRootWithCorePlugins({ uiSettings: { overrides: { foo: 'bar', } } }); - await kbnServer.ready(); + await root.start(); + + kbnServer = kbnTestServer.getKbnServer(root); await kbnServer.server.plugins.elasticsearch.waitUntilReady(); } @@ -79,8 +82,9 @@ export function getServices() { export async function stopServers() { services = null; - if (kbnServer) { - await kbnServer.close(); + if (root) { + await root.shutdown(); + root = null; kbnServer = null; } diff --git a/x-pack/package.json b/x-pack/package.json index c9ebc165708ee8b..4b5e2ec4efa4b86 100644 --- a/x-pack/package.json +++ b/x-pack/package.json @@ -68,8 +68,8 @@ "run-sequence": "^2.2.1", "simple-git": "1.37.0", "sinon": "^5.0.7", - "supertest": "3.0.0", - "supertest-as-promised": "4.0.2", + "supertest": "^3.1.0", + "supertest-as-promised": "^4.0.2", "tmp": "0.0.31", "tree-kill": "^1.1.0", "typescript": "^2.9.2", diff --git a/x-pack/yarn.lock b/x-pack/yarn.lock index 1c859041251d4f3..ed04fa8a8f7391c 100644 --- a/x-pack/yarn.lock +++ b/x-pack/yarn.lock @@ -7387,7 +7387,7 @@ subtext@4.x.x: pez "2.x.x" wreck "12.x.x" -superagent@^3.0.0: +superagent@3.8.2: version "3.8.2" resolved "https://registry.yarnpkg.com/superagent/-/superagent-3.8.2.tgz#e4a11b9d047f7d3efeb3bbe536d9ec0021d16403" dependencies: @@ -7402,19 +7402,19 @@ superagent@^3.0.0: qs "^6.5.1" readable-stream "^2.0.5" -supertest-as-promised@4.0.2: +supertest-as-promised@^4.0.2: version "4.0.2" resolved "https://registry.yarnpkg.com/supertest-as-promised/-/supertest-as-promised-4.0.2.tgz#0464f2bd256568d4a59bce84269c0548f6879f1a" dependencies: bluebird "^3.3.1" methods "^1.1.1" -supertest@3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/supertest/-/supertest-3.0.0.tgz#8d4bb68fd1830ee07033b1c5a5a9a4021c965296" +supertest@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/supertest/-/supertest-3.1.0.tgz#f9ebaf488e60f2176021ec580bdd23ad269e7bc6" dependencies: methods "~1.1.2" - superagent "^3.0.0" + superagent "3.8.2" supports-color@1.2.0: version "1.2.0" diff --git a/yarn.lock b/yarn.lock index 40e7ee89fd84275..e79290fcc30700b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -541,9 +541,9 @@ "@types/cookiejar" "*" "@types/node" "*" -"@types/supertest@^2.0.4": - version "2.0.4" - resolved "https://registry.yarnpkg.com/@types/supertest/-/supertest-2.0.4.tgz#28770e13293365e240a842d7d5c5a1b3d2dee593" +"@types/supertest@^2.0.5": + version "2.0.5" + resolved "https://registry.yarnpkg.com/@types/supertest/-/supertest-2.0.5.tgz#18d082a667eaed22759be98f4923e0061ae70c62" dependencies: "@types/superagent" "*" @@ -12699,7 +12699,7 @@ suffix@^0.1.0: version "0.1.1" resolved "https://registry.yarnpkg.com/suffix/-/suffix-0.1.1.tgz#cc58231646a0ef1102f79478ef3a9248fd9c842f" -superagent@^3.0.0: +superagent@3.8.2: version "3.8.2" resolved "https://registry.yarnpkg.com/superagent/-/superagent-3.8.2.tgz#e4a11b9d047f7d3efeb3bbe536d9ec0021d16403" dependencies: @@ -12714,19 +12714,19 @@ superagent@^3.0.0: qs "^6.5.1" readable-stream "^2.0.5" -supertest-as-promised@4.0.2: +supertest-as-promised@^4.0.2: version "4.0.2" resolved "https://registry.yarnpkg.com/supertest-as-promised/-/supertest-as-promised-4.0.2.tgz#0464f2bd256568d4a59bce84269c0548f6879f1a" dependencies: bluebird "^3.3.1" methods "^1.1.1" -supertest@3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/supertest/-/supertest-3.0.0.tgz#8d4bb68fd1830ee07033b1c5a5a9a4021c965296" +supertest@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/supertest/-/supertest-3.1.0.tgz#f9ebaf488e60f2176021ec580bdd23ad269e7bc6" dependencies: methods "~1.1.2" - superagent "^3.0.0" + superagent "3.8.2" supports-color@3.1.2: version "3.1.2"