[7.x] [cli/dev] remove cluster module, modernize, test (#84726) (#85174)

Co-authored-by: Alejandro Fernández Haro <afharo@gmail.com>
Co-authored-by: spalger <spalger@users.noreply.github.com>
Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Spencer 2020-12-07 13:20:03 -07:00 committed by GitHub
parent fb7bdf8dfc
commit 28663e102c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
49 changed files with 2369 additions and 1595 deletions

View file

@ -577,6 +577,7 @@
"apollo-link": "^1.2.3",
"apollo-link-error": "^1.1.7",
"apollo-link-state": "^0.4.1",
"argsplit": "^1.0.5",
"autoprefixer": "^9.7.4",
"axe-core": "^4.0.2",
"babel-eslint": "^10.0.3",

View file

@ -33,7 +33,6 @@ export function getEnvOptions(options: DeepPartial<EnvOptions> = {}): EnvOptions
quiet: false,
silent: false,
watch: false,
repl: false,
basePath: false,
disableOptimizer: true,
cache: true,

View file

@ -12,7 +12,6 @@ Env {
"envName": "development",
"oss": false,
"quiet": false,
"repl": false,
"runExamples": false,
"silent": false,
"watch": false,
@ -57,7 +56,6 @@ Env {
"envName": "production",
"oss": false,
"quiet": false,
"repl": false,
"runExamples": false,
"silent": false,
"watch": false,
@ -101,7 +99,6 @@ Env {
"dist": false,
"oss": false,
"quiet": false,
"repl": false,
"runExamples": false,
"silent": false,
"watch": false,
@ -145,7 +142,6 @@ Env {
"dist": false,
"oss": false,
"quiet": false,
"repl": false,
"runExamples": false,
"silent": false,
"watch": false,
@ -189,7 +185,6 @@ Env {
"dist": false,
"oss": false,
"quiet": false,
"repl": false,
"runExamples": false,
"silent": false,
"watch": false,
@ -233,7 +228,6 @@ Env {
"dist": false,
"oss": false,
"quiet": false,
"repl": false,
"runExamples": false,
"silent": false,
"watch": false,

View file

@ -36,7 +36,6 @@ export interface CliArgs {
quiet: boolean;
silent: boolean;
watch: boolean;
repl: boolean;
basePath: boolean;
oss: boolean;
/** @deprecated use disableOptimizer to know if the @kbn/optimizer is disabled in development */

View file

@ -7,6 +7,7 @@ Object {
"error": true,
"info": true,
"silent": true,
"success": true,
"verbose": false,
"warning": true,
},
@ -21,6 +22,7 @@ Object {
"error": true,
"info": false,
"silent": true,
"success": false,
"verbose": false,
"warning": false,
},
@ -35,6 +37,7 @@ Object {
"error": true,
"info": true,
"silent": true,
"success": true,
"verbose": false,
"warning": true,
},
@ -49,6 +52,7 @@ Object {
"error": false,
"info": false,
"silent": true,
"success": false,
"verbose": false,
"warning": false,
},
@ -63,6 +67,7 @@ Object {
"error": true,
"info": true,
"silent": true,
"success": true,
"verbose": true,
"warning": true,
},
@ -77,6 +82,7 @@ Object {
"error": true,
"info": false,
"silent": true,
"success": false,
"verbose": false,
"warning": true,
},
@ -84,8 +90,8 @@ Object {
}
`;
exports[`throws error for invalid levels: bar 1`] = `"Invalid log level \\"bar\\" (expected one of silent,error,warning,info,debug,verbose)"`;
exports[`throws error for invalid levels: bar 1`] = `"Invalid log level \\"bar\\" (expected one of silent,error,warning,success,info,debug,verbose)"`;
exports[`throws error for invalid levels: foo 1`] = `"Invalid log level \\"foo\\" (expected one of silent,error,warning,info,debug,verbose)"`;
exports[`throws error for invalid levels: foo 1`] = `"Invalid log level \\"foo\\" (expected one of silent,error,warning,success,info,debug,verbose)"`;
exports[`throws error for invalid levels: warn 1`] = `"Invalid log level \\"warn\\" (expected one of silent,error,warning,info,debug,verbose)"`;
exports[`throws error for invalid levels: warn 1`] = `"Invalid log level \\"warn\\" (expected one of silent,error,warning,success,info,debug,verbose)"`;

View file

@ -170,7 +170,7 @@ exports[`level:warning/type:warning snapshots: output 1`] = `
"
`;
exports[`throws error if created with invalid level 1`] = `"Invalid log level \\"foo\\" (expected one of silent,error,warning,info,debug,verbose)"`;
exports[`throws error if created with invalid level 1`] = `"Invalid log level \\"foo\\" (expected one of silent,error,warning,success,info,debug,verbose)"`;
exports[`throws error if writeTo config is not defined or doesn't have a write method 1`] = `"ToolingLogTextWriter requires the \`writeTo\` option be set to a stream (like process.stdout)"`;

View file

@ -17,8 +17,8 @@
* under the License.
*/
export type LogLevel = 'silent' | 'error' | 'warning' | 'info' | 'debug' | 'verbose';
const LEVELS: LogLevel[] = ['silent', 'error', 'warning', 'info', 'debug', 'verbose'];
const LEVELS = ['silent', 'error', 'warning', 'success', 'info', 'debug', 'verbose'] as const;
export type LogLevel = typeof LEVELS[number];
export function pickLevelFromFlags(
flags: Record<string, string | boolean | string[] | undefined>,

View file

@ -8814,7 +8814,7 @@ module.exports = (chalk, temporary) => {
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.parseLogLevel = exports.pickLevelFromFlags = void 0;
const LEVELS = ['silent', 'error', 'warning', 'info', 'debug', 'verbose'];
const LEVELS = ['silent', 'error', 'warning', 'success', 'info', 'debug', 'verbose'];
function pickLevelFromFlags(flags, options = {}) {
if (flags.verbose)
return 'verbose';

View file

@ -22,9 +22,7 @@ import { pkg } from '../core/server/utils';
import Command from './command';
import serveCommand from './serve/serve';
const argv = process.env.kbnWorkerArgv
? JSON.parse(process.env.kbnWorkerArgv)
: process.argv.slice();
const argv = process.argv.slice();
const program = new Command('bin/kibana');
program

View file

@ -1,70 +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.
*/
/* eslint-env jest */
// eslint-disable-next-line max-classes-per-file
import { EventEmitter } from 'events';
import { assign, random } from 'lodash';
import { delay } from 'bluebird';
class MockClusterFork extends EventEmitter {
public exitCode = 0;
constructor(cluster: MockCluster) {
super();
let dead = true;
function wait() {
return delay(random(10, 250));
}
assign(this, {
process: {
kill: jest.fn(() => {
(async () => {
await wait();
this.emit('disconnect');
await wait();
dead = true;
this.emit('exit');
cluster.emit('exit', this, this.exitCode || 0);
})();
}),
},
isDead: jest.fn(() => dead),
send: jest.fn(),
});
jest.spyOn(this as EventEmitter, 'on');
jest.spyOn(this as EventEmitter, 'off');
jest.spyOn(this as EventEmitter, 'emit');
(async () => {
await wait();
dead = false;
this.emit('online');
})();
}
}
export class MockCluster extends EventEmitter {
fork = jest.fn(() => new MockClusterFork(this));
setupMaster = jest.fn();
}

View file

@ -1,162 +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 * as Rx from 'rxjs';
import { mockCluster } from './cluster_manager.test.mocks';
jest.mock('readline', () => ({
createInterface: jest.fn(() => ({
on: jest.fn(),
prompt: jest.fn(),
setPrompt: jest.fn(),
})),
}));
const mockConfig: any = {};
import { sample } from 'lodash';
import { ClusterManager, SomeCliArgs } from './cluster_manager';
import { Worker } from './worker';
const CLI_ARGS: SomeCliArgs = {
disableOptimizer: true,
oss: false,
quiet: false,
repl: false,
runExamples: false,
silent: false,
watch: false,
cache: false,
dist: false,
};
describe('CLI cluster manager', () => {
beforeEach(() => {
mockCluster.fork.mockImplementation(() => {
return {
process: {
kill: jest.fn(),
},
isDead: jest.fn().mockReturnValue(false),
off: jest.fn(),
on: jest.fn(),
send: jest.fn(),
} as any;
});
});
afterEach(() => {
mockCluster.fork.mockReset();
});
test('has two workers', () => {
const manager = new ClusterManager(CLI_ARGS, mockConfig);
expect(manager.workers).toHaveLength(1);
for (const worker of manager.workers) {
expect(worker).toBeInstanceOf(Worker);
}
expect(manager.server).toBeInstanceOf(Worker);
});
test('delivers broadcast messages to other workers', () => {
const manager = new ClusterManager(CLI_ARGS, mockConfig);
for (const worker of manager.workers) {
Worker.prototype.start.call(worker); // bypass the debounced start method
worker.onOnline();
}
const football = {};
const messenger = sample(manager.workers) as any;
messenger.emit('broadcast', football);
for (const worker of manager.workers) {
if (worker === messenger) {
expect(worker.fork!.send).not.toHaveBeenCalled();
} else {
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() };
new ClusterManager(CLI_ARGS, mockConfig, basePathProxyMock as any);
expect(basePathProxyMock.start).toHaveBeenCalledWith({
shouldRedirectFromOldBasePath: expect.any(Function),
delayUntil: expect.any(Function),
});
});
describe('basePathProxy config', () => {
let clusterManager: ClusterManager;
let shouldRedirectFromOldBasePath: (path: string) => boolean;
let delayUntil: () => Rx.Observable<undefined>;
beforeEach(async () => {
const basePathProxyMock = { start: jest.fn() };
clusterManager = new ClusterManager(CLI_ARGS, mockConfig, basePathProxyMock as any);
[[{ delayUntil, shouldRedirectFromOldBasePath }]] = basePathProxyMock.start.mock.calls;
});
describe('shouldRedirectFromOldBasePath()', () => {
test('returns `false` for unknown paths.', () => {
expect(shouldRedirectFromOldBasePath('')).toBe(false);
expect(shouldRedirectFromOldBasePath('some-path/')).toBe(false);
expect(shouldRedirectFromOldBasePath('some-other-path')).toBe(false);
});
test('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);
});
});
describe('delayUntil()', () => {
test('returns an observable which emits when the server and kbnOptimizer are ready and completes', async () => {
clusterManager.serverReady$.next(false);
clusterManager.kbnOptimizerReady$.next(false);
const events: Array<string | Error> = [];
delayUntil().subscribe(
() => events.push('next'),
(error) => events.push(error),
() => events.push('complete')
);
clusterManager.serverReady$.next(true);
expect(events).toEqual([]);
clusterManager.kbnOptimizerReady$.next(true);
expect(events).toEqual(['next', 'complete']);
});
});
});
});
});

View file

@ -1,335 +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 Fs from 'fs';
import { REPO_ROOT } from '@kbn/utils';
import { FSWatcher } from 'chokidar';
import * as Rx from 'rxjs';
import { startWith, mapTo, filter, map, take, tap } from 'rxjs/operators';
import { runKbnOptimizer } from './run_kbn_optimizer';
import { CliArgs } from '../../core/server/config';
import { LegacyConfig } from '../../core/server/legacy';
import { BasePathProxyServer } from '../../core/server/http';
import { Log } from './log';
import { Worker } from './worker';
export type SomeCliArgs = Pick<
CliArgs,
| 'quiet'
| 'silent'
| 'repl'
| 'disableOptimizer'
| 'watch'
| 'oss'
| 'runExamples'
| 'cache'
| 'dist'
>;
const firstAllTrue = (...sources: Array<Rx.Observable<boolean>>) =>
Rx.combineLatest(sources).pipe(
filter((values) => values.every((v) => v === true)),
take(1),
mapTo(undefined)
);
export class ClusterManager {
public server: Worker;
public workers: Worker[];
private watcher: FSWatcher | null = null;
private basePathProxy: BasePathProxyServer | undefined;
private log: Log;
private addedCount = 0;
private inReplMode: boolean;
// exposed for testing
public readonly serverReady$ = new Rx.ReplaySubject<boolean>(1);
// exposed for testing
public readonly kbnOptimizerReady$ = new Rx.ReplaySubject<boolean>(1);
constructor(opts: SomeCliArgs, config: LegacyConfig, basePathProxy?: BasePathProxyServer) {
this.log = new Log(opts.quiet, opts.silent);
this.inReplMode = !!opts.repl;
this.basePathProxy = basePathProxy;
if (!this.basePathProxy) {
this.log.warn(
'===================================================================================================='
);
this.log.warn(
'no-base-path',
'Running Kibana in dev mode with --no-base-path disables several useful features and is not recommended'
);
this.log.warn(
'===================================================================================================='
);
}
// run @kbn/optimizer and write it's state to kbnOptimizerReady$
if (opts.disableOptimizer) {
this.kbnOptimizerReady$.next(true);
} else {
runKbnOptimizer(opts, config)
.pipe(
map(({ state }) => state.phase === 'success' || state.phase === 'issue'),
tap({
error: (error) => {
this.log.bad('@kbn/optimizer error', error.stack);
process.exit(1);
},
})
)
.subscribe(this.kbnOptimizerReady$);
}
const serverArgv = [];
if (this.basePathProxy) {
serverArgv.push(
`--server.port=${this.basePathProxy.targetPort}`,
`--server.basePath=${this.basePathProxy.basePath}`,
'--server.rewriteBasePath=true'
);
}
this.workers = [
(this.server = new Worker({
type: 'server',
log: this.log,
argv: serverArgv,
apmServiceName: 'kibana',
})),
];
// write server status to the serverReady$ subject
Rx.merge(
Rx.fromEvent(this.server, 'starting').pipe(mapTo(false)),
Rx.fromEvent(this.server, 'listening').pipe(mapTo(true)),
Rx.fromEvent(this.server, 'crashed').pipe(mapTo(true))
)
.pipe(startWith(this.server.listening || this.server.crashed))
.subscribe(this.serverReady$);
// broker messages between workers
this.workers.forEach((worker) => {
worker.on('broadcast', (msg) => {
this.workers.forEach((to) => {
if (to !== worker && to.online) {
to.fork!.send(msg);
}
});
});
});
// When receive that event from server worker
// forward a reloadLoggingConfig message to master
// and all workers. This is only used by LogRotator service
// when the cluster mode is enabled
this.server.on('reloadLoggingConfigFromServerWorker', () => {
process.emit('message' as any, { reloadLoggingConfig: true } as any);
this.workers.forEach((worker) => {
worker.fork!.send({ reloadLoggingConfig: true });
});
});
if (opts.watch) {
const pluginPaths = config.get<string[]>('plugins.paths');
const scanDirs = [
...config.get<string[]>('plugins.scanDirs'),
resolve(REPO_ROOT, 'src/plugins'),
resolve(REPO_ROOT, 'x-pack/plugins'),
];
const extraPaths = [...pluginPaths, ...scanDirs];
const pluginInternalDirsIgnore = scanDirs
.map((scanDir) => resolve(scanDir, '*'))
.concat(pluginPaths)
.reduce(
(acc, path) =>
acc.concat(
resolve(path, 'test/**'),
resolve(path, 'build/**'),
resolve(path, 'target/**'),
resolve(path, 'scripts/**'),
resolve(path, 'docs/**')
),
[] as string[]
);
this.setupWatching(extraPaths, pluginInternalDirsIgnore);
} else this.startCluster();
}
startCluster() {
this.setupManualRestart();
for (const worker of this.workers) {
worker.start();
}
if (this.basePathProxy) {
this.basePathProxy.start({
delayUntil: () => firstAllTrue(this.serverReady$, this.kbnOptimizerReady$),
shouldRedirectFromOldBasePath: (path: string) => {
// strip `s/{id}` prefix when checking for need to redirect
if (path.startsWith('s/')) {
path = path.split('/').slice(2).join('/');
}
const isApp = path.startsWith('app/');
const isKnownShortPath = ['login', 'logout', 'status'].includes(path);
return isApp || isKnownShortPath;
},
});
}
}
setupWatching(extraPaths: string[], pluginInternalDirsIgnore: string[]) {
// eslint-disable-next-line @typescript-eslint/no-var-requires
const chokidar = require('chokidar');
// eslint-disable-next-line @typescript-eslint/no-var-requires
const { fromRoot } = require('../../core/server/utils');
const watchPaths = Array.from(
new Set(
[
fromRoot('src/core'),
fromRoot('src/legacy/server'),
fromRoot('src/legacy/ui'),
fromRoot('src/legacy/utils'),
fromRoot('config'),
...extraPaths,
].map((path) => resolve(path))
)
);
for (const watchPath of watchPaths) {
if (!Fs.existsSync(fromRoot(watchPath))) {
throw new Error(
`A watch directory [${watchPath}] does not exist, which will cause chokidar to fail. Either make sure the directory exists or remove it as a watch source in the ClusterManger`
);
}
}
const ignorePaths = [
/[\\\/](\..*|node_modules|bower_components|target|public|__[a-z0-9_]+__|coverage)([\\\/]|$)/,
/\.test\.(js|tsx?)$/,
/\.md$/,
/debug\.log$/,
...pluginInternalDirsIgnore,
fromRoot('x-pack/plugins/reporting/chromium'),
fromRoot('x-pack/plugins/security_solution/cypress'),
fromRoot('x-pack/plugins/apm/e2e'),
fromRoot('x-pack/plugins/apm/scripts'),
fromRoot('x-pack/plugins/canvas/canvas_plugin_src'), // prevents server from restarting twice for Canvas plugin changes,
fromRoot('x-pack/plugins/case/server/scripts'),
fromRoot('x-pack/plugins/lists/scripts'),
fromRoot('x-pack/plugins/lists/server/scripts'),
fromRoot('x-pack/plugins/security_solution/scripts'),
fromRoot('x-pack/plugins/security_solution/server/lib/detection_engine/scripts'),
];
this.watcher = chokidar.watch(watchPaths, {
cwd: fromRoot('.'),
ignored: ignorePaths,
}) as FSWatcher;
this.watcher.on('add', this.onWatcherAdd);
this.watcher.on('error', this.onWatcherError);
this.watcher.once('ready', () => {
// start sending changes to workers
this.watcher!.removeListener('add', this.onWatcherAdd);
this.watcher!.on('all', this.onWatcherChange);
this.log.good('watching for changes', `(${this.addedCount} files)`);
this.startCluster();
});
}
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;
}
// eslint-disable-next-line @typescript-eslint/no-var-requires
const readline = require('readline');
const rl = readline.createInterface(process.stdin, process.stdout);
let nls = 0;
const clear = () => (nls = 0);
let clearTimer: number | undefined;
const clearSoon = () => {
clearSoon.cancel();
clearTimer = setTimeout(() => {
clearTimer = undefined;
clear();
});
};
clearSoon.cancel = () => {
clearTimeout(clearTimer);
clearTimer = undefined;
};
rl.setPrompt('');
rl.prompt();
rl.on('line', () => {
nls = nls + 1;
if (nls >= 2) {
clearSoon.cancel();
clear();
this.server.start();
} else {
clearSoon();
}
rl.prompt();
});
rl.on('SIGINT', () => {
rl.pause();
process.kill(process.pid, 'SIGINT');
});
}
onWatcherAdd = () => {
this.addedCount += 1;
};
onWatcherChange = (e: any, path: string) => {
for (const worker of this.workers) {
worker.onChange(path);
}
};
onWatcherError = (err: any) => {
this.log.bad('failed to watch files!\n', err.stack);
process.exit(1);
};
}

View file

@ -1,85 +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 Chalk from 'chalk';
import moment from 'moment';
import { REPO_ROOT } from '@kbn/utils';
import {
ToolingLog,
pickLevelFromFlags,
ToolingLogTextWriter,
parseLogLevel,
} from '@kbn/dev-utils';
import { runOptimizer, OptimizerConfig, logOptimizerState } from '@kbn/optimizer';
import { CliArgs } from '../../core/server/config';
import { LegacyConfig } from '../../core/server/legacy';
type SomeCliArgs = Pick<CliArgs, 'watch' | 'cache' | 'dist' | 'oss' | 'runExamples'>;
export function runKbnOptimizer(opts: SomeCliArgs, config: LegacyConfig) {
const optimizerConfig = OptimizerConfig.create({
repoRoot: REPO_ROOT,
watch: !!opts.watch,
includeCoreBundle: true,
cache: !!opts.cache,
dist: !!opts.dist,
oss: !!opts.oss,
examples: !!opts.runExamples,
pluginPaths: config.get('plugins.paths'),
});
const dim = Chalk.dim('np bld');
const name = Chalk.magentaBright('@kbn/optimizer');
const time = () => moment().format('HH:mm:ss.SSS');
const level = (msgType: string) => {
switch (msgType) {
case 'info':
return Chalk.green(msgType);
case 'success':
return Chalk.cyan(msgType);
case 'debug':
return Chalk.gray(msgType);
default:
return msgType;
}
};
const { flags: levelFlags } = parseLogLevel(pickLevelFromFlags(opts));
const toolingLog = new ToolingLog();
const has = <T extends object>(obj: T, x: any): x is keyof T => obj.hasOwnProperty(x);
toolingLog.setWriters([
{
write(msg) {
if (has(levelFlags, msg.type) && !levelFlags[msg.type]) {
return false;
}
ToolingLogTextWriter.write(
process.stdout,
`${dim} log [${time()}] [${level(msg.type)}][${name}] `,
msg
);
return true;
},
},
]);
return runOptimizer(optimizerConfig).pipe(logOptimizerState(toolingLog, optimizerConfig));
}

View file

@ -1,219 +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 { mockCluster } from './cluster_manager.test.mocks';
import { Worker, ClusterWorker } from './worker';
import { Log } from './log';
const workersToShutdown: Worker[] = [];
function assertListenerAdded(emitter: NodeJS.EventEmitter, event: any) {
expect(emitter.on).toHaveBeenCalledWith(event, expect.any(Function));
}
function assertListenerRemoved(emitter: NodeJS.EventEmitter, event: any) {
const [, onEventListener] = (emitter.on as jest.Mock).mock.calls.find(([eventName]) => {
return eventName === event;
});
expect(emitter.off).toHaveBeenCalledWith(event, onEventListener);
}
function setup(opts = {}) {
const worker = new Worker({
log: new Log(false, true),
...opts,
baseArgv: [],
type: 'test',
});
workersToShutdown.push(worker);
return worker;
}
describe('CLI cluster manager', () => {
afterEach(async () => {
while (workersToShutdown.length > 0) {
const worker = workersToShutdown.pop() as Worker;
// If `fork` exists we should set `exitCode` to the non-zero value to
// prevent worker from auto restart.
if (worker.fork) {
worker.fork.exitCode = 1;
}
await worker.shutdown();
}
mockCluster.fork.mockClear();
});
describe('#onChange', () => {
describe('opts.watch = true', () => {
test('restarts the fork', () => {
const worker = setup({ watch: true });
jest.spyOn(worker, 'start').mockResolvedValue();
worker.onChange('/some/path');
expect(worker.changes).toEqual(['/some/path']);
expect(worker.start).toHaveBeenCalledTimes(1);
});
});
describe('opts.watch = false', () => {
test('does not restart the fork', () => {
const worker = setup({ watch: false });
jest.spyOn(worker, 'start').mockResolvedValue();
worker.onChange('/some/path');
expect(worker.changes).toEqual([]);
expect(worker.start).not.toHaveBeenCalled();
});
});
});
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 as ClusterWorker;
expect(fork!.process.kill).not.toHaveBeenCalled();
assertListenerAdded(fork, 'message');
assertListenerAdded(fork, 'online');
assertListenerAdded(fork, 'disconnect');
await worker.shutdown();
expect(fork!.process.kill).toHaveBeenCalledTimes(1);
assertListenerRemoved(fork, 'message');
assertListenerRemoved(fork, 'online');
assertListenerRemoved(fork, 'disconnect');
});
});
describe('before being started', () => {
test('does nothing', () => {
const worker = setup();
worker.shutdown();
});
});
});
describe('#parseIncomingMessage()', () => {
describe('on a started worker', () => {
test(`is bound to fork's message event`, async () => {
const worker = setup();
await worker.start();
expect(worker.fork!.on).toHaveBeenCalledWith('message', expect.any(Function));
});
});
describe('do after', () => {
test('ignores non-array messages', () => {
const worker = setup();
worker.parseIncomingMessage('some string thing');
worker.parseIncomingMessage(0);
worker.parseIncomingMessage(null);
worker.parseIncomingMessage(undefined);
worker.parseIncomingMessage({ like: 'an object' });
worker.parseIncomingMessage(/weird/);
});
test('calls #onMessage with message parts', () => {
const worker = setup();
jest.spyOn(worker, 'onMessage').mockImplementation(() => {});
worker.parseIncomingMessage(['event', 'some-data']);
expect(worker.onMessage).toHaveBeenCalledWith('event', 'some-data');
});
});
});
describe('#onMessage', () => {
describe('when sent WORKER_BROADCAST message', () => {
test('emits the data to be broadcasted', () => {
const worker = setup();
const data = {};
jest.spyOn(worker, 'emit').mockImplementation(() => true);
worker.onMessage('WORKER_BROADCAST', data);
expect(worker.emit).toHaveBeenCalledWith('broadcast', data);
});
});
describe('when sent WORKER_LISTENING message', () => {
test('sets the listening flag and emits the listening event', () => {
const worker = setup();
jest.spyOn(worker, 'emit').mockImplementation(() => true);
expect(worker).toHaveProperty('listening', false);
worker.onMessage('WORKER_LISTENING');
expect(worker).toHaveProperty('listening', true);
expect(worker.emit).toHaveBeenCalledWith('listening');
});
});
describe('when passed an unknown message', () => {
test('does nothing', () => {
const worker = setup();
worker.onMessage('asdlfkajsdfahsdfiohuasdofihsdoif');
});
});
});
describe('#start', () => {
describe('when not started', () => {
test('creates a fork and waits for it to come online', async () => {
const worker = setup();
jest.spyOn(worker, 'on');
await worker.start();
expect(mockCluster.fork).toHaveBeenCalledTimes(1);
expect(worker.on).toHaveBeenCalledWith('fork:online', expect.any(Function));
});
test('listens for cluster and process "exit" events', async () => {
const worker = setup();
jest.spyOn(process, 'on');
jest.spyOn(mockCluster, 'on');
await worker.start();
expect(mockCluster.on).toHaveBeenCalledTimes(1);
expect(mockCluster.on).toHaveBeenCalledWith('exit', expect.any(Function));
expect(process.on).toHaveBeenCalledTimes(1);
expect(process.on).toHaveBeenCalledWith('exit', expect.any(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();
jest.spyOn(worker, 'shutdown');
jest.spyOn(worker, 'on');
worker.start();
expect(worker.shutdown).toHaveBeenCalledTimes(1);
expect(worker.on).toHaveBeenCalledWith('online', expect.any(Function));
});
});
});
});

View file

@ -1,219 +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 _ from 'lodash';
import cluster from 'cluster';
import { EventEmitter } from 'events';
import { BinderFor } from './binder_for';
import { fromRoot } from '../../core/server/utils';
const cliPath = fromRoot('src/cli/dev');
const baseArgs = _.difference(process.argv.slice(2), ['--no-watch']);
const baseArgv = [process.execPath, cliPath].concat(baseArgs);
export type ClusterWorker = cluster.Worker & {
killed: boolean;
exitCode?: number;
};
cluster.setupMaster({
exec: cliPath,
silent: false,
});
const dead = (fork: ClusterWorker) => {
return fork.isDead() || fork.killed;
};
interface WorkerOptions {
type: string;
log: any; // src/cli/log.js
argv?: string[];
title?: string;
watch?: boolean;
baseArgv?: string[];
apmServiceName?: string;
}
export class Worker extends EventEmitter {
private readonly clusterBinder: BinderFor;
private readonly processBinder: BinderFor;
private title: string;
private log: any;
private forkBinder: BinderFor | null = null;
private startCount: number;
private watch: boolean;
private env: Record<string, string>;
public fork: ClusterWorker | null = null;
public changes: string[];
// status flags
public online = false; // the fork can accept messages
public listening = false; // the fork is listening for connections
public crashed = false; // the fork crashed
constructor(opts: WorkerOptions) {
super();
this.log = opts.log;
this.title = opts.title || opts.type;
this.watch = opts.watch !== false;
this.startCount = 0;
this.changes = [];
this.clusterBinder = new BinderFor(cluster as any); // lack the 'off' method
this.processBinder = new BinderFor(process);
this.env = {
NODE_OPTIONS: process.env.NODE_OPTIONS || '',
isDevCliChild: 'true',
kbnWorkerArgv: JSON.stringify([...(opts.baseArgv || baseArgv), ...(opts.argv || [])]),
ELASTIC_APM_SERVICE_NAME: opts.apmServiceName || '',
};
}
onExit(fork: ClusterWorker, code: number) {
if (this.fork !== fork) return;
// we have our fork's exit, so stop listening for others
this.clusterBinder.destroy();
// our fork is gone, clear our ref so we don't try to talk to it anymore
this.fork = null;
this.forkBinder = null;
this.online = false;
this.listening = false;
this.emit('fork:exit');
this.crashed = code > 0;
if (this.crashed) {
this.emit('crashed');
this.log.bad(`${this.title} crashed`, 'with status code', code);
if (!this.watch) process.exit(code);
} else {
// restart after graceful shutdowns
this.start();
}
}
onChange(path: string) {
if (!this.watch) return;
this.changes.push(path);
this.start();
}
async shutdown() {
if (this.fork && !dead(this.fork)) {
// kill the fork
this.fork.process.kill();
this.fork.killed = true;
// stop listening to the fork, it's just going to die
this.forkBinder!.destroy();
// we don't need to react to process.exit anymore
this.processBinder.destroy();
// wait until the cluster reports this fork has exited, then resolve
await new Promise((resolve) => this.once('fork:exit', resolve));
}
}
parseIncomingMessage(msg: any) {
if (!Array.isArray(msg)) {
return;
}
this.onMessage(msg[0], msg[1]);
}
onMessage(type: string, data?: any) {
switch (type) {
case 'WORKER_BROADCAST':
this.emit('broadcast', data);
break;
case 'OPTIMIZE_STATUS':
this.emit('optimizeStatus', data);
break;
case 'WORKER_LISTENING':
this.listening = true;
this.emit('listening');
break;
case 'RELOAD_LOGGING_CONFIG_FROM_SERVER_WORKER':
this.emit('reloadLoggingConfigFromServerWorker');
break;
}
}
onOnline() {
this.online = true;
this.emit('fork:online');
this.crashed = false;
}
onDisconnect() {
this.online = false;
this.listening = false;
}
flushChangeBuffer() {
const files = _.uniq(this.changes.splice(0));
const prefix = files.length > 1 ? '\n - ' : '';
return files.reduce(function (list, file) {
return `${list || ''}${prefix}"${file}"`;
}, '');
}
async start() {
if (this.fork) {
// once "exit" event is received with 0 status, start() is called again
this.shutdown();
await new Promise((cb) => this.once('online', cb));
return;
}
if (this.changes.length) {
this.log.warn(`restarting ${this.title}`, `due to changes in ${this.flushChangeBuffer()}`);
} else if (this.startCount++) {
this.log.warn(`restarting ${this.title}...`);
}
this.fork = cluster.fork(this.env) as ClusterWorker;
this.emit('starting');
this.forkBinder = new BinderFor(this.fork);
// when the fork sends a message, comes online, or loses its connection, then react
this.forkBinder.on('message', (msg: any) => this.parseIncomingMessage(msg));
this.forkBinder.on('online', () => this.onOnline());
this.forkBinder.on('disconnect', () => this.onDisconnect());
// when the cluster says a fork has exited, check if it is ours
this.clusterBinder.on('exit', (fork: ClusterWorker, code: number) => this.onExit(fork, code));
// when the process exits, make sure we kill our workers
this.processBinder.on('exit', () => this.shutdown());
// wait for the fork to report it is online before resolving
await new Promise((cb) => this.once('fork:online', cb));
}
}

View file

@ -1,113 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`repl it allows print depth to be specified 1`] = `
"<ref *1> {
'0': { '1': { '2': [Object] } },
whoops: [Circular *1]
}"
`;
exports[`repl it colorizes raw values 1`] = `"{ meaning: 42 }"`;
exports[`repl it handles deep and recursive objects 1`] = `
"<ref *1> {
'0': {
'1': {
'2': { '3': { '4': { '5': [Object] } } }
}
},
whoops: [Circular *1]
}"
`;
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:
",
"<ref *1> {
'0': {
'1': {
'2': { '3': { '4': { '5': [Object] } } }
}
},
whoops: [Circular *1]
}",
],
]
`;
exports[`repl promises resolves only write to a specific depth 1`] = `
Array [
Array [
"Waiting for promise...",
],
Array [
"Promise Resolved:
",
"<ref *1> {
'0': {
'1': {
'2': { '3': { '4': { '5': [Object] } } }
}
},
whoops: [Circular *1]
}",
],
]
`;
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] } }",
],
]
`;

