Skip to content

Commit

Permalink
[Alerting] Adds a builtin action for triggering webhooks (#43538)
Browse files Browse the repository at this point in the history
Adds the ability to trigger webhooks using an action.

This feature is currently locked off while we figure out the right privileges model.
  • Loading branch information
gmmorris authored Aug 23, 2019
1 parent 6a9844c commit e8c50c0
Show file tree
Hide file tree
Showing 12 changed files with 745 additions and 19 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -10,19 +10,17 @@ import nodemailerServices from 'nodemailer/lib/well-known/services.json';

import { sendEmail, JSON_TRANSPORT_SERVICE } from './lib/send_email';
import { nullableType } from './lib/nullable';
import { portSchema } from './lib/schemas';
import { ActionType, ActionTypeExecutorOptions, ActionTypeExecutorResult } from '../types';

const PORT_MAX = 256 * 256 - 1;

// config definition

export type ActionTypeConfigType = TypeOf<typeof ConfigSchema>;

const ConfigSchema = schema.object(
{
service: nullableType(schema.string()),
host: nullableType(schema.string()),
port: nullableType(schema.number({ min: 1, max: PORT_MAX })),
port: nullableType(portSchema()),
secure: nullableType(schema.boolean()),
from: schema.string(),
},
Expand Down
Original file line number Diff line number Diff line change
@@ -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 { fromNullable, Option } from 'fp-ts/lib/Option';

export function getRetryAfterIntervalFromHeaders(headers: Record<string, string>): Option<number> {
return fromNullable(headers['retry-after'])
.map(retryAfter => parseInt(retryAfter, 10))
.filter(retryAfter => !isNaN(retryAfter));
}
Original file line number Diff line number Diff line change
@@ -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.
*/

// There appears to be an unexported implementation of Either in here: src/core/server/saved_objects/service/lib/repository.ts
// Which is basically the Haskel equivalent of Rust/ML/Scala's Result
// I'll reach out to other's in Kibana to see if we can merge these into one type

// eslint-disable-next-line @typescript-eslint/prefer-interface
export type Ok<T> = {
tag: 'ok';
value: T;
};
// eslint-disable-next-line @typescript-eslint/prefer-interface
export type Err<E> = {
tag: 'err';
error: E;
};
export type Result<T, E> = Ok<T> | Err<E>;

export function asOk<T>(value: T): Ok<T> {
return {
tag: 'ok',
value,
};
}

export function asErr<T>(error: T): Err<T> {
return {
tag: 'err',
error,
};
}

export function isOk<T, E>(result: Result<T, E>): result is Ok<T> {
return result.tag === 'ok';
}

export function isErr<T, E>(result: Result<T, E>): result is Err<E> {
return !isOk(result);
}

export async function promiseResult<T, E>(future: Promise<T>): Promise<Result<T, E>> {
try {
return asOk(await future);
} catch (e) {
return asErr(e);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
/*
* 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';

const PORT_MAX = 256 * 256 - 1;
export const portSchema = () => schema.number({ min: 1, max: PORT_MAX });
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import { i18n } from '@kbn/i18n';
import { schema, TypeOf } from '@kbn/config-schema';
import { IncomingWebhook, IncomingWebhookResult } from '@slack/webhook';
import { getRetryAfterIntervalFromHeaders } from './lib/http_rersponse_retry_header';

import {
ActionType,
Expand Down Expand Up @@ -81,13 +82,9 @@ async function slackExecutor(

// special handling for rate limiting
if (status === 429) {
const retryAfterString = headers['retry-after'];
if (retryAfterString != null) {
const retryAfter = parseInt(retryAfterString, 10);
if (!isNaN(retryAfter)) {
return retryResultSeconds(id, err.message, retryAfter);
}
}
return getRetryAfterIntervalFromHeaders(headers)
.map(retry => retryResultSeconds(id, err.message, retry))
.getOrElse(retryResult(id, err.message));
}

return errorResult(id, `${err.message} - ${statusText}`);
Expand Down Expand Up @@ -154,7 +151,7 @@ function retryResult(id: string, message: string): ActionTypeExecutorResult {
function retryResultSeconds(
id: string,
message: string,
retryAfter: number = 60
retryAfter: number
): ActionTypeExecutorResult {
const retryEpoch = Date.now() + retryAfter * 1000;
const retry = new Date(retryEpoch);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
/*
* 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 { actionType } from './webhook';
import { validateConfig, validateSecrets, validateParams } from '../lib';

describe('actionType', () => {
test('exposes the action as `webhook` on its Id and Name', () => {
expect(actionType.id).toEqual('.webhook');
expect(actionType.name).toEqual('webhook');
});
});

describe('secrets validation', () => {
test('succeeds when secrets is valid', () => {
const secrets: Record<string, any> = {
user: 'bob',
password: 'supersecret',
};
expect(validateSecrets(actionType, secrets)).toEqual(secrets);
});

test('fails when secret password is omitted', () => {
expect(() => {
validateSecrets(actionType, { user: 'bob' });
}).toThrowErrorMatchingInlineSnapshot(
`"error validating action type secrets: [password]: expected value of type [string] but got [undefined]"`
);
});

test('fails when secret user is omitted', () => {
expect(() => {
validateSecrets(actionType, {});
}).toThrowErrorMatchingInlineSnapshot(
`"error validating action type secrets: [user]: expected value of type [string] but got [undefined]"`
);
});
});

describe('config validation', () => {
const defaultValues: Record<string, any> = {
headers: null,
method: 'post',
};

test('config validation passes when only required fields are provided', () => {
const config: Record<string, any> = {
url: 'http://mylisteningserver:9200/endpoint',
};
expect(validateConfig(actionType, config)).toEqual({
...defaultValues,
...config,
});
});

test('config validation passes when valid methods are provided', () => {
['post', 'put'].forEach(method => {
const config: Record<string, any> = {
url: 'http://mylisteningserver:9200/endpoint',
method,
};
expect(validateConfig(actionType, config)).toEqual({
...defaultValues,
...config,
});
});
});

test('should validate and throw error when method on config is invalid', () => {
const config: Record<string, any> = {
url: 'http://mylisteningserver:9200/endpoint',
method: 'https',
};
expect(() => {
validateConfig(actionType, config);
}).toThrowErrorMatchingInlineSnapshot(`
"error validating action type config: [method]: types that failed validation:
- [method.0]: expected value to equal [post] but got [https]
- [method.1]: expected value to equal [put] but got [https]"
`);
});

test('config validation passes when a url is specified', () => {
const config: Record<string, any> = {
url: 'http://mylisteningserver:9200/endpoint',
};
expect(validateConfig(actionType, config)).toEqual({
...defaultValues,
...config,
});
});

test('config validation passes when valid headers are provided', () => {
const config: Record<string, any> = {
url: 'http://mylisteningserver:9200/endpoint',
headers: {
'Content-Type': 'application/json',
},
};
expect(validateConfig(actionType, config)).toEqual({
...defaultValues,
...config,
});
});

test('should validate and throw error when headers on config is invalid', () => {
const config: Record<string, any> = {
url: 'http://mylisteningserver:9200/endpoint',
headers: 'application/json',
};
expect(() => {
validateConfig(actionType, config);
}).toThrowErrorMatchingInlineSnapshot(`
"error validating action type config: [headers]: types that failed validation:
- [headers.0]: expected value of type [object] but got [string]
- [headers.1]: expected value to equal [null] but got [application/json]"
`);
});
});

describe('params validation', () => {
test('param validation passes when no fields are provided as none are required', () => {
const params: Record<string, any> = {};
expect(validateParams(actionType, params)).toEqual({});
});

test('params validation passes when a valid body is provided', () => {
const params: Record<string, any> = {
body: 'count: {{ctx.payload.hits.total}}',
};
expect(validateParams(actionType, params)).toEqual({
...params,
});
});
});
Loading

0 comments on commit e8c50c0

Please sign in to comment.