-
Notifications
You must be signed in to change notification settings - Fork 8.3k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Create actions plugin #35679
Create actions plugin #35679
Changes from 51 commits
2b3af4c
bc0140b
682743e
0311aff
d2d9250
06669ab
d84f9d6
dfd0f36
632a2d9
174efea
c8580ef
d7fad22
8085c6b
e4c8897
661e731
d393e46
2d5857a
488f7dd
3e40856
c5f48ef
75c0145
0917339
b1faff5
9ff7836
40d59a2
44966a4
ff69d43
46e2d53
ef1fb55
25c1bfe
3bd30f8
bebf717
3641255
9915fb4
4e2b1df
4d7d6f2
7eda24c
fcd9306
d21d02a
0ccd660
4c5661c
da985f8
e2bec4f
33243f4
9573053
1aa660c
e57191a
c9af1e0
05ef18a
6d9363d
c26eaf6
9225e44
78b1d25
7e4dc57
e1dd525
c4ef04a
fe23aec
b1938ab
9fc8360
eb06854
8af6507
0fab9c8
30bcd8a
910fc7b
8254f73
e0df91d
42098d4
94107eb
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,30 @@ | ||
/* | ||
* 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 { Root } from 'joi'; | ||
import mappings from './mappings.json'; | ||
import { init } from './server'; | ||
|
||
export { ActionsPlugin, ActionsClient } from './server'; | ||
|
||
export function actions(kibana: any) { | ||
return new kibana.Plugin({ | ||
id: 'actions', | ||
configPrefix: 'xpack.actions', | ||
require: ['kibana', 'elasticsearch', 'encrypted_saved_objects'], | ||
config(Joi: Root) { | ||
return Joi.object() | ||
.keys({ | ||
enabled: Joi.boolean().default(true), | ||
}) | ||
.default(); | ||
}, | ||
init, | ||
uiExports: { | ||
mappings, | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Using a single "saved object type" for all actions means we won't be able to restrict users to only access certain "action types", similar to the discussion which I raised here: #36836 If we're going to want to restrict users to a subset of actions in the future, it might be advantageous to create different saved object types for the various alert types. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Similar to what I wrote above, I'm thinking we do a future phase post design discussion to properly implement feature controls / actions limiting. |
||
}, | ||
}); | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,19 @@ | ||
{ | ||
"action": { | ||
"properties": { | ||
"description": { | ||
"type": "text" | ||
}, | ||
"actionTypeId": { | ||
"type": "keyword" | ||
}, | ||
"actionTypeConfig": { | ||
"enabled": false, | ||
"type": "object" | ||
}, | ||
"actionTypeConfigSecrets": { | ||
"type": "binary" | ||
} | ||
} | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,331 @@ | ||
/* | ||
* 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 Joi from 'joi'; | ||
import { ActionTypeService } from '../action_type_service'; | ||
|
||
describe('register()', () => { | ||
test('able to register action types', () => { | ||
const executor = jest.fn(); | ||
const actionTypeService = new ActionTypeService(); | ||
mikecote marked this conversation as resolved.
Show resolved
Hide resolved
|
||
actionTypeService.register({ | ||
id: 'my-action-type', | ||
name: 'My action type', | ||
executor, | ||
}); | ||
expect(actionTypeService.has('my-action-type')).toEqual(true); | ||
}); | ||
|
||
test('throws error if action type already registered', () => { | ||
const executor = jest.fn(); | ||
const actionTypeService = new ActionTypeService(); | ||
actionTypeService.register({ | ||
id: 'my-action-type', | ||
name: 'My action type', | ||
executor, | ||
}); | ||
expect(() => | ||
actionTypeService.register({ | ||
id: 'my-action-type', | ||
name: 'My action type', | ||
executor, | ||
}) | ||
).toThrowErrorMatchingInlineSnapshot( | ||
`"Action type \\"my-action-type\\" is already registered."` | ||
); | ||
}); | ||
}); | ||
|
||
describe('get()', () => { | ||
test('returns action type', () => { | ||
const actionTypeService = new ActionTypeService(); | ||
actionTypeService.register({ | ||
id: 'my-action-type', | ||
name: 'My action type', | ||
async executor() {}, | ||
}); | ||
const actionType = actionTypeService.get('my-action-type'); | ||
expect(actionType).toMatchInlineSnapshot(` | ||
Object { | ||
"executor": [Function], | ||
"id": "my-action-type", | ||
"name": "My action type", | ||
} | ||
`); | ||
}); | ||
|
||
test(`throws an error when action type doesn't exist`, () => { | ||
const actionTypeService = new ActionTypeService(); | ||
expect(() => actionTypeService.get('my-action-type')).toThrowErrorMatchingInlineSnapshot( | ||
`"Action type \\"my-action-type\\" is not registered."` | ||
); | ||
}); | ||
}); | ||
|
||
describe('getUnencryptedAttributes()', () => { | ||
test('returns empty array when unencryptedAttributes is undefined', () => { | ||
const actionTypeService = new ActionTypeService(); | ||
actionTypeService.register({ | ||
id: 'my-action-type', | ||
name: 'My action type', | ||
async executor() {}, | ||
}); | ||
const result = actionTypeService.getUnencryptedAttributes('my-action-type'); | ||
expect(result).toEqual([]); | ||
}); | ||
|
||
test('returns values inside unencryptedAttributes array when it exists', () => { | ||
const actionTypeService = new ActionTypeService(); | ||
actionTypeService.register({ | ||
id: 'my-action-type', | ||
name: 'My action type', | ||
unencryptedAttributes: ['a', 'b', 'c'], | ||
async executor() {}, | ||
}); | ||
const result = actionTypeService.getUnencryptedAttributes('my-action-type'); | ||
expect(result).toEqual(['a', 'b', 'c']); | ||
}); | ||
}); | ||
|
||
describe('list()', () => { | ||
test('returns list of action types', () => { | ||
const actionTypeService = new ActionTypeService(); | ||
actionTypeService.register({ | ||
id: 'my-action-type', | ||
name: 'My action type', | ||
async executor() {}, | ||
}); | ||
const actionTypes = actionTypeService.list(); | ||
expect(actionTypes).toEqual([ | ||
{ | ||
id: 'my-action-type', | ||
name: 'My action type', | ||
}, | ||
]); | ||
}); | ||
}); | ||
|
||
describe('validateParams()', () => { | ||
test('should pass when validation not defined', () => { | ||
const actionTypeService = new ActionTypeService(); | ||
actionTypeService.register({ | ||
id: 'my-action-type', | ||
name: 'My action type', | ||
async executor() {}, | ||
}); | ||
actionTypeService.validateParams('my-action-type', {}); | ||
}); | ||
|
||
test('should validate and pass when params is valid', () => { | ||
const actionTypeService = new ActionTypeService(); | ||
actionTypeService.register({ | ||
id: 'my-action-type', | ||
name: 'My action type', | ||
validate: { | ||
params: Joi.object() | ||
.keys({ | ||
param1: Joi.string().required(), | ||
}) | ||
.required(), | ||
}, | ||
async executor() {}, | ||
}); | ||
actionTypeService.validateParams('my-action-type', { param1: 'value' }); | ||
}); | ||
|
||
test('should validate and throw error when params is invalid', () => { | ||
const actionTypeService = new ActionTypeService(); | ||
actionTypeService.register({ | ||
id: 'my-action-type', | ||
name: 'My action type', | ||
validate: { | ||
params: Joi.object() | ||
.keys({ | ||
param1: Joi.string().required(), | ||
}) | ||
.required(), | ||
}, | ||
async executor() {}, | ||
}); | ||
expect(() => | ||
actionTypeService.validateParams('my-action-type', {}) | ||
).toThrowErrorMatchingInlineSnapshot( | ||
`"child \\"param1\\" fails because [\\"param1\\" is required]"` | ||
); | ||
}); | ||
}); | ||
|
||
describe('validateActionTypeConfig()', () => { | ||
test('should pass when validation not defined', () => { | ||
const actionTypeService = new ActionTypeService(); | ||
actionTypeService.register({ | ||
id: 'my-action-type', | ||
name: 'My action type', | ||
async executor() {}, | ||
}); | ||
actionTypeService.validateActionTypeConfig('my-action-type', {}); | ||
}); | ||
|
||
test('should validate and pass when actionTypeConfig is valid', () => { | ||
const actionTypeService = new ActionTypeService(); | ||
actionTypeService.register({ | ||
id: 'my-action-type', | ||
name: 'My action type', | ||
validate: { | ||
actionTypeConfig: Joi.object() | ||
.keys({ | ||
param1: Joi.string().required(), | ||
}) | ||
.required(), | ||
}, | ||
async executor() {}, | ||
}); | ||
actionTypeService.validateActionTypeConfig('my-action-type', { param1: 'value' }); | ||
}); | ||
|
||
test('should validate and throw error when actionTypeConfig is invalid', () => { | ||
const actionTypeService = new ActionTypeService(); | ||
actionTypeService.register({ | ||
id: 'my-action-type', | ||
name: 'My action type', | ||
validate: { | ||
actionTypeConfig: Joi.object() | ||
.keys({ | ||
param1: Joi.string().required(), | ||
}) | ||
.required(), | ||
}, | ||
async executor() {}, | ||
}); | ||
expect(() => | ||
actionTypeService.validateActionTypeConfig('my-action-type', {}) | ||
).toThrowErrorMatchingInlineSnapshot( | ||
`"child \\"param1\\" fails because [\\"param1\\" is required]"` | ||
); | ||
}); | ||
}); | ||
|
||
describe('has()', () => { | ||
test('returns false for unregistered action types', () => { | ||
const actionTypeService = new ActionTypeService(); | ||
expect(actionTypeService.has('my-action-type')).toEqual(false); | ||
}); | ||
|
||
test('returns true after registering an action type', () => { | ||
const executor = jest.fn(); | ||
const actionTypeService = new ActionTypeService(); | ||
actionTypeService.register({ | ||
id: 'my-action-type', | ||
name: 'My action type', | ||
executor, | ||
}); | ||
expect(actionTypeService.has('my-action-type')); | ||
}); | ||
}); | ||
|
||
describe('execute()', () => { | ||
test('calls the executor with proper params', async () => { | ||
const executor = jest.fn().mockResolvedValueOnce({ success: true }); | ||
const actionTypeService = new ActionTypeService(); | ||
actionTypeService.register({ | ||
id: 'my-action-type', | ||
name: 'My action type', | ||
executor, | ||
}); | ||
await actionTypeService.execute({ | ||
id: 'my-action-type', | ||
actionTypeConfig: { foo: true }, | ||
params: { bar: false }, | ||
}); | ||
expect(executor).toMatchInlineSnapshot(` | ||
[MockFunction] { | ||
"calls": Array [ | ||
Array [ | ||
Object { | ||
"actionTypeConfig": Object { | ||
"foo": true, | ||
}, | ||
"params": Object { | ||
"bar": false, | ||
}, | ||
}, | ||
], | ||
], | ||
"results": Array [ | ||
Object { | ||
"type": "return", | ||
"value": Promise {}, | ||
}, | ||
], | ||
} | ||
`); | ||
}); | ||
|
||
test('validates params', async () => { | ||
const executor = jest.fn().mockResolvedValueOnce({ success: true }); | ||
const actionTypeService = new ActionTypeService(); | ||
actionTypeService.register({ | ||
id: 'my-action-type', | ||
name: 'My action type', | ||
executor, | ||
validate: { | ||
params: Joi.object() | ||
.keys({ | ||
param1: Joi.string().required(), | ||
}) | ||
.required(), | ||
}, | ||
}); | ||
await expect( | ||
actionTypeService.execute({ | ||
id: 'my-action-type', | ||
actionTypeConfig: {}, | ||
params: {}, | ||
}) | ||
).rejects.toThrowErrorMatchingInlineSnapshot( | ||
`"child \\"param1\\" fails because [\\"param1\\" is required]"` | ||
); | ||
}); | ||
|
||
test('validates actionTypeConfig', async () => { | ||
const executor = jest.fn().mockResolvedValueOnce({ success: true }); | ||
const actionTypeService = new ActionTypeService(); | ||
actionTypeService.register({ | ||
id: 'my-action-type', | ||
name: 'My action type', | ||
executor, | ||
validate: { | ||
actionTypeConfig: Joi.object() | ||
.keys({ | ||
param1: Joi.string().required(), | ||
}) | ||
.required(), | ||
}, | ||
}); | ||
await expect( | ||
actionTypeService.execute({ | ||
id: 'my-action-type', | ||
actionTypeConfig: {}, | ||
params: {}, | ||
}) | ||
).rejects.toThrowErrorMatchingInlineSnapshot( | ||
`"child \\"param1\\" fails because [\\"param1\\" is required]"` | ||
); | ||
}); | ||
|
||
test('throws error if action type not registered', async () => { | ||
const actionTypeService = new ActionTypeService(); | ||
await expect( | ||
actionTypeService.execute({ | ||
id: 'my-action-type', | ||
actionTypeConfig: { foo: true }, | ||
params: { bar: false }, | ||
}) | ||
).rejects.toThrowErrorMatchingInlineSnapshot( | ||
`"Action type \\"my-action-type\\" is not registered."` | ||
); | ||
}); | ||
}); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Do we anticipate all "actions" being "space specific"? How will this interact with Stack Monitoring's usage of alerting, which will likely be "space agnostic"?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
At this time, we haven't decided how we're going to approach this. I will setup a design discussion for spaces and another one for feature controls. Until then I'm thinking of keeping it space specific until we have those discussions.