From 4c90a61a35db9963c11afda95000875503b4b868 Mon Sep 17 00:00:00 2001 From: Michael Basnight Date: Tue, 14 Aug 2018 11:06:18 -0500 Subject: [PATCH] Watcher: migrate PagerDuty v1 events API to v2 API (#32285) The PagerDuty v1 API is EOL and will stop accepting new accounts shortly. This commit swaps out the watcher use of the v1 API with the new v2 API. It does not change anything about the existing watcher API. Closes #32243 --- .../pagerduty/ExecutablePagerDutyAction.java | 2 +- .../notification/pagerduty/IncidentEvent.java | 120 +++++++++++------ .../pagerduty/IncidentEventContext.java | 79 +++++++++++ .../pagerduty/PagerDutyAccount.java | 4 +- .../pagerduty/PagerDutyActionTests.java | 2 +- .../pagerduty/IncidentEventTests.java | 126 ++++++++++++++++++ .../pagerduty/PagerDutyAccountsTests.java | 9 +- 7 files changed, 296 insertions(+), 46 deletions(-) create mode 100644 x-pack/plugin/watcher/src/test/java/org/elasticsearch/xpack/watcher/notification/pagerduty/IncidentEventTests.java diff --git a/x-pack/plugin/watcher/src/main/java/org/elasticsearch/xpack/watcher/actions/pagerduty/ExecutablePagerDutyAction.java b/x-pack/plugin/watcher/src/main/java/org/elasticsearch/xpack/watcher/actions/pagerduty/ExecutablePagerDutyAction.java index 224e72e1a3d..59381dc3336 100644 --- a/x-pack/plugin/watcher/src/main/java/org/elasticsearch/xpack/watcher/actions/pagerduty/ExecutablePagerDutyAction.java +++ b/x-pack/plugin/watcher/src/main/java/org/elasticsearch/xpack/watcher/actions/pagerduty/ExecutablePagerDutyAction.java @@ -47,7 +47,7 @@ public class ExecutablePagerDutyAction extends ExecutableAction return new PagerDutyAction.Result.Simulated(event); } - SentEvent sentEvent = account.send(event, payload); + SentEvent sentEvent = account.send(event, payload, ctx.id().watchId()); return new PagerDutyAction.Result.Executed(account.getName(), sentEvent); } diff --git a/x-pack/plugin/watcher/src/main/java/org/elasticsearch/xpack/watcher/notification/pagerduty/IncidentEvent.java b/x-pack/plugin/watcher/src/main/java/org/elasticsearch/xpack/watcher/notification/pagerduty/IncidentEvent.java index 0fb1a52d286..c44fbf36e0b 100644 --- a/x-pack/plugin/watcher/src/main/java/org/elasticsearch/xpack/watcher/notification/pagerduty/IncidentEvent.java +++ b/x-pack/plugin/watcher/src/main/java/org/elasticsearch/xpack/watcher/notification/pagerduty/IncidentEvent.java @@ -24,22 +24,22 @@ import org.elasticsearch.xpack.watcher.common.text.TextTemplateEngine; import java.io.IOException; import java.util.ArrayList; import java.util.Arrays; +import java.util.Collections; import java.util.List; import java.util.Map; import java.util.Objects; +import java.util.stream.Collectors; /** * Official documentation for this can be found at * - * https://developer.pagerduty.com/documentation/howto/manually-trigger-an-incident/ - * https://developer.pagerduty.com/documentation/integration/events/trigger - * https://developer.pagerduty.com/documentation/integration/events/acknowledge - * https://developer.pagerduty.com/documentation/integration/events/resolve + * https://v2.developer.pagerduty.com/docs/send-an-event-events-api-v2 */ public class IncidentEvent implements ToXContentObject { static final String HOST = "events.pagerduty.com"; - static final String PATH = "/generic/2010-04-15/create_event.json"; + static final String PATH = "/v2/enqueue"; + static final String ACCEPT_HEADER = "application/vnd.pagerduty+json;version=2"; final String description; @Nullable final HttpProxy proxy; @@ -93,46 +93,81 @@ public class IncidentEvent implements ToXContentObject { return result; } - public HttpRequest createRequest(final String serviceKey, final Payload payload) throws IOException { + HttpRequest createRequest(final String serviceKey, final Payload payload, final String watchId) throws IOException { return HttpRequest.builder(HOST, -1) .method(HttpMethod.POST) .scheme(Scheme.HTTPS) .path(PATH) .proxy(proxy) - .jsonBody(new ToXContent() { - @Override - public XContentBuilder toXContent(XContentBuilder builder, Params params) throws IOException { - builder.field(Fields.SERVICE_KEY.getPreferredName(), serviceKey); - builder.field(Fields.EVENT_TYPE.getPreferredName(), eventType); - builder.field(Fields.DESCRIPTION.getPreferredName(), description); - if (incidentKey != null) { - builder.field(Fields.INCIDENT_KEY.getPreferredName(), incidentKey); - } - if (client != null) { - builder.field(Fields.CLIENT.getPreferredName(), client); - } - if (clientUrl != null) { - builder.field(Fields.CLIENT_URL.getPreferredName(), clientUrl); - } - if (attachPayload) { - builder.startObject(Fields.DETAILS.getPreferredName()); - builder.field(Fields.PAYLOAD.getPreferredName()); - payload.toXContent(builder, params); - builder.endObject(); - } - if (contexts != null && contexts.length > 0) { - builder.startArray(Fields.CONTEXTS.getPreferredName()); - for (IncidentEventContext context : contexts) { - context.toXContent(builder, params); - } - builder.endArray(); - } - return builder; - } - }) + .setHeader("Accept", ACCEPT_HEADER) + .jsonBody((b, p) -> buildAPIXContent(b, p, serviceKey, payload, watchId)) .build(); } + XContentBuilder buildAPIXContent(XContentBuilder builder, Params params, String serviceKey, + Payload payload, String watchId) throws IOException { + builder.field(Fields.ROUTING_KEY.getPreferredName(), serviceKey); + builder.field(Fields.EVENT_ACTION.getPreferredName(), eventType); + if (incidentKey != null) { + builder.field(Fields.DEDUP_KEY.getPreferredName(), incidentKey); + } + + builder.startObject(Fields.PAYLOAD.getPreferredName()); + { + builder.field(Fields.SUMMARY.getPreferredName(), description); + + if (attachPayload && payload != null) { + builder.startObject(Fields.CUSTOM_DETAILS.getPreferredName()); + { + builder.field(Fields.PAYLOAD.getPreferredName(), payload, params); + } + builder.endObject(); + } + + if (watchId != null) { + builder.field(Fields.SOURCE.getPreferredName(), watchId); + } else { + builder.field(Fields.SOURCE.getPreferredName(), "watcher"); + } + // TODO externalize this into something user editable + builder.field(Fields.SEVERITY.getPreferredName(), "critical"); + } + builder.endObject(); + + if (client != null) { + builder.field(Fields.CLIENT.getPreferredName(), client); + } + if (clientUrl != null) { + builder.field(Fields.CLIENT_URL.getPreferredName(), clientUrl); + } + + if (contexts != null && contexts.length > 0) { + toXContentV2Contexts(builder, params, contexts); + } + + return builder; + } + + /** + * Turns the V1 API contexts into 2 distinct lists, images and links. The V2 API has separated these out into 2 top level fields. + */ + private void toXContentV2Contexts(XContentBuilder builder, ToXContent.Params params, + IncidentEventContext[] contexts) throws IOException { + // contexts can be either links or images, and the v2 api needs them separate + Map> groups = Arrays.stream(contexts) + .collect(Collectors.groupingBy(iec -> iec.type)); + + List links = groups.getOrDefault(IncidentEventContext.Type.LINK, Collections.emptyList()); + if (links.isEmpty() == false) { + builder.array(Fields.LINKS.getPreferredName(), links.toArray()); + } + + List images = groups.getOrDefault(IncidentEventContext.Type.IMAGE, Collections.emptyList()); + if (images.isEmpty() == false) { + builder.array(Fields.IMAGES.getPreferredName(), images.toArray()); + } + } + @Override public XContentBuilder toXContent(XContentBuilder builder, ToXContent.Params params) throws IOException { builder.startObject(); @@ -445,8 +480,15 @@ public class IncidentEvent implements ToXContentObject { // we need to keep this for BWC ParseField CONTEXT_DEPRECATED = new ParseField("context"); - ParseField SERVICE_KEY = new ParseField("service_key"); ParseField PAYLOAD = new ParseField("payload"); - ParseField DETAILS = new ParseField("details"); + ParseField ROUTING_KEY = new ParseField("routing_key"); + ParseField EVENT_ACTION = new ParseField("event_action"); + ParseField DEDUP_KEY = new ParseField("dedup_key"); + ParseField SUMMARY = new ParseField("summary"); + ParseField SOURCE = new ParseField("source"); + ParseField SEVERITY = new ParseField("severity"); + ParseField LINKS = new ParseField("links"); + ParseField IMAGES = new ParseField("images"); + ParseField CUSTOM_DETAILS = new ParseField("custom_details"); } } diff --git a/x-pack/plugin/watcher/src/main/java/org/elasticsearch/xpack/watcher/notification/pagerduty/IncidentEventContext.java b/x-pack/plugin/watcher/src/main/java/org/elasticsearch/xpack/watcher/notification/pagerduty/IncidentEventContext.java index d43829346b6..cd9924ae9dc 100644 --- a/x-pack/plugin/watcher/src/main/java/org/elasticsearch/xpack/watcher/notification/pagerduty/IncidentEventContext.java +++ b/x-pack/plugin/watcher/src/main/java/org/elasticsearch/xpack/watcher/notification/pagerduty/IncidentEventContext.java @@ -92,6 +92,85 @@ public class IncidentEventContext implements ToXContentObject { return builder.endObject(); } + public static IncidentEventContext parse(XContentParser parser) throws IOException { + Type type = null; + String href = null; + String text = null; + String src = null; + String alt = null; + + String currentFieldName = null; + XContentParser.Token token; + while ((token = parser.nextToken()) != XContentParser.Token.END_OBJECT) { + if (token == XContentParser.Token.FIELD_NAME) { + currentFieldName = parser.currentName(); + } else if (Strings.hasLength(currentFieldName)) { + if (XField.TYPE.match(currentFieldName, parser.getDeprecationHandler())) { + try { + type = Type.valueOf(parser.text().toUpperCase(Locale.ROOT)); + } catch (IllegalArgumentException e) { + String msg = "could not parse trigger incident event context. unknown context type [{}]"; + throw new ElasticsearchParseException(msg, parser.text()); + } + } else { + if (XField.HREF.match(currentFieldName, parser.getDeprecationHandler())) { + href = parser.text(); + } else if (XField.TEXT.match(currentFieldName, parser.getDeprecationHandler())) { + text = parser.text(); + } else if (XField.SRC.match(currentFieldName, parser.getDeprecationHandler())) { + src = parser.text(); + } else if (XField.ALT.match(currentFieldName, parser.getDeprecationHandler())) { + alt = parser.text(); + } else { + String msg = "could not parse trigger incident event context. unknown field [{}]"; + throw new ElasticsearchParseException(msg, currentFieldName); + } + } + } + } + + return createAndValidateTemplate(type, href, src, alt, text); + } + + private static IncidentEventContext createAndValidateTemplate(Type type, String href, String src, String alt, + String text) { + if (type == null) { + throw new ElasticsearchParseException("could not parse trigger incident event context. missing required field [{}]", + XField.TYPE.getPreferredName()); + } + + switch (type) { + case LINK: + if (href == null) { + throw new ElasticsearchParseException("could not parse trigger incident event context. missing required field " + + "[{}] for [{}] context", XField.HREF.getPreferredName(), Type.LINK.name().toLowerCase(Locale.ROOT)); + } + if (src != null) { + throw new ElasticsearchParseException("could not parse trigger incident event context. unexpected field [{}] for " + + "[{}] context", XField.SRC.getPreferredName(), Type.LINK.name().toLowerCase(Locale.ROOT)); + } + if (alt != null) { + throw new ElasticsearchParseException("could not parse trigger incident event context. unexpected field [{}] for " + + "[{}] context", XField.ALT.getPreferredName(), Type.LINK.name().toLowerCase(Locale.ROOT)); + } + return link(href, text); + case IMAGE: + if (src == null) { + throw new ElasticsearchParseException("could not parse trigger incident event context. missing required field " + + "[{}] for [{}] context", XField.SRC.getPreferredName(), Type.IMAGE.name().toLowerCase(Locale.ROOT)); + } + if (text != null) { + throw new ElasticsearchParseException("could not parse trigger incident event context. unexpected field [{}] for " + + "[{}] context", XField.TEXT.getPreferredName(), Type.IMAGE.name().toLowerCase(Locale.ROOT)); + } + return image(src, href, alt); + default: + throw new ElasticsearchParseException("could not parse trigger incident event context. unknown context type [{}]", + type); + } + } + + public static class Template implements ToXContentObject { final Type type; diff --git a/x-pack/plugin/watcher/src/main/java/org/elasticsearch/xpack/watcher/notification/pagerduty/PagerDutyAccount.java b/x-pack/plugin/watcher/src/main/java/org/elasticsearch/xpack/watcher/notification/pagerduty/PagerDutyAccount.java index 5cf1a77f971..b2498a749d7 100644 --- a/x-pack/plugin/watcher/src/main/java/org/elasticsearch/xpack/watcher/notification/pagerduty/PagerDutyAccount.java +++ b/x-pack/plugin/watcher/src/main/java/org/elasticsearch/xpack/watcher/notification/pagerduty/PagerDutyAccount.java @@ -48,8 +48,8 @@ public class PagerDutyAccount { return eventDefaults; } - public SentEvent send(IncidentEvent event, Payload payload) throws IOException { - HttpRequest request = event.createRequest(serviceKey, payload); + public SentEvent send(IncidentEvent event, Payload payload, String watchId) throws IOException { + HttpRequest request = event.createRequest(serviceKey, payload, watchId); HttpResponse response = httpClient.execute(request); return SentEvent.responded(event, request, response); } diff --git a/x-pack/plugin/watcher/src/test/java/org/elasticsearch/xpack/watcher/actions/pagerduty/PagerDutyActionTests.java b/x-pack/plugin/watcher/src/test/java/org/elasticsearch/xpack/watcher/actions/pagerduty/PagerDutyActionTests.java index 6f57ccd82d9..07a55c628ec 100644 --- a/x-pack/plugin/watcher/src/test/java/org/elasticsearch/xpack/watcher/actions/pagerduty/PagerDutyActionTests.java +++ b/x-pack/plugin/watcher/src/test/java/org/elasticsearch/xpack/watcher/actions/pagerduty/PagerDutyActionTests.java @@ -111,7 +111,7 @@ public class PagerDutyActionTests extends ESTestCase { when(response.status()).thenReturn(200); HttpRequest request = mock(HttpRequest.class); SentEvent sentEvent = SentEvent.responded(event, request, response); - when(account.send(event, payload)).thenReturn(sentEvent); + when(account.send(event, payload, wid.watchId())).thenReturn(sentEvent); when(service.getAccount(accountName)).thenReturn(account); Action.Result result = executable.execute("_id", ctx, payload); diff --git a/x-pack/plugin/watcher/src/test/java/org/elasticsearch/xpack/watcher/notification/pagerduty/IncidentEventTests.java b/x-pack/plugin/watcher/src/test/java/org/elasticsearch/xpack/watcher/notification/pagerduty/IncidentEventTests.java new file mode 100644 index 00000000000..3638d5f85d9 --- /dev/null +++ b/x-pack/plugin/watcher/src/test/java/org/elasticsearch/xpack/watcher/notification/pagerduty/IncidentEventTests.java @@ -0,0 +1,126 @@ +/* + * 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. + */ +package org.elasticsearch.xpack.watcher.notification.pagerduty; + +import org.elasticsearch.common.bytes.BytesReference; +import org.elasticsearch.common.xcontent.ToXContent; +import org.elasticsearch.common.xcontent.XContentBuilder; +import org.elasticsearch.common.xcontent.XContentParser; +import org.elasticsearch.test.ESTestCase; +import org.elasticsearch.test.rest.yaml.ObjectPath; +import org.elasticsearch.xpack.core.watcher.watch.Payload; +import org.elasticsearch.xpack.watcher.common.http.HttpProxy; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +import static org.elasticsearch.common.xcontent.XContentFactory.jsonBuilder; +import static org.hamcrest.Matchers.equalTo; + +public class IncidentEventTests extends ESTestCase { + + public void testPagerDutyXContent() throws IOException { + + String serviceKey = randomAlphaOfLength(3); + boolean attachPayload = randomBoolean(); + Payload payload = null; + if (attachPayload) { + payload = new Payload.Simple(Collections.singletonMap(randomAlphaOfLength(3), randomAlphaOfLength(3))); + } + String watchId = randomAlphaOfLength(3); + String description = randomAlphaOfLength(3); + String eventType = randomAlphaOfLength(3); + String incidentKey = rarely() ? null : randomAlphaOfLength(3); + String client = rarely() ? null : randomAlphaOfLength(3); + String clientUrl = rarely() ? null : randomAlphaOfLength(3); + String account = rarely() ? null : randomAlphaOfLength(3); + + IncidentEventContext[] contexts = null; + List links = new ArrayList<>(); + List images = new ArrayList<>(); + + if (randomBoolean()) { + int numContexts = randomIntBetween(0, 3); + contexts = new IncidentEventContext[numContexts]; + for (int i = 0; i < numContexts; i++) { + if (randomBoolean()) { + contexts[i] = IncidentEventContext.link("href", "text"); + links.add(contexts[i]); + } else { + contexts[i] = IncidentEventContext.image("src", "href", "alt"); + images.add(contexts[i]); + } + } + } + + HttpProxy proxy = rarely() ? null : HttpProxy.NO_PROXY; + + IncidentEvent event = new IncidentEvent(description, eventType, incidentKey, client, clientUrl, account, + attachPayload, contexts, proxy); + + XContentBuilder jsonBuilder = jsonBuilder(); + jsonBuilder.startObject(); // since its a snippet + event.buildAPIXContent(jsonBuilder, ToXContent.EMPTY_PARAMS, serviceKey, payload, watchId); + jsonBuilder.endObject(); + XContentParser parser = createParser(jsonBuilder); + parser.nextToken(); + + ObjectPath objectPath = ObjectPath.createFromXContent(jsonBuilder.contentType().xContent(), BytesReference.bytes(jsonBuilder)); + + String actualServiceKey = objectPath.evaluate(IncidentEvent.Fields.ROUTING_KEY.getPreferredName()); + String actualWatchId = objectPath.evaluate(IncidentEvent.Fields.PAYLOAD.getPreferredName() + + "." + IncidentEvent.Fields.SOURCE.getPreferredName()); + if (actualWatchId == null) { + actualWatchId = "watcher"; // hardcoded if the SOURCE is null + } + String actualDescription = objectPath.evaluate(IncidentEvent.Fields.PAYLOAD.getPreferredName() + + "." + IncidentEvent.Fields.SUMMARY.getPreferredName()); + String actualEventType = objectPath.evaluate(IncidentEvent.Fields.EVENT_ACTION.getPreferredName()); + String actualIncidentKey = objectPath.evaluate(IncidentEvent.Fields.DEDUP_KEY.getPreferredName()); + String actualClient = objectPath.evaluate(IncidentEvent.Fields.CLIENT.getPreferredName()); + String actualClientUrl = objectPath.evaluate(IncidentEvent.Fields.CLIENT_URL.getPreferredName()); + String actualSeverity = objectPath.evaluate(IncidentEvent.Fields.PAYLOAD.getPreferredName() + + "." + IncidentEvent.Fields.SEVERITY.getPreferredName()); + Map payloadDetails = objectPath.evaluate("payload.custom_details.payload"); + Payload actualPayload = null; + + if (payloadDetails != null) { + actualPayload = new Payload.Simple(payloadDetails); + } + + List actualLinks = new ArrayList<>(); + List> linkMap = (List>) objectPath.evaluate(IncidentEvent.Fields.LINKS.getPreferredName()); + if (linkMap != null) { + for (Map iecValue : linkMap) { + actualLinks.add(IncidentEventContext.link(iecValue.get("href"), iecValue.get("text"))); + } + } + + List actualImages = new ArrayList<>(); + List> imgMap = (List>) objectPath.evaluate(IncidentEvent.Fields.IMAGES.getPreferredName()); + if (imgMap != null) { + for (Map iecValue : imgMap) { + actualImages.add(IncidentEventContext.image(iecValue.get("src"), iecValue.get("href"), iecValue.get("alt"))); + } + } + + // assert the actuals were the same as expected + assertThat(serviceKey, equalTo(actualServiceKey)); + assertThat(eventType, equalTo(actualEventType)); + assertThat(incidentKey, equalTo(actualIncidentKey)); + assertThat(description, equalTo(actualDescription)); + assertThat(watchId, equalTo(actualWatchId)); + assertThat("critical", equalTo(actualSeverity)); + assertThat(client, equalTo(actualClient)); + assertThat(clientUrl, equalTo(actualClientUrl)); + assertThat(links, equalTo(actualLinks)); + assertThat(images, equalTo(actualImages)); + assertThat(payload, equalTo(actualPayload)); + } +} diff --git a/x-pack/plugin/watcher/src/test/java/org/elasticsearch/xpack/watcher/notification/pagerduty/PagerDutyAccountsTests.java b/x-pack/plugin/watcher/src/test/java/org/elasticsearch/xpack/watcher/notification/pagerduty/PagerDutyAccountsTests.java index d70badc4bec..1e88c696142 100644 --- a/x-pack/plugin/watcher/src/test/java/org/elasticsearch/xpack/watcher/notification/pagerduty/PagerDutyAccountsTests.java +++ b/x-pack/plugin/watcher/src/test/java/org/elasticsearch/xpack/watcher/notification/pagerduty/PagerDutyAccountsTests.java @@ -24,6 +24,7 @@ import java.util.HashSet; import static org.hamcrest.Matchers.is; import static org.hamcrest.Matchers.notNullValue; +import static org.hamcrest.Matchers.nullValue; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; @@ -48,7 +49,7 @@ public class PagerDutyAccountsTests extends ESTestCase { HttpProxy proxy = new HttpProxy("localhost", 8080); IncidentEvent event = new IncidentEvent("foo", null, null, null, null, account.getName(), true, null, proxy); - account.send(event, Payload.EMPTY); + account.send(event, Payload.EMPTY, null); HttpRequest request = argumentCaptor.getValue(); assertThat(request.proxy(), is(proxy)); @@ -72,11 +73,13 @@ public class PagerDutyAccountsTests extends ESTestCase { "https://www.elastic.co/products/x-pack/alerting", "X-Pack-Alerting website link with log") }; IncidentEvent event = new IncidentEvent("foo", null, null, null, null, account.getName(), true, contexts, HttpProxy.NO_PROXY); - account.send(event, Payload.EMPTY); + account.send(event, Payload.EMPTY, null); HttpRequest request = argumentCaptor.getValue(); ObjectPath source = ObjectPath.createFromXContent(JsonXContent.jsonXContent, new BytesArray(request.body())); - assertThat(source.evaluate("contexts"), notNullValue()); + assertThat(source.evaluate("contexts"), nullValue()); + assertThat(source.evaluate("links"), notNullValue()); + assertThat(source.evaluate("images"), notNullValue()); } private void addAccountSettings(String name, Settings.Builder builder) {