View file

@ -1,92 +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 repl from 'repl';
import util from 'util';
const PRINT_DEPTH = 5;
/**
* 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({
displayPrompt: () => replServer.displayPrompt(),
getPrintDepth: () => replServer.context.repl.printDepth,
}),
});
const initializeContext = () => {
replServer.context.kbnServer = kbnServer;
replServer.context.server = kbnServer.server;
replServer.context.repl = {
printDepth: PRINT_DEPTH,
print(obj, depth = null) {
console.log(
promisePrint(
obj,
() => replServer.displayPrompt(),
() => depth
)
);
return '';
},
};
};
initializeContext();
replServer.on('reset', initializeContext);
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, getPrintDepth }) {
return (result) => promisePrint(result, displayPrompt, getPrintDepth);
}
function promisePrint(result, displayPrompt, getPrintDepth) {
const depth = getPrintDepth();
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);
}

View file

@ -1,199 +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('repl', () => ({ start: (opts) => ({ opts, context: {} }) }), { virtual: true });
describe('repl', () => {
const originalConsoleLog = console.log;
let mockRepl;
beforeEach(() => {
global.console.log = jest.fn();
require('repl').start = (opts) => {
let resetHandler;
const replServer = {
opts,
context: {},
on: jest.fn((eventName, handler) => {
expect(eventName).toBe('reset');
resetHandler = handler;
}),
};
mockRepl = {
replServer,
clear() {
replServer.context = {};
resetHandler(replServer.context);
},
};
return replServer;
};
});
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 allows print depth to be specified', () => {
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;
replServer.context.repl.printDepth = 2;
expect(replServer.opts.writer(splosion)).toMatchSnapshot();
});
test('resets context to original when reset', () => {
const { startRepl } = require('.');
const testServer = {
server: {},
};
const replServer = startRepl(testServer);
replServer.context.foo = 'bar';
expect(replServer.context.server).toBe(testServer.server);
expect(replServer.context.kbnServer).toBe(testServer);
expect(replServer.context.foo).toBe('bar');
mockRepl.clear();
expect(replServer.context.server).toBe(testServer.server);
expect(replServer.context.kbnServer).toBe(testServer);
expect(replServer.context.foo).toBeUndefined();
});
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;
}
});

View file

@ -42,11 +42,8 @@ function canRequire(path) {
}
}
const CLUSTER_MANAGER_PATH = resolve(__dirname, '../cluster/cluster_manager');
const DEV_MODE_SUPPORTED = canRequire(CLUSTER_MANAGER_PATH);
const REPL_PATH = resolve(__dirname, '../repl');
const CAN_REPL = canRequire(REPL_PATH);
const DEV_MODE_PATH = resolve(__dirname, '../../dev/cli_dev_mode');
const DEV_MODE_SUPPORTED = canRequire(DEV_MODE_PATH);
const pathCollector = function () {
const paths = [];
@ -176,10 +173,6 @@ export default function (program) {
.option('--plugins <path>', 'an alias for --plugin-dir', pluginDirCollector)
.option('--optimize', 'Deprecated, running the optimizer is no longer required');
if (CAN_REPL) {
command.option('--repl', 'Run the server with a REPL prompt and access to the server object');
}
if (!IS_KIBANA_DISTRIBUTABLE) {
command
.option('--oss', 'Start Kibana without X-Pack')
@ -225,7 +218,6 @@ export default function (program) {
quiet: !!opts.quiet,
silent: !!opts.silent,
watch: !!opts.watch,
repl: !!opts.repl,
runExamples: !!opts.runExamples,
// We want to run without base path when the `--run-examples` flag is given so that we can use local
// links in other documentation sources, like "View this tutorial [here](http://localhost:5601/app/tutorial/xyz)".
@ -241,7 +233,6 @@ export default function (program) {
},
features: {
isCliDevModeSupported: DEV_MODE_SUPPORTED,
isReplModeSupported: CAN_REPL,
},
applyConfigOverrides: (rawConfig) => applyConfigOverrides(rawConfig, opts, unknownOptions),
});

View file

@ -23,9 +23,7 @@ import { EncryptionConfig } from './encryption_config';
import { generateCli } from './generate';
const argv = process.env.kbnWorkerArgv
? JSON.parse(process.env.kbnWorkerArgv)
: process.argv.slice();
const argv = process.argv.slice();
const program = new Command('bin/kibana-encryption-keys');
program.version(pkg.version).description('A tool for managing encryption keys');

View file

@ -29,9 +29,7 @@ import { addCli } from './add';
import { removeCli } from './remove';
import { getKeystore } from './get_keystore';
const argv = process.env.kbnWorkerArgv
? JSON.parse(process.env.kbnWorkerArgv)
: process.argv.slice();
const argv = process.argv.slice();
const program = new Command('bin/kibana-keystore');
program

View file

@ -23,9 +23,7 @@ import { listCommand } from './list';
import { installCommand } from './install';
import { removeCommand } from './remove';
const argv = process.env.kbnWorkerArgv
? JSON.parse(process.env.kbnWorkerArgv)
: process.argv.slice();
const argv = process.argv.slice();
const program = new Command('bin/kibana-plugin');
program

View file

@ -27,9 +27,6 @@ interface KibanaFeatures {
// a child process together with optimizer "worker" processes that are
// orchestrated by a parent process (dev mode only feature).
isCliDevModeSupported: boolean;
// Indicates whether we can run Kibana in REPL mode (dev mode only feature).
isReplModeSupported: boolean;
}
interface BootstrapArgs {
@ -50,10 +47,6 @@ export async function bootstrap({
applyConfigOverrides,
features,
}: BootstrapArgs) {
if (cliArgs.repl && !features.isReplModeSupported) {
onRootShutdown('Kibana REPL mode can only be run in development mode.');
}
if (cliArgs.optimize) {
// --optimize is deprecated and does nothing now, avoid starting up and just shutdown
return;

View file

@ -52,6 +52,14 @@ export class BasePathProxyServer {
return this.devConfig.basePathProxyTargetPort;
}
public get host() {
return this.httpConfig.host;
}
public get port() {
return this.httpConfig.port;
}
constructor(
private readonly log: Logger,
private readonly httpConfig: HttpConfig,
@ -92,7 +100,10 @@ export class BasePathProxyServer {
await this.server.start();
this.log.info(
`basepath proxy server running at ${this.server.info.uri}${this.httpConfig.basePath}`
`basepath proxy server running at ${Url.format({
host: this.server.info.uri,
pathname: this.httpConfig.basePath,
})}`
);
}

View file

@ -17,4 +17,4 @@
* under the License.
*/
export { ClusterManager } from '../../../cli/cluster/cluster_manager';
export { CliDevMode } from '../../../dev/cli_dev_mode';

