diff --git a/.ci/Jenkinsfile_visual_baseline b/.ci/Jenkinsfile_visual_baseline index 2a16c499fa168c..7c7cc8d98c306e 100644 --- a/.ci/Jenkinsfile_visual_baseline +++ b/.ci/Jenkinsfile_visual_baseline @@ -21,5 +21,6 @@ kibanaPipeline(timeoutMinutes: 120) { } kibanaPipeline.sendMail() + slackNotifications.onFailure() } } diff --git a/docs/development/core/server/kibana-plugin-core-server.discoveredplugin.md b/docs/development/core/server/kibana-plugin-core-server.discoveredplugin.md index 0e2b9bd60ab670..b88a179c5c4b3a 100644 --- a/docs/development/core/server/kibana-plugin-core-server.discoveredplugin.md +++ b/docs/development/core/server/kibana-plugin-core-server.discoveredplugin.md @@ -19,5 +19,6 @@ export interface DiscoveredPlugin | [configPath](./kibana-plugin-core-server.discoveredplugin.configpath.md) | ConfigPath | Root configuration path used by the plugin, defaults to "id" in snake\_case format. | | [id](./kibana-plugin-core-server.discoveredplugin.id.md) | PluginName | Identifier of the plugin. | | [optionalPlugins](./kibana-plugin-core-server.discoveredplugin.optionalplugins.md) | readonly PluginName[] | An optional list of the other plugins that if installed and enabled \*\*may be\*\* leveraged by this plugin for some additional functionality but otherwise are not required for this plugin to work properly. | +| [requiredBundles](./kibana-plugin-core-server.discoveredplugin.requiredbundles.md) | readonly PluginName[] | List of plugin ids that this plugin's UI code imports modules from that are not in requiredPlugins. | | [requiredPlugins](./kibana-plugin-core-server.discoveredplugin.requiredplugins.md) | readonly PluginName[] | An optional list of the other plugins that \*\*must be\*\* installed and enabled for this plugin to function properly. | diff --git a/docs/development/core/server/kibana-plugin-core-server.discoveredplugin.requiredbundles.md b/docs/development/core/server/kibana-plugin-core-server.discoveredplugin.requiredbundles.md new file mode 100644 index 00000000000000..6d54adb5236eaa --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.discoveredplugin.requiredbundles.md @@ -0,0 +1,18 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [DiscoveredPlugin](./kibana-plugin-core-server.discoveredplugin.md) > [requiredBundles](./kibana-plugin-core-server.discoveredplugin.requiredbundles.md) + +## DiscoveredPlugin.requiredBundles property + +List of plugin ids that this plugin's UI code imports modules from that are not in `requiredPlugins`. + +Signature: + +```typescript +readonly requiredBundles: readonly PluginName[]; +``` + +## Remarks + +The plugins listed here will be loaded in the browser, even if the plugin is disabled. Required by `@kbn/optimizer` to support cross-plugin imports. "core" and plugins already listed in `requiredPlugins` do not need to be duplicated here. + diff --git a/docs/development/core/server/kibana-plugin-core-server.pluginmanifest.md b/docs/development/core/server/kibana-plugin-core-server.pluginmanifest.md index 5edee51d6c523d..6db2f89590149f 100644 --- a/docs/development/core/server/kibana-plugin-core-server.pluginmanifest.md +++ b/docs/development/core/server/kibana-plugin-core-server.pluginmanifest.md @@ -25,6 +25,7 @@ Should never be used in code outside of Core but is exported for documentation p | [id](./kibana-plugin-core-server.pluginmanifest.id.md) | PluginName | Identifier of the plugin. Must be a string in camelCase. Part of a plugin public contract. Other plugins leverage it to access plugin API, navigate to the plugin, etc. | | [kibanaVersion](./kibana-plugin-core-server.pluginmanifest.kibanaversion.md) | string | The version of Kibana the plugin is compatible with, defaults to "version". | | [optionalPlugins](./kibana-plugin-core-server.pluginmanifest.optionalplugins.md) | readonly PluginName[] | An optional list of the other plugins that if installed and enabled \*\*may be\*\* leveraged by this plugin for some additional functionality but otherwise are not required for this plugin to work properly. | +| [requiredBundles](./kibana-plugin-core-server.pluginmanifest.requiredbundles.md) | readonly string[] | List of plugin ids that this plugin's UI code imports modules from that are not in requiredPlugins. | | [requiredPlugins](./kibana-plugin-core-server.pluginmanifest.requiredplugins.md) | readonly PluginName[] | An optional list of the other plugins that \*\*must be\*\* installed and enabled for this plugin to function properly. | | [server](./kibana-plugin-core-server.pluginmanifest.server.md) | boolean | Specifies whether plugin includes some server-side specific functionality. | | [ui](./kibana-plugin-core-server.pluginmanifest.ui.md) | boolean | Specifies whether plugin includes some client/browser specific functionality that should be included into client bundle via public/ui_plugin.js file. | diff --git a/docs/development/core/server/kibana-plugin-core-server.pluginmanifest.requiredbundles.md b/docs/development/core/server/kibana-plugin-core-server.pluginmanifest.requiredbundles.md new file mode 100644 index 00000000000000..98505d07101fe2 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.pluginmanifest.requiredbundles.md @@ -0,0 +1,18 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [PluginManifest](./kibana-plugin-core-server.pluginmanifest.md) > [requiredBundles](./kibana-plugin-core-server.pluginmanifest.requiredbundles.md) + +## PluginManifest.requiredBundles property + +List of plugin ids that this plugin's UI code imports modules from that are not in `requiredPlugins`. + +Signature: + +```typescript +readonly requiredBundles: readonly string[]; +``` + +## Remarks + +The plugins listed here will be loaded in the browser, even if the plugin is disabled. Required by `@kbn/optimizer` to support cross-plugin imports. "core" and plugins already listed in `requiredPlugins` do not need to be duplicated here. + diff --git a/examples/bfetch_explorer/kibana.json b/examples/bfetch_explorer/kibana.json index 0039e9647bf83f..f32cdfc13a1fe2 100644 --- a/examples/bfetch_explorer/kibana.json +++ b/examples/bfetch_explorer/kibana.json @@ -5,5 +5,6 @@ "server": true, "ui": true, "requiredPlugins": ["bfetch", "developerExamples"], - "optionalPlugins": [] + "optionalPlugins": [], + "requiredBundles": ["kibanaReact"] } diff --git a/examples/dashboard_embeddable_examples/kibana.json b/examples/dashboard_embeddable_examples/kibana.json index bb2ced569edb5c..807229fad9dcf9 100644 --- a/examples/dashboard_embeddable_examples/kibana.json +++ b/examples/dashboard_embeddable_examples/kibana.json @@ -5,5 +5,6 @@ "server": false, "ui": true, "requiredPlugins": ["embeddable", "embeddableExamples", "dashboard", "developerExamples"], - "optionalPlugins": [] + "optionalPlugins": [], + "requiredBundles": ["esUiShared"] } diff --git a/examples/embeddable_examples/kibana.json b/examples/embeddable_examples/kibana.json index 8ae04c1f6c6444..771c19cfdbd3dd 100644 --- a/examples/embeddable_examples/kibana.json +++ b/examples/embeddable_examples/kibana.json @@ -6,5 +6,6 @@ "ui": true, "requiredPlugins": ["embeddable", "uiActions"], "optionalPlugins": [], - "extraPublicDirs": ["public/todo", "public/hello_world", "public/todo/todo_ref_embeddable"] + "extraPublicDirs": ["public/todo", "public/hello_world", "public/todo/todo_ref_embeddable"], + "requiredBundles": ["kibanaReact"] } diff --git a/examples/state_containers_examples/kibana.json b/examples/state_containers_examples/kibana.json index 66da207cb4e772..58346af8f1d191 100644 --- a/examples/state_containers_examples/kibana.json +++ b/examples/state_containers_examples/kibana.json @@ -5,5 +5,6 @@ "server": true, "ui": true, "requiredPlugins": ["navigation", "data", "developerExamples"], - "optionalPlugins": [] + "optionalPlugins": [], + "requiredBundles": ["kibanaUtils", "kibanaReact"] } diff --git a/examples/ui_action_examples/kibana.json b/examples/ui_action_examples/kibana.json index cd12442daf61c0..0e0b6b6830b950 100644 --- a/examples/ui_action_examples/kibana.json +++ b/examples/ui_action_examples/kibana.json @@ -5,5 +5,6 @@ "server": false, "ui": true, "requiredPlugins": ["uiActions"], - "optionalPlugins": [] + "optionalPlugins": [], + "requiredBundles": ["kibanaReact"] } diff --git a/examples/ui_actions_explorer/kibana.json b/examples/ui_actions_explorer/kibana.json index f57072e89b06dd..0a55e603747103 100644 --- a/examples/ui_actions_explorer/kibana.json +++ b/examples/ui_actions_explorer/kibana.json @@ -5,5 +5,6 @@ "server": false, "ui": true, "requiredPlugins": ["uiActions", "uiActionsExamples", "developerExamples"], - "optionalPlugins": [] + "optionalPlugins": [], + "requiredBundles": ["kibanaReact"] } diff --git a/packages/kbn-optimizer/src/__fixtures__/mock_repo/plugins/bar/kibana.json b/packages/kbn-optimizer/src/__fixtures__/mock_repo/plugins/bar/kibana.json index 20c8046daa65e5..33f53e336598d4 100644 --- a/packages/kbn-optimizer/src/__fixtures__/mock_repo/plugins/bar/kibana.json +++ b/packages/kbn-optimizer/src/__fixtures__/mock_repo/plugins/bar/kibana.json @@ -1,4 +1,5 @@ { "id": "bar", - "ui": true + "ui": true, + "requiredBundles": ["foo"] } diff --git a/packages/kbn-optimizer/src/__fixtures__/mock_repo/plugins/bar/public/legacy/_other_styles.scss b/packages/kbn-optimizer/src/__fixtures__/mock_repo/plugins/bar/public/legacy/_other_styles.scss new file mode 100644 index 00000000000000..2c1b9562b9567e --- /dev/null +++ b/packages/kbn-optimizer/src/__fixtures__/mock_repo/plugins/bar/public/legacy/_other_styles.scss @@ -0,0 +1,3 @@ +p { + background-color: rebeccapurple; +} diff --git a/packages/kbn-optimizer/src/__fixtures__/mock_repo/plugins/bar/public/legacy/styles.scss b/packages/kbn-optimizer/src/__fixtures__/mock_repo/plugins/bar/public/legacy/styles.scss index e71a2d485a2f85..1dc7bbe9daeb07 100644 --- a/packages/kbn-optimizer/src/__fixtures__/mock_repo/plugins/bar/public/legacy/styles.scss +++ b/packages/kbn-optimizer/src/__fixtures__/mock_repo/plugins/bar/public/legacy/styles.scss @@ -1,3 +1,5 @@ +@import "./other_styles.scss"; + body { width: $globalStyleConstant; background-image: url("ui/icon.svg"); diff --git a/packages/kbn-optimizer/src/cli.ts b/packages/kbn-optimizer/src/cli.ts index 0916f12a7110da..9d3f4b88a258f9 100644 --- a/packages/kbn-optimizer/src/cli.ts +++ b/packages/kbn-optimizer/src/cli.ts @@ -87,6 +87,11 @@ run( throw createFlagError('expected --report-stats to have no value'); } + const filter = typeof flags.filter === 'string' ? [flags.filter] : flags.filter; + if (!Array.isArray(filter) || !filter.every((f) => typeof f === 'string')) { + throw createFlagError('expected --filter to be one or more strings'); + } + const config = OptimizerConfig.create({ repoRoot: REPO_ROOT, watch, @@ -99,6 +104,7 @@ run( extraPluginScanDirs, inspectWorkers, includeCoreBundle, + filter, }); let update$ = runOptimizer(config); @@ -128,12 +134,13 @@ run( 'inspect-workers', 'report-stats', ], - string: ['workers', 'scan-dir'], + string: ['workers', 'scan-dir', 'filter'], default: { core: true, examples: true, cache: true, 'inspect-workers': true, + filter: [], }, help: ` --watch run the optimizer in watch mode @@ -142,6 +149,7 @@ run( --profile profile the webpack builds and write stats.json files to build outputs --no-core disable generating the core bundle --no-cache disable the cache + --filter comma-separated list of bundle id filters, results from multiple flags are merged, * and ! are supported --no-examples don't build the example plugins --dist create bundles that are suitable for inclusion in the Kibana distributable --scan-dir add a directory to the list of directories scanned for plugins (specify as many times as necessary) diff --git a/packages/kbn-optimizer/src/common/bundle.test.ts b/packages/kbn-optimizer/src/common/bundle.test.ts index b209bbca25ac4d..6197a084858548 100644 --- a/packages/kbn-optimizer/src/common/bundle.test.ts +++ b/packages/kbn-optimizer/src/common/bundle.test.ts @@ -50,6 +50,7 @@ it('creates cache keys', () => { "spec": Object { "contextDir": "/foo/bar", "id": "bar", + "manifestPath": undefined, "outputDir": "/foo/bar/target", "publicDirNames": Array [ "public", @@ -85,6 +86,7 @@ it('parses bundles from JSON specs', () => { }, "contextDir": "/foo/bar", "id": "bar", + "manifestPath": undefined, "outputDir": "/foo/bar/target", "publicDirNames": Array [ "public", diff --git a/packages/kbn-optimizer/src/common/bundle.ts b/packages/kbn-optimizer/src/common/bundle.ts index 80af94c30f8da4..a354da7a21521f 100644 --- a/packages/kbn-optimizer/src/common/bundle.ts +++ b/packages/kbn-optimizer/src/common/bundle.ts @@ -18,6 +18,7 @@ */ import Path from 'path'; +import Fs from 'fs'; import { BundleCache } from './bundle_cache'; import { UnknownVals } from './ts_helpers'; @@ -25,6 +26,11 @@ import { includes, ascending, entriesToObject } from './array_helpers'; const VALID_BUNDLE_TYPES = ['plugin' as const, 'entry' as const]; +const DEFAULT_IMPLICIT_BUNDLE_DEPS = ['core']; + +const isStringArray = (input: any): input is string[] => + Array.isArray(input) && input.every((x) => typeof x === 'string'); + export interface BundleSpec { readonly type: typeof VALID_BUNDLE_TYPES[0]; /** Unique id for this bundle */ @@ -37,6 +43,8 @@ export interface BundleSpec { readonly sourceRoot: string; /** Absolute path to the directory where output should be written */ readonly outputDir: string; + /** Absolute path to a kibana.json manifest file, if omitted we assume there are not dependenices */ + readonly manifestPath?: string; } export class Bundle { @@ -56,6 +64,12 @@ export class Bundle { public readonly sourceRoot: BundleSpec['sourceRoot']; /** Absolute path to the output directory for this bundle */ public readonly outputDir: BundleSpec['outputDir']; + /** + * Absolute path to a manifest file with "requiredBundles" which will be + * used to allow bundleRefs from this bundle to the exports of another bundle. + * Every bundle mentioned in the `requiredBundles` must be built together. + */ + public readonly manifestPath: BundleSpec['manifestPath']; public readonly cache: BundleCache; @@ -66,6 +80,7 @@ export class Bundle { this.contextDir = spec.contextDir; this.sourceRoot = spec.sourceRoot; this.outputDir = spec.outputDir; + this.manifestPath = spec.manifestPath; this.cache = new BundleCache(Path.resolve(this.outputDir, '.kbn-optimizer-cache')); } @@ -96,8 +111,54 @@ export class Bundle { contextDir: this.contextDir, sourceRoot: this.sourceRoot, outputDir: this.outputDir, + manifestPath: this.manifestPath, }; } + + readBundleDeps(): { implicit: string[]; explicit: string[] } { + if (!this.manifestPath) { + return { + implicit: [...DEFAULT_IMPLICIT_BUNDLE_DEPS], + explicit: [], + }; + } + + let json: string; + try { + json = Fs.readFileSync(this.manifestPath, 'utf8'); + } catch (error) { + if (error.code !== 'ENOENT') { + throw error; + } + + json = '{}'; + } + + let parsedManifest: { requiredPlugins?: string[]; requiredBundles?: string[] }; + try { + parsedManifest = JSON.parse(json); + } catch (error) { + throw new Error( + `unable to parse manifest at [${this.manifestPath}], error: [${error.message}]` + ); + } + + if (typeof parsedManifest === 'object' && parsedManifest) { + const explicit = parsedManifest.requiredBundles || []; + const implicit = [...DEFAULT_IMPLICIT_BUNDLE_DEPS, ...(parsedManifest.requiredPlugins || [])]; + + if (isStringArray(explicit) && isStringArray(implicit)) { + return { + explicit, + implicit, + }; + } + } + + throw new Error( + `Expected "requiredBundles" and "requiredPlugins" in manifest file [${this.manifestPath}] to be arrays of strings` + ); + } } /** @@ -152,6 +213,13 @@ export function parseBundles(json: string) { throw new Error('`bundles[]` must have an absolute path `outputDir` property'); } + const { manifestPath } = spec; + if (manifestPath !== undefined) { + if (!(typeof manifestPath === 'string' && Path.isAbsolute(manifestPath))) { + throw new Error('`bundles[]` must have an absolute path `manifestPath` property'); + } + } + return new Bundle({ type, id, @@ -159,6 +227,7 @@ export function parseBundles(json: string) { contextDir, sourceRoot, outputDir, + manifestPath, }); } ); diff --git a/packages/kbn-optimizer/src/common/bundle_cache.ts b/packages/kbn-optimizer/src/common/bundle_cache.ts index 5ae3e4c28a2018..7607e270b5b4f7 100644 --- a/packages/kbn-optimizer/src/common/bundle_cache.ts +++ b/packages/kbn-optimizer/src/common/bundle_cache.ts @@ -24,6 +24,7 @@ export interface State { optimizerCacheKey?: unknown; cacheKey?: unknown; moduleCount?: number; + workUnits?: number; files?: string[]; bundleRefExportIds?: string[]; } @@ -96,6 +97,10 @@ export class BundleCache { return this.get().cacheKey; } + public getWorkUnits() { + return this.get().workUnits; + } + public getOptimizerCacheKey() { return this.get().optimizerCacheKey; } diff --git a/packages/kbn-optimizer/src/common/bundle_refs.ts b/packages/kbn-optimizer/src/common/bundle_refs.ts index a5c60f2031c0b6..85731f32f8991f 100644 --- a/packages/kbn-optimizer/src/common/bundle_refs.ts +++ b/packages/kbn-optimizer/src/common/bundle_refs.ts @@ -114,6 +114,10 @@ export class BundleRefs { constructor(private readonly refs: BundleRef[]) {} + public forBundleIds(bundleIds: string[]) { + return this.refs.filter((r) => bundleIds.includes(r.bundleId)); + } + public filterByExportIds(exportIds: string[]) { return this.refs.filter((r) => exportIds.includes(r.exportId)); } diff --git a/packages/kbn-optimizer/src/integration_tests/__snapshots__/basic_optimization.test.ts.snap b/packages/kbn-optimizer/src/integration_tests/__snapshots__/basic_optimization.test.ts.snap index 211cfac3806ad7..c52873ab7ec20f 100644 --- a/packages/kbn-optimizer/src/integration_tests/__snapshots__/basic_optimization.test.ts.snap +++ b/packages/kbn-optimizer/src/integration_tests/__snapshots__/basic_optimization.test.ts.snap @@ -10,6 +10,7 @@ OptimizerConfig { }, "contextDir": /packages/kbn-optimizer/src/__fixtures__/__tmp__/mock_repo/plugins/bar, "id": "bar", + "manifestPath": /packages/kbn-optimizer/src/__fixtures__/__tmp__/mock_repo/plugins/bar/kibana.json, "outputDir": /packages/kbn-optimizer/src/__fixtures__/__tmp__/mock_repo/plugins/bar/target/public, "publicDirNames": Array [ "public", @@ -24,6 +25,7 @@ OptimizerConfig { }, "contextDir": /packages/kbn-optimizer/src/__fixtures__/__tmp__/mock_repo/plugins/foo, "id": "foo", + "manifestPath": /packages/kbn-optimizer/src/__fixtures__/__tmp__/mock_repo/plugins/foo/kibana.json, "outputDir": /packages/kbn-optimizer/src/__fixtures__/__tmp__/mock_repo/plugins/foo/target/public, "publicDirNames": Array [ "public", @@ -42,18 +44,21 @@ OptimizerConfig { "extraPublicDirs": Array [], "id": "bar", "isUiPlugin": true, + "manifestPath": /packages/kbn-optimizer/src/__fixtures__/__tmp__/mock_repo/plugins/bar/kibana.json, }, Object { "directory": /packages/kbn-optimizer/src/__fixtures__/__tmp__/mock_repo/plugins/foo, "extraPublicDirs": Array [], "id": "foo", "isUiPlugin": true, + "manifestPath": /packages/kbn-optimizer/src/__fixtures__/__tmp__/mock_repo/plugins/foo/kibana.json, }, Object { "directory": /packages/kbn-optimizer/src/__fixtures__/__tmp__/mock_repo/plugins/nested/baz, "extraPublicDirs": Array [], "id": "baz", "isUiPlugin": false, + "manifestPath": /packages/kbn-optimizer/src/__fixtures__/__tmp__/mock_repo/plugins/nested/baz/kibana.json, }, ], "profileWebpack": false, @@ -66,7 +71,7 @@ OptimizerConfig { } `; -exports[`prepares assets for distribution: bar bundle 1`] = `"(function(modules){var installedModules={};function __webpack_require__(moduleId){if(installedModules[moduleId]){return installedModules[moduleId].exports}var module=installedModules[moduleId]={i:moduleId,l:false,exports:{}};modules[moduleId].call(module.exports,module,module.exports,__webpack_require__);module.l=true;return module.exports}__webpack_require__.m=modules;__webpack_require__.c=installedModules;__webpack_require__.d=function(exports,name,getter){if(!__webpack_require__.o(exports,name)){Object.defineProperty(exports,name,{enumerable:true,get:getter})}};__webpack_require__.r=function(exports){if(typeof Symbol!==\\"undefined\\"&&Symbol.toStringTag){Object.defineProperty(exports,Symbol.toStringTag,{value:\\"Module\\"})}Object.defineProperty(exports,\\"__esModule\\",{value:true})};__webpack_require__.t=function(value,mode){if(mode&1)value=__webpack_require__(value);if(mode&8)return value;if(mode&4&&typeof value===\\"object\\"&&value&&value.__esModule)return value;var ns=Object.create(null);__webpack_require__.r(ns);Object.defineProperty(ns,\\"default\\",{enumerable:true,value:value});if(mode&2&&typeof value!=\\"string\\")for(var key in value)__webpack_require__.d(ns,key,function(key){return value[key]}.bind(null,key));return ns};__webpack_require__.n=function(module){var getter=module&&module.__esModule?function getDefault(){return module[\\"default\\"]}:function getModuleExports(){return module};__webpack_require__.d(getter,\\"a\\",getter);return getter};__webpack_require__.o=function(object,property){return Object.prototype.hasOwnProperty.call(object,property)};__webpack_require__.p=\\"\\";return __webpack_require__(__webpack_require__.s=5)})([function(module,exports,__webpack_require__){\\"use strict\\";var isOldIE=function isOldIE(){var memo;return function memorize(){if(typeof memo===\\"undefined\\"){memo=Boolean(window&&document&&document.all&&!window.atob)}return memo}}();var getTarget=function getTarget(){var memo={};return function memorize(target){if(typeof memo[target]===\\"undefined\\"){var styleTarget=document.querySelector(target);if(window.HTMLIFrameElement&&styleTarget instanceof window.HTMLIFrameElement){try{styleTarget=styleTarget.contentDocument.head}catch(e){styleTarget=null}}memo[target]=styleTarget}return memo[target]}}();var stylesInDom=[];function getIndexByIdentifier(identifier){var result=-1;for(var i=0;i { expect(foo.cache.getModuleCount()).toBe(6); expect(foo.cache.getReferencedFiles()).toMatchInlineSnapshot(` Array [ + /packages/kbn-optimizer/src/__fixtures__/__tmp__/mock_repo/plugins/foo/kibana.json, /packages/kbn-optimizer/src/__fixtures__/__tmp__/mock_repo/plugins/foo/public/async_import.ts, /packages/kbn-optimizer/src/__fixtures__/__tmp__/mock_repo/plugins/foo/public/ext.ts, /packages/kbn-optimizer/src/__fixtures__/__tmp__/mock_repo/plugins/foo/public/index.ts, @@ -160,12 +161,17 @@ it('builds expected bundles, saves bundle counts to metadata', async () => { Array [ /node_modules/css-loader/package.json, /node_modules/style-loader/package.json, + /packages/kbn-optimizer/src/__fixtures__/__tmp__/mock_repo/plugins/bar/kibana.json, /packages/kbn-optimizer/src/__fixtures__/__tmp__/mock_repo/plugins/bar/public/index.scss, /packages/kbn-optimizer/src/__fixtures__/__tmp__/mock_repo/plugins/bar/public/index.ts, + /packages/kbn-optimizer/src/__fixtures__/__tmp__/mock_repo/plugins/bar/public/legacy/_other_styles.scss, /packages/kbn-optimizer/src/__fixtures__/__tmp__/mock_repo/plugins/bar/public/legacy/styles.scss, /packages/kbn-optimizer/src/__fixtures__/__tmp__/mock_repo/plugins/bar/public/lib.ts, /packages/kbn-optimizer/src/__fixtures__/__tmp__/mock_repo/src/legacy/ui/public/icon.svg, + /packages/kbn-optimizer/src/__fixtures__/__tmp__/mock_repo/src/legacy/ui/public/styles/_globals_v7dark.scss, + /packages/kbn-optimizer/src/__fixtures__/__tmp__/mock_repo/src/legacy/ui/public/styles/_globals_v7light.scss, /packages/kbn-optimizer/target/worker/entry_point_creator.js, + /packages/kbn-optimizer/target/worker/postcss.config.js, /packages/kbn-ui-shared-deps/public_path_module_creator.js, ] `); diff --git a/packages/kbn-optimizer/src/log_optimizer_state.ts b/packages/kbn-optimizer/src/log_optimizer_state.ts index 23767be610da4f..20d98f74dbe860 100644 --- a/packages/kbn-optimizer/src/log_optimizer_state.ts +++ b/packages/kbn-optimizer/src/log_optimizer_state.ts @@ -54,12 +54,18 @@ export function logOptimizerState(log: ToolingLog, config: OptimizerConfig) { if (event?.type === 'worker started') { let moduleCount = 0; + let workUnits = 0; for (const bundle of event.bundles) { moduleCount += bundle.cache.getModuleCount() ?? NaN; + workUnits += bundle.cache.getWorkUnits() ?? NaN; } - const mcString = isFinite(moduleCount) ? String(moduleCount) : '?'; - const bcString = String(event.bundles.length); - log.info(`starting worker [${bcString} bundles, ${mcString} modules]`); + + log.info( + `starting worker [${event.bundles.length} ${ + event.bundles.length === 1 ? 'bundle' : 'bundles' + }]` + ); + log.debug(`modules [${moduleCount}] work units [${workUnits}]`); } if (state.phase === 'reallocating') { diff --git a/packages/kbn-optimizer/src/optimizer/assign_bundles_to_workers.test.ts b/packages/kbn-optimizer/src/optimizer/assign_bundles_to_workers.test.ts index ca50a49e269139..5443a88eb1a634 100644 --- a/packages/kbn-optimizer/src/optimizer/assign_bundles_to_workers.test.ts +++ b/packages/kbn-optimizer/src/optimizer/assign_bundles_to_workers.test.ts @@ -23,11 +23,11 @@ import { Bundle } from '../common'; import { assignBundlesToWorkers, Assignments } from './assign_bundles_to_workers'; -const hasModuleCount = (b: Bundle) => b.cache.getModuleCount() !== undefined; -const noModuleCount = (b: Bundle) => b.cache.getModuleCount() === undefined; +const hasWorkUnits = (b: Bundle) => b.cache.getWorkUnits() !== undefined; +const noWorkUnits = (b: Bundle) => b.cache.getWorkUnits() === undefined; const summarizeBundles = (w: Assignments) => [ - w.moduleCount ? `${w.moduleCount} known modules` : '', + w.workUnits ? `${w.workUnits} work units` : '', w.newBundles ? `${w.newBundles} new bundles` : '', ] .filter(Boolean) @@ -42,15 +42,15 @@ const assertReturnVal = (workers: Assignments[]) => { expect(workers).toBeInstanceOf(Array); for (const worker of workers) { expect(worker).toEqual({ - moduleCount: expect.any(Number), + workUnits: expect.any(Number), newBundles: expect.any(Number), bundles: expect.any(Array), }); - expect(worker.bundles.filter(noModuleCount).length).toBe(worker.newBundles); + expect(worker.bundles.filter(noWorkUnits).length).toBe(worker.newBundles); expect( - worker.bundles.filter(hasModuleCount).reduce((sum, b) => sum + b.cache.getModuleCount()!, 0) - ).toBe(worker.moduleCount); + worker.bundles.filter(hasWorkUnits).reduce((sum, b) => sum + b.cache.getWorkUnits()!, 0) + ).toBe(worker.workUnits); } }; @@ -76,7 +76,7 @@ const getBundles = ({ for (let i = 1; i <= withCounts; i++) { const id = `foo${i}`; const bundle = testBundle(id); - bundle.cache.set({ moduleCount: i % 5 === 0 ? i * 10 : i }); + bundle.cache.set({ workUnits: i % 5 === 0 ? i * 10 : i }); bundles.push(bundle); } @@ -95,8 +95,8 @@ it('creates less workers if maxWorkersCount is larger than bundle count', () => expect(workers.length).toBe(2); expect(readConfigs(workers)).toMatchInlineSnapshot(` Array [ - "worker 0 (1 known modules) => foo1", - "worker 1 (2 known modules) => foo2", + "worker 0 (1 work units) => foo1", + "worker 1 (2 work units) => foo2", ] `); }); @@ -121,10 +121,10 @@ it('distributes bundles without module counts evenly after assigning modules wit assertReturnVal(workers); expect(readConfigs(workers)).toMatchInlineSnapshot(` Array [ - "worker 0 (78 known modules, 3 new bundles) => foo5,foo11,foo8,foo6,foo2,foo1,bar9,bar5,bar1", - "worker 1 (78 known modules, 3 new bundles) => foo16,foo14,foo13,foo12,foo9,foo7,foo4,foo3,bar8,bar4,bar0", - "worker 2 (100 known modules, 2 new bundles) => foo10,bar7,bar3", - "worker 3 (150 known modules, 2 new bundles) => foo15,bar6,bar2", + "worker 0 (78 work units, 3 new bundles) => foo5,foo11,foo8,foo6,foo2,foo1,bar9,bar5,bar1", + "worker 1 (78 work units, 3 new bundles) => foo16,foo14,foo13,foo12,foo9,foo7,foo4,foo3,bar8,bar4,bar0", + "worker 2 (100 work units, 2 new bundles) => foo10,bar7,bar3", + "worker 3 (150 work units, 2 new bundles) => foo15,bar6,bar2", ] `); }); @@ -135,8 +135,8 @@ it('distributes 2 bundles to workers evenly', () => { assertReturnVal(workers); expect(readConfigs(workers)).toMatchInlineSnapshot(` Array [ - "worker 0 (1 known modules) => foo1", - "worker 1 (2 known modules) => foo2", + "worker 0 (1 work units) => foo1", + "worker 1 (2 work units) => foo2", ] `); }); @@ -147,10 +147,10 @@ it('distributes 5 bundles to workers evenly', () => { assertReturnVal(workers); expect(readConfigs(workers)).toMatchInlineSnapshot(` Array [ - "worker 0 (3 known modules) => foo2,foo1", - "worker 1 (3 known modules) => foo3", - "worker 2 (4 known modules) => foo4", - "worker 3 (50 known modules) => foo5", + "worker 0 (3 work units) => foo2,foo1", + "worker 1 (3 work units) => foo3", + "worker 2 (4 work units) => foo4", + "worker 3 (50 work units) => foo5", ] `); }); @@ -161,10 +161,10 @@ it('distributes 10 bundles to workers evenly', () => { assertReturnVal(workers); expect(readConfigs(workers)).toMatchInlineSnapshot(` Array [ - "worker 0 (20 known modules) => foo9,foo6,foo4,foo1", - "worker 1 (20 known modules) => foo8,foo7,foo3,foo2", - "worker 2 (50 known modules) => foo5", - "worker 3 (100 known modules) => foo10", + "worker 0 (20 work units) => foo9,foo6,foo4,foo1", + "worker 1 (20 work units) => foo8,foo7,foo3,foo2", + "worker 2 (50 work units) => foo5", + "worker 3 (100 work units) => foo10", ] `); }); @@ -175,10 +175,10 @@ it('distributes 15 bundles to workers evenly', () => { assertReturnVal(workers); expect(readConfigs(workers)).toMatchInlineSnapshot(` Array [ - "worker 0 (70 known modules) => foo14,foo13,foo12,foo11,foo9,foo6,foo4,foo1", - "worker 1 (70 known modules) => foo5,foo8,foo7,foo3,foo2", - "worker 2 (100 known modules) => foo10", - "worker 3 (150 known modules) => foo15", + "worker 0 (70 work units) => foo14,foo13,foo12,foo11,foo9,foo6,foo4,foo1", + "worker 1 (70 work units) => foo5,foo8,foo7,foo3,foo2", + "worker 2 (100 work units) => foo10", + "worker 3 (150 work units) => foo15", ] `); }); @@ -189,10 +189,10 @@ it('distributes 20 bundles to workers evenly', () => { assertReturnVal(workers); expect(readConfigs(workers)).toMatchInlineSnapshot(` Array [ - "worker 0 (153 known modules) => foo15,foo3", - "worker 1 (153 known modules) => foo10,foo16,foo13,foo11,foo7,foo6", - "worker 2 (154 known modules) => foo5,foo19,foo18,foo17,foo14,foo12,foo9,foo8,foo4,foo2,foo1", - "worker 3 (200 known modules) => foo20", + "worker 0 (153 work units) => foo15,foo3", + "worker 1 (153 work units) => foo10,foo16,foo13,foo11,foo7,foo6", + "worker 2 (154 work units) => foo5,foo19,foo18,foo17,foo14,foo12,foo9,foo8,foo4,foo2,foo1", + "worker 3 (200 work units) => foo20", ] `); }); @@ -203,10 +203,10 @@ it('distributes 25 bundles to workers evenly', () => { assertReturnVal(workers); expect(readConfigs(workers)).toMatchInlineSnapshot(` Array [ - "worker 0 (250 known modules) => foo20,foo17,foo13,foo9,foo8,foo2,foo1", - "worker 1 (250 known modules) => foo15,foo23,foo22,foo18,foo16,foo11,foo7,foo3", - "worker 2 (250 known modules) => foo10,foo5,foo24,foo21,foo19,foo14,foo12,foo6,foo4", - "worker 3 (250 known modules) => foo25", + "worker 0 (250 work units) => foo20,foo17,foo13,foo9,foo8,foo2,foo1", + "worker 1 (250 work units) => foo15,foo23,foo22,foo18,foo16,foo11,foo7,foo3", + "worker 2 (250 work units) => foo10,foo5,foo24,foo21,foo19,foo14,foo12,foo6,foo4", + "worker 3 (250 work units) => foo25", ] `); }); @@ -217,10 +217,10 @@ it('distributes 30 bundles to workers evenly', () => { assertReturnVal(workers); expect(readConfigs(workers)).toMatchInlineSnapshot(` Array [ - "worker 0 (352 known modules) => foo30,foo22,foo14,foo11,foo4,foo1", - "worker 1 (352 known modules) => foo15,foo10,foo28,foo24,foo19,foo16,foo9,foo6", - "worker 2 (353 known modules) => foo20,foo5,foo29,foo23,foo21,foo13,foo12,foo3,foo2", - "worker 3 (353 known modules) => foo25,foo27,foo26,foo18,foo17,foo8,foo7", + "worker 0 (352 work units) => foo30,foo22,foo14,foo11,foo4,foo1", + "worker 1 (352 work units) => foo15,foo10,foo28,foo24,foo19,foo16,foo9,foo6", + "worker 2 (353 work units) => foo20,foo5,foo29,foo23,foo21,foo13,foo12,foo3,foo2", + "worker 3 (353 work units) => foo25,foo27,foo26,foo18,foo17,foo8,foo7", ] `); }); diff --git a/packages/kbn-optimizer/src/optimizer/assign_bundles_to_workers.ts b/packages/kbn-optimizer/src/optimizer/assign_bundles_to_workers.ts index e1bcb22230bf9f..44a3b21c5fd470 100644 --- a/packages/kbn-optimizer/src/optimizer/assign_bundles_to_workers.ts +++ b/packages/kbn-optimizer/src/optimizer/assign_bundles_to_workers.ts @@ -20,19 +20,18 @@ import { Bundle, descending, ascending } from '../common'; // helper types used inside getWorkerConfigs so we don't have -// to calculate moduleCounts over and over - +// to calculate workUnits over and over export interface Assignments { - moduleCount: number; + workUnits: number; newBundles: number; bundles: Bundle[]; } /** assign a wrapped bundle to a worker */ const assignBundle = (worker: Assignments, bundle: Bundle) => { - const moduleCount = bundle.cache.getModuleCount(); - if (moduleCount !== undefined) { - worker.moduleCount += moduleCount; + const workUnits = bundle.cache.getWorkUnits(); + if (workUnits !== undefined) { + worker.workUnits += workUnits; } else { worker.newBundles += 1; } @@ -59,7 +58,7 @@ export function assignBundlesToWorkers(bundles: Bundle[], maxWorkerCount: number const workers: Assignments[] = []; for (let i = 0; i < workerCount; i++) { workers.push({ - moduleCount: 0, + workUnits: 0, newBundles: 0, bundles: [], }); @@ -67,18 +66,18 @@ export function assignBundlesToWorkers(bundles: Bundle[], maxWorkerCount: number /** * separate the bundles which do and don't have module - * counts and sort them by [moduleCount, id] + * counts and sort them by [workUnits, id] */ const bundlesWithCountsDesc = bundles - .filter((b) => b.cache.getModuleCount() !== undefined) + .filter((b) => b.cache.getWorkUnits() !== undefined) .sort( descending( - (b) => b.cache.getModuleCount(), + (b) => b.cache.getWorkUnits(), (b) => b.id ) ); const bundlesWithoutModuleCounts = bundles - .filter((b) => b.cache.getModuleCount() === undefined) + .filter((b) => b.cache.getWorkUnits() === undefined) .sort(descending((b) => b.id)); /** @@ -87,9 +86,9 @@ export function assignBundlesToWorkers(bundles: Bundle[], maxWorkerCount: number * with module counts are assigned */ while (bundlesWithCountsDesc.length) { - const [smallestWorker, nextSmallestWorker] = workers.sort(ascending((w) => w.moduleCount)); + const [smallestWorker, nextSmallestWorker] = workers.sort(ascending((w) => w.workUnits)); - while (!nextSmallestWorker || smallestWorker.moduleCount <= nextSmallestWorker.moduleCount) { + while (!nextSmallestWorker || smallestWorker.workUnits <= nextSmallestWorker.workUnits) { const bundle = bundlesWithCountsDesc.shift(); if (!bundle) { @@ -104,7 +103,7 @@ export function assignBundlesToWorkers(bundles: Bundle[], maxWorkerCount: number * assign bundles without module counts to workers round-robin * starting with the smallest workers */ - workers.sort(ascending((w) => w.moduleCount)); + workers.sort(ascending((w) => w.workUnits)); while (bundlesWithoutModuleCounts.length) { for (const worker of workers) { const bundle = bundlesWithoutModuleCounts.shift(); diff --git a/packages/kbn-optimizer/src/optimizer/filter_by_id.test.ts b/packages/kbn-optimizer/src/optimizer/filter_by_id.test.ts new file mode 100644 index 00000000000000..3e848fe616b490 --- /dev/null +++ b/packages/kbn-optimizer/src/optimizer/filter_by_id.test.ts @@ -0,0 +1,72 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { filterById, HasId } from './filter_by_id'; + +const bundles: HasId[] = [ + { id: 'foo' }, + { id: 'bar' }, + { id: 'abc' }, + { id: 'abcd' }, + { id: 'abcde' }, + { id: 'example_a' }, +]; + +const print = (result: HasId[]) => + result + .map((b) => b.id) + .sort((a, b) => a.localeCompare(b)) + .join(', '); + +it('[] matches everything', () => { + expect(print(filterById([], bundles))).toMatchInlineSnapshot( + `"abc, abcd, abcde, bar, example_a, foo"` + ); +}); + +it('* matches everything', () => { + expect(print(filterById(['*'], bundles))).toMatchInlineSnapshot( + `"abc, abcd, abcde, bar, example_a, foo"` + ); +}); + +it('combines mutliple filters to select any bundle which is matched', () => { + expect(print(filterById(['foo', 'bar'], bundles))).toMatchInlineSnapshot(`"bar, foo"`); + expect(print(filterById(['bar', 'abc*'], bundles))).toMatchInlineSnapshot( + `"abc, abcd, abcde, bar"` + ); +}); + +it('matches everything if any filter is *', () => { + expect(print(filterById(['*', '!abc*'], bundles))).toMatchInlineSnapshot( + `"abc, abcd, abcde, bar, example_a, foo"` + ); +}); + +it('only matches bundles which are matched by an entire single filter', () => { + expect(print(filterById(['*,!abc*'], bundles))).toMatchInlineSnapshot(`"bar, example_a, foo"`); +}); + +it('handles purely positive filters', () => { + expect(print(filterById(['abc*'], bundles))).toMatchInlineSnapshot(`"abc, abcd, abcde"`); +}); + +it('handles purely negative filters', () => { + expect(print(filterById(['!abc*'], bundles))).toMatchInlineSnapshot(`"bar, example_a, foo"`); +}); diff --git a/packages/kbn-optimizer/src/optimizer/filter_by_id.ts b/packages/kbn-optimizer/src/optimizer/filter_by_id.ts new file mode 100644 index 00000000000000..ccf61a9efc880c --- /dev/null +++ b/packages/kbn-optimizer/src/optimizer/filter_by_id.ts @@ -0,0 +1,48 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export interface HasId { + id: string; +} + +function parseFilter(filter: string) { + const positive: RegExp[] = []; + const negative: RegExp[] = []; + + for (const segment of filter.split(',')) { + let trimmed = segment.trim(); + let list = positive; + + if (trimmed.startsWith('!')) { + trimmed = trimmed.slice(1); + list = negative; + } + + list.push(new RegExp(`^${trimmed.split('*').join('.*')}$`)); + } + + return (bundle: HasId) => + (!positive.length || positive.some((p) => p.test(bundle.id))) && + (!negative.length || !negative.some((p) => p.test(bundle.id))); +} + +export function filterById(filterStrings: string[], bundles: T[]) { + const filters = filterStrings.map(parseFilter); + return bundles.filter((b) => !filters.length || filters.some((f) => f(b))); +} diff --git a/packages/kbn-optimizer/src/optimizer/get_plugin_bundles.test.ts b/packages/kbn-optimizer/src/optimizer/get_plugin_bundles.test.ts index bbd3ddc11f448d..a70cfc759dd55b 100644 --- a/packages/kbn-optimizer/src/optimizer/get_plugin_bundles.test.ts +++ b/packages/kbn-optimizer/src/optimizer/get_plugin_bundles.test.ts @@ -32,18 +32,21 @@ it('returns a bundle for core and each plugin', () => { id: 'foo', isUiPlugin: true, extraPublicDirs: [], + manifestPath: '/repo/plugins/foo/kibana.json', }, { directory: '/repo/plugins/bar', id: 'bar', isUiPlugin: false, extraPublicDirs: [], + manifestPath: '/repo/plugins/bar/kibana.json', }, { directory: '/outside/of/repo/plugins/baz', id: 'baz', isUiPlugin: true, extraPublicDirs: [], + manifestPath: '/outside/of/repo/plugins/baz/kibana.json', }, ], '/repo' @@ -53,6 +56,7 @@ it('returns a bundle for core and each plugin', () => { Object { "contextDir": /plugins/foo, "id": "foo", + "manifestPath": /plugins/foo/kibana.json, "outputDir": /plugins/foo/target/public, "publicDirNames": Array [ "public", @@ -63,6 +67,7 @@ it('returns a bundle for core and each plugin', () => { Object { "contextDir": "/outside/of/repo/plugins/baz", "id": "baz", + "manifestPath": "/outside/of/repo/plugins/baz/kibana.json", "outputDir": "/outside/of/repo/plugins/baz/target/public", "publicDirNames": Array [ "public", diff --git a/packages/kbn-optimizer/src/optimizer/get_plugin_bundles.ts b/packages/kbn-optimizer/src/optimizer/get_plugin_bundles.ts index 2635289088725f..04ab992addeec1 100644 --- a/packages/kbn-optimizer/src/optimizer/get_plugin_bundles.ts +++ b/packages/kbn-optimizer/src/optimizer/get_plugin_bundles.ts @@ -35,6 +35,7 @@ export function getPluginBundles(plugins: KibanaPlatformPlugin[], repoRoot: stri sourceRoot: repoRoot, contextDir: p.directory, outputDir: Path.resolve(p.directory, 'target/public'), + manifestPath: p.manifestPath, }) ); } diff --git a/packages/kbn-optimizer/src/optimizer/kibana_platform_plugins.test.ts b/packages/kbn-optimizer/src/optimizer/kibana_platform_plugins.test.ts index f7b457ca42c6dc..06fffc953f58bb 100644 --- a/packages/kbn-optimizer/src/optimizer/kibana_platform_plugins.test.ts +++ b/packages/kbn-optimizer/src/optimizer/kibana_platform_plugins.test.ts @@ -40,24 +40,28 @@ it('parses kibana.json files of plugins found in pluginDirs', () => { "extraPublicDirs": Array [], "id": "bar", "isUiPlugin": true, + "manifestPath": /packages/kbn-optimizer/src/__fixtures__/mock_repo/plugins/bar/kibana.json, }, Object { "directory": /packages/kbn-optimizer/src/__fixtures__/mock_repo/plugins/foo, "extraPublicDirs": Array [], "id": "foo", "isUiPlugin": true, + "manifestPath": /packages/kbn-optimizer/src/__fixtures__/mock_repo/plugins/foo/kibana.json, }, Object { "directory": /packages/kbn-optimizer/src/__fixtures__/mock_repo/plugins/nested/baz, "extraPublicDirs": Array [], "id": "baz", "isUiPlugin": false, + "manifestPath": /packages/kbn-optimizer/src/__fixtures__/mock_repo/plugins/nested/baz/kibana.json, }, Object { "directory": /packages/kbn-optimizer/src/__fixtures__/mock_repo/test_plugins/test_baz, "extraPublicDirs": Array [], "id": "test_baz", "isUiPlugin": false, + "manifestPath": /packages/kbn-optimizer/src/__fixtures__/mock_repo/test_plugins/test_baz/kibana.json, }, ] `); diff --git a/packages/kbn-optimizer/src/optimizer/kibana_platform_plugins.ts b/packages/kbn-optimizer/src/optimizer/kibana_platform_plugins.ts index 83637691004f40..b489c53be47b96 100644 --- a/packages/kbn-optimizer/src/optimizer/kibana_platform_plugins.ts +++ b/packages/kbn-optimizer/src/optimizer/kibana_platform_plugins.ts @@ -24,6 +24,7 @@ import loadJsonFile from 'load-json-file'; export interface KibanaPlatformPlugin { readonly directory: string; + readonly manifestPath: string; readonly id: string; readonly isUiPlugin: boolean; readonly extraPublicDirs: string[]; @@ -92,6 +93,7 @@ function readKibanaPlatformPlugin(manifestPath: string): KibanaPlatformPlugin { return { directory: Path.dirname(manifestPath), + manifestPath, id: manifest.id, isUiPlugin: !!manifest.ui, extraPublicDirs: extraPublicDirs || [], diff --git a/packages/kbn-optimizer/src/optimizer/optimizer_config.test.ts b/packages/kbn-optimizer/src/optimizer/optimizer_config.test.ts index 5b46d67479fd58..f97646e2bbbd3e 100644 --- a/packages/kbn-optimizer/src/optimizer/optimizer_config.test.ts +++ b/packages/kbn-optimizer/src/optimizer/optimizer_config.test.ts @@ -21,6 +21,7 @@ jest.mock('./assign_bundles_to_workers.ts'); jest.mock('./kibana_platform_plugins.ts'); jest.mock('./get_plugin_bundles.ts'); jest.mock('../common/theme_tags.ts'); +jest.mock('./filter_by_id.ts'); import Path from 'path'; import Os from 'os'; @@ -113,6 +114,7 @@ describe('OptimizerConfig::parseOptions()', () => { Object { "cache": true, "dist": false, + "filters": Array [], "includeCoreBundle": false, "inspectWorkers": false, "maxWorkerCount": 2, @@ -139,6 +141,7 @@ describe('OptimizerConfig::parseOptions()', () => { Object { "cache": false, "dist": false, + "filters": Array [], "includeCoreBundle": false, "inspectWorkers": false, "maxWorkerCount": 2, @@ -165,6 +168,7 @@ describe('OptimizerConfig::parseOptions()', () => { Object { "cache": true, "dist": false, + "filters": Array [], "includeCoreBundle": false, "inspectWorkers": false, "maxWorkerCount": 2, @@ -193,6 +197,7 @@ describe('OptimizerConfig::parseOptions()', () => { Object { "cache": true, "dist": false, + "filters": Array [], "includeCoreBundle": false, "inspectWorkers": false, "maxWorkerCount": 2, @@ -218,6 +223,7 @@ describe('OptimizerConfig::parseOptions()', () => { Object { "cache": true, "dist": false, + "filters": Array [], "includeCoreBundle": false, "inspectWorkers": false, "maxWorkerCount": 2, @@ -243,6 +249,7 @@ describe('OptimizerConfig::parseOptions()', () => { Object { "cache": true, "dist": false, + "filters": Array [], "includeCoreBundle": false, "inspectWorkers": false, "maxWorkerCount": 100, @@ -265,6 +272,7 @@ describe('OptimizerConfig::parseOptions()', () => { Object { "cache": false, "dist": false, + "filters": Array [], "includeCoreBundle": false, "inspectWorkers": false, "maxWorkerCount": 100, @@ -287,6 +295,7 @@ describe('OptimizerConfig::parseOptions()', () => { Object { "cache": false, "dist": false, + "filters": Array [], "includeCoreBundle": false, "inspectWorkers": false, "maxWorkerCount": 100, @@ -310,6 +319,7 @@ describe('OptimizerConfig::parseOptions()', () => { Object { "cache": false, "dist": false, + "filters": Array [], "includeCoreBundle": false, "inspectWorkers": false, "maxWorkerCount": 100, @@ -333,6 +343,7 @@ describe('OptimizerConfig::parseOptions()', () => { Object { "cache": true, "dist": false, + "filters": Array [], "includeCoreBundle": false, "inspectWorkers": false, "maxWorkerCount": 100, @@ -358,6 +369,7 @@ describe('OptimizerConfig::create()', () => { const findKibanaPlatformPlugins: jest.Mock = jest.requireMock('./kibana_platform_plugins.ts') .findKibanaPlatformPlugins; const getPluginBundles: jest.Mock = jest.requireMock('./get_plugin_bundles.ts').getPluginBundles; + const filterById: jest.Mock = jest.requireMock('./filter_by_id.ts').filterById; beforeEach(() => { if ('mock' in OptimizerConfig.parseOptions) { @@ -370,6 +382,7 @@ describe('OptimizerConfig::create()', () => { ]); findKibanaPlatformPlugins.mockReturnValue(Symbol('new platform plugins')); getPluginBundles.mockReturnValue([Symbol('bundle1'), Symbol('bundle2')]); + filterById.mockReturnValue(Symbol('filtered bundles')); jest.spyOn(OptimizerConfig, 'parseOptions').mockImplementation((): any => ({ cache: Symbol('parsed cache'), @@ -382,6 +395,7 @@ describe('OptimizerConfig::create()', () => { themeTags: Symbol('theme tags'), inspectWorkers: Symbol('parsed inspect workers'), profileWebpack: Symbol('parsed profile webpack'), + filters: [], })); }); @@ -392,10 +406,7 @@ describe('OptimizerConfig::create()', () => { expect(config).toMatchInlineSnapshot(` OptimizerConfig { - "bundles": Array [ - Symbol(bundle1), - Symbol(bundle2), - ], + "bundles": Symbol(filtered bundles), "cache": Symbol(parsed cache), "dist": Symbol(parsed dist), "inspectWorkers": Symbol(parsed inspect workers), @@ -431,6 +442,32 @@ describe('OptimizerConfig::create()', () => { } `); + expect(filterById.mock).toMatchInlineSnapshot(` + Object { + "calls": Array [ + Array [ + Array [], + Array [ + Symbol(bundle1), + Symbol(bundle2), + ], + ], + ], + "instances": Array [ + [Window], + ], + "invocationCallOrder": Array [ + 23, + ], + "results": Array [ + Object { + "type": "return", + "value": Symbol(filtered bundles), + }, + ], + } + `); + expect(getPluginBundles.mock).toMatchInlineSnapshot(` Object { "calls": Array [ diff --git a/packages/kbn-optimizer/src/optimizer/optimizer_config.ts b/packages/kbn-optimizer/src/optimizer/optimizer_config.ts index 7757004139d0d4..0e588ab36238b3 100644 --- a/packages/kbn-optimizer/src/optimizer/optimizer_config.ts +++ b/packages/kbn-optimizer/src/optimizer/optimizer_config.ts @@ -31,6 +31,7 @@ import { import { findKibanaPlatformPlugins, KibanaPlatformPlugin } from './kibana_platform_plugins'; import { getPluginBundles } from './get_plugin_bundles'; +import { filterById } from './filter_by_id'; function pickMaxWorkerCount(dist: boolean) { // don't break if cpus() returns nothing, or an empty array @@ -77,6 +78,18 @@ interface Options { pluginScanDirs?: string[]; /** absolute paths that should be added to the default scan dirs */ extraPluginScanDirs?: string[]; + /** + * array of comma separated patterns that will be matched against bundle ids. + * bundles will only be built if they match one of the specified patterns. + * `*` can exist anywhere in each pattern and will match anything, `!` inverts the pattern + * + * examples: + * --filter foo --filter bar # [foo, bar], excludes [foobar] + * --filter foo,bar # [foo, bar], excludes [foobar] + * --filter foo* # [foo, foobar], excludes [bar] + * --filter f*r # [foobar], excludes [foo, bar] + */ + filter?: string[]; /** flag that causes the core bundle to be built along with plugins */ includeCoreBundle?: boolean; @@ -103,6 +116,7 @@ interface ParsedOptions { dist: boolean; pluginPaths: string[]; pluginScanDirs: string[]; + filters: string[]; inspectWorkers: boolean; includeCoreBundle: boolean; themeTags: ThemeTags; @@ -118,6 +132,7 @@ export class OptimizerConfig { const inspectWorkers = !!options.inspectWorkers; const cache = options.cache !== false && !process.env.KBN_OPTIMIZER_NO_CACHE; const includeCoreBundle = !!options.includeCoreBundle; + const filters = options.filter || []; const repoRoot = options.repoRoot; if (!Path.isAbsolute(repoRoot)) { @@ -172,6 +187,7 @@ export class OptimizerConfig { cache, pluginScanDirs, pluginPaths, + filters, inspectWorkers, includeCoreBundle, themeTags, @@ -198,7 +214,7 @@ export class OptimizerConfig { ]; return new OptimizerConfig( - bundles, + filterById(options.filters, bundles), options.cache, options.watch, options.inspectWorkers, diff --git a/packages/kbn-optimizer/src/worker/bundle_ref_module.ts b/packages/kbn-optimizer/src/worker/bundle_ref_module.ts index cde25564cf5282..563b4ecb4bc37b 100644 --- a/packages/kbn-optimizer/src/worker/bundle_ref_module.ts +++ b/packages/kbn-optimizer/src/worker/bundle_ref_module.ts @@ -10,6 +10,7 @@ // @ts-ignore not typed by @types/webpack import Module from 'webpack/lib/Module'; +import { BundleRef } from '../common'; export class BundleRefModule extends Module { public built = false; @@ -17,12 +18,12 @@ export class BundleRefModule extends Module { public buildInfo?: any; public exportsArgument = '__webpack_exports__'; - constructor(public readonly exportId: string) { + constructor(public readonly ref: BundleRef) { super('kbn/bundleRef', null); } libIdent() { - return this.exportId; + return this.ref.exportId; } chunkCondition(chunk: any) { @@ -30,7 +31,7 @@ export class BundleRefModule extends Module { } identifier() { - return '@kbn/bundleRef ' + JSON.stringify(this.exportId); + return '@kbn/bundleRef ' + JSON.stringify(this.ref.exportId); } readableIdentifier() { @@ -51,7 +52,7 @@ export class BundleRefModule extends Module { source() { return ` __webpack_require__.r(__webpack_exports__); - var ns = __kbnBundles__.get('${this.exportId}'); + var ns = __kbnBundles__.get('${this.ref.exportId}'); Object.defineProperties(__webpack_exports__, Object.getOwnPropertyDescriptors(ns)) `; } diff --git a/packages/kbn-optimizer/src/worker/bundle_refs_plugin.ts b/packages/kbn-optimizer/src/worker/bundle_refs_plugin.ts index 9c4d5ed7f8a983..5396d11726f7a7 100644 --- a/packages/kbn-optimizer/src/worker/bundle_refs_plugin.ts +++ b/packages/kbn-optimizer/src/worker/bundle_refs_plugin.ts @@ -44,6 +44,7 @@ export class BundleRefsPlugin { private readonly resolvedRefEntryCache = new Map>(); private readonly resolvedRequestCache = new Map>(); private readonly ignorePrefix = Path.resolve(this.bundle.contextDir) + Path.sep; + private allowedBundleIds = new Set(); constructor(private readonly bundle: Bundle, private readonly bundleRefs: BundleRefs) {} @@ -81,6 +82,45 @@ export class BundleRefsPlugin { } ); }); + + compiler.hooks.compilation.tap('BundleRefsPlugin/getRequiredBundles', (compilation) => { + this.allowedBundleIds.clear(); + + const manifestPath = this.bundle.manifestPath; + if (!manifestPath) { + return; + } + + const deps = this.bundle.readBundleDeps(); + for (const ref of this.bundleRefs.forBundleIds([...deps.explicit, ...deps.implicit])) { + this.allowedBundleIds.add(ref.bundleId); + } + + compilation.hooks.additionalAssets.tap('BundleRefsPlugin/watchManifest', () => { + compilation.fileDependencies.add(manifestPath); + }); + + compilation.hooks.finishModules.tapPromise( + 'BundleRefsPlugin/finishModules', + async (modules) => { + const usedBundleIds = (modules as any[]) + .filter((m: any): m is BundleRefModule => m instanceof BundleRefModule) + .map((m) => m.ref.bundleId); + + const unusedBundleIds = deps.explicit + .filter((id) => !usedBundleIds.includes(id)) + .join(', '); + + if (unusedBundleIds) { + const error = new Error( + `Bundle for [${this.bundle.id}] lists [${unusedBundleIds}] as a required bundle, but does not use it. Please remove it.` + ); + (error as any).file = manifestPath; + compilation.errors.push(error); + } + } + ); + }); } private cachedResolveRefEntry(ref: BundleRef) { @@ -170,21 +210,29 @@ export class BundleRefsPlugin { return; } - const eligibleRefs = this.bundleRefs.filterByContextPrefix(this.bundle, resolved); - if (!eligibleRefs.length) { + const possibleRefs = this.bundleRefs.filterByContextPrefix(this.bundle, resolved); + if (!possibleRefs.length) { // import doesn't match a bundle context return; } - for (const ref of eligibleRefs) { + for (const ref of possibleRefs) { const resolvedEntry = await this.cachedResolveRefEntry(ref); - if (resolved === resolvedEntry) { - return new BundleRefModule(ref.exportId); + if (resolved !== resolvedEntry) { + continue; } + + if (!this.allowedBundleIds.has(ref.bundleId)) { + throw new Error( + `import [${request}] references a public export of the [${ref.bundleId}] bundle, but that bundle is not in the "requiredPlugins" or "requiredBundles" list in the plugin manifest [${this.bundle.manifestPath}]` + ); + } + + return new BundleRefModule(ref); } - const bundleId = Array.from(new Set(eligibleRefs.map((r) => r.bundleId))).join(', '); - const publicDir = eligibleRefs.map((r) => r.entry).join(', '); + const bundleId = Array.from(new Set(possibleRefs.map((r) => r.bundleId))).join(', '); + const publicDir = possibleRefs.map((r) => r.entry).join(', '); throw new Error( `import [${request}] references a non-public export of the [${bundleId}] bundle and must point to one of the public directories: [${publicDir}]` ); diff --git a/packages/kbn-optimizer/src/worker/run_compilers.ts b/packages/kbn-optimizer/src/worker/run_compilers.ts index ca7673748bde91..c7be943d65a489 100644 --- a/packages/kbn-optimizer/src/worker/run_compilers.ts +++ b/packages/kbn-optimizer/src/worker/run_compilers.ts @@ -50,6 +50,15 @@ import { const PLUGIN_NAME = '@kbn/optimizer'; +/** + * sass-loader creates about a 40% overhead on the overall optimizer runtime, and + * so this constant is used to indicate to assignBundlesToWorkers() that there is + * extra work done in a bundle that has a lot of scss imports. The value is + * arbitrary and just intended to weigh the bundles so that they are distributed + * across mulitple workers on machines with lots of cores. + */ +const EXTRA_SCSS_WORK_UNITS = 100; + /** * Create an Observable for a specific child compiler + bundle */ @@ -102,6 +111,11 @@ const observeCompiler = ( const bundleRefExportIds: string[] = []; const referencedFiles = new Set(); let normalModuleCount = 0; + let workUnits = stats.compilation.fileDependencies.size; + + if (bundle.manifestPath) { + referencedFiles.add(bundle.manifestPath); + } for (const module of stats.compilation.modules) { if (isNormalModule(module)) { @@ -111,6 +125,15 @@ const observeCompiler = ( if (!parsedPath.dirs.includes('node_modules')) { referencedFiles.add(path); + + if (path.endsWith('.scss')) { + workUnits += EXTRA_SCSS_WORK_UNITS; + + for (const depPath of module.buildInfo.fileDependencies) { + referencedFiles.add(depPath); + } + } + continue; } @@ -127,7 +150,7 @@ const observeCompiler = ( } if (module instanceof BundleRefModule) { - bundleRefExportIds.push(module.exportId); + bundleRefExportIds.push(module.ref.exportId); continue; } @@ -158,6 +181,7 @@ const observeCompiler = ( optimizerCacheKey: workerConfig.optimizerCacheKey, cacheKey: bundle.createCacheKey(files, mtimes), moduleCount: normalModuleCount, + workUnits, files, }); diff --git a/src/core/public/plugins/plugin.test.ts b/src/core/public/plugins/plugin.test.ts index 3f77161f8c34d9..7331157951dec0 100644 --- a/src/core/public/plugins/plugin.test.ts +++ b/src/core/public/plugins/plugin.test.ts @@ -32,6 +32,7 @@ function createManifest( configPath: ['path'], requiredPlugins: required, optionalPlugins: optional, + requiredBundles: [], } as DiscoveredPlugin; } diff --git a/src/core/public/plugins/plugins_service.test.ts b/src/core/public/plugins/plugins_service.test.ts index 7dc5f3655fca03..28cbfce3fd6510 100644 --- a/src/core/public/plugins/plugins_service.test.ts +++ b/src/core/public/plugins/plugins_service.test.ts @@ -73,6 +73,7 @@ function createManifest( configPath: ['path'], requiredPlugins: required, optionalPlugins: optional, + requiredBundles: [], }; } diff --git a/src/core/server/legacy/legacy_service.test.ts b/src/core/server/legacy/legacy_service.test.ts index ae4cf612e92ce7..f8f04c59766b38 100644 --- a/src/core/server/legacy/legacy_service.test.ts +++ b/src/core/server/legacy/legacy_service.test.ts @@ -109,6 +109,7 @@ beforeEach(() => { [ 'plugin-id', { + requiredBundles: [], publicTargetDir: 'path/to/target/public', publicAssetsDir: '/plugins/name/assets/', }, diff --git a/src/core/server/plugins/discovery/plugin_manifest_parser.test.ts b/src/core/server/plugins/discovery/plugin_manifest_parser.test.ts index 5ffdef88104c83..64d1256be2f30f 100644 --- a/src/core/server/plugins/discovery/plugin_manifest_parser.test.ts +++ b/src/core/server/plugins/discovery/plugin_manifest_parser.test.ts @@ -302,6 +302,7 @@ test('set defaults for all missing optional fields', async () => { kibanaVersion: '7.0.0', optionalPlugins: [], requiredPlugins: [], + requiredBundles: [], server: true, ui: false, }); @@ -331,6 +332,7 @@ test('return all set optional fields as they are in manifest', async () => { version: 'some-version', kibanaVersion: '7.0.0', optionalPlugins: ['some-optional-plugin'], + requiredBundles: [], requiredPlugins: ['some-required-plugin', 'some-required-plugin-2'], server: false, ui: true, @@ -361,6 +363,7 @@ test('return manifest when plugin expected Kibana version matches actual version kibanaVersion: '7.0.0-alpha2', optionalPlugins: [], requiredPlugins: ['some-required-plugin'], + requiredBundles: [], server: true, ui: false, }); @@ -390,6 +393,7 @@ test('return manifest when plugin expected Kibana version is `kibana`', async () kibanaVersion: 'kibana', optionalPlugins: [], requiredPlugins: ['some-required-plugin'], + requiredBundles: [], server: true, ui: true, }); diff --git a/src/core/server/plugins/discovery/plugin_manifest_parser.ts b/src/core/server/plugins/discovery/plugin_manifest_parser.ts index f2c3a29eca0ac6..0d33e266c37dbd 100644 --- a/src/core/server/plugins/discovery/plugin_manifest_parser.ts +++ b/src/core/server/plugins/discovery/plugin_manifest_parser.ts @@ -58,6 +58,7 @@ const KNOWN_MANIFEST_FIELDS = (() => { ui: true, server: true, extraPublicDirs: true, + requiredBundles: true, }; return new Set(Object.keys(manifestFields)); @@ -191,6 +192,7 @@ export async function parseManifest( configPath: manifest.configPath || snakeCase(manifest.id), requiredPlugins: Array.isArray(manifest.requiredPlugins) ? manifest.requiredPlugins : [], optionalPlugins: Array.isArray(manifest.optionalPlugins) ? manifest.optionalPlugins : [], + requiredBundles: Array.isArray(manifest.requiredBundles) ? manifest.requiredBundles : [], ui: includesUiPlugin, server: includesServerPlugin, extraPublicDirs: manifest.extraPublicDirs, diff --git a/src/core/server/plugins/integration_tests/plugins_service.test.ts b/src/core/server/plugins/integration_tests/plugins_service.test.ts index e676c789449caf..49c129d0ae67d7 100644 --- a/src/core/server/plugins/integration_tests/plugins_service.test.ts +++ b/src/core/server/plugins/integration_tests/plugins_service.test.ts @@ -43,6 +43,7 @@ describe('PluginsService', () => { disabled = false, version = 'some-version', requiredPlugins = [], + requiredBundles = [], optionalPlugins = [], kibanaVersion = '7.0.0', configPath = [path], @@ -53,6 +54,7 @@ describe('PluginsService', () => { disabled?: boolean; version?: string; requiredPlugins?: string[]; + requiredBundles?: string[]; optionalPlugins?: string[]; kibanaVersion?: string; configPath?: ConfigPath; @@ -68,6 +70,7 @@ describe('PluginsService', () => { configPath: `${configPath}${disabled ? '-disabled' : ''}`, kibanaVersion, requiredPlugins, + requiredBundles, optionalPlugins, server, ui, diff --git a/src/core/server/plugins/plugin.test.ts b/src/core/server/plugins/plugin.test.ts index ec0a3986b48775..4f26686e1f5e0c 100644 --- a/src/core/server/plugins/plugin.test.ts +++ b/src/core/server/plugins/plugin.test.ts @@ -54,6 +54,7 @@ function createPluginManifest(manifestProps: Partial = {}): Plug kibanaVersion: '7.0.0', requiredPlugins: ['some-required-dep'], optionalPlugins: ['some-optional-dep'], + requiredBundles: [], server: true, ui: true, ...manifestProps, diff --git a/src/core/server/plugins/plugin.ts b/src/core/server/plugins/plugin.ts index 2e5881c6518439..8bd98408556546 100644 --- a/src/core/server/plugins/plugin.ts +++ b/src/core/server/plugins/plugin.ts @@ -53,6 +53,7 @@ export class PluginWrapper< public readonly configPath: PluginManifest['configPath']; public readonly requiredPlugins: PluginManifest['requiredPlugins']; public readonly optionalPlugins: PluginManifest['optionalPlugins']; + public readonly requiredBundles: PluginManifest['requiredBundles']; public readonly includesServerPlugin: PluginManifest['server']; public readonly includesUiPlugin: PluginManifest['ui']; @@ -81,6 +82,7 @@ export class PluginWrapper< this.configPath = params.manifest.configPath; this.requiredPlugins = params.manifest.requiredPlugins; this.optionalPlugins = params.manifest.optionalPlugins; + this.requiredBundles = params.manifest.requiredBundles; this.includesServerPlugin = params.manifest.server; this.includesUiPlugin = params.manifest.ui; } diff --git a/src/core/server/plugins/plugin_context.test.ts b/src/core/server/plugins/plugin_context.test.ts index 69b354661abc9d..ebd068caadfb9f 100644 --- a/src/core/server/plugins/plugin_context.test.ts +++ b/src/core/server/plugins/plugin_context.test.ts @@ -43,6 +43,7 @@ function createPluginManifest(manifestProps: Partial = {}): Plug configPath: 'path', kibanaVersion: '7.0.0', requiredPlugins: ['some-required-dep'], + requiredBundles: [], optionalPlugins: ['some-optional-dep'], server: true, ui: true, diff --git a/src/core/server/plugins/plugins_service.test.ts b/src/core/server/plugins/plugins_service.test.ts index 46fd2b00c23044..aa77335991e2cd 100644 --- a/src/core/server/plugins/plugins_service.test.ts +++ b/src/core/server/plugins/plugins_service.test.ts @@ -64,6 +64,7 @@ const createPlugin = ( disabled = false, version = 'some-version', requiredPlugins = [], + requiredBundles = [], optionalPlugins = [], kibanaVersion = '7.0.0', configPath = [path], @@ -74,6 +75,7 @@ const createPlugin = ( disabled?: boolean; version?: string; requiredPlugins?: string[]; + requiredBundles?: string[]; optionalPlugins?: string[]; kibanaVersion?: string; configPath?: ConfigPath; @@ -89,6 +91,7 @@ const createPlugin = ( configPath: `${configPath}${disabled ? '-disabled' : ''}`, kibanaVersion, requiredPlugins, + requiredBundles, optionalPlugins, server, ui, @@ -460,6 +463,7 @@ describe('PluginsService', () => { id: plugin.name, configPath: plugin.manifest.configPath, requiredPlugins: [], + requiredBundles: [], optionalPlugins: [], }, ]; @@ -563,10 +567,12 @@ describe('PluginsService', () => { "plugin-1" => Object { "publicAssetsDir": /path-1/public/assets, "publicTargetDir": /path-1/target/public, + "requiredBundles": Array [], }, "plugin-2" => Object { "publicAssetsDir": /path-2/public/assets, "publicTargetDir": /path-2/target/public, + "requiredBundles": Array [], }, } `); diff --git a/src/core/server/plugins/plugins_service.ts b/src/core/server/plugins/plugins_service.ts index 5d1261e697bc06..06de48a215881a 100644 --- a/src/core/server/plugins/plugins_service.ts +++ b/src/core/server/plugins/plugins_service.ts @@ -228,6 +228,7 @@ export class PluginsService implements CoreService uiPluginNames.includes(p) ), + requiredBundles: plugin.manifest.requiredBundles, }, ]; }) diff --git a/src/core/server/plugins/types.ts b/src/core/server/plugins/types.ts index 9e86ee22c607bf..9695c9171a7714 100644 --- a/src/core/server/plugins/types.ts +++ b/src/core/server/plugins/types.ts @@ -136,6 +136,18 @@ export interface PluginManifest { */ readonly requiredPlugins: readonly PluginName[]; + /** + * List of plugin ids that this plugin's UI code imports modules from that are + * not in `requiredPlugins`. + * + * @remarks + * The plugins listed here will be loaded in the browser, even if the plugin is + * disabled. Required by `@kbn/optimizer` to support cross-plugin imports. + * "core" and plugins already listed in `requiredPlugins` do not need to be + * duplicated here. + */ + readonly requiredBundles: readonly string[]; + /** * An optional list of the other plugins that if installed and enabled **may be** * leveraged by this plugin for some additional functionality but otherwise are @@ -191,12 +203,28 @@ export interface DiscoveredPlugin { * not required for this plugin to work properly. */ readonly optionalPlugins: readonly PluginName[]; + + /** + * List of plugin ids that this plugin's UI code imports modules from that are + * not in `requiredPlugins`. + * + * @remarks + * The plugins listed here will be loaded in the browser, even if the plugin is + * disabled. Required by `@kbn/optimizer` to support cross-plugin imports. + * "core" and plugins already listed in `requiredPlugins` do not need to be + * duplicated here. + */ + readonly requiredBundles: readonly PluginName[]; } /** * @internal */ export interface InternalPluginInfo { + /** + * Bundles that must be loaded for this plugoin + */ + readonly requiredBundles: readonly string[]; /** * Path to the target/public directory of the plugin which should be * served diff --git a/src/core/server/server.api.md b/src/core/server/server.api.md index 95912c3af63e51..3d3e1905577d91 100644 --- a/src/core/server/server.api.md +++ b/src/core/server/server.api.md @@ -637,6 +637,7 @@ export interface DiscoveredPlugin { readonly configPath: ConfigPath; readonly id: PluginName; readonly optionalPlugins: readonly PluginName[]; + readonly requiredBundles: readonly PluginName[]; readonly requiredPlugins: readonly PluginName[]; } @@ -1684,6 +1685,7 @@ export interface PluginManifest { readonly id: PluginName; readonly kibanaVersion: string; readonly optionalPlugins: readonly PluginName[]; + readonly requiredBundles: readonly string[]; readonly requiredPlugins: readonly PluginName[]; readonly server: boolean; readonly ui: boolean; @@ -2706,8 +2708,8 @@ export const validBodyOutput: readonly ["data", "stream"]; // src/core/server/legacy/types.ts:165:3 - (ae-forgotten-export) The symbol "LegacyNavLinkSpec" needs to be exported by the entry point index.d.ts // src/core/server/legacy/types.ts:166:3 - (ae-forgotten-export) The symbol "LegacyAppSpec" needs to be exported by the entry point index.d.ts // src/core/server/legacy/types.ts:167:16 - (ae-forgotten-export) The symbol "LegacyPluginSpec" needs to be exported by the entry point index.d.ts -// src/core/server/plugins/types.ts:238:3 - (ae-forgotten-export) The symbol "KibanaConfigType" needs to be exported by the entry point index.d.ts -// src/core/server/plugins/types.ts:238:3 - (ae-forgotten-export) The symbol "SharedGlobalConfigKeys" needs to be exported by the entry point index.d.ts -// src/core/server/plugins/types.ts:240:3 - (ae-forgotten-export) The symbol "PathConfigType" needs to be exported by the entry point index.d.ts +// src/core/server/plugins/types.ts:266:3 - (ae-forgotten-export) The symbol "KibanaConfigType" needs to be exported by the entry point index.d.ts +// src/core/server/plugins/types.ts:266:3 - (ae-forgotten-export) The symbol "SharedGlobalConfigKeys" needs to be exported by the entry point index.d.ts +// src/core/server/plugins/types.ts:268:3 - (ae-forgotten-export) The symbol "PathConfigType" needs to be exported by the entry point index.d.ts ``` diff --git a/src/legacy/ui/ui_render/ui_render_mixin.js b/src/legacy/ui/ui_render/ui_render_mixin.js index b4b18e086e809f..168dddf0253d9e 100644 --- a/src/legacy/ui/ui_render/ui_render_mixin.js +++ b/src/legacy/ui/ui_render/ui_render_mixin.js @@ -150,7 +150,23 @@ export function uiRenderMixin(kbnServer, server, config) { ]), ]; - const kpPluginIds = Array.from(kbnServer.newPlatform.__internals.uiPlugins.public.keys()); + const kpUiPlugins = kbnServer.newPlatform.__internals.uiPlugins; + const kpPluginPublicPaths = new Map(); + const kpPluginBundlePaths = new Set(); + + // recursively iterate over the kpUiPlugin ids and their required bundles + // to populate kpPluginPublicPaths and kpPluginBundlePaths + (function readKpPlugins(ids) { + for (const id of ids) { + if (kpPluginPublicPaths.has(id)) { + continue; + } + + kpPluginPublicPaths.set(id, `${regularBundlePath}/plugin/${id}/`); + kpPluginBundlePaths.add(`${regularBundlePath}/plugin/${id}/${id}.plugin.js`); + readKpPlugins(kpUiPlugins.internal.get(id).requiredBundles); + } + })(kpUiPlugins.public.keys()); const jsDependencyPaths = [ ...UiSharedDeps.jsDepFilenames.map( @@ -160,9 +176,7 @@ export function uiRenderMixin(kbnServer, server, config) { ...(isCore ? [] : [`${dllBundlePath}/vendors_runtime.bundle.dll.js`, ...dllJsChunks]), `${regularBundlePath}/core/core.entry.js`, - ...kpPluginIds.map( - (pluginId) => `${regularBundlePath}/plugin/${pluginId}/${pluginId}.plugin.js` - ), + ...kpPluginBundlePaths, ]; // These paths should align with the bundle routes configured in @@ -170,13 +184,7 @@ export function uiRenderMixin(kbnServer, server, config) { const publicPathMap = JSON.stringify({ core: `${regularBundlePath}/core/`, 'kbn-ui-shared-deps': `${regularBundlePath}/kbn-ui-shared-deps/`, - ...kpPluginIds.reduce( - (acc, pluginId) => ({ - ...acc, - [pluginId]: `${regularBundlePath}/plugin/${pluginId}/`, - }), - {} - ), + ...Object.fromEntries(kpPluginPublicPaths), }); const bootstrap = new AppBootstrap({ diff --git a/src/plugins/advanced_settings/kibana.json b/src/plugins/advanced_settings/kibana.json index e6ca6e797ba450..8cf9b9c656d8f2 100644 --- a/src/plugins/advanced_settings/kibana.json +++ b/src/plugins/advanced_settings/kibana.json @@ -3,5 +3,6 @@ "version": "kibana", "server": true, "ui": true, - "requiredPlugins": ["management"] + "requiredPlugins": ["management"], + "requiredBundles": ["kibanaReact"] } diff --git a/src/plugins/bfetch/kibana.json b/src/plugins/bfetch/kibana.json index 462d2f4b8bb7d9..9f9f2176af671f 100644 --- a/src/plugins/bfetch/kibana.json +++ b/src/plugins/bfetch/kibana.json @@ -2,5 +2,6 @@ "id": "bfetch", "version": "kibana", "server": true, - "ui": true + "ui": true, + "requiredBundles": ["kibanaUtils"] } diff --git a/src/plugins/charts/kibana.json b/src/plugins/charts/kibana.json index 9f4433e7099d8c..c4643d541c31c7 100644 --- a/src/plugins/charts/kibana.json +++ b/src/plugins/charts/kibana.json @@ -2,5 +2,6 @@ "id": "charts", "version": "kibana", "server": true, - "ui": true + "ui": true, + "requiredBundles": ["kibanaUtils", "kibanaReact", "data"] } diff --git a/src/plugins/console/kibana.json b/src/plugins/console/kibana.json index 57de385ba565c7..031aa00eb6613d 100644 --- a/src/plugins/console/kibana.json +++ b/src/plugins/console/kibana.json @@ -4,5 +4,6 @@ "server": true, "ui": true, "requiredPlugins": ["devTools", "home"], - "optionalPlugins": ["usageCollection"] + "optionalPlugins": ["usageCollection"], + "requiredBundles": ["esUiShared", "kibanaReact", "kibanaUtils"] } diff --git a/src/plugins/dashboard/kibana.json b/src/plugins/dashboard/kibana.json index 4cd8f3c7d981f6..1b38c6d124fe1f 100644 --- a/src/plugins/dashboard/kibana.json +++ b/src/plugins/dashboard/kibana.json @@ -12,5 +12,6 @@ ], "optionalPlugins": ["home", "share", "usageCollection"], "server": true, - "ui": true + "ui": true, + "requiredBundles": ["kibanaUtils", "kibanaReact", "home"] } diff --git a/src/plugins/data/kibana.json b/src/plugins/data/kibana.json index 3e5d96a4bc47bf..2ffd0688b134ee 100644 --- a/src/plugins/data/kibana.json +++ b/src/plugins/data/kibana.json @@ -8,5 +8,11 @@ "uiActions" ], "optionalPlugins": ["usageCollection"], - "extraPublicDirs": ["common", "common/utils/abort_utils"] + "extraPublicDirs": ["common", "common/utils/abort_utils"], + "requiredBundles": [ + "kibanaUtils", + "kibanaReact", + "kibanaLegacy", + "inspector" + ] } diff --git a/src/plugins/discover/kibana.json b/src/plugins/discover/kibana.json index 14dd399697b568..041f362bf06230 100644 --- a/src/plugins/discover/kibana.json +++ b/src/plugins/discover/kibana.json @@ -1,7 +1,6 @@ { "id": "discover", "version": "kibana", - "optionalPlugins": ["share"], "server": true, "ui": true, "requiredPlugins": [ @@ -14,5 +13,11 @@ "uiActions", "visualizations" ], - "optionalPlugins": ["home", "share"] + "optionalPlugins": ["home", "share"], + "requiredBundles": [ + "kibanaUtils", + "home", + "savedObjects", + "kibanaReact" + ] } diff --git a/src/plugins/embeddable/kibana.json b/src/plugins/embeddable/kibana.json index 332237d19e2187..3163c4bde4704b 100644 --- a/src/plugins/embeddable/kibana.json +++ b/src/plugins/embeddable/kibana.json @@ -10,5 +10,9 @@ ], "extraPublicDirs": [ "public/lib/test_samples" + ], + "requiredBundles": [ + "savedObjects", + "kibanaReact" ] } diff --git a/src/plugins/es_ui_shared/kibana.json b/src/plugins/es_ui_shared/kibana.json index 980f43ea46a68f..eab7355d66f096 100644 --- a/src/plugins/es_ui_shared/kibana.json +++ b/src/plugins/es_ui_shared/kibana.json @@ -10,5 +10,8 @@ "static/forms/helpers", "static/forms/components", "static/forms/helpers/field_validators/types" + ], + "requiredBundles": [ + "data" ] } diff --git a/src/plugins/expressions/kibana.json b/src/plugins/expressions/kibana.json index 4774c69cc29ffd..5163331088103a 100644 --- a/src/plugins/expressions/kibana.json +++ b/src/plugins/expressions/kibana.json @@ -6,5 +6,10 @@ "requiredPlugins": [ "bfetch" ], - "extraPublicDirs": ["common", "common/fonts"] + "extraPublicDirs": ["common", "common/fonts"], + "requiredBundles": [ + "kibanaUtils", + "inspector", + "data" + ] } diff --git a/src/plugins/home/kibana.json b/src/plugins/home/kibana.json index 1c4b44a946e62b..74bd3625ca9643 100644 --- a/src/plugins/home/kibana.json +++ b/src/plugins/home/kibana.json @@ -4,5 +4,8 @@ "server": true, "ui": true, "requiredPlugins": ["data", "kibanaLegacy"], - "optionalPlugins": ["usageCollection", "telemetry"] + "optionalPlugins": ["usageCollection", "telemetry"], + "requiredBundles": [ + "kibanaReact" + ] } diff --git a/src/plugins/index_pattern_management/kibana.json b/src/plugins/index_pattern_management/kibana.json index 23adef2626a72b..d0ad6a96065c30 100644 --- a/src/plugins/index_pattern_management/kibana.json +++ b/src/plugins/index_pattern_management/kibana.json @@ -3,5 +3,6 @@ "version": "kibana", "server": true, "ui": true, - "requiredPlugins": ["management", "data", "kibanaLegacy"] + "requiredPlugins": ["management", "data", "kibanaLegacy"], + "requiredBundles": ["kibanaReact", "kibanaUtils"] } diff --git a/src/plugins/input_control_vis/kibana.json b/src/plugins/input_control_vis/kibana.json index 4a4ec328c1352b..6928eb19d02e13 100644 --- a/src/plugins/input_control_vis/kibana.json +++ b/src/plugins/input_control_vis/kibana.json @@ -4,5 +4,6 @@ "kibanaVersion": "kibana", "server": true, "ui": true, - "requiredPlugins": ["data", "expressions", "visualizations"] + "requiredPlugins": ["data", "expressions", "visualizations"], + "requiredBundles": ["kibanaReact"] } diff --git a/src/plugins/inspector/kibana.json b/src/plugins/inspector/kibana.json index 99a38d2928df69..90e5d602507283 100644 --- a/src/plugins/inspector/kibana.json +++ b/src/plugins/inspector/kibana.json @@ -3,5 +3,6 @@ "version": "kibana", "server": false, "ui": true, - "extraPublicDirs": ["common", "common/adapters/request"] + "extraPublicDirs": ["common", "common/adapters/request"], + "requiredBundles": ["kibanaReact"] } diff --git a/src/plugins/kibana_react/kibana.json b/src/plugins/kibana_react/kibana.json index 0add1bee84ae02..a507fe457b6335 100644 --- a/src/plugins/kibana_react/kibana.json +++ b/src/plugins/kibana_react/kibana.json @@ -1,5 +1,6 @@ { "id": "kibanaReact", "version": "kibana", - "ui": true + "ui": true, + "requiredBundles": ["kibanaUtils"] } diff --git a/src/plugins/management/kibana.json b/src/plugins/management/kibana.json index cc411a8c6a25c4..f48158e98ff3f2 100644 --- a/src/plugins/management/kibana.json +++ b/src/plugins/management/kibana.json @@ -3,5 +3,6 @@ "version": "kibana", "server": true, "ui": true, - "requiredPlugins": ["kibanaLegacy", "home"] + "requiredPlugins": ["kibanaLegacy", "home"], + "requiredBundles": ["kibanaReact"] } diff --git a/src/plugins/maps_legacy/kibana.json b/src/plugins/maps_legacy/kibana.json index cd503883164ace..d9bf33e6613684 100644 --- a/src/plugins/maps_legacy/kibana.json +++ b/src/plugins/maps_legacy/kibana.json @@ -4,5 +4,6 @@ "kibanaVersion": "kibana", "configPath": ["map"], "ui": true, - "server": true + "server": true, + "requiredBundles": ["kibanaReact", "charts"] } diff --git a/src/plugins/region_map/kibana.json b/src/plugins/region_map/kibana.json index ac7e1f8659d66d..6e1980c327dc05 100644 --- a/src/plugins/region_map/kibana.json +++ b/src/plugins/region_map/kibana.json @@ -11,5 +11,10 @@ "mapsLegacy", "kibanaLegacy", "data" + ], + "requiredBundles": [ + "kibanaUtils", + "kibanaReact", + "charts" ] } diff --git a/src/plugins/saved_objects/kibana.json b/src/plugins/saved_objects/kibana.json index 7ae1b84eecad80..589aafbd2aaf5e 100644 --- a/src/plugins/saved_objects/kibana.json +++ b/src/plugins/saved_objects/kibana.json @@ -3,5 +3,9 @@ "version": "kibana", "server": true, "ui": true, - "requiredPlugins": ["data"] + "requiredPlugins": ["data"], + "requiredBundles": [ + "kibanaUtils", + "kibanaReact" + ] } diff --git a/src/plugins/saved_objects_management/kibana.json b/src/plugins/saved_objects_management/kibana.json index 6184d890c415cf..0270c1d8f5d398 100644 --- a/src/plugins/saved_objects_management/kibana.json +++ b/src/plugins/saved_objects_management/kibana.json @@ -5,5 +5,6 @@ "ui": true, "requiredPlugins": ["home", "management", "data"], "optionalPlugins": ["dashboard", "visualizations", "discover"], - "extraPublicDirs": ["public/lib"] + "extraPublicDirs": ["public/lib"], + "requiredBundles": ["kibanaReact"] } diff --git a/src/plugins/share/kibana.json b/src/plugins/share/kibana.json index dce2ac9281aba7..7760ea321992dc 100644 --- a/src/plugins/share/kibana.json +++ b/src/plugins/share/kibana.json @@ -2,5 +2,6 @@ "id": "share", "version": "kibana", "server": true, - "ui": true + "ui": true, + "requiredBundles": ["kibanaUtils"] } diff --git a/src/plugins/telemetry/kibana.json b/src/plugins/telemetry/kibana.json index a4975977625207..520ca6076dbbd5 100644 --- a/src/plugins/telemetry/kibana.json +++ b/src/plugins/telemetry/kibana.json @@ -9,5 +9,9 @@ ], "extraPublicDirs": [ "common/constants" + ], + "requiredBundles": [ + "kibanaUtils", + "kibanaReact" ] } diff --git a/src/plugins/tile_map/kibana.json b/src/plugins/tile_map/kibana.json index bb8ef5a2465494..9881a2dd72308b 100644 --- a/src/plugins/tile_map/kibana.json +++ b/src/plugins/tile_map/kibana.json @@ -11,5 +11,10 @@ "mapsLegacy", "kibanaLegacy", "data" + ], + "requiredBundles": [ + "kibanaUtils", + "kibanaReact", + "charts" ] } diff --git a/src/plugins/ui_actions/kibana.json b/src/plugins/ui_actions/kibana.json index 907cbabbdf9c97..7b24b3cc5c48b5 100644 --- a/src/plugins/ui_actions/kibana.json +++ b/src/plugins/ui_actions/kibana.json @@ -5,5 +5,8 @@ "ui": true, "extraPublicDirs": [ "public/tests/test_samples" + ], + "requiredBundles": [ + "kibanaReact" ] } diff --git a/src/plugins/usage_collection/kibana.json b/src/plugins/usage_collection/kibana.json index ae86b6c5d7ad18..6ef78018c7d7fc 100644 --- a/src/plugins/usage_collection/kibana.json +++ b/src/plugins/usage_collection/kibana.json @@ -3,5 +3,8 @@ "configPath": ["usageCollection"], "version": "kibana", "server": true, - "ui": true + "ui": true, + "requiredBundles": [ + "kibanaUtils" + ] } diff --git a/src/plugins/vis_type_markdown/kibana.json b/src/plugins/vis_type_markdown/kibana.json index d52e22118ccf09..9241f5eeee8371 100644 --- a/src/plugins/vis_type_markdown/kibana.json +++ b/src/plugins/vis_type_markdown/kibana.json @@ -3,5 +3,6 @@ "version": "kibana", "ui": true, "server": true, - "requiredPlugins": ["expressions", "visualizations"] + "requiredPlugins": ["expressions", "visualizations"], + "requiredBundles": ["kibanaUtils", "kibanaReact", "data", "charts"] } diff --git a/src/plugins/vis_type_metric/kibana.json b/src/plugins/vis_type_metric/kibana.json index 24135d257b3177..b2ebc91471e9d3 100644 --- a/src/plugins/vis_type_metric/kibana.json +++ b/src/plugins/vis_type_metric/kibana.json @@ -4,5 +4,6 @@ "kibanaVersion": "kibana", "server": true, "ui": true, - "requiredPlugins": ["data", "visualizations", "charts","expressions"] + "requiredPlugins": ["data", "visualizations", "charts","expressions"], + "requiredBundles": ["kibanaUtils", "kibanaReact"] } diff --git a/src/plugins/vis_type_table/kibana.json b/src/plugins/vis_type_table/kibana.json index ed098d71614033..b3c15564290770 100644 --- a/src/plugins/vis_type_table/kibana.json +++ b/src/plugins/vis_type_table/kibana.json @@ -8,5 +8,11 @@ "visualizations", "data", "kibanaLegacy" + ], + "requiredBundles": [ + "kibanaUtils", + "kibanaReact", + "share", + "charts" ] } diff --git a/src/plugins/vis_type_tagcloud/kibana.json b/src/plugins/vis_type_tagcloud/kibana.json index dbc9a1b9ef6926..86f72ebfa936d7 100644 --- a/src/plugins/vis_type_tagcloud/kibana.json +++ b/src/plugins/vis_type_tagcloud/kibana.json @@ -3,5 +3,6 @@ "version": "kibana", "ui": true, "server": true, - "requiredPlugins": ["data", "expressions", "visualizations", "charts"] + "requiredPlugins": ["data", "expressions", "visualizations", "charts"], + "requiredBundles": ["kibanaUtils", "kibanaReact"] } diff --git a/src/plugins/vis_type_timelion/kibana.json b/src/plugins/vis_type_timelion/kibana.json index 85c282c51a2e7a..6946568f5d809d 100644 --- a/src/plugins/vis_type_timelion/kibana.json +++ b/src/plugins/vis_type_timelion/kibana.json @@ -4,5 +4,6 @@ "kibanaVersion": "kibana", "server": true, "ui": true, - "requiredPlugins": ["visualizations", "data", "expressions"] + "requiredPlugins": ["visualizations", "data", "expressions"], + "requiredBundles": ["kibanaUtils", "kibanaReact"] } diff --git a/src/plugins/vis_type_timeseries/kibana.json b/src/plugins/vis_type_timeseries/kibana.json index 9053d2543e0d0e..f2284726c463fb 100644 --- a/src/plugins/vis_type_timeseries/kibana.json +++ b/src/plugins/vis_type_timeseries/kibana.json @@ -5,5 +5,6 @@ "server": true, "ui": true, "requiredPlugins": ["charts", "data", "expressions", "visualizations"], - "optionalPlugins": ["usageCollection"] + "optionalPlugins": ["usageCollection"], + "requiredBundles": ["kibanaUtils", "kibanaReact"] } diff --git a/src/plugins/vis_type_vega/kibana.json b/src/plugins/vis_type_vega/kibana.json index f1f82e7f5b7ad3..d7a92de627a992 100644 --- a/src/plugins/vis_type_vega/kibana.json +++ b/src/plugins/vis_type_vega/kibana.json @@ -3,5 +3,6 @@ "version": "kibana", "server": true, "ui": true, - "requiredPlugins": ["data", "visualizations", "mapsLegacy", "expressions"] + "requiredPlugins": ["data", "visualizations", "mapsLegacy", "expressions"], + "requiredBundles": ["kibanaUtils", "kibanaReact"] } diff --git a/src/plugins/vis_type_vislib/kibana.json b/src/plugins/vis_type_vislib/kibana.json index cad0ebe01494a0..7cba2e0d6a6b4b 100644 --- a/src/plugins/vis_type_vislib/kibana.json +++ b/src/plugins/vis_type_vislib/kibana.json @@ -4,5 +4,6 @@ "server": true, "ui": true, "requiredPlugins": ["charts", "data", "expressions", "visualizations", "kibanaLegacy"], - "optionalPlugins": ["visTypeXy"] + "optionalPlugins": ["visTypeXy"], + "requiredBundles": ["kibanaUtils", "kibanaReact"] } diff --git a/src/plugins/visualizations/kibana.json b/src/plugins/visualizations/kibana.json index f3f9cbd8341ecf..da3edfbdd3bf5a 100644 --- a/src/plugins/visualizations/kibana.json +++ b/src/plugins/visualizations/kibana.json @@ -3,5 +3,6 @@ "version": "kibana", "server": true, "ui": true, - "requiredPlugins": ["data", "expressions", "uiActions", "embeddable", "usageCollection", "inspector"] + "requiredPlugins": ["data", "expressions", "uiActions", "embeddable", "usageCollection", "inspector"], + "requiredBundles": ["kibanaUtils", "discover", "savedObjects"] } diff --git a/src/plugins/visualize/kibana.json b/src/plugins/visualize/kibana.json index c27cfec24b332d..520d1e1daa6fe8 100644 --- a/src/plugins/visualize/kibana.json +++ b/src/plugins/visualize/kibana.json @@ -11,5 +11,11 @@ "visualizations", "embeddable" ], - "optionalPlugins": ["home", "share"] + "optionalPlugins": ["home", "share"], + "requiredBundles": [ + "kibanaUtils", + "kibanaReact", + "home", + "discover" + ] } diff --git a/test/functional/services/remote/webdriver.ts b/test/functional/services/remote/webdriver.ts index 867e78dfad8dcb..0611c80f59b92f 100644 --- a/test/functional/services/remote/webdriver.ts +++ b/test/functional/services/remote/webdriver.ts @@ -28,7 +28,7 @@ import { delay } from 'bluebird'; import chromeDriver from 'chromedriver'; // @ts-ignore types not available import geckoDriver from 'geckodriver'; -import { Builder, Capabilities, logging } from 'selenium-webdriver'; +import { Builder, logging } from 'selenium-webdriver'; import chrome from 'selenium-webdriver/chrome'; import firefox from 'selenium-webdriver/firefox'; import edge from 'selenium-webdriver/edge'; diff --git a/test/interpreter_functional/plugins/kbn_tp_run_pipeline/kibana.json b/test/interpreter_functional/plugins/kbn_tp_run_pipeline/kibana.json index f0c1c3a34fbc09..7eafb185617c42 100644 --- a/test/interpreter_functional/plugins/kbn_tp_run_pipeline/kibana.json +++ b/test/interpreter_functional/plugins/kbn_tp_run_pipeline/kibana.json @@ -9,5 +9,8 @@ "expressions" ], "server": false, - "ui": true + "ui": true, + "requiredBundles": [ + "inspector" + ] } diff --git a/test/plugin_functional/plugins/app_link_test/kibana.json b/test/plugin_functional/plugins/app_link_test/kibana.json index 8cdc464abfec19..5384d4fee1508c 100644 --- a/test/plugin_functional/plugins/app_link_test/kibana.json +++ b/test/plugin_functional/plugins/app_link_test/kibana.json @@ -3,5 +3,6 @@ "version": "0.0.1", "kibanaVersion": "kibana", "server": false, - "ui": true + "ui": true, + "requiredBundles": ["kibanaReact"] } diff --git a/test/plugin_functional/plugins/kbn_sample_panel_action/kibana.json b/test/plugin_functional/plugins/kbn_sample_panel_action/kibana.json index 109afbcd5dabd4..08ce182aa02933 100644 --- a/test/plugin_functional/plugins/kbn_sample_panel_action/kibana.json +++ b/test/plugin_functional/plugins/kbn_sample_panel_action/kibana.json @@ -5,5 +5,6 @@ "configPath": ["kbn_sample_panel_action"], "server": false, "ui": true, - "requiredPlugins": ["uiActions", "embeddable"] -} \ No newline at end of file + "requiredPlugins": ["uiActions", "embeddable"], + "requiredBundles": ["kibanaReact"] +} diff --git a/test/scripts/jenkins_build_kibana.sh b/test/scripts/jenkins_build_kibana.sh index 3e49edc8e6ae5f..2310a35f94f331 100755 --- a/test/scripts/jenkins_build_kibana.sh +++ b/test/scripts/jenkins_build_kibana.sh @@ -2,16 +2,10 @@ source src/dev/ci_setup/setup_env.sh -echo " -> building examples separate from test plugins" +echo " -> building kibana platform plugins" node scripts/build_kibana_platform_plugins \ --oss \ - --examples \ - --verbose; - -echo " -> building test plugins" -node scripts/build_kibana_platform_plugins \ - --oss \ - --no-examples \ + --filter '!alertingExample' \ --scan-dir "$KIBANA_DIR/test/plugin_functional/plugins" \ --scan-dir "$KIBANA_DIR/test/interpreter_functional/plugins" \ --verbose; diff --git a/test/scripts/jenkins_xpack_build_kibana.sh b/test/scripts/jenkins_xpack_build_kibana.sh index 58ef6a42d3fe4c..c962b962b1e5e6 100755 --- a/test/scripts/jenkins_xpack_build_kibana.sh +++ b/test/scripts/jenkins_xpack_build_kibana.sh @@ -3,14 +3,8 @@ cd "$KIBANA_DIR" source src/dev/ci_setup/setup_env.sh -echo " -> building examples separate from test plugins" +echo " -> building kibana platform plugins" node scripts/build_kibana_platform_plugins \ - --examples \ - --verbose; - -echo " -> building test plugins" -node scripts/build_kibana_platform_plugins \ - --no-examples \ --scan-dir "$KIBANA_DIR/test/plugin_functional/plugins" \ --scan-dir "$XPACK_DIR/test/plugin_functional/plugins" \ --scan-dir "$XPACK_DIR/test/functional_with_es_ssl/fixtures/plugins" \ diff --git a/test/scripts/jenkins_xpack_visual_regression.sh b/test/scripts/jenkins_xpack_visual_regression.sh index 930d4a74345d90..ac567a188a6d40 100755 --- a/test/scripts/jenkins_xpack_visual_regression.sh +++ b/test/scripts/jenkins_xpack_visual_regression.sh @@ -5,7 +5,7 @@ source "$KIBANA_DIR/src/dev/ci_setup/setup_percy.sh" echo " -> building and extracting default Kibana distributable" cd "$KIBANA_DIR" -node scripts/build --debug +node scripts/build --debug --no-oss linuxBuild="$(find "$KIBANA_DIR/target" -name 'kibana-*-linux-x86_64.tar.gz')" installDir="$PARENT_DIR/install/kibana" mkdir -p "$installDir" @@ -22,5 +22,5 @@ yarn percy exec -t 10000 -- -- \ # cd "$KIBANA_DIR" # source "test/scripts/jenkins_xpack_page_load_metrics.sh" -cd "$XPACK_DIR" -source "$KIBANA_DIR/test/scripts/jenkins_xpack_saved_objects_field_metrics.sh" +cd "$KIBANA_DIR" +source "test/scripts/jenkins_xpack_saved_objects_field_metrics.sh" diff --git a/x-pack/examples/ui_actions_enhanced_examples/kibana.json b/x-pack/examples/ui_actions_enhanced_examples/kibana.json index a1cd895bb3cd62..160352a9afd660 100644 --- a/x-pack/examples/ui_actions_enhanced_examples/kibana.json +++ b/x-pack/examples/ui_actions_enhanced_examples/kibana.json @@ -6,5 +6,9 @@ "server": false, "ui": true, "requiredPlugins": ["uiActionsEnhanced", "data", "discover"], - "optionalPlugins": [] + "optionalPlugins": [], + "requiredBundles": [ + "kibanaUtils", + "kibanaReact" + ] } diff --git a/x-pack/plugins/actions/README.md b/x-pack/plugins/actions/README.md index 494f2f38e8bffc..9e07727204f88f 100644 --- a/x-pack/plugins/actions/README.md +++ b/x-pack/plugins/actions/README.md @@ -26,15 +26,19 @@ Table of Contents - [Executor](#executor) - [Example](#example) - [RESTful API](#restful-api) - - [`POST /api/actions/action`: Create action](#post-apiaction-create-action) - - [`DELETE /api/actions/action/{id}`: Delete action](#delete-apiactionid-delete-action) - - [`GET /api/actions`: Get all actions](#get-apiactiongetall-get-all-actions) - - [`GET /api/actions/action/{id}`: Get action](#get-apiactionid-get-action) - - [`GET /api/actions/list_action_types`: List action types](#get-apiactiontypes-list-action-types) - - [`PUT /api/actions/action/{id}`: Update action](#put-apiactionid-update-action) - - [`POST /api/actions/action/{id}/_execute`: Execute action](#post-apiactionidexecute-execute-action) + - [`POST /api/actions/action`: Create action](#post-apiactionsaction-create-action) + - [`DELETE /api/actions/action/{id}`: Delete action](#delete-apiactionsactionid-delete-action) + - [`GET /api/actions`: Get all actions](#get-apiactions-get-all-actions) + - [`GET /api/actions/action/{id}`: Get action](#get-apiactionsactionid-get-action) + - [`GET /api/actions/list_action_types`: List action types](#get-apiactionslist_action_types-list-action-types) + - [`PUT /api/actions/action/{id}`: Update action](#put-apiactionsactionid-update-action) + - [`POST /api/actions/action/{id}/_execute`: Execute action](#post-apiactionsactionid_execute-execute-action) - [Firing actions](#firing-actions) + - [Accessing a scoped ActionsClient](#accessing-a-scoped-actionsclient) + - [actionsClient.enqueueExecution(options)](#actionsclientenqueueexecutionoptions) - [Example](#example-1) + - [actionsClient.execute(options)](#actionsclientexecuteoptions) + - [Example](#example-2) - [Built-in Action Types](#built-in-action-types) - [Server log](#server-log) - [`config`](#config) @@ -70,6 +74,11 @@ Table of Contents - [`secrets`](#secrets-7) - [`params`](#params-7) - [`subActionParams (pushToService)`](#subactionparams-pushtoservice-1) + - [IBM Resilient](#ibm-resilient) + - [`config`](#config-8) + - [`secrets`](#secrets-8) + - [`params`](#params-8) + - [`subActionParams (pushToService)`](#subactionparams-pushtoservice-2) - [Command Line Utility](#command-line-utility) - [Developing New Action Types](#developing-new-action-types) @@ -99,7 +108,7 @@ Built-In-Actions are configured using the _xpack.actions_ namespoace under _kiba | _xpack.actions._**enabled** | Feature toggle which enabled Actions in Kibana. | boolean | | _xpack.actions._**whitelistedHosts** | Which _hostnames_ are whitelisted for the Built-In-Action? This list should contain hostnames of every external service you wish to interact with using Webhooks, Email or any other built in Action. Note that you may use the string "\*" in place of a specific hostname to enable Kibana to target any URL, but keep in mind the potential use of such a feature to execute [SSRF](https://www.owasp.org/index.php/Server_Side_Request_Forgery) attacks from your server. | Array | | _xpack.actions._**enabledActionTypes** | A list of _actionTypes_ id's that are enabled. A "\*" may be used as an element to indicate all registered actionTypes should be enabled. The actionTypes registered for Kibana are `.server-log`, `.slack`, `.email`, `.index`, `.pagerduty`, `.webhook`. Default: `["*"]` | Array | -| _xpack.actions._**preconfigured** | A object of action id / preconfigured actions. Default: `{}` | Array | +| _xpack.actions._**preconfigured** | A object of action id / preconfigured actions. Default: `{}` | Array | #### Whitelisting Built-in Action Types @@ -251,6 +260,7 @@ Once you have a scoped ActionsClient you can execute an action by caling either This api schedules a task which will run the action using the current user scope at the soonest opportunity. Running the action by scheduling a task means that we will no longer have a user request by which to ascertain the action's privileges and so you might need to provide these yourself: + - The **SpaceId** in which the user's action is expected to run - When security is enabled you'll also need to provide an **apiKey** which allows us to mimic the user and their privileges. @@ -287,14 +297,14 @@ This api runs the action and asynchronously returns the result of running the ac The following table describes the properties of the `options` object. -| Property | Description | Type | -| -------- | ------------------------------------------------------------------------------------------------------ | ------ | -| id | The id of the action you want to execute. | string | -| params | The `params` value to give the action type executor. | object | +| Property | Description | Type | +| -------- | ---------------------------------------------------- | ------ | +| id | The id of the action you want to execute. | string | +| params | The `params` value to give the action type executor. | object | ## Example -As with the previous example, we'll use the action `3c5b2bd4-5424-4e4b-8cf5-c0a58c762cc5` to send an email. +As with the previous example, we'll use the action `3c5b2bd4-5424-4e4b-8cf5-c0a58c762cc5` to send an email. ```typescript const actionsClient = await server.plugins.actions.getActionsClientWithRequest(request); @@ -559,10 +569,10 @@ The Jira action uses the [V2 API](https://developer.atlassian.com/cloud/jira/pla ### `config` -| Property | Description | Type | -| ------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------ | -| apiUrl | ServiceNow instance URL. | string | -| casesConfiguration | Case configuration object. The object should contain an attribute called `mapping`. A `mapping` is an array of objects. Each mapping object should be of the form `{ source: string, target: string, actionType: string }`. `source` is the Case field. `target` is the Jira field where `source` will be mapped to. `actionType` can be one of `nothing`, `overwrite` or `append`. For example the `{ source: 'title', target: 'summary', actionType: 'overwrite' }` record, inside mapping array, means that the title of a case will be mapped to the short description of an incident in ServiceNow and will be overwrite on each update. | object | +| Property | Description | Type | +| ------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------ | +| apiUrl | Jira instance URL. | string | +| casesConfiguration | Case configuration object. The object should contain an attribute called `mapping`. A `mapping` is an array of objects. Each mapping object should be of the form `{ source: string, target: string, actionType: string }`. `source` is the Case field. `target` is the Jira field where `source` will be mapped to. `actionType` can be one of `nothing`, `overwrite` or `append`. For example the `{ source: 'title', target: 'summary', actionType: 'overwrite' }` record, inside mapping array, means that the title of a case will be mapped to the short description of an incident in Jira and will be overwrite on each update. | object | ### `secrets` @@ -588,6 +598,41 @@ The Jira action uses the [V2 API](https://developer.atlassian.com/cloud/jira/pla | comments | The comments of the case. A comment is of the form `{ commentId: string, version: string, comment: string }` | object[] _(optional)_ | | externalId | The id of the incident in Jira. If presented the incident will be update. Otherwise a new incident will be created. | string _(optional)_ | +## IBM Resilient + +ID: `.resilient` + +### `config` + +| Property | Description | Type | +| ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------ | +| apiUrl | IBM Resilient instance URL. | string | +| casesConfiguration | Case configuration object. The object should contain an attribute called `mapping`. A `mapping` is an array of objects. Each mapping object should be of the form `{ source: string, target: string, actionType: string }`. `source` is the Case field. `target` is the Jira field where `source` will be mapped to. `actionType` can be one of `nothing`, `overwrite` or `append`. For example the `{ source: 'title', target: 'summary', actionType: 'overwrite' }` record, inside mapping array, means that the title of a case will be mapped to the short description of an incident in IBM Resilient and will be overwrite on each update. | object | + +### `secrets` + +| Property | Description | Type | +| ------------ | -------------------------------------------- | ------ | +| apiKeyId | API key ID for HTTP Basic authentication | string | +| apiKeySecret | API key secret for HTTP Basic authentication | string | + +### `params` + +| Property | Description | Type | +| --------------- | ------------------------------------------------------------------------------------ | ------ | +| subAction | The sub action to perform. It can be `pushToService`, `handshake`, and `getIncident` | string | +| subActionParams | The parameters of the sub action | object | + +#### `subActionParams (pushToService)` + +| Property | Description | Type | +| ----------- | ---------------------------------------------------------------------------------------------------------------------------- | --------------------- | +| caseId | The case id | string | +| title | The title of the case | string _(optional)_ | +| description | The description of the case | string _(optional)_ | +| comments | The comments of the case. A comment is of the form `{ commentId: string, version: string, comment: string }` | object[] _(optional)_ | +| externalId | The id of the incident in IBM Resilient. If presented the incident will be update. Otherwise a new incident will be created. | string _(optional)_ | + # Command Line Utility The [`kbn-action`](https://github.com/pmuellr/kbn-action) tool can be used to send HTTP requests to the Actions plugin. For instance, to create a Slack action from the `.slack` Action Type, use the following command: diff --git a/x-pack/plugins/actions/server/builtin_action_types/case/utils.test.ts b/x-pack/plugins/actions/server/builtin_action_types/case/utils.test.ts index dbb18fa5c695c4..2e3cee3946d618 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/case/utils.test.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/case/utils.test.ts @@ -243,7 +243,7 @@ describe('transformFields', () => { }); }); - test('add newline character to descripton', () => { + test('add newline character to description', () => { const fields = prepareFieldsForTransformation({ externalCase: fullParams.externalCase, mapping: finalMapping, diff --git a/x-pack/plugins/actions/server/builtin_action_types/index.ts b/x-pack/plugins/actions/server/builtin_action_types/index.ts index 0020161789d716..80a171cbe624d2 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/index.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/index.ts @@ -16,6 +16,7 @@ import { getActionType as getSlackActionType } from './slack'; import { getActionType as getWebhookActionType } from './webhook'; import { getActionType as getServiceNowActionType } from './servicenow'; import { getActionType as getJiraActionType } from './jira'; +import { getActionType as getResilientActionType } from './resilient'; export function registerBuiltInActionTypes({ actionsConfigUtils: configurationUtilities, @@ -34,4 +35,5 @@ export function registerBuiltInActionTypes({ actionTypeRegistry.register(getWebhookActionType({ logger, configurationUtilities })); actionTypeRegistry.register(getServiceNowActionType({ logger, configurationUtilities })); actionTypeRegistry.register(getJiraActionType({ configurationUtilities })); + actionTypeRegistry.register(getResilientActionType({ configurationUtilities })); } diff --git a/x-pack/plugins/actions/server/builtin_action_types/resilient/api.test.ts b/x-pack/plugins/actions/server/builtin_action_types/resilient/api.test.ts new file mode 100644 index 00000000000000..734f6be382629d --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/resilient/api.test.ts @@ -0,0 +1,517 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { api } from '../case/api'; +import { externalServiceMock, mapping, apiParams } from './mocks'; +import { ExternalService } from '../case/types'; + +describe('api', () => { + let externalService: jest.Mocked; + + beforeEach(() => { + externalService = externalServiceMock.create(); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('pushToService', () => { + describe('create incident', () => { + test('it creates an incident', async () => { + const params = { ...apiParams, externalId: null }; + const res = await api.pushToService({ externalService, mapping, params }); + + expect(res).toEqual({ + id: '1', + title: '1', + pushedDate: '2020-06-03T15:09:13.606Z', + url: 'https://resilient.elastic.co/#incidents/1', + comments: [ + { + commentId: 'case-comment-1', + pushedDate: '2020-06-03T15:09:13.606Z', + }, + { + commentId: 'case-comment-2', + pushedDate: '2020-06-03T15:09:13.606Z', + }, + ], + }); + }); + + test('it creates an incident without comments', async () => { + const params = { ...apiParams, externalId: null, comments: [] }; + const res = await api.pushToService({ externalService, mapping, params }); + + expect(res).toEqual({ + id: '1', + title: '1', + pushedDate: '2020-06-03T15:09:13.606Z', + url: 'https://resilient.elastic.co/#incidents/1', + }); + }); + + test('it calls createIncident correctly', async () => { + const params = { ...apiParams, externalId: null }; + await api.pushToService({ externalService, mapping, params }); + + expect(externalService.createIncident).toHaveBeenCalledWith({ + incident: { + description: + 'Incident description (created at 2020-06-03T15:09:13.606Z by Elastic User)', + name: 'Incident title (created at 2020-06-03T15:09:13.606Z by Elastic User)', + }, + }); + expect(externalService.updateIncident).not.toHaveBeenCalled(); + }); + + test('it calls createComment correctly', async () => { + const params = { ...apiParams, externalId: null }; + await api.pushToService({ externalService, mapping, params }); + expect(externalService.createComment).toHaveBeenCalledTimes(2); + expect(externalService.createComment).toHaveBeenNthCalledWith(1, { + incidentId: '1', + comment: { + commentId: 'case-comment-1', + comment: 'A comment (added at 2020-06-03T15:09:13.606Z by Elastic User)', + createdAt: '2020-06-03T15:09:13.606Z', + createdBy: { + fullName: 'Elastic User', + username: 'elastic', + }, + updatedAt: '2020-06-03T15:09:13.606Z', + updatedBy: { + fullName: 'Elastic User', + username: 'elastic', + }, + }, + field: 'comments', + }); + + expect(externalService.createComment).toHaveBeenNthCalledWith(2, { + incidentId: '1', + comment: { + commentId: 'case-comment-2', + comment: 'Another comment (added at 2020-06-03T15:09:13.606Z by Elastic User)', + createdAt: '2020-06-03T15:09:13.606Z', + createdBy: { + fullName: 'Elastic User', + username: 'elastic', + }, + updatedAt: '2020-06-03T15:09:13.606Z', + updatedBy: { + fullName: 'Elastic User', + username: 'elastic', + }, + }, + field: 'comments', + }); + }); + }); + + describe('update incident', () => { + test('it updates an incident', async () => { + const res = await api.pushToService({ externalService, mapping, params: apiParams }); + + expect(res).toEqual({ + id: '1', + title: '1', + pushedDate: '2020-06-03T15:09:13.606Z', + url: 'https://resilient.elastic.co/#incidents/1', + comments: [ + { + commentId: 'case-comment-1', + pushedDate: '2020-06-03T15:09:13.606Z', + }, + { + commentId: 'case-comment-2', + pushedDate: '2020-06-03T15:09:13.606Z', + }, + ], + }); + }); + + test('it updates an incident without comments', async () => { + const params = { ...apiParams, comments: [] }; + const res = await api.pushToService({ externalService, mapping, params }); + + expect(res).toEqual({ + id: '1', + title: '1', + pushedDate: '2020-06-03T15:09:13.606Z', + url: 'https://resilient.elastic.co/#incidents/1', + }); + }); + + test('it calls updateIncident correctly', async () => { + const params = { ...apiParams }; + await api.pushToService({ externalService, mapping, params }); + + expect(externalService.updateIncident).toHaveBeenCalledWith({ + incidentId: 'incident-3', + incident: { + description: + 'Incident description (updated at 2020-06-03T15:09:13.606Z by Elastic User)', + name: 'Incident title (updated at 2020-06-03T15:09:13.606Z by Elastic User)', + }, + }); + expect(externalService.createIncident).not.toHaveBeenCalled(); + }); + + test('it calls createComment correctly', async () => { + const params = { ...apiParams }; + await api.pushToService({ externalService, mapping, params }); + expect(externalService.createComment).toHaveBeenCalledTimes(2); + expect(externalService.createComment).toHaveBeenNthCalledWith(1, { + incidentId: '1', + comment: { + commentId: 'case-comment-1', + comment: 'A comment (added at 2020-06-03T15:09:13.606Z by Elastic User)', + createdAt: '2020-06-03T15:09:13.606Z', + createdBy: { + fullName: 'Elastic User', + username: 'elastic', + }, + updatedAt: '2020-06-03T15:09:13.606Z', + updatedBy: { + fullName: 'Elastic User', + username: 'elastic', + }, + }, + field: 'comments', + }); + + expect(externalService.createComment).toHaveBeenNthCalledWith(2, { + incidentId: '1', + comment: { + commentId: 'case-comment-2', + comment: 'Another comment (added at 2020-06-03T15:09:13.606Z by Elastic User)', + createdAt: '2020-06-03T15:09:13.606Z', + createdBy: { + fullName: 'Elastic User', + username: 'elastic', + }, + updatedAt: '2020-06-03T15:09:13.606Z', + updatedBy: { + fullName: 'Elastic User', + username: 'elastic', + }, + }, + field: 'comments', + }); + }); + }); + + describe('mapping variations', () => { + test('overwrite & append', async () => { + mapping.set('title', { + target: 'name', + actionType: 'overwrite', + }); + + mapping.set('description', { + target: 'description', + actionType: 'append', + }); + + mapping.set('comments', { + target: 'comments', + actionType: 'append', + }); + + mapping.set('name', { + target: 'title', + actionType: 'overwrite', + }); + + await api.pushToService({ externalService, mapping, params: apiParams }); + expect(externalService.updateIncident).toHaveBeenCalledWith({ + incidentId: 'incident-3', + incident: { + name: 'Incident title (updated at 2020-06-03T15:09:13.606Z by Elastic User)', + description: + 'description from ibm resilient \r\nIncident description (updated at 2020-06-03T15:09:13.606Z by Elastic User)', + }, + }); + }); + + test('nothing & append', async () => { + mapping.set('title', { + target: 'name', + actionType: 'nothing', + }); + + mapping.set('description', { + target: 'description', + actionType: 'append', + }); + + mapping.set('comments', { + target: 'comments', + actionType: 'append', + }); + + mapping.set('name', { + target: 'title', + actionType: 'nothing', + }); + + await api.pushToService({ externalService, mapping, params: apiParams }); + expect(externalService.updateIncident).toHaveBeenCalledWith({ + incidentId: 'incident-3', + incident: { + description: + 'description from ibm resilient \r\nIncident description (updated at 2020-06-03T15:09:13.606Z by Elastic User)', + }, + }); + }); + + test('append & append', async () => { + mapping.set('title', { + target: 'name', + actionType: 'append', + }); + + mapping.set('description', { + target: 'description', + actionType: 'append', + }); + + mapping.set('comments', { + target: 'comments', + actionType: 'append', + }); + + mapping.set('name', { + target: 'title', + actionType: 'append', + }); + + await api.pushToService({ externalService, mapping, params: apiParams }); + expect(externalService.updateIncident).toHaveBeenCalledWith({ + incidentId: 'incident-3', + incident: { + name: + 'title from ibm resilient \r\nIncident title (updated at 2020-06-03T15:09:13.606Z by Elastic User)', + description: + 'description from ibm resilient \r\nIncident description (updated at 2020-06-03T15:09:13.606Z by Elastic User)', + }, + }); + }); + + test('nothing & nothing', async () => { + mapping.set('title', { + target: 'name', + actionType: 'nothing', + }); + + mapping.set('description', { + target: 'description', + actionType: 'nothing', + }); + + mapping.set('comments', { + target: 'comments', + actionType: 'append', + }); + + mapping.set('name', { + target: 'title', + actionType: 'nothing', + }); + + await api.pushToService({ externalService, mapping, params: apiParams }); + expect(externalService.updateIncident).toHaveBeenCalledWith({ + incidentId: 'incident-3', + incident: {}, + }); + }); + + test('overwrite & nothing', async () => { + mapping.set('title', { + target: 'name', + actionType: 'overwrite', + }); + + mapping.set('description', { + target: 'description', + actionType: 'nothing', + }); + + mapping.set('comments', { + target: 'comments', + actionType: 'append', + }); + + mapping.set('name', { + target: 'title', + actionType: 'overwrite', + }); + + await api.pushToService({ externalService, mapping, params: apiParams }); + expect(externalService.updateIncident).toHaveBeenCalledWith({ + incidentId: 'incident-3', + incident: { + name: 'Incident title (updated at 2020-06-03T15:09:13.606Z by Elastic User)', + }, + }); + }); + + test('overwrite & overwrite', async () => { + mapping.set('title', { + target: 'name', + actionType: 'overwrite', + }); + + mapping.set('description', { + target: 'description', + actionType: 'overwrite', + }); + + mapping.set('comments', { + target: 'comments', + actionType: 'append', + }); + + mapping.set('name', { + target: 'title', + actionType: 'overwrite', + }); + + await api.pushToService({ externalService, mapping, params: apiParams }); + expect(externalService.updateIncident).toHaveBeenCalledWith({ + incidentId: 'incident-3', + incident: { + name: 'Incident title (updated at 2020-06-03T15:09:13.606Z by Elastic User)', + description: + 'Incident description (updated at 2020-06-03T15:09:13.606Z by Elastic User)', + }, + }); + }); + + test('nothing & overwrite', async () => { + mapping.set('title', { + target: 'name', + actionType: 'nothing', + }); + + mapping.set('description', { + target: 'description', + actionType: 'overwrite', + }); + + mapping.set('comments', { + target: 'comments', + actionType: 'append', + }); + + mapping.set('name', { + target: 'title', + actionType: 'nothing', + }); + + await api.pushToService({ externalService, mapping, params: apiParams }); + expect(externalService.updateIncident).toHaveBeenCalledWith({ + incidentId: 'incident-3', + incident: { + description: + 'Incident description (updated at 2020-06-03T15:09:13.606Z by Elastic User)', + }, + }); + }); + + test('append & overwrite', async () => { + mapping.set('title', { + target: 'name', + actionType: 'append', + }); + + mapping.set('description', { + target: 'description', + actionType: 'overwrite', + }); + + mapping.set('comments', { + target: 'comments', + actionType: 'append', + }); + + mapping.set('name', { + target: 'title', + actionType: 'append', + }); + + await api.pushToService({ externalService, mapping, params: apiParams }); + expect(externalService.updateIncident).toHaveBeenCalledWith({ + incidentId: 'incident-3', + incident: { + name: + 'title from ibm resilient \r\nIncident title (updated at 2020-06-03T15:09:13.606Z by Elastic User)', + description: + 'Incident description (updated at 2020-06-03T15:09:13.606Z by Elastic User)', + }, + }); + }); + + test('append & nothing', async () => { + mapping.set('title', { + target: 'name', + actionType: 'append', + }); + + mapping.set('description', { + target: 'description', + actionType: 'nothing', + }); + + mapping.set('comments', { + target: 'comments', + actionType: 'append', + }); + + mapping.set('name', { + target: 'title', + actionType: 'append', + }); + + await api.pushToService({ externalService, mapping, params: apiParams }); + expect(externalService.updateIncident).toHaveBeenCalledWith({ + incidentId: 'incident-3', + incident: { + name: + 'title from ibm resilient \r\nIncident title (updated at 2020-06-03T15:09:13.606Z by Elastic User)', + }, + }); + }); + + test('comment nothing', async () => { + mapping.set('title', { + target: 'name', + actionType: 'overwrite', + }); + + mapping.set('description', { + target: 'description', + actionType: 'nothing', + }); + + mapping.set('comments', { + target: 'comments', + actionType: 'nothing', + }); + + mapping.set('name', { + target: 'title', + actionType: 'overwrite', + }); + + await api.pushToService({ externalService, mapping, params: apiParams }); + expect(externalService.createComment).not.toHaveBeenCalled(); + }); + }); + }); +}); diff --git a/x-pack/plugins/actions/server/builtin_action_types/resilient/api.ts b/x-pack/plugins/actions/server/builtin_action_types/resilient/api.ts new file mode 100644 index 00000000000000..3db66e5884af4a --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/resilient/api.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { api } from '../case/api'; diff --git a/x-pack/plugins/actions/server/builtin_action_types/resilient/config.ts b/x-pack/plugins/actions/server/builtin_action_types/resilient/config.ts new file mode 100644 index 00000000000000..4ce9417bfa9a16 --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/resilient/config.ts @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { ExternalServiceConfiguration } from '../case/types'; +import * as i18n from './translations'; + +export const config: ExternalServiceConfiguration = { + id: '.resilient', + name: i18n.NAME, + minimumLicenseRequired: 'platinum', +}; diff --git a/x-pack/plugins/actions/server/builtin_action_types/resilient/index.ts b/x-pack/plugins/actions/server/builtin_action_types/resilient/index.ts new file mode 100644 index 00000000000000..e98bc71559d3f1 --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/resilient/index.ts @@ -0,0 +1,24 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { createConnector } from '../case/utils'; + +import { api } from './api'; +import { config } from './config'; +import { validate } from './validators'; +import { createExternalService } from './service'; +import { ResilientSecretConfiguration, ResilientPublicConfiguration } from './schema'; + +export const getActionType = createConnector({ + api, + config, + validate, + createExternalService, + validationSchema: { + config: ResilientPublicConfiguration, + secrets: ResilientSecretConfiguration, + }, +}); diff --git a/x-pack/plugins/actions/server/builtin_action_types/resilient/mocks.ts b/x-pack/plugins/actions/server/builtin_action_types/resilient/mocks.ts new file mode 100644 index 00000000000000..bba9c58bf28c9d --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/resilient/mocks.ts @@ -0,0 +1,124 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { + ExternalService, + PushToServiceApiParams, + ExecutorSubActionPushParams, + MapRecord, +} from '../case/types'; + +const createMock = (): jest.Mocked => { + const service = { + getIncident: jest.fn().mockImplementation(() => + Promise.resolve({ + id: '1', + name: 'title from ibm resilient', + description: 'description from ibm resilient', + discovered_date: 1589391874472, + create_date: 1591192608323, + inc_last_modified_date: 1591192650372, + }) + ), + createIncident: jest.fn().mockImplementation(() => + Promise.resolve({ + id: '1', + title: '1', + pushedDate: '2020-06-03T15:09:13.606Z', + url: 'https://resilient.elastic.co/#incidents/1', + }) + ), + updateIncident: jest.fn().mockImplementation(() => + Promise.resolve({ + id: '1', + title: '1', + pushedDate: '2020-06-03T15:09:13.606Z', + url: 'https://resilient.elastic.co/#incidents/1', + }) + ), + createComment: jest.fn(), + }; + + service.createComment.mockImplementationOnce(() => + Promise.resolve({ + commentId: 'case-comment-1', + pushedDate: '2020-06-03T15:09:13.606Z', + externalCommentId: '1', + }) + ); + + service.createComment.mockImplementationOnce(() => + Promise.resolve({ + commentId: 'case-comment-2', + pushedDate: '2020-06-03T15:09:13.606Z', + externalCommentId: '2', + }) + ); + + return service; +}; + +const externalServiceMock = { + create: createMock, +}; + +const mapping: Map> = new Map(); + +mapping.set('title', { + target: 'name', + actionType: 'overwrite', +}); + +mapping.set('description', { + target: 'description', + actionType: 'overwrite', +}); + +mapping.set('comments', { + target: 'comments', + actionType: 'append', +}); + +mapping.set('name', { + target: 'title', + actionType: 'overwrite', +}); + +const executorParams: ExecutorSubActionPushParams = { + savedObjectId: 'd4387ac5-0899-4dc2-bbfa-0dd605c934aa', + externalId: 'incident-3', + createdAt: '2020-06-03T15:09:13.606Z', + createdBy: { fullName: 'Elastic User', username: 'elastic' }, + updatedAt: '2020-06-03T15:09:13.606Z', + updatedBy: { fullName: 'Elastic User', username: 'elastic' }, + title: 'Incident title', + description: 'Incident description', + comments: [ + { + commentId: 'case-comment-1', + comment: 'A comment', + createdAt: '2020-06-03T15:09:13.606Z', + createdBy: { fullName: 'Elastic User', username: 'elastic' }, + updatedAt: '2020-06-03T15:09:13.606Z', + updatedBy: { fullName: 'Elastic User', username: 'elastic' }, + }, + { + commentId: 'case-comment-2', + comment: 'Another comment', + createdAt: '2020-06-03T15:09:13.606Z', + createdBy: { fullName: 'Elastic User', username: 'elastic' }, + updatedAt: '2020-06-03T15:09:13.606Z', + updatedBy: { fullName: 'Elastic User', username: 'elastic' }, + }, + ], +}; + +const apiParams: PushToServiceApiParams = { + ...executorParams, + externalCase: { name: 'Incident title', description: 'Incident description' }, +}; + +export { externalServiceMock, mapping, executorParams, apiParams }; diff --git a/x-pack/plugins/actions/server/builtin_action_types/resilient/schema.ts b/x-pack/plugins/actions/server/builtin_action_types/resilient/schema.ts new file mode 100644 index 00000000000000..c13de2b27e2b9e --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/resilient/schema.ts @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { schema } from '@kbn/config-schema'; +import { ExternalIncidentServiceConfiguration } from '../case/schema'; + +export const ResilientPublicConfiguration = { + orgId: schema.string(), + ...ExternalIncidentServiceConfiguration, +}; + +export const ResilientPublicConfigurationSchema = schema.object(ResilientPublicConfiguration); + +export const ResilientSecretConfiguration = { + apiKeyId: schema.string(), + apiKeySecret: schema.string(), +}; + +export const ResilientSecretConfigurationSchema = schema.object(ResilientSecretConfiguration); diff --git a/x-pack/plugins/actions/server/builtin_action_types/resilient/service.test.ts b/x-pack/plugins/actions/server/builtin_action_types/resilient/service.test.ts new file mode 100644 index 00000000000000..573885698014e5 --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/resilient/service.test.ts @@ -0,0 +1,422 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import axios from 'axios'; + +import { createExternalService, getValueTextContent, formatUpdateRequest } from './service'; +import * as utils from '../lib/axios_utils'; +import { ExternalService } from '../case/types'; + +jest.mock('axios'); +jest.mock('../lib/axios_utils', () => { + const originalUtils = jest.requireActual('../lib/axios_utils'); + return { + ...originalUtils, + request: jest.fn(), + }; +}); + +axios.create = jest.fn(() => axios); +const requestMock = utils.request as jest.Mock; +const now = Date.now; +const TIMESTAMP = 1589391874472; + +// Incident update makes three calls to the API. +// The function below mocks this calls. +// a) Get the latest incident +// b) Update the incident +// c) Get the updated incident +const mockIncidentUpdate = (withUpdateError = false) => { + requestMock.mockImplementationOnce(() => ({ + data: { + id: '1', + name: 'title', + description: { + format: 'html', + content: 'description', + }, + }, + })); + + if (withUpdateError) { + requestMock.mockImplementationOnce(() => { + throw new Error('An error has occurred'); + }); + } else { + requestMock.mockImplementationOnce(() => ({ + data: { + success: true, + id: '1', + inc_last_modified_date: 1589391874472, + }, + })); + } + + requestMock.mockImplementationOnce(() => ({ + data: { + id: '1', + name: 'title_updated', + description: { + format: 'html', + content: 'desc_updated', + }, + inc_last_modified_date: 1589391874472, + }, + })); +}; + +describe('IBM Resilient service', () => { + let service: ExternalService; + + beforeAll(() => { + service = createExternalService({ + config: { apiUrl: 'https://resilient.elastic.co', orgId: '201' }, + secrets: { apiKeyId: 'keyId', apiKeySecret: 'secret' }, + }); + }); + + afterAll(() => { + Date.now = now; + }); + + beforeEach(() => { + jest.resetAllMocks(); + Date.now = jest.fn().mockReturnValue(TIMESTAMP); + }); + + describe('getValueTextContent', () => { + test('transforms correctly', () => { + expect(getValueTextContent('name', 'title')).toEqual({ + text: 'title', + }); + }); + + test('transforms correctly the description', () => { + expect(getValueTextContent('description', 'desc')).toEqual({ + textarea: { + format: 'html', + content: 'desc', + }, + }); + }); + }); + + describe('formatUpdateRequest', () => { + test('transforms correctly', () => { + const oldIncident = { name: 'title', description: 'desc' }; + const newIncident = { name: 'title_updated', description: 'desc_updated' }; + expect(formatUpdateRequest({ oldIncident, newIncident })).toEqual({ + changes: [ + { + field: { name: 'name' }, + old_value: { text: 'title' }, + new_value: { text: 'title_updated' }, + }, + { + field: { name: 'description' }, + old_value: { + textarea: { + format: 'html', + content: 'desc', + }, + }, + new_value: { + textarea: { + format: 'html', + content: 'desc_updated', + }, + }, + }, + ], + }); + }); + }); + + describe('createExternalService', () => { + test('throws without url', () => { + expect(() => + createExternalService({ + config: { apiUrl: null, orgId: '201' }, + secrets: { apiKeyId: 'token', apiKeySecret: 'secret' }, + }) + ).toThrow(); + }); + + test('throws without orgId', () => { + expect(() => + createExternalService({ + config: { apiUrl: 'test.com', orgId: null }, + secrets: { apiKeyId: 'token', apiKeySecret: 'secret' }, + }) + ).toThrow(); + }); + + test('throws without username', () => { + expect(() => + createExternalService({ + config: { apiUrl: 'test.com', orgId: '201' }, + secrets: { apiKeyId: '', apiKeySecret: 'secret' }, + }) + ).toThrow(); + }); + + test('throws without password', () => { + expect(() => + createExternalService({ + config: { apiUrl: 'test.com', orgId: '201' }, + secrets: { apiKeyId: '', apiKeySecret: undefined }, + }) + ).toThrow(); + }); + }); + + describe('getIncident', () => { + test('it returns the incident correctly', async () => { + requestMock.mockImplementation(() => ({ + data: { + id: '1', + name: '1', + description: { + format: 'html', + content: 'description', + }, + }, + })); + const res = await service.getIncident('1'); + expect(res).toEqual({ id: '1', name: '1', description: 'description' }); + }); + + test('it should call request with correct arguments', async () => { + requestMock.mockImplementation(() => ({ + data: { id: '1' }, + })); + + await service.getIncident('1'); + expect(requestMock).toHaveBeenCalledWith({ + axios, + url: 'https://resilient.elastic.co/rest/orgs/201/incidents/1', + params: { + text_content_output_format: 'objects_convert', + }, + }); + }); + + test('it should throw an error', async () => { + requestMock.mockImplementation(() => { + throw new Error('An error has occurred'); + }); + expect(service.getIncident('1')).rejects.toThrow( + 'Unable to get incident with id 1. Error: An error has occurred' + ); + }); + }); + + describe('createIncident', () => { + test('it creates the incident correctly', async () => { + requestMock.mockImplementation(() => ({ + data: { + id: '1', + name: 'title', + description: 'description', + discovered_date: 1589391874472, + create_date: 1589391874472, + }, + })); + + const res = await service.createIncident({ + incident: { name: 'title', description: 'desc' }, + }); + + expect(res).toEqual({ + title: '1', + id: '1', + pushedDate: '2020-05-13T17:44:34.472Z', + url: 'https://resilient.elastic.co/#incidents/1', + }); + }); + + test('it should call request with correct arguments', async () => { + requestMock.mockImplementation(() => ({ + data: { + id: '1', + name: 'title', + description: 'description', + discovered_date: 1589391874472, + create_date: 1589391874472, + }, + })); + + await service.createIncident({ + incident: { name: 'title', description: 'desc' }, + }); + + expect(requestMock).toHaveBeenCalledWith({ + axios, + url: 'https://resilient.elastic.co/rest/orgs/201/incidents', + method: 'post', + data: { + name: 'title', + description: { + format: 'html', + content: 'desc', + }, + discovered_date: TIMESTAMP, + }, + }); + }); + + test('it should throw an error', async () => { + requestMock.mockImplementation(() => { + throw new Error('An error has occurred'); + }); + + expect( + service.createIncident({ + incident: { name: 'title', description: 'desc' }, + }) + ).rejects.toThrow( + '[Action][IBM Resilient]: Unable to create incident. Error: An error has occurred' + ); + }); + }); + + describe('updateIncident', () => { + test('it updates the incident correctly', async () => { + mockIncidentUpdate(); + const res = await service.updateIncident({ + incidentId: '1', + incident: { name: 'title_updated', description: 'desc_updated' }, + }); + + expect(res).toEqual({ + title: '1', + id: '1', + pushedDate: '2020-05-13T17:44:34.472Z', + url: 'https://resilient.elastic.co/#incidents/1', + }); + }); + + test('it should call request with correct arguments', async () => { + mockIncidentUpdate(); + + await service.updateIncident({ + incidentId: '1', + incident: { name: 'title_updated', description: 'desc_updated' }, + }); + + // Incident update makes three calls to the API. + // The second call to the API is the update call. + expect(requestMock.mock.calls[1][0]).toEqual({ + axios, + method: 'patch', + url: 'https://resilient.elastic.co/rest/orgs/201/incidents/1', + data: { + changes: [ + { + field: { name: 'name' }, + old_value: { text: 'title' }, + new_value: { text: 'title_updated' }, + }, + { + field: { name: 'description' }, + old_value: { + textarea: { + content: 'description', + format: 'html', + }, + }, + new_value: { + textarea: { + content: 'desc_updated', + format: 'html', + }, + }, + }, + ], + }, + }); + }); + + test('it should throw an error', async () => { + mockIncidentUpdate(true); + + expect( + service.updateIncident({ + incidentId: '1', + incident: { name: 'title', description: 'desc' }, + }) + ).rejects.toThrow( + '[Action][IBM Resilient]: Unable to update incident with id 1. Error: An error has occurred' + ); + }); + }); + + describe('createComment', () => { + test('it creates the comment correctly', async () => { + requestMock.mockImplementation(() => ({ + data: { + id: '1', + create_date: 1589391874472, + }, + })); + + const res = await service.createComment({ + incidentId: '1', + comment: { comment: 'comment', commentId: 'comment-1' }, + field: 'comments', + }); + + expect(res).toEqual({ + commentId: 'comment-1', + pushedDate: '2020-05-13T17:44:34.472Z', + externalCommentId: '1', + }); + }); + + test('it should call request with correct arguments', async () => { + requestMock.mockImplementation(() => ({ + data: { + id: '1', + create_date: 1589391874472, + }, + })); + + await service.createComment({ + incidentId: '1', + comment: { comment: 'comment', commentId: 'comment-1' }, + field: 'my_field', + }); + + expect(requestMock).toHaveBeenCalledWith({ + axios, + method: 'post', + url: 'https://resilient.elastic.co/rest/orgs/201/incidents/1/comments', + data: { + text: { + content: 'comment', + format: 'text', + }, + }, + }); + }); + + test('it should throw an error', async () => { + requestMock.mockImplementation(() => { + throw new Error('An error has occurred'); + }); + + expect( + service.createComment({ + incidentId: '1', + comment: { comment: 'comment', commentId: 'comment-1' }, + field: 'comments', + }) + ).rejects.toThrow( + '[Action][IBM Resilient]: Unable to create comment at incident with id 1. Error: An error has occurred' + ); + }); + }); +}); diff --git a/x-pack/plugins/actions/server/builtin_action_types/resilient/service.ts b/x-pack/plugins/actions/server/builtin_action_types/resilient/service.ts new file mode 100644 index 00000000000000..8d0526ca3b5718 --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/resilient/service.ts @@ -0,0 +1,197 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import axios from 'axios'; + +import { ExternalServiceCredentials, ExternalService, ExternalServiceParams } from '../case/types'; +import { + ResilientPublicConfigurationType, + ResilientSecretConfigurationType, + CreateIncidentRequest, + UpdateIncidentRequest, + CreateCommentRequest, + UpdateFieldText, + UpdateFieldTextArea, +} from './types'; + +import * as i18n from './translations'; +import { getErrorMessage, request } from '../lib/axios_utils'; + +const BASE_URL = `rest`; +const INCIDENT_URL = `incidents`; +const COMMENT_URL = `comments`; + +const VIEW_INCIDENT_URL = `#incidents`; + +export const getValueTextContent = ( + field: string, + value: string +): UpdateFieldText | UpdateFieldTextArea => { + if (field === 'description') { + return { + textarea: { + format: 'html', + content: value, + }, + }; + } + + return { + text: value, + }; +}; + +export const formatUpdateRequest = ({ + oldIncident, + newIncident, +}: ExternalServiceParams): UpdateIncidentRequest => { + return { + changes: Object.keys(newIncident).map((key) => ({ + field: { name: key }, + old_value: getValueTextContent(key, oldIncident[key]), + new_value: getValueTextContent(key, newIncident[key]), + })), + }; +}; + +export const createExternalService = ({ + config, + secrets, +}: ExternalServiceCredentials): ExternalService => { + const { apiUrl: url, orgId } = config as ResilientPublicConfigurationType; + const { apiKeyId, apiKeySecret } = secrets as ResilientSecretConfigurationType; + + if (!url || !orgId || !apiKeyId || !apiKeySecret) { + throw Error(`[Action]${i18n.NAME}: Wrong configuration.`); + } + + const urlWithoutTrailingSlash = url.endsWith('/') ? url.slice(0, -1) : url; + const incidentUrl = `${urlWithoutTrailingSlash}/${BASE_URL}/orgs/${orgId}/${INCIDENT_URL}`; + const commentUrl = `${incidentUrl}/{inc_id}/${COMMENT_URL}`; + const axiosInstance = axios.create({ + auth: { username: apiKeyId, password: apiKeySecret }, + }); + + const getIncidentViewURL = (key: string) => { + return `${urlWithoutTrailingSlash}/${VIEW_INCIDENT_URL}/${key}`; + }; + + const getCommentsURL = (incidentId: string) => { + return commentUrl.replace('{inc_id}', incidentId); + }; + + const getIncident = async (id: string) => { + try { + const res = await request({ + axios: axiosInstance, + url: `${incidentUrl}/${id}`, + params: { + text_content_output_format: 'objects_convert', + }, + }); + + return { ...res.data, description: res.data.description?.content ?? '' }; + } catch (error) { + throw new Error( + getErrorMessage(i18n.NAME, `Unable to get incident with id ${id}. Error: ${error.message}`) + ); + } + }; + + const createIncident = async ({ incident }: ExternalServiceParams) => { + try { + const res = await request({ + axios: axiosInstance, + url: `${incidentUrl}`, + method: 'post', + data: { + ...incident, + description: { + format: 'html', + content: incident.description ?? '', + }, + discovered_date: Date.now(), + }, + }); + + return { + title: `${res.data.id}`, + id: `${res.data.id}`, + pushedDate: new Date(res.data.create_date).toISOString(), + url: getIncidentViewURL(res.data.id), + }; + } catch (error) { + throw new Error( + getErrorMessage(i18n.NAME, `Unable to create incident. Error: ${error.message}`) + ); + } + }; + + const updateIncident = async ({ incidentId, incident }: ExternalServiceParams) => { + try { + const latestIncident = await getIncident(incidentId); + + const data = formatUpdateRequest({ oldIncident: latestIncident, newIncident: incident }); + const res = await request({ + axios: axiosInstance, + method: 'patch', + url: `${incidentUrl}/${incidentId}`, + data, + }); + + if (!res.data.success) { + throw new Error(res.data.message); + } + + const updatedIncident = await getIncident(incidentId); + + return { + title: `${updatedIncident.id}`, + id: `${updatedIncident.id}`, + pushedDate: new Date(updatedIncident.inc_last_modified_date).toISOString(), + url: getIncidentViewURL(updatedIncident.id), + }; + } catch (error) { + throw new Error( + getErrorMessage( + i18n.NAME, + `Unable to update incident with id ${incidentId}. Error: ${error.message}` + ) + ); + } + }; + + const createComment = async ({ incidentId, comment, field }: ExternalServiceParams) => { + try { + const res = await request({ + axios: axiosInstance, + method: 'post', + url: getCommentsURL(incidentId), + data: { text: { format: 'text', content: comment.comment } }, + }); + + return { + commentId: comment.commentId, + externalCommentId: res.data.id, + pushedDate: new Date(res.data.create_date).toISOString(), + }; + } catch (error) { + throw new Error( + getErrorMessage( + i18n.NAME, + `Unable to create comment at incident with id ${incidentId}. Error: ${error.message}` + ) + ); + } + }; + + return { + getIncident, + createIncident, + updateIncident, + createComment, + }; +}; diff --git a/x-pack/plugins/actions/server/builtin_action_types/resilient/translations.ts b/x-pack/plugins/actions/server/builtin_action_types/resilient/translations.ts new file mode 100644 index 00000000000000..d952838d5a2b34 --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/resilient/translations.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; + +export const NAME = i18n.translate('xpack.actions.builtin.case.resilientTitle', { + defaultMessage: 'IBM Resilient', +}); diff --git a/x-pack/plugins/actions/server/builtin_action_types/resilient/types.ts b/x-pack/plugins/actions/server/builtin_action_types/resilient/types.ts new file mode 100644 index 00000000000000..6869e2ff3a1056 --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/resilient/types.ts @@ -0,0 +1,46 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { TypeOf } from '@kbn/config-schema'; +import { ResilientPublicConfigurationSchema, ResilientSecretConfigurationSchema } from './schema'; + +export type ResilientPublicConfigurationType = TypeOf; +export type ResilientSecretConfigurationType = TypeOf; + +interface CreateIncidentBasicRequestArgs { + name: string; + description: string; + discovered_date: number; +} + +interface Comment { + text: { format: string; content: string }; +} + +interface CreateIncidentRequestArgs extends CreateIncidentBasicRequestArgs { + comments?: Comment[]; +} + +export interface UpdateFieldText { + text: string; +} + +export interface UpdateFieldTextArea { + textarea: { format: 'html' | 'text'; content: string }; +} + +interface UpdateField { + field: { name: string }; + old_value: UpdateFieldText | UpdateFieldTextArea; + new_value: UpdateFieldText | UpdateFieldTextArea; +} + +export type CreateIncidentRequest = CreateIncidentRequestArgs; +export type CreateCommentRequest = Comment; + +export interface UpdateIncidentRequest { + changes: UpdateField[]; +} diff --git a/x-pack/plugins/actions/server/builtin_action_types/resilient/validators.ts b/x-pack/plugins/actions/server/builtin_action_types/resilient/validators.ts new file mode 100644 index 00000000000000..7226071392bc63 --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/resilient/validators.ts @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { validateCommonConfig, validateCommonSecrets } from '../case/validators'; +import { ExternalServiceValidation } from '../case/types'; + +export const validate: ExternalServiceValidation = { + config: validateCommonConfig, + secrets: validateCommonSecrets, +}; diff --git a/x-pack/plugins/apm/kibana.json b/x-pack/plugins/apm/kibana.json index 56a9e226b65284..ee89abf59ee236 100644 --- a/x-pack/plugins/apm/kibana.json +++ b/x-pack/plugins/apm/kibana.json @@ -28,5 +28,10 @@ ], "extraPublicDirs": [ "public/style/variables" + ], + "requiredBundles": [ + "kibanaReact", + "kibanaUtils", + "observability" ] } diff --git a/x-pack/plugins/canvas/kibana.json b/x-pack/plugins/canvas/kibana.json index 2d6ab43228aa1c..5f4ea5802cb138 100644 --- a/x-pack/plugins/canvas/kibana.json +++ b/x-pack/plugins/canvas/kibana.json @@ -6,5 +6,6 @@ "server": true, "ui": true, "requiredPlugins": ["data", "embeddable", "expressions", "features", "home", "inspector", "uiActions"], - "optionalPlugins": ["usageCollection"] + "optionalPlugins": ["usageCollection"], + "requiredBundles": ["kibanaReact", "maps", "lens", "visualizations", "kibanaUtils", "kibanaLegacy", "discover", "savedObjects", "reporting"] } diff --git a/x-pack/plugins/case/common/api/cases/configure.ts b/x-pack/plugins/case/common/api/cases/configure.ts index 7d20011a428cfe..38fff5b190f25a 100644 --- a/x-pack/plugins/case/common/api/cases/configure.ts +++ b/x-pack/plugins/case/common/api/cases/configure.ts @@ -10,6 +10,7 @@ import { ActionResult } from '../../../../actions/common'; import { UserRT } from '../user'; import { JiraFieldsRT } from '../connectors/jira'; import { ServiceNowFieldsRT } from '../connectors/servicenow'; +import { ResilientFieldsRT } from '../connectors/resilient'; /* * This types below are related to the service now configuration @@ -29,7 +30,12 @@ const CaseFieldRT = rt.union([ rt.literal('comments'), ]); -const ThirdPartyFieldRT = rt.union([JiraFieldsRT, ServiceNowFieldsRT, rt.literal('not_mapped')]); +const ThirdPartyFieldRT = rt.union([ + JiraFieldsRT, + ServiceNowFieldsRT, + ResilientFieldsRT, + rt.literal('not_mapped'), +]); export const CasesConfigurationMapsRT = rt.type({ source: CaseFieldRT, diff --git a/x-pack/plugins/case/common/api/connectors/index.ts b/x-pack/plugins/case/common/api/connectors/index.ts index c1fc284c938b75..0a7840d3aba226 100644 --- a/x-pack/plugins/case/common/api/connectors/index.ts +++ b/x-pack/plugins/case/common/api/connectors/index.ts @@ -6,3 +6,4 @@ export * from './jira'; export * from './servicenow'; +export * from './resilient'; diff --git a/x-pack/plugins/case/common/api/connectors/resilient.ts b/x-pack/plugins/case/common/api/connectors/resilient.ts new file mode 100644 index 00000000000000..c7e2f198091406 --- /dev/null +++ b/x-pack/plugins/case/common/api/connectors/resilient.ts @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import * as rt from 'io-ts'; + +export const ResilientFieldsRT = rt.union([ + rt.literal('name'), + rt.literal('description'), + rt.literal('comments'), +]); + +export type ResilientFieldsType = rt.TypeOf; diff --git a/x-pack/plugins/case/common/constants.ts b/x-pack/plugins/case/common/constants.ts index e912c661439b2f..bd12c258a5388f 100644 --- a/x-pack/plugins/case/common/constants.ts +++ b/x-pack/plugins/case/common/constants.ts @@ -29,4 +29,4 @@ export const ACTION_URL = '/api/actions'; export const ACTION_TYPES_URL = '/api/actions/list_action_types'; export const SERVICENOW_ACTION_TYPE_ID = '.servicenow'; -export const SUPPORTED_CONNECTORS = ['.servicenow', '.jira']; +export const SUPPORTED_CONNECTORS = ['.servicenow', '.jira', '.resilient']; diff --git a/x-pack/plugins/cross_cluster_replication/kibana.json b/x-pack/plugins/cross_cluster_replication/kibana.json index ccf98f41def476..13746bb0e34c3a 100644 --- a/x-pack/plugins/cross_cluster_replication/kibana.json +++ b/x-pack/plugins/cross_cluster_replication/kibana.json @@ -13,5 +13,10 @@ "optionalPlugins": [ "usageCollection" ], - "configPath": ["xpack", "ccr"] + "configPath": ["xpack", "ccr"], + "requiredBundles": [ + "kibanaReact", + "esUiShared", + "data" + ] } diff --git a/x-pack/plugins/dashboard_enhanced/kibana.json b/x-pack/plugins/dashboard_enhanced/kibana.json index 3a95419d2f2fe0..ba5d8052ca7876 100644 --- a/x-pack/plugins/dashboard_enhanced/kibana.json +++ b/x-pack/plugins/dashboard_enhanced/kibana.json @@ -4,5 +4,10 @@ "server": false, "ui": true, "requiredPlugins": ["data", "uiActionsEnhanced", "embeddable", "dashboard", "share"], - "configPath": ["xpack", "dashboardEnhanced"] + "configPath": ["xpack", "dashboardEnhanced"], + "requiredBundles": [ + "kibanaUtils", + "embeddableEnhanced", + "kibanaReact" + ] } diff --git a/x-pack/plugins/data_enhanced/kibana.json b/x-pack/plugins/data_enhanced/kibana.json index 1be55d2b7a6352..f0baa84afca322 100644 --- a/x-pack/plugins/data_enhanced/kibana.json +++ b/x-pack/plugins/data_enhanced/kibana.json @@ -10,5 +10,6 @@ ], "optionalPlugins": ["kibanaReact", "kibanaUtils"], "server": true, - "ui": true + "ui": true, + "requiredBundles": ["kibanaReact", "kibanaUtils"] } diff --git a/x-pack/plugins/discover_enhanced/kibana.json b/x-pack/plugins/discover_enhanced/kibana.json index 704096ce7fcade..fbd04fe0096877 100644 --- a/x-pack/plugins/discover_enhanced/kibana.json +++ b/x-pack/plugins/discover_enhanced/kibana.json @@ -6,5 +6,6 @@ "ui": true, "requiredPlugins": ["uiActions", "embeddable", "discover"], "optionalPlugins": ["share"], - "configPath": ["xpack", "discoverEnhanced"] + "configPath": ["xpack", "discoverEnhanced"], + "requiredBundles": ["kibanaUtils", "data"] } diff --git a/x-pack/plugins/graph/kibana.json b/x-pack/plugins/graph/kibana.json index ebe18dba2b58c8..4e653393100c90 100644 --- a/x-pack/plugins/graph/kibana.json +++ b/x-pack/plugins/graph/kibana.json @@ -6,5 +6,6 @@ "ui": true, "requiredPlugins": ["licensing", "data", "navigation", "savedObjects", "kibanaLegacy"], "optionalPlugins": ["home", "features"], - "configPath": ["xpack", "graph"] + "configPath": ["xpack", "graph"], + "requiredBundles": ["kibanaUtils", "kibanaReact", "home"] } diff --git a/x-pack/plugins/grokdebugger/kibana.json b/x-pack/plugins/grokdebugger/kibana.json index 4d37f9ccdb0dec..8466c191ed9b6f 100644 --- a/x-pack/plugins/grokdebugger/kibana.json +++ b/x-pack/plugins/grokdebugger/kibana.json @@ -9,5 +9,8 @@ ], "server": true, "ui": true, - "configPath": ["xpack", "grokdebugger"] + "configPath": ["xpack", "grokdebugger"], + "requiredBundles": [ + "kibanaReact" + ] } diff --git a/x-pack/plugins/index_lifecycle_management/kibana.json b/x-pack/plugins/index_lifecycle_management/kibana.json index 6385646b957890..1a9f133b846fb7 100644 --- a/x-pack/plugins/index_lifecycle_management/kibana.json +++ b/x-pack/plugins/index_lifecycle_management/kibana.json @@ -12,5 +12,10 @@ "usageCollection", "indexManagement" ], - "configPath": ["xpack", "ilm"] + "configPath": ["xpack", "ilm"], + "requiredBundles": [ + "indexManagement", + "kibanaReact", + "esUiShared" + ] } diff --git a/x-pack/plugins/index_management/kibana.json b/x-pack/plugins/index_management/kibana.json index 40ecb26e8f0c96..6ab691054382ec 100644 --- a/x-pack/plugins/index_management/kibana.json +++ b/x-pack/plugins/index_management/kibana.json @@ -13,5 +13,9 @@ "usageCollection", "ingestManager" ], - "configPath": ["xpack", "index_management"] + "configPath": ["xpack", "index_management"], + "requiredBundles": [ + "kibanaReact", + "esUiShared" + ] } diff --git a/x-pack/plugins/infra/kibana.json b/x-pack/plugins/infra/kibana.json index e5ce1b1cd96f8a..06394c2aa916c2 100644 --- a/x-pack/plugins/infra/kibana.json +++ b/x-pack/plugins/infra/kibana.json @@ -16,5 +16,12 @@ "optionalPlugins": ["ml", "observability"], "server": true, "ui": true, - "configPath": ["xpack", "infra"] + "configPath": ["xpack", "infra"], + "requiredBundles": [ + "observability", + "licenseManagement", + "kibanaUtils", + "kibanaReact", + "apm" + ] } diff --git a/x-pack/plugins/ingest_manager/kibana.json b/x-pack/plugins/ingest_manager/kibana.json index 877184740166f5..ab0a2ba24ba663 100644 --- a/x-pack/plugins/ingest_manager/kibana.json +++ b/x-pack/plugins/ingest_manager/kibana.json @@ -6,5 +6,6 @@ "configPath": ["xpack", "ingestManager"], "requiredPlugins": ["licensing", "data", "encryptedSavedObjects"], "optionalPlugins": ["security", "features", "cloud", "usageCollection", "home"], - "extraPublicDirs": ["common"] + "extraPublicDirs": ["common"], + "requiredBundles": ["kibanaReact", "esUiShared"] } diff --git a/x-pack/plugins/ingest_pipelines/kibana.json b/x-pack/plugins/ingest_pipelines/kibana.json index cb24133b1f6ba4..75e5e9b5d6c514 100644 --- a/x-pack/plugins/ingest_pipelines/kibana.json +++ b/x-pack/plugins/ingest_pipelines/kibana.json @@ -5,5 +5,6 @@ "ui": true, "requiredPlugins": ["licensing", "management"], "optionalPlugins": ["security", "usageCollection"], - "configPath": ["xpack", "ingest_pipelines"] + "configPath": ["xpack", "ingest_pipelines"], + "requiredBundles": ["esUiShared", "kibanaReact"] } diff --git a/x-pack/plugins/lens/kibana.json b/x-pack/plugins/lens/kibana.json index 7da5eaed5155ef..b8747fc1f0cde7 100644 --- a/x-pack/plugins/lens/kibana.json +++ b/x-pack/plugins/lens/kibana.json @@ -15,5 +15,6 @@ ], "optionalPlugins": ["embeddable", "usageCollection", "taskManager", "uiActions"], "configPath": ["xpack", "lens"], - "extraPublicDirs": ["common/constants"] + "extraPublicDirs": ["common/constants"], + "requiredBundles": ["savedObjects", "kibanaUtils", "kibanaReact", "embeddable"] } diff --git a/x-pack/plugins/license_management/kibana.json b/x-pack/plugins/license_management/kibana.json index 6da923c5cff5a3..3dbf99fced0b07 100644 --- a/x-pack/plugins/license_management/kibana.json +++ b/x-pack/plugins/license_management/kibana.json @@ -6,5 +6,9 @@ "requiredPlugins": ["home", "licensing", "management"], "optionalPlugins": ["telemetry"], "configPath": ["xpack", "license_management"], - "extraPublicDirs": ["common/constants"] + "extraPublicDirs": ["common/constants"], + "requiredBundles": [ + "telemetryManagementSection", + "kibanaReact" + ] } diff --git a/x-pack/plugins/licensing/kibana.json b/x-pack/plugins/licensing/kibana.json index 9edaa726c6ba96..2d38a82271eb03 100644 --- a/x-pack/plugins/licensing/kibana.json +++ b/x-pack/plugins/licensing/kibana.json @@ -4,5 +4,6 @@ "kibanaVersion": "kibana", "configPath": ["xpack", "licensing"], "server": true, - "ui": true + "ui": true, + "requiredBundles": ["kibanaReact"] } diff --git a/x-pack/plugins/logstash/kibana.json b/x-pack/plugins/logstash/kibana.json index 1eb325dcc1610b..5949d5db041f2e 100644 --- a/x-pack/plugins/logstash/kibana.json +++ b/x-pack/plugins/logstash/kibana.json @@ -13,5 +13,6 @@ "security" ], "server": true, - "ui": true + "ui": true, + "requiredBundles": ["home"] } diff --git a/x-pack/plugins/maps/kibana.json b/x-pack/plugins/maps/kibana.json index f8a30b8d0337e4..e422efb31cb0d7 100644 --- a/x-pack/plugins/maps/kibana.json +++ b/x-pack/plugins/maps/kibana.json @@ -19,5 +19,11 @@ ], "ui": true, "server": true, - "extraPublicDirs": ["common/constants"] + "extraPublicDirs": ["common/constants"], + "requiredBundles": [ + "charts", + "kibanaReact", + "kibanaUtils", + "savedObjects" + ] } diff --git a/x-pack/plugins/ml/kibana.json b/x-pack/plugins/ml/kibana.json index f93e7bc19f9603..a08b9b6d97116a 100644 --- a/x-pack/plugins/ml/kibana.json +++ b/x-pack/plugins/ml/kibana.json @@ -25,5 +25,13 @@ "licenseManagement" ], "server": true, - "ui": true + "ui": true, + "requiredBundles": [ + "esUiShared", + "kibanaUtils", + "kibanaReact", + "management", + "dashboard", + "savedObjects" + ] } diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/configuration_step/analysis_fields_table.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/configuration_step/analysis_fields_table.tsx index def6acdae14e3b..ff8797bc523c1b 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/configuration_step/analysis_fields_table.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/configuration_step/analysis_fields_table.tsx @@ -85,16 +85,27 @@ export const AnalysisFieldsTable: FC<{ includes: string[]; loadingItems: boolean; setFormState: React.Dispatch>; + minimumFieldsRequiredMessage?: string; + setMinimumFieldsRequiredMessage: React.Dispatch>; tableItems: FieldSelectionItem[]; -}> = ({ dependentVariable, includes, loadingItems, setFormState, tableItems }) => { + unsupportedFieldsError?: string; + setUnsupportedFieldsError: React.Dispatch>; +}> = ({ + dependentVariable, + includes, + loadingItems, + setFormState, + minimumFieldsRequiredMessage, + setMinimumFieldsRequiredMessage, + tableItems, + unsupportedFieldsError, + setUnsupportedFieldsError, +}) => { const [sortableProperties, setSortableProperties] = useState(); const [currentPaginationData, setCurrentPaginationData] = useState<{ pageIndex: number; itemsPerPage: number; }>({ pageIndex: 0, itemsPerPage: 5 }); - const [minimumFieldsRequiredMessage, setMinimumFieldsRequiredMessage] = useState< - undefined | string - >(undefined); useEffect(() => { if (includes.length === 0 && tableItems.length > 0) { @@ -164,8 +175,21 @@ export const AnalysisFieldsTable: FC<{ label={i18n.translate('xpack.ml.dataframe.analytics.create.includedFieldsLabel', { defaultMessage: 'Included fields', })} - isInvalid={minimumFieldsRequiredMessage !== undefined} - error={minimumFieldsRequiredMessage} + fullWidth + isInvalid={ + minimumFieldsRequiredMessage !== undefined || unsupportedFieldsError !== undefined + } + error={[ + ...(minimumFieldsRequiredMessage !== undefined ? [minimumFieldsRequiredMessage] : []), + ...(unsupportedFieldsError !== undefined + ? [ + i18n.translate('xpack.ml.dataframe.analytics.create.unsupportedFieldsError', { + defaultMessage: 'Invalid. {message}', + values: { message: unsupportedFieldsError }, + }), + ] + : []), + ]} > @@ -209,9 +233,10 @@ export const AnalysisFieldsTable: FC<{ ) { selection = [dependentVariable]; } - // If nothing selected show minimum fields required message and don't update form yet + // If includes is empty show minimum fields required message and don't update form yet if (selection.length === 0) { setMinimumFieldsRequiredMessage(minimumFieldsMessage); + setUnsupportedFieldsError(undefined); } else { setMinimumFieldsRequiredMessage(undefined); setFormState({ includes: selection }); diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/configuration_step/configuration_step_form.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/configuration_step/configuration_step_form.tsx index 9dae54b6537b3d..571c7731822c06 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/configuration_step/configuration_step_form.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/configuration_step/configuration_step_form.tsx @@ -73,6 +73,9 @@ export const ConfigurationStepForm: FC = ({ const [includesTableItems, setIncludesTableItems] = useState([]); const [maxDistinctValuesError, setMaxDistinctValuesError] = useState(); const [unsupportedFieldsError, setUnsupportedFieldsError] = useState(); + const [minimumFieldsRequiredMessage, setMinimumFieldsRequiredMessage] = useState< + undefined | string + >(); const { setEstimatedModelMemoryLimit, setFormState } = actions; const { estimatedModelMemoryLimit, form, isJobCreated, requestMessages } = state; @@ -117,6 +120,7 @@ export const ConfigurationStepForm: FC = ({ dependentVariableEmpty || jobType === undefined || maxDistinctValuesError !== undefined || + minimumFieldsRequiredMessage !== undefined || requiredFieldsError !== undefined || unsupportedFieldsError !== undefined; @@ -400,32 +404,22 @@ export const ConfigurationStepForm: FC = ({ )} diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/create_step/progress_stats.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/create_step/progress_stats.tsx index a50254334526cb..c87f0f4206feb6 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/create_step/progress_stats.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/create_step/progress_stats.tsx @@ -15,13 +15,15 @@ import { } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { useMlKibana } from '../../../../../contexts/kibana'; -import { getDataFrameAnalyticsProgressPhase } from '../../../analytics_management/components/analytics_list/common'; +import { + getDataFrameAnalyticsProgressPhase, + DATA_FRAME_TASK_STATE, +} from '../../../analytics_management/components/analytics_list/common'; import { isGetDataFrameAnalyticsStatsResponseOk } from '../../../analytics_management/services/analytics_service/get_analytics'; import { ml } from '../../../../../services/ml_api_service'; import { DataFrameAnalyticsId } from '../../../../common/analytics'; export const PROGRESS_REFRESH_INTERVAL_MS = 1000; -const FAILED = 'failed'; export const ProgressStats: FC<{ jobId: DataFrameAnalyticsId }> = ({ jobId }) => { const [initialized, setInitialized] = useState(false); @@ -54,7 +56,7 @@ export const ProgressStats: FC<{ jobId: DataFrameAnalyticsId }> = ({ jobId }) => if (jobStats !== undefined) { const progressStats = getDataFrameAnalyticsProgressPhase(jobStats); - if (jobStats.state === FAILED) { + if (jobStats.state === DATA_FRAME_TASK_STATE.FAILED) { clearInterval(interval); setFailedJobMessage( jobStats.failure_reason || @@ -70,8 +72,9 @@ export const ProgressStats: FC<{ jobId: DataFrameAnalyticsId }> = ({ jobId }) => setCurrentProgress(progressStats); if ( - progressStats.currentPhase === progressStats.totalPhases && - progressStats.progress === 100 + (progressStats.currentPhase === progressStats.totalPhases && + progressStats.progress === 100) || + jobStats.state === DATA_FRAME_TASK_STATE.STOPPED ) { clearInterval(interval); } diff --git a/x-pack/plugins/monitoring/kibana.json b/x-pack/plugins/monitoring/kibana.json index c3000218aa1253..65dd4b373a71aa 100644 --- a/x-pack/plugins/monitoring/kibana.json +++ b/x-pack/plugins/monitoring/kibana.json @@ -6,5 +6,6 @@ "requiredPlugins": ["licensing", "features", "data", "navigation", "kibanaLegacy"], "optionalPlugins": ["alerts", "actions", "infra", "telemetryCollectionManager", "usageCollection", "home", "cloud"], "server": true, - "ui": true + "ui": true, + "requiredBundles": ["kibanaUtils", "home", "alerts", "kibanaReact", "licenseManagement"] } diff --git a/x-pack/plugins/observability/kibana.json b/x-pack/plugins/observability/kibana.json index 712a46f76bb747..2a04a35830a471 100644 --- a/x-pack/plugins/observability/kibana.json +++ b/x-pack/plugins/observability/kibana.json @@ -10,5 +10,10 @@ "licensing" ], "ui": true, - "server": true + "server": true, + "requiredBundles": [ + "data", + "kibanaReact", + "kibanaUtils" + ] } diff --git a/x-pack/plugins/painless_lab/kibana.json b/x-pack/plugins/painless_lab/kibana.json index 4b4ea24202846e..ca97e73704e705 100644 --- a/x-pack/plugins/painless_lab/kibana.json +++ b/x-pack/plugins/painless_lab/kibana.json @@ -12,5 +12,8 @@ "painless_lab" ], "server": true, - "ui": true + "ui": true, + "requiredBundles": [ + "kibanaReact" + ] } diff --git a/x-pack/plugins/remote_clusters/kibana.json b/x-pack/plugins/remote_clusters/kibana.json index f1b9d20f762d36..d90d6ea460573d 100644 --- a/x-pack/plugins/remote_clusters/kibana.json +++ b/x-pack/plugins/remote_clusters/kibana.json @@ -15,5 +15,9 @@ "cloud" ], "server": true, - "ui": true + "ui": true, + "requiredBundles": [ + "kibanaReact", + "esUiShared" + ] } diff --git a/x-pack/plugins/reporting/kibana.json b/x-pack/plugins/reporting/kibana.json index bc1a808d500e09..a5d7f3d20c44c9 100644 --- a/x-pack/plugins/reporting/kibana.json +++ b/x-pack/plugins/reporting/kibana.json @@ -17,5 +17,9 @@ "share" ], "server": true, - "ui": true + "ui": true, + "requiredBundles": [ + "kibanaReact", + "discover" + ] } diff --git a/x-pack/plugins/rollup/kibana.json b/x-pack/plugins/rollup/kibana.json index f897051d3ed8a6..e6915f65599ccf 100644 --- a/x-pack/plugins/rollup/kibana.json +++ b/x-pack/plugins/rollup/kibana.json @@ -15,5 +15,12 @@ "usageCollection", "visTypeTimeseries" ], - "configPath": ["xpack", "rollup"] + "configPath": ["xpack", "rollup"], + "requiredBundles": [ + "kibanaUtils", + "kibanaReact", + "home", + "esUiShared", + "data" + ] } diff --git a/x-pack/plugins/searchprofiler/kibana.json b/x-pack/plugins/searchprofiler/kibana.json index f92083ee9d9fe5..a5e42f20b5c7af 100644 --- a/x-pack/plugins/searchprofiler/kibana.json +++ b/x-pack/plugins/searchprofiler/kibana.json @@ -5,5 +5,6 @@ "requiredPlugins": ["devTools", "home", "licensing"], "configPath": ["xpack", "searchprofiler"], "server": true, - "ui": true + "ui": true, + "requiredBundles": ["esUiShared"] } diff --git a/x-pack/plugins/security/kibana.json b/x-pack/plugins/security/kibana.json index 7d1940e393becf..0daab9d5dbce31 100644 --- a/x-pack/plugins/security/kibana.json +++ b/x-pack/plugins/security/kibana.json @@ -6,5 +6,13 @@ "requiredPlugins": ["data", "features", "licensing"], "optionalPlugins": ["home", "management"], "server": true, - "ui": true + "ui": true, + "requiredBundles": [ + "home", + "management", + "kibanaReact", + "spaces", + "esUiShared", + "management" + ] } diff --git a/x-pack/plugins/security_solution/kibana.json b/x-pack/plugins/security_solution/kibana.json index f6f2d5171312cc..40d3402378895c 100644 --- a/x-pack/plugins/security_solution/kibana.json +++ b/x-pack/plugins/security_solution/kibana.json @@ -29,5 +29,10 @@ "lists" ], "server": true, - "ui": true + "ui": true, + "requiredBundles": [ + "kibanaUtils", + "esUiShared", + "kibanaReact" + ] } diff --git a/x-pack/plugins/security_solution/public/cases/components/configure_cases/index.test.tsx b/x-pack/plugins/security_solution/public/cases/components/configure_cases/index.test.tsx index 91a5aa5c88beb8..7974116f4dc43e 100644 --- a/x-pack/plugins/security_solution/public/cases/components/configure_cases/index.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/configure_cases/index.test.tsx @@ -166,6 +166,9 @@ describe('ConfigureCases', () => { expect.objectContaining({ id: '.jira', }), + expect.objectContaining({ + id: '.resilient', + }), ]); expect(wrapper.find(ConnectorEditFlyout).prop('editFlyoutVisible')).toBe(false); diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/add_exception_modal/index.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/add_exception_modal/index.tsx index be89aa8e33718a..10d510c5f56c3f 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/add_exception_modal/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/add_exception_modal/index.tsx @@ -43,6 +43,7 @@ import { defaultEndpointExceptionItems, entryHasListType, entryHasNonEcsType, + getMappedNonEcsValue, } from '../helpers'; import { useFetchIndexPatterns } from '../../../../detections/containers/detection_engine/rules'; @@ -65,7 +66,7 @@ interface AddExceptionModalProps { nonEcsData: TimelineNonEcsData[]; }; onCancel: () => void; - onConfirm: () => void; + onConfirm: (didCloseAlert: boolean) => void; } const Modal = styled(EuiModal)` @@ -130,8 +131,8 @@ export const AddExceptionModal = memo(function AddExceptionModal({ ); const onSuccess = useCallback(() => { displaySuccessToast(i18n.ADD_EXCEPTION_SUCCESS, dispatchToaster); - onConfirm(); - }, [dispatchToaster, onConfirm]); + onConfirm(shouldCloseAlert); + }, [dispatchToaster, onConfirm, shouldCloseAlert]); const [{ isLoading: addExceptionIsLoading }, addOrUpdateExceptionItems] = useAddOrUpdateException( { @@ -193,6 +194,12 @@ export const AddExceptionModal = memo(function AddExceptionModal({ indexPatterns, ]); + useEffect(() => { + if (shouldDisableBulkClose === true) { + setShouldBulkCloseAlert(false); + } + }, [shouldDisableBulkClose]); + const onCommentChange = useCallback( (value: string) => { setComment(value); @@ -214,6 +221,21 @@ export const AddExceptionModal = memo(function AddExceptionModal({ [setShouldBulkCloseAlert] ); + const retrieveAlertOsTypes = useCallback(() => { + const osDefaults = ['windows', 'macos', 'linux']; + if (alertData) { + const osTypes = getMappedNonEcsValue({ + data: alertData.nonEcsData, + fieldName: 'host.os.family', + }); + if (osTypes.length === 0) { + return osDefaults; + } + return osTypes; + } + return osDefaults; + }, [alertData]); + const enrichExceptionItems = useCallback(() => { let enriched: Array = []; enriched = @@ -221,11 +243,11 @@ export const AddExceptionModal = memo(function AddExceptionModal({ ? enrichExceptionItemsWithComments(exceptionItemsToAdd, [{ comment }]) : exceptionItemsToAdd; if (exceptionListType === 'endpoint') { - const osTypes = alertData ? ['windows'] : ['windows', 'macos', 'linux']; + const osTypes = retrieveAlertOsTypes(); enriched = enrichExceptionItemsWithOS(enriched, osTypes); } return enriched; - }, [comment, exceptionItemsToAdd, exceptionListType, alertData]); + }, [comment, exceptionItemsToAdd, exceptionListType, retrieveAlertOsTypes]); const onAddExceptionConfirm = useCallback(() => { if (addOrUpdateExceptionItems !== null) { diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/edit_exception_modal/index.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/edit_exception_modal/index.tsx index aa36b65e04b694..0ef3350fbefc76 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/edit_exception_modal/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/edit_exception_modal/index.tsx @@ -37,7 +37,7 @@ import { AddExceptionComments } from '../add_exception_comments'; import { enrichExceptionItemsWithComments, enrichExceptionItemsWithOS, - getOsTagValues, + getOperatingSystems, entryHasListType, entryHasNonEcsType, } from '../helpers'; @@ -135,6 +135,12 @@ export const EditExceptionModal = memo(function EditExceptionModal({ indexPatterns, ]); + useEffect(() => { + if (shouldDisableBulkClose === true) { + setShouldBulkCloseAlert(false); + } + }, [shouldDisableBulkClose]); + const handleBuilderOnChange = useCallback( ({ exceptionItems, @@ -167,7 +173,7 @@ export const EditExceptionModal = memo(function EditExceptionModal({ ...(comment !== '' ? [{ comment }] : []), ]); if (exceptionListType === 'endpoint') { - const osTypes = exceptionItem._tags ? getOsTagValues(exceptionItem._tags) : ['windows']; + const osTypes = exceptionItem._tags ? getOperatingSystems(exceptionItem._tags) : []; enriched = enrichExceptionItemsWithOS(enriched, osTypes); } return enriched; @@ -199,6 +205,8 @@ export const EditExceptionModal = memo(function EditExceptionModal({ {!isSignalIndexLoading && ( <> + {i18n.EXCEPTION_BUILDER_INFO} + { beforeEach(() => { @@ -248,6 +258,36 @@ describe('Exception helpers', () => { }); }); + describe('#getEntryValue', () => { + it('returns "match" entry value', () => { + const payload = getEntryMatchMock(); + const result = getEntryValue(payload); + const expected = 'some host name'; + expect(result).toEqual(expected); + }); + + it('returns "match any" entry values', () => { + const payload = getEntryMatchAnyMock(); + const result = getEntryValue(payload); + const expected = ['some host name']; + expect(result).toEqual(expected); + }); + + it('returns "exists" entry value', () => { + const payload = getEntryExistsMock(); + const result = getEntryValue(payload); + const expected = undefined; + expect(result).toEqual(expected); + }); + + it('returns "list" entry value', () => { + const payload = getEntryListMock(); + const result = getEntryValue(payload); + const expected = 'some-list-id'; + expect(result).toEqual(expected); + }); + }); + describe('#formatEntry', () => { test('it formats an entry', () => { const payload = getEntryMatchMock(); @@ -280,25 +320,55 @@ describe('Exception helpers', () => { test('it returns null if no operating system tag specified', () => { const result = getOperatingSystems(['some tag', 'some other tag']); - expect(result).toEqual(''); + expect(result).toEqual([]); }); test('it returns null if operating system tag malformed', () => { const result = getOperatingSystems(['some tag', 'jibberos:mac,windows', 'some other tag']); + expect(result).toEqual([]); + }); + + test('it returns operating systems if space included in os tag', () => { + const result = getOperatingSystems(['some tag', 'os: macos', 'some other tag']); + expect(result).toEqual(['macos']); + }); + + test('it returns operating systems if multiple os tags specified', () => { + const result = getOperatingSystems(['some tag', 'os: macos', 'some other tag', 'os:windows']); + expect(result).toEqual(['macos', 'windows']); + }); + }); + + describe('#formatOperatingSystems', () => { + test('it returns null if no operating system tag specified', () => { + const result = formatOperatingSystems(getOperatingSystems(['some tag', 'some other tag'])); + + expect(result).toEqual(''); + }); + + test('it returns null if operating system tag malformed', () => { + const result = formatOperatingSystems( + getOperatingSystems(['some tag', 'jibberos:mac,windows', 'some other tag']) + ); + expect(result).toEqual(''); }); test('it returns formatted operating systems if space included in os tag', () => { - const result = getOperatingSystems(['some tag', 'os: mac', 'some other tag']); + const result = formatOperatingSystems( + getOperatingSystems(['some tag', 'os: macos', 'some other tag']) + ); - expect(result).toEqual('Mac'); + expect(result).toEqual('macOS'); }); test('it returns formatted operating systems if multiple os tags specified', () => { - const result = getOperatingSystems(['some tag', 'os: mac', 'some other tag', 'os:windows']); + const result = formatOperatingSystems( + getOperatingSystems(['some tag', 'os: macos', 'some other tag', 'os:windows']) + ); - expect(result).toEqual('Mac, Windows'); + expect(result).toEqual('macOS, Windows'); }); }); @@ -441,4 +511,176 @@ describe('Exception helpers', () => { expect(exceptions).toEqual([{ ...rest, meta: undefined }]); }); }); + + describe('#formatExceptionItemForUpdate', () => { + test('it should return correct update fields', () => { + const payload = getExceptionListItemSchemaMock(); + const result = formatExceptionItemForUpdate(payload); + const expected = { + _tags: ['endpoint', 'process', 'malware', 'os:linux'], + comments: [], + description: 'This is a sample endpoint type exception', + entries: ENTRIES, + id: '1', + item_id: 'endpoint_list_item', + meta: {}, + name: 'Sample Endpoint Exception List', + namespace_type: 'single', + tags: ['user added string for a tag', 'malware'], + type: 'simple', + }; + expect(result).toEqual(expected); + }); + }); + + describe('#enrichExceptionItemsWithComments', () => { + test('it should add comments to an exception item', () => { + const payload = [getExceptionListItemSchemaMock()]; + const comments = getCommentsArrayMock(); + const result = enrichExceptionItemsWithComments(payload, comments); + const expected = [ + { + ...getExceptionListItemSchemaMock(), + comments: getCommentsArrayMock(), + }, + ]; + expect(result).toEqual(expected); + }); + + test('it should add comments to multiple exception items', () => { + const payload = [getExceptionListItemSchemaMock(), getExceptionListItemSchemaMock()]; + const comments = getCommentsArrayMock(); + const result = enrichExceptionItemsWithComments(payload, comments); + const expected = [ + { + ...getExceptionListItemSchemaMock(), + comments: getCommentsArrayMock(), + }, + { + ...getExceptionListItemSchemaMock(), + comments: getCommentsArrayMock(), + }, + ]; + expect(result).toEqual(expected); + }); + }); + + describe('#enrichExceptionItemsWithOS', () => { + test('it should add an os tag to an exception item', () => { + const payload = [getExceptionListItemSchemaMock()]; + const osTypes = ['windows']; + const result = enrichExceptionItemsWithOS(payload, osTypes); + const expected = [ + { + ...getExceptionListItemSchemaMock(), + _tags: [...getExceptionListItemSchemaMock()._tags, 'os:windows'], + }, + ]; + expect(result).toEqual(expected); + }); + + test('it should add multiple os tags to all exception items', () => { + const payload = [getExceptionListItemSchemaMock(), getExceptionListItemSchemaMock()]; + const osTypes = ['windows', 'macos']; + const result = enrichExceptionItemsWithOS(payload, osTypes); + const expected = [ + { + ...getExceptionListItemSchemaMock(), + _tags: [...getExceptionListItemSchemaMock()._tags, 'os:windows', 'os:macos'], + }, + { + ...getExceptionListItemSchemaMock(), + _tags: [...getExceptionListItemSchemaMock()._tags, 'os:windows', 'os:macos'], + }, + ]; + expect(result).toEqual(expected); + }); + + test('it should add os tag to all exception items without duplication', () => { + const payload = [ + { ...getExceptionListItemSchemaMock(), _tags: ['os:linux', 'os:windows'] }, + { ...getExceptionListItemSchemaMock(), _tags: ['os:linux'] }, + ]; + const osTypes = ['windows']; + const result = enrichExceptionItemsWithOS(payload, osTypes); + const expected = [ + { + ...getExceptionListItemSchemaMock(), + _tags: ['os:linux', 'os:windows'], + }, + { + ...getExceptionListItemSchemaMock(), + _tags: ['os:linux', 'os:windows'], + }, + ]; + expect(result).toEqual(expected); + }); + }); + + describe('#entryHasListType', () => { + test('it should return false with an empty array', () => { + const payload: ExceptionListItemSchema[] = []; + const result = entryHasListType(payload); + expect(result).toEqual(false); + }); + + test("it should return false with exception items that don't contain a list type", () => { + const payload = [getExceptionListItemSchemaMock(), getExceptionListItemSchemaMock()]; + const result = entryHasListType(payload); + expect(result).toEqual(false); + }); + + test('it should return true with exception items that do contain a list type', () => { + const payload = [ + { + ...getExceptionListItemSchemaMock(), + entries: [{ type: OperatorTypeEnum.LIST }] as EntriesArray, + }, + getExceptionListItemSchemaMock(), + ]; + const result = entryHasListType(payload); + expect(result).toEqual(true); + }); + }); + + describe('#entryHasNonEcsType', () => { + const mockEcsIndexPattern = { + title: 'testIndex', + fields: [ + { + name: 'some.parentField', + }, + { + name: 'some.not.nested.field', + }, + { + name: 'nested.field', + }, + ], + } as IIndexPattern; + + test('it should return false with an empty array', () => { + const payload: ExceptionListItemSchema[] = []; + const result = entryHasNonEcsType(payload, mockEcsIndexPattern); + expect(result).toEqual(false); + }); + + test("it should return false with exception items that don't contain a non ecs type", () => { + const payload = [getExceptionListItemSchemaMock(), getExceptionListItemSchemaMock()]; + const result = entryHasNonEcsType(payload, mockEcsIndexPattern); + expect(result).toEqual(false); + }); + + test('it should return true with exception items that do contain a non ecs type', () => { + const payload = [ + { + ...getExceptionListItemSchemaMock(), + entries: [{ field: 'some.nonEcsField' }] as EntriesArray, + }, + getExceptionListItemSchemaMock(), + ]; + const result = entryHasNonEcsType(payload, mockEcsIndexPattern); + expect(result).toEqual(true); + }); + }); }); diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/helpers.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/helpers.tsx index db7cb5aeac8f09..481b2736b75975 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/helpers.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/helpers.tsx @@ -14,7 +14,6 @@ import * as i18n from './translations'; import { FormattedEntry, BuilderEntry, - EmptyListEntry, DescriptionListItem, FormattedBuilderEntry, CreateExceptionListItemBuilderSchema, @@ -39,9 +38,6 @@ import { ExceptionListType, } from '../../../lists_plugin_deps'; import { IFieldType, IIndexPattern } from '../../../../../../../src/plugins/data/common'; - -export const isListType = (item: BuilderEntry): item is EmptyListEntry => - item.type === OperatorTypeEnum.LIST; import { TimelineNonEcsData } from '../../../graphql/types'; import { WithCopyToClipboard } from '../../lib/clipboard/with_copy_to_clipboard'; @@ -82,11 +78,6 @@ export const getExceptionOperatorSelect = (item: BuilderEntry): OperatorOption = } }; -export const getExceptionOperatorFromSelect = (value: string): OperatorOption => { - const operator = EXCEPTION_OPERATORS.filter(({ message }) => message === value); - return operator[0] ?? isOperator; -}; - /** * Formats ExceptionItem entries into simple field, operator, value * for use in rendering items in table @@ -158,19 +149,32 @@ export const formatEntry = ({ }; }; -export const getOperatingSystems = (tags: string[]): string => { - const osMatches = tags - .filter((tag) => tag.startsWith('os:')) - .map((os) => capitalize(os.substring(3).trim())) - .join(', '); - - return osMatches; +/** + * Retrieves the values of tags marked as os + * + * @param tags an ExceptionItem's tags + */ +export const getOperatingSystems = (tags: string[]): string[] => { + return tags.filter((tag) => tag.startsWith('os:')).map((os) => os.substring(3).trim()); }; -export const getOsTagValues = (tags: string[]): string[] => { - return tags.filter((tag) => tag.startsWith('os:')).map((os) => os.substring(3).trim()); +/** + * Formats os value array to a displayable string + */ +export const formatOperatingSystems = (osTypes: string[]): string => { + return osTypes + .map((os) => { + if (os === 'macos') { + return 'macOS'; + } + return capitalize(os); + }) + .join(', '); }; +/** + * Returns all tags that match a given regex + */ export const getTagsInclude = ({ tags, regex, @@ -194,7 +198,7 @@ export const getDescriptionListContent = ( const details = [ { title: i18n.OPERATING_SYSTEM, - value: getOperatingSystems(exceptionItem._tags), + value: formatOperatingSystems(getOperatingSystems(exceptionItem._tags ?? [])), }, { title: i18n.DATE_CREATED, @@ -376,6 +380,11 @@ export const formatExceptionItemForUpdate = ( }; }; +/** + * Adds new and existing comments to all new exceptionItems if not present already + * @param exceptionItems new or existing ExceptionItem[] + * @param comments new Comments + */ export const enrichExceptionItemsWithComments = ( exceptionItems: Array, comments: Array @@ -388,6 +397,11 @@ export const enrichExceptionItemsWithComments = ( }); }; +/** + * Adds provided osTypes to all exceptionItems if not present already + * @param exceptionItems new or existing ExceptionItem[] + * @param osTypes array of os values + */ export const enrichExceptionItemsWithOS = ( exceptionItems: Array, osTypes: string[] @@ -402,18 +416,21 @@ export const enrichExceptionItemsWithOS = ( }); }; +/** + * Returns the value for the given fieldname within TimelineNonEcsData if it exists + */ export const getMappedNonEcsValue = ({ data, fieldName, }: { data: TimelineNonEcsData[]; fieldName: string; -}): string[] | undefined => { +}): string[] => { const item = data.find((d) => d.field === fieldName); if (item != null && item.value != null) { return item.value; } - return undefined; + return []; }; export const entryHasListType = ( @@ -421,7 +438,7 @@ export const entryHasListType = ( ) => { for (const { entries } of exceptionItems) { for (const exceptionEntry of entries ?? []) { - if (getOperatorType(exceptionEntry) === 'list') { + if (getOperatorType(exceptionEntry) === OperatorTypeEnum.LIST) { return true; } } @@ -429,16 +446,29 @@ export const entryHasListType = ( return false; }; +/** + * Determines whether or not any entries within the given exceptionItems contain values not in the specified ECS mapping + */ export const entryHasNonEcsType = ( exceptionItems: Array, indexPatterns: IIndexPattern ): boolean => { + const doesFieldNameExist = (exceptionEntry: Entry): boolean => { + return indexPatterns.fields.some(({ name }) => name === exceptionEntry.field); + }; + if (exceptionItems.length === 0) { return false; } for (const { entries } of exceptionItems) { for (const exceptionEntry of entries ?? []) { - if (indexPatterns.fields.find(({ name }) => name === exceptionEntry.field) === undefined) { + if (exceptionEntry.type === 'nested') { + for (const nestedExceptionEntry of exceptionEntry.entries) { + if (doesFieldNameExist(nestedExceptionEntry) === false) { + return true; + } + } + } else if (doesFieldNameExist(exceptionEntry) === false) { return true; } } @@ -446,19 +476,25 @@ export const entryHasNonEcsType = ( return false; }; +/** + * Returns the default values from the alert data to autofill new endpoint exceptions + */ export const defaultEndpointExceptionItems = ( listType: ExceptionListType, listId: string, ruleName: string, alertData: TimelineNonEcsData[] ): ExceptionsBuilderExceptionItem[] => { - const [filePath] = getMappedNonEcsValue({ data: alertData, fieldName: 'file.path' }) ?? []; - const [signatureSigner] = - getMappedNonEcsValue({ data: alertData, fieldName: 'file.Ext.code_signature.subject_name' }) ?? - []; - const [signatureTrusted] = - getMappedNonEcsValue({ data: alertData, fieldName: 'file.Ext.code_signature.trusted' }) ?? []; - const [sha1Hash] = getMappedNonEcsValue({ data: alertData, fieldName: 'file.hash.sha1' }) ?? []; + const [filePath] = getMappedNonEcsValue({ data: alertData, fieldName: 'file.path' }); + const [signatureSigner] = getMappedNonEcsValue({ + data: alertData, + fieldName: 'file.Ext.code_signature.subject_name', + }); + const [signatureTrusted] = getMappedNonEcsValue({ + data: alertData, + fieldName: 'file.Ext.code_signature.trusted', + }); + const [sha1Hash] = getMappedNonEcsValue({ data: alertData, fieldName: 'file.hash.sha1' }); const namespaceType = 'agnostic'; return [ @@ -483,7 +519,7 @@ export const defaultEndpointExceptionItems = ( value: signatureSigner ?? '', }, { - field: 'file.code_signature.trusted', + field: 'file.Ext.code_signature.trusted', operator: 'included', type: 'match', value: signatureTrusted ?? '', @@ -508,7 +544,7 @@ export const defaultEndpointExceptionItems = ( field: 'event.category', operator: 'included', type: 'match_any', - value: getMappedNonEcsValue({ data: alertData, fieldName: 'event.category' }) ?? [], + value: getMappedNonEcsValue({ data: alertData, fieldName: 'event.category' }), }, ], }, diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/index.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/index.tsx index cdfa358a0f9c2b..3d9fe2ebaddae5 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/index.tsx @@ -184,7 +184,11 @@ const ExceptionsViewerComponent = ({ [setCurrentModal] ); - const handleCloseExceptionModal = useCallback((): void => { + const handleOnCancelExceptionModal = useCallback((): void => { + setCurrentModal(null); + }, [setCurrentModal]); + + const handleOnConfirmExceptionModal = useCallback((): void => { setCurrentModal(null); handleFetchList(); }, [setCurrentModal, handleFetchList]); @@ -255,8 +259,8 @@ const ExceptionsViewerComponent = ({ ruleName={ruleName} exceptionListType={exceptionListTypeToEdit} exceptionItem={exceptionToEdit} - onCancel={handleCloseExceptionModal} - onConfirm={handleCloseExceptionModal} + onCancel={handleOnCancelExceptionModal} + onConfirm={handleOnConfirmExceptionModal} /> )} @@ -265,8 +269,8 @@ const ExceptionsViewerComponent = ({ ruleName={ruleName} ruleId={ruleId} exceptionListType={exceptionListTypeToEdit} - onCancel={handleCloseExceptionModal} - onConfirm={handleCloseExceptionModal} + onCancel={handleOnCancelExceptionModal} + onConfirm={handleOnConfirmExceptionModal} /> )} diff --git a/x-pack/plugins/security_solution/public/common/lib/connectors/config.ts b/x-pack/plugins/security_solution/public/common/lib/connectors/config.ts index 0b19e4177f5c27..833f85712b5faa 100644 --- a/x-pack/plugins/security_solution/public/common/lib/connectors/config.ts +++ b/x-pack/plugins/security_solution/public/common/lib/connectors/config.ts @@ -7,9 +7,11 @@ // eslint-disable-next-line @kbn/eslint/no-restricted-paths import { ServiceNowConnectorConfiguration } from '../../../../../triggers_actions_ui/public/common'; import { connector as jiraConnectorConfig } from './jira/config'; +import { connector as resilientConnectorConfig } from './resilient/config'; import { ConnectorConfiguration } from './types'; export const connectorsConfiguration: Record = { '.servicenow': ServiceNowConnectorConfiguration as ConnectorConfiguration, '.jira': jiraConnectorConfig, + '.resilient': resilientConnectorConfig, }; diff --git a/x-pack/plugins/security_solution/public/common/lib/connectors/index.ts b/x-pack/plugins/security_solution/public/common/lib/connectors/index.ts index 83b07a2905ef0f..f32e1e0df184e7 100644 --- a/x-pack/plugins/security_solution/public/common/lib/connectors/index.ts +++ b/x-pack/plugins/security_solution/public/common/lib/connectors/index.ts @@ -5,3 +5,4 @@ */ export { getActionType as jiraActionType } from './jira'; +export { getActionType as resilientActionType } from './resilient'; diff --git a/x-pack/plugins/security_solution/public/common/lib/connectors/resilient/config.ts b/x-pack/plugins/security_solution/public/common/lib/connectors/resilient/config.ts new file mode 100644 index 00000000000000..7d4edbf624877a --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/lib/connectors/resilient/config.ts @@ -0,0 +1,40 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { ConnectorConfiguration } from './types'; + +import * as i18n from './translations'; +import logo from './logo.svg'; + +export const connector: ConnectorConfiguration = { + id: '.resilient', + name: i18n.RESILIENT_TITLE, + logo, + enabled: true, + enabledInConfig: true, + enabledInLicense: true, + minimumLicenseRequired: 'platinum', + fields: { + name: { + label: i18n.MAPPING_FIELD_NAME, + validSourceFields: ['title', 'description'], + defaultSourceField: 'title', + defaultActionType: 'overwrite', + }, + description: { + label: i18n.MAPPING_FIELD_DESC, + validSourceFields: ['title', 'description'], + defaultSourceField: 'description', + defaultActionType: 'overwrite', + }, + comments: { + label: i18n.MAPPING_FIELD_COMMENTS, + validSourceFields: ['comments'], + defaultSourceField: 'comments', + defaultActionType: 'append', + }, + }, +}; diff --git a/x-pack/plugins/security_solution/public/common/lib/connectors/resilient/flyout.tsx b/x-pack/plugins/security_solution/public/common/lib/connectors/resilient/flyout.tsx new file mode 100644 index 00000000000000..31bf0a4dfc34b3 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/lib/connectors/resilient/flyout.tsx @@ -0,0 +1,114 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React from 'react'; +import { + EuiFieldText, + EuiFlexGroup, + EuiFlexItem, + EuiFormRow, + EuiFieldPassword, + EuiSpacer, +} from '@elastic/eui'; + +import * as i18n from './translations'; +import { ConnectorFlyoutFormProps } from '../types'; +import { ResilientActionConnector } from './types'; +import { withConnectorFlyout } from '../components/connector_flyout'; + +const resilientConnectorForm: React.FC> = ({ + errors, + action, + onChangeSecret, + onBlurSecret, + onChangeConfig, + onBlurConfig, +}) => { + const { orgId } = action.config; + const { apiKeyId, apiKeySecret } = action.secrets; + const isOrgIdInvalid: boolean = errors.orgId.length > 0 && orgId != null; + const isApiKeyIdInvalid: boolean = errors.apiKeyId.length > 0 && apiKeyId != null; + const isApiKeySecretInvalid: boolean = errors.apiKeySecret.length > 0 && apiKeySecret != null; + + return ( + <> + + + + onChangeConfig('orgId', evt.target.value)} + onBlur={() => onBlurConfig('orgId')} + /> + + + + + + + + onChangeSecret('apiKeyId', evt.target.value)} + onBlur={() => onBlurSecret('apiKeyId')} + /> + + + + + + + + onChangeSecret('apiKeySecret', evt.target.value)} + onBlur={() => onBlurSecret('apiKeySecret')} + /> + + + + + ); +}; + +export const resilientConnectorFlyout = withConnectorFlyout({ + ConnectorFormComponent: resilientConnectorForm, + secretKeys: ['apiKeyId', 'apiKeySecret'], + configKeys: ['orgId'], + connectorActionTypeId: '.resilient', +}); + +// eslint-disable-next-line import/no-default-export +export { resilientConnectorFlyout as default }; diff --git a/x-pack/plugins/security_solution/public/common/lib/connectors/resilient/index.tsx b/x-pack/plugins/security_solution/public/common/lib/connectors/resilient/index.tsx new file mode 100644 index 00000000000000..d3daf195582a8e --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/lib/connectors/resilient/index.tsx @@ -0,0 +1,54 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { lazy } from 'react'; +import { + ValidationResult, + // eslint-disable-next-line @kbn/eslint/no-restricted-paths +} from '../../../../../../triggers_actions_ui/public/types'; + +import { connector } from './config'; +import { createActionType } from '../utils'; +import logo from './logo.svg'; +import { ResilientActionConnector } from './types'; +import * as i18n from './translations'; + +interface Errors { + orgId: string[]; + apiKeyId: string[]; + apiKeySecret: string[]; +} + +const validateConnector = (action: ResilientActionConnector): ValidationResult => { + const errors: Errors = { + orgId: [], + apiKeyId: [], + apiKeySecret: [], + }; + + if (!action.config.orgId) { + errors.orgId = [...errors.orgId, i18n.RESILIENT_PROJECT_KEY_LABEL]; + } + + if (!action.secrets.apiKeyId) { + errors.apiKeyId = [...errors.apiKeyId, i18n.RESILIENT_API_KEY_ID_REQUIRED]; + } + + if (!action.secrets.apiKeySecret) { + errors.apiKeySecret = [...errors.apiKeySecret, i18n.RESILIENT_API_KEY_SECRET_REQUIRED]; + } + + return { errors }; +}; + +export const getActionType = createActionType({ + id: connector.id, + iconClass: logo, + selectMessage: i18n.RESILIENT_DESC, + actionTypeTitle: connector.name, + validateConnector, + actionConnectorFields: lazy(() => import('./flyout')), +}); diff --git a/x-pack/plugins/security_solution/public/common/lib/connectors/resilient/logo.svg b/x-pack/plugins/security_solution/public/common/lib/connectors/resilient/logo.svg new file mode 100644 index 00000000000000..553c2c62b7191c --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/lib/connectors/resilient/logo.svg @@ -0,0 +1,3 @@ + + + diff --git a/x-pack/plugins/security_solution/public/common/lib/connectors/resilient/translations.ts b/x-pack/plugins/security_solution/public/common/lib/connectors/resilient/translations.ts new file mode 100644 index 00000000000000..f8aec2eea3d4b4 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/lib/connectors/resilient/translations.ts @@ -0,0 +1,72 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; + +export * from '../translations'; + +export const RESILIENT_DESC = i18n.translate( + 'xpack.securitySolution.case.connectors.resilient.selectMessageText', + { + defaultMessage: 'Push or update SIEM case data to a new issue in resilient', + } +); + +export const RESILIENT_TITLE = i18n.translate( + 'xpack.securitySolution.case.connectors.resilient.actionTypeTitle', + { + defaultMessage: 'IBM Resilient', + } +); + +export const RESILIENT_PROJECT_KEY_LABEL = i18n.translate( + 'xpack.securitySolution.case.connectors.resilient.orgId', + { + defaultMessage: 'Organization Id', + } +); + +export const RESILIENT_PROJECT_KEY_REQUIRED = i18n.translate( + 'xpack.securitySolution.case.connectors.resilient.requiredOrgIdTextField', + { + defaultMessage: 'Organization Id', + } +); + +export const RESILIENT_API_KEY_ID_LABEL = i18n.translate( + 'xpack.securitySolution.case.connectors.resilient.apiKeyId', + { + defaultMessage: 'API key id', + } +); + +export const RESILIENT_API_KEY_ID_REQUIRED = i18n.translate( + 'xpack.securitySolution.case.connectors.resilient.requiredApiKeyIdTextField', + { + defaultMessage: 'API key id is required', + } +); + +export const RESILIENT_API_KEY_SECRET_LABEL = i18n.translate( + 'xpack.securitySolution.case.connectors.resilient.apiKeySecret', + { + defaultMessage: 'API key secret', + } +); + +export const RESILIENT_API_KEY_SECRET_REQUIRED = i18n.translate( + 'xpack.securitySolution.case.connectors.resilient.requiredApiKeySecretTextField', + { + defaultMessage: 'API key secret is required', + } +); + +export const MAPPING_FIELD_NAME = i18n.translate( + 'xpack.securitySolution.case.configureCases.mappingFieldName', + { + defaultMessage: 'Name', + } +); diff --git a/x-pack/plugins/security_solution/public/common/lib/connectors/resilient/types.ts b/x-pack/plugins/security_solution/public/common/lib/connectors/resilient/types.ts new file mode 100644 index 00000000000000..fe6dbb2b3674ab --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/lib/connectors/resilient/types.ts @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +/* eslint-disable no-restricted-imports */ +/* eslint-disable @kbn/eslint/no-restricted-paths */ + +import { + ResilientPublicConfigurationType, + ResilientSecretConfigurationType, +} from '../../../../../../actions/server/builtin_action_types/resilient/types'; + +export { ResilientFieldsType } from '../../../../../../case/common/api/connectors'; + +export * from '../types'; + +export interface ResilientActionConnector { + config: ResilientPublicConfigurationType; + secrets: ResilientSecretConfigurationType; +} diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/default_config.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/default_config.tsx index 697dff40129823..e95ea4531d9ad7 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_table/default_config.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/default_config.tsx @@ -36,7 +36,7 @@ import { SetEventsLoadingProps, UpdateTimelineLoading, } from './types'; -import { Ecs } from '../../../graphql/types'; +import { Ecs, TimelineNonEcsData } from '../../../graphql/types'; import { AddExceptionOnClick } from '../../../common/components/exceptions/add_exception_modal'; import { getMappedNonEcsValue } from '../../../common/components/exceptions/helpers'; @@ -174,6 +174,8 @@ export const requiredFieldsForActions = [ 'signal.rule.query', 'signal.rule.to', 'signal.rule.id', + 'signal.original_event.kind', + 'signal.original_event.module', // Endpoint exception fields 'file.path', @@ -189,6 +191,7 @@ interface AlertActionArgs { createTimeline: CreateTimeline; dispatch: Dispatch; ecsRowData: Ecs; + nonEcsRowData: TimelineNonEcsData[]; hasIndexWrite: boolean; onAlertStatusUpdateFailure: (status: Status, error: Error) => void; onAlertStatusUpdateSuccess: (count: number, status: Status) => void; @@ -211,6 +214,7 @@ export const getAlertActions = ({ createTimeline, dispatch, ecsRowData, + nonEcsRowData, hasIndexWrite, onAlertStatusUpdateFailure, onAlertStatusUpdateSuccess, @@ -281,6 +285,18 @@ export const getAlertActions = ({ width: DEFAULT_ICON_BUTTON_WIDTH, }; + const isEndpointAlert = () => { + const [module] = getMappedNonEcsValue({ + data: nonEcsRowData, + fieldName: 'signal.original_event.module', + }); + const [kind] = getMappedNonEcsValue({ + data: nonEcsRowData, + fieldName: 'signal.original_event.kind', + }); + return module === 'endpoint' && kind === 'alert'; + }; + return [ { ...getInvestigateInResolverAction({ dispatch, timelineId }), @@ -305,15 +321,14 @@ export const getAlertActions = ({ ...(FILTER_OPEN !== status ? [openAlertActionComponent] : []), ...(FILTER_CLOSED !== status ? [closeAlertActionComponent] : []), ...(FILTER_IN_PROGRESS !== status ? [inProgressAlertActionComponent] : []), - // TODO: disable this option if the alert is not an Endpoint alert { onClick: ({ ecsData, data }: TimelineRowActionOnClick) => { - const ruleNameValue = getMappedNonEcsValue({ data, fieldName: 'signal.rule.name' }); - const ruleId = getMappedNonEcsValue({ data, fieldName: 'signal.rule.id' }); - if (ruleId !== undefined && ruleId.length > 0) { + const [ruleName] = getMappedNonEcsValue({ data, fieldName: 'signal.rule.name' }); + const [ruleId] = getMappedNonEcsValue({ data, fieldName: 'signal.rule.id' }); + if (ruleId !== undefined) { openAddExceptionModal({ - ruleName: ruleNameValue ? ruleNameValue[0] : '', - ruleId: ruleId[0], + ruleName: ruleName ?? '', + ruleId, exceptionListType: 'endpoint', alertData: { ecsData, @@ -323,7 +338,7 @@ export const getAlertActions = ({ } }, id: 'addEndpointException', - isActionDisabled: () => !canUserCRUD || !hasIndexWrite, + isActionDisabled: () => !canUserCRUD || !hasIndexWrite || !isEndpointAlert(), dataTestSubj: 'add-endpoint-exception-menu-item', ariaLabel: 'Add Endpoint Exception', content: {i18n.ACTION_ADD_ENDPOINT_EXCEPTION}, @@ -331,12 +346,12 @@ export const getAlertActions = ({ }, { onClick: ({ ecsData, data }: TimelineRowActionOnClick) => { - const ruleNameValue = getMappedNonEcsValue({ data, fieldName: 'signal.rule.name' }); - const ruleId = getMappedNonEcsValue({ data, fieldName: 'signal.rule.id' }); - if (ruleId !== undefined && ruleId.length > 0) { + const [ruleName] = getMappedNonEcsValue({ data, fieldName: 'signal.rule.name' }); + const [ruleId] = getMappedNonEcsValue({ data, fieldName: 'signal.rule.id' }); + if (ruleId !== undefined) { openAddExceptionModal({ - ruleName: ruleNameValue ? ruleNameValue[0] : '', - ruleId: ruleId[0], + ruleName: ruleName ?? '', + ruleId, exceptionListType: 'detection', alertData: { ecsData, diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/index.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/index.tsx index 81aebe95930ac9..b9b963a84e966f 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_table/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/index.tsx @@ -22,7 +22,10 @@ import { inputsSelectors, State, inputsModel } from '../../../common/store'; import { timelineActions, timelineSelectors } from '../../../timelines/store/timeline'; import { TimelineModel } from '../../../timelines/store/timeline/model'; import { timelineDefaults } from '../../../timelines/store/timeline/defaults'; -import { useManageTimeline } from '../../../timelines/components/manage_timeline'; +import { + useManageTimeline, + TimelineRowActionArgs, +} from '../../../timelines/components/manage_timeline'; import { useApolloClient } from '../../../common/utils/apollo_context'; import { updateAlertStatusAction } from './actions'; @@ -48,7 +51,6 @@ import { displaySuccessToast, displayErrorToast, } from '../../../common/components/toasters'; -import { Ecs } from '../../../graphql/types'; import { getInvestigateInResolverAction } from '../../../timelines/components/timeline/body/helpers'; import { AddExceptionModal, @@ -321,12 +323,13 @@ export const AlertsTableComponent: React.FC = ({ // Send to Timeline / Update Alert Status Actions for each table row const additionalActions = useMemo( - () => (ecsRowData: Ecs) => + () => ({ ecsData, nonEcsData }: TimelineRowActionArgs) => getAlertActions({ apolloClient, canUserCRUD, createTimeline: createTimelineCallback, - ecsRowData, + ecsRowData: ecsData, + nonEcsRowData: nonEcsData, dispatch, hasIndexWrite, onAlertStatusUpdateFailure, @@ -401,9 +404,12 @@ export const AlertsTableComponent: React.FC = ({ closeAddExceptionModal(); }, [closeAddExceptionModal]); - const onAddExceptionConfirm = useCallback(() => { - closeAddExceptionModal(); - }, [closeAddExceptionModal]); + const onAddExceptionConfirm = useCallback( + (didCloseAlert: boolean) => { + closeAddExceptionModal(); + }, + [closeAddExceptionModal] + ); if (loading || isEmpty(signalsIndex)) { return ( diff --git a/x-pack/plugins/security_solution/public/plugin.tsx b/x-pack/plugins/security_solution/public/plugin.tsx index 6096a9b0e0bb85..7bb4be6b508797 100644 --- a/x-pack/plugins/security_solution/public/plugin.tsx +++ b/x-pack/plugins/security_solution/public/plugin.tsx @@ -22,7 +22,7 @@ import { Storage } from '../../../../src/plugins/kibana_utils/public'; import { FeatureCatalogueCategory } from '../../../../src/plugins/home/public'; import { initTelemetry } from './common/lib/telemetry'; import { KibanaServices } from './common/lib/kibana/services'; -import { jiraActionType } from './common/lib/connectors'; +import { jiraActionType, resilientActionType } from './common/lib/connectors'; import { PluginSetup, PluginStart, @@ -84,6 +84,7 @@ export class Plugin implements IPlugin { const storage = new Storage(localStorage); diff --git a/x-pack/plugins/security_solution/public/timelines/components/manage_timeline/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/manage_timeline/index.tsx index 99d79a2441ccc0..7882185cbd9d65 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/manage_timeline/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/manage_timeline/index.tsx @@ -14,7 +14,7 @@ import { SubsetTimelineModel } from '../../store/timeline/model'; import * as i18n from '../../../common/components/events_viewer/translations'; import * as i18nF from '../timeline/footer/translations'; import { timelineDefaults as timelineDefaultModel } from '../../store/timeline/defaults'; -import { Ecs } from '../../../graphql/types'; +import { Ecs, TimelineNonEcsData } from '../../../graphql/types'; interface ManageTimelineInit { documentType?: string; @@ -25,11 +25,16 @@ interface ManageTimelineInit { indexToAdd?: string[] | null; loadingText?: string; selectAll?: boolean; - timelineRowActions: (ecsData: Ecs) => TimelineRowAction[]; + timelineRowActions: ({ ecsData, nonEcsData }: TimelineRowActionArgs) => TimelineRowAction[]; title?: string; unit?: (totalCount: number) => string; } +export interface TimelineRowActionArgs { + ecsData: Ecs; + nonEcsData: TimelineNonEcsData[]; +} + interface ManageTimeline { documentType: string; defaultModel: SubsetTimelineModel; @@ -41,7 +46,7 @@ interface ManageTimeline { loadingText: string; queryFields: string[]; selectAll: boolean; - timelineRowActions: (ecsData: Ecs) => TimelineRowAction[]; + timelineRowActions: ({ ecsData, nonEcsData }: TimelineRowActionArgs) => TimelineRowAction[]; title: string; unit: (totalCount: number) => string; } @@ -71,7 +76,7 @@ type ActionManageTimeline = id: string; payload: { queryFields?: string[]; - timelineRowActions: (ecsData: Ecs) => TimelineRowAction[]; + timelineRowActions: ({ ecsData, nonEcsData }: TimelineRowActionArgs) => TimelineRowAction[]; }; }; @@ -142,7 +147,7 @@ interface UseTimelineManager { setTimelineRowActions: (actionsArgs: { id: string; queryFields?: string[]; - timelineRowActions: (ecsData: Ecs) => TimelineRowAction[]; + timelineRowActions: ({ ecsData, nonEcsData }: TimelineRowActionArgs) => TimelineRowAction[]; }) => void; } @@ -167,7 +172,7 @@ const useTimelineManager = (manageTimelineForTesting?: ManageTimelineById): UseT }: { id: string; queryFields?: string[]; - timelineRowActions: (ecsData: Ecs) => TimelineRowAction[]; + timelineRowActions: ({ ecsData, nonEcsData }: TimelineRowActionArgs) => TimelineRowAction[]; }) => { dispatch({ type: 'SET_TIMELINE_ACTIONS', diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/event_column_view.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/event_column_view.tsx index a450d082cb85de..63de117aeaf3d5 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/event_column_view.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/event_column_view.tsx @@ -90,8 +90,8 @@ export const EventColumnView = React.memo( }) => { const { getManageTimelineById } = useManageTimeline(); const timelineActions = useMemo( - () => getManageTimelineById(timelineId).timelineRowActions(ecsData), - [ecsData, getManageTimelineById, timelineId] + () => getManageTimelineById(timelineId).timelineRowActions({ nonEcsData: data, ecsData }), + [data, ecsData, getManageTimelineById, timelineId] ); const [isPopoverOpen, setPopover] = useState(false); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/index.tsx index 8f8f19020697cf..b474e4047eadd1 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/index.tsx @@ -33,6 +33,7 @@ import { Sort } from './sort'; import { useManageTimeline } from '../../manage_timeline'; import { GraphOverlay } from '../../graph_overlay'; import { DEFAULT_ICON_BUTTON_WIDTH } from '../helpers'; +import { TimelineRowAction } from './actions'; export interface BodyProps { addNoteToEvent: AddNoteToEvent; @@ -104,7 +105,18 @@ export const Body = React.memo( const containerElementRef = useRef(null); const { getManageTimelineById } = useManageTimeline(); const timelineActions = useMemo( - () => (data.length > 0 ? getManageTimelineById(id).timelineRowActions(data[0].ecs) : []), + () => + data.reduce((acc: TimelineRowAction[], rowData) => { + const rowActions = getManageTimelineById(id).timelineRowActions({ + ecsData: rowData.ecs, + nonEcsData: rowData.data, + }); + return rowActions && + rowActions.filter((v) => v.displayType === 'icon').length > + acc.filter((v) => v.displayType === 'icon').length + ? rowActions + : acc; + }, []), [data, getManageTimelineById, id] ); diff --git a/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/lists.test.ts b/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/lists.test.ts index acde455f77cb4b..1a19306b2fd606 100644 --- a/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/lists.test.ts +++ b/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/lists.test.ts @@ -139,6 +139,139 @@ describe('buildEventTypeSignal', () => { }); }); + test('it should deduplicate exception entries', async () => { + const testEntries: EntriesArray = [ + { field: 'server.domain.text', operator: 'included', type: 'match', value: 'DOMAIN' }, + { field: 'server.domain.text', operator: 'included', type: 'match', value: 'DOMAIN' }, + { field: 'server.domain.text', operator: 'included', type: 'match', value: 'DOMAIN' }, + { field: 'server.ip', operator: 'included', type: 'match', value: '192.168.1.1' }, + { + field: 'host.hostname.text', + operator: 'included', + type: 'match_any', + value: ['estc', 'kibana'], + }, + ]; + + const expectedEndpointExceptions = { + type: 'simple', + entries: [ + { + field: 'server.domain', + operator: 'included', + type: 'exact_caseless', + value: 'DOMAIN', + }, + { + field: 'server.ip', + operator: 'included', + type: 'exact_cased', + value: '192.168.1.1', + }, + { + field: 'host.hostname', + operator: 'included', + type: 'exact_caseless_any', + value: ['estc', 'kibana'], + }, + ], + }; + + const first = getFoundExceptionListItemSchemaMock(); + first.data[0].entries = testEntries; + mockExceptionClient.findExceptionListItem = jest.fn().mockReturnValueOnce(first); + + const resp = await getFullEndpointExceptionList(mockExceptionClient, 'linux', 'v1'); + expect(resp).toEqual({ + entries: [expectedEndpointExceptions], + }); + }); + + test('it should not deduplicate exception entries across nested boundaries', async () => { + const testEntries: EntriesArray = [ + { + entries: [ + { field: 'nested.field', operator: 'included', type: 'match', value: 'some value' }, + ], + field: 'some.parentField', + type: 'nested', + }, + // Same as above but not inside the nest + { field: 'nested.field', operator: 'included', type: 'match', value: 'some value' }, + ]; + + const expectedEndpointExceptions = { + type: 'simple', + entries: [ + { + entries: [ + { + field: 'nested.field', + operator: 'included', + type: 'exact_cased', + value: 'some value', + }, + ], + field: 'some.parentField', + type: 'nested', + }, + { + field: 'nested.field', + operator: 'included', + type: 'exact_cased', + value: 'some value', + }, + ], + }; + + const first = getFoundExceptionListItemSchemaMock(); + first.data[0].entries = testEntries; + mockExceptionClient.findExceptionListItem = jest.fn().mockReturnValueOnce(first); + + const resp = await getFullEndpointExceptionList(mockExceptionClient, 'linux', 'v1'); + expect(resp).toEqual({ + entries: [expectedEndpointExceptions], + }); + }); + + test('it should deduplicate exception items', async () => { + const testEntries: EntriesArray = [ + { field: 'server.domain.text', operator: 'included', type: 'match', value: 'DOMAIN' }, + { field: 'server.ip', operator: 'included', type: 'match', value: '192.168.1.1' }, + ]; + + const expectedEndpointExceptions = { + type: 'simple', + entries: [ + { + field: 'server.domain', + operator: 'included', + type: 'exact_caseless', + value: 'DOMAIN', + }, + { + field: 'server.ip', + operator: 'included', + type: 'exact_cased', + value: '192.168.1.1', + }, + ], + }; + + const first = getFoundExceptionListItemSchemaMock(); + first.data[0].entries = testEntries; + + // Create a second exception item with the same entries + first.data[1] = getExceptionListItemSchemaMock(); + first.data[1].entries = testEntries; + mockExceptionClient.findExceptionListItem = jest.fn().mockReturnValueOnce(first); + + const resp = await getFullEndpointExceptionList(mockExceptionClient, 'linux', 'v1'); + expect(resp).toEqual({ + entries: [expectedEndpointExceptions], + }); + }); + test('it should ignore unsupported entries', async () => { // Lists and exists are not supported by the Endpoint const testEntries: EntriesArray = [ @@ -178,8 +311,9 @@ describe('buildEventTypeSignal', () => { }); test('it should convert the exception lists response to the proper endpoint format while paging', async () => { - // The first call returns one exception + // The first call returns two exceptions const first = getFoundExceptionListItemSchemaMock(); + first.data.push(getExceptionListItemSchemaMock()); // The second call returns two exceptions const second = getFoundExceptionListItemSchemaMock(); @@ -194,7 +328,8 @@ describe('buildEventTypeSignal', () => { .mockReturnValueOnce(second) .mockReturnValueOnce(third); const resp = await getFullEndpointExceptionList(mockExceptionClient, 'linux', 'v1'); - expect(resp.entries.length).toEqual(3); + // Expect 2 exceptions, the first two calls returned the same exception list items + expect(resp.entries.length).toEqual(2); }); test('it should handle no exceptions', async () => { diff --git a/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/lists.ts b/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/lists.ts index 556405adff62f9..b756c4e3d14c33 100644 --- a/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/lists.ts +++ b/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/lists.ts @@ -97,10 +97,18 @@ export function translateToEndpointExceptions( exc: FoundExceptionListItemSchema, schemaVersion: string ): TranslatedExceptionListItem[] { + const entrySet = new Set(); + const entriesFiltered: TranslatedExceptionListItem[] = []; if (schemaVersion === 'v1') { - return exc.data.map((item) => { - return translateItem(schemaVersion, item); + exc.data.forEach((entry) => { + const translatedItem = translateItem(schemaVersion, entry); + const entryHash = createHash('sha256').update(JSON.stringify(translatedItem)).digest('hex'); + if (!entrySet.has(entryHash)) { + entriesFiltered.push(translatedItem); + entrySet.add(entryHash); + } }); + return entriesFiltered; } else { throw new Error('unsupported schemaVersion'); } @@ -124,12 +132,17 @@ function translateItem( schemaVersion: string, item: ExceptionListItemSchema ): TranslatedExceptionListItem { + const itemSet = new Set(); return { type: item.type, entries: item.entries.reduce((translatedEntries: TranslatedEntry[], entry) => { const translatedEntry = translateEntry(schemaVersion, entry); if (translatedEntry !== undefined && translatedEntryType.is(translatedEntry)) { - translatedEntries.push(translatedEntry); + const itemHash = createHash('sha256').update(JSON.stringify(translatedEntry)).digest('hex'); + if (!itemSet.has(itemHash)) { + translatedEntries.push(translatedEntry); + itemSet.add(itemHash); + } } return translatedEntries; }, []), diff --git a/x-pack/plugins/snapshot_restore/kibana.json b/x-pack/plugins/snapshot_restore/kibana.json index df72102e520862..92f3e27d6d5b82 100644 --- a/x-pack/plugins/snapshot_restore/kibana.json +++ b/x-pack/plugins/snapshot_restore/kibana.json @@ -13,5 +13,9 @@ "security", "cloud" ], - "configPath": ["xpack", "snapshot_restore"] + "configPath": ["xpack", "snapshot_restore"], + "requiredBundles": [ + "esUiShared", + "kibanaReact" + ] } diff --git a/x-pack/plugins/spaces/kibana.json b/x-pack/plugins/spaces/kibana.json index 9483cb67392c43..0698535cc15fd7 100644 --- a/x-pack/plugins/spaces/kibana.json +++ b/x-pack/plugins/spaces/kibana.json @@ -14,5 +14,11 @@ ], "server": true, "ui": true, - "extraPublicDirs": ["common"] + "extraPublicDirs": ["common"], + "requiredBundles": [ + "kibanaReact", + "savedObjectsManagement", + "management", + "home" + ] } diff --git a/x-pack/plugins/transform/kibana.json b/x-pack/plugins/transform/kibana.json index 391a95853cc16c..d7e7a7fabba4fd 100644 --- a/x-pack/plugins/transform/kibana.json +++ b/x-pack/plugins/transform/kibana.json @@ -13,5 +13,13 @@ "security", "usageCollection" ], - "configPath": ["xpack", "transform"] + "configPath": ["xpack", "transform"], + "requiredBundles": [ + "ml", + "esUiShared", + "discover", + "kibanaUtils", + "kibanaReact", + "savedObjects" + ] } diff --git a/x-pack/plugins/triggers_actions_ui/kibana.json b/x-pack/plugins/triggers_actions_ui/kibana.json index 158cfa100d5463..03fef90e8ca7f4 100644 --- a/x-pack/plugins/triggers_actions_ui/kibana.json +++ b/x-pack/plugins/triggers_actions_ui/kibana.json @@ -6,5 +6,6 @@ "optionalPlugins": ["alerts", "alertingBuiltins"], "requiredPlugins": ["management", "charts", "data"], "configPath": ["xpack", "trigger_actions_ui"], - "extraPublicDirs": ["public/common", "public/common/constants"] + "extraPublicDirs": ["public/common", "public/common/constants"], + "requiredBundles": ["alerts", "esUiShared"] } diff --git a/x-pack/plugins/ui_actions_enhanced/kibana.json b/x-pack/plugins/ui_actions_enhanced/kibana.json index a813903d8b2124..108c66505f25c6 100644 --- a/x-pack/plugins/ui_actions_enhanced/kibana.json +++ b/x-pack/plugins/ui_actions_enhanced/kibana.json @@ -8,5 +8,10 @@ "licensing" ], "server": false, - "ui": true + "ui": true, + "requiredBundles": [ + "kibanaUtils", + "kibanaReact", + "data" + ] } diff --git a/x-pack/plugins/upgrade_assistant/kibana.json b/x-pack/plugins/upgrade_assistant/kibana.json index 273036a653aeb8..31109dd963ab45 100644 --- a/x-pack/plugins/upgrade_assistant/kibana.json +++ b/x-pack/plugins/upgrade_assistant/kibana.json @@ -5,5 +5,6 @@ "ui": true, "configPath": ["xpack", "upgrade_assistant"], "requiredPlugins": ["management", "licensing"], - "optionalPlugins": ["cloud", "usageCollection"] + "optionalPlugins": ["cloud", "usageCollection"], + "requiredBundles": ["management"] } diff --git a/x-pack/plugins/uptime/kibana.json b/x-pack/plugins/uptime/kibana.json index 152839836ad997..a057e546e4414a 100644 --- a/x-pack/plugins/uptime/kibana.json +++ b/x-pack/plugins/uptime/kibana.json @@ -13,5 +13,14 @@ ], "server": true, "ui": true, - "version": "8.0.0" + "version": "8.0.0", + "requiredBundles": [ + "observability", + "kibanaReact", + "home", + "data", + "ml", + "apm", + "maps" + ] } diff --git a/x-pack/plugins/watcher/kibana.json b/x-pack/plugins/watcher/kibana.json index a0482ad00797ce..ba6a9bfa5e1949 100644 --- a/x-pack/plugins/watcher/kibana.json +++ b/x-pack/plugins/watcher/kibana.json @@ -10,5 +10,9 @@ "data" ], "server": true, - "ui": true + "ui": true, + "requiredBundles": [ + "esUiShared", + "kibanaReact" + ] } diff --git a/x-pack/test/alerting_api_integration/common/config.ts b/x-pack/test/alerting_api_integration/common/config.ts index 0877fdc949dc41..e3281cfdfa9a3f 100644 --- a/x-pack/test/alerting_api_integration/common/config.ts +++ b/x-pack/test/alerting_api_integration/common/config.ts @@ -25,6 +25,7 @@ const enabledActionTypes = [ '.server-log', '.servicenow', '.jira', + '.resilient', '.slack', '.webhook', 'test.authorization', diff --git a/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions_simulators/server/plugin.ts b/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions_simulators/server/plugin.ts index f1ac3f91c68db6..b8b2cbdc03f39f 100644 --- a/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions_simulators/server/plugin.ts +++ b/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions_simulators/server/plugin.ts @@ -12,6 +12,7 @@ import { ActionType } from '../../../../../../../plugins/actions/server'; import { initPlugin as initPagerduty } from './pagerduty_simulation'; import { initPlugin as initServiceNow } from './servicenow_simulation'; import { initPlugin as initJira } from './jira_simulation'; +import { initPlugin as initResilient } from './resilient_simulation'; export const NAME = 'actions-FTS-external-service-simulators'; @@ -20,6 +21,7 @@ export enum ExternalServiceSimulator { SERVICENOW = 'servicenow', SLACK = 'slack', JIRA = 'jira', + RESILIENT = 'resilient', WEBHOOK = 'webhook', } @@ -33,6 +35,7 @@ export function getAllExternalServiceSimulatorPaths(): string[] { ); allPaths.push(`/api/_${NAME}/${ExternalServiceSimulator.SERVICENOW}/api/now/v2/table/incident`); allPaths.push(`/api/_${NAME}/${ExternalServiceSimulator.JIRA}/rest/api/2/issue`); + allPaths.push(`/api/_${NAME}/${ExternalServiceSimulator.RESILIENT}/rest/orgs/201/incidents`); return allPaths; } @@ -88,6 +91,7 @@ export class FixturePlugin implements Plugin, + res: KibanaResponseFactory + ): Promise> { + return jsonResponse(res, 200, { + id: '123', + create_date: 1589391874472, + }); + } + ); + + router.patch( + { + path: `${path}/rest/orgs/201/incidents/{id}`, + options: { + authRequired: false, + }, + validate: {}, + }, + async function ( + context: RequestHandlerContext, + req: KibanaRequest, + res: KibanaResponseFactory + ): Promise> { + return jsonResponse(res, 200, { + success: true, + }); + } + ); + + router.get( + { + path: `${path}/rest/orgs/201/incidents/{id}`, + options: { + authRequired: false, + }, + validate: {}, + }, + async function ( + context: RequestHandlerContext, + req: KibanaRequest, + res: KibanaResponseFactory + ): Promise> { + return jsonResponse(res, 200, { + id: '123', + create_date: 1589391874472, + inc_last_modified_date: 1589391874472, + name: 'title', + description: 'description', + }); + } + ); + + router.post( + { + path: `${path}/rest/api/2/issue/{id}/comment`, + options: { + authRequired: false, + }, + validate: {}, + }, + async function ( + context: RequestHandlerContext, + req: KibanaRequest, + res: KibanaResponseFactory + ): Promise> { + return jsonResponse(res, 200, { + id: '123', + created: '2020-04-27T14:17:45.490Z', + }); + } + ); +} + +function jsonResponse( + res: KibanaResponseFactory, + code: number, + object: Record = {} +) { + return res.custom>({ body: object, statusCode: code }); +} diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/resilient.ts b/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/resilient.ts new file mode 100644 index 00000000000000..a77e0414a19d48 --- /dev/null +++ b/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/resilient.ts @@ -0,0 +1,549 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from '@kbn/expect'; + +import { FtrProviderContext } from '../../../../common/ftr_provider_context'; + +import { + getExternalServiceSimulatorPath, + ExternalServiceSimulator, +} from '../../../../common/fixtures/plugins/actions_simulators/server/plugin'; + +const mapping = [ + { + source: 'title', + target: 'name', + actionType: 'overwrite', + }, + { + source: 'description', + target: 'description', + actionType: 'overwrite', + }, + { + source: 'comments', + target: 'comments', + actionType: 'append', + }, +]; + +// eslint-disable-next-line import/no-default-export +export default function resilientTest({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + const esArchiver = getService('esArchiver'); + const kibanaServer = getService('kibanaServer'); + + const mockResilient = { + config: { + apiUrl: 'www.jiraisinkibanaactions.com', + orgId: '201', + casesConfiguration: { mapping }, + }, + secrets: { + apiKeyId: 'key', + apiKeySecret: 'secret', + }, + params: { + subAction: 'pushToService', + subActionParams: { + savedObjectId: '123', + title: 'a title', + description: 'a description', + createdAt: '2020-03-13T08:34:53.450Z', + createdBy: { fullName: 'Elastic User', username: 'elastic' }, + updatedAt: null, + updatedBy: null, + externalId: null, + comments: [ + { + commentId: '456', + version: 'WzU3LDFd', + comment: 'first comment', + createdAt: '2020-03-13T08:34:53.450Z', + createdBy: { fullName: 'Elastic User', username: 'elastic' }, + updatedAt: null, + updatedBy: null, + }, + ], + }, + }, + }; + + let resilientSimulatorURL: string = ''; + + describe('IBM Resilient', () => { + before(() => { + resilientSimulatorURL = kibanaServer.resolveUrl( + getExternalServiceSimulatorPath(ExternalServiceSimulator.RESILIENT) + ); + }); + + after(() => esArchiver.unload('empty_kibana')); + + describe('IBM Resilient - Action Creation', () => { + it('should return 200 when creating a ibm resilient action successfully', async () => { + const { body: createdAction } = await supertest + .post('/api/actions/action') + .set('kbn-xsrf', 'foo') + .send({ + name: 'An IBM Resilient action', + actionTypeId: '.resilient', + config: { + ...mockResilient.config, + apiUrl: resilientSimulatorURL, + }, + secrets: mockResilient.secrets, + }) + .expect(200); + + expect(createdAction).to.eql({ + id: createdAction.id, + isPreconfigured: false, + name: 'An IBM Resilient action', + actionTypeId: '.resilient', + config: { + apiUrl: resilientSimulatorURL, + orgId: mockResilient.config.orgId, + casesConfiguration: mockResilient.config.casesConfiguration, + }, + }); + + const { body: fetchedAction } = await supertest + .get(`/api/actions/action/${createdAction.id}`) + .expect(200); + + expect(fetchedAction).to.eql({ + id: fetchedAction.id, + isPreconfigured: false, + name: 'An IBM Resilient action', + actionTypeId: '.resilient', + config: { + apiUrl: resilientSimulatorURL, + orgId: mockResilient.config.orgId, + casesConfiguration: mockResilient.config.casesConfiguration, + }, + }); + }); + + it('should respond with a 400 Bad Request when creating a ibm resilient action with no apiUrl', async () => { + await supertest + .post('/api/actions/action') + .set('kbn-xsrf', 'foo') + .send({ + name: 'An IBM Resilient', + actionTypeId: '.resilient', + config: { orgId: '201' }, + }) + .expect(400) + .then((resp: any) => { + expect(resp.body).to.eql({ + statusCode: 400, + error: 'Bad Request', + message: + 'error validating action type config: [apiUrl]: expected value of type [string] but got [undefined]', + }); + }); + }); + + it('should respond with a 400 Bad Request when creating a ibm resilient action with no orgId', async () => { + await supertest + .post('/api/actions/action') + .set('kbn-xsrf', 'foo') + .send({ + name: 'An IBM Resilient', + actionTypeId: '.resilient', + config: { apiUrl: resilientSimulatorURL }, + }) + .expect(400) + .then((resp: any) => { + expect(resp.body).to.eql({ + statusCode: 400, + error: 'Bad Request', + message: + 'error validating action type config: [orgId]: expected value of type [string] but got [undefined]', + }); + }); + }); + + it('should respond with a 400 Bad Request when creating a ibm resilient action with a non whitelisted apiUrl', async () => { + await supertest + .post('/api/actions/action') + .set('kbn-xsrf', 'foo') + .send({ + name: 'An IBM Resilient', + actionTypeId: '.resilient', + config: { + apiUrl: 'http://resilient.mynonexistent.com', + orgId: mockResilient.config.orgId, + casesConfiguration: mockResilient.config.casesConfiguration, + }, + secrets: mockResilient.secrets, + }) + .expect(400) + .then((resp: any) => { + expect(resp.body).to.eql({ + statusCode: 400, + error: 'Bad Request', + message: + 'error validating action type config: error configuring connector action: target url "http://resilient.mynonexistent.com" is not whitelisted in the Kibana config xpack.actions.whitelistedHosts', + }); + }); + }); + + it('should respond with a 400 Bad Request when creating a ibm resilient action without secrets', async () => { + await supertest + .post('/api/actions/action') + .set('kbn-xsrf', 'foo') + .send({ + name: 'An IBM Resilient', + actionTypeId: '.resilient', + config: { + apiUrl: resilientSimulatorURL, + orgId: mockResilient.config.orgId, + casesConfiguration: mockResilient.config.casesConfiguration, + }, + }) + .expect(400) + .then((resp: any) => { + expect(resp.body).to.eql({ + statusCode: 400, + error: 'Bad Request', + message: + 'error validating action type secrets: [apiKeyId]: expected value of type [string] but got [undefined]', + }); + }); + }); + + it('should respond with a 400 Bad Request when creating a ibm resilient action without casesConfiguration', async () => { + await supertest + .post('/api/actions/action') + .set('kbn-xsrf', 'foo') + .send({ + name: 'An IBM Resilient', + actionTypeId: '.resilient', + config: { + apiUrl: resilientSimulatorURL, + orgId: mockResilient.config.orgId, + }, + secrets: mockResilient.secrets, + }) + .expect(400) + .then((resp: any) => { + expect(resp.body).to.eql({ + statusCode: 400, + error: 'Bad Request', + message: + 'error validating action type config: [casesConfiguration.mapping]: expected value of type [array] but got [undefined]', + }); + }); + }); + + it('should respond with a 400 Bad Request when creating a ibm resilient action with empty mapping', async () => { + await supertest + .post('/api/actions/action') + .set('kbn-xsrf', 'foo') + .send({ + name: 'An IBM Resilient', + actionTypeId: '.resilient', + config: { + apiUrl: resilientSimulatorURL, + orgId: mockResilient.config.orgId, + casesConfiguration: { mapping: [] }, + }, + secrets: mockResilient.secrets, + }) + .expect(400) + .then((resp: any) => { + expect(resp.body).to.eql({ + statusCode: 400, + error: 'Bad Request', + message: + 'error validating action type config: [casesConfiguration.mapping]: expected non-empty but got empty', + }); + }); + }); + + it('should respond with a 400 Bad Request when creating a ibm resilient action with wrong actionType', async () => { + await supertest + .post('/api/actions/action') + .set('kbn-xsrf', 'foo') + .send({ + name: 'An IBM Resilient', + actionTypeId: '.resilient', + config: { + apiUrl: resilientSimulatorURL, + orgId: mockResilient.config.orgId, + casesConfiguration: { + mapping: [ + { + source: 'title', + target: 'description', + actionType: 'non-supported', + }, + ], + }, + }, + secrets: mockResilient.secrets, + }) + .expect(400); + }); + }); + + describe('IBM Resilient - Executor', () => { + let simulatedActionId: string; + before(async () => { + const { body } = await supertest + .post('/api/actions/action') + .set('kbn-xsrf', 'foo') + .send({ + name: 'A ibm resilient simulator', + actionTypeId: '.resilient', + config: { + apiUrl: resilientSimulatorURL, + orgId: mockResilient.config.orgId, + casesConfiguration: mockResilient.config.casesConfiguration, + }, + secrets: mockResilient.secrets, + }); + simulatedActionId = body.id; + }); + + describe('Validation', () => { + it('should handle failing with a simulated success without action', async () => { + await supertest + .post(`/api/actions/action/${simulatedActionId}/_execute`) + .set('kbn-xsrf', 'foo') + .send({ + params: {}, + }) + .then((resp: any) => { + expect(resp.body).to.eql({ + actionId: simulatedActionId, + status: 'error', + retry: false, + message: `error validating action params: Cannot read property 'Symbol(Symbol.iterator)' of undefined`, + }); + }); + }); + + it('should handle failing with a simulated success without unsupported action', async () => { + await supertest + .post(`/api/actions/action/${simulatedActionId}/_execute`) + .set('kbn-xsrf', 'foo') + .send({ + params: { subAction: 'non-supported' }, + }) + .then((resp: any) => { + expect(resp.body).to.eql({ + actionId: simulatedActionId, + status: 'error', + retry: false, + message: + 'error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [getIncident]\n- [1.subAction]: expected value to equal [handshake]\n- [2.subAction]: expected value to equal [pushToService]', + }); + }); + }); + + it('should handle failing with a simulated success without subActionParams', async () => { + await supertest + .post(`/api/actions/action/${simulatedActionId}/_execute`) + .set('kbn-xsrf', 'foo') + .send({ + params: { subAction: 'pushToService' }, + }) + .then((resp: any) => { + expect(resp.body).to.eql({ + actionId: simulatedActionId, + status: 'error', + retry: false, + message: + 'error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [getIncident]\n- [1.subAction]: expected value to equal [handshake]\n- [2.subActionParams.savedObjectId]: expected value of type [string] but got [undefined]', + }); + }); + }); + + it('should handle failing with a simulated success without savedObjectId', async () => { + await supertest + .post(`/api/actions/action/${simulatedActionId}/_execute`) + .set('kbn-xsrf', 'foo') + .send({ + params: { subAction: 'pushToService', subActionParams: {} }, + }) + .then((resp: any) => { + expect(resp.body).to.eql({ + actionId: simulatedActionId, + status: 'error', + retry: false, + message: + 'error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [getIncident]\n- [1.subAction]: expected value to equal [handshake]\n- [2.subActionParams.savedObjectId]: expected value of type [string] but got [undefined]', + }); + }); + }); + + it('should handle failing with a simulated success without title', async () => { + await supertest + .post(`/api/actions/action/${simulatedActionId}/_execute`) + .set('kbn-xsrf', 'foo') + .send({ + params: { + ...mockResilient.params, + subActionParams: { + savedObjectId: 'success', + }, + }, + }) + .then((resp: any) => { + expect(resp.body).to.eql({ + actionId: simulatedActionId, + status: 'error', + retry: false, + message: + 'error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [getIncident]\n- [1.subAction]: expected value to equal [handshake]\n- [2.subActionParams.title]: expected value of type [string] but got [undefined]', + }); + }); + }); + + it('should handle failing with a simulated success without createdAt', async () => { + await supertest + .post(`/api/actions/action/${simulatedActionId}/_execute`) + .set('kbn-xsrf', 'foo') + .send({ + params: { + ...mockResilient.params, + subActionParams: { + savedObjectId: 'success', + title: 'success', + }, + }, + }) + .then((resp: any) => { + expect(resp.body).to.eql({ + actionId: simulatedActionId, + status: 'error', + retry: false, + message: + 'error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [getIncident]\n- [1.subAction]: expected value to equal [handshake]\n- [2.subActionParams.createdAt]: expected value of type [string] but got [undefined]', + }); + }); + }); + + it('should handle failing with a simulated success without commentId', async () => { + await supertest + .post(`/api/actions/action/${simulatedActionId}/_execute`) + .set('kbn-xsrf', 'foo') + .send({ + params: { + ...mockResilient.params, + subActionParams: { + ...mockResilient.params.subActionParams, + savedObjectId: 'success', + title: 'success', + createdAt: 'success', + createdBy: { username: 'elastic' }, + comments: [{}], + }, + }, + }) + .then((resp: any) => { + expect(resp.body).to.eql({ + actionId: simulatedActionId, + status: 'error', + retry: false, + message: + 'error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [getIncident]\n- [1.subAction]: expected value to equal [handshake]\n- [2.subActionParams.comments]: types that failed validation:\n - [subActionParams.comments.0.0.commentId]: expected value of type [string] but got [undefined]\n - [subActionParams.comments.1]: expected value to equal [null]', + }); + }); + }); + + it('should handle failing with a simulated success without comment message', async () => { + await supertest + .post(`/api/actions/action/${simulatedActionId}/_execute`) + .set('kbn-xsrf', 'foo') + .send({ + params: { + ...mockResilient.params, + subActionParams: { + ...mockResilient.params.subActionParams, + savedObjectId: 'success', + title: 'success', + createdAt: 'success', + createdBy: { username: 'elastic' }, + comments: [{ commentId: 'success' }], + }, + }, + }) + .then((resp: any) => { + expect(resp.body).to.eql({ + actionId: simulatedActionId, + status: 'error', + retry: false, + message: + 'error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [getIncident]\n- [1.subAction]: expected value to equal [handshake]\n- [2.subActionParams.comments]: types that failed validation:\n - [subActionParams.comments.0.0.comment]: expected value of type [string] but got [undefined]\n - [subActionParams.comments.1]: expected value to equal [null]', + }); + }); + }); + + it('should handle failing with a simulated success without comment.createdAt', async () => { + await supertest + .post(`/api/actions/action/${simulatedActionId}/_execute`) + .set('kbn-xsrf', 'foo') + .send({ + params: { + ...mockResilient.params, + subActionParams: { + ...mockResilient.params.subActionParams, + savedObjectId: 'success', + title: 'success', + createdAt: 'success', + createdBy: { username: 'elastic' }, + comments: [{ commentId: 'success', comment: 'success' }], + }, + }, + }) + .then((resp: any) => { + expect(resp.body).to.eql({ + actionId: simulatedActionId, + status: 'error', + retry: false, + message: + 'error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [getIncident]\n- [1.subAction]: expected value to equal [handshake]\n- [2.subActionParams.comments]: types that failed validation:\n - [subActionParams.comments.0.0.createdAt]: expected value of type [string] but got [undefined]\n - [subActionParams.comments.1]: expected value to equal [null]', + }); + }); + }); + }); + + describe('Execution', () => { + it('should handle creating an incident without comments', async () => { + const { body } = await supertest + .post(`/api/actions/action/${simulatedActionId}/_execute`) + .set('kbn-xsrf', 'foo') + .send({ + params: { + ...mockResilient.params, + subActionParams: { + ...mockResilient.params.subActionParams, + comments: [], + }, + }, + }) + .expect(200); + + expect(body).to.eql({ + status: 'ok', + actionId: simulatedActionId, + data: { + id: '123', + title: '123', + pushedDate: '2020-05-13T17:44:34.472Z', + url: `${resilientSimulatorURL}/#incidents/123`, + }, + }); + }); + }); + }); + }); +} diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/index.ts b/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/index.ts index 18b1714582d131..9cdc0c9fa663ee 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/index.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/index.ts @@ -16,6 +16,7 @@ export default function actionsTests({ loadTestFile }: FtrProviderContext) { loadTestFile(require.resolve('./builtin_action_types/server_log')); loadTestFile(require.resolve('./builtin_action_types/servicenow')); loadTestFile(require.resolve('./builtin_action_types/jira')); + loadTestFile(require.resolve('./builtin_action_types/resilient')); loadTestFile(require.resolve('./builtin_action_types/slack')); loadTestFile(require.resolve('./builtin_action_types/webhook')); loadTestFile(require.resolve('./create'));