[optimizer] extract plugin discovery (#14745)

* [plugins] extract plugin discover from the kibana server

* integrate plugin discovery module with server

* [pluginDiscovery] fully extend config before checking enabled status

* [pluginDiscovery] limit arbitrary defaults in PluginSpec

* [ui/navLink] fix tests

* [ui/injectedVars] fix tests

* [ui/app] fix tests

* [server/plugins] convert init to callPluginHook tests

* [build/verifyTranslations] update verify logic

* [pluginDiscovery] remove rx utils

* fix i18n transaltion key name

* [pluginDiscovery] do kibana version checks as a part of discovery

* [pluginDiscovery/createPacksInDirectory$] clarify error handling

* [eslint] fix lint errors

* [uiApp/modules] ensure load order matches master

* [uiBundle] use known uiExport type for providers

* [uiExports] use the `home` export type

* [uiExports] validate that all uiExport types are known

* [timelion] remove archaic/broken bwc check

* revert some stragler changes

* [pluginSpecs] reformat comments

* [uiBundle] rebel and use more fcb 😬

* correct comment

* [server/waitForPluginsInit] describe queues var

* [server/plugins] prevent multiple calls to next() by using single then()

* [uiApp] remove archaic deprecation warning

* [uiApp] tighten up tests

* [pluginDiscovery/errors] remove $ from symbol var

* [pluginDiscovery/reduceExportSpecs] update docs

* [pluginDiscovery/findPluginSpecs] rightVersion -> isRightVersion

* [pluginDiscovery/findPluginSpecs] fix typos

* [uiApps/getById] use Map() rather than memoize

* save

* [savedObjects/mappings] use uiExports.savedObjectMappings

* [server/mapping/indexMapping] update tests, addRootProperties method removed

* [uiExports] "embeddableHandlers" -> "embeddableFactories"

* [pluginDiscovery] fix pluralization of invalidVersionSpec$

* [pluginDiscover] add README

* [pluginDiscovery/reduceExportSpecs] don't ignore fasly spec values, just undefined

* [ui/exportTypes] use better reducer names

* [ui/uiExports] add README

* fix links

* [pluginDiscovery/readme] expand examples

* [pluginDiscovery/readme] clean up reduceExportSpecs() doc

* [ui/uiExports/readme] cleanup example

* [pluginDiscovery] remove needless use of lodash

* [pluginDiscovery/waitForComplete] use better name

* [pluginDiscovery/findPluginSpecs] use fixtures rather than core_plugins

* [pluginDiscovery/stubSchema] use deafult: false

* [plguinDiscovery/pluginConfig] add tests

* typo

* [uiExports/readme] fix link

* [pluginDiscovery/packAtPath] fail with InvalidPackError if path is not a string

* [pluginDiscovery/packAtPath] rely on error.code to detect missing package.json file

* [pluginDiscovery/packAtPath] only attempt to get pack when observable is subscribed

* [pluginDiscovery/packAtPath] add tests

* [pluginDiscovery/pluginPack] move absolute path checks into fs lib

* [pluginDiscovery/packsInDirectory] fix error type check

* [pluginDiscovery/pluginPack/tests] share some utils

* [pluginDiscovery/packsInDirectory] add tests

* [pluginDiscovery/pluginPack] only cast undefined to array

* [pluginDiscovery/pluginPack] add tests

* [pluginDiscovery/pluginSpec/isVersionCompatible] add tests

* [pluginDiscovery/InvalidPluginError] be less redundant

* [pluginDiscovery/pluginSpec] verify config service is passed to isEnabled()

* [pluginDiscovery/pluginSpec] add tests

* fix "existent" spelling
This commit is contained in:
Spencer 2017-12-05 18:11:50 -07:00 committed by GitHub
parent f71ec29bd6
commit c65da14d7c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
171 changed files with 4641 additions and 2212 deletions

View file

@ -6,7 +6,7 @@ import { map as promiseMap, fromNode } from 'bluebird';
import { Agent as HttpsAgent } from 'https';
import { readFileSync } from 'fs';
import Config from '../../server/config/config';
import { Config } from '../../server/config/config';
import setupConnection from '../../server/http/setup_connection';
import registerHapiPlugins from '../../server/http/register_hapi_plugins';
import setupLogging from '../../server/logging';

View file

@ -31,6 +31,14 @@ export default function (kibana) {
id: 'console',
require: [ 'elasticsearch' ],
isEnabled(config) {
// console must be disabled when tribe mode is configured
return (
config.get('console.enabled') &&
!config.get('elasticsearch.tribe.url')
);
},
config: function (Joi) {
return Joi.object({
enabled: Joi.boolean().default(true),
@ -115,7 +123,7 @@ export default function (kibana) {
}
});
const testApp = kibana.uiExports.apps.hidden.byId['sense-tests'];
const testApp = server.getHiddenUiAppById('sense-tests');
if (testApp) {
server.route({
path: '/app/sense-tests',

View file

@ -1,6 +1,14 @@
export default (kibana) => {
if (!kibana.config.get('env.dev')) return;
return new kibana.Plugin({
id: 'dev_mode',
isEnabled(config) {
return (
config.get('env.dev') &&
config.get('dev_mode.enabled')
);
},
uiExports: {
spyModes: [
'plugins/dev_mode/vis_debug_spy_panel'

View file

@ -17,7 +17,7 @@ export default async function manageUuid(server) {
return result.toString(FILE_ENCODING);
} catch (err) {
if (err.code === 'ENOENT') {
// non-existant uuid file is ok
// non-existent uuid file is ok
return false;
}
server.log(['error', 'read-uuid'], err);

View file

@ -6,7 +6,7 @@ export default function (kibana) {
title: 'Redirecting',
id: 'stateSessionStorageRedirect',
main: 'plugins/state_session_storage_redirect',
listed: false,
hidden: true,
}
}
});

View file

@ -1,7 +1,10 @@
import { union } from 'lodash';
import findSourceFiles from './find_source_files';
import { fromRoot } from '../../utils';
import findSourceFiles from './find_source_files';
import { createTestEntryTemplate } from './tests_entry_template';
export default (kibana) => {
return new kibana.Plugin({
config: (Joi) => {
@ -13,9 +16,18 @@ export default (kibana) => {
},
uiExports: {
bundle: async (UiBundle, env, apps, plugins) => {
async __bundleProvider__(kbnServer) {
let modules = [];
const config = kibana.config;
const {
config,
uiApps,
uiBundles,
plugins,
uiExports: {
uiSettingDefaults = {}
}
} = kbnServer;
const testGlobs = [
'src/ui/public/**/*.js',
@ -26,20 +38,25 @@ export default (kibana) => {
if (testingPluginIds) {
testGlobs.push('!src/ui/public/**/__tests__/**/*');
testingPluginIds.split(',').forEach((pluginId) => {
const plugin = plugins.byId[pluginId];
if (!plugin) throw new Error('Invalid testingPluginId :: unknown plugin ' + pluginId);
const plugin = plugins
.find(plugin => plugin.id === pluginId);
if (!plugin) {
throw new Error('Invalid testingPluginId :: unknown plugin ' + pluginId);
}
// add the modules from all of this plugins apps
for (const app of plugin.apps) {
modules = union(modules, app.getModules());
for (const app of uiApps) {
if (app.getPluginId() === pluginId) {
modules = union(modules, app.getModules());
}
}
testGlobs.push(`${plugin.publicDir}/**/__tests__/**/*.js`);
});
} else {
// add the modules from all of the apps
for (const app of apps) {
for (const app of uiApps) {
modules = union(modules, app.getModules());
}
@ -52,24 +69,17 @@ export default (kibana) => {
for (const f of testFiles) modules.push(f);
if (config.get('tests_bundle.instrument')) {
env.addPostLoader({
uiBundles.addPostLoader({
test: /\.js$/,
exclude: /[\/\\](__tests__|node_modules|bower_components|webpackShims)[\/\\]/,
loader: 'istanbul-instrumenter'
loader: 'istanbul-instrumenter-loader'
});
}
env.defaultUiSettings = plugins.kbnServer.uiExports.consumers
// find the first uiExportsConsumer that has a getUiSettingDefaults method
// See src/ui/ui_settings/ui_exports_consumer.js
.find(consumer => typeof consumer.getUiSettingDefaults === 'function')
.getUiSettingDefaults();
return new UiBundle({
uiBundles.add({
id: 'tests',
modules: modules,
template: require('./tests_entry_template'),
env: env
modules,
template: createTestEntryTemplate(uiSettingDefaults),
});
},

View file

@ -1,24 +1,12 @@
import { esTestConfig } from '../../test_utils/es';
export default function ({ env, bundle }) {
const pluginSlug = env.pluginInfo.sort()
.map(p => ' * - ' + p)
.join('\n');
const requires = bundle.modules
.map(m => `require(${JSON.stringify(m)});`)
.join('\n');
return `
export const createTestEntryTemplate = (defaultUiSettings) => (bundle) => `
/**
* Test entry file
*
* This is programatically created and updated, do not modify
*
* context: ${JSON.stringify(env.context)}
* includes code from:
${pluginSlug}
* context: ${bundle.getContext()}
*
*/
@ -47,14 +35,12 @@ window.__KBN__ = {
}
},
uiSettings: {
defaults: ${JSON.stringify(env.defaultUiSettings, null, 2).split('\n').join('\n ')},
defaults: ${JSON.stringify(defaultUiSettings, null, 2).split('\n').join('\n ')},
user: {}
}
};
require('ui/test_harness');
${requires}
${bundle.getRequires().join('\n')}
require('ui/test_harness').bootstrap(/* go! */);
`;
}

View file

@ -1,15 +1,4 @@
export default function (kibana) {
let mainFile = 'plugins/timelion/app';
const ownDescriptor = Object.getOwnPropertyDescriptor(kibana, 'autoload');
const protoDescriptor = Object.getOwnPropertyDescriptor(kibana.constructor.prototype, 'autoload');
const descriptor = ownDescriptor || protoDescriptor || {};
if (descriptor.get) {
// the autoload list has been replaced with a getter that complains about
// improper access, bypass that getter by seeing if it is defined
mainFile = 'plugins/timelion/app_with_autoload';
}
return new kibana.Plugin({
require: ['kibana', 'elasticsearch'],
uiExports: {
@ -18,7 +7,7 @@ export default function (kibana) {
order: -1000,
description: 'Time series expressions for everything',
icon: 'plugins/timelion/icon.svg',
main: mainFile,
main: 'plugins/timelion/app',
injectVars: function (server) {
const config = server.config();
return {

View file

@ -6,6 +6,7 @@ import { SavedObjectRegistryProvider } from 'ui/saved_objects/saved_object_regis
import { notify } from 'ui/notify';
import { timezoneProvider } from 'ui/vis/lib/timezone';
require('ui/autoload/all');
require('plugins/timelion/directives/cells/cells');
require('plugins/timelion/directives/fixed_element');
require('plugins/timelion/directives/fullscreen/fullscreen');

View file

@ -1,2 +0,0 @@
require('ui/autoload/all');
require('./app');

View file

@ -25,8 +25,7 @@ const BABEL_EXCLUDE_RE = [
export default class BaseOptimizer {
constructor(opts) {
this.env = opts.env;
this.bundles = opts.bundles;
this.uiBundles = opts.uiBundles;
this.profile = opts.profile || false;
switch (opts.sourceMaps) {
@ -60,7 +59,7 @@ export default class BaseOptimizer {
this.compiler.plugin('done', stats => {
if (!this.profile) return;
const path = resolve(this.env.workingDir, 'stats.json');
const path = this.uiBundles.resolvePath('stats.json');
const content = JSON.stringify(stats.toJson());
writeFile(path, content, function (err) {
if (err) throw err;
@ -71,7 +70,7 @@ export default class BaseOptimizer {
}
getConfig() {
const cacheDirectory = resolve(this.env.workingDir, '../.cache', this.bundles.hashBundleEntries());
const cacheDirectory = this.uiBundles.getCachePath();
function getStyleLoaders(preProcessors = [], postProcessors = []) {
return ExtractTextPlugin.extract({
@ -105,13 +104,13 @@ export default class BaseOptimizer {
const commonConfig = {
node: { fs: 'empty' },
context: fromRoot('.'),
entry: this.bundles.toWebpackEntries(),
entry: this.uiBundles.toWebpackEntries(),
devtool: this.sourceMaps,
profile: this.profile || false,
output: {
path: this.env.workingDir,
path: this.uiBundles.getWorkingDir(),
filename: '[name].bundle.js',
sourceMapFilename: '[file].map',
publicPath: PUBLIC_PATH_PLACEHOLDER,
@ -168,7 +167,7 @@ export default class BaseOptimizer {
},
{
test: /\.js$/,
exclude: BABEL_EXCLUDE_RE.concat(this.env.noParse),
exclude: BABEL_EXCLUDE_RE.concat(this.uiBundles.getWebpackNoParseRules()),
use: [
{
loader: 'cache-loader',
@ -187,12 +186,12 @@ export default class BaseOptimizer {
},
],
},
...this.env.postLoaders.map(loader => ({
...this.uiBundles.getPostLoaders().map(loader => ({
enforce: 'post',
...loader
})),
],
noParse: this.env.noParse,
noParse: this.uiBundles.getWebpackNoParseRules(),
},
resolve: {
@ -205,12 +204,12 @@ export default class BaseOptimizer {
'node_modules',
fromRoot('node_modules'),
],
alias: this.env.aliases,
alias: this.uiBundles.getAliases(),
unsafeCache: this.unsafeCache,
},
};
if (this.env.context.env === 'development') {
if (this.uiBundles.isDevMode()) {
return webpackMerge(commonConfig, {
// In the test env we need to add react-addons (and a few other bits) for the
// enzyme tests to work.

View file

@ -219,7 +219,7 @@ describe('optimizer/bundle route', () => {
const server = createServer();
const response = await server.inject({
url: '/bundles/non_existant.js'
url: '/bundles/non_existent.js'
});
expect(response.statusCode).to.be(404);

View file

@ -17,19 +17,21 @@ export default async (kbnServer, server, config) => {
return await kbnServer.mixin(require('./lazy/lazy'));
}
const bundles = kbnServer.bundles;
const { uiBundles } = kbnServer;
server.route(createBundlesRoute({
bundlesPath: bundles.env.workingDir,
bundlesPath: uiBundles.getWorkingDir(),
basePublicPath: config.get('server.basePath')
}));
await bundles.writeEntryFiles();
await uiBundles.writeEntryFiles();
// in prod, only bundle when someing is missing or invalid
const invalidBundles = config.get('optimize.useBundleCache') ? await bundles.getInvalidBundles() : bundles;
const reuseCache = config.get('optimize.useBundleCache')
? await uiBundles.areAllBundleCachesValid()
: false;
// we might not have any work to do
if (!invalidBundles.getIds().length) {
if (reuseCache) {
server.log(
['debug', 'optimize'],
`All bundles are cached and ready to go!`
@ -39,8 +41,7 @@ export default async (kbnServer, server, config) => {
// only require the FsOptimizer when we need to
const optimizer = new FsOptimizer({
env: bundles.env,
bundles: bundles,
uiBundles,
profile: config.get('optimize.profile'),
sourceMaps: config.get('optimize.sourceMaps'),
unsafeCache: config.get('optimize.unsafeCache'),
@ -48,12 +49,12 @@ export default async (kbnServer, server, config) => {
server.log(
['info', 'optimize'],
`Optimizing and caching ${bundles.desc()}. This may take a few minutes`
`Optimizing and caching ${uiBundles.getDescription()}. This may take a few minutes`
);
const start = Date.now();
await optimizer.run();
const seconds = ((Date.now() - start) / 1000).toFixed(2);
server.log(['info', 'optimize'], `Optimization of ${bundles.desc()} complete in ${seconds} seconds`);
server.log(['info', 'optimize'], `Optimization of ${uiBundles.getDescription()} complete in ${seconds} seconds`);
};

View file

@ -23,7 +23,7 @@ export default class LazyOptimizer extends BaseOptimizer {
async init() {
this.initializing = true;
await this.bundles.writeEntryFiles();
await this.uiBundles.writeEntryFiles();
await this.initCompiler();
this.compiler.plugin('watch-run', (w, webpackCb) => {
@ -59,8 +59,8 @@ export default class LazyOptimizer extends BaseOptimizer {
this.initializing = false;
this.log(['info', 'optimize'], {
tmpl: `Lazy optimization of ${this.bundles.desc()} ready`,
bundles: this.bundles.getIds()
tmpl: `Lazy optimization of ${this.uiBundles.getDescription()} ready`,
bundles: this.uiBundles.getIds()
});
}
@ -90,14 +90,14 @@ export default class LazyOptimizer extends BaseOptimizer {
logRunStart() {
this.log(['info', 'optimize'], {
tmpl: `Lazy optimization started`,
bundles: this.bundles.getIds()
bundles: this.uiBundles.getIds()
});
}
logRunSuccess() {
this.log(['info', 'optimize'], {
tmpl: 'Lazy optimization <%= status %> in <%= seconds %> seconds',
bundles: this.bundles.getIds(),
bundles: this.uiBundles.getIds(),
status: 'success',
seconds: this.timer.end()
});
@ -110,7 +110,7 @@ export default class LazyOptimizer extends BaseOptimizer {
this.log(['fatal', 'optimize'], {
tmpl: 'Lazy optimization <%= status %> in <%= seconds %> seconds<%= err %>',
bundles: this.bundles.getIds(),
bundles: this.uiBundles.getIds(),
status: 'failed',
seconds: this.timer.end(),
err: err

View file

@ -8,8 +8,7 @@ export default async (kbnServer, kibanaHapiServer, config) => {
config.get('server.basePath'),
new LazyOptimizer({
log: (tags, data) => kibanaHapiServer.log(tags, data),
env: kbnServer.bundles.env,
bundles: kbnServer.bundles,
uiBundles: kbnServer.uiBundles,
profile: config.get('optimize.profile'),
sourceMaps: config.get('optimize.sourceMaps'),
prebuild: config.get('optimize.lazyPrebuild'),

View file

@ -0,0 +1,140 @@
# Plugin Discovery
The plugin discovery module defines the core plugin loading logic used by the Kibana server. It exports functions for
## `findPluginSpecs(settings, [config])`
Finds [`PluginSpec`][PluginSpec] objects
### params
- `settings`: the same settings object accepted by [`KbnServer`][KbnServer]
- `[config]`: Optional - a [`Config`][Config] service. Using this param causes `findPluginSpecs()` to modify `config`'s schema to support the configuration for each discovered [`PluginSpec`][PluginSpec]. If you can, please use the [`Config`][Config] service produced by `extendedConfig$` rather than passing in an existing service so that `findPluginSpecs()` is side-effect free.
### return value
`findPluginSpecs()` returns an object of Observables which produce values at different parts of the process. Since the Observables are all aware of their own dependencies you can subscribe to any combination (within the same tick) and only the necessary plugin logic will be executed.
If you *never* subscribe to any of the Observables then plugin discovery won't actually run.
- `pack$`: emits every [`PluginPack`][PluginPack] found
- `invalidDirectoryError$: Observable<Error>`: emits [`InvalidDirectoryError`][Errors]s caused by `settings.plugins.scanDirs` values that don't point to actual directories. `findPluginSpecs()` will not abort when this error is encountered.
- `invalidPackError$: Observable<Error>`: emits [`InvalidPackError`][Errors]s caused by children of `settings.plugins.scanDirs` or `settings.plugins.paths` values which don't meet the requirements of a [`PluginPack`][PluginPack] (probably missing a `package.json`). `findPluginSpecs()` will not abort when this error is encountered.
- `deprecation$: Observable<string>`: emits deprecation warnings that are produces when reading each [`PluginPack`][PluginPack]'s configuration
- `extendedConfig$: Observable<Config>`: emits the [`Config`][Config] service that was passed to `findPluginSpecs()` (or created internally if none was passed) after it has been extended with the configuration from each plugin
- `spec$: Observable<PluginSpec>`: emits every *enabled* [`PluginSpec`][PluginSpec] defined by the discovered [`PluginPack`][PluginPack]s
- `disabledSpecs$: Observable<PluginSpec>`: emits every *disabled* [`PluginSpec`][PluginSpec] defined by the discovered [`PluginPack`][PluginPack]s
- `invalidVersionSpec$: Observable<PluginSpec>`: emits every [`PluginSpec`][PluginSpec] who's required kibana version does not match the version exposed by `config.get('pkg.version')`
### example
Just get the plugin specs, only fail if there is an uncaught error of some sort:
```js
const { pack$ } = findPluginSpecs(settings);
const packs = await pack$.toArray().toPromise()
```
Just log the deprecation messages:
```js
const { deprecation$ } = findPluginSpecs(settings);
for (const warning of await deprecation$.toArray().toPromise()) {
console.log('DEPRECATION:', warning)
}
```
Get the packs but fail if any packs are invalid:
```js
const { pack$, invalidDirectoryError$ } = findPluginSpecs(settings);
const packs = await Observable.merge(
pack$.toArray(),
// if we ever get an InvalidDirectoryError, throw it
// into the stream so that all streams are unsubscribed,
// the discovery process is aborted, and the promise rejects
invalidDirectoryError$.map(error => {
throw error
}),
).toPromise()
```
Handle everything
```js
const {
pack$,
invalidDirectoryError$,
invalidPackError$,
deprecation$,
extendedConfig$,
spec$,
disabledSpecs$,
invalidVersionSpec$,
} = findPluginSpecs(settings);
Observable.merge(
pack$
.do(pluginPack => console.log('Found plugin pack', pluginPack)),
invalidDirectoryError$
.do(error => console.log('Invalid directory error', error)),
invalidPackError$
.do(error => console.log('Invalid plugin pack error', error)),
deprecation$
.do(msg => console.log('DEPRECATION:', msg)),
extendedConfig$
.do(config => console.log('config service extended by plugins', config)),
spec$
.do(pluginSpec => console.log('enabled plugin spec found', spec)),
disabledSpec$
.do(pluginSpec => console.log('disabled plugin spec found', spec)),
invalidVersionSpec$
.do(pluginSpec => console.log('plugin spec with invalid version found', spec)),
)
.toPromise()
.then(() => {
console.log('plugin discovery complete')
})
.catch((error) => {
console.log('plugin discovery failed', error)
})
```
## `reduceExportSpecs(pluginSpecs, reducers, [defaults={}])`
Reduces every value exported by the [`PluginSpec`][PluginSpec]s to produce a single value. If an exported value is an array each item in the array will be reduced individually. If the exported value is `undefined` it will be ignored. The reducer is called with the signature:
```js
reducer(
// the result of the previous reducer call, or `defaults`
acc: any,
// the exported value, found at `uiExports[type]` or `uiExports[type][i]`
// in the PluginSpec config.
spec: any,
// the key in `uiExports` where this export was found
type: string,
// the PluginSpec which exported this spec
pluginSpec: PluginSpec
)
```
## `new PluginPack(options)` class
Only exported so that `PluginPack` instances can be created in tests and used in place of on-disk plugin fixtures. Use `findPluginSpecs()`, or the cached result of a call to `findPluginSpecs()` (like `kbnServer.pluginSpecs`) any time you might need access to `PluginPack` objects in distributed code.
### params
- `options.path`: absolute path to where this plugin pack was found, this is normally a direct child of `./src/core_plugins` or `./plugins`
- `options.pkg`: the parsed `package.json` for this pack, used for defaults in `PluginSpec` objects defined by this pack
- `options.provider`: the default export of the pack, a function which is called with the `PluginSpec` class which should return one or more `PluginSpec` objects.
[PluginPack]: ./plugin_pack/plugin_pack.js "PluginPath class definition"
[PluginSpec]: ./plugin_spec/plugin_spec.js "PluginSpec class definition"
[Errors]: ./errors.js "PluginDiscover specific error types"
[KbnServer]: ../server/kbn_server.js "KbnServer class definition"
[Config]: ../server/config/config.js "KbnServer/Config class definition"

View file

@ -0,0 +1,74 @@
import { resolve } from 'path';
import expect from 'expect.js';
import { findPluginSpecs } from '../find_plugin_specs';
import { PluginSpec } from '../plugin_spec';
const PLUGIN_FIXTURES = resolve(__dirname, 'fixtures/plugins');
describe('plugin discovery', () => {
describe('findPluginSpecs()', function () {
this.timeout(10000);
it('finds specs for specified plugin paths', async () => {
const { spec$ } = findPluginSpecs({
plugins: {
paths: [
resolve(PLUGIN_FIXTURES, 'foo'),
resolve(PLUGIN_FIXTURES, 'bar'),
]
}
});
const specs = await spec$.toArray().toPromise();
expect(specs).to.have.length(3);
specs.forEach(spec => {
expect(spec).to.be.a(PluginSpec);
});
expect(specs.map(s => s.getId()).sort())
.to.eql(['bar:one', 'bar:two', 'foo']);
});
it('finds all specs in scanDirs', async () => {
const { spec$ } = findPluginSpecs({
// used to ensure the dev_mode plugin is enabled
env: 'development',
plugins: {
scanDirs: [PLUGIN_FIXTURES]
}
});
const specs = await spec$.toArray().toPromise();
expect(specs).to.have.length(3);
specs.forEach(spec => {
expect(spec).to.be.a(PluginSpec);
});
expect(specs.map(s => s.getId()).sort())
.to.eql(['bar:one', 'bar:two', 'foo']);
});
it('does not find disabled plugins', async () => {
const { spec$ } = findPluginSpecs({
'bar:one': {
enabled: false
},
plugins: {
paths: [
resolve(PLUGIN_FIXTURES, 'foo'),
resolve(PLUGIN_FIXTURES, 'bar')
]
}
});
const specs = await spec$.toArray().toPromise();
expect(specs).to.have.length(2);
specs.forEach(spec => {
expect(spec).to.be.a(PluginSpec);
});
expect(specs.map(s => s.getId()).sort())
.to.eql(['bar:two', 'foo']);
});
});
});

View file

@ -0,0 +1,10 @@
export default function (kibana) {
return [
new kibana.Plugin({
id: 'bar:one',
}),
new kibana.Plugin({
id: 'bar:two',
}),
];
}

View file

@ -0,0 +1,4 @@
{
"name": "foo",
"version": "kibana"
}

View file

@ -0,0 +1,5 @@
module.exports = function (kibana) {
return new kibana.Plugin({
id: 'foo',
});
};

View file

@ -0,0 +1,4 @@
{
"name": "foo",
"version": "kibana"
}

View file

@ -0,0 +1,63 @@
const errorCodeProperty = Symbol('pluginDiscovery/errorCode');
/**
* Thrown when reading a plugin directory fails, wraps failure
* @type {String}
*/
const ERROR_INVALID_DIRECTORY = 'ERROR_INVALID_DIRECTORY';
export function createInvalidDirectoryError(sourceError, path) {
sourceError[errorCodeProperty] = ERROR_INVALID_DIRECTORY;
sourceError.path = path;
return sourceError;
}
export function isInvalidDirectoryError(error) {
return error && error[errorCodeProperty] === ERROR_INVALID_DIRECTORY;
}
/**
* Thrown when trying to create a PluginPack for a path that
* is not a valid plugin definition
* @type {String}
*/
const ERROR_INVALID_PACK = 'ERROR_INVALID_PACK';
export function createInvalidPackError(path, reason) {
const error = new Error(`PluginPack${path ? ` at "${path}"` : ''} ${reason}`);
error[errorCodeProperty] = ERROR_INVALID_PACK;
error.path = path;
return error;
}
export function isInvalidPackError(error) {
return error && error[errorCodeProperty] === ERROR_INVALID_PACK;
}
/**
* Thrown when trying to load a PluginSpec that is invalid for some reason
* @type {String}
*/
const ERROR_INVALID_PLUGIN = 'ERROR_INVALID_PLUGIN';
export function createInvalidPluginError(spec, reason) {
const error = new Error(`Plugin from ${spec.getId()} at ${spec.getPack().getPath()} is invalid because ${reason}`);
error[errorCodeProperty] = ERROR_INVALID_PLUGIN;
error.spec = spec;
return error;
}
export function isInvalidPluginError(error) {
return error && error[errorCodeProperty] === ERROR_INVALID_PLUGIN;
}
/**
* Thrown when trying to load a PluginSpec whose version is incompatible
* @type {String}
*/
const ERROR_INCOMPATIBLE_PLUGIN_VERSION = 'ERROR_INCOMPATIBLE_PLUGIN_VERSION';
export function createIncompatiblePluginVersionError(spec) {
const error = new Error(`Plugin ${spec.getId()} is only compatible with Kibana version ${spec.getExpectedKibanaVersion()}`);
error[errorCodeProperty] = ERROR_INCOMPATIBLE_PLUGIN_VERSION;
error.spec = spec;
return error;
}
export function isIncompatiblePluginVersionError(error) {
return error && error[errorCodeProperty] === ERROR_INCOMPATIBLE_PLUGIN_VERSION;
}

View file

@ -0,0 +1,134 @@
import { Observable } from 'rxjs';
import { transformDeprecations, Config } from '../server/config';
import {
extendConfigService,
disableConfigExtension,
} from './plugin_config';
import {
createPackAtPath$,
createPacksInDirectory$,
} from './plugin_pack';
import {
isInvalidDirectoryError,
isInvalidPackError,
} from './errors';
function defaultConfig(settings) {
return Config.withDefaultSchema(
transformDeprecations(settings)
);
}
function bufferAllResults(observable) {
return observable
// buffer all results into a single array
.toArray()
// merge the array back into the stream when complete
.mergeMap(array => array);
}
/**
* Creates a collection of observables for discovering pluginSpecs
* using Kibana's defaults, settings, and config service
*
* @param {Object} settings
* @param {ConfigService} [config] when supplied **it is mutated** to include
* the config from discovered plugin specs
* @return {Object<name,Rx>}
*/
export function findPluginSpecs(settings, config = defaultConfig(settings)) {
// find plugin packs in configured paths/dirs
const find$ = Observable.merge(
...config.get('plugins.paths').map(createPackAtPath$),
...config.get('plugins.scanDirs').map(createPacksInDirectory$)
)
.share();
const extendConfig$ = find$
// get the specs for each found plugin pack
.mergeMap(({ pack }) => (
pack ? pack.getPluginSpecs() : []
))
.mergeMap(async (spec) => {
// extend the config service with this plugin spec and
// collect its deprecations messages if some of its
// settings are outdated
const deprecations = [];
await extendConfigService(spec, config, settings, (message) => {
deprecations.push({ spec, message });
});
return {
spec,
deprecations,
};
})
// extend the config with all plugins before determining enabled status
.let(bufferAllResults)
.map(({ spec, deprecations }) => {
const isRightVersion = spec.isVersionCompatible(config.get('pkg.version'));
const enabled = isRightVersion && spec.isEnabled(config);
return {
spec,
deprecations,
enabledSpecs: enabled ? [spec] : [],
disabledSpecs: enabled ? [] : [spec],
invalidVersionSpecs: isRightVersion ? [] : [spec],
};
})
// determine which plugins are disabled before actually removing things from the config
.let(bufferAllResults)
.do(result => {
for (const spec of result.disabledSpecs) {
disableConfigExtension(spec, config);
}
})
.share();
return {
// plugin packs found when searching configured paths
pack$: find$
.mergeMap(result => (
result.pack ? [result.pack] : []
)),
// errors caused by invalid directories of plugin directories
invalidDirectoryError$: find$
.mergeMap(result => (
isInvalidDirectoryError(result.error) ? [result.error] : []
)),
// errors caused by directories that we expected to be plugin but were invalid
invalidPackError$: find$
.mergeMap(result => (
isInvalidPackError(result.error) ? [result.error] : []
)),
// { spec, message } objects produced when transforming deprecated
// settings for a plugin spec
deprecation$: extendConfig$
.mergeMap(result => result.deprecations),
// the config service we extended with all of the plugin specs,
// only emitted once it is fully extended by all
extendedConfig$: extendConfig$
.ignoreElements()
.concat([config]),
// all enabled PluginSpec objects
spec$: extendConfig$
.mergeMap(result => result.enabledSpecs),
// all disabled PluginSpec objects
disabledSpecs$: extendConfig$
.mergeMap(result => result.disabledSpecs),
// all PluginSpec objects that were disabled because their version was incompatible
invalidVersionSpec$: extendConfig$
.mergeMap(result => result.invalidVersionSpecs),
};
}

View file

@ -0,0 +1,3 @@
export { findPluginSpecs } from './find_plugin_specs';
export { reduceExportSpecs } from './plugin_exports';
export { PluginPack } from './plugin_pack';

View file

@ -0,0 +1,177 @@
import sinon from 'sinon';
import expect from 'expect.js';
import { Config } from '../../../server/config';
import { PluginPack } from '../../plugin_pack';
import { extendConfigService, disableConfigExtension } from '../extend_config_service';
import * as SchemaNS from '../schema';
import * as SettingsNS from '../settings';
describe('plugin discovery/extend config service', () => {
const sandbox = sinon.sandbox.create();
afterEach(() => sandbox.restore());
const pluginSpec = new PluginPack({
path: '/dev/null',
pkg: {
name: 'test',
version: 'kibana',
},
provider: ({ Plugin }) => new Plugin({
configPrefix: 'foo.bar.baz',
config: Joi => Joi.object({
enabled: Joi.boolean().default(true),
test: Joi.string().default('bonk'),
}).default(),
deprecations({ rename }) {
return [
rename('oldTest', 'test'),
];
},
}),
})
.getPluginSpecs()
.pop();
describe('extendConfigService()', () => {
it('calls getSettings, getSchema, and Config.extendSchema() correctly', async () => {
const rootSettings = {
foo: {
bar: {
enabled: false
}
}
};
const schema = {
validate: () => {}
};
const configPrefix = 'foo.bar';
const config = {
extendSchema: sandbox.stub()
};
const pluginSpec = {
getConfigPrefix: sandbox.stub()
.returns(configPrefix)
};
const logDeprecation = sandbox.stub();
const getSettings = sandbox.stub(SettingsNS, 'getSettings')
.returns(rootSettings.foo.bar);
const getSchema = sandbox.stub(SchemaNS, 'getSchema')
.returns(schema);
await extendConfigService(pluginSpec, config, rootSettings, logDeprecation);
sinon.assert.calledOnce(getSettings);
sinon.assert.calledWithExactly(getSettings, pluginSpec, rootSettings, logDeprecation);
sinon.assert.calledOnce(getSchema);
sinon.assert.calledWithExactly(getSchema, pluginSpec);
sinon.assert.calledOnce(config.extendSchema);
sinon.assert.calledWithExactly(config.extendSchema, schema, rootSettings.foo.bar, configPrefix);
});
it('adds the schema for a plugin spec to its config prefix', async () => {
const config = Config.withDefaultSchema();
expect(config.has('foo.bar.baz')).to.be(false);
await extendConfigService(pluginSpec, config);
expect(config.has('foo.bar.baz')).to.be(true);
});
it('initializes it with the default settings', async () => {
const config = Config.withDefaultSchema();
await extendConfigService(pluginSpec, config);
expect(config.get('foo.bar.baz.enabled')).to.be(true);
expect(config.get('foo.bar.baz.test')).to.be('bonk');
});
it('initializes it with values from root settings if defined', async () => {
const config = Config.withDefaultSchema();
await extendConfigService(pluginSpec, config, {
foo: {
bar: {
baz: {
test: 'hello world'
}
}
}
});
expect(config.get('foo.bar.baz.test')).to.be('hello world');
});
it('throws if root settings are invalid', async () => {
const config = Config.withDefaultSchema();
try {
await extendConfigService(pluginSpec, config, {
foo: {
bar: {
baz: {
test: {
'not a string': true
}
}
}
}
});
throw new Error('Expected extendConfigService() to throw because of bad settings');
} catch (error) {
expect(error.message).to.contain('"test" must be a string');
}
});
it('calls logDeprecation() with deprecation messages', async () => {
const config = Config.withDefaultSchema();
const logDeprecation = sinon.stub();
await extendConfigService(pluginSpec, config, {
foo: {
bar: {
baz: {
oldTest: '123'
}
}
}
}, logDeprecation);
sinon.assert.calledOnce(logDeprecation);
sinon.assert.calledWithExactly(logDeprecation, sinon.match('"oldTest" is deprecated'));
});
it('uses settings after transforming deprecations', async () => {
const config = Config.withDefaultSchema();
await extendConfigService(pluginSpec, config, {
foo: {
bar: {
baz: {
oldTest: '123'
}
}
}
});
expect(config.get('foo.bar.baz.test')).to.be('123');
});
});
describe('disableConfigExtension()', () => {
it('removes added config', async () => {
const config = Config.withDefaultSchema();
await extendConfigService(pluginSpec, config);
expect(config.has('foo.bar.baz.test')).to.be(true);
await disableConfigExtension(pluginSpec, config);
expect(config.has('foo.bar.baz.test')).to.be(false);
});
it('leaves {configPrefix}.enabled config', async () => {
const config = Config.withDefaultSchema();
expect(config.has('foo.bar.baz.enabled')).to.be(false);
await extendConfigService(pluginSpec, config);
expect(config.get('foo.bar.baz.enabled')).to.be(true);
await disableConfigExtension(pluginSpec, config);
expect(config.get('foo.bar.baz.enabled')).to.be(false);
});
});
});

View file

@ -0,0 +1,72 @@
import expect from 'expect.js';
import { PluginPack } from '../../plugin_pack';
import { getSchema, getStubSchema } from '../schema';
describe('plugin discovery/schema', () => {
function createPluginSpec(configProvider) {
return new PluginPack({
path: '/dev/null',
pkg: {
name: 'test',
version: 'kibana',
},
provider: ({ Plugin }) => new Plugin({
configPrefix: 'foo.bar.baz',
config: configProvider,
}),
})
.getPluginSpecs()
.pop();
}
describe('getSchema()', () => {
it('calls the config provider and returns its return value', async () => {
const pluginSpec = createPluginSpec(() => 'foo');
expect(await getSchema(pluginSpec)).to.be('foo');
});
it('supports config provider that returns a promise', async () => {
const pluginSpec = createPluginSpec(() => Promise.resolve('foo'));
expect(await getSchema(pluginSpec)).to.be('foo');
});
it('uses default schema when no config provider', async () => {
const schema = await getSchema(createPluginSpec());
expect(schema).to.be.an('object');
expect(schema).to.have.property('validate').a('function');
expect(schema.validate({}).value).to.eql({
enabled: true
});
});
it('uses default schema when config returns falsy value', async () => {
const schema = await getSchema(createPluginSpec(() => null));
expect(schema).to.be.an('object');
expect(schema).to.have.property('validate').a('function');
expect(schema.validate({}).value).to.eql({
enabled: true
});
});
it('uses default schema when config promise resolves to falsy value', async () => {
const schema = await getSchema(createPluginSpec(() => Promise.resolve(null)));
expect(schema).to.be.an('object');
expect(schema).to.have.property('validate').a('function');
expect(schema.validate({}).value).to.eql({
enabled: true
});
});
});
describe('getStubSchema()', () => {
it('returns schema with enabled: false', async () => {
const schema = await getStubSchema();
expect(schema).to.be.an('object');
expect(schema).to.have.property('validate').a('function');
expect(schema.validate({}).value).to.eql({
enabled: false
});
});
});
});

View file

@ -0,0 +1,64 @@
import expect from 'expect.js';
import sinon from 'sinon';
import { PluginPack } from '../../plugin_pack';
import { getSettings } from '../settings';
describe('plugin_discovery/settings', () => {
const pluginSpec = new PluginPack({
path: '/dev/null',
pkg: {
name: 'test',
version: 'kibana',
},
provider: ({ Plugin }) => new Plugin({
configPrefix: 'a.b.c',
deprecations: ({ rename }) => [
rename('foo', 'bar')
]
}),
})
.getPluginSpecs()
.pop();
describe('getSettings()', () => {
it('reads settings from config prefix', async () => {
const rootSettings = {
a: {
b: {
c: {
enabled: false
}
}
}
};
expect(await getSettings(pluginSpec, rootSettings))
.to.eql({
enabled: false
});
});
it('allows rootSettings to be undefined', async () => {
expect(await getSettings(pluginSpec))
.to.eql(undefined);
});
it('resolves deprecations', async () => {
const logDeprecation = sinon.stub();
expect(await getSettings(pluginSpec, {
a: {
b: {
c: {
foo: true
}
}
}
}, logDeprecation)).to.eql({
bar: true
});
sinon.assert.calledOnce(logDeprecation);
});
});
});

View file

@ -0,0 +1,31 @@
import { getSettings } from './settings';
import { getSchema, getStubSchema } from './schema';
/**
* Extend a config service with the schema and settings for a
* plugin spec and optionally call logDeprecation with warning
* messages about deprecated settings that are used
* @param {PluginSpec} spec
* @param {Server.Config} config
* @param {Object} rootSettings
* @param {Function} [logDeprecation]
* @return {Promise<undefined>}
*/
export async function extendConfigService(spec, config, rootSettings, logDeprecation) {
const settings = await getSettings(spec, rootSettings, logDeprecation);
const schema = await getSchema(spec);
config.extendSchema(schema, settings, spec.getConfigPrefix());
}
/**
* Disable the schema and settings applied to a config service for
* a plugin spec
* @param {PluginSpec} spec
* @param {Server.Config} config
* @return {undefined}
*/
export function disableConfigExtension(spec, config) {
const prefix = spec.getConfigPrefix();
config.removeSchema(prefix);
config.extendSchema(getStubSchema(), { enabled: false }, prefix);
}

View file

@ -0,0 +1,4 @@
export {
extendConfigService,
disableConfigExtension,
} from './extend_config_service';

View file

@ -0,0 +1,24 @@
import Joi from 'joi';
const STUB_CONFIG_SCHEMA = Joi.object().keys({
enabled: Joi.valid(false).default(false)
}).default();
const DEFAULT_CONFIG_SCHEMA = Joi.object().keys({
enabled: Joi.boolean().default(true)
}).default();
/**
* Get the config schema for a plugin spec
* @param {PluginSpec} spec
* @return {Promise<Joi>}
*/
export async function getSchema(spec) {
const provider = spec.getConfigSchemaProvider();
return (provider && await provider(Joi)) || DEFAULT_CONFIG_SCHEMA;
}
export function getStubSchema() {
return STUB_CONFIG_SCHEMA;
}

View file

@ -0,0 +1,25 @@
import { get, noop } from 'lodash';
import * as serverConfig from '../../server/config';
import { createTransform, Deprecations } from '../../deprecation';
async function getDeprecationTransformer(spec) {
const provider = spec.getDeprecationsProvider() || noop;
return createTransform(await provider(Deprecations) || []);
}
/**
* Get the settings for a pluginSpec from the raw root settings while
* optionally calling logDeprecation() with warnings about deprecated
* settings that were used
* @param {PluginSpec} spec
* @param {Object} rootSettings
* @param {Function} [logDeprecation]
* @return {Promise<Object>}
*/
export async function getSettings(spec, rootSettings, logDeprecation) {
const prefix = spec.getConfigPrefix();
const transformer = await getDeprecationTransformer(spec);
const rawSettings = get(serverConfig.transformDeprecations(rootSettings), prefix);
return transformer(rawSettings, logDeprecation);
}

View file

@ -0,0 +1,77 @@
import expect from 'expect.js';
import { PluginPack } from '../../plugin_pack';
import { reduceExportSpecs } from '../reduce_export_specs';
const PLUGIN = new PluginPack({
path: __dirname,
pkg: {
name: 'foo',
version: 'kibana'
},
provider: ({ Plugin }) => (
new Plugin({
uiExports: {
concatNames: {
name: 'export1'
},
concat: [
'export2',
'export3',
],
}
})
)
});
const REDUCERS = {
concatNames(acc, spec, type, pluginSpec) {
return {
names: [].concat(
acc.names || [],
`${pluginSpec.getId()}:${spec.name}`,
)
};
},
concat(acc, spec, type, pluginSpec) {
return {
names: [].concat(
acc.names || [],
`${pluginSpec.getId()}:${spec}`,
)
};
},
};
const PLUGIN_SPECS = PLUGIN.getPluginSpecs();
describe('reduceExportSpecs', () => {
it('combines ui exports from a list of plugin definitions', () => {
const exports = reduceExportSpecs(PLUGIN_SPECS, REDUCERS);
expect(exports).to.eql({
names: [
'foo:export1',
'foo:export2',
'foo:export3',
]
});
});
it('starts with the defaults', () => {
const exports = reduceExportSpecs(PLUGIN_SPECS, REDUCERS, {
names: [
'default'
]
});
expect(exports).to.eql({
names: [
'default',
'foo:export1',
'foo:export2',
'foo:export3',
]
});
});
});

View file

@ -0,0 +1 @@
export { reduceExportSpecs } from './reduce_export_specs';

View file

@ -0,0 +1,34 @@
/**
* Combine the exportSpecs from a list of pluginSpecs
* by calling the reducers for each export type
* @param {Array<PluginSpecs>} pluginSpecs
* @param {Object<exportType,reducer>} reducers
* @param {Object<exportType,exports>} [defaults={}]
* @return {Object<exportType,exports>}
*/
export function reduceExportSpecs(pluginSpecs, reducers, defaults = {}) {
return pluginSpecs.reduce((acc, pluginSpec) => {
const specsByType = pluginSpec.getExportSpecs() || {};
const types = Object.keys(specsByType);
return types.reduce((acc, type) => {
const reducer = (reducers[type] || reducers.unknown);
if (!reducer) {
throw new Error(`Unknown export type ${type}`);
}
// convert specs to an array if not already one or
// ignore the spec if it is undefined
const specs = [].concat(
specsByType[type] === undefined
? []
: specsByType[type]
);
return specs.reduce((acc, spec) => (
reducer(acc, spec, type, pluginSpec)
), acc);
}, acc);
}, defaults);
}

View file

@ -0,0 +1,3 @@
{
"name":
}

View file

@ -0,0 +1 @@
export default 1;

View file

@ -0,0 +1,4 @@
{
"name": "foo",
"version": "kibana"
}

View file

@ -0,0 +1,3 @@
export default {
foo: 'bar'
};

View file

@ -0,0 +1,4 @@
{
"name": "foo",
"version": "kibana"
}

View file

@ -0,0 +1 @@
export default 'foo';

View file

@ -0,0 +1,4 @@
{
"name": "foo",
"version": "kibana"
}

View file

@ -0,0 +1,5 @@
module.exports = function (kibana) {
return new kibana.Plugin({
id: 'foo',
});
};

View file

@ -0,0 +1,4 @@
{
"name": "foo",
"version": "kibana"
}

View file

@ -0,0 +1 @@
console.log('hello world');

View file

@ -0,0 +1 @@
export { myLib } from './my_lib';

View file

@ -0,0 +1,3 @@
export function myLib() {
console.log('lib');
}

View file

@ -0,0 +1,14 @@
/* eslint-disable */
'use strict';
Object.defineProperty(exports, "__esModule", {
value: true
});
exports.default = function (_ref) {
var Plugin = _ref.Plugin;
return new Plugin({
id: 'foo'
});
};

View file

@ -0,0 +1,3 @@
{
"name": "prebuilt"
}

View file

@ -0,0 +1,76 @@
import { resolve } from 'path';
import expect from 'expect.js';
import { createPackAtPath$ } from '../pack_at_path';
import { PluginPack } from '../plugin_pack';
import {
PLUGINS_DIR,
assertInvalidPackError,
assertInvalidDirectoryError
} from './utils';
describe('plugin discovery/plugin_pack', () => {
describe('createPackAtPath$()', () => {
it('returns an observable', () => {
expect(createPackAtPath$())
.to.have.property('subscribe').a('function');
});
it('gets the default provider from prebuilt babel modules', async () => {
const results = await createPackAtPath$(resolve(PLUGINS_DIR, 'prebuilt')).toArray().toPromise();
expect(results).to.have.length(1);
expect(results[0]).to.only.have.keys(['pack']);
expect(results[0].pack).to.be.a(PluginPack);
});
describe('errors emitted as { error } results', () => {
async function checkError(path, check) {
const results = await createPackAtPath$(path).toArray().toPromise();
expect(results).to.have.length(1);
expect(results[0]).to.only.have.keys(['error']);
const { error } = results[0];
await check(error);
}
it('undefined path', () => checkError(undefined, error => {
assertInvalidDirectoryError(error);
expect(error.message).to.contain('path must be a string');
}));
it('relative path', () => checkError('plugins/foo', error => {
assertInvalidDirectoryError(error);
expect(error.message).to.contain('path must be absolute');
}));
it('./relative path', () => checkError('./plugins/foo', error => {
assertInvalidDirectoryError(error);
expect(error.message).to.contain('path must be absolute');
}));
it('non-existent path', () => checkError(resolve(PLUGINS_DIR, 'baz'), error => {
assertInvalidPackError(error);
expect(error.message).to.contain('must be a directory');
}));
it('path to a file', () => checkError(resolve(PLUGINS_DIR, 'index.js'), error => {
assertInvalidPackError(error);
expect(error.message).to.contain('must be a directory');
}));
it('directory without a package.json', () => checkError(resolve(PLUGINS_DIR, 'lib'), error => {
assertInvalidPackError(error);
expect(error.message).to.contain('must have a package.json file');
}));
it('directory with an invalid package.json', () => checkError(resolve(PLUGINS_DIR, 'broken'), error => {
assertInvalidPackError(error);
expect(error.message).to.contain('must have a valid package.json file');
}));
it('default export is an object', () => checkError(resolve(PLUGINS_DIR, 'exports_object'), error => {
assertInvalidPackError(error);
expect(error.message).to.contain('must export a function');
}));
it('default export is an number', () => checkError(resolve(PLUGINS_DIR, 'exports_number'), error => {
assertInvalidPackError(error);
expect(error.message).to.contain('must export a function');
}));
it('default export is an string', () => checkError(resolve(PLUGINS_DIR, 'exports_string'), error => {
assertInvalidPackError(error);
expect(error.message).to.contain('must export a function');
}));
});
});
});

View file

@ -0,0 +1,69 @@
import { resolve } from 'path';
import expect from 'expect.js';
import { createPacksInDirectory$ } from '../packs_in_directory';
import { PluginPack } from '../plugin_pack';
import {
PLUGINS_DIR,
assertInvalidDirectoryError,
assertInvalidPackError,
} from './utils';
describe('plugin discovery/packs in directory', () => {
describe('createPacksInDirectory$()', () => {
describe('errors emitted as { error } results', () => {
async function checkError(path, check) {
const results = await createPacksInDirectory$(path).toArray().toPromise();
expect(results).to.have.length(1);
expect(results[0]).to.only.have.keys('error');
const { error } = results[0];
await check(error);
}
it('undefined path', () => checkError(undefined, error => {
assertInvalidDirectoryError(error);
expect(error.message).to.contain('path must be a string');
}));
it('relative path', () => checkError('my/plugins', error => {
assertInvalidDirectoryError(error);
expect(error.message).to.contain('path must be absolute');
}));
it('./relative path', () => checkError('./my/pluginsd', error => {
assertInvalidDirectoryError(error);
expect(error.message).to.contain('path must be absolute');
}));
it('non-existent path', () => checkError(resolve(PLUGINS_DIR, 'notreal'), error => {
assertInvalidDirectoryError(error);
expect(error.message).to.contain('no such file or directory');
}));
it('path to a file', () => checkError(resolve(PLUGINS_DIR, 'index.js'), error => {
assertInvalidDirectoryError(error);
expect(error.message).to.contain('not a directory');
}));
});
it('includes child errors for invalid packs within a valid directory', async () => {
const results = await createPacksInDirectory$(PLUGINS_DIR).toArray().toPromise();
const errors = results
.map(result => result.error)
.filter(Boolean);
const packs = results
.map(result => result.pack)
.filter(Boolean);
errors.forEach(assertInvalidPackError);
packs.forEach(pack => expect(pack).to.be.a(PluginPack));
// there should be one result for each item in PLUGINS_DIR
expect(results).to.have.length(8);
// six of the fixtures are errors of some sorta
expect(errors).to.have.length(6);
// two of them are valid
expect(packs).to.have.length(2);
});
});
});

View file

@ -0,0 +1,110 @@
import expect from 'expect.js';
import sinon from 'sinon';
import { PluginPack } from '../plugin_pack';
import { PluginSpec } from '../../plugin_spec';
describe('plugin discovery/plugin pack', () => {
describe('constructor', () => {
it('requires an object', () => {
expect(() => {
new PluginPack();
}).to.throwError();
});
});
describe('#getPkg()', () => {
it('returns the `pkg` constructor argument', () => {
const pkg = {};
const pack = new PluginPack({ pkg });
expect(pack.getPkg()).to.be(pkg);
});
});
describe('#getPath()', () => {
it('returns the `path` constructor argument', () => {
const path = {};
const pack = new PluginPack({ path });
expect(pack.getPath()).to.be(path);
});
});
describe('#getPluginSpecs()', () => {
it('calls the `provider` constructor argument with an api including a single sub class of PluginSpec', () => {
const provider = sinon.stub();
const pack = new PluginPack({ provider });
sinon.assert.notCalled(provider);
pack.getPluginSpecs();
sinon.assert.calledOnce(provider);
sinon.assert.calledWithExactly(provider, {
Plugin: sinon.match(Class => {
return Class.prototype instanceof PluginSpec;
}, 'Subclass of PluginSpec')
});
});
it('casts undefined return value to array', () => {
const pack = new PluginPack({ provider: () => undefined });
expect(pack.getPluginSpecs()).to.eql([]);
});
it('casts single PluginSpec to an array', () => {
const pack = new PluginPack({
path: '/dev/null',
pkg: { name: 'foo', version: 'kibana' },
provider: ({ Plugin }) => new Plugin({})
});
const specs = pack.getPluginSpecs();
expect(specs).to.be.an('array');
expect(specs).to.have.length(1);
expect(specs[0]).to.be.a(PluginSpec);
});
it('returns an array of PluginSpec', () => {
const pack = new PluginPack({
path: '/dev/null',
pkg: { name: 'foo', version: 'kibana' },
provider: ({ Plugin }) => [
new Plugin({}),
new Plugin({}),
]
});
const specs = pack.getPluginSpecs();
expect(specs).to.be.an('array');
expect(specs).to.have.length(2);
expect(specs[0]).to.be.a(PluginSpec);
expect(specs[1]).to.be.a(PluginSpec);
});
it('throws if non-undefined return value is not an instance of api.Plugin', () => {
let OtherPluginSpecClass;
const otherPack = new PluginPack({
path: '/dev/null',
pkg: { name: 'foo', version: 'kibana' },
provider: (api) => {
OtherPluginSpecClass = api.Plugin;
}
});
// call getPluginSpecs() on other pack to get it's api.Plugin class
otherPack.getPluginSpecs();
const badPacks = [
new PluginPack({ provider: () => false }),
new PluginPack({ provider: () => null }),
new PluginPack({ provider: () => 1 }),
new PluginPack({ provider: () => 'true' }),
new PluginPack({ provider: () => true }),
new PluginPack({ provider: () => new Date() }),
new PluginPack({ provider: () => /foo.*bar/ }),
new PluginPack({ provider: () => function () {} }),
new PluginPack({ provider: () => new OtherPluginSpecClass({}) }),
];
for (const pack of badPacks) {
expect(() => pack.getPluginSpecs()).to.throwError(error => {
expect(error.message).to.contain('unexpected plugin export');
});
}
});
});
});

View file

@ -0,0 +1,18 @@
import { resolve } from 'path';
import { inspect } from 'util';
import { isInvalidPackError, isInvalidDirectoryError } from '../../errors';
export const PLUGINS_DIR = resolve(__dirname, 'fixtures/plugins');
export function assertInvalidDirectoryError(error) {
if (!isInvalidDirectoryError(error)) {
throw new Error(`Expected ${inspect(error)} to be an 'InvalidDirectoryError'`);
}
}
export function assertInvalidPackError(error) {
if (!isInvalidPackError(error)) {
throw new Error(`Expected ${inspect(error)} to be an 'InvalidPackError'`);
}
}

View file

@ -0,0 +1,3 @@
export { PluginPack } from './plugin_pack';
export { createPackAtPath$ } from './pack_at_path';
export { createPacksInDirectory$ } from './packs_in_directory';

View file

@ -0,0 +1,63 @@
import { stat, readdir } from 'fs';
import { resolve, isAbsolute } from 'path';
import { fromNode as fcb } from 'bluebird';
import { Observable } from 'rxjs';
import { createInvalidDirectoryError } from '../../errors';
function assertAbsolutePath(path) {
if (typeof path !== 'string') {
throw createInvalidDirectoryError(new TypeError('path must be a string'), path);
}
if (!isAbsolute(path)) {
throw createInvalidDirectoryError(new TypeError('path must be absolute'), path);
}
}
async function statTest(path, test) {
try {
const stats = await fcb(cb => stat(path, cb));
return Boolean(test(stats));
} catch (error) {
if (error.code !== 'ENOENT') {
throw error;
}
}
return false;
}
/**
* Determine if a path currently points to a directory
* @param {String} path
* @return {Promise<boolean>}
*/
export async function isDirectory(path) {
assertAbsolutePath(path);
return await statTest(path, stat => stat.isDirectory());
}
/**
* Get absolute paths for child directories within a path
* @param {string} path
* @return {Promise<Array<string>>}
*/
export const createChildDirectory$ = (path) => (
Observable
.defer(() => {
assertAbsolutePath(path);
return fcb(cb => readdir(path, cb));
})
.catch(error => {
throw createInvalidDirectoryError(error, path);
})
.mergeAll()
.filter(name => !name.startsWith('.'))
.map(name => resolve(path, name))
.mergeMap(v => (
Observable
.fromPromise(isDirectory(path))
.mergeMap(pass => pass ? [v] : [])
))
);

View file

@ -0,0 +1,4 @@
export {
isDirectory,
createChildDirectory$,
} from './fs';

View file

@ -0,0 +1,41 @@
import { Observable } from 'rxjs';
import { resolve } from 'path';
import { createInvalidPackError } from '../errors';
import { isDirectory } from './lib';
import { PluginPack } from './plugin_pack';
async function createPackAtPath(path) {
if (!await isDirectory(path)) {
throw createInvalidPackError(path, 'must be a directory');
}
let pkg;
try {
pkg = require(resolve(path, 'package.json'));
} catch (error) {
if (error.code === 'MODULE_NOT_FOUND') {
throw createInvalidPackError(path, 'must have a package.json file');
}
}
if (!pkg || typeof pkg !== 'object') {
throw createInvalidPackError(path, 'must have a valid package.json file');
}
let provider = require(path);
if (provider.__esModule) {
provider = provider.default;
}
if (typeof provider !== 'function') {
throw createInvalidPackError(path, 'must export a function');
}
return new PluginPack({ path, pkg, provider });
}
export const createPackAtPath$ = (path) => (
Observable.defer(() => createPackAtPath(path))
.map(pack => ({ pack }))
.catch(error => [{ error }])
);

View file

@ -0,0 +1,32 @@
import { isInvalidDirectoryError } from '../errors';
import { createChildDirectory$ } from './lib';
import { createPackAtPath$ } from './pack_at_path';
/**
* Finds the plugins within a directory. Results are
* an array of objects with either `pack` or `error`
* keys.
*
* - `{ error }` results are provided when the path is not
* a directory, or one of the child directories is not a
* valid plugin pack.
* - `{ pack }` results are for discovered plugins defs
*
* @param {String} path
* @return {Array<{pack}|{error}>}
*/
export const createPacksInDirectory$ = (path) => (
createChildDirectory$(path)
.mergeMap(createPackAtPath$)
.catch(error => {
// this error is produced by createChildDirectory$() when the path
// is invalid, we return them as an error result similar to how
// createPackAtPath$ works when it finds invalid packs in a directory
if (isInvalidDirectoryError(error)) {
return [{ error }];
}
throw error;
})
);

View file

@ -0,0 +1,55 @@
import { inspect } from 'util';
import { PluginSpec } from '../plugin_spec';
export class PluginPack {
constructor({ path, pkg, provider }) {
this._path = path;
this._pkg = pkg;
this._provider = provider;
}
/**
* Get the contents of this plugin pack's package.json file
* @return {Object}
*/
getPkg() {
return this._pkg;
}
/**
* Get the absolute path to this plugin pack on disk
* @return {String}
*/
getPath() {
return this._path;
}
/**
* Invoke the plugin pack's provider to get the list
* of specs defined in this plugin.
* @return {Array<PluginSpec>}
*/
getPluginSpecs() {
const pack = this;
const api = {
Plugin: class ScopedPluginSpec extends PluginSpec {
constructor(options) {
super(pack, options);
}
}
};
const result = this._provider(api);
const specs = [].concat(result === undefined ? [] : result);
// verify that all specs are instances of passed "Plugin" class
specs.forEach(spec => {
if (!(spec instanceof api.Plugin)) {
throw new TypeError('unexpected plugin export ' + inspect(spec));
}
});
return specs;
}
}

View file

@ -0,0 +1,29 @@
import expect from 'expect.js';
import { isVersionCompatible } from '../is_version_compatible';
describe('plugin discovery/plugin spec', () => {
describe('isVersionCompatible()', () => {
const tests = [
['kibana', '6.0.0', true],
['kibana', '6.0.0-rc1', true],
['6.0.0-rc1', '6.0.0', true],
['6.0.0', '6.0.0-rc1', true],
['6.0.0-rc2', '6.0.0-rc1', true],
['6.0.0-rc2', '6.0.0-rc3', true],
['foo', 'bar', false],
['6.0.0', '5.1.4', false],
['5.1.4', '6.0.0', false],
['5.1.4-SNAPSHOT', '6.0.0-rc2-SNAPSHOT', false],
['5.1.4', '6.0.0-rc2-SNAPSHOT', false],
['5.1.4-SNAPSHOT', '6.0.0', false],
['5.1.4-SNAPSHOT', '6.0.0-rc2', false],
];
for (const [plugin, kibana, shouldPass] of tests) {
it(`${shouldPass ? 'should' : `shouldn't`} allow plugin: ${plugin} kibana: ${kibana}`, () => {
expect(isVersionCompatible(plugin, kibana)).to.be(shouldPass);
});
}
});
});

View file

@ -0,0 +1,473 @@
import { resolve } from 'path';
import expect from 'expect.js';
import sinon from 'sinon';
import { PluginPack } from '../../plugin_pack';
import { PluginSpec } from '../plugin_spec';
import * as IsVersionCompatibleNS from '../is_version_compatible';
const fooPack = new PluginPack({
path: '/dev/null',
pkg: { name: 'foo', version: 'kibana' },
});
describe('plugin discovery/plugin spec', () => {
describe('PluginSpec', () => {
describe('validation', () => {
it('throws if missing spec.id AND Pack has no name', () => {
const pack = new PluginPack({ pkg: {} });
expect(() => new PluginSpec(pack, {})).to.throwError(error => {
expect(error.message).to.contain('Unable to determine plugin id');
});
});
it('throws if missing spec.kibanaVersion AND Pack has no version', () => {
const pack = new PluginPack({ pkg: { name: 'foo' } });
expect(() => new PluginSpec(pack, {})).to.throwError(error => {
expect(error.message).to.contain('Unable to determine plugin version');
});
});
it('throws if spec.require is defined, but not an array', () => {
function assert(require) {
expect(() => new PluginSpec(fooPack, { require })).to.throwError(error => {
expect(error.message).to.contain('"plugin.require" must be an array of plugin ids');
});
}
assert(null);
assert('');
assert('kibana');
assert(1);
assert(0);
assert(/a.*b/);
});
it('throws if spec.publicDir is truthy and not a string', () => {
function assert(publicDir) {
expect(() => new PluginSpec(fooPack, { publicDir })).to.throwError(error => {
expect(error.message).to.contain('Path must be a string');
});
}
assert(1);
assert(function () {});
assert([]);
assert(/a.*b/);
});
it('throws if spec.publicDir is not an absolute path', () => {
function assert(publicDir) {
expect(() => new PluginSpec(fooPack, { publicDir })).to.throwError(error => {
expect(error.message).to.contain('plugin.publicDir must be an absolute path');
});
}
assert('relative/path');
assert('./relative/path');
});
it('throws if spec.publicDir basename is not `public`', () => {
function assert(publicDir) {
expect(() => new PluginSpec(fooPack, { publicDir })).to.throwError(error => {
expect(error.message).to.contain('must end with a "public" directory');
});
}
assert('/www');
assert('/www/');
assert('/www/public/my_plugin');
assert('/www/public/my_plugin/');
});
});
describe('#getPack()', () => {
it('returns the pack', () => {
const spec = new PluginSpec(fooPack, {});
expect(spec.getPack()).to.be(fooPack);
});
});
describe('#getPkg()', () => {
it('returns the pkg from the pack', () => {
const spec = new PluginSpec(fooPack, {});
expect(spec.getPkg()).to.be(fooPack.getPkg());
});
});
describe('#getPath()', () => {
it('returns the path from the pack', () => {
const spec = new PluginSpec(fooPack, {});
expect(spec.getPath()).to.be(fooPack.getPath());
});
});
describe('#getId()', () => {
it('uses spec.id', () => {
const spec = new PluginSpec(fooPack, {
id: 'bar'
});
expect(spec.getId()).to.be('bar');
});
it('defaults to pack.pkg.name', () => {
const spec = new PluginSpec(fooPack, {});
expect(spec.getId()).to.be('foo');
});
});
describe('#getVerison()', () => {
it('uses spec.version', () => {
const spec = new PluginSpec(fooPack, {
version: 'bar'
});
expect(spec.getVersion()).to.be('bar');
});
it('defaults to pack.pkg.version', () => {
const spec = new PluginSpec(fooPack, {});
expect(spec.getVersion()).to.be('kibana');
});
});
describe('#isEnabled()', () => {
describe('spec.isEnabled is not defined', () => {
function setup(configPrefix, configGetImpl) {
const spec = new PluginSpec(fooPack, { configPrefix });
const config = {
get: sinon.spy(configGetImpl),
has: sinon.stub()
};
return { spec, config };
}
it('throws if not passed a config service', () => {
const { spec } = setup('a.b.c', () => true);
expect(() => spec.isEnabled()).to.throwError(error => {
expect(error.message).to.contain('must be called with a config service');
});
expect(() => spec.isEnabled(null)).to.throwError(error => {
expect(error.message).to.contain('must be called with a config service');
});
expect(() => spec.isEnabled({ get: () => {} })).to.throwError(error => {
expect(error.message).to.contain('must be called with a config service');
});
});
it('returns true when config.get([...configPrefix, "enabled"]) returns true', () => {
const { spec, config } = setup('d.e.f', () => true);
expect(spec.isEnabled(config)).to.be(true);
sinon.assert.calledOnce(config.get);
sinon.assert.calledWithExactly(config.get, ['d', 'e', 'f', 'enabled']);
});
it('returns false when config.get([...configPrefix, "enabled"]) returns false', () => {
const { spec, config } = setup('g.h.i', () => false);
expect(spec.isEnabled(config)).to.be(false);
sinon.assert.calledOnce(config.get);
sinon.assert.calledWithExactly(config.get, ['g', 'h', 'i', 'enabled']);
});
});
describe('spec.isEnabled is defined', () => {
function setup(isEnabledImpl) {
const isEnabled = sinon.spy(isEnabledImpl);
const spec = new PluginSpec(fooPack, { isEnabled });
const config = {
get: sinon.stub(),
has: sinon.stub()
};
return { isEnabled, spec, config };
}
it('throws if not passed a config service', () => {
const { spec } = setup(() => true);
expect(() => spec.isEnabled()).to.throwError(error => {
expect(error.message).to.contain('must be called with a config service');
});
expect(() => spec.isEnabled(null)).to.throwError(error => {
expect(error.message).to.contain('must be called with a config service');
});
expect(() => spec.isEnabled({ get: () => {} })).to.throwError(error => {
expect(error.message).to.contain('must be called with a config service');
});
});
it('does not check config if spec.isEnabled returns true', () => {
const { spec, isEnabled, config } = setup(() => true);
expect(spec.isEnabled(config)).to.be(true);
sinon.assert.calledOnce(isEnabled);
sinon.assert.notCalled(config.get);
});
it('does not check config if spec.isEnabled returns false', () => {
const { spec, isEnabled, config } = setup(() => false);
expect(spec.isEnabled(config)).to.be(false);
sinon.assert.calledOnce(isEnabled);
sinon.assert.notCalled(config.get);
});
});
});
describe('#getExpectedKibanaVersion()', () => {
describe('has: spec.kibanaVersion,pkg.kibana.version,spec.version,pkg.version', () => {
it('uses spec.kibanaVersion', () => {
const pack = new PluginPack({
path: '/dev/null',
pkg: {
name: 'expkv',
version: '1.0.0',
kibana: {
version: '6.0.0'
}
}
});
const spec = new PluginSpec(pack, {
version: '2.0.0',
kibanaVersion: '5.0.0'
});
expect(spec.getExpectedKibanaVersion()).to.be('5.0.0');
});
});
describe('missing: spec.kibanaVersion, has: pkg.kibana.version,spec.version,pkg.version', () => {
it('uses pkg.kibana.version', () => {
const pack = new PluginPack({
path: '/dev/null',
pkg: {
name: 'expkv',
version: '1.0.0',
kibana: {
version: '6.0.0'
}
}
});
const spec = new PluginSpec(pack, {
version: '2.0.0',
});
expect(spec.getExpectedKibanaVersion()).to.be('6.0.0');
});
});
describe('missing: spec.kibanaVersion,pkg.kibana.version, has: spec.version,pkg.version', () => {
it('uses spec.version', () => {
const pack = new PluginPack({
path: '/dev/null',
pkg: {
name: 'expkv',
version: '1.0.0',
}
});
const spec = new PluginSpec(pack, {
version: '2.0.0',
});
expect(spec.getExpectedKibanaVersion()).to.be('2.0.0');
});
});
describe('missing: spec.kibanaVersion,pkg.kibana.version,spec.version, has: pkg.version', () => {
it('uses pkg.version', () => {
const pack = new PluginPack({
path: '/dev/null',
pkg: {
name: 'expkv',
version: '1.0.0',
}
});
const spec = new PluginSpec(pack, {});
expect(spec.getExpectedKibanaVersion()).to.be('1.0.0');
});
});
});
describe('#isVersionCompatible()', () => {
it('passes this.getExpectedKibanaVersion() and arg to isVersionCompatible(), returns its result', () => {
const spec = new PluginSpec(fooPack, { version: '1.0.0' });
sinon.stub(spec, 'getExpectedKibanaVersion').returns('foo');
const isVersionCompatible = sinon.stub(IsVersionCompatibleNS, 'isVersionCompatible').returns('bar');
expect(spec.isVersionCompatible('baz')).to.be('bar');
sinon.assert.calledOnce(spec.getExpectedKibanaVersion);
sinon.assert.calledWithExactly(spec.getExpectedKibanaVersion);
sinon.assert.calledOnce(isVersionCompatible);
sinon.assert.calledWithExactly(isVersionCompatible, 'foo', 'baz');
});
});
describe('#getRequiredPluginIds()', () => {
it('returns spec.require', () => {
const spec = new PluginSpec(fooPack, { require: [1, 2, 3] });
expect(spec.getRequiredPluginIds()).to.eql([1, 2, 3]);
});
});
describe('#getPublicDir()', () => {
describe('spec.publicDir === false', () => {
it('returns null', () => {
const spec = new PluginSpec(fooPack, { publicDir: false });
expect(spec.getPublicDir()).to.be(null);
});
});
describe('spec.publicDir is falsy', () => {
it('returns public child of pack path', () => {
function assert(publicDir) {
const spec = new PluginSpec(fooPack, { publicDir });
expect(spec.getPublicDir()).to.be(resolve('/dev/null/public'));
}
assert(0);
assert('');
assert(null);
assert(undefined);
assert(NaN);
});
});
describe('spec.publicDir is an absolute path', () => {
it('returns the path', () => {
const spec = new PluginSpec(fooPack, {
publicDir: '/var/www/public'
});
expect(spec.getPublicDir()).to.be('/var/www/public');
});
});
// NOTE: see constructor tests for other truthy-tests that throw in constructor
});
describe('#getExportSpecs()', () => {
it('returns spec.uiExports', () => {
const spec = new PluginSpec(fooPack, {
uiExports: 'foo'
});
expect(spec.getExportSpecs()).to.be('foo');
});
});
describe('#getPreInitHandler()', () => {
it('returns spec.preInit', () => {
const spec = new PluginSpec(fooPack, {
preInit: 'foo'
});
expect(spec.getPreInitHandler()).to.be('foo');
});
});
describe('#getInitHandler()', () => {
it('returns spec.init', () => {
const spec = new PluginSpec(fooPack, {
init: 'foo'
});
expect(spec.getInitHandler()).to.be('foo');
});
});
describe('#getConfigPrefix()', () => {
describe('spec.configPrefix is truthy', () => {
it('returns spec.configPrefix', () => {
const spec = new PluginSpec(fooPack, {
configPrefix: 'foo.bar.baz'
});
expect(spec.getConfigPrefix()).to.be('foo.bar.baz');
});
});
describe('spec.configPrefix is falsy', () => {
it('returns spec.getId()', () => {
function assert(configPrefix) {
const spec = new PluginSpec(fooPack, { configPrefix });
sinon.stub(spec, 'getId').returns('foo');
expect(spec.getConfigPrefix()).to.be('foo');
sinon.assert.calledOnce(spec.getId);
}
assert(false);
assert(null);
assert(undefined);
assert('');
assert(0);
});
});
});
describe('#getConfigSchemaProvider()', () => {
it('returns spec.config', () => {
const spec = new PluginSpec(fooPack, {
config: 'foo'
});
expect(spec.getConfigSchemaProvider()).to.be('foo');
});
});
describe('#readConfigValue()', () => {
const spec = new PluginSpec(fooPack, {
configPrefix: 'foo.bar'
});
const config = {
get: sinon.stub()
};
afterEach(() => config.get.reset());
describe('key = "foo"', () => {
it('passes key as own array item', () => {
spec.readConfigValue(config, 'foo');
sinon.assert.calledOnce(config.get);
sinon.assert.calledWithExactly(config.get, ['foo', 'bar', 'foo']);
});
});
describe('key = "foo.bar"', () => {
it('passes key as two array items', () => {
spec.readConfigValue(config, 'foo.bar');
sinon.assert.calledOnce(config.get);
sinon.assert.calledWithExactly(config.get, ['foo', 'bar', 'foo', 'bar']);
});
});
describe('key = ["foo", "bar"]', () => {
it('merged keys into array', () => {
spec.readConfigValue(config, ['foo', 'bar']);
sinon.assert.calledOnce(config.get);
sinon.assert.calledWithExactly(config.get, ['foo', 'bar', 'foo', 'bar']);
});
});
});
describe('#getDeprecationsProvider()', () => {
it('returns spec.deprecations', () => {
const spec = new PluginSpec(fooPack, {
deprecations: 'foo'
});
expect(spec.getDeprecationsProvider()).to.be('foo');
});
});
});
});

View file

@ -0,0 +1 @@
export { PluginSpec } from './plugin_spec';

View file

@ -0,0 +1,17 @@
import {
cleanVersion,
versionSatisfies
} from '../../utils/version';
export function isVersionCompatible(version, compatibleWith) {
// the special "kibana" version can be used to always be compatible,
// but is intentionally not supported by the plugin installer
if (version === 'kibana') {
return true;
}
return versionSatisfies(
cleanVersion(version),
cleanVersion(compatibleWith)
);
}

View file

@ -0,0 +1,175 @@
import { resolve, basename, isAbsolute as isAbsolutePath } from 'path';
import toPath from 'lodash/internal/toPath';
import { get } from 'lodash';
import { createInvalidPluginError } from '../errors';
import { isVersionCompatible } from './is_version_compatible';
export class PluginSpec {
/**
* @param {PluginPack} pack The plugin pack that produced this spec
* @param {Object} opts the options for this plugin
* @param {String} [opts.id=pkg.name] the id for this plugin.
* @param {Object} [opts.uiExports] a mapping of UiExport types to
* UI modules or metadata about the UI module
* @param {Array} [opts.require] the other plugins that this plugin
* requires. These plugins must exist and be enabled for this plugin
* to function. The require'd plugins will also be initialized first,
* in order to make sure that dependencies provided by these plugins
* are available
* @param {String} [opts.version=pkg.version] the version of this plugin
* @param {Function} [opts.init] A function that will be called to initialize
* this plugin at the appropriate time.
* @param {Function} [opts.configPrefix=this.id] The prefix to use for
* configuration values in the main configuration service
* @param {Function} [opts.config] A function that produces a configuration
* schema using Joi, which is passed as its first argument.
* @param {String|False} [opts.publicDir=path + '/public'] the public
* directory for this plugin. The final directory must have the name "public",
* though it can be located somewhere besides the root of the plugin. Set
* this to false to disable exposure of a public directory
*/
constructor(pack, options) {
const {
id,
require,
version,
kibanaVersion,
uiExports,
publicDir,
configPrefix,
config,
deprecations,
preInit,
init,
isEnabled,
} = options;
this._id = id;
this._pack = pack;
this._version = version;
this._kibanaVersion = kibanaVersion;
this._require = require;
this._publicDir = publicDir;
this._uiExports = uiExports;
this._configPrefix = configPrefix;
this._configSchemaProvider = config;
this._configDeprecationsProvider = deprecations;
this._isEnabled = isEnabled;
this._preInit = preInit;
this._init = init;
if (!this.getId()) {
throw createInvalidPluginError(this, 'Unable to determine plugin id');
}
if (!this.getVersion()) {
throw createInvalidPluginError(this, 'Unable to determine plugin version');
}
if (this.getRequiredPluginIds() !== undefined && !Array.isArray(this.getRequiredPluginIds())) {
throw createInvalidPluginError(this, '"plugin.require" must be an array of plugin ids');
}
if (this._publicDir) {
if (!isAbsolutePath(this._publicDir)) {
throw createInvalidPluginError(this, 'plugin.publicDir must be an absolute path');
}
if (basename(this._publicDir) !== 'public') {
throw createInvalidPluginError(this, `publicDir for plugin ${this.getId()} must end with a "public" directory.`);
}
}
}
getPack() {
return this._pack;
}
getPkg() {
return this._pack.getPkg();
}
getPath() {
return this._pack.getPath();
}
getId() {
return this._id || this.getPkg().name;
}
getVersion() {
return this._version || this.getPkg().version;
}
isEnabled(config) {
if (!config || typeof config.get !== 'function' || typeof config.has !== 'function') {
throw new TypeError('PluginSpec#isEnabled() must be called with a config service');
}
if (this._isEnabled) {
return this._isEnabled(config);
}
return Boolean(this.readConfigValue(config, 'enabled'));
}
getExpectedKibanaVersion() {
// Plugins must specify their version, and by default that version should match
// the version of kibana down to the patch level. If these two versions need
// to diverge, they can specify a kibana.version in the package to indicate the
// version of kibana the plugin is intended to work with.
return this._kibanaVersion || get(this.getPack().getPkg(), 'kibana.version') || this.getVersion();
}
isVersionCompatible(actualKibanaVersion) {
return isVersionCompatible(this.getExpectedKibanaVersion(), actualKibanaVersion);
}
getRequiredPluginIds() {
return this._require;
}
getPublicDir() {
if (this._publicDir === false) {
return null;
}
if (!this._publicDir) {
return resolve(this.getPack().getPath(), 'public');
}
return this._publicDir;
}
getExportSpecs() {
return this._uiExports;
}
getPreInitHandler() {
return this._preInit;
}
getInitHandler() {
return this._init;
}
getConfigPrefix() {
return this._configPrefix || this.getId();
}
getConfigSchemaProvider() {
return this._configSchemaProvider;
}
readConfigValue(config, key) {
return config.get([...toPath(this.getConfigPrefix()), ...toPath(key)]);
}
getDeprecationsProvider() {
return this._configDeprecationsProvider;
}
}

View file

@ -1,4 +1,4 @@
import Config from '../config';
import { Config } from '../config';
import expect from 'expect.js';
import _ from 'lodash';
import Joi from 'joi';

View file

@ -8,7 +8,7 @@ const schema = Symbol('Joi Schema');
const schemaExts = Symbol('Schema Extensions');
const vals = Symbol('config values');
export default class Config {
export class Config {
static withDefaultSchema(settings = {}) {
return new Config(createDefaultSchema(), settings);
}

View file

@ -0,0 +1,2 @@
export { transformDeprecations } from './transform_deprecations';
export { Config } from './config';

View file

@ -1,4 +1,4 @@
import Config from './config';
import { Config } from './config';
import { transformDeprecations } from './transform_deprecations';
export default function (kbnServer) {

View file

@ -136,7 +136,7 @@ export default async function (kbnServer, server, config) {
return;
}
const app = kbnServer.uiExports.apps.byId.stateSessionStorageRedirect;
const app = server.getHiddenUiAppById('stateSessionStorageRedirect');
reply.renderApp(app, {
redirectUrl: url,
});

View file

@ -1,28 +1,24 @@
import { constant, once, compact, flatten } from 'lodash';
import { resolve, fromNode } from 'bluebird';
import { fromNode } from 'bluebird';
import { isWorker } from 'cluster';
import { fromRoot, pkg } from '../utils';
import Config from './config/config';
import { Config } from './config';
import loggingConfiguration from './logging/configuration';
import configSetupMixin from './config/setup';
import httpMixin from './http';
import loggingMixin from './logging';
import warningsMixin from './warnings';
import statusMixin from './status';
import pidMixin from './pid';
import pluginsScanMixin from './plugins/scan';
import pluginsCheckEnabledMixin from './plugins/check_enabled';
import pluginsCheckVersionMixin from './plugins/check_version';
import configCompleteMixin from './config/complete';
import uiMixin from '../ui';
import optimizeMixin from '../optimize';
import pluginsInitializeMixin from './plugins/initialize';
import * as Plugins from './plugins';
import { indexPatternsMixin } from './index_patterns';
import { savedObjectsMixin } from './saved_objects';
import { statsMixin } from './stats';
import { kibanaIndexMappingsMixin } from './mappings';
import { serverExtensionsMixin } from './server_extensions';
import { uiMixin } from '../ui';
const rootDir = fromRoot('.');
@ -35,6 +31,8 @@ export default class KbnServer {
this.settings = settings || {};
this.ready = constant(this.mixin(
Plugins.waitForInitSetupMixin,
// sets this.config, reads this.settings
configSetupMixin,
// sets this.server
@ -51,25 +49,19 @@ export default class KbnServer {
// writes pid file
pidMixin,
// find plugins and set this.plugins
pluginsScanMixin,
// disable the plugins that are disabled through configuration
pluginsCheckEnabledMixin,
// disable the plugins that are incompatible with the current version of Kibana
pluginsCheckVersionMixin,
// find plugins and set this.plugins and this.pluginSpecs
Plugins.scanMixin,
// tell the config we are done loading plugins
configCompleteMixin,
// setup kbnServer.mappings and server.getKibanaIndexMappingsDsl()
kibanaIndexMappingsMixin,
// setup this.uiExports and this.bundles
// setup this.uiExports and this.uiBundles
uiMixin,
indexPatternsMixin,
// setup server.getKibanaIndexMappingsDsl()
kibanaIndexMappingsMixin,
// setup saved object routes
savedObjectsMixin,
@ -77,11 +69,15 @@ export default class KbnServer {
// lazy bundle server is running
optimizeMixin,
// finally, initialize the plugins
pluginsInitializeMixin,
// initialize the plugins
Plugins.initializeMixin,
// notify any deffered setup logic that plugins have intialized
Plugins.waitForInitResolveMixin,
() => {
if (this.config.get('server.autoListen')) {
this.ready = constant(resolve());
this.ready = constant(Promise.resolve());
return this.listen();
}
}
@ -134,17 +130,11 @@ export default class KbnServer {
}
async inject(opts) {
if (!this.server) await this.ready();
if (!this.server) {
await this.ready();
}
return await fromNode(cb => {
try {
this.server.inject(opts, (resp) => {
cb(null, resp);
});
} catch (err) {
cb(err);
}
});
return await this.server.inject(opts);
}
applyLoggingConfiguration(settings) {

View file

@ -64,32 +64,26 @@ describe('server/mapping/index_mapping', function () {
}
});
});
});
describe('#getDsl()', () => {
// tests are light because this method is used all over these tests
it('returns mapping as es dsl', function () {
const mapping = new IndexMappings();
expect(mapping.getDsl()).to.be.an('object');
});
});
describe('#addRootProperties()', () => {
it('extends the properties of the root type', () => {
const mapping = new IndexMappings({
it('accepts an array of new extensions that will be added to the mapping', () => {
const initialMapping = {
x: { properties: {} }
});
mapping.addRootProperties({
y: {
};
const extensions = [
{
properties: {
z: {
type: 'text'
y: {
properties: {
z: {
type: 'text'
}
}
}
}
}
});
];
const mapping = new IndexMappings(initialMapping, extensions);
expect(mapping.getDsl()).to.eql({
x: {
properties: {
@ -105,24 +99,47 @@ describe('server/mapping/index_mapping', function () {
});
});
it('throws if any property is conflicting', () => {
const props = { foo: 'bar' };
const mapping = new IndexMappings({
root: { properties: props }
});
it('throws if any of the new properties conflict', () => {
const initialMapping = {
root: { properties: { foo: 'bar' } }
};
const extensions = [
{
properties: {
foo: 'bar'
}
}
];
expect(() => {
mapping.addRootProperties(props);
new IndexMappings(initialMapping, extensions);
}).to.throwException(/foo/);
});
it('includes the plugin option in the error message when specified', () => {
const props = { foo: 'bar' };
const mapping = new IndexMappings({ root: { properties: props } });
it('includes the pluginId from the extension in the error message if defined', () => {
const initialMapping = {
root: { properties: { foo: 'bar' } }
};
const extensions = [
{
pluginId: 'abc123',
properties: {
foo: 'bar'
}
}
];
expect(() => {
mapping.addRootProperties(props, { plugin: 'abc123' });
new IndexMappings(initialMapping, extensions);
}).to.throwException(/plugin abc123/);
});
});
describe('#getDsl()', () => {
// tests are light because this method is used all over these tests
it('returns mapping as es dsl', function () {
const mapping = new IndexMappings();
expect(mapping.getDsl()).to.be.an('object');
});
});
});

View file

@ -11,7 +11,7 @@ const DEFAULT_INITIAL_DSL = {
};
export class IndexMappings {
constructor(initialDsl = DEFAULT_INITIAL_DSL) {
constructor(initialDsl = DEFAULT_INITIAL_DSL, mappingExtensions = []) {
this._dsl = cloneDeep(initialDsl);
if (!isPlainObject(this._dsl)) {
throw new TypeError('initial mapping must be an object');
@ -20,34 +20,34 @@ export class IndexMappings {
// ensure that we have a properties object in the dsl
// and that the dsl can be parsed with getRootProperties() and kin
this._setProperties(getRootProperties(this._dsl) || {});
// extend this._dsl with each extension (which currently come from uiExports.savedObjectMappings)
mappingExtensions.forEach(({ properties, pluginId }) => {
const rootProperties = getRootProperties(this._dsl);
const conflicts = Object.keys(properties)
.filter(key => rootProperties.hasOwnProperty(key));
if (conflicts.length) {
const props = formatListAsProse(conflicts);
const owner = pluginId ? `registered by plugin ${pluginId} ` : '';
throw new Error(
`Mappings for ${props} ${owner}have already been defined`
);
}
this._setProperties({
...rootProperties,
...properties
});
});
}
getDsl() {
return cloneDeep(this._dsl);
}
addRootProperties(newProperties, options = {}) {
const { plugin } = options;
const rootProperties = getRootProperties(this._dsl);
const conflicts = Object.keys(newProperties)
.filter(key => rootProperties.hasOwnProperty(key));
if (conflicts.length) {
const props = formatListAsProse(conflicts);
const owner = plugin ? `registered by plugin ${plugin} ` : '';
throw new Error(
`Mappings for ${props} ${owner}have already been defined`
);
}
this._setProperties({
...rootProperties,
...newProperties
});
}
_setProperties(newProperties) {
const rootType = getRootType(this._dsl);
this._dsl = {

View file

@ -6,7 +6,7 @@ import { IndexMappings } from './index_mappings';
* and timelion plugins for examples.
* @type {EsMappingDsl}
*/
const BASE_KIBANA_INDEX_MAPPINGS_DSL = {
const BASE_SAVED_OBJECT_MAPPINGS = {
doc: {
dynamic: 'strict',
properties: {
@ -29,19 +29,10 @@ const BASE_KIBANA_INDEX_MAPPINGS_DSL = {
};
export function kibanaIndexMappingsMixin(kbnServer, server) {
/**
* Stores the current mappings that we expect to find in the Kibana
* index. Using `kbnServer.mappings.addRootProperties()` the UiExports
* class extends these mappings based on `mappings` ui export specs.
*
* Application code should not access this object, and instead should
* use `server.getKibanaIndexMappingsDsl()` from below, mixed with the
* helpers exposed by this module, to interact with the mappings via
* their DSL.
*
* @type {IndexMappings}
*/
kbnServer.mappings = new IndexMappings(BASE_KIBANA_INDEX_MAPPINGS_DSL);
const mappings = new IndexMappings(
BASE_SAVED_OBJECT_MAPPINGS,
kbnServer.uiExports.savedObjectMappings
);
/**
* Get the mappings dsl that we expect to see in the
@ -57,6 +48,6 @@ export function kibanaIndexMappingsMixin(kbnServer, server) {
* @returns {EsMappingDsl}
*/
server.decorate('server', 'getKibanaIndexMappingsDsl', () => {
return kbnServer.mappings.getDsl();
return mappings.getDsl();
});
}

View file

@ -1,77 +0,0 @@
import { values } from 'lodash';
import expect from 'expect.js';
import sinon from 'sinon';
import pluginInit from '../plugin_init';
describe('Plugin init', () => {
const getPluginCollection = (plugins) => ({
byId: plugins,
toArray: () => values(plugins)
});
it('should call preInit before init', async () => {
const plugins = {
foo: {
id: 'foo',
init: sinon.spy(),
preInit: sinon.spy(),
requiredIds: []
},
bar: {
id: 'bar',
init: sinon.spy(),
preInit: sinon.spy(),
requiredIds: []
},
baz: {
id: 'baz',
init: sinon.spy(),
preInit: sinon.spy(),
requiredIds: []
}
};
await pluginInit(getPluginCollection(plugins));
expect(plugins.foo.preInit.calledBefore(plugins.foo.init)).to.be.ok();
expect(plugins.foo.preInit.calledBefore(plugins.bar.init)).to.be.ok();
expect(plugins.foo.preInit.calledBefore(plugins.baz.init)).to.be.ok();
expect(plugins.bar.preInit.calledBefore(plugins.foo.init)).to.be.ok();
expect(plugins.bar.preInit.calledBefore(plugins.bar.init)).to.be.ok();
expect(plugins.bar.preInit.calledBefore(plugins.baz.init)).to.be.ok();
expect(plugins.baz.preInit.calledBefore(plugins.foo.init)).to.be.ok();
expect(plugins.baz.preInit.calledBefore(plugins.bar.init)).to.be.ok();
expect(plugins.baz.preInit.calledBefore(plugins.baz.init)).to.be.ok();
});
it('should call preInits in correct order based on requirements', async () => {
const plugins = {
foo: {
id: 'foo',
init: sinon.spy(),
preInit: sinon.spy(),
requiredIds: ['bar', 'baz']
},
bar: {
id: 'bar',
init: sinon.spy(),
preInit: sinon.spy(),
requiredIds: []
},
baz: {
id: 'baz',
init: sinon.spy(),
preInit: sinon.spy(),
requiredIds: ['bar']
}
};
await pluginInit(getPluginCollection(plugins));
expect(plugins.bar.preInit.firstCall.calledBefore(plugins.foo.init.firstCall)).to.be.ok();
expect(plugins.bar.preInit.firstCall.calledBefore(plugins.baz.init.firstCall)).to.be.ok();
expect(plugins.baz.preInit.firstCall.calledBefore(plugins.foo.init.firstCall)).to.be.ok();
});
});

View file

@ -1,25 +0,0 @@
import toPath from 'lodash/internal/toPath';
export default async function (kbnServer, server, config) {
const forcedOverride = {
console: function (enabledInConfig) {
return !config.get('elasticsearch.tribe.url') && enabledInConfig;
}
};
const { plugins } = kbnServer;
for (const plugin of plugins) {
const enabledInConfig = config.get([...toPath(plugin.configPrefix), 'enabled']);
const hasOveride = forcedOverride.hasOwnProperty(plugin.id);
if (hasOveride) {
if (!forcedOverride[plugin.id](enabledInConfig)) {
plugins.disable(plugin);
}
} else if (!enabledInConfig) {
plugins.disable(plugin);
}
}
return;
}

View file

@ -1,36 +0,0 @@
import { cleanVersion, versionSatisfies } from '../../utils/version';
import { get } from 'lodash';
function compatibleWithKibana(kbnServer, plugin) {
//core plugins have a version of 'kibana' and are always compatible
if (plugin.kibanaVersion === 'kibana') return true;
const pluginKibanaVersion = cleanVersion(plugin.kibanaVersion);
const kibanaVersion = cleanVersion(kbnServer.version);
return versionSatisfies(pluginKibanaVersion, kibanaVersion);
}
export default async function (kbnServer, server) {
//because a plugin pack can contain more than one actual plugin, (for example x-pack)
//we make sure that the warning messages are unique
const warningMessages = new Set();
const plugins = kbnServer.plugins;
for (const plugin of plugins) {
const version = plugin.kibanaVersion;
const name = get(plugin, 'pkg.name');
if (!compatibleWithKibana(kbnServer, plugin)) {
const message = `Plugin "${name}" was disabled because it expected Kibana version "${version}", and found "${kbnServer.version}".`;
warningMessages.add(message);
plugins.disable(plugin);
}
}
for (const message of warningMessages) {
server.log(['warning'], message);
}
return;
}

View file

@ -0,0 +1,3 @@
export { scanMixin } from './scan_mixin';
export { initializeMixin } from './initialize_mixin';
export { waitForInitSetupMixin, waitForInitResolveMixin } from './wait_for_plugins_init';

View file

@ -1,22 +0,0 @@
import pluginInit from './plugin_init';
export default async function (kbnServer, server, config) {
if (!config.get('plugins.initialize')) {
server.log(['info'], 'Plugin initialization disabled.');
return [];
}
const { plugins } = kbnServer;
// extend plugin apis with additional context
plugins.getPluginApis().forEach(api => {
Object.defineProperty(api, 'uiExports', {
value: kbnServer.uiExports
});
});
await pluginInit(plugins);
}

View file

@ -0,0 +1,27 @@
import { callPluginHook } from './lib';
/**
* KbnServer mixin that initializes all plugins found in ./scan mixin
* @param {KbnServer} kbnServer
* @param {Hapi.Server} server
* @param {Config} config
* @return {Promise<undefined>}
*/
export async function initializeMixin(kbnServer, server, config) {
if (!config.get('plugins.initialize')) {
server.log(['info'], 'Plugin initialization disabled.');
return;
}
async function callHookOnPlugins(hookName) {
const { plugins } = kbnServer;
const ids = plugins.map(p => p.id);
for (const id of ids) {
await callPluginHook(hookName, plugins, id, []);
}
}
await callHookOnPlugins('preInit');
await callHookOnPlugins('init');
}

View file

@ -0,0 +1,87 @@
import expect from 'expect.js';
import sinon from 'sinon';
import { callPluginHook } from '../call_plugin_hook';
describe('server/plugins/callPluginHook', () => {
it('should call in correct order based on requirements', async () => {
const plugins = [
{
id: 'foo',
init: sinon.spy(),
preInit: sinon.spy(),
requiredIds: ['bar', 'baz']
},
{
id: 'bar',
init: sinon.spy(),
preInit: sinon.spy(),
requiredIds: []
},
{
id: 'baz',
init: sinon.spy(),
preInit: sinon.spy(),
requiredIds: ['bar']
}
];
await callPluginHook('init', plugins, 'foo', []);
const [foo, bar, baz] = plugins;
sinon.assert.calledOnce(foo.init);
sinon.assert.calledTwice(bar.init);
sinon.assert.calledOnce(baz.init);
sinon.assert.callOrder(
bar.init,
baz.init,
foo.init,
);
});
it('throws meaningful error when required plugin is missing', async () => {
const plugins = [
{
id: 'foo',
init: sinon.spy(),
preInit: sinon.spy(),
requiredIds: ['bar']
},
];
try {
await callPluginHook('init', plugins, 'foo', []);
throw new Error('expected callPluginHook to throw');
} catch (error) {
expect(error.message).to.contain('"bar" for plugin "foo"');
}
});
it('throws meaningful error when dependencies are circular', async () => {
const plugins = [
{
id: 'foo',
init: sinon.spy(),
preInit: sinon.spy(),
requiredIds: ['bar']
},
{
id: 'bar',
init: sinon.spy(),
preInit: sinon.spy(),
requiredIds: ['baz']
},
{
id: 'baz',
init: sinon.spy(),
preInit: sinon.spy(),
requiredIds: ['foo']
},
];
try {
await callPluginHook('init', plugins, 'foo', []);
throw new Error('expected callPluginHook to throw');
} catch (error) {
expect(error.message).to.contain('foo -> bar -> baz -> foo');
}
});
});

View file

@ -0,0 +1,31 @@
import { last } from 'lodash';
export async function callPluginHook(hookName, plugins, id, history) {
const plugin = plugins.find(plugin => plugin.id === id);
// make sure this is a valid plugin id
if (!plugin) {
if (history.length) {
throw new Error(`Unmet requirement "${id}" for plugin "${last(history)}"`);
} else {
throw new Error(`Unknown plugin "${id}"`);
}
}
const circleStart = history.indexOf(id);
const path = [...history, id];
// make sure we are not trying to load a dependency within itself
if (circleStart > -1) {
const circle = path.slice(circleStart);
throw new Error(`circular dependency found: "${circle.join(' -> ')}"`);
}
// call hook on all dependencies
for (const req of plugin.requiredIds) {
await callPluginHook(hookName, plugins, req, path);
}
// call hook on this plugin
await plugin[hookName]();
}

View file

@ -0,0 +1,2 @@
export { callPluginHook } from './call_plugin_hook';
export { Plugin } from './plugin';

View file

@ -0,0 +1,103 @@
import { once } from 'lodash';
/**
* The server plugin class, used to extend the server
* and add custom behavior. A "scoped" plugin class is
* created by the PluginApi class and provided to plugin
* providers that automatically binds all but the `opts`
* arguments.
*
* @class Plugin
* @param {KbnServer} kbnServer - the KbnServer this plugin
* belongs to.
* @param {PluginDefinition} def
* @param {PluginSpec} spec
*/
export class Plugin {
constructor(kbnServer, spec) {
this.kbnServer = kbnServer;
this.spec = spec;
this.pkg = spec.getPkg();
this.path = spec.getPath();
this.id = spec.getId();
this.version = spec.getVersion();
this.requiredIds = spec.getRequiredPluginIds() || [];
this.externalPreInit = spec.getPreInitHandler();
this.externalInit = spec.getInitHandler();
this.enabled = spec.isEnabled(kbnServer.config);
this.configPrefix = spec.getConfigPrefix();
this.publicDir = spec.getPublicDir();
this.preInit = once(this.preInit);
this.init = once(this.init);
}
async preInit() {
if (this.externalPreInit) {
return await this.externalPreInit(this.kbnServer.server);
}
}
async init() {
const { id, version, kbnServer, configPrefix } = this;
const { config } = kbnServer;
// setup the hapi register function and get on with it
const asyncRegister = async (server, options) => {
this._server = server;
this._options = options;
server.log(['plugins', 'debug'], {
tmpl: 'Initializing plugin <%= plugin.toString() %>',
plugin: this
});
if (this.publicDir) {
server.exposeStaticDir(`/plugins/${id}/{path*}`, this.publicDir);
}
// Many of the plugins are simply adding static assets to the server and we don't need
// to track their "status". Since plugins must have an init() function to even set its status
// we shouldn't even create a status unless the plugin can use it.
if (this.externalInit) {
this.status = kbnServer.status.createForPlugin(this);
server.expose('status', this.status);
await this.externalInit(server, options);
}
};
const register = (server, options, next) => {
asyncRegister(server, options)
.then(() => next(), next);
};
register.attributes = { name: id, version: version };
await kbnServer.server.register({
register: register,
options: config.has(configPrefix) ? config.get(configPrefix) : null
});
// Only change the plugin status to green if the
// intial status has not been changed
if (this.status && this.status.state === 'uninitialized') {
this.status.green('Ready');
}
}
getServer() {
return this._server;
}
getOptions() {
return this._options;
}
toJSON() {
return this.pkg;
}
toString() {
return `${this.id}@${this.version}`;
}
}

View file

@ -1,175 +0,0 @@
import _ from 'lodash';
import Joi from 'joi';
import Bluebird, { attempt, fromNode } from 'bluebird';
import { basename, resolve } from 'path';
import { Deprecations } from '../../deprecation';
const extendInitFns = Symbol('extend plugin initialization');
const defaultConfigSchema = Joi.object({
enabled: Joi.boolean().default(true)
}).default();
/**
* The server plugin class, used to extend the server
* and add custom behavior. A "scoped" plugin class is
* created by the PluginApi class and provided to plugin
* providers that automatically binds all but the `opts`
* arguments.
*
* @class Plugin
* @param {KbnServer} kbnServer - the KbnServer this plugin
* belongs to.
* @param {String} path - the path from which the plugin hails
* @param {Object} pkg - the value of package.json for the plugin
* @param {Objects} opts - the options for this plugin
* @param {String} [opts.id=pkg.name] - the id for this plugin.
* @param {Object} [opts.uiExports] - a mapping of UiExport types
* to UI modules or metadata about
* the UI module
* @param {Array} [opts.require] - the other plugins that this plugin
* requires. These plugins must exist and
* be enabled for this plugin to function.
* The require'd plugins will also be
* initialized first, in order to make sure
* that dependencies provided by these plugins
* are available
* @param {String} [opts.version=pkg.version] - the version of this plugin
* @param {Function} [opts.init] - A function that will be called to initialize
* this plugin at the appropriate time.
* @param {Function} [opts.configPrefix=this.id] - The prefix to use for configuration
* values in the main configuration service
* @param {Function} [opts.config] - A function that produces a configuration
* schema using Joi, which is passed as its
* first argument.
* @param {String|False} [opts.publicDir=path + '/public']
* - the public directory for this plugin. The final directory must
* have the name "public", though it can be located somewhere besides
* the root of the plugin. Set this to false to disable exposure of a
* public directory
*/
export default class Plugin {
constructor(kbnServer, path, pkg, opts) {
this.kbnServer = kbnServer;
this.pkg = pkg;
this.path = path;
this.id = opts.id || pkg.name;
this.uiExportsSpecs = opts.uiExports || {};
this.requiredIds = opts.require || [];
this.version = opts.version || pkg.version;
// Plugins must specify their version, and by default that version should match
// the version of kibana down to the patch level. If these two versions need
// to diverge, they can specify a kibana.version in the package to indicate the
// version of kibana the plugin is intended to work with.
this.kibanaVersion = opts.kibanaVersion || _.get(pkg, 'kibana.version', this.version);
this.externalPreInit = opts.preInit || _.noop;
this.externalInit = opts.init || _.noop;
this.configPrefix = opts.configPrefix || this.id;
this.getExternalConfigSchema = opts.config || _.noop;
this.getExternalDeprecations = opts.deprecations || _.noop;
this.preInit = _.once(this.preInit);
this.init = _.once(this.init);
this[extendInitFns] = [];
if (opts.publicDir === false) {
this.publicDir = null;
}
else if (!opts.publicDir) {
this.publicDir = resolve(this.path, 'public');
}
else {
this.publicDir = opts.publicDir;
if (basename(this.publicDir) !== 'public') {
throw new Error(`publicDir for plugin ${this.id} must end with a "public" directory.`);
}
}
}
static scoped(kbnServer, path, pkg) {
return class ScopedPlugin extends Plugin {
constructor(opts) {
super(kbnServer, path, pkg, opts || {});
}
};
}
async getConfigSchema() {
const schema = await this.getExternalConfigSchema(Joi);
return schema || defaultConfigSchema;
}
getDeprecations() {
const rules = this.getExternalDeprecations(Deprecations);
return rules || [];
}
async preInit() {
return await this.externalPreInit(this.kbnServer.server);
}
async init() {
const { id, version, kbnServer, configPrefix } = this;
const { config } = kbnServer;
// setup the hapi register function and get on with it
const asyncRegister = async (server, options) => {
this.server = server;
for (const fn of this[extendInitFns]) {
await fn.call(this, server, options);
}
server.log(['plugins', 'debug'], {
tmpl: 'Initializing plugin <%= plugin.toString() %>',
plugin: this
});
if (this.publicDir) {
server.exposeStaticDir(`/plugins/${id}/{path*}`, this.publicDir);
}
// Many of the plugins are simply adding static assets to the server and we don't need
// to track their "status". Since plugins must have an init() function to even set its status
// we shouldn't even create a status unless the plugin can use it.
if (this.externalInit !== _.noop) {
this.status = kbnServer.status.createForPlugin(this);
server.expose('status', this.status);
}
return await attempt(this.externalInit, [server, options], this);
};
const register = (server, options, next) => {
Bluebird.resolve(asyncRegister(server, options)).nodeify(next);
};
register.attributes = { name: id, version: version };
await fromNode(cb => {
kbnServer.server.register({
register: register,
options: config.has(configPrefix) ? config.get(configPrefix) : null
}, cb);
});
// Only change the plugin status to green if the
// intial status has not been changed
if (this.status && this.status.state === 'uninitialized') {
this.status.green('Ready');
}
}
extendInit(fn) {
this[extendInitFns].push(fn);
}
toJSON() {
return this.pkg;
}
toString() {
return `${this.id}@${this.version}`;
}
}

View file

@ -1,30 +0,0 @@
import Plugin from './plugin';
import { join } from 'path';
export default class PluginApi {
constructor(kibana, pluginPath) {
this.config = kibana.config;
this.rootDir = kibana.rootDir;
this.package = require(join(pluginPath, 'package.json'));
this.Plugin = Plugin.scoped(kibana, pluginPath, this.package);
}
get uiExports() {
throw new Error('plugin.uiExports is not defined until initialize phase');
}
get autoload() {
console.warn(
`${this.package.id} accessed the autoload lists which are no longer available via the Plugin API.` +
'Use the `ui/autoload/*` modules instead.'
);
return {
directives: [],
filters: [],
styles: [],
modules: [],
require: []
};
}
}

View file

@ -1,78 +0,0 @@
import PluginApi from './plugin_api';
import { inspect } from 'util';
import { get, indexBy } from 'lodash';
import Collection from '../../utils/collection';
import { transformDeprecations } from '../config/transform_deprecations';
import { createTransform } from '../../deprecation';
import Joi from 'joi';
const byIdCache = Symbol('byIdCache');
const pluginApis = Symbol('pluginApis');
async function addPluginConfig(pluginCollection, plugin) {
const { config, server, settings } = pluginCollection.kbnServer;
const transformedSettings = transformDeprecations(settings);
const pluginSettings = get(transformedSettings, plugin.configPrefix);
const deprecations = plugin.getDeprecations();
const transformedPluginSettings = createTransform(deprecations)(pluginSettings, (message) => {
server.log(['warning', plugin.configPrefix, 'config', 'deprecation'], message);
});
const configSchema = await plugin.getConfigSchema();
config.extendSchema(configSchema, transformedPluginSettings, plugin.configPrefix);
}
function disablePluginConfig(pluginCollection, plugin) {
// when disabling a plugin's config we remove the existing schema and
// replace it with a simple schema/config that only has enabled set to false
const { config } = pluginCollection.kbnServer;
config.removeSchema(plugin.configPrefix);
const schema = Joi.object({ enabled: Joi.bool() });
config.extendSchema(schema, { enabled: false }, plugin.configPrefix);
}
export default class Plugins extends Collection {
constructor(kbnServer) {
super();
this.kbnServer = kbnServer;
this[pluginApis] = new Set();
}
async new(path) {
const api = new PluginApi(this.kbnServer, path);
this[pluginApis].add(api);
const output = [].concat(require(path)(api) || []);
if (!output.length) return;
// clear the byIdCache
this[byIdCache] = null;
for (const plugin of output) {
if (!plugin instanceof api.Plugin) {
throw new TypeError('unexpected plugin export ' + inspect(plugin));
}
await addPluginConfig(this, plugin);
this.add(plugin);
}
}
async disable(plugin) {
disablePluginConfig(this, plugin);
this.delete(plugin);
}
get byId() {
return this[byIdCache] || (this[byIdCache] = indexBy([...this], 'id'));
}
getPluginApis() {
return this[pluginApis];
}
}

View file

@ -1,35 +0,0 @@
import { includes } from 'lodash';
export default async (plugins) => {
const path = [];
const initialize = async function (id, fn) {
const plugin = plugins.byId[id];
if (includes(path, id)) {
throw new Error(`circular dependencies found: "${path.concat(id).join(' -> ')}"`);
}
path.push(id);
for (const reqId of plugin.requiredIds) {
if (!plugins.byId[reqId]) {
throw new Error(`Unmet requirement "${reqId}" for plugin "${id}"`);
}
await initialize(reqId, fn);
}
await plugin[fn]();
path.pop();
};
const collection = plugins.toArray();
for (const { id } of collection) {
await initialize(id, 'preInit');
}
for (const { id } of collection) {
await initialize(id, 'init');
}
};

View file

@ -1,59 +0,0 @@
import _ from 'lodash';
import { fromNode, each } from 'bluebird';
import { readdir, stat } from 'fs';
import { resolve } from 'path';
import PluginCollection from './plugin_collection';
export default async (kbnServer, server, config) => {
const plugins = kbnServer.plugins = new PluginCollection(kbnServer);
const scanDirs = [].concat(config.get('plugins.scanDirs') || []);
const pluginPaths = [].concat(config.get('plugins.paths') || []);
const debug = _.bindKey(server, 'log', ['plugins', 'debug']);
const warning = _.bindKey(server, 'log', ['plugins', 'warning']);
// scan all scanDirs to find pluginPaths
await each(scanDirs, async dir => {
debug({ tmpl: 'Scanning `<%= dir %>` for plugins', dir: dir });
let filenames = null;
try {
filenames = await fromNode(cb => readdir(dir, cb));
} catch (err) {
if (err.code !== 'ENOENT') throw err;
filenames = [];
warning({
tmpl: '<%= err.code %>: Unable to scan non-existent directory for plugins "<%= dir %>"',
err: err,
dir: dir
});
}
await each(filenames, async name => {
if (name[0] === '.') return;
const path = resolve(dir, name);
const stats = await fromNode(cb => stat(path, cb));
if (stats.isDirectory()) {
pluginPaths.push(path);
}
});
});
for (const path of pluginPaths) {
let modulePath;
try {
modulePath = require.resolve(path);
} catch (e) {
warning({ tmpl: 'Skipping non-plugin directory at <%= path %>', path: path });
continue;
}
await plugins.new(path);
debug({ tmpl: 'Found plugin at <%= path %>', path: modulePath });
}
};

View file

@ -0,0 +1,64 @@
import { Observable } from 'rxjs';
import { findPluginSpecs } from '../../plugin_discovery';
import { Plugin } from './lib';
export async function scanMixin(kbnServer, server, config) {
const {
pack$,
invalidDirectoryError$,
invalidPackError$,
deprecation$,
invalidVersionSpec$,
spec$,
} = findPluginSpecs(kbnServer.settings, config);
const logging$ = Observable.merge(
pack$.do(definition => {
server.log(['plugin', 'debug'], {
tmpl: 'Found plugin at <%= path %>',
path: definition.getPath()
});
}),
invalidDirectoryError$.do(error => {
server.log(['plugin', 'warning'], {
tmpl: '<%= err.code %>: Unable to scan directory for plugins "<%= dir %>"',
err: error,
dir: error.path
});
}),
invalidPackError$.do(error => {
server.log(['plugin', 'warning'], {
tmpl: 'Skipping non-plugin directory at <%= path %>',
path: error.path
});
}),
invalidVersionSpec$
.map(spec => {
const name = spec.getId();
const pluginVersion = spec.getExpectedKibanaVersion();
const kibanaVersion = config.get('pkg.version');
return `Plugin "${name}" was disabled because it expected Kibana version "${pluginVersion}", and found "${kibanaVersion}".`;
})
.distinct()
.do(message => {
server.log(['plugin', 'warning'], message);
}),
deprecation$.do(({ spec, message }) => {
server.log(['warning', spec.getConfigPrefix(), 'config', 'deprecation'], message);
})
);
kbnServer.pluginSpecs = await spec$
.merge(logging$.ignoreElements())
.toArray()
.toPromise();
kbnServer.plugins = kbnServer.pluginSpecs.map(spec => (
new Plugin(kbnServer, spec)
));
}

View file

@ -0,0 +1,33 @@
/**
* Tracks the individual queue for each kbnServer, rather than attaching
* it to the kbnServer object via a property or something
* @type {WeakMap}
*/
const queues = new WeakMap();
export function waitForInitSetupMixin(kbnServer) {
queues.set(kbnServer, []);
kbnServer.afterPluginsInit = function (callback) {
const queue = queues.get(kbnServer);
if (!queue) {
throw new Error('Plugins have already initialized. Only use this method for setup logic that must wait for plugins to initialize.');
}
queue.push(callback);
};
}
export async function waitForInitResolveMixin(kbnServer, server, config) {
const queue = queues.get(kbnServer);
queues.set(kbnServer, null);
// only actually call the callbacks if we are really initializing
if (config.get('plugins.initialize')) {
for (const cb of queue) {
await cb();
}
}
}

View file

@ -40,24 +40,23 @@ export default function (kbnServer, server, config) {
}));
server.decorate('reply', 'renderStatusPage', async function () {
const app = kbnServer.uiExports.getHiddenApp('status_page');
const response = await getResponse(this);
response.code(kbnServer.status.isGreen() ? 200 : 503);
return response;
const app = server.getHiddenUiAppById('status_page');
const reply = this;
const response = app
? await reply.renderApp(app)
: reply(kbnServer.status.toString());
function getResponse(ctx) {
if (app) {
return ctx.renderApp(app);
}
return ctx(kbnServer.status.toString());
if (response) {
response.code(kbnServer.status.isGreen() ? 200 : 503);
return response;
}
});
server.route(wrapAuth({
method: 'GET',
path: '/status',
handler: function (request, reply) {
return reply.renderStatusPage();
handler(request, reply) {
reply.renderStatusPage();
}
}));
}

View file

@ -8,6 +8,12 @@ export default kibana => new kibana.Plugin({
from_test_app: true
};
}
},
injectDefaultVars() {
return {
from_defaults: true
};
}
}
});

View file

@ -1,278 +0,0 @@
import expect from 'expect.js';
import UiApp from '../ui_app.js';
import UiExports from '../ui_exports';
import { noop } from 'lodash';
function getMockSpec(extraParams) {
return {
id: 'uiapp-test',
main: 'main.js',
title: 'UIApp Test',
order: 9000,
description: 'Test of UI App Constructor',
icon: 'ui_app_test.svg',
linkToLastSubUrl: true,
hidden: false,
listed: null,
templateName: 'ui_app_test',
...extraParams
};
}
describe('UiApp', () => {
describe('constructor', () => {
const uiExports = new UiExports({});
it('throws an exception if an ID is not given', () => {
function newAppMissingID() {
const spec = {}; // should have id property
const newApp = new UiApp(uiExports, spec);
return newApp;
}
expect(newAppMissingID).to.throwException();
});
describe('defaults', () => {
const spec = { id: 'uiapp-test-defaults' };
let newApp;
beforeEach(() => {
newApp = new UiApp(uiExports, spec);
});
it('copies the ID from the spec', () => {
expect(newApp.id).to.be(spec.id);
});
it('has a default navLink', () => {
expect(newApp.navLink).to.eql({
id: 'uiapp-test-defaults',
title: undefined,
order: 0,
url: '/app/uiapp-test-defaults',
subUrlBase: '/app/uiapp-test-defaults',
description: undefined,
icon: undefined,
linkToLastSubUrl: true,
hidden: false,
disabled: false,
tooltip: ''
});
});
it('has a default order of 0', () => {
expect(newApp.order).to.be(0);
});
it('has a default template name of ui_app', () => {
expect(newApp.templateName).to.be('ui_app');
});
});
describe('with spec', () => {
const spec = getMockSpec();
let newApp;
beforeEach(() => {
newApp = new UiApp(uiExports, spec);
});
it('copies the ID from the spec', () => {
expect(newApp.id).to.be(spec.id);
});
it('copies field values from spec', () => {
// test that the fields exist, but have undefined value
expect(newApp.main).to.be(spec.main);
expect(newApp.title).to.be(spec.title);
expect(newApp.description).to.be(spec.description);
expect(newApp.icon).to.be(spec.icon);
expect(newApp.linkToLastSubUrl).to.be(spec.linkToLastSubUrl);
expect(newApp.templateName).to.be(spec.templateName);
expect(newApp.order).to.be(spec.order);
expect(newApp.navLink).to.eql({
id: 'uiapp-test',
title: 'UIApp Test',
order: 9000,
url: '/app/uiapp-test',
subUrlBase: '/app/uiapp-test',
description: 'Test of UI App Constructor',
icon: 'ui_app_test.svg',
linkToLastSubUrl: true,
hidden: false,
disabled: false,
tooltip: ''
});
});
});
describe('reference fields', () => {
const spec = getMockSpec({ testSpec: true });
let newApp;
beforeEach(() => {
newApp = new UiApp(uiExports, spec);
});
it('has a reference to the uiExports object', () => {
expect(newApp.uiExports).to.be(uiExports);
});
it('has a reference to the original spec', () => {
expect(newApp.spec).to.be(spec);
});
it('has a reference to the spec.injectVars function', () => {
const helloFunction = () => 'hello';
const spec = {
id: 'uiapp-test',
injectVars: helloFunction
};
const newApp = new UiApp(uiExports, spec);
expect(newApp.getInjectedVars).to.be(helloFunction);
});
});
describe('app.getInjectedVars', () => {
it('is noop function by default', () => {
const spec = {
id: 'uiapp-test'
};
const newApp = new UiApp(uiExports, spec);
expect(newApp.getInjectedVars).to.be(noop);
});
});
/*
* The "hidden" and "listed" flags have an bound relationship. The "hidden"
* flag gets cast to a boolean value, and the "listed" flag is dependent on
* "hidden"
*/
describe('hidden flag', () => {
describe('is cast to boolean value', () => {
it('when undefined', () => {
const spec = {
id: 'uiapp-test',
};
const newApp = new UiApp(uiExports, spec);
expect(newApp.hidden).to.be(false);
});
it('when null', () => {
const spec = {
id: 'uiapp-test',
hidden: null,
};
const newApp = new UiApp(uiExports, spec);
expect(newApp.hidden).to.be(false);
});
});
});
describe('listed flag', () => {
describe('defaults to the opposite value of hidden', () => {
it(`when it's null and hidden is true`, () => {
const spec = {
id: 'uiapp-test',
hidden: true,
listed: null,
};
const newApp = new UiApp(uiExports, spec);
expect(newApp.listed).to.be(false);
});
it(`when it's null and hidden is false`, () => {
const spec = {
id: 'uiapp-test',
hidden: false,
listed: null,
};
const newApp = new UiApp(uiExports, spec);
expect(newApp.listed).to.be(true);
});
it(`when it's undefined and hidden is false`, () => {
const spec = {
id: 'uiapp-test',
hidden: false,
};
const newApp = new UiApp(uiExports, spec);
expect(newApp.listed).to.be(true);
});
it(`when it's undefined and hidden is true`, () => {
const spec = {
id: 'uiapp-test',
hidden: true,
};
const newApp = new UiApp(uiExports, spec);
expect(newApp.listed).to.be(false);
});
});
it(`is set to true when it's passed as true`, () => {
const spec = {
id: 'uiapp-test',
listed: true,
};
const newApp = new UiApp(uiExports, spec);
expect(newApp.listed).to.be(true);
});
it(`is set to false when it's passed as false`, () => {
const spec = {
id: 'uiapp-test',
listed: false,
};
const newApp = new UiApp(uiExports, spec);
expect(newApp.listed).to.be(false);
});
});
});
describe('getModules', () => {
it('gets modules from uiExports', () => {
const uiExports = new UiExports({});
uiExports.consumePlugin({
uiExportsSpecs: {
chromeNavControls: [ 'plugins/ui_app_test/views/nav_control' ],
hacks: [ 'plugins/ui_app_test/hacks/init' ]
}
});
const spec = getMockSpec();
const newApp = new UiApp(uiExports, spec);
expect(newApp.getModules()).to.eql([
'main.js',
'plugins/ui_app_test/views/nav_control',
'plugins/ui_app_test/hacks/init'
]);
});
});
describe('toJSON', function () {
it('creates plain object', () => {
const uiExports = new UiExports({});
const spec = getMockSpec();
const newApp = new UiApp(uiExports, spec);
expect(newApp.toJSON()).to.eql({
id: 'uiapp-test',
title: 'UIApp Test',
description: 'Test of UI App Constructor',
icon: 'ui_app_test.svg',
main: 'main.js',
navLink: {
id: 'uiapp-test',
title: 'UIApp Test',
order: 9000,
url: '/app/uiapp-test',
subUrlBase: '/app/uiapp-test',
description: 'Test of UI App Constructor',
icon: 'ui_app_test.svg',
linkToLastSubUrl: true,
hidden: false,
disabled: false,
tooltip: ''
},
linkToLastSubUrl: true
});
});
});
});

View file

@ -1,107 +0,0 @@
import expect from 'expect.js';
import { resolve } from 'path';
import UiExports from '../ui_exports';
import * as kbnTestServer from '../../test_utils/kbn_server';
describe('UiExports', function () {
describe('#find()', function () {
it('finds exports based on the passed export names', function () {
const uiExports = new UiExports({});
uiExports.aliases.foo = ['a', 'b', 'c'];
uiExports.aliases.bar = ['d', 'e', 'f'];
expect(uiExports.find(['foo'])).to.eql(['a', 'b', 'c']);
expect(uiExports.find(['bar'])).to.eql(['d', 'e', 'f']);
expect(uiExports.find(['foo', 'bar'])).to.eql(['a', 'b', 'c', 'd', 'e', 'f']);
});
it('allows query types that match nothing', function () {
const uiExports = new UiExports({});
uiExports.aliases.foo = ['a', 'b', 'c'];
expect(uiExports.find(['foo'])).to.eql(['a', 'b', 'c']);
expect(uiExports.find(['bar'])).to.eql([]);
expect(uiExports.find(['foo', 'bar'])).to.eql(['a', 'b', 'c']);
});
});
//
describe('#defaultInjectedVars', function () {
describe('two plugins, two sync', function () {
this.slow(10000);
this.timeout(60000);
let kbnServer;
before(async function () {
kbnServer = kbnTestServer.createServer({
plugins: {
paths: [
resolve(__dirname, 'fixtures/plugin_foo'),
resolve(__dirname, 'fixtures/plugin_bar'),
]
},
plugin_foo: {
shared: 'foo'
},
plugin_bar: {
shared: 'bar'
}
});
await kbnServer.ready();
});
after(async function () {
await kbnServer.close();
});
it('merges the two plugins in the order they are loaded', function () {
expect(kbnServer.uiExports.defaultInjectedVars).to.eql({
shared: 'foo'
});
});
});
describe('two plugins, one async', function () {
this.slow(10000);
this.timeout(60000);
let kbnServer;
before(async function () {
kbnServer = kbnTestServer.createServer({
plugins: {
scanDirs: [],
paths: [
resolve(__dirname, 'fixtures/plugin_async_foo'),
resolve(__dirname, 'fixtures/plugin_bar'),
]
},
plugin_async_foo: {
delay: 500,
shared: 'foo'
},
plugin_bar: {
shared: 'bar'
}
});
await kbnServer.ready();
});
after(async function () {
await kbnServer.close();
});
it('merges the two plugins in the order they are loaded', function () {
// even though plugin_async_foo loads 500ms later, it is still "first" to merge
expect(kbnServer.uiExports.defaultInjectedVars).to.eql({
shared: 'foo'
});
});
});
});
});

View file

@ -18,7 +18,11 @@ const injectReplacer = (kbnServer, replacer) => {
// normally the replacer would be defined in a plugin's uiExports,
// but that requires stubbing out an entire plugin directory for
// each test, so we fake it and jam the replacer into uiExports
kbnServer.uiExports.injectedVarsReplacers.push(replacer);
const { injectedVarsReplacers = [] } = kbnServer.uiExports;
kbnServer.uiExports.injectedVarsReplacers = [
...injectedVarsReplacers,
replacer
];
};
describe('UiExports', function () {
@ -122,7 +126,6 @@ describe('UiExports', function () {
it('starts off with the injected vars for the app merged with the default injected vars', async () => {
const stub = sinon.stub();
injectReplacer(kbnServer, stub);
kbnServer.uiExports.defaultInjectedVars.from_defaults = true;
await kbnServer.inject('/app/test_app');
sinon.assert.calledOnce(stub);

View file

@ -1,138 +0,0 @@
import expect from 'expect.js';
import UiNavLink from '../ui_nav_link';
describe('UiNavLink', () => {
describe('constructor', () => {
it ('initializes the object properties as expected', () => {
const uiExports = {
urlBasePath: 'http://localhost:5601/rnd'
};
const spec = {
id: 'kibana:discover',
title: 'Discover',
order: -1003,
url: '/app/kibana#/discover',
description: 'interactively explore your data',
icon: 'plugins/kibana/assets/discover.svg',
hidden: true,
disabled: true
};
const link = new UiNavLink(uiExports, spec);
expect(link.id).to.be(spec.id);
expect(link.title).to.be(spec.title);
expect(link.order).to.be(spec.order);
expect(link.url).to.be(`${uiExports.urlBasePath}${spec.url}`);
expect(link.description).to.be(spec.description);
expect(link.icon).to.be(spec.icon);
expect(link.hidden).to.be(spec.hidden);
expect(link.disabled).to.be(spec.disabled);
});
it ('initializes the url property without a base path when one is not specified in the spec', () => {
const uiExports = {};
const spec = {
id: 'kibana:discover',
title: 'Discover',
order: -1003,
url: '/app/kibana#/discover',
description: 'interactively explore your data',
icon: 'plugins/kibana/assets/discover.svg',
};
const link = new UiNavLink(uiExports, spec);
expect(link.url).to.be(spec.url);
});
it ('initializes the order property to 0 when order is not specified in the spec', () => {
const uiExports = {};
const spec = {
id: 'kibana:discover',
title: 'Discover',
url: '/app/kibana#/discover',
description: 'interactively explore your data',
icon: 'plugins/kibana/assets/discover.svg',
};
const link = new UiNavLink(uiExports, spec);
expect(link.order).to.be(0);
});
it ('initializes the linkToLastSubUrl property to false when false is specified in the spec', () => {
const uiExports = {};
const spec = {
id: 'kibana:discover',
title: 'Discover',
order: -1003,
url: '/app/kibana#/discover',
description: 'interactively explore your data',
icon: 'plugins/kibana/assets/discover.svg',
linkToLastSubUrl: false
};
const link = new UiNavLink(uiExports, spec);
expect(link.linkToLastSubUrl).to.be(false);
});
it ('initializes the linkToLastSubUrl property to true by default', () => {
const uiExports = {};
const spec = {
id: 'kibana:discover',
title: 'Discover',
order: -1003,
url: '/app/kibana#/discover',
description: 'interactively explore your data',
icon: 'plugins/kibana/assets/discover.svg',
};
const link = new UiNavLink(uiExports, spec);
expect(link.linkToLastSubUrl).to.be(true);
});
it ('initializes the hidden property to false by default', () => {
const uiExports = {};
const spec = {
id: 'kibana:discover',
title: 'Discover',
order: -1003,
url: '/app/kibana#/discover',
description: 'interactively explore your data',
icon: 'plugins/kibana/assets/discover.svg',
};
const link = new UiNavLink(uiExports, spec);
expect(link.hidden).to.be(false);
});
it ('initializes the disabled property to false by default', () => {
const uiExports = {};
const spec = {
id: 'kibana:discover',
title: 'Discover',
order: -1003,
url: '/app/kibana#/discover',
description: 'interactively explore your data',
icon: 'plugins/kibana/assets/discover.svg',
};
const link = new UiNavLink(uiExports, spec);
expect(link.disabled).to.be(false);
});
it ('initializes the tooltip property to an empty string by default', () => {
const uiExports = {};
const spec = {
id: 'kibana:discover',
title: 'Discover',
order: -1003,
url: '/app/kibana#/discover',
description: 'interactively explore your data',
icon: 'plugins/kibana/assets/discover.svg',
};
const link = new UiNavLink(uiExports, spec);
expect(link.tooltip).to.be('');
});
});
});

View file

@ -1,29 +0,0 @@
export default function ({ env, bundle }) {
const pluginSlug = env.pluginInfo.sort()
.map(p => ' * - ' + p)
.join('\n');
const requires = bundle.modules
.map(m => `require('${m}');`)
.join('\n');
return `
/**
* Test entry file
*
* This is programatically created and updated, do not modify
*
* context: ${JSON.stringify(env.context)}
* includes code from:
${pluginSlug}
*
*/
require('ui/chrome');
${requires}
require('ui/chrome').bootstrap(/* xoxo */);
`;
}

View file

@ -0,0 +1,2 @@
export { fieldFormatsMixin } from './field_formats_mixin';
export { FieldFormat } from './field_format';

View file

@ -1 +0,0 @@
export { I18n } from './i18n';

View file

@ -1,137 +1,2 @@
import { defaults, _ } from 'lodash';
import { props, reduce as reduceAsync } from 'bluebird';
import Boom from 'boom';
import { resolve } from 'path';
import UiExports from './ui_exports';
import UiBundle from './ui_bundle';
import UiBundleCollection from './ui_bundle_collection';
import UiBundlerEnv from './ui_bundler_env';
import { UiI18n } from './ui_i18n';
import { uiSettingsMixin } from './ui_settings';
import { fieldFormatsMixin } from './field_formats/field_formats_mixin';
export default async (kbnServer, server, config) => {
const uiExports = kbnServer.uiExports = new UiExports({
urlBasePath: config.get('server.basePath'),
kibanaIndexMappings: kbnServer.mappings,
});
await kbnServer.mixin(uiSettingsMixin);
await kbnServer.mixin(fieldFormatsMixin);
const uiI18n = kbnServer.uiI18n = new UiI18n(config.get('i18n.defaultLocale'));
uiI18n.addUiExportConsumer(uiExports);
const bundlerEnv = new UiBundlerEnv(config.get('optimize.bundleDir'));
bundlerEnv.addContext('env', config.get('env.name'));
bundlerEnv.addContext('sourceMaps', config.get('optimize.sourceMaps'));
bundlerEnv.addContext('kbnVersion', config.get('pkg.version'));
bundlerEnv.addContext('buildNum', config.get('pkg.buildNum'));
uiExports.addConsumer(bundlerEnv);
for (const plugin of kbnServer.plugins) {
uiExports.consumePlugin(plugin);
}
const bundles = kbnServer.bundles = new UiBundleCollection(bundlerEnv, config.get('optimize.bundleFilter'));
for (const app of uiExports.getAllApps()) {
bundles.addApp(app);
}
for (const gen of uiExports.getBundleProviders()) {
const bundle = await gen(UiBundle, bundlerEnv, uiExports.getAllApps(), kbnServer.plugins);
if (bundle) bundles.add(bundle);
}
// render all views from the ui/views directory
server.setupViews(resolve(__dirname, 'views'));
server.route({
path: '/app/{id}',
method: 'GET',
async handler(req, reply) {
const id = req.params.id;
const app = uiExports.apps.byId[id];
if (!app) return reply(Boom.notFound('Unknown app ' + id));
try {
if (kbnServer.status.isGreen()) {
await reply.renderApp(app);
} else {
await reply.renderStatusPage();
}
} catch (err) {
reply(Boom.boomify(err));
}
}
});
async function getKibanaPayload({ app, request, includeUserProvidedConfig, injectedVarsOverrides }) {
const uiSettings = request.getUiSettingsService();
const translations = await uiI18n.getTranslationsForRequest(request);
return {
app: app,
nav: uiExports.navLinks.inOrder,
version: kbnServer.version,
branch: config.get('pkg.branch'),
buildNum: config.get('pkg.buildNum'),
buildSha: config.get('pkg.buildSha'),
basePath: config.get('server.basePath'),
serverName: config.get('server.name'),
devMode: config.get('env.dev'),
translations: translations,
uiSettings: await props({
defaults: uiSettings.getDefaults(),
user: includeUserProvidedConfig && uiSettings.getUserProvided()
}),
vars: await reduceAsync(
uiExports.injectedVarsReplacers,
async (acc, replacer) => await replacer(acc, request, server),
defaults(injectedVarsOverrides, await app.getInjectedVars() || {}, uiExports.defaultInjectedVars)
),
};
}
async function renderApp({ app, reply, includeUserProvidedConfig = true, injectedVarsOverrides = {} }) {
try {
const request = reply.request;
const translations = await uiI18n.getTranslationsForRequest(request);
return reply.view(app.templateName, {
app,
kibanaPayload: await getKibanaPayload({
app,
request,
includeUserProvidedConfig,
injectedVarsOverrides
}),
bundlePath: `${config.get('server.basePath')}/bundles`,
i18n: key => _.get(translations, key, ''),
});
} catch (err) {
reply(err);
}
}
server.decorate('reply', 'renderApp', function (app, injectedVarsOverrides) {
return renderApp({
app,
reply: this,
includeUserProvidedConfig: true,
injectedVarsOverrides,
});
});
server.decorate('reply', 'renderAppWithDefaultConfig', function (app) {
return renderApp({
app,
reply: this,
includeUserProvidedConfig: false,
});
});
};
export { uiMixin } from './ui_mixin';
export { collectUiExports } from './ui_exports';

View file

@ -7,4 +7,4 @@ angular.module('ui.bootstrap.bindHtml', [])
element.html(value || '');
});
};
});
});

Some files were not shown because too many files have changed in this diff Show more