Skip to content

Commit 1fbf9ac

Browse files
gmmorrispmuellr
authored andcommitted
[Alerting] Adds a builtin action for triggering webhooks (#43538) (#43889)
Adds the ability to trigger webhooks using an action. This feature is currently locked off while we figure out the right privileges model.
1 parent a8a0f35 commit 1fbf9ac

File tree

12 files changed

+745
-19
lines changed

12 files changed

+745
-19
lines changed

x-pack/legacy/plugins/actions/server/builtin_action_types/email.ts

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,19 +10,17 @@ import nodemailerServices from 'nodemailer/lib/well-known/services.json';
1010

1111
import { sendEmail, JSON_TRANSPORT_SERVICE } from './lib/send_email';
1212
import { nullableType } from './lib/nullable';
13+
import { portSchema } from './lib/schemas';
1314
import { ActionType, ActionTypeExecutorOptions, ActionTypeExecutorResult } from '../types';
1415

15-
const PORT_MAX = 256 * 256 - 1;
16-
1716
// config definition
18-
1917
export type ActionTypeConfigType = TypeOf<typeof ConfigSchema>;
2018

2119
const ConfigSchema = schema.object(
2220
{
2321
service: nullableType(schema.string()),
2422
host: nullableType(schema.string()),
25-
port: nullableType(schema.number({ min: 1, max: PORT_MAX })),
23+
port: nullableType(portSchema()),
2624
secure: nullableType(schema.boolean()),
2725
from: schema.string(),
2826
},
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the Elastic License;
4+
* you may not use this file except in compliance with the Elastic License.
5+
*/
6+
7+
import { fromNullable, Option } from 'fp-ts/lib/Option';
8+
9+
export function getRetryAfterIntervalFromHeaders(headers: Record<string, string>): Option<number> {
10+
return fromNullable(headers['retry-after'])
11+
.map(retryAfter => parseInt(retryAfter, 10))
12+
.filter(retryAfter => !isNaN(retryAfter));
13+
}
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the Elastic License;
4+
* you may not use this file except in compliance with the Elastic License.
5+
*/
6+
7+
// There appears to be an unexported implementation of Either in here: src/core/server/saved_objects/service/lib/repository.ts
8+
// Which is basically the Haskel equivalent of Rust/ML/Scala's Result
9+
// I'll reach out to other's in Kibana to see if we can merge these into one type
10+
11+
// eslint-disable-next-line @typescript-eslint/prefer-interface
12+
export type Ok<T> = {
13+
tag: 'ok';
14+
value: T;
15+
};
16+
// eslint-disable-next-line @typescript-eslint/prefer-interface
17+
export type Err<E> = {
18+
tag: 'err';
19+
error: E;
20+
};
21+
export type Result<T, E> = Ok<T> | Err<E>;
22+
23+
export function asOk<T>(value: T): Ok<T> {
24+
return {
25+
tag: 'ok',
26+
value,
27+
};
28+
}
29+
30+
export function asErr<T>(error: T): Err<T> {
31+
return {
32+
tag: 'err',
33+
error,
34+
};
35+
}
36+
37+
export function isOk<T, E>(result: Result<T, E>): result is Ok<T> {
38+
return result.tag === 'ok';
39+
}
40+
41+
export function isErr<T, E>(result: Result<T, E>): result is Err<E> {
42+
return !isOk(result);
43+
}
44+
45+
export async function promiseResult<T, E>(future: Promise<T>): Promise<Result<T, E>> {
46+
try {
47+
return asOk(await future);
48+
} catch (e) {
49+
return asErr(e);
50+
}
51+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the Elastic License;
4+
* you may not use this file except in compliance with the Elastic License.
5+
*/
6+
7+
import { schema } from '@kbn/config-schema';
8+
9+
const PORT_MAX = 256 * 256 - 1;
10+
export const portSchema = () => schema.number({ min: 1, max: PORT_MAX });

x-pack/legacy/plugins/actions/server/builtin_action_types/slack.ts

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import { i18n } from '@kbn/i18n';
88
import { schema, TypeOf } from '@kbn/config-schema';
99
import { IncomingWebhook, IncomingWebhookResult } from '@slack/webhook';
10+
import { getRetryAfterIntervalFromHeaders } from './lib/http_rersponse_retry_header';
1011

1112
import {
1213
ActionType,
@@ -81,13 +82,9 @@ async function slackExecutor(
8182

8283
// special handling for rate limiting
8384
if (status === 429) {
84-
const retryAfterString = headers['retry-after'];
85-
if (retryAfterString != null) {
86-
const retryAfter = parseInt(retryAfterString, 10);
87-
if (!isNaN(retryAfter)) {
88-
return retryResultSeconds(id, err.message, retryAfter);
89-
}
90-
}
85+
return getRetryAfterIntervalFromHeaders(headers)
86+
.map(retry => retryResultSeconds(id, err.message, retry))
87+
.getOrElse(retryResult(id, err.message));
9188
}
9289

9390
return errorResult(id, `${err.message} - ${statusText}`);
@@ -154,7 +151,7 @@ function retryResult(id: string, message: string): ActionTypeExecutorResult {
154151
function retryResultSeconds(
155152
id: string,
156153
message: string,
157-
retryAfter: number = 60
154+
retryAfter: number
158155
): ActionTypeExecutorResult {
159156
const retryEpoch = Date.now() + retryAfter * 1000;
160157
const retry = new Date(retryEpoch);
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the Elastic License;
4+
* you may not use this file except in compliance with the Elastic License.
5+
*/
6+
7+
import { actionType } from './webhook';
8+
import { validateConfig, validateSecrets, validateParams } from '../lib';
9+
10+
describe('actionType', () => {
11+
test('exposes the action as `webhook` on its Id and Name', () => {
12+
expect(actionType.id).toEqual('.webhook');
13+
expect(actionType.name).toEqual('webhook');
14+
});
15+
});
16+
17+
describe('secrets validation', () => {
18+
test('succeeds when secrets is valid', () => {
19+
const secrets: Record<string, any> = {
20+
user: 'bob',
21+
password: 'supersecret',
22+
};
23+
expect(validateSecrets(actionType, secrets)).toEqual(secrets);
24+
});
25+
26+
test('fails when secret password is omitted', () => {
27+
expect(() => {
28+
validateSecrets(actionType, { user: 'bob' });
29+
}).toThrowErrorMatchingInlineSnapshot(
30+
`"error validating action type secrets: [password]: expected value of type [string] but got [undefined]"`
31+
);
32+
});
33+
34+
test('fails when secret user is omitted', () => {
35+
expect(() => {
36+
validateSecrets(actionType, {});
37+
}).toThrowErrorMatchingInlineSnapshot(
38+
`"error validating action type secrets: [user]: expected value of type [string] but got [undefined]"`
39+
);
40+
});
41+
});
42+
43+
describe('config validation', () => {
44+
const defaultValues: Record<string, any> = {
45+
headers: null,
46+
method: 'post',
47+
};
48+
49+
test('config validation passes when only required fields are provided', () => {
50+
const config: Record<string, any> = {
51+
url: 'http://mylisteningserver:9200/endpoint',
52+
};
53+
expect(validateConfig(actionType, config)).toEqual({
54+
...defaultValues,
55+
...config,
56+
});
57+
});
58+
59+
test('config validation passes when valid methods are provided', () => {
60+
['post', 'put'].forEach(method => {
61+
const config: Record<string, any> = {
62+
url: 'http://mylisteningserver:9200/endpoint',
63+
method,
64+
};
65+
expect(validateConfig(actionType, config)).toEqual({
66+
...defaultValues,
67+
...config,
68+
});
69+
});
70+
});
71+
72+
test('should validate and throw error when method on config is invalid', () => {
73+
const config: Record<string, any> = {
74+
url: 'http://mylisteningserver:9200/endpoint',
75+
method: 'https',
76+
};
77+
expect(() => {
78+
validateConfig(actionType, config);
79+
}).toThrowErrorMatchingInlineSnapshot(`
80+
"error validating action type config: [method]: types that failed validation:
81+
- [method.0]: expected value to equal [post] but got [https]
82+
- [method.1]: expected value to equal [put] but got [https]"
83+
`);
84+
});
85+
86+
test('config validation passes when a url is specified', () => {
87+
const config: Record<string, any> = {
88+
url: 'http://mylisteningserver:9200/endpoint',
89+
};
90+
expect(validateConfig(actionType, config)).toEqual({
91+
...defaultValues,
92+
...config,
93+
});
94+
});
95+
96+
test('config validation passes when valid headers are provided', () => {
97+
const config: Record<string, any> = {
98+
url: 'http://mylisteningserver:9200/endpoint',
99+
headers: {
100+
'Content-Type': 'application/json',
101+
},
102+
};
103+
expect(validateConfig(actionType, config)).toEqual({
104+
...defaultValues,
105+
...config,
106+
});
107+
});
108+
109+
test('should validate and throw error when headers on config is invalid', () => {
110+
const config: Record<string, any> = {
111+
url: 'http://mylisteningserver:9200/endpoint',
112+
headers: 'application/json',
113+
};
114+
expect(() => {
115+
validateConfig(actionType, config);
116+
}).toThrowErrorMatchingInlineSnapshot(`
117+
"error validating action type config: [headers]: types that failed validation:
118+
- [headers.0]: expected value of type [object] but got [string]
119+
- [headers.1]: expected value to equal [null] but got [application/json]"
120+
`);
121+
});
122+
});
123+
124+
describe('params validation', () => {
125+
test('param validation passes when no fields are provided as none are required', () => {
126+
const params: Record<string, any> = {};
127+
expect(validateParams(actionType, params)).toEqual({});
128+
});
129+
130+
test('params validation passes when a valid body is provided', () => {
131+
const params: Record<string, any> = {
132+
body: 'count: {{ctx.payload.hits.total}}',
133+
};
134+
expect(validateParams(actionType, params)).toEqual({
135+
...params,
136+
});
137+
});
138+
});

0 commit comments

Comments
 (0)