View file

@ -18,13 +18,13 @@
*/
jest.mock('../../../legacy/server/kbn_server');
jest.mock('./cluster_manager');
jest.mock('./cli_dev_mode');
import { BehaviorSubject, throwError } from 'rxjs';
import { REPO_ROOT } from '@kbn/dev-utils';
// @ts-expect-error js file to remove TS dependency on cli
import { ClusterManager as MockClusterManager } from './cluster_manager';
import { CliDevMode as MockCliDevMode } from './cli_dev_mode';
import KbnServer from '../../../legacy/server/kbn_server';
import { Config, Env, ObjectToConfigAdapter } from '../config';
import { BasePathProxyServer } from '../http';
@ -239,7 +239,7 @@ describe('once LegacyService is set up with connection info', () => {
);
expect(MockKbnServer).not.toHaveBeenCalled();
expect(MockClusterManager).not.toHaveBeenCalled();
expect(MockCliDevMode).not.toHaveBeenCalled();
});
test('reconfigures logging configuration if new config is received.', async () => {
@ -355,7 +355,7 @@ describe('once LegacyService is set up in `devClusterMaster` mode', () => {
});
});
test('creates ClusterManager without base path proxy.', async () => {
test('creates CliDevMode without base path proxy.', async () => {
const devClusterLegacyService = new LegacyService({
coreId,
env: Env.createDefault(
@ -373,8 +373,8 @@ describe('once LegacyService is set up in `devClusterMaster` mode', () => {
await devClusterLegacyService.setup(setupDeps);
await devClusterLegacyService.start(startDeps);
expect(MockClusterManager).toHaveBeenCalledTimes(1);
expect(MockClusterManager).toHaveBeenCalledWith(
expect(MockCliDevMode.fromCoreServices).toHaveBeenCalledTimes(1);
expect(MockCliDevMode.fromCoreServices).toHaveBeenCalledWith(
expect.objectContaining({ silent: true, basePath: false }),
expect.objectContaining({
get: expect.any(Function),
@ -384,7 +384,7 @@ describe('once LegacyService is set up in `devClusterMaster` mode', () => {
);
});
test('creates ClusterManager with base path proxy.', async () => {
test('creates CliDevMode with base path proxy.', async () => {
const devClusterLegacyService = new LegacyService({
coreId,
env: Env.createDefault(
@ -402,8 +402,8 @@ describe('once LegacyService is set up in `devClusterMaster` mode', () => {
await devClusterLegacyService.setup(setupDeps);
await devClusterLegacyService.start(startDeps);
expect(MockClusterManager).toHaveBeenCalledTimes(1);
expect(MockClusterManager).toHaveBeenCalledWith(
expect(MockCliDevMode.fromCoreServices).toHaveBeenCalledTimes(1);
expect(MockCliDevMode.fromCoreServices).toHaveBeenCalledWith(
expect.objectContaining({ quiet: true, basePath: true }),
expect.objectContaining({
get: expect.any(Function),

View file

@ -145,7 +145,7 @@ export class LegacyService implements CoreService {
// Receive initial config and create kbnServer/ClusterManager.
if (this.coreContext.env.isDevCliParent) {
await this.createClusterManager(this.legacyRawConfig!);
await this.setupCliDevMode(this.legacyRawConfig!);
} else {
this.kbnServer = await this.createKbnServer(
this.settings!,
@ -170,7 +170,7 @@ export class LegacyService implements CoreService {
}
}
private async createClusterManager(config: LegacyConfig) {
private async setupCliDevMode(config: LegacyConfig) {
const basePathProxy$ = this.coreContext.env.cliArgs.basePath
? combineLatest([this.devConfig$, this.httpConfig$]).pipe(
first(),
@ -182,8 +182,8 @@ export class LegacyService implements CoreService {
: EMPTY;
// eslint-disable-next-line @typescript-eslint/no-var-requires
const { ClusterManager } = require('./cluster_manager');
return new ClusterManager(
const { CliDevMode } = require('./cli_dev_mode');
CliDevMode.fromCoreServices(
this.coreContext.env.cliArgs,
config,
await basePathProxy$.toPromise()
@ -310,12 +310,6 @@ export class LegacyService implements CoreService {
logger: this.coreContext.logger,
});
// Prevent the repl from being started multiple times in different processes.
if (this.coreContext.env.cliArgs.repl && process.env.isDevCliChild) {
// eslint-disable-next-line @typescript-eslint/no-var-requires
require('./cli').startRepl(kbnServer);
}
const { autoListen } = await this.httpConfig$.pipe(first()).toPromise();
if (autoListen) {

View file

@ -73,7 +73,6 @@ export function createRootWithSettings(
quiet: false,
silent: false,
watch: false,
repl: false,
basePath: false,
runExamples: false,
oss: true,

View file

@ -33,7 +33,6 @@ export const CopySource: Task = {
'!src/**/{target,__tests__,__snapshots__,__mocks__}/**',
'!src/test_utils/**',
'!src/fixtures/**',
'!src/cli/cluster/**',
'!src/cli/repl/**',
'!src/cli/dev.js',
'!src/functional_test_runner/**',

View file

@ -0,0 +1,33 @@
# `CliDevMode`
A class that manages the alternate behavior of the Kibana cli when using the `--dev` flag. This mode provides several useful features in a single CLI for a nice developer experience:
- automatic server restarts when code changes
- runs the `@kbn/optimizer` to build browser bundles
- runs a base path proxy which helps developers test that they are writing code which is compatible with custom basePath settings while they work
- pauses requests when the server or optimizer are not ready to handle requests so that when users load Kibana in the browser it's always using the code as it exists on disk
To accomplish this, and to make it easier to test, the `CliDevMode` class manages several objects:
## `Watcher`
The `Watcher` manages a [chokidar](https://github.com/paulmillr/chokidar) instance to watch the server files, logs about file changes observed and provides an observable to the `DevServer` via its `serverShouldRestart$()` method.
## `DevServer`
The `DevServer` object is responsible for everything related to running and restarting the Kibana server process:
- listens to restart notifications from the `Watcher` object, sending `SIGKILL` to the existing server and launching a new instance with the current code
- writes the stdout/stderr logs from the Kibana server to the parent process
- gracefully kills the process if the SIGINT signal is sent
- kills the server if the SIGTERM signal is sent, process.exit() is used, a second SIGINT is sent, or the gracefull shutdown times out
- proxies SIGHUP notifications to the child process, though the core team is working on migrating this functionality to the KP and making this unnecessary
## `Optimizer`
The `Optimizer` object manages a `@kbn/optimizer` instance, adapting its configuration and logging to the data available to the CLI.
## `BasePathProxyServer` (currently passed from core)
The `BasePathProxyServer` is passed to the `CliDevMode` from core when the dev mode is trigged by the `--dev` flag. This proxy injects a random three character base path in the URL that Kibana is served from to help ensure that Kibana features are written to adapt to custom base path configurations from users.
The basePathProxy also has another important job, ensuring that requests don't fail because the server is restarting and that the browser receives front-end assets containing all saved changes. We accomplish this by observing the ready state of the `Optimizer` and `DevServer` objects and pausing all requests through the proxy until both objects report that they aren't building/restarting based on recently saved changes.

View file

@ -0,0 +1,404 @@
/*
* 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 Path from 'path';
import {
REPO_ROOT,
createAbsolutePathSerializer,
createAnyInstanceSerializer,
} from '@kbn/dev-utils';
import * as Rx from 'rxjs';
import { TestLog } from './log';
import { CliDevMode } from './cli_dev_mode';
expect.addSnapshotSerializer(createAbsolutePathSerializer());
expect.addSnapshotSerializer(createAnyInstanceSerializer(Rx.Observable, 'Rx.Observable'));
expect.addSnapshotSerializer(createAnyInstanceSerializer(TestLog));
jest.mock('./watcher');
const { Watcher } = jest.requireMock('./watcher');
jest.mock('./optimizer');
const { Optimizer } = jest.requireMock('./optimizer');
jest.mock('./dev_server');
const { DevServer } = jest.requireMock('./dev_server');
jest.mock('./get_server_watch_paths', () => ({
getServerWatchPaths: jest.fn(() => ({
watchPaths: ['<mock watch paths>'],
ignorePaths: ['<mock ignore paths>'],
})),
}));
beforeEach(() => {
process.argv = ['node', './script', 'foo', 'bar', 'baz'];
jest.clearAllMocks();
});
const log = new TestLog();
const mockBasePathProxy = {
targetPort: 9999,
basePath: '/foo/bar',
start: jest.fn(),
stop: jest.fn(),
};
const defaultOptions = {
cache: true,
disableOptimizer: false,
dist: true,
oss: true,
pluginPaths: [],
pluginScanDirs: [Path.resolve(REPO_ROOT, 'src/plugins')],
quiet: false,
silent: false,
runExamples: false,
watch: true,
log,
};
afterEach(() => {
log.messages.length = 0;
});
it('passes correct args to sub-classes', () => {
new CliDevMode(defaultOptions);
expect(DevServer.mock.calls).toMatchInlineSnapshot(`
Array [
Array [
Object {
"argv": Array [
"foo",
"bar",
"baz",
],
"gracefulTimeout": 5000,
"log": <TestLog>,
"mapLogLine": [Function],
"script": <absolute path>/scripts/kibana,
"watcher": Watcher {
"serverShouldRestart$": [MockFunction],
},
},
],
]
`);
expect(Optimizer.mock.calls).toMatchInlineSnapshot(`
Array [
Array [
Object {
"cache": true,
"dist": true,
"enabled": true,
"oss": true,
"pluginPaths": Array [],
"quiet": false,
"repoRoot": <absolute path>,
"runExamples": false,
"silent": false,
"watch": true,
},
],
]
`);
expect(Watcher.mock.calls).toMatchInlineSnapshot(`
Array [
Array [
Object {
"cwd": <absolute path>,
"enabled": true,
"ignore": Array [
"<mock ignore paths>",
],
"log": <TestLog>,
"paths": Array [
"<mock watch paths>",
],
},
],
]
`);
expect(log.messages).toMatchInlineSnapshot(`Array []`);
});
it('disables the optimizer', () => {
new CliDevMode({
...defaultOptions,
disableOptimizer: true,
});
expect(Optimizer.mock.calls[0][0]).toHaveProperty('enabled', false);
});
it('disables the watcher', () => {
new CliDevMode({
...defaultOptions,
watch: false,
});
expect(Optimizer.mock.calls[0][0]).toHaveProperty('watch', false);
expect(Watcher.mock.calls[0][0]).toHaveProperty('enabled', false);
});
it('overrides the basePath of the server when basePathProxy is defined', () => {
new CliDevMode({
...defaultOptions,
basePathProxy: mockBasePathProxy as any,
});
expect(DevServer.mock.calls[0][0].argv).toMatchInlineSnapshot(`
Array [
"foo",
"bar",
"baz",
"--server.port=9999",
"--server.basePath=/foo/bar",
"--server.rewriteBasePath=true",
]
`);
});
describe('#start()/#stop()', () => {
let optimizerRun$: Rx.Subject<void>;
let optimizerReady$: Rx.Subject<void>;
let watcherRun$: Rx.Subject<void>;
let devServerRun$: Rx.Subject<void>;
let devServerReady$: Rx.Subject<void>;
let processExitMock: jest.SpyInstance;
beforeAll(() => {
processExitMock = jest.spyOn(process, 'exit').mockImplementation(
// @ts-expect-error process.exit isn't supposed to return
() => {}
);
});
beforeEach(() => {
Optimizer.mockImplementation(() => {
optimizerRun$ = new Rx.Subject();
optimizerReady$ = new Rx.Subject();
return {
isReady$: jest.fn(() => optimizerReady$),
run$: optimizerRun$,
};
});
Watcher.mockImplementation(() => {
watcherRun$ = new Rx.Subject();
return {
run$: watcherRun$,
};
});
DevServer.mockImplementation(() => {
devServerRun$ = new Rx.Subject();
devServerReady$ = new Rx.Subject();
return {
isReady$: jest.fn(() => devServerReady$),
run$: devServerRun$,
};
});
});
afterEach(() => {
Optimizer.mockReset();
Watcher.mockReset();
DevServer.mockReset();
});
afterAll(() => {
processExitMock.mockRestore();
});
it('logs a warning if basePathProxy is not passed', () => {
new CliDevMode({
...defaultOptions,
}).start();
expect(log.messages).toMatchInlineSnapshot(`
Array [
Object {
"args": Array [
"no-base-path",
"====================================================================================================",
],
"type": "warn",
},
Object {
"args": Array [
"no-base-path",
"Running Kibana in dev mode with --no-base-path disables several useful features and is not recommended",
],
"type": "warn",
},
Object {
"args": Array [
"no-base-path",
"====================================================================================================",
],
"type": "warn",
},
]
`);
});
it('calls start on BasePathProxy if enabled', () => {
const basePathProxy: any = {
start: jest.fn(),
};
new CliDevMode({
...defaultOptions,
basePathProxy,
}).start();
expect(basePathProxy.start.mock.calls).toMatchInlineSnapshot(`
Array [
Array [
Object {
"delayUntil": [Function],
"shouldRedirectFromOldBasePath": [Function],
},
],
]
`);
});
it('subscribes to Optimizer#run$, Watcher#run$, and DevServer#run$', () => {
new CliDevMode(defaultOptions).start();
expect(optimizerRun$.observers).toHaveLength(1);
expect(watcherRun$.observers).toHaveLength(1);
expect(devServerRun$.observers).toHaveLength(1);
});
it('logs an error and exits the process if Optimizer#run$ errors', () => {
new CliDevMode({
...defaultOptions,
basePathProxy: mockBasePathProxy as any,
}).start();
expect(processExitMock).not.toHaveBeenCalled();
optimizerRun$.error({ stack: 'Error: foo bar' });
expect(log.messages).toMatchInlineSnapshot(`
Array [
Object {
"args": Array [
"[@kbn/optimizer] fatal error",
"Error: foo bar",
],
"type": "bad",
},
]
`);
expect(processExitMock.mock.calls).toMatchInlineSnapshot(`
Array [
Array [
1,
],
]
`);
});
it('logs an error and exits the process if Watcher#run$ errors', () => {
new CliDevMode({
...defaultOptions,
basePathProxy: mockBasePathProxy as any,
}).start();
expect(processExitMock).not.toHaveBeenCalled();
watcherRun$.error({ stack: 'Error: foo bar' });
expect(log.messages).toMatchInlineSnapshot(`
Array [
Object {
"args": Array [
"[watcher] fatal error",
"Error: foo bar",
],
"type": "bad",
},
]
`);
expect(processExitMock.mock.calls).toMatchInlineSnapshot(`
Array [
Array [
1,
],
]
`);
});
it('logs an error and exits the process if DevServer#run$ errors', () => {
new CliDevMode({
...defaultOptions,
basePathProxy: mockBasePathProxy as any,
}).start();
expect(processExitMock).not.toHaveBeenCalled();
devServerRun$.error({ stack: 'Error: foo bar' });
expect(log.messages).toMatchInlineSnapshot(`
Array [
Object {
"args": Array [
"[dev server] fatal error",
"Error: foo bar",
],
"type": "bad",
},
]
`);
expect(processExitMock.mock.calls).toMatchInlineSnapshot(`
Array [
Array [
1,
],
]
`);
});
it('throws if start() has already been called', () => {
expect(() => {
const devMode = new CliDevMode({
...defaultOptions,
basePathProxy: mockBasePathProxy as any,
});
devMode.start();
devMode.start();
}).toThrowErrorMatchingInlineSnapshot(`"CliDevMode already started"`);
});
it('unsubscribes from all observables and stops basePathProxy when stopped', () => {
const devMode = new CliDevMode({
...defaultOptions,
basePathProxy: mockBasePathProxy as any,
});
devMode.start();
devMode.stop();
expect(optimizerRun$.observers).toHaveLength(0);
expect(watcherRun$.observers).toHaveLength(0);
expect(devServerRun$.observers).toHaveLength(0);
expect(mockBasePathProxy.stop).toHaveBeenCalledTimes(1);
});
});

View file

@ -0,0 +1,254 @@
/*
* 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 Path from 'path';
import { REPO_ROOT } from '@kbn/dev-utils';
import * as Rx from 'rxjs';
import { mapTo, filter, take, tap, distinctUntilChanged, switchMap } from 'rxjs/operators';
import { CliArgs } from '../../core/server/config';
import { LegacyConfig } from '../../core/server/legacy';
import { BasePathProxyServer } from '../../core/server/http';
import { Log, CliLog } from './log';
import { Optimizer } from './optimizer';
import { DevServer } from './dev_server';
import { Watcher } from './watcher';
import { shouldRedirectFromOldBasePath } from './should_redirect_from_old_base_path';
import { getServerWatchPaths } from './get_server_watch_paths';
// timeout where the server is allowed to exit gracefully
const GRACEFUL_TIMEOUT = 5000;
export type SomeCliArgs = Pick<
CliArgs,
'quiet' | 'silent' | 'disableOptimizer' | 'watch' | 'oss' | 'runExamples' | 'cache' | 'dist'
>;
export interface CliDevModeOptions {
basePathProxy?: BasePathProxyServer;
log?: Log;
// cli flags
dist: boolean;
oss: boolean;
runExamples: boolean;
pluginPaths: string[];
pluginScanDirs: string[];
disableOptimizer: boolean;
quiet: boolean;
silent: boolean;
watch: boolean;
cache: boolean;
}
const firstAllTrue = (...sources: Array<Rx.Observable<boolean>>) =>
Rx.combineLatest(sources).pipe(
filter((values) => values.every((v) => v === true)),
take(1),
mapTo(undefined)
);
/**
* setup and manage the parent process of the dev server:
*
* - runs the Kibana server in a child process
* - watches for changes to the server source code, restart the server on changes.
* - run the kbn/optimizer
* - run the basePathProxy
* - delay requests received by the basePathProxy when either the server isn't ready
* or the kbn/optimizer isn't ready
*
*/
export class CliDevMode {
static fromCoreServices(
cliArgs: SomeCliArgs,
config: LegacyConfig,
basePathProxy?: BasePathProxyServer
) {
new CliDevMode({
quiet: !!cliArgs.quiet,
silent: !!cliArgs.silent,
cache: !!cliArgs.cache,
disableOptimizer: !!cliArgs.disableOptimizer,
dist: !!cliArgs.dist,
oss: !!cliArgs.oss,
runExamples: !!cliArgs.runExamples,
pluginPaths: config.get<string[]>('plugins.paths'),
pluginScanDirs: config.get<string[]>('plugins.scanDirs'),
watch: !!cliArgs.watch,
basePathProxy,
}).start();
}
private readonly log: Log;
private readonly basePathProxy?: BasePathProxyServer;
private readonly watcher: Watcher;
private readonly devServer: DevServer;
private readonly optimizer: Optimizer;
private subscription?: Rx.Subscription;
constructor(options: CliDevModeOptions) {
this.basePathProxy = options.basePathProxy;
this.log = options.log || new CliLog(!!options.quiet, !!options.silent);
const { watchPaths, ignorePaths } = getServerWatchPaths({
pluginPaths: options.pluginPaths ?? [],
pluginScanDirs: [
...(options.pluginScanDirs ?? []),
Path.resolve(REPO_ROOT, 'src/plugins'),
Path.resolve(REPO_ROOT, 'x-pack/plugins'),
],
});
this.watcher = new Watcher({
enabled: !!options.watch,
log: this.log,
cwd: REPO_ROOT,
paths: watchPaths,
ignore: ignorePaths,
});
this.devServer = new DevServer({
log: this.log,
watcher: this.watcher,
gracefulTimeout: GRACEFUL_TIMEOUT,
script: Path.resolve(REPO_ROOT, 'scripts/kibana'),
argv: [
...process.argv.slice(2).filter((v) => v !== '--no-watch'),
...(options.basePathProxy
? [
`--server.port=${options.basePathProxy.targetPort}`,
`--server.basePath=${options.basePathProxy.basePath}`,
'--server.rewriteBasePath=true',
]
: []),
],
mapLogLine: (line) => {
if (!this.basePathProxy) {
return line;
}
return line
.split(`${this.basePathProxy.host}:${this.basePathProxy.targetPort}`)
.join(`${this.basePathProxy.host}:${this.basePathProxy.port}`);
},
});
this.optimizer = new Optimizer({
enabled: !options.disableOptimizer,
repoRoot: REPO_ROOT,
oss: options.oss,
pluginPaths: options.pluginPaths,
runExamples: options.runExamples,
cache: options.cache,
dist: options.dist,
quiet: options.quiet,
silent: options.silent,
watch: options.watch,
});
}
public start() {
const { basePathProxy } = this;
if (this.subscription) {
throw new Error('CliDevMode already started');
}
this.subscription = new Rx.Subscription();
if (basePathProxy) {
const serverReady$ = new Rx.BehaviorSubject(false);
const optimizerReady$ = new Rx.BehaviorSubject(false);
const userWaiting$ = new Rx.BehaviorSubject(false);
this.subscription.add(
Rx.merge(
this.devServer.isReady$().pipe(tap(serverReady$)),
this.optimizer.isReady$().pipe(tap(optimizerReady$)),
userWaiting$.pipe(
distinctUntilChanged(),
switchMap((waiting) =>
!waiting
? Rx.EMPTY
: Rx.timer(1000).pipe(
tap(() => {
this.log.warn(
'please hold',
!optimizerReady$.getValue()
? 'optimizer is still bundling so requests have been paused'
: 'server is not ready so requests have been paused'
);
})
)
)
)
).subscribe(this.observer('readiness checks'))
);
basePathProxy.start({
delayUntil: () => {
userWaiting$.next(true);
return firstAllTrue(serverReady$, optimizerReady$).pipe(
tap(() => userWaiting$.next(false))
);
},
shouldRedirectFromOldBasePath,
});
this.subscription.add(() => basePathProxy.stop());
} else {
this.log.warn('no-base-path', '='.repeat(100));
this.log.warn(
'no-base-path',
'Running Kibana in dev mode with --no-base-path disables several useful features and is not recommended'
);
this.log.warn('no-base-path', '='.repeat(100));
}
this.subscription.add(this.optimizer.run$.subscribe(this.observer('@kbn/optimizer')));
this.subscription.add(this.watcher.run$.subscribe(this.observer('watcher')));
this.subscription.add(this.devServer.run$.subscribe(this.observer('dev server')));
}
public stop() {
if (!this.subscription) {
throw new Error('CliDevMode has not been started');
}
this.subscription.unsubscribe();
this.subscription = undefined;
}
private observer = (title: string): Rx.Observer<unknown> => ({
next: () => {
// noop
},
error: (error) => {
this.log.bad(`[${title}] fatal error`, error.stack);
process.exit(1);
},
complete: () => {
// noop
},
});
}

View file

@ -0,0 +1,319 @@
/*
* 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 { PassThrough } from 'stream';
import * as Rx from 'rxjs';
import { extendedEnvSerializer } from './test_helpers';
import { DevServer, Options } from './dev_server';
import { TestLog } from './log';
class MockProc extends EventEmitter {
public readonly signalsSent: string[] = [];
stdout = new PassThrough();
stderr = new PassThrough();
kill = jest.fn((signal) => {
this.signalsSent.push(signal);
});
mockExit(code: number) {
this.emit('exit', code, undefined);
// close stdio streams
this.stderr.end();
this.stdout.end();
}
mockListening() {
this.emit('message', ['SERVER_LISTENING'], undefined);
}
}
jest.mock('execa');
const execa = jest.requireMock('execa');
let currentProc: MockProc | undefined;
execa.node.mockImplementation(() => {
const proc = new MockProc();
currentProc = proc;
return proc;
});
function isProc(proc: MockProc | undefined): asserts proc is MockProc {
expect(proc).toBeInstanceOf(MockProc);
}
const restart$ = new Rx.Subject<void>();
const mockWatcher = {
enabled: true,
serverShouldRestart$: jest.fn(() => restart$),
};
const processExit$ = new Rx.Subject<void>();
const sigint$ = new Rx.Subject<void>();
const sigterm$ = new Rx.Subject<void>();
const log = new TestLog();
const defaultOptions: Options = {
log,
watcher: mockWatcher as any,
script: 'some/script',
argv: ['foo', 'bar'],
gracefulTimeout: 100,
processExit$,
sigint$,
sigterm$,
};
expect.addSnapshotSerializer(extendedEnvSerializer);
beforeEach(() => {
jest.clearAllMocks();
log.messages.length = 0;
currentProc = undefined;
});
const subscriptions: Rx.Subscription[] = [];
const run = (server: DevServer) => {
const subscription = server.run$.subscribe({
error(e) {
throw e;
},
});
subscriptions.push(subscription);
return subscription;
};
afterEach(() => {
if (currentProc) {
currentProc.removeAllListeners();
currentProc = undefined;
}
for (const sub of subscriptions) {
sub.unsubscribe();
}
subscriptions.length = 0;
});
describe('#run$', () => {
it('starts the dev server with the right options', () => {
run(new DevServer(defaultOptions)).unsubscribe();
expect(execa.node.mock.calls).toMatchInlineSnapshot(`
Array [
Array [
"some/script",
Array [
"foo",
"bar",
"--logging.json=false",
],
Object {
"env": Object {
"<inheritted process.env>": true,
"ELASTIC_APM_SERVICE_NAME": "kibana",
"isDevCliChild": "true",
},
"nodeOptions": Array [],
"stdio": "pipe",
},
],
]
`);
});
it('writes stdout and stderr lines to logger', () => {
run(new DevServer(defaultOptions));
isProc(currentProc);
currentProc.stdout.write('hello ');
currentProc.stderr.write('something ');
currentProc.stdout.write('world\n');
currentProc.stderr.write('went wrong\n');
expect(log.messages).toMatchInlineSnapshot(`
Array [
Object {
"args": Array [
"hello world",
],
"type": "write",
},
Object {
"args": Array [
"something went wrong",
],
"type": "write",
},
]
`);
});
it('is ready when message sends SERVER_LISTENING message', () => {
const server = new DevServer(defaultOptions);
run(server);
isProc(currentProc);
let ready;
subscriptions.push(
server.isReady$().subscribe((_ready) => {
ready = _ready;
})
);
expect(ready).toBe(false);
currentProc.mockListening();
expect(ready).toBe(true);
});
it('is not ready when process exits', () => {
const server = new DevServer(defaultOptions);
run(server);
isProc(currentProc);
const ready$ = new Rx.BehaviorSubject<undefined | boolean>(undefined);
subscriptions.push(server.isReady$().subscribe(ready$));
currentProc.mockListening();
expect(ready$.getValue()).toBe(true);
currentProc.mockExit(0);
expect(ready$.getValue()).toBe(false);
});
it('logs about crashes when process exits with non-zero code', () => {
const server = new DevServer(defaultOptions);
run(server);
isProc(currentProc);
currentProc.mockExit(1);
expect(log.messages).toMatchInlineSnapshot(`
Array [
Object {
"args": Array [
"server crashed",
"with status code",
1,
],
"type": "bad",
},
]
`);
});
it('does not restart the server when process exits with 0 and stdio streams complete', async () => {
const server = new DevServer(defaultOptions);
run(server);
isProc(currentProc);
const initialProc = currentProc;
const ready$ = new Rx.BehaviorSubject<undefined | boolean>(undefined);
subscriptions.push(server.isReady$().subscribe(ready$));
currentProc.mockExit(0);
expect(ready$.getValue()).toBe(false);
expect(initialProc).toBe(currentProc); // no restart or the proc would have been updated
});
it('kills server and restarts when watcher says to', () => {
run(new DevServer(defaultOptions));
const initialProc = currentProc;
isProc(initialProc);
restart$.next();
expect(initialProc.signalsSent).toEqual(['SIGKILL']);
isProc(currentProc);
expect(currentProc).not.toBe(initialProc);
});
it('subscribes to sigint$, sigterm$, and processExit$ options', () => {
run(new DevServer(defaultOptions));
expect(sigint$.observers).toHaveLength(1);
expect(sigterm$.observers).toHaveLength(1);
expect(processExit$.observers).toHaveLength(1);
});
it('kills the server on sigint$ before listening', () => {
run(new DevServer(defaultOptions));
isProc(currentProc);
expect(currentProc.signalsSent).toEqual([]);
sigint$.next();
expect(currentProc.signalsSent).toEqual(['SIGKILL']);
});
it('kills the server on processExit$', () => {
run(new DevServer(defaultOptions));
isProc(currentProc);
expect(currentProc.signalsSent).toEqual([]);
processExit$.next();
expect(currentProc.signalsSent).toEqual(['SIGKILL']);
});
it('kills the server on sigterm$', () => {
run(new DevServer(defaultOptions));
isProc(currentProc);
expect(currentProc.signalsSent).toEqual([]);
sigterm$.next();
expect(currentProc.signalsSent).toEqual(['SIGKILL']);
});
it('sends SIGINT to child process on sigint$ after listening', () => {
run(new DevServer(defaultOptions));
isProc(currentProc);
currentProc.mockListening();
expect(currentProc.signalsSent).toEqual([]);
sigint$.next();
expect(currentProc.signalsSent).toEqual(['SIGINT']);
});
it('sends SIGKILL to child process on double sigint$ after listening', () => {
run(new DevServer(defaultOptions));
isProc(currentProc);
currentProc.mockListening();
expect(currentProc.signalsSent).toEqual([]);
sigint$.next();
sigint$.next();
expect(currentProc.signalsSent).toEqual(['SIGINT', 'SIGKILL']);
});
it('kills the server after sending SIGINT and gracefulTimeout is passed after listening', async () => {
run(new DevServer(defaultOptions));
isProc(currentProc);
currentProc.mockListening();
expect(currentProc.signalsSent).toEqual([]);
sigint$.next();
expect(currentProc.signalsSent).toEqual(['SIGINT']);
await new Promise((resolve) => setTimeout(resolve, 1000));
expect(currentProc.signalsSent).toEqual(['SIGINT', 'SIGKILL']);
});
});

View file

@ -0,0 +1,228 @@
/*
* 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 * as Rx from 'rxjs';
import {
map,
tap,
take,
share,
mergeMap,
switchMap,
takeUntil,
ignoreElements,
} from 'rxjs/operators';
import { observeLines } from '@kbn/dev-utils';
import { usingServerProcess } from './using_server_process';
import { Watcher } from './watcher';
import { Log } from './log';
export interface Options {
log: Log;
watcher: Watcher;
script: string;
argv: string[];
gracefulTimeout: number;
processExit$?: Rx.Observable<void>;
sigint$?: Rx.Observable<void>;
sigterm$?: Rx.Observable<void>;
mapLogLine?: DevServer['mapLogLine'];
}
export class DevServer {
private readonly log: Log;
private readonly watcher: Watcher;
private readonly processExit$: Rx.Observable<void>;
private readonly sigint$: Rx.Observable<void>;
private readonly sigterm$: Rx.Observable<void>;
private readonly ready$ = new Rx.BehaviorSubject(false);
private readonly script: string;
private readonly argv: string[];
private readonly gracefulTimeout: number;
private readonly mapLogLine?: (line: string) => string | null;
constructor(options: Options) {
this.log = options.log;
this.watcher = options.watcher;
this.script = options.script;
this.argv = options.argv;
this.gracefulTimeout = options.gracefulTimeout;
this.processExit$ = options.processExit$ ?? Rx.fromEvent(process as EventEmitter, 'exit');
this.sigint$ = options.sigint$ ?? Rx.fromEvent(process as EventEmitter, 'SIGINT');
this.sigterm$ = options.sigterm$ ?? Rx.fromEvent(process as EventEmitter, 'SIGTERM');
this.mapLogLine = options.mapLogLine;
}
isReady$() {
return this.ready$.asObservable();
}
/**
* Run the Kibana server
*
* The observable will error if the child process failes to spawn for some reason, but if
* the child process is successfully spawned then the server will be run until it completes
* and restart when the watcher indicates it should. In order to restart the server as
* quickly as possible we kill it with SIGKILL and spawn the process again.
*
* While the process is running we also observe SIGINT signals and forward them to the child
* process. If the process doesn't exit within options.gracefulTimeout we kill the process
* with SIGKILL and complete our observable which should allow the parent process to exit.
*
* When the global 'exit' event or SIGTERM is observed we send the SIGKILL signal to the
* child process to make sure that it's immediately gone.
*/
run$ = new Rx.Observable<void>((subscriber) => {
// listen for SIGINT and forward to process if it's running, otherwise unsub
const gracefulShutdown$ = new Rx.Subject();
subscriber.add(
this.sigint$
.pipe(
map((_, index) => {
if (this.ready$.getValue() && index === 0) {
gracefulShutdown$.next();
} else {
subscriber.complete();
}
})
)
.subscribe({
error(error) {
subscriber.error(error);
},
})
);
// force unsubscription/kill on process.exit or SIGTERM
subscriber.add(
Rx.merge(this.processExit$, this.sigterm$).subscribe(() => {
subscriber.complete();
})
);
const runServer = () =>
usingServerProcess(this.script, this.argv, (proc) => {
// observable which emits devServer states containing lines
// logged to stdout/stderr, completes when stdio streams complete
const log$ = Rx.merge(observeLines(proc.stdout!), observeLines(proc.stderr!)).pipe(
tap((observedLine) => {
const line = this.mapLogLine ? this.mapLogLine(observedLine) : observedLine;
if (line !== null) {
this.log.write(line);
}
})
);
// observable which emits exit states and is the switch which
// ends all other merged observables
const exit$ = Rx.fromEvent<[number]>(proc, 'exit').pipe(
tap(([code]) => {
this.ready$.next(false);
if (code != null && code !== 0) {
if (this.watcher.enabled) {
this.log.bad(`server crashed`, 'with status code', code);
} else {
throw new Error(`server crashed with exit code [${code}]`);
}
}
}),
take(1),
share()
);
// throw errors if spawn fails
const error$ = Rx.fromEvent<Error>(proc, 'error').pipe(
map((error) => {
throw error;
}),
takeUntil(exit$)
);
// handles messages received from the child process
const msg$ = Rx.fromEvent<[any]>(proc, 'message').pipe(
tap(([received]) => {
if (!Array.isArray(received)) {
return;
}
const msg = received[0];
if (msg === 'SERVER_LISTENING') {
this.ready$.next(true);
}
// TODO: remove this once Pier is done migrating log rotation to KP
if (msg === 'RELOAD_LOGGING_CONFIG_FROM_SERVER_WORKER') {
// When receive that event from server worker
// forward a reloadLoggingConfig message to parent
// and child proc. This is only used by LogRotator service
// when the cluster mode is enabled
process.emit('message' as any, { reloadLoggingConfig: true } as any);
proc.send({ reloadLoggingConfig: true });
}
}),
takeUntil(exit$)
);
// handle graceful shutdown requests
const triggerGracefulShutdown$ = gracefulShutdown$.pipe(
mergeMap(() => {
// signal to the process that it should exit
proc.kill('SIGINT');
// if the timer fires before exit$ we will send SIGINT
return Rx.timer(this.gracefulTimeout).pipe(
tap(() => {
this.log.warn(
`server didnt exit`,
`sent [SIGINT] to the server but it didn't exit within ${this.gracefulTimeout}ms, killing with SIGKILL`
);
proc.kill('SIGKILL');
})
);
}),
// if exit$ emits before the gracefulTimeout then this
// will unsub and cancel the timer
takeUntil(exit$)
);
return Rx.merge(log$, exit$, error$, msg$, triggerGracefulShutdown$);
});
subscriber.add(
Rx.concat([undefined], this.watcher.serverShouldRestart$())
.pipe(
// on each tick unsubscribe from the previous server process
// causing it to be SIGKILL-ed, then setup a new one
switchMap(runServer),
ignoreElements()
)
.subscribe(subscriber)
);
});
}

View file

@ -17,14 +17,27 @@
* under the License.
*/
import { BinderBase, Emitter } from './binder';
import getopts from 'getopts';
// @ts-expect-error no types available, very simple module https://github.com/evanlucas/argsplit
import argsplit from 'argsplit';
export class BinderFor extends BinderBase {
constructor(private readonly emitter: Emitter) {
super();
const execOpts = getopts(process.execArgv);
const envOpts = getopts(process.env.NODE_OPTIONS ? argsplit(process.env.NODE_OPTIONS) : []);
export function getActiveInspectFlag() {
if (execOpts.inspect) {
return '--inspect';
}
public on(...args: any[]) {
super.on(this.emitter, ...args);
if (execOpts['inspect-brk']) {
return '--inspect-brk';
}
if (envOpts.inspect) {
return '--inspect';
}
if (envOpts['inspect-brk']) {
return '--inspect-brk';
}
}

View file

@ -0,0 +1,90 @@
/*
* 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 Path from 'path';
import { REPO_ROOT, createAbsolutePathSerializer } from '@kbn/dev-utils';
import { getServerWatchPaths } from './get_server_watch_paths';
expect.addSnapshotSerializer(createAbsolutePathSerializer());
it('produces the right watch and ignore list', () => {
const { watchPaths, ignorePaths } = getServerWatchPaths({
pluginPaths: [Path.resolve(REPO_ROOT, 'x-pack/test/plugin_functional/plugins/resolver_test')],
pluginScanDirs: [
Path.resolve(REPO_ROOT, 'src/plugins'),
Path.resolve(REPO_ROOT, 'test/plugin_functional/plugins'),
Path.resolve(REPO_ROOT, 'x-pack/plugins'),
],
});
expect(watchPaths).toMatchInlineSnapshot(`
Array [
<absolute path>/src/core,
<absolute path>/src/legacy/server,
<absolute path>/src/legacy/ui,
<absolute path>/src/legacy/utils,
<absolute path>/config,
<absolute path>/x-pack/test/plugin_functional/plugins/resolver_test,
<absolute path>/src/plugins,
<absolute path>/test/plugin_functional/plugins,
<absolute path>/x-pack/plugins,
]
`);
expect(ignorePaths).toMatchInlineSnapshot(`
Array [
/\\[\\\\\\\\\\\\/\\]\\(\\\\\\.\\.\\*\\|node_modules\\|bower_components\\|target\\|public\\|__\\[a-z0-9_\\]\\+__\\|coverage\\)\\(\\[\\\\\\\\\\\\/\\]\\|\\$\\)/,
/\\\\\\.test\\\\\\.\\(js\\|tsx\\?\\)\\$/,
/\\\\\\.\\(md\\|sh\\|txt\\)\\$/,
/debug\\\\\\.log\\$/,
<absolute path>/src/plugins/*/test/**,
<absolute path>/src/plugins/*/build/**,
<absolute path>/src/plugins/*/target/**,
<absolute path>/src/plugins/*/scripts/**,
<absolute path>/src/plugins/*/docs/**,
<absolute path>/test/plugin_functional/plugins/*/test/**,
<absolute path>/test/plugin_functional/plugins/*/build/**,
<absolute path>/test/plugin_functional/plugins/*/target/**,
<absolute path>/test/plugin_functional/plugins/*/scripts/**,
<absolute path>/test/plugin_functional/plugins/*/docs/**,
<absolute path>/x-pack/plugins/*/test/**,
<absolute path>/x-pack/plugins/*/build/**,
<absolute path>/x-pack/plugins/*/target/**,
<absolute path>/x-pack/plugins/*/scripts/**,
<absolute path>/x-pack/plugins/*/docs/**,
<absolute path>/x-pack/test/plugin_functional/plugins/resolver_test/test/**,
<absolute path>/x-pack/test/plugin_functional/plugins/resolver_test/build/**,
<absolute path>/x-pack/test/plugin_functional/plugins/resolver_test/target/**,
<absolute path>/x-pack/test/plugin_functional/plugins/resolver_test/scripts/**,
<absolute path>/x-pack/test/plugin_functional/plugins/resolver_test/docs/**,
<absolute path>/x-pack/plugins/reporting/chromium,
<absolute path>/x-pack/plugins/security_solution/cypress,
<absolute path>/x-pack/plugins/apm/e2e,
<absolute path>/x-pack/plugins/apm/scripts,
<absolute path>/x-pack/plugins/canvas/canvas_plugin_src,
<absolute path>/x-pack/plugins/case/server/scripts,
<absolute path>/x-pack/plugins/lists/scripts,
<absolute path>/x-pack/plugins/lists/server/scripts,
<absolute path>/x-pack/plugins/security_solution/scripts,
<absolute path>/x-pack/plugins/security_solution/server/lib/detection_engine/scripts,
]
`);
});

View file

@ -0,0 +1,94 @@
/*
* 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 Path from 'path';
import Fs from 'fs';
import { REPO_ROOT } from '@kbn/dev-utils';
interface Options {
pluginPaths: string[];
pluginScanDirs: string[];
}
export type WatchPaths = ReturnType<typeof getServerWatchPaths>;
export function getServerWatchPaths({ pluginPaths, pluginScanDirs }: Options) {
const fromRoot = (p: string) => Path.resolve(REPO_ROOT, p);
const pluginInternalDirsIgnore = pluginScanDirs
.map((scanDir) => Path.resolve(scanDir, '*'))
.concat(pluginPaths)
.reduce(
(acc: string[], path) => [
...acc,
Path.resolve(path, 'test/**'),
Path.resolve(path, 'build/**'),
Path.resolve(path, 'target/**'),
Path.resolve(path, 'scripts/**'),
Path.resolve(path, 'docs/**'),
],
[]
);
const watchPaths = Array.from(
new Set(
[
fromRoot('src/core'),
fromRoot('src/legacy/server'),
fromRoot('src/legacy/ui'),
fromRoot('src/legacy/utils'),
fromRoot('config'),
...pluginPaths,
...pluginScanDirs,
].map((path) => Path.resolve(path))
)
);
for (const watchPath of watchPaths) {
if (!Fs.existsSync(fromRoot(watchPath))) {
throw new Error(
`A watch directory [${watchPath}] does not exist, which will cause chokidar to fail. Either make sure the directory exists or remove it as a watch source in the ClusterManger`
);
}
}
const ignorePaths = [
/[\\\/](\..*|node_modules|bower_components|target|public|__[a-z0-9_]+__|coverage)([\\\/]|$)/,
/\.test\.(js|tsx?)$/,
/\.(md|sh|txt)$/,
/debug\.log$/,
...pluginInternalDirsIgnore,
fromRoot('x-pack/plugins/reporting/chromium'),
fromRoot('x-pack/plugins/security_solution/cypress'),
fromRoot('x-pack/plugins/apm/e2e'),
fromRoot('x-pack/plugins/apm/scripts'),
fromRoot('x-pack/plugins/canvas/canvas_plugin_src'), // prevents server from restarting twice for Canvas plugin changes,
fromRoot('x-pack/plugins/case/server/scripts'),
fromRoot('x-pack/plugins/lists/scripts'),
fromRoot('x-pack/plugins/lists/server/scripts'),
fromRoot('x-pack/plugins/security_solution/scripts'),
fromRoot('x-pack/plugins/security_solution/server/lib/detection_engine/scripts'),
];
return {
watchPaths,
ignorePaths,
};
}

View file

@ -17,4 +17,5 @@
* under the License.
*/
export { startRepl } from '../../../cli/repl';
export * from './cli_dev_mode';
export * from './log';

View file

@ -17,9 +17,18 @@
* under the License.
*/
/* eslint-disable max-classes-per-file */
import Chalk from 'chalk';
export class Log {
export interface Log {
good(label: string, ...args: any[]): void;
warn(label: string, ...args: any[]): void;
bad(label: string, ...args: any[]): void;
write(label: string, ...args: any[]): void;
}
export class CliLog implements Log {
constructor(private readonly quiet: boolean, private readonly silent: boolean) {}
good(label: string, ...args: any[]) {
@ -54,3 +63,35 @@ export class Log {
console.log(` ${label.trim()} `, ...args);
}
}
export class TestLog implements Log {
public readonly messages: Array<{ type: string; args: any[] }> = [];
bad(label: string, ...args: any[]) {
this.messages.push({
type: 'bad',
args: [label, ...args],
});
}
good(label: string, ...args: any[]) {
this.messages.push({
type: 'good',
args: [label, ...args],
});
}
warn(label: string, ...args: any[]) {
this.messages.push({
type: 'warn',
args: [label, ...args],
});
}
write(label: string, ...args: any[]) {
this.messages.push({
type: 'write',
args: [label, ...args],
});
}
}

View file

@ -0,0 +1,214 @@
/*
* 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 { PassThrough } from 'stream';
import * as Rx from 'rxjs';
import { toArray } from 'rxjs/operators';
import { OptimizerUpdate } from '@kbn/optimizer';
import { observeLines, createReplaceSerializer } from '@kbn/dev-utils';
import { firstValueFrom } from '@kbn/std';
import { Optimizer, Options } from './optimizer';
jest.mock('@kbn/optimizer');
const realOptimizer = jest.requireActual('@kbn/optimizer');
const { runOptimizer, OptimizerConfig, logOptimizerState } = jest.requireMock('@kbn/optimizer');
logOptimizerState.mockImplementation(realOptimizer.logOptimizerState);
class MockOptimizerConfig {}
const mockOptimizerUpdate = (phase: OptimizerUpdate['state']['phase']) => {
return {
state: {
compilerStates: [],
durSec: 0,
offlineBundles: [],
onlineBundles: [],
phase,
startTime: 100,
},
};
};
const defaultOptions: Options = {
enabled: true,
cache: true,
dist: true,
oss: true,
pluginPaths: ['/some/dir'],
quiet: true,
silent: true,
repoRoot: '/app',
runExamples: true,
watch: true,
};
function setup(options: Options = defaultOptions) {
const update$ = new Rx.Subject<OptimizerUpdate>();
OptimizerConfig.create.mockImplementation(() => new MockOptimizerConfig());
runOptimizer.mockImplementation(() => update$);
const optimizer = new Optimizer(options);
return { optimizer, update$ };
}
const subscriptions: Rx.Subscription[] = [];
expect.addSnapshotSerializer(createReplaceSerializer(/\[\d\d:\d\d:\d\d\.\d\d\d\]/, '[timestamp]'));
afterEach(() => {
for (const sub of subscriptions) {
sub.unsubscribe();
}
subscriptions.length = 0;
jest.clearAllMocks();
});
it('uses options to create valid OptimizerConfig', () => {
setup();
setup({
...defaultOptions,
cache: false,
dist: false,
runExamples: false,
oss: false,
pluginPaths: [],
repoRoot: '/foo/bar',
watch: false,
});
expect(OptimizerConfig.create.mock.calls).toMatchInlineSnapshot(`
Array [
Array [
Object {
"cache": true,
"dist": true,
"examples": true,
"includeCoreBundle": true,
"oss": true,
"pluginPaths": Array [
"/some/dir",
],
"repoRoot": "/app",
"watch": true,
},
],
Array [
Object {
"cache": false,
"dist": false,
"examples": false,
"includeCoreBundle": true,
"oss": false,
"pluginPaths": Array [],
"repoRoot": "/foo/bar",
"watch": false,
},
],
]
`);
});
it('is ready when optimizer phase is success or issue and logs in familiar format', async () => {
const writeLogTo = new PassThrough();
const linesPromise = firstValueFrom(observeLines(writeLogTo).pipe(toArray()));
const { update$, optimizer } = setup({
...defaultOptions,
quiet: false,
silent: false,
writeLogTo,
});
const history: any[] = ['<init>'];
subscriptions.push(
optimizer.isReady$().subscribe({
next(ready) {
history.push(`ready: ${ready}`);
},
error(error) {
throw error;
},
complete() {
history.push(`complete`);
},
})
);
subscriptions.push(
optimizer.run$.subscribe({
error(error) {
throw error;
},
})
);
history.push('<success>');
update$.next(mockOptimizerUpdate('success'));
history.push('<running>');
update$.next(mockOptimizerUpdate('running'));
history.push('<issue>');
update$.next(mockOptimizerUpdate('issue'));
update$.complete();
expect(history).toMatchInlineSnapshot(`
Array [
"<init>",
"<success>",
"ready: true",
"<running>",
"ready: false",
"<issue>",
"ready: true",
]
`);
writeLogTo.end();
const lines = await linesPromise;
expect(lines).toMatchInlineSnapshot(`
Array [
"np bld log [timestamp] [success][@kbn/optimizer] 0 bundles compiled successfully after 0 sec",
"np bld log [timestamp] [error][@kbn/optimizer] webpack compile errors",
]
`);
});
it('completes immedately and is immediately ready when disabled', () => {
const ready$ = new Rx.BehaviorSubject<undefined | boolean>(undefined);
const { optimizer, update$ } = setup({
...defaultOptions,
enabled: false,
});
subscriptions.push(optimizer.isReady$().subscribe(ready$));
expect(update$.observers).toHaveLength(0);
expect(runOptimizer).not.toHaveBeenCalled();
expect(ready$).toHaveProperty('isStopped', true);
expect(ready$.getValue()).toBe(true);
});

View file

@ -0,0 +1,128 @@
/*
* 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 moment from 'moment';
import { Writable } from 'stream';
import { tap } from 'rxjs/operators';
import {
ToolingLog,
pickLevelFromFlags,
ToolingLogTextWriter,
parseLogLevel,
} from '@kbn/dev-utils';
import * as Rx from 'rxjs';
import { ignoreElements } from 'rxjs/operators';
import { runOptimizer, OptimizerConfig, logOptimizerState } from '@kbn/optimizer';
export interface Options {
enabled: boolean;
repoRoot: string;
quiet: boolean;
silent: boolean;
watch: boolean;
cache: boolean;
dist: boolean;
oss: boolean;
runExamples: boolean;
pluginPaths: string[];
writeLogTo?: Writable;
}
export class Optimizer {
public readonly run$: Rx.Observable<void>;
private readonly ready$ = new Rx.ReplaySubject<boolean>(1);
constructor(options: Options) {
if (!options.enabled) {
this.run$ = Rx.EMPTY;
this.ready$.next(true);
this.ready$.complete();
return;
}
const config = OptimizerConfig.create({
repoRoot: options.repoRoot,
watch: options.watch,
includeCoreBundle: true,
cache: options.cache,
dist: options.dist,
oss: options.oss,
examples: options.runExamples,
pluginPaths: options.pluginPaths,
});
const dim = Chalk.dim('np bld');
const name = Chalk.magentaBright('@kbn/optimizer');
const time = () => moment().format('HH:mm:ss.SSS');
const level = (msgType: string) => {
switch (msgType) {
case 'info':
return Chalk.green(msgType);
case 'success':
return Chalk.cyan(msgType);
case 'debug':
return Chalk.gray(msgType);
case 'warning':
return Chalk.yellowBright(msgType);
default:
return msgType;
}
};
const { flags: levelFlags } = parseLogLevel(
pickLevelFromFlags({
quiet: options.quiet,
silent: options.silent,
})
);
const log = new ToolingLog();
const has = <T extends object>(obj: T, x: any): x is keyof T => obj.hasOwnProperty(x);
log.setWriters([
{
write(msg) {
if (has(levelFlags, msg.type) && !levelFlags[msg.type]) {
return false;
}
ToolingLogTextWriter.write(
options.writeLogTo ?? process.stdout,
`${dim} log [${time()}] [${level(msg.type)}][${name}] `,
msg
);
return true;
},
},
]);
this.run$ = runOptimizer(config).pipe(
logOptimizerState(log, config),
tap(({ state }) => {
this.ready$.next(state.phase === 'success' || state.phase === 'issue');
}),
ignoreElements()
);
}
isReady$() {
return this.ready$.asObservable();
}
}

View file

@ -17,27 +17,23 @@
* under the License.
*/
export interface Emitter {
on: (...args: any[]) => void;
off: (...args: any[]) => void;
addListener: Emitter['on'];
removeListener: Emitter['off'];
}
export class BinderBase {
private disposal: Array<() => void> = [];
public on(emitter: Emitter, ...args: any[]) {
const on = emitter.on || emitter.addListener;
const off = emitter.off || emitter.removeListener;
on.apply(emitter, args);
this.disposal.push(() => off.apply(emitter, args));
import { shouldRedirectFromOldBasePath } from './should_redirect_from_old_base_path';
it.each([
['app/foo'],
['app/bar'],
['login'],
['logout'],
['status'],
['s/1/status'],
['s/2/app/foo'],
])('allows %s', (path) => {
if (!shouldRedirectFromOldBasePath(path)) {
throw new Error(`expected [${path}] to be redirected from old base path`);
}
});
public destroy() {
const destroyers = this.disposal;
this.disposal = [];
destroyers.forEach((fn) => fn());
it.each([['api/foo'], ['v1/api/bar'], ['bundles/foo/foo.bundle.js']])('blocks %s', (path) => {
if (shouldRedirectFromOldBasePath(path)) {
throw new Error(`expected [${path}] to NOT be redirected from old base path`);
}
}
});

View file

@ -17,6 +17,19 @@
* under the License.
*/
import { MockCluster } from './cluster.mock';
export const mockCluster = new MockCluster();
jest.mock('cluster', () => mockCluster);
/**
* Determine which requested paths should be redirected from one basePath
* to another. We only do this for a supset of the paths so that people don't
* think that specifying a random three character string at the beginning of
* a URL will work.
*/
export function shouldRedirectFromOldBasePath(path: string) {
// strip `s/{id}` prefix when checking for need to redirect
if (path.startsWith('s/')) {
path = path.split('/').slice(2).join('/');
}
const isApp = path.startsWith('app/');
const isKnownShortPath = ['login', 'logout', 'status'].includes(path);
return isApp || isKnownShortPath;
}

View file

@ -0,0 +1,49 @@
/*
* 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 const extendedEnvSerializer: jest.SnapshotSerializerPlugin = {
test: (v) =>
typeof v === 'object' &&
v !== null &&
typeof v.env === 'object' &&
v.env !== null &&
!v.env['<inheritted process.env>'],
serialize(val, config, indentation, depth, refs, printer) {
const customizations: Record<string, unknown> = {
'<inheritted process.env>': true,
};
for (const [key, value] of Object.entries(val.env)) {
if (process.env[key] !== value) {
customizations[key] = value;
}
}
return printer(
{
...val,
env: customizations,
},
config,
indentation,
depth,
refs
);
},
};

View file

@ -0,0 +1,67 @@
/*
* 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 execa from 'execa';
import * as Rx from 'rxjs';
import { getActiveInspectFlag } from './get_active_inspect_flag';
const ACTIVE_INSPECT_FLAG = getActiveInspectFlag();
interface ProcResource extends Rx.Unsubscribable {
proc: execa.ExecaChildProcess;
unsubscribe(): void;
}
export function usingServerProcess<T>(
script: string,
argv: string[],
fn: (proc: execa.ExecaChildProcess) => Rx.Observable<T>
) {
return Rx.using(
(): ProcResource => {
const proc = execa.node(script, [...argv, '--logging.json=false'], {
stdio: 'pipe',
nodeOptions: [
...process.execArgv,
...(ACTIVE_INSPECT_FLAG ? [`${ACTIVE_INSPECT_FLAG}=${process.debugPort + 1}`] : []),
],
env: {
...process.env,
NODE_OPTIONS: process.env.NODE_OPTIONS,
isDevCliChild: 'true',
ELASTIC_APM_SERVICE_NAME: 'kibana',
...(process.stdout.isTTY ? { FORCE_COLOR: 'true' } : {}),
},
});
return {
proc,
unsubscribe() {
proc.kill('SIGKILL');
},
};
},
(resource) => {
const { proc } = resource as ProcResource;
return fn(proc);
}
);
}

View file

@ -0,0 +1,219 @@
/*
* 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 * as Rx from 'rxjs';
import { materialize, toArray } from 'rxjs/operators';
import { firstValueFrom } from '@kbn/std';
import { TestLog } from './log';
import { Watcher, Options } from './watcher';
class MockChokidar extends EventEmitter {
close = jest.fn();
}
let mockChokidar: MockChokidar | undefined;
jest.mock('chokidar');
const chokidar = jest.requireMock('chokidar');
function isMock(mock: MockChokidar | undefined): asserts mock is MockChokidar {
expect(mock).toBeInstanceOf(MockChokidar);
}
chokidar.watch.mockImplementation(() => {
mockChokidar = new MockChokidar();
return mockChokidar;
});
const subscriptions: Rx.Subscription[] = [];
const run = (watcher: Watcher) => {
const subscription = watcher.run$.subscribe({
error(e) {
throw e;
},
});
subscriptions.push(subscription);
return subscription;
};
const log = new TestLog();
const defaultOptions: Options = {
enabled: true,
log,
paths: ['foo.js', 'bar.js'],
ignore: [/^f/],
cwd: '/app/repo',
};
afterEach(() => {
jest.clearAllMocks();
if (mockChokidar) {
mockChokidar.removeAllListeners();
mockChokidar = undefined;
}
for (const sub of subscriptions) {
sub.unsubscribe();
}
subscriptions.length = 0;
log.messages.length = 0;
});
it('completes restart streams immediately when disabled', () => {
const watcher = new Watcher({
...defaultOptions,
enabled: false,
});
const restart$ = new Rx.BehaviorSubject<void>(undefined);
subscriptions.push(watcher.serverShouldRestart$().subscribe(restart$));
run(watcher);
expect(restart$.isStopped).toBe(true);
});
it('calls chokidar.watch() with expected arguments', () => {
const watcher = new Watcher(defaultOptions);
expect(chokidar.watch).not.toHaveBeenCalled();
run(watcher);
expect(chokidar.watch.mock.calls).toMatchInlineSnapshot(`
Array [
Array [
Array [
"foo.js",
"bar.js",
],
Object {
"cwd": "/app/repo",
"ignored": Array [
/\\^f/,
],
},
],
]
`);
});
it('closes chokidar watcher when unsubscribed', () => {
const sub = run(new Watcher(defaultOptions));
isMock(mockChokidar);
expect(mockChokidar.close).not.toHaveBeenCalled();
sub.unsubscribe();
expect(mockChokidar.close).toHaveBeenCalledTimes(1);
});
it('rethrows chokidar errors', async () => {
const watcher = new Watcher(defaultOptions);
const promise = firstValueFrom(watcher.run$.pipe(materialize(), toArray()));
isMock(mockChokidar);
mockChokidar.emit('error', new Error('foo bar'));
const notifications = await promise;
expect(notifications).toMatchInlineSnapshot(`
Array [
Notification {
"error": [Error: foo bar],
"hasValue": false,
"kind": "E",
"value": undefined,
},
]
`);
});
it('logs the count of add events after the ready event', () => {
run(new Watcher(defaultOptions));
isMock(mockChokidar);
mockChokidar.emit('add');
mockChokidar.emit('add');
mockChokidar.emit('add');
mockChokidar.emit('add');
mockChokidar.emit('ready');
expect(log.messages).toMatchInlineSnapshot(`
Array [
Object {
"args": Array [
"watching for changes",
"(4 files)",
],
"type": "good",
},
]
`);
});
it('buffers subsequent changes before logging and notifying serverShouldRestart$', async () => {
const watcher = new Watcher(defaultOptions);
const history: any[] = [];
subscriptions.push(
watcher
.serverShouldRestart$()
.pipe(materialize())
.subscribe((n) => history.push(n))
);
run(watcher);
expect(history).toMatchInlineSnapshot(`Array []`);
isMock(mockChokidar);
mockChokidar.emit('ready');
mockChokidar.emit('all', ['add', 'foo.js']);
mockChokidar.emit('all', ['add', 'bar.js']);
mockChokidar.emit('all', ['delete', 'bar.js']);
await new Promise((resolve) => setTimeout(resolve, 1000));
expect(log.messages).toMatchInlineSnapshot(`
Array [
Object {
"args": Array [
"watching for changes",
"(0 files)",
],
"type": "good",
},
Object {
"args": Array [
"restarting server",
"due to changes in
- \\"foo.js\\"
- \\"bar.js\\"",
],
"type": "warn",
},
]
`);
expect(history).toMatchInlineSnapshot(`
Array [
Notification {
"error": undefined,
"hasValue": true,
"kind": "N",
"value": undefined,
},
]
`);
});

View file

@ -0,0 +1,122 @@
/*
* 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 * as Rx from 'rxjs';
import {
map,
tap,
takeUntil,
count,
share,
buffer,
debounceTime,
ignoreElements,
} from 'rxjs/operators';
import Chokidar from 'chokidar';
import { Log } from './log';
export interface Options {
enabled: boolean;
log: Log;
paths: string[];
ignore: Array<string | RegExp>;
cwd: string;
}
export class Watcher {
public readonly enabled: boolean;
private readonly log: Log;
private readonly paths: string[];
private readonly ignore: Array<string | RegExp>;
private readonly cwd: string;
private readonly restart$ = new Rx.Subject<void>();
constructor(options: Options) {
this.enabled = !!options.enabled;
this.log = options.log;
this.paths = options.paths;
this.ignore = options.ignore;
this.cwd = options.cwd;
}
run$ = new Rx.Observable((subscriber) => {
if (!this.enabled) {
this.restart$.complete();
subscriber.complete();
return;
}
const chokidar = Chokidar.watch(this.paths, {
cwd: this.cwd,
ignored: this.ignore,
});
subscriber.add(() => {
chokidar.close();
});
const error$ = Rx.fromEvent(chokidar, 'error').pipe(
map((error) => {
throw error;
})
);
const init$ = Rx.fromEvent(chokidar, 'add').pipe(
takeUntil(Rx.fromEvent(chokidar, 'ready')),
count(),
tap((fileCount) => {
this.log.good('watching for changes', `(${fileCount} files)`);
})
);
const change$ = Rx.fromEvent<[string, string]>(chokidar, 'all').pipe(
map(([, path]) => path),
share()
);
subscriber.add(
Rx.merge(
error$,
Rx.concat(
init$,
change$.pipe(
buffer(change$.pipe(debounceTime(50))),
map((changes) => {
const paths = Array.from(new Set(changes));
const prefix = paths.length > 1 ? '\n - ' : ' ';
const fileList = paths.reduce((list, file) => `${list || ''}${prefix}"${file}"`, '');
this.log.warn(`restarting server`, `due to changes in${fileList}`);
this.restart$.next();
})
)
)
)
.pipe(ignoreElements())
.subscribe(subscriber)
);
});
serverShouldRestart$() {
return this.restart$.asObservable();
}
}

View file

@ -122,7 +122,7 @@ export default class KbnServer {
if (process.env.isDevCliChild) {
// help parent process know when we are ready
process.send(['WORKER_LISTENING']);
process.send(['SERVER_LISTENING']);
}
server.log(

View file

@ -7178,6 +7178,11 @@ argparse@^1.0.7, argparse@~1.0.9:
dependencies:
sprintf-js "~1.0.2"
argsplit@^1.0.5:
version "1.0.5"
resolved "https://registry.yarnpkg.com/argsplit/-/argsplit-1.0.5.tgz#9319a6ef63411716cfeb216c45ec1d13b35c5e99"
integrity sha1-kxmm72NBFxbP6yFsRewdE7NcXpk=
aria-hidden@^1.1.1:
version "1.1.1"
resolved "https://registry.yarnpkg.com/aria-hidden/-/aria-hidden-1.1.1.tgz#0c356026d3f65e2bd487a3adb73f0c586be2c37e"