mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 01:38:56 -04:00
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:
parent
db1a64da76
commit
b78c1b1042
40 changed files with 2444 additions and 17 deletions
1
.github/CODEOWNERS
vendored
1
.github/CODEOWNERS
vendored
|
@ -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
|
||||
|
|
|
@ -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());
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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 = {
|
||||
|
|
|
@ -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;
|
||||
|
|
299
x-pack/plugins/event_log/README.md
Normal file
299
x-pack/plugins/event_log/README.md
Normal 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
|
||||
|
4
x-pack/plugins/event_log/generated/README.md
Normal file
4
x-pack/plugins/event_log/generated/README.md
Normal 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.
|
96
x-pack/plugins/event_log/generated/mappings.json
Normal file
96
x-pack/plugins/event_log/generated/mappings.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
95
x-pack/plugins/event_log/generated/schemas.ts
Normal file
95
x-pack/plugins/event_log/generated/schemas.ts
Normal 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;
|
||||
}
|
8
x-pack/plugins/event_log/kibana.json
Normal file
8
x-pack/plugins/event_log/kibana.json
Normal file
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"id": "event_log",
|
||||
"version": "0.0.1",
|
||||
"kibanaVersion": "kibana",
|
||||
"configPath": ["xpack", "event_log"],
|
||||
"server": true,
|
||||
"ui": false
|
||||
}
|
322
x-pack/plugins/event_log/scripts/create_schemas.js
Executable file
322
x-pack/plugins/event_log/scripts/create_schemas.js
Executable 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();
|
39
x-pack/plugins/event_log/scripts/lib/line_writer.js
Normal file
39
x-pack/plugins/event_log/scripts/lib/line_writer.js
Normal 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();
|
||||
}
|
63
x-pack/plugins/event_log/scripts/mappings.js
Normal file
63
x-pack/plugins/event_log/scripts/mappings.js
Normal 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'];
|
46
x-pack/plugins/event_log/server/es/context.mock.ts
Normal file
46
x-pack/plugins/event_log/server/es/context.mock.ts
Normal 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 {};
|
||||
}
|
||||
}
|
99
x-pack/plugins/event_log/server/es/context.ts
Normal file
99
x-pack/plugins/event_log/server/es/context.ts
Normal 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}`);
|
||||
}
|
||||
}
|
43
x-pack/plugins/event_log/server/es/documents.test.ts
Normal file
43
x-pack/plugins/event_log/server/es/documents.test.ts
Normal 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();
|
||||
});
|
||||
});
|
51
x-pack/plugins/event_log/server/es/documents.ts
Normal file
51
x-pack/plugins/event_log/server/es/documents.ts
Normal 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
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
7
x-pack/plugins/event_log/server/es/index.ts
Normal file
7
x-pack/plugins/event_log/server/es/index.ts
Normal 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';
|
137
x-pack/plugins/event_log/server/es/init.ts
Normal file
137
x-pack/plugins/event_log/server/es/init.ts
Normal 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);
|
||||
}
|
||||
}
|
20
x-pack/plugins/event_log/server/es/names.test.ts
Normal file
20
x-pack/plugins/event_log/server/es/names.test.ts
Normal 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`);
|
||||
});
|
||||
});
|
28
x-pack/plugins/event_log/server/es/names.ts
Normal file
28
x-pack/plugins/event_log/server/es/names.ts
Normal 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`,
|
||||
};
|
||||
}
|
116
x-pack/plugins/event_log/server/event_log_service.test.ts
Normal file
116
x-pack/plugins/event_log/server/event_log_service.test.ts
Normal 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();
|
||||
});
|
||||
});
|
85
x-pack/plugins/event_log/server/event_log_service.ts
Normal file
85
x-pack/plugins/event_log/server/event_log_service.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
}
|
15
x-pack/plugins/event_log/server/event_logger.mock.ts
Normal file
15
x-pack/plugins/event_log/server/event_logger.mock.ts
Normal 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 {},
|
||||
};
|
||||
}
|
75
x-pack/plugins/event_log/server/event_logger.test.ts
Normal file
75
x-pack/plugins/event_log/server/event_logger.test.ts
Normal 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();
|
||||
});
|
||||
});
|
177
x-pack/plugins/event_log/server/event_logger.ts
Normal file
177
x-pack/plugins/event_log/server/event_logger.ts
Normal 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)}`);
|
||||
}
|
13
x-pack/plugins/event_log/server/index.ts
Normal file
13
x-pack/plugins/event_log/server/index.ts
Normal 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);
|
161
x-pack/plugins/event_log/server/lib/bounded_queue.test.ts
Normal file
161
x-pack/plugins/event_log/server/lib/bounded_queue.test.ts
Normal 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 = [];
|
||||
}
|
||||
}
|
91
x-pack/plugins/event_log/server/lib/bounded_queue.ts
Normal file
91
x-pack/plugins/event_log/server/lib/bounded_queue.ts
Normal 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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
21
x-pack/plugins/event_log/server/lib/delay.test.ts
Normal file
21
x-pack/plugins/event_log/server/lib/delay.test.ts
Normal 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);
|
||||
});
|
||||
});
|
9
x-pack/plugins/event_log/server/lib/delay.ts
Normal file
9
x-pack/plugins/event_log/server/lib/delay.ts
Normal 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));
|
||||
}
|
40
x-pack/plugins/event_log/server/lib/ready_signal.test.ts
Normal file
40
x-pack/plugins/event_log/server/lib/ready_signal.test.ts
Normal 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);
|
||||
}
|
28
x-pack/plugins/event_log/server/lib/ready_signal.ts
Normal file
28
x-pack/plugins/event_log/server/lib/ready_signal.ts
Normal 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 };
|
||||
}
|
102
x-pack/plugins/event_log/server/plugin.ts
Normal file
102
x-pack/plugins/event_log/server/plugin.ts
Normal 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',
|
||||
});
|
||||
}
|
||||
}
|
38
x-pack/plugins/event_log/server/types.ts
Normal file
38
x-pack/plugins/event_log/server/types.ts
Normal 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;
|
||||
}
|
|
@ -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')}`,
|
||||
|
|
|
@ -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 = {
|
||||
|
|
|
@ -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`)
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue