Adds event log for actions and alerting (#45081)

initial code for event log

see issue https://github.com/elastic/kibana/issues/45083
This commit is contained in:
Patrick Mueller 2020-01-21 18:00:08 -05:00 committed by GitHub
parent db1a64da76
commit b78c1b1042
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
40 changed files with 2444 additions and 17 deletions

1
.github/CODEOWNERS vendored
View file

@ -129,6 +129,7 @@
# Kibana Alerting Services
/x-pack/legacy/plugins/alerting/ @elastic/kibana-alerting-services
/x-pack/legacy/plugins/actions/ @elastic/kibana-alerting-services
/x-pack/plugins/event_log/ @elastic/kibana-alerting-services
/x-pack/plugins/task_manager/ @elastic/kibana-alerting-services
/x-pack/test/alerting_api_integration/ @elastic/kibana-alerting-services
/x-pack/test/plugin_api_integration/plugins/task_manager/ @elastic/kibana-alerting-services

View file

@ -13,6 +13,7 @@ import {
savedObjectsClientMock,
loggingServiceMock,
} from '../../../../../../src/core/server/mocks';
import { createEventLoggerMock } from '../../../../../plugins/event_log/server/event_logger.mock';
const actionExecutor = new ActionExecutor();
const savedObjectsClient = savedObjectsClientMock.create();
@ -58,6 +59,7 @@ actionExecutor.initialize({
getServices,
actionTypeRegistry,
encryptedSavedObjectsPlugin,
eventLogger: createEventLoggerMock(),
});
beforeEach(() => jest.resetAllMocks());

View file

@ -15,6 +15,8 @@ import {
GetServicesFunction,
RawAction,
} from '../types';
import { EVENT_LOG_ACTIONS } from '../plugin';
import { IEvent, IEventLogger } from '../../../../../plugins/event_log/server';
export interface ActionExecutorContext {
logger: Logger;
@ -22,6 +24,7 @@ export interface ActionExecutorContext {
getServices: GetServicesFunction;
encryptedSavedObjectsPlugin: EncryptedSavedObjectsStartContract;
actionTypeRegistry: ActionTypeRegistryContract;
eventLogger: IEventLogger;
}
export interface ExecuteOptions {
@ -54,11 +57,11 @@ export class ActionExecutor {
}
const {
logger,
spaces,
getServices,
encryptedSavedObjectsPlugin,
actionTypeRegistry,
eventLogger,
} = this.actionExecutorContext!;
const spacesPlugin = spaces();
@ -89,9 +92,9 @@ export class ActionExecutor {
);
const actionType = actionTypeRegistry.get(actionTypeId);
let validatedParams;
let validatedConfig;
let validatedSecrets;
let validatedParams: Record<string, any>;
let validatedConfig: Record<string, any>;
let validatedSecrets: Record<string, any>;
try {
validatedParams = validateParams(actionType, params);
@ -101,11 +104,16 @@ export class ActionExecutor {
return { status: 'error', actionId, message: err.message, retry: false };
}
let result: ActionTypeExecutorResult | null = null;
const actionLabel = `${actionId} - ${actionTypeId} - ${name}`;
const actionLabel = `${actionTypeId}:${actionId}: ${name}`;
const event: IEvent = {
event: { action: EVENT_LOG_ACTIONS.execute },
kibana: { namespace, saved_objects: [{ type: 'action', id: actionId }] },
};
eventLogger.startTiming(event);
let rawResult: ActionTypeExecutorResult | null | undefined | void;
try {
result = await actionType.executor({
rawResult = await actionType.executor({
actionId,
services,
params: validatedParams,
@ -113,15 +121,51 @@ export class ActionExecutor {
secrets: validatedSecrets,
});
} catch (err) {
logger.warn(`action executed unsuccessfully: ${actionLabel} - ${err.message}`);
throw err;
rawResult = {
actionId,
status: 'error',
message: 'an error occurred while running the action executor',
serviceMessage: err.message,
retry: false,
};
}
eventLogger.stopTiming(event);
// allow null-ish return to indicate success
const result = rawResult || {
actionId,
status: 'ok',
};
if (result.status === 'ok') {
event.message = `action executed: ${actionLabel}`;
} else if (result.status === 'error') {
event.message = `action execution failure: ${actionLabel}`;
event.error = event.error || {};
event.error.message = actionErrorToMessage(result);
} else {
event.message = `action execution returned unexpected result: ${actionLabel}`;
event.error = event.error || {};
event.error.message = 'action execution returned unexpected result';
}
logger.debug(`action executed successfully: ${actionLabel}`);
// return basic response if none provided
if (result == null) return { status: 'ok', actionId };
eventLogger.logEvent(event);
return result;
}
}
function actionErrorToMessage(result: ActionTypeExecutorResult): string {
let message = result.message || 'unknown error running action';
if (result.serviceMessage) {
message = `${message}: ${result.serviceMessage}`;
}
if (result.retry instanceof Date) {
message = `${message}; retry at ${result.retry.toISOString()}`;
} else if (result.retry) {
message = `${message}; retry: ${JSON.stringify(result.retry)}`;
}
return message;
}

View file

@ -16,6 +16,7 @@ import {
savedObjectsClientMock,
loggingServiceMock,
} from '../../../../../../src/core/server/mocks';
import { createEventLoggerMock } from '../../../../../plugins/event_log/server/event_logger.mock';
const spaceIdToNamespace = jest.fn();
const actionTypeRegistry = actionTypeRegistryMock.create();
@ -62,6 +63,7 @@ const actionExecutorInitializerParams = {
actionTypeRegistry,
spaces: () => undefined,
encryptedSavedObjectsPlugin: mockedEncryptedSavedObjectsPlugin,
eventLogger: createEventLoggerMock(),
};
const taskRunnerFactoryInitializerParams = {
spaceIdToNamespace,

View file

@ -35,6 +35,13 @@ import {
} from './routes';
import { extendRouteWithLicenseCheck } from './extend_route_with_license_check';
import { LicenseState } from './lib/license_state';
import { IEventLogger } from '../../../../plugins/event_log/server';
const EVENT_LOG_PROVIDER = 'actions';
export const EVENT_LOG_ACTIONS = {
execute: 'execute',
executeViaHttp: 'execute-via-http',
};
export interface PluginSetupContract {
registerType: ActionTypeRegistry['register'];
@ -57,6 +64,7 @@ export class Plugin {
private actionExecutor?: ActionExecutor;
private defaultKibanaIndex?: string;
private licenseState: LicenseState | null = null;
private eventLogger?: IEventLogger;
constructor(initializerContext: ActionsPluginInitializerContext) {
this.logger = initializerContext.logger.get('plugins', 'actions');
@ -88,6 +96,11 @@ export class Plugin {
attributesToEncrypt: new Set(['apiKey']),
});
plugins.event_log.registerProviderActions(EVENT_LOG_PROVIDER, Object.values(EVENT_LOG_ACTIONS));
this.eventLogger = plugins.event_log.getLogger({
event: { provider: EVENT_LOG_PROVIDER },
});
const actionExecutor = new ActionExecutor();
const taskRunnerFactory = new TaskRunnerFactory(actionExecutor);
const actionsConfigUtils = getActionsConfigurationUtilities(config as ActionsConfigType);
@ -156,6 +169,7 @@ export class Plugin {
getServices,
encryptedSavedObjectsPlugin: plugins.encryptedSavedObjects,
actionTypeRegistry: actionTypeRegistry!,
eventLogger: this.eventLogger!,
});
taskRunnerFactory!.initialize({
encryptedSavedObjectsPlugin: plugins.encryptedSavedObjects,

View file

@ -27,6 +27,7 @@ import {
SavedObjectsLegacyService,
} from '../../../../../src/core/server';
import { LicensingPluginSetup } from '../../../../plugins/licensing/server';
import { IEventLogService } from '../../../../plugins/event_log/server';
export interface KibanaConfig {
index: string;
@ -67,6 +68,7 @@ export interface ActionsPluginsSetup {
xpack_main: XPackMainPluginSetupContract;
encryptedSavedObjects: EncryptedSavedObjectsSetupContract;
licensing: LicensingPluginSetup;
event_log: IEventLogService;
}
export interface ActionsPluginsStart {
security?: SecurityPluginStartContract;
@ -126,6 +128,7 @@ export function shim(
encryptedSavedObjects: newPlatform.setup.plugins
.encryptedSavedObjects as EncryptedSavedObjectsSetupContract,
licensing: newPlatform.setup.plugins.licensing as LicensingPluginSetup,
event_log: newPlatform.setup.plugins.event_log as IEventLogService,
};
const pluginsStart: ActionsPluginsStart = {

View file

@ -63,7 +63,7 @@ export interface ActionTypeExecutorResult {
// signature of the action type executor function
export type ExecutorType = (
options: ActionTypeExecutorOptions
) => Promise<ActionTypeExecutorResult>;
) => Promise<ActionTypeExecutorResult | null | undefined | void>;
interface ValidatorType {
validate<T>(value: any): any;

View file

@ -0,0 +1,299 @@
# Event Log
## Overview
The purpose of this plugin is to provide a way to persist a history of events
occuring in Kibana, initially just for the Make It Action project - alerts
and actions.
## Basic Usage - Logging Events
Follow these steps to use `event_log` in your plugin:
1. Declare `event_log` as a dependency in `kibana.json`:
```json
{
...
"requiredPlugins": ["event_log"],
...
}
```
2. Register provider / actions, and create your plugin's logger, using service
API provided in the `setup` stage:
```typescript
...
import { IEventLogger, IEventLogService } from '../../event_log/server';
interface PluginSetupDependencies {
event_log: IEventLogService;
}
...
public setup(core: CoreSetup, { event_log }: PluginSetupDependencies) {
...
event_log.registerProviderActions('my-plugin', ['action-1, action-2']);
const eventLogger: IEventLogger = event_log.getLogger({ event: { provider: 'my-plugin' } });
...
}
...
```
4. To log an event, call `logEvent()` on the `eventLogger` object you created:
```typescript
...
eventLogger.logEvent({ event: { action: 'action-1' }, tags: ['fe', 'fi', 'fo'] });
...
```
## Testing
### Unit tests
From `kibana-root-folder/x-pack`, run:
```bash
$ node node scripts/jest plugins/event_log
```
### API Integration tests
None yet!
## Background
For the Make It Action alerting / action plugins, we will need a way to
persist data regarding alerts and actions, for UI and investigative purposes.
We're referring to this persisted data as "events", and will be persisted to
a new elasticsearch index referred to as the "event log".
Example events are actions firing, alerts running their scheduled functions,
alerts scheduling actions to run, etc.
This functionality will be provided in a new NP plugin `event_log`, and will
provide server-side plugin APIs to write to the event log, and run limited
queries against it. For now, access via HTTP will not be available, due to
security concerns and lack of use cases.
The current clients for the event log are the actions and alerting plugins,
however the event log currently has nothing specific to them, and is general
purpose, so can be used by any plugin to "log events".
We currently assume that there may be many events logged, and that (some) customers
may not be interested in "old" events, and so to keep the event log from
consuming too much disk space, we'll set it up with ILM and some kind of
reasonable default policy that can be customized by the user. This implies
also the use of rollver, setting a write index alias upon rollover, and
that searches for events will be done via an ES index pattern / alias to search
across event log indices with a wildcard.
The shape of the documents indexed into the event log index is a subset of ECS
properties with a few Kibana extensions. Over time the subset is of ECS and
Kibana extensions will likely grow.
# Basic example
When an action is executed, an event should be written to the event log.
Here's a [`kbn-action` command](https://github.com/pmuellr/kbn-action) to
execute a "server log" action (writes a message to the Kibana log):
```console
$ kbn-action execute 79b4c37e-ef42-4421-a0b0-b536840f930d '{level:info message:hallo}'
{
"status": "ok"
}
```
Here's the event written to the event log index:
```json
{
"_index": ".kibana-event-log-000001",
"_type": "_doc",
"_id": "d2CXT20BPOpswQ8vgXp5",
"_score": 1,
"_source": {
"event": {
"provider": "actions",
"action": "execute",
"start": "2019-12-09T21:16:43.424Z",
"end": "2019-12-09T21:16:43.425Z",
"duration": 1000000
},
"kibana": {
"namespace": "default",
"saved_objects": [
{
"type": "action",
"id": "79b4c37e-ef42-4421-a0b0-b536840f930d"
}
]
},
"message": "action executed successfully: 79b4c37e-ef42-4421-a0b0-b536840f930d - .server-log - server-log",
"@timestamp": "2019-12-09T21:16:43.425Z",
"ecs": {
"version": "1.3.1"
}
}
}
```
The shape of the document written to the index is a subset of [ECS][] with an
extended field of `kibana` with some Kibana-related properties contained within
it.
The ES mappings for the ECS data, and the config-schema for the ECS data, are
generated by a script, and available here:
- [`generated/mappings.json`](generated/mappings.json)
- [`generated/schemas.ts`](generated/schemas.ts)
It's anticipated that these interfaces will grow over time, hopefully adding
more ECS fields but adding Kibana extensions as required.
Since there are some security concerns with the data, we are currently
restricting access via known saved object ids. That is, you can only query
history records associated with specific saved object ids.
[ECS]: https://www.elastic.co/guide/en/ecs/current/index.html
## API
```typescript
// IEvent is a TS type generated from the subset of ECS supported
// the NP plugin returns a service instance from setup() and start()
export interface IEventLogService {
registerProviderActions(provider: string, actions: string[]): void;
isProviderActionRegistered(provider: string, action: string): boolean;
getProviderActions(): Map<string, Set<string>>;
getLogger(properties: IEvent): IEventLogger;
}
export interface IEventLogger {
logEvent(properties: IEvent): void;
startTiming(event: IEvent): void;
stopTiming(event: IEvent): void;
}
```
The plugin exposes an `IEventLogService` object to plugins that pre-req it.
Those plugins need to call `registerProviderActions()` to indicate the values
of the `event.provider` and `event.action` values they will be using
when logging events.
The pre-registration helps in two ways:
- dealing with misspelled values
- preventing index explosion on those fields
Once the values are registered, the plugin will get an `IEventLogger` instance
by passing in a set of default properties to be used for all it's logging,
to the `getLogger()` method. For instance, the `actions` plugin creates a
logger with `event.provider` set to `actions`, and provides `event.action`
values when writing actual entries.
The `IEventLogger` object can be cached at the plugin level and accessed by
any code in the plugin. It has a single method to write an event log entry,
`logEvent()`, which is passed specific properties for the event.
The final data written is a combination of the data passed to `getLogger()` when
creating the logger, and the data passed on the `logEvent()` call, and then
that result is validated to ensure it's complete and valid. Errors will be
logged to the server log.
The `logEvent()` method returns no values, and is itself not asynchronous.
It's a "call and forget" kind of thing. The method itself will arrange
to have the ultimate document written to the index asynchronously. It's designed
this way because it's not clear what a client would do with a result from this
method, nor what it would do if the method threw an error. All the error
processing involved with getting the data into the index is handled internally,
and logged to the server log as appropriate.
The `startTiming()` and `stopTiming()` methods can be used to set the timing
properties `start`, `end`, and `duration` in the event. For example:
```typescript
const loggedEvent: IEvent = { event: { action: 'foo' } };
// sets event.start
eventLogger.startTiming(loggedEvent);
longRunningFunction();
// sets event.end and event.duration
eventLogger.stopTiming(loggedEvent);
eventLogger.logEvent(loggedEvent);
```
It's anticipated that more "helper" methods like this will be provided in the
future.
## Stored data
The elasticsearch index for the event log will have ILM and rollover support,
as customers may decide to only keep recent event documents, wanting indices
with older event documents deleted, turned cold, frozen, etc. We'll supply
some default values, but customers will be able to tweak these.
The index template, mappings, config-schema types, etc for the index can
be found in the [generated directory](generated). These files are generated
from a script which takes as input the ECS properties to use, and the Kibana
extensions.
See [ilm rollover action docs][] for more info on the `is_write_index`, and `index.lifecycle.*` properties.
[ilm rollover action docs]: https://www.elastic.co/guide/en/elasticsearch/reference/current/_actions.html#ilm-rollover-action
Of particular note in the `mappings`:
- all "objects" are `dynamic: 'strict'` implies users can't add new fields
- all the `properties` are indexed
We may change some of that before releasing.
## ILM setup
We'll want to provide default ILM policy, this seems like a reasonable first
attempt:
```
PUT _ilm/policy/event_log_policy
{
"policy": {
"phases": {
"hot": {
"actions": {
"rollover": {
"max_size": "5GB",
"max_age": "30d"
}
}
}
}
}
}
```
This means that ILM would "rollover" the current index, say
`.kibana-event-log-000001` by creating a new index `.kibana-event-log-000002`,
which would "inherit" everything from the index template, and then ILM will
set the write index of the the alias to the new index. This would happen
when the original index grew past 5 GB, or was created more than 30 days ago.
For more relevant information on ILM, see:
[getting started with ILM doc][] and [write index alias behavior][]:
[getting started with ILM doc]: https://www.elastic.co/guide/en/elasticsearch/reference/current/getting-started-index-lifecycle-management.html
[write index alias behavior]: https://www.elastic.co/guide/en/elasticsearch/reference/master/indices-rollover-index.html#indices-rollover-is-write-index

View file

@ -0,0 +1,4 @@
The files in this directory were generated by manually running the script
../scripts/create-schemas.js from the root directory of the repository.
These files should not be edited by hand.

View file

@ -0,0 +1,96 @@
{
"dynamic": "strict",
"properties": {
"@timestamp": {
"type": "date"
},
"tags": {
"ignore_above": 1024,
"type": "keyword"
},
"message": {
"norms": false,
"type": "text"
},
"ecs": {
"properties": {
"version": {
"ignore_above": 1024,
"type": "keyword"
}
},
"dynamic": "strict"
},
"event": {
"properties": {
"action": {
"ignore_above": 1024,
"type": "keyword"
},
"provider": {
"ignore_above": 1024,
"type": "keyword"
},
"start": {
"type": "date"
},
"duration": {
"type": "long"
},
"end": {
"type": "date"
}
},
"dynamic": "strict"
},
"error": {
"properties": {
"message": {
"norms": false,
"type": "text"
}
},
"dynamic": "strict"
},
"user": {
"properties": {
"name": {
"ignore_above": 1024,
"type": "keyword"
}
},
"dynamic": "strict"
},
"kibana": {
"properties": {
"server_uuid": {
"type": "keyword",
"ignore_above": 1024
},
"namespace": {
"type": "keyword",
"ignore_above": 1024
},
"saved_objects": {
"properties": {
"store": {
"type": "keyword",
"ignore_above": 1024
},
"id": {
"type": "keyword",
"ignore_above": 1024
},
"type": {
"type": "keyword",
"ignore_above": 1024
}
},
"type": "nested",
"dynamic": "strict"
}
},
"dynamic": "strict"
}
}
}

View file

@ -0,0 +1,95 @@
/*
* 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.
*/
// ---------------------------------- WARNING ----------------------------------
// this file was generated, and should not be edited by hand
// ---------------------------------- WARNING ----------------------------------
// provides TypeScript and config-schema interfaces for ECS for use with
// the event log
import { schema, TypeOf } from '@kbn/config-schema';
type DeepWriteable<T> = { -readonly [P in keyof T]: DeepWriteable<T[P]> };
type DeepPartial<T> = {
[P in keyof T]?: T[P] extends Array<infer U> ? Array<DeepPartial<U>> : DeepPartial<T[P]>;
};
export const ECS_VERSION = '1.3.1';
// types and config-schema describing the es structures
export type IValidatedEvent = TypeOf<typeof EventSchema>;
export type IEvent = DeepPartial<DeepWriteable<IValidatedEvent>>;
export const EventSchema = schema.maybe(
schema.object({
'@timestamp': ecsDate(),
tags: ecsStringMulti(),
message: ecsString(),
ecs: schema.maybe(
schema.object({
version: ecsString(),
})
),
event: schema.maybe(
schema.object({
action: ecsString(),
provider: ecsString(),
start: ecsDate(),
duration: ecsNumber(),
end: ecsDate(),
})
),
error: schema.maybe(
schema.object({
message: ecsString(),
})
),
user: schema.maybe(
schema.object({
name: ecsString(),
})
),
kibana: schema.maybe(
schema.object({
server_uuid: ecsString(),
namespace: ecsString(),
saved_objects: schema.maybe(
schema.arrayOf(
schema.object({
store: ecsString(),
id: ecsString(),
type: ecsString(),
})
)
),
})
),
})
);
function ecsStringMulti() {
return schema.maybe(schema.arrayOf(schema.string()));
}
function ecsString() {
return schema.maybe(schema.string());
}
function ecsNumber() {
return schema.maybe(schema.number());
}
function ecsDate() {
return schema.maybe(schema.string({ validate: validateDate }));
}
const ISO_DATE_PATTERN = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
function validateDate(isoDate: string) {
if (ISO_DATE_PATTERN.test(isoDate)) return;
return 'string is not a valid ISO date: ' + isoDate;
}

View file

@ -0,0 +1,8 @@
{
"id": "event_log",
"version": "0.0.1",
"kibanaVersion": "kibana",
"configPath": ["xpack", "event_log"],
"server": true,
"ui": false
}

View file

@ -0,0 +1,322 @@
#!/usr/bin/env node
/*
* 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.
*/
const fs = require('fs');
const path = require('path');
const lodash = require('lodash');
const LineWriter = require('./lib/line_writer');
const mappings = require('./mappings');
const PLUGIN_DIR = path.resolve(path.join(__dirname, '..'));
const ECS_MAPPINGS_FILE = 'generated/elasticsearch/7/template.json';
const EVENT_LOG_MAPPINGS_FILE = 'generated/mappings.json';
const EVENT_LOG_CONFIG_SCHEMA_FILE = 'generated/schemas.ts';
function main() {
const ecsDir = getEcsDir();
const ecsVersion = getEcsVersion(ecsDir);
const ecsMappings = readEcsJSONFile(ecsDir, ECS_MAPPINGS_FILE);
// add our custom fields
ecsMappings.mappings.properties.kibana = mappings.EcsKibanaExtensionsMappings;
const exportedProperties = mappings.EcsEventLogProperties;
const multiValuedProperties = new Set(mappings.EcsEventLogMultiValuedProperties);
const elMappings = getEventLogMappings(ecsMappings, exportedProperties);
console.log(`generating files in ${PLUGIN_DIR}`);
writeEventLogMappings(elMappings);
writeEventLogConfigSchema(elMappings, ecsVersion, multiValuedProperties);
}
// return a stripped down version of the ecs schema, with only exportedProperties
function getEventLogMappings(ecsSchema, exportedProperties) {
const result = { mappings: { properties: {} } };
// get full list of properties to copy
const leafProperties = exportedProperties.map(replaceDotWithProperties);
// copy the leaf values of the properties
for (const prop of leafProperties) {
const value = lodash.get(ecsSchema.mappings.properties, prop);
lodash.set(result.mappings.properties, prop, value);
}
// set the non-leaf values as appropriate
const nonLeafProperties = getNonLeafProperties(exportedProperties).map(replaceDotWithProperties);
for (const prop of nonLeafProperties) {
const ecsValue = lodash.get(ecsSchema.mappings.properties, prop);
const elValue = lodash.get(result.mappings.properties, prop);
elValue.type = ecsValue.type;
elValue.dynamic = 'strict';
}
return result;
}
// eg, 'ecs.version' -> 'ecs.properties.version'
function replaceDotWithProperties(s) {
return s.replace(/\./g, '.properties.');
}
// given an array of property names, return array of object/nested ones
function getNonLeafProperties(propertyNames) {
const result = new Set();
for (const propertyName of propertyNames) {
const parts = propertyName.split(/\./g);
if (parts.length <= 1) continue;
parts.pop();
result.add(parts.join('.'));
}
return Array.from(result);
}
function writeEventLogMappings(elSchema) {
// fixObjectTypes(elSchema.mappings);
const mappings = {
dynamic: 'strict',
properties: elSchema.mappings.properties,
};
writeGeneratedFile(EVENT_LOG_MAPPINGS_FILE, JSON.stringify(mappings, null, 4));
console.log('generated:', EVENT_LOG_MAPPINGS_FILE);
}
function writeEventLogConfigSchema(elSchema, ecsVersion, multiValuedProperties) {
const lineWriter = LineWriter.createLineWriter();
const elSchemaMappings = augmentMappings(elSchema.mappings, multiValuedProperties);
generateSchemaLines(lineWriter, null, elSchemaMappings);
// last line will have an extraneous comma
const schemaLines = lineWriter.getContent().replace(/,$/, '');
const contents = getSchemaFileContents(ecsVersion, schemaLines);
const schemaCode = `${contents}\n`;
writeGeneratedFile(EVENT_LOG_CONFIG_SCHEMA_FILE, schemaCode);
console.log('generated:', EVENT_LOG_CONFIG_SCHEMA_FILE);
}
const StringTypes = new Set(['string', 'keyword', 'text', 'ip']);
const NumberTypes = new Set(['long', 'integer', 'float']);
function augmentMappings(mappings, multiValuedProperties) {
// clone the mappings, as we're adding some additional properties
mappings = JSON.parse(JSON.stringify(mappings));
for (const prop of multiValuedProperties) {
const fullProp = replaceDotWithProperties(prop);
lodash.set(mappings.properties, `${fullProp}.multiValued`, true);
}
return mappings;
}
function generateSchemaLines(lineWriter, prop, mappings) {
const propKey = legalPropertyName(prop);
if (StringTypes.has(mappings.type)) {
if (mappings.multiValued) {
lineWriter.addLine(`${propKey}: ecsStringMulti(),`);
} else {
lineWriter.addLine(`${propKey}: ecsString(),`);
}
return;
}
if (NumberTypes.has(mappings.type)) {
lineWriter.addLine(`${propKey}: ecsNumber(),`);
return;
}
if (mappings.type === 'date') {
lineWriter.addLine(`${propKey}: ecsDate(),`);
return;
}
// only handling objects for the rest of this function
if (mappings.properties == null) {
logError(`unknown properties to map: ${prop}: ${JSON.stringify(mappings)}`);
}
// top-level object does not have a property name
if (prop == null) {
lineWriter.addLine(`schema.maybe(`);
lineWriter.indent();
lineWriter.addLine(`schema.object({`);
} else {
lineWriter.addLine(`${propKey}: schema.maybe(`);
lineWriter.indent();
if (mappings.type === 'nested') {
lineWriter.addLine(`schema.arrayOf(`);
lineWriter.indent();
}
lineWriter.addLine(`schema.object({`);
}
// write the object properties
lineWriter.indent();
for (const prop of Object.keys(mappings.properties)) {
generateSchemaLines(lineWriter, prop, mappings.properties[prop]);
}
lineWriter.dedent();
lineWriter.addLine('})');
if (mappings.type === 'nested') {
lineWriter.dedent();
lineWriter.addLine(')');
}
lineWriter.dedent();
lineWriter.addLine('),');
}
function legalPropertyName(prop) {
if (prop === '@timestamp') return `'@timestamp'`;
return prop;
}
function readEcsJSONFile(ecsDir, fileName) {
const contents = readEcsFile(ecsDir, fileName);
let object;
try {
object = JSON.parse(contents);
} catch (err) {
logError(`ecs file is not JSON: ${fileName}: ${err.message}`);
}
return object;
}
function writeGeneratedFile(fileName, contents) {
const genFileName = path.join(PLUGIN_DIR, fileName);
try {
fs.writeFileSync(genFileName, contents);
} catch (err) {
logError(`error writing file: ${genFileName}: ${err.message}`);
}
}
function readEcsFile(ecsDir, fileName) {
const ecsFile = path.resolve(path.join(ecsDir, fileName));
let contents;
try {
contents = fs.readFileSync(ecsFile, { encoding: 'utf8' });
} catch (err) {
logError(`ecs file not found: ${ecsFile}: ${err.message}`);
}
return contents;
}
function getEcsVersion(ecsDir) {
const contents = readEcsFile(ecsDir, 'version').trim();
if (!contents.match(/^\d+\.\d+\.\d+$/)) {
logError(`ecs is not at a stable version: : ${contents}`);
}
return contents;
}
function getEcsDir() {
const ecsDir = path.resolve(path.join(__dirname, '../../../../../ecs'));
let stats;
let error;
try {
stats = fs.statSync(ecsDir);
} catch (err) {
error = err;
}
if (error || !stats.isDirectory()) {
logError(
`directory not found: ${ecsDir} - did you checkout elastic/ecs as a peer of this repo?`
);
}
return ecsDir;
}
function logError(message) {
console.log(`error: ${message}`);
process.exit(1);
}
const SchemaFileTemplate = `
/*
* 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.
*/
// ---------------------------------- WARNING ----------------------------------
// this file was generated, and should not be edited by hand
// ---------------------------------- WARNING ----------------------------------
// provides TypeScript and config-schema interfaces for ECS for use with
// the event log
import { schema, TypeOf } from '@kbn/config-schema';
type DeepWriteable<T> = { -readonly [P in keyof T]: DeepWriteable<T[P]> };
type DeepPartial<T> = {
[P in keyof T]?: T[P] extends Array<infer U> ? Array<DeepPartial<U>> : DeepPartial<T[P]>;
};
export const ECS_VERSION = '%%ECS_VERSION%%';
// types and config-schema describing the es structures
export type IValidatedEvent = TypeOf<typeof EventSchema>;
export type IEvent = DeepPartial<DeepWriteable<IValidatedEvent>>;
export const EventSchema = %%SCHEMA%%;
function ecsStringMulti() {
return schema.maybe(schema.arrayOf(schema.string()));
}
function ecsString() {
return schema.maybe(schema.string());
}
function ecsNumber() {
return schema.maybe(schema.number());
}
function ecsDate() {
return schema.maybe(schema.string({ validate: validateDate }));
}
const ISO_DATE_PATTERN = /^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{3}Z$/;
function validateDate(isoDate: string) {
if (ISO_DATE_PATTERN.test(isoDate)) return;
return 'string is not a valid ISO date: ' + isoDate;
}
`.trim();
function getSchemaFileContents(ecsVersion, schemaLines) {
return SchemaFileTemplate.replace('%%ECS_VERSION%%', ecsVersion).replace(
'%%SCHEMA%%',
schemaLines
);
// .replace('%%INTERFACE%%', interfaceLines);
}
// run as a command-line script
if (require.main === module) main();

View file

@ -0,0 +1,39 @@
/*
* 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.
*/
const INDENT_LENGTH = 2;
const INDENT = ''.padStart(INDENT_LENGTH);
module.exports = {
createLineWriter,
};
class LineWriter {
constructor() {
this._indent = '';
this._lines = [];
}
addLine(line) {
this._lines.push(`${this._indent}${line}`);
}
indent() {
this._indent = `${this._indent}${INDENT}`;
}
dedent() {
this._indent = this._indent.substr(INDENT_LENGTH);
}
getContent() {
return this._lines.join('\n');
}
}
function createLineWriter() {
return new LineWriter();
}

View file

@ -0,0 +1,63 @@
/*
* 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.
*/
exports.EcsKibanaExtensionsMappings = {
properties: {
// kibana server uuid
server_uuid: {
type: 'keyword',
ignore_above: 1024,
},
// relevant kibana space
namespace: {
type: 'keyword',
ignore_above: 1024,
},
// array of saved object references, for "linking" via search
saved_objects: {
type: 'nested',
properties: {
// 'kibana' for typical saved object, 'task_manager' for TM, etc
store: {
type: 'keyword',
ignore_above: 1024,
},
id: {
type: 'keyword',
ignore_above: 1024,
},
type: {
type: 'keyword',
ignore_above: 1024,
},
},
},
},
};
// ECS and Kibana ECS extension properties to generate
exports.EcsEventLogProperties = [
'@timestamp',
'tags',
'message',
'ecs.version',
'event.action',
'event.provider',
'event.start',
'event.duration',
'event.end',
'error.message',
'user.name',
'kibana.server_uuid',
'kibana.namespace',
'kibana.saved_objects.store',
'kibana.saved_objects.id',
'kibana.saved_objects.name',
'kibana.saved_objects.type',
];
// properties that can have multiple values (array vs single value)
exports.EcsEventLogMultiValuedProperties = ['tags'];

View file

@ -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 { Logger, ClusterClient } from '../../../../../src/core/server';
import { EsContext } from './context';
import { EsNames } from './names';
export type EsClusterClient = Pick<ClusterClient, 'callAsInternalUser' | 'asScoped'>;
export interface EsError {
readonly statusCode: number;
readonly message: string;
}
interface CreateMockEsContextParams {
logger: Logger;
esNames: EsNames;
}
export function createMockEsContext(params: CreateMockEsContextParams): EsContext {
return new EsContextMock(params);
}
class EsContextMock implements EsContext {
public logger: Logger;
public esNames: EsNames;
constructor(params: CreateMockEsContextParams) {
this.logger = params.logger;
this.esNames = params.esNames;
}
initialize() {}
async waitTillReady(): Promise<boolean> {
return true;
}
async callEs(operation: string, body?: any): Promise<any> {
return {};
}
}

View file

@ -0,0 +1,99 @@
/*
* 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 { Logger, ClusterClient } from 'src/core/server';
import { EsNames, getEsNames } from './names';
import { initializeEs } from './init';
import { createReadySignal, ReadySignal } from '../lib/ready_signal';
export type EsClusterClient = Pick<ClusterClient, 'callAsInternalUser' | 'asScoped'>;
export interface EsContext {
logger: Logger;
esNames: EsNames;
initialize(): void;
waitTillReady(): Promise<boolean>;
callEs(operation: string, body?: any): Promise<any>;
}
export interface EsError {
readonly statusCode: number;
readonly message: string;
}
export function createEsContext(params: EsContextCtorParams): EsContext {
return new EsContextImpl(params);
}
export interface EsContextCtorParams {
logger: Logger;
clusterClient: EsClusterClient;
indexNameRoot: string;
}
class EsContextImpl implements EsContext {
public readonly logger: Logger;
public readonly esNames: EsNames;
private readonly clusterClient: EsClusterClient;
private readonly readySignal: ReadySignal<boolean>;
private initialized: boolean;
constructor(params: EsContextCtorParams) {
this.logger = params.logger;
this.esNames = getEsNames(params.indexNameRoot);
this.clusterClient = params.clusterClient;
this.readySignal = createReadySignal();
this.initialized = false;
}
initialize() {
// only run the initialization method once
if (this.initialized) return;
this.initialized = true;
this.logger.debug('initializing EsContext');
setImmediate(async () => {
try {
await this._initialize();
this.logger.debug('readySignal.signal(true)');
this.readySignal.signal(true);
} catch (err) {
this.logger.debug('readySignal.signal(false)');
this.readySignal.signal(false);
}
});
}
async waitTillReady(): Promise<boolean> {
return await this.readySignal.wait();
}
async callEs(operation: string, body?: any): Promise<any> {
try {
this.debug(`callEs(${operation}) calls:`, body);
const result = await this.clusterClient.callAsInternalUser(operation, body);
this.debug(`callEs(${operation}) result:`, result);
return result;
} catch (err) {
this.debug(`callEs(${operation}) error:`, {
message: err.message,
statusCode: err.statusCode,
});
throw err;
}
}
private async _initialize() {
await initializeEs(this);
}
private debug(message: string, object?: any) {
const objectString = object == null ? '' : JSON.stringify(object);
this.logger.debug(`esContext: ${message} ${objectString}`);
}
}

View file

@ -0,0 +1,43 @@
/*
* 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 { getIndexTemplate, getIlmPolicy } from './documents';
import { getEsNames } from './names';
describe('getIlmPolicy()', () => {
test('returns the basic structure of an ilm policy', () => {
expect(getIlmPolicy()).toMatchObject({
policy: {
phases: {},
},
});
});
});
describe('getIndexTemplate()', () => {
const esNames = getEsNames('XYZ');
test('returns the correct details of the index template', () => {
const indexTemplate = getIndexTemplate(esNames, true);
expect(indexTemplate.index_patterns).toEqual([esNames.indexPattern]);
expect(indexTemplate.aliases[esNames.alias]).toEqual({});
expect(indexTemplate.settings.number_of_shards).toBeGreaterThanOrEqual(0);
expect(indexTemplate.settings.number_of_replicas).toBeGreaterThanOrEqual(0);
expect(indexTemplate.mappings).toMatchObject({});
});
test('returns correct index template bits for ilm when ilm is supported', () => {
const indexTemplate = getIndexTemplate(esNames, true);
expect(indexTemplate.settings['index.lifecycle.name']).toBe(esNames.ilmPolicy);
expect(indexTemplate.settings['index.lifecycle.rollover_alias']).toBe(esNames.alias);
});
test('returns correct index template bits for ilm when ilm is not supported', () => {
const indexTemplate = getIndexTemplate(esNames, false);
expect(indexTemplate.settings['index.lifecycle.name']).toBeUndefined();
expect(indexTemplate.settings['index.lifecycle.rollover_alias']).toBeUndefined();
});
});

View file

@ -0,0 +1,51 @@
/*
* 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 { EsNames } from './names';
import mappings from '../../generated/mappings.json';
// returns the body of an index template used in an ES indices.putTemplate call
export function getIndexTemplate(esNames: EsNames, ilmExists: boolean) {
const indexTemplateBody: any = {
index_patterns: [esNames.indexPattern],
aliases: {
[esNames.alias]: {},
},
settings: {
number_of_shards: 1,
number_of_replicas: 1,
'index.lifecycle.name': esNames.ilmPolicy,
'index.lifecycle.rollover_alias': esNames.alias,
},
mappings,
};
if (!ilmExists) {
delete indexTemplateBody.settings['index.lifecycle.name'];
delete indexTemplateBody.settings['index.lifecycle.rollover_alias'];
}
return indexTemplateBody;
}
// returns the body of an ilm policy used in an ES PUT _ilm/policy call
export function getIlmPolicy() {
return {
policy: {
phases: {
hot: {
actions: {
rollover: {
max_size: '5GB',
max_age: '30d',
// max_docs: 1, // you know, for testing
},
},
},
},
},
};
}

View file

@ -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 { EsClusterClient, EsContext, createEsContext } from './context';

View file

@ -0,0 +1,137 @@
/*
* 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 { getIlmPolicy, getIndexTemplate } from './documents';
import { EsContext } from './context';
export async function initializeEs(esContext: EsContext): Promise<boolean> {
esContext.logger.debug('initializing elasticsearch resources starting');
try {
await initializeEsResources(esContext);
} catch (err) {
esContext.logger.error(`error initializing elasticsearch resources: ${err.message}`);
return false;
}
esContext.logger.debug('initializing elasticsearch resources complete');
return true;
}
async function initializeEsResources(esContext: EsContext) {
const steps = new EsInitializationSteps(esContext);
let ilmExists: boolean;
// create the ilm policy, if required
ilmExists = await steps.doesIlmPolicyExist();
if (!ilmExists) {
ilmExists = await steps.createIlmPolicy();
}
if (!(await steps.doesIndexTemplateExist())) {
await steps.createIndexTemplate({ ilmExists });
}
if (!(await steps.doesInitialIndexExist())) {
await steps.createInitialIndex();
}
}
interface AddTemplateOpts {
ilmExists: boolean;
}
class EsInitializationSteps {
constructor(private readonly esContext: EsContext) {
this.esContext = esContext;
}
async doesIlmPolicyExist(): Promise<boolean> {
const request = {
method: 'GET',
path: `_ilm/policy/${this.esContext.esNames.ilmPolicy}`,
};
try {
await this.esContext.callEs('transport.request', request);
} catch (err) {
if (err.statusCode === 404) return false;
// TODO: remove following once kibana user can access ilm
if (err.statusCode === 403) return false;
throw new Error(`error checking existance of ilm policy: ${err.message}`);
}
return true;
}
async createIlmPolicy(): Promise<boolean> {
const request = {
method: 'PUT',
path: `_ilm/policy/${this.esContext.esNames.ilmPolicy}`,
body: getIlmPolicy(),
};
try {
await this.esContext.callEs('transport.request', request);
} catch (err) {
// TODO: remove following once kibana user can access ilm
if (err.statusCode === 403) return false;
throw new Error(`error creating ilm policy: ${err.message}`);
}
return true;
}
async doesIndexTemplateExist(): Promise<boolean> {
const name = this.esContext.esNames.indexTemplate;
let result;
try {
result = await this.esContext.callEs('indices.existsTemplate', { name });
} catch (err) {
throw new Error(`error checking existance of index template: ${err.message}`);
}
return result as boolean;
}
async createIndexTemplate(opts: AddTemplateOpts): Promise<void> {
const templateBody = getIndexTemplate(this.esContext.esNames, opts.ilmExists);
const addTemplateParams = {
create: true,
name: this.esContext.esNames.indexTemplate,
body: templateBody,
};
try {
await this.esContext.callEs('indices.putTemplate', addTemplateParams);
} catch (err) {
throw new Error(`error creating index template: ${err.message}`);
}
}
async doesInitialIndexExist(): Promise<boolean> {
const name = this.esContext.esNames.alias;
let result;
try {
result = await this.esContext.callEs('indices.existsAlias', { name });
} catch (err) {
throw new Error(`error checking existance of initial index: ${err.message}`);
}
return result as boolean;
}
async createInitialIndex(): Promise<void> {
const index = this.esContext.esNames.initialIndex;
try {
await this.esContext.callEs('indices.create', { index });
} catch (err) {
throw new Error(`error creating initial index: ${err.message}`);
}
}
debug(message: string) {
this.esContext.logger.debug(message);
}
warn(message: string) {
this.esContext.logger.warn(message);
}
}

View file

@ -0,0 +1,20 @@
/*
* 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 { getEsNames } from './names';
describe('getEsNames()', () => {
test('works as expected', () => {
const base = 'XYZ';
const esNames = getEsNames(base);
expect(esNames.base).toEqual(base);
expect(esNames.alias).toEqual(`${base}-event-log`);
expect(esNames.ilmPolicy).toEqual(`${base}-event-log-policy`);
expect(esNames.indexPattern).toEqual(`${base}-event-log-*`);
expect(esNames.initialIndex).toEqual(`${base}-event-log-000001`);
expect(esNames.indexTemplate).toEqual(`${base}-event-log-template`);
});
});

View file

@ -0,0 +1,28 @@
/*
* 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.
*/
const EVENT_LOG_NAME_SUFFIX = '-event-log';
export interface EsNames {
base: string;
alias: string;
ilmPolicy: string;
indexPattern: string;
initialIndex: string;
indexTemplate: string;
}
export function getEsNames(baseName: string): EsNames {
const eventLogName = `${baseName}${EVENT_LOG_NAME_SUFFIX}`;
return {
base: baseName,
alias: eventLogName,
ilmPolicy: `${eventLogName}-policy`,
indexPattern: `${eventLogName}-*`,
initialIndex: `${eventLogName}-000001`,
indexTemplate: `${eventLogName}-template`,
};
}

View file

@ -0,0 +1,116 @@
/*
* 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 { IEventLogConfig } from './types';
import { EventLogService } from './event_log_service';
import { getEsNames } from './es/names';
import { createMockEsContext } from './es/context.mock';
import { loggingServiceMock } from '../../../../src/core/server/logging/logging_service.mock';
const loggingService = loggingServiceMock.create();
const systemLogger = loggingService.get();
describe('EventLogService', () => {
const esContext = createMockEsContext({
esNames: getEsNames('ABC'),
logger: systemLogger,
});
function getService(config: IEventLogConfig) {
const { enabled, logEntries, indexEntries } = config;
return new EventLogService({
esContext,
systemLogger,
config: {
enabled,
logEntries,
indexEntries,
},
});
}
test('returns config values from service methods', () => {
let service;
service = getService({ enabled: true, logEntries: true, indexEntries: true });
expect(service.isEnabled()).toEqual(true);
expect(service.isLoggingEntries()).toEqual(true);
expect(service.isIndexingEntries()).toEqual(true);
service = getService({ enabled: true, logEntries: false, indexEntries: true });
expect(service.isEnabled()).toEqual(true);
expect(service.isLoggingEntries()).toEqual(false);
expect(service.isIndexingEntries()).toEqual(true);
service = getService({ enabled: true, logEntries: true, indexEntries: false });
expect(service.isEnabled()).toEqual(true);
expect(service.isLoggingEntries()).toEqual(true);
expect(service.isIndexingEntries()).toEqual(false);
service = getService({ enabled: true, logEntries: false, indexEntries: false });
expect(service.isEnabled()).toEqual(true);
expect(service.isLoggingEntries()).toEqual(false);
expect(service.isIndexingEntries()).toEqual(false);
// this is the only non-obvious one; when enabled is false,
// logging/indexing will be false as well.
service = getService({ enabled: false, logEntries: true, indexEntries: true });
expect(service.isEnabled()).toEqual(false);
expect(service.isLoggingEntries()).toEqual(false);
expect(service.isIndexingEntries()).toEqual(false);
});
test('handles registering provider actions correctly', () => {
const params = {
esContext,
systemLogger,
config: {
enabled: true,
logEntries: true,
indexEntries: true,
},
};
const service = new EventLogService(params);
let providerActions: ReturnType<EventLogService['getProviderActions']>;
providerActions = service.getProviderActions();
expect(providerActions.size).toEqual(0);
service.registerProviderActions('foo', ['foo-1', 'foo-2']);
providerActions = service.getProviderActions();
expect(providerActions.size).toEqual(1);
expect(providerActions.get('foo')).toEqual(new Set(['foo-1', 'foo-2']));
expect(() => {
service.registerProviderActions('invalid', []);
}).toThrow('actions parameter must not be empty for provider: "invalid"');
expect(() => {
service.registerProviderActions('foo', ['abc']);
}).toThrow('provider already registered: "foo"');
expect(providerActions.get('foo')!.size).toEqual(2);
expect(service.isProviderActionRegistered('foo', 'foo-1')).toEqual(true);
expect(service.isProviderActionRegistered('foo', 'foo-2')).toEqual(true);
expect(service.isProviderActionRegistered('foo', 'foo-3')).toEqual(false);
expect(service.isProviderActionRegistered('invalid', 'foo')).toEqual(false);
});
test('returns a non-null logger from getLogger()', () => {
const params = {
esContext,
systemLogger,
config: {
enabled: true,
logEntries: true,
indexEntries: true,
},
};
const service = new EventLogService(params);
const eventLogger = service.getLogger({});
expect(eventLogger).toBeTruthy();
});
});

View file

@ -0,0 +1,85 @@
/*
* 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 _ from 'lodash';
import { Observable } from 'rxjs';
import { ClusterClient } from 'src/core/server';
import { Plugin } from './plugin';
import { EsContext } from './es';
import { IEvent, IEventLogger, IEventLogService, IEventLogConfig } from './types';
import { EventLogger } from './event_logger';
export type PluginClusterClient = Pick<ClusterClient, 'callAsInternalUser' | 'asScoped'>;
export type AdminClusterClient$ = Observable<PluginClusterClient>;
type SystemLogger = Plugin['systemLogger'];
interface EventLogServiceCtorParams {
config: IEventLogConfig;
esContext: EsContext;
systemLogger: SystemLogger;
}
// note that clusterClient may be null, indicating we can't write to ES
export class EventLogService implements IEventLogService {
private config: IEventLogConfig;
private systemLogger: SystemLogger;
private esContext: EsContext;
private registeredProviderActions: Map<string, Set<string>>;
constructor({ config, systemLogger, esContext }: EventLogServiceCtorParams) {
this.config = config;
this.esContext = esContext;
this.systemLogger = systemLogger;
this.registeredProviderActions = new Map<string, Set<string>>();
}
public isEnabled(): boolean {
return this.config.enabled;
}
public isLoggingEntries(): boolean {
return this.isEnabled() && this.config.logEntries;
}
public isIndexingEntries(): boolean {
return this.isEnabled() && this.config.indexEntries;
}
registerProviderActions(provider: string, actions: string[]): void {
if (actions.length === 0) {
throw new Error(`actions parameter must not be empty for provider: "${provider}"`);
}
if (this.registeredProviderActions.has(provider)) {
throw new Error(`provider already registered: "${provider}"`);
}
this.registeredProviderActions.set(provider, new Set(actions));
}
isProviderActionRegistered(provider: string, action: string): boolean {
const actions = this.registeredProviderActions.get(provider);
if (actions == null) return false;
if (actions.has(action)) return true;
return false;
}
getProviderActions() {
return new Map(this.registeredProviderActions.entries());
}
getLogger(initialProperties: IEvent): IEventLogger {
return new EventLogger({
esContext: this.esContext,
eventLogService: this,
initialProperties,
systemLogger: this.systemLogger,
});
}
}

View file

@ -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 { IEvent, IEventLogger } from './types';
export function createEventLoggerMock(): IEventLogger {
return {
logEvent(eventProperties: IEvent): void {},
startTiming(event: IEvent): void {},
stopTiming(event: IEvent): void {},
};
}

View file

@ -0,0 +1,75 @@
/*
* 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 { IEvent, IEventLogger } from './index';
import { EventLogService } from './event_log_service';
import { getEsNames } from './es/names';
import { createMockEsContext } from './es/context.mock';
import { loggingServiceMock } from '../../../../src/core/server/logging/logging_service.mock';
import { delay } from './lib/delay';
const loggingService = loggingServiceMock.create();
const systemLogger = loggingService.get();
describe('EventLogger', () => {
const esContext = createMockEsContext({ esNames: getEsNames('ABC'), logger: systemLogger });
const config = { enabled: true, logEntries: true, indexEntries: true };
const service = new EventLogService({ esContext, systemLogger, config });
let eventLogger: IEventLogger;
beforeEach(() => {
eventLogger = service.getLogger({});
});
test('logEvent()', () => {
service.registerProviderActions('test-provider', ['test-action-1']);
const initialProperties = {
event: { provider: 'test-provider' },
};
eventLogger = service.getLogger(initialProperties);
// ATM, just make sure it doesn't blow up
eventLogger.logEvent({});
});
test('timing', async () => {
const event: IEvent = {};
eventLogger.startTiming(event);
const timeStart = event.event!.start!;
expect(timeStart).toBeTruthy();
expect(new Date(timeStart)).toBeTruthy();
await delay(100);
eventLogger.stopTiming(event);
const timeStop = event.event!.end!;
expect(timeStop).toBeTruthy();
expect(new Date(timeStop)).toBeTruthy();
const duration = event.event!.duration!;
expect(duration).toBeGreaterThan(90 * 1000 * 1000);
});
test('timing - no start', async () => {
const event: IEvent = {};
eventLogger.stopTiming(event);
expect(event.event).toBeUndefined();
});
test('timing - bad start', async () => {
const event: IEvent = {
event: {
start: 'not a date that can be parsed',
},
};
eventLogger.stopTiming(event);
expect(event.event!.end).toBeUndefined();
expect(event.event!.duration).toBeUndefined();
});
});

View file

@ -0,0 +1,177 @@
/*
* 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 { Logger } from 'src/core/server';
import { merge } from 'lodash';
import { Plugin } from './plugin';
import { EsContext } from './es';
import {
IEvent,
IValidatedEvent,
IEventLogger,
IEventLogService,
ECS_VERSION,
EventSchema,
} from './types';
type SystemLogger = Plugin['systemLogger'];
interface Doc {
index: string;
body: IEvent;
}
interface IEventLoggerCtorParams {
esContext: EsContext;
eventLogService: IEventLogService;
initialProperties: IEvent;
systemLogger: SystemLogger;
}
export class EventLogger implements IEventLogger {
private esContext: EsContext;
private eventLogService: IEventLogService;
private initialProperties: IEvent;
private systemLogger: SystemLogger;
constructor(ctorParams: IEventLoggerCtorParams) {
this.esContext = ctorParams.esContext;
this.eventLogService = ctorParams.eventLogService;
this.initialProperties = ctorParams.initialProperties;
this.systemLogger = ctorParams.systemLogger;
}
startTiming(event: IEvent): void {
if (event == null) return;
event.event = event.event || {};
event.event.start = new Date().toISOString();
}
stopTiming(event: IEvent): void {
if (event?.event == null) return;
const start = getEventStart(event);
if (start == null || isNaN(start)) return;
const end = Date.now();
event.event.end = new Date(end).toISOString();
event.event.duration = (end - start) * 1000 * 1000; // nanoseconds
}
// non-blocking, but spawns an async task to do the work
logEvent(eventProperties: IEvent): void {
if (!this.eventLogService.isEnabled()) return;
const event: IEvent = {};
// merge the initial properties and event properties
merge(event, this.initialProperties, eventProperties);
// add fixed properties
event['@timestamp'] = new Date().toISOString();
event.ecs = event.ecs || {};
event.ecs.version = ECS_VERSION;
// TODO add kibana server uuid
// event.kibana.server_uuid = NP version of config.get('server.uuid');
let validatedEvent: IValidatedEvent;
try {
validatedEvent = validateEvent(this.eventLogService, event);
} catch (err) {
this.systemLogger.warn(`invalid event logged: ${err.message}`);
return;
}
const doc: Doc = {
index: this.esContext.esNames.alias,
body: validatedEvent,
};
if (this.eventLogService.isIndexingEntries()) {
indexEventDoc(this.esContext, doc);
}
if (this.eventLogService.isLoggingEntries()) {
logEventDoc(this.systemLogger, doc);
}
}
}
// return the epoch millis of the start date, or null; may be NaN if garbage
function getEventStart(event: IEvent): number | null {
if (event?.event?.start == null) return null;
return Date.parse(event.event.start);
}
const RequiredEventSchema = schema.object({
provider: schema.string({ minLength: 1 }),
action: schema.string({ minLength: 1 }),
});
function validateEvent(eventLogService: IEventLogService, event: IEvent): IValidatedEvent {
if (event?.event == null) {
throw new Error(`no "event" property`);
}
// ensure there are provider/action properties in event as strings
const requiredProps = {
provider: event.event.provider,
action: event.event.action,
};
// will throw an error if structure doesn't validate
const { provider, action } = RequiredEventSchema.validate(requiredProps);
if (!eventLogService.isProviderActionRegistered(provider, action)) {
throw new Error(`unregistered provider/action: "${provider}" / "${action}"`);
}
// could throw an error
return EventSchema.validate(event);
}
function logEventDoc(logger: Logger, doc: Doc): void {
setImmediate(() => {
logger.info(`event logged ${JSON.stringify(doc.body)}`);
});
}
function indexEventDoc(esContext: EsContext, doc: Doc): void {
// TODO:
// the setImmediate() on an async function is a little overkill, but,
// setImmediate() may be tweakable via node params, whereas async
// tweaking is in the v8 params realm, which is very dicey.
// Long-term, we should probably create an in-memory queue for this, so
// we can explictly see/set the queue lengths.
// already verified this.clusterClient isn't null above
setImmediate(async () => {
try {
await indexLogEventDoc(esContext, doc);
} catch (err) {
esContext.logger.warn(`error writing event doc: ${err.message}`);
writeLogEventDocOnError(esContext, doc);
}
});
}
// whew, the thing that actually writes the event log document!
async function indexLogEventDoc(esContext: EsContext, doc: any) {
esContext.logger.debug(`writing to event log: ${JSON.stringify(doc)}`);
await esContext.waitTillReady();
await esContext.callEs('index', doc);
esContext.logger.debug(`writing to event log complete`);
}
// TODO: write log entry to a bounded queue buffer
function writeLogEventDocOnError(esContext: EsContext, doc: any) {
esContext.logger.warn(`unable to write event doc: ${JSON.stringify(doc)}`);
}

View file

@ -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 { PluginInitializerContext } from 'src/core/server';
import { ConfigSchema } from './types';
import { Plugin } from './plugin';
export { IEventLogService, IEventLogger, IEvent } from './types';
export const config = { schema: ConfigSchema };
export const plugin = (context: PluginInitializerContext) => new Plugin(context);

View file

@ -0,0 +1,161 @@
/*
* 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 { createBoundedQueue } from './bounded_queue';
import { loggingServiceMock } from '../../../../../src/core/server/logging/logging_service.mock';
const loggingService = loggingServiceMock.create();
const logger = loggingService.get();
describe('basic', () => {
let discardedHelper: DiscardedHelper<number>;
let onDiscarded: (object: number) => void;
let queue2: ReturnType<typeof createBoundedQueue>;
let queue10: ReturnType<typeof createBoundedQueue>;
beforeAll(() => {
discardedHelper = new DiscardedHelper();
onDiscarded = discardedHelper.onDiscarded.bind(discardedHelper);
});
beforeEach(() => {
queue2 = createBoundedQueue<number>({ logger, maxLength: 2, onDiscarded });
queue10 = createBoundedQueue<number>({ logger, maxLength: 10, onDiscarded });
});
test('queued items: 0', () => {
discardedHelper.reset();
expect(queue2.isEmpty()).toEqual(true);
expect(queue2.isFull()).toEqual(false);
expect(queue2.isCloseToFull()).toEqual(false);
expect(queue2.length).toEqual(0);
expect(queue2.maxLength).toEqual(2);
expect(queue2.pull(1)).toEqual([]);
expect(queue2.pull(100)).toEqual([]);
expect(discardedHelper.discarded).toEqual([]);
});
test('queued items: 1', () => {
discardedHelper.reset();
queue2.push(1);
expect(queue2.isEmpty()).toEqual(false);
expect(queue2.isFull()).toEqual(false);
expect(queue2.isCloseToFull()).toEqual(false);
expect(queue2.length).toEqual(1);
expect(queue2.maxLength).toEqual(2);
expect(queue2.pull(1)).toEqual([1]);
expect(queue2.pull(1)).toEqual([]);
expect(discardedHelper.discarded).toEqual([]);
});
test('queued items: 2', () => {
discardedHelper.reset();
queue2.push(1);
queue2.push(2);
expect(queue2.isEmpty()).toEqual(false);
expect(queue2.isFull()).toEqual(true);
expect(queue2.isCloseToFull()).toEqual(true);
expect(queue2.length).toEqual(2);
expect(queue2.maxLength).toEqual(2);
expect(queue2.pull(1)).toEqual([1]);
expect(queue2.pull(1)).toEqual([2]);
expect(queue2.pull(1)).toEqual([]);
expect(discardedHelper.discarded).toEqual([]);
});
test('queued items: 3', () => {
discardedHelper.reset();
queue2.push(1);
queue2.push(2);
queue2.push(3);
expect(queue2.isEmpty()).toEqual(false);
expect(queue2.isFull()).toEqual(true);
expect(queue2.isCloseToFull()).toEqual(true);
expect(queue2.length).toEqual(2);
expect(queue2.maxLength).toEqual(2);
expect(queue2.pull(1)).toEqual([2]);
expect(queue2.pull(1)).toEqual([3]);
expect(queue2.pull(1)).toEqual([]);
expect(discardedHelper.discarded).toEqual([1]);
});
test('closeToFull()', () => {
discardedHelper.reset();
expect(queue10.isCloseToFull()).toEqual(false);
for (let i = 1; i <= 8; i++) {
queue10.push(i);
expect(queue10.isCloseToFull()).toEqual(false);
}
queue10.push(9);
expect(queue10.isCloseToFull()).toEqual(true);
queue10.push(10);
expect(queue10.isCloseToFull()).toEqual(true);
queue10.pull(2);
expect(queue10.isCloseToFull()).toEqual(false);
queue10.push(11);
expect(queue10.isCloseToFull()).toEqual(true);
});
test('discarded', () => {
discardedHelper.reset();
queue2.push(1);
queue2.push(2);
queue2.push(3);
expect(discardedHelper.discarded).toEqual([1]);
discardedHelper.reset();
queue2.push(4);
queue2.push(5);
expect(discardedHelper.discarded).toEqual([2, 3]);
});
test('pull', () => {
discardedHelper.reset();
expect(queue10.pull(4)).toEqual([]);
for (let i = 1; i <= 10; i++) {
queue10.push(i);
}
expect(queue10.pull(4)).toEqual([1, 2, 3, 4]);
expect(queue10.length).toEqual(6);
expect(queue10.pull(4)).toEqual([5, 6, 7, 8]);
expect(queue10.length).toEqual(2);
expect(queue10.pull(4)).toEqual([9, 10]);
expect(queue10.length).toEqual(0);
expect(queue10.pull(1)).toEqual([]);
expect(queue10.pull(4)).toEqual([]);
});
});
class DiscardedHelper<T> {
private _discarded: T[];
constructor() {
this.reset();
this._discarded = [];
this.onDiscarded = this.onDiscarded.bind(this);
}
onDiscarded(object: T) {
this._discarded.push(object);
}
public get discarded(): T[] {
return this._discarded;
}
reset() {
this._discarded = [];
}
}

View file

@ -0,0 +1,91 @@
/*
* 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 { Plugin } from '../plugin';
const CLOSE_TO_FULL_PERCENT = 0.9;
type SystemLogger = Plugin['systemLogger'];
export interface IBoundedQueue<T> {
maxLength: number;
length: number;
push(object: T): void;
pull(count: number): T[];
isEmpty(): boolean;
isFull(): boolean;
isCloseToFull(): boolean;
}
export interface CreateBoundedQueueParams<T> {
maxLength: number;
onDiscarded(object: T): void;
logger: SystemLogger;
}
export function createBoundedQueue<T>(params: CreateBoundedQueueParams<T>): IBoundedQueue<T> {
if (params.maxLength <= 0) throw new Error(`invalid bounded queue maxLength ${params.maxLength}`);
return new BoundedQueue<T>(params);
}
class BoundedQueue<T> implements IBoundedQueue<T> {
private _maxLength: number;
private _buffer: T[];
private _onDiscarded: (object: T) => void;
private _logger: SystemLogger;
constructor(params: CreateBoundedQueueParams<T>) {
this._maxLength = params.maxLength;
this._buffer = [];
this._onDiscarded = params.onDiscarded;
this._logger = params.logger;
}
public get maxLength(): number {
return this._maxLength;
}
public get length(): number {
return this._buffer.length;
}
isEmpty() {
return this._buffer.length === 0;
}
isFull() {
return this._buffer.length >= this._maxLength;
}
isCloseToFull() {
return this._buffer.length / this._maxLength >= CLOSE_TO_FULL_PERCENT;
}
push(object: T) {
this.ensureRoom();
this._buffer.push(object);
}
pull(count: number) {
if (count <= 0) throw new Error(`invalid pull count ${count}`);
return this._buffer.splice(0, count);
}
private ensureRoom() {
if (this.length < this._maxLength) return;
const discarded = this.pull(this.length - this._maxLength + 1);
for (const object of discarded) {
try {
this._onDiscarded(object!);
} catch (err) {
this._logger.warn(`error discarding circular buffer entry: ${err.message}`);
}
}
}
}

View file

@ -0,0 +1,21 @@
/*
* 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 { delay } from './delay';
const TEST_DELAY = 100;
describe('delay', () => {
test('works as expected', async () => {
const timeStart = Date.now();
await delay(TEST_DELAY);
// note: testing with .toBeGreaterThanOrEqual(TEST_DELAY) is flaky,
// sometimes the actual value is TEST_DELAY - 1, so ... using that as the
// value to test against; something funky with time rounding I'd guess.
expect(Date.now() - timeStart).toBeGreaterThanOrEqual(TEST_DELAY - 1);
});
});

View file

@ -0,0 +1,9 @@
/*
* 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 async function delay(millis: number) {
await new Promise(resolve => setTimeout(resolve, millis));
}

View file

@ -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 { createReadySignal, ReadySignal } from './ready_signal';
describe('ReadySignal', () => {
let readySignal: ReadySignal<number>;
beforeEach(() => {
readySignal = createReadySignal<number>();
});
test('works as expected', async done => {
let value = 41;
timeoutSet(100, () => {
expect(value).toBe(41);
});
timeoutSet(250, () => readySignal.signal(42));
timeoutSet(400, async () => {
expect(value).toBe(42);
const innerValue = await readySignal.wait();
expect(innerValue).toBe(42);
done();
});
value = await readySignal.wait();
expect(value).toBe(42);
});
});
function timeoutSet(ms: number, fn: any) {
setTimeout(fn, ms);
}

View file

@ -0,0 +1,28 @@
/*
* 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 interface ReadySignal<T> {
wait(): Promise<T>;
signal(value: T): void;
}
export function createReadySignal<T>(): ReadySignal<T> {
let resolver: (value: T) => void;
const promise = new Promise<T>(resolve => {
resolver = resolve;
});
async function wait(): Promise<T> {
return await promise;
}
function signal(value: T) {
resolver(value);
}
return { wait, signal };
}

View file

@ -0,0 +1,102 @@
/*
* 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 { first } from 'rxjs/operators';
import {
CoreSetup,
CoreStart,
Logger,
Plugin as CorePlugin,
PluginInitializerContext,
ClusterClient,
} from 'src/core/server';
import { IEventLogConfig, IEventLogService, IEventLogger, IEventLogConfig$ } from './types';
import { EventLogService } from './event_log_service';
import { createEsContext, EsContext } from './es';
export type PluginClusterClient = Pick<ClusterClient, 'callAsInternalUser' | 'asScoped'>;
// TODO - figure out how to get ${kibana.index} for `.kibana`
const KIBANA_INDEX = '.kibana';
const PROVIDER = 'event_log';
const ACTIONS = {
starting: 'starting',
stopping: 'stopping',
};
export class Plugin implements CorePlugin<IEventLogService> {
private readonly config$: IEventLogConfig$;
private systemLogger: Logger;
private eventLogService?: IEventLogService;
private esContext?: EsContext;
private eventLogger?: IEventLogger;
constructor(private readonly context: PluginInitializerContext) {
this.systemLogger = this.context.logger.get();
this.config$ = this.context.config.create<IEventLogConfig>();
}
async setup(core: CoreSetup): Promise<IEventLogService> {
this.systemLogger.debug('setting up plugin');
const config = await this.config$.pipe(first()).toPromise();
this.esContext = createEsContext({
logger: this.systemLogger,
// TODO: get index prefix from config.get(kibana.index)
indexNameRoot: KIBANA_INDEX,
clusterClient: core.elasticsearch.adminClient,
});
this.eventLogService = new EventLogService({
config,
esContext: this.esContext,
systemLogger: this.systemLogger,
});
this.eventLogService.registerProviderActions(PROVIDER, Object.values(ACTIONS));
this.eventLogger = this.eventLogService.getLogger({
event: { provider: PROVIDER },
});
return this.eventLogService;
}
async start(core: CoreStart) {
this.systemLogger.debug('starting plugin');
if (!this.esContext) throw new Error('esContext not initialized');
if (!this.eventLogger) throw new Error('eventLogger not initialized');
if (!this.eventLogService) throw new Error('eventLogService not initialized');
// launches initialization async
if (this.eventLogService.isIndexingEntries()) {
this.esContext.initialize();
}
// will log the event after initialization
this.eventLogger.logEvent({
event: { action: ACTIONS.starting },
message: 'event_log starting',
});
}
stop() {
this.systemLogger.debug('stopping plugin');
if (!this.eventLogger) throw new Error('eventLogger not initialized');
// note that it's unlikely this event would ever be written,
// when Kibana is actuaelly stopping, as it's written asynchronously
this.eventLogger.logEvent({
event: { action: ACTIONS.stopping },
message: 'event_log stopping',
});
}
}

View file

@ -0,0 +1,38 @@
/*
* 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 { Observable } from 'rxjs';
import { schema, TypeOf } from '@kbn/config-schema';
export { IEvent, IValidatedEvent, EventSchema, ECS_VERSION } from '../generated/schemas';
import { IEvent } from '../generated/schemas';
export const ConfigSchema = schema.object({
enabled: schema.boolean({ defaultValue: true }),
logEntries: schema.boolean({ defaultValue: false }),
indexEntries: schema.boolean({ defaultValue: false }),
});
export type IEventLogConfig = TypeOf<typeof ConfigSchema>;
export type IEventLogConfig$ = Observable<Readonly<IEventLogConfig>>;
// the object exposed by plugin.setup()
export interface IEventLogService {
isEnabled(): boolean;
isLoggingEntries(): boolean;
isIndexingEntries(): boolean;
registerProviderActions(provider: string, actions: string[]): void;
isProviderActionRegistered(provider: string, action: string): boolean;
getProviderActions(): Map<string, Set<string>>;
getLogger(properties: IEvent): IEventLogger;
}
export interface IEventLogger {
logEvent(properties: IEvent): void;
startTiming(event: IEvent): void;
stopTiming(event: IEvent): void;
}

View file

@ -75,6 +75,7 @@ export function createTestConfig(name: string, options: CreateTestConfigOptions)
])}`,
`--xpack.actions.enabledActionTypes=${JSON.stringify(enabledActionTypes)}`,
'--xpack.alerting.enabled=true',
'--xpack.event_log.logEntries=true',
...disabledPlugins.map(key => `--xpack.${key}.enabled=false`),
`--plugin-path=${path.join(__dirname, 'fixtures', 'plugins', 'alerts')}`,
`--plugin-path=${path.join(__dirname, 'fixtures', 'plugins', 'actions')}`,

View file

@ -63,7 +63,7 @@ export default function(kibana: any) {
}),
},
async executor({ config, secrets, params, services }: ActionTypeExecutorOptions) {
return await services.callCluster('index', {
await services.callCluster('index', {
index: params.index,
refresh: 'wait_for',
body: {
@ -97,7 +97,7 @@ export default function(kibana: any) {
source: 'action:test.failing',
},
});
throw new Error('Failed to execute action type');
throw new Error(`expected failure for ${params.index} ${params.reference}`);
},
};
const rateLimitedActionType: ActionType = {

View file

@ -88,6 +88,38 @@ export default function({ getService }: FtrProviderContext) {
});
});
it('should handle failed executions', async () => {
const { body: createdAction } = await supertest
.post(`${getUrlPrefix(Spaces.space1.id)}/api/action`)
.set('kbn-xsrf', 'foo')
.send({
name: 'failing action',
actionTypeId: 'test.failing',
})
.expect(200);
objectRemover.add(Spaces.space1.id, createdAction.id, 'action');
const reference = `actions-failure-1:${Spaces.space1.id}`;
const response = await supertest
.post(`${getUrlPrefix(Spaces.space1.id)}/api/action/${createdAction.id}/_execute`)
.set('kbn-xsrf', 'foo')
.send({
params: {
reference,
index: ES_TEST_INDEX_NAME,
},
});
expect(response.statusCode).to.eql(200);
expect(response.body).to.eql({
actionId: createdAction.id,
status: 'error',
message: 'an error occurred while running the action executor',
serviceMessage: `expected failure for ${ES_TEST_INDEX_NAME} ${reference}`,
retry: false,
});
});
it(`shouldn't execute an action from another space`, async () => {
const { body: createdAction } = await supertest
.post(`${getUrlPrefix(Spaces.space1.id)}/api/action`)