From fc5e8d81fe5c4b1a01765d5f5a5395f2125d4c8b Mon Sep 17 00:00:00 2001 From: Chris Davies Date: Thu, 10 May 2018 16:33:04 -0400 Subject: [PATCH] Add a REPL mode to the Kibana server (#17638) Add a repl option to the Kibana server. This will run Kibana in dev mode with a repl that has access to the server object. --- src/cli/cluster/cluster_manager.js | 7 + src/cli/repl/__snapshots__/repl.test.js.snap | 118 +++++++++++++++ src/cli/repl/index.js | 56 +++++++ src/cli/repl/repl.test.js | 151 +++++++++++++++++++ src/cli/serve/serve.js | 21 +++ src/dev/build/tasks/copy_source_task.js | 1 + 6 files changed, 354 insertions(+) create mode 100644 src/cli/repl/__snapshots__/repl.test.js.snap create mode 100644 src/cli/repl/index.js create mode 100644 src/cli/repl/repl.test.js diff --git a/src/cli/cluster/cluster_manager.js b/src/cli/cluster/cluster_manager.js index 43b75cfec2cb..7505c4cb8fa3 100644 --- a/src/cli/cluster/cluster_manager.js +++ b/src/cli/cluster/cluster_manager.js @@ -20,6 +20,7 @@ export default class ClusterManager { constructor(opts, config) { this.log = new Log(opts.quiet, opts.silent); this.addedCount = 0; + this.inReplMode = !!opts.repl; const serverArgv = []; const optimizerArgv = [ @@ -144,6 +145,12 @@ export default class ClusterManager { } setupManualRestart() { + // If we're in REPL mode, the user can use the REPL to manually restart. + // The setupManualRestart method interferes with stdin/stdout, in a way + // that negatively affects the REPL. + if (this.inReplMode) { + return; + } const readline = require('readline'); const rl = readline.createInterface(process.stdin, process.stdout); diff --git a/src/cli/repl/__snapshots__/repl.test.js.snap b/src/cli/repl/__snapshots__/repl.test.js.snap new file mode 100644 index 000000000000..beab8226480c --- /dev/null +++ b/src/cli/repl/__snapshots__/repl.test.js.snap @@ -0,0 +1,118 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`repl it colorizes raw values 1`] = `"{ meaning: 42 }"`; + +exports[`repl it handles deep and recursive objects 1`] = ` +"{ '0': + { '1': + { '2': + { '3': + { '4': + { '5': + { '6': + { '7': + { '8': + { '9': + { '10': { '11': { '12': { '13': { '14': { '15': [Object] } } } } } } } } } } } } } } }, + whoops: [Circular] }" +`; + +exports[`repl it handles undefined 1`] = `"undefined"`; + +exports[`repl it prints promise rejects 1`] = ` +Array [ + Array [ + "Waiting for promise...", + ], + Array [ + "Promise Rejected: +", + "'Dang, diggity!'", + ], +] +`; + +exports[`repl it prints promise resolves 1`] = ` +Array [ + Array [ + "Waiting for promise...", + ], + Array [ + "Promise Resolved: +", + "[ 1, 2, 3 ]", + ], +] +`; + +exports[`repl promises rejects only write to a specific depth 1`] = ` +Array [ + Array [ + "Waiting for promise...", + ], + Array [ + "Promise Rejected: +", + "{ '0': + { '1': + { '2': + { '3': + { '4': + { '5': + { '6': + { '7': + { '8': + { '9': + { '10': { '11': { '12': { '13': { '14': { '15': [Object] } } } } } } } } } } } } } } }, + whoops: [Circular] }", + ], +] +`; + +exports[`repl promises resolves only write to a specific depth 1`] = ` +Array [ + Array [ + "Waiting for promise...", + ], + Array [ + "Promise Resolved: +", + "{ '0': + { '1': + { '2': + { '3': + { '4': + { '5': + { '6': + { '7': + { '8': + { '9': + { '10': { '11': { '12': { '13': { '14': { '15': [Object] } } } } } } } } } } } } } } }, + whoops: [Circular] }", + ], +] +`; + +exports[`repl repl exposes a print object that lets you tailor depth 1`] = ` +Array [ + Array [ + "{ hello: { world: [Object] } }", + ], +] +`; + +exports[`repl repl exposes a print object that prints promises 1`] = ` +Array [ + Array [ + "", + ], + Array [ + "Waiting for promise...", + ], + Array [ + "Promise Resolved: +", + "{ hello: { world: [Object] } }", + ], +] +`; diff --git a/src/cli/repl/index.js b/src/cli/repl/index.js new file mode 100644 index 000000000000..4f9ffd2c1336 --- /dev/null +++ b/src/cli/repl/index.js @@ -0,0 +1,56 @@ +import repl from 'repl'; +import util from 'util'; + +/** + * Starts an interactive REPL with a global `server` object. + * + * @param {KibanaServer} kbnServer + */ +export function startRepl(kbnServer) { + const replServer = repl.start({ + prompt: 'Kibana> ', + useColors: true, + writer: promiseFriendlyWriter(() => replServer.displayPrompt()), + }); + + replServer.context.kbnServer = kbnServer; + replServer.context.server = kbnServer.server; + replServer.context.repl = { + print(obj, depth = null) { + console.log(promisePrint(obj, () => replServer.displayPrompt(), depth)); + return ''; + }, + }; + + return replServer; +} + +function colorize(o, depth) { + return util.inspect(o, { colors: true, depth }); +} + +function prettyPrint(text, o, depth) { + console.log(text, colorize(o, depth)); +} + +// This lets us handle promises more gracefully than the default REPL, +// which doesn't show the results. +function promiseFriendlyWriter(displayPrompt) { + const PRINT_DEPTH = 15; + return (result) => promisePrint(result, displayPrompt, PRINT_DEPTH); +} + +function promisePrint(result, displayPrompt, depth) { + if (result && typeof result.then === 'function') { + // Bit of a hack to encourage the user to wait for the result of a promise + // by printing text out beside the current prompt. + Promise.resolve() + .then(() => console.log('Waiting for promise...')) + .then(() => result) + .then((o) => prettyPrint('Promise Resolved: \n', o, depth)) + .catch((err) => prettyPrint('Promise Rejected: \n', err, depth)) + .then(displayPrompt); + return ''; + } + return colorize(result, depth); +} diff --git a/src/cli/repl/repl.test.js b/src/cli/repl/repl.test.js new file mode 100644 index 000000000000..57cb152ecca7 --- /dev/null +++ b/src/cli/repl/repl.test.js @@ -0,0 +1,151 @@ +jest.mock('repl', () => ({ start: (opts) => ({ opts, context: {} }) }), { virtual: true }); + +describe('repl', () => { + const originalConsoleLog = console.log; + + beforeEach(() => { + global.console.log = jest.fn(); + require('repl').start = (opts) => { + return { + opts, + context: { }, + }; + }; + }); + + afterEach(() => { + global.console.log = originalConsoleLog; + }); + + test('it exposes the server object', () => { + const { startRepl } = require('.'); + const testServer = { + server: { }, + }; + const replServer = startRepl(testServer); + expect(replServer.context.server).toBe(testServer.server); + expect(replServer.context.kbnServer).toBe(testServer); + }); + + test('it prompts with Kibana>', () => { + const { startRepl } = require('.'); + expect(startRepl({}).opts.prompt).toBe('Kibana> '); + }); + + test('it colorizes raw values', () => { + const { startRepl } = require('.'); + const replServer = startRepl({}); + expect(replServer.opts.writer({ meaning: 42 })) + .toMatchSnapshot(); + }); + + test('it handles undefined', () => { + const { startRepl } = require('.'); + const replServer = startRepl({}); + expect(replServer.opts.writer()) + .toMatchSnapshot(); + }); + + test('it handles deep and recursive objects', () => { + const { startRepl } = require('.'); + const replServer = startRepl({}); + const splosion = {}; + let child = splosion; + for (let i = 0; i < 2000; ++i) { + child[i] = {}; + child = child[i]; + } + splosion.whoops = splosion; + expect(replServer.opts.writer(splosion)) + .toMatchSnapshot(); + }); + + test('it prints promise resolves', async () => { + const { startRepl } = require('.'); + const replServer = startRepl({}); + const calls = await waitForPrompt( + replServer, + () => replServer.opts.writer(Promise.resolve([1, 2, 3])), + ); + expect(calls) + .toMatchSnapshot(); + }); + + test('it prints promise rejects', async () => { + const { startRepl } = require('.'); + const replServer = startRepl({}); + const calls = await waitForPrompt( + replServer, + () => replServer.opts.writer(Promise.reject('Dang, diggity!')), + ); + expect(calls) + .toMatchSnapshot(); + }); + + test('promises resolves only write to a specific depth', async () => { + const { startRepl } = require('.'); + const replServer = startRepl({}); + const splosion = {}; + let child = splosion; + for (let i = 0; i < 2000; ++i) { + child[i] = {}; + child = child[i]; + } + splosion.whoops = splosion; + const calls = await waitForPrompt( + replServer, + () => replServer.opts.writer(Promise.resolve(splosion)), + ); + expect(calls) + .toMatchSnapshot(); + }); + + test('promises rejects only write to a specific depth', async () => { + const { startRepl } = require('.'); + const replServer = startRepl({}); + const splosion = {}; + let child = splosion; + for (let i = 0; i < 2000; ++i) { + child[i] = {}; + child = child[i]; + } + splosion.whoops = splosion; + const calls = await waitForPrompt( + replServer, + () => replServer.opts.writer(Promise.reject(splosion)), + ); + expect(calls) + .toMatchSnapshot(); + }); + + test('repl exposes a print object that lets you tailor depth', () => { + const { startRepl } = require('.'); + const replServer = startRepl({}); + replServer.context.repl.print({ hello: { world: { nstuff: 'yo' } } }, 1); + expect(global.console.log.mock.calls) + .toMatchSnapshot(); + }); + + test('repl exposes a print object that prints promises', async () => { + const { startRepl } = require('.'); + const replServer = startRepl({}); + const promise = Promise.resolve({ hello: { world: { nstuff: 'yo' } } }); + const calls = await waitForPrompt( + replServer, + () => replServer.context.repl.print(promise, 1), + ); + expect(calls) + .toMatchSnapshot(); + }); + + async function waitForPrompt(replServer, fn) { + let resolveDone; + const done = new Promise((resolve) => resolveDone = resolve); + replServer.displayPrompt = () => { + resolveDone(); + }; + fn(); + await done; + return global.console.log.mock.calls; + } +}); diff --git a/src/cli/serve/serve.js b/src/cli/serve/serve.js index 0874475eadea..fd62df6c527d 100644 --- a/src/cli/serve/serve.js +++ b/src/cli/serve/serve.js @@ -10,6 +10,8 @@ import { readKeystore } from './read_keystore'; 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); @@ -152,6 +154,10 @@ export default function (program) { ) .option('--plugins ', 'an alias for --plugin-dir', pluginDirCollector); + if (!!startRepl) { + command.option('--repl', 'Run the server with a REPL prompt and access to the server object'); + } + if (XPACK_OPTIONAL) { command .option('--oss', 'Start Kibana without X-Pack'); @@ -223,10 +229,25 @@ export default function (program) { kbnServer.server.log(['info', 'config'], 'Reloaded logging configuration due to SIGHUP.'); }); + if (shouldStartRepl(opts)) { + startRepl(kbnServer); + } + 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); diff --git a/src/dev/build/tasks/copy_source_task.js b/src/dev/build/tasks/copy_source_task.js index 0f3b31628766..f06f862b732f 100644 --- a/src/dev/build/tasks/copy_source_task.js +++ b/src/dev/build/tasks/copy_source_task.js @@ -17,6 +17,7 @@ export const CopySourceTask = { '!src/core_plugins/testbed/**', '!src/core_plugins/console/public/tests/**', '!src/cli/cluster/**', + '!src/cli/repl/**', '!src/es_archiver/**', '!src/functional_test_runner/**', '!src/dev/**',