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
This commit is contained in:
Michael Basnight 2018-08-14 11:06:18 -05:00 committed by GitHub
parent cd0de16089
commit 4c90a61a35
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 296 additions and 46 deletions

View File

@ -47,7 +47,7 @@ public class ExecutablePagerDutyAction extends ExecutableAction<PagerDutyAction>
return new PagerDutyAction.Result.Simulated(event); 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); return new PagerDutyAction.Result.Executed(account.getName(), sentEvent);
} }

View File

@ -24,22 +24,22 @@ import org.elasticsearch.xpack.watcher.common.text.TextTemplateEngine;
import java.io.IOException; import java.io.IOException;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Arrays; import java.util.Arrays;
import java.util.Collections;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Objects; import java.util.Objects;
import java.util.stream.Collectors;
/** /**
* Official documentation for this can be found at * Official documentation for this can be found at
* *
* https://developer.pagerduty.com/documentation/howto/manually-trigger-an-incident/ * https://v2.developer.pagerduty.com/docs/send-an-event-events-api-v2
* https://developer.pagerduty.com/documentation/integration/events/trigger
* https://developer.pagerduty.com/documentation/integration/events/acknowledge
* https://developer.pagerduty.com/documentation/integration/events/resolve
*/ */
public class IncidentEvent implements ToXContentObject { public class IncidentEvent implements ToXContentObject {
static final String HOST = "events.pagerduty.com"; 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; final String description;
@Nullable final HttpProxy proxy; @Nullable final HttpProxy proxy;
@ -93,46 +93,81 @@ public class IncidentEvent implements ToXContentObject {
return result; 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) return HttpRequest.builder(HOST, -1)
.method(HttpMethod.POST) .method(HttpMethod.POST)
.scheme(Scheme.HTTPS) .scheme(Scheme.HTTPS)
.path(PATH) .path(PATH)
.proxy(proxy) .proxy(proxy)
.jsonBody(new ToXContent() { .setHeader("Accept", ACCEPT_HEADER)
@Override .jsonBody((b, p) -> buildAPIXContent(b, p, serviceKey, payload, watchId))
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;
}
})
.build(); .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<IncidentEventContext.Type, List<IncidentEventContext>> groups = Arrays.stream(contexts)
.collect(Collectors.groupingBy(iec -> iec.type));
List<IncidentEventContext> links = groups.getOrDefault(IncidentEventContext.Type.LINK, Collections.emptyList());
if (links.isEmpty() == false) {
builder.array(Fields.LINKS.getPreferredName(), links.toArray());
}
List<IncidentEventContext> images = groups.getOrDefault(IncidentEventContext.Type.IMAGE, Collections.emptyList());
if (images.isEmpty() == false) {
builder.array(Fields.IMAGES.getPreferredName(), images.toArray());
}
}
@Override @Override
public XContentBuilder toXContent(XContentBuilder builder, ToXContent.Params params) throws IOException { public XContentBuilder toXContent(XContentBuilder builder, ToXContent.Params params) throws IOException {
builder.startObject(); builder.startObject();
@ -445,8 +480,15 @@ public class IncidentEvent implements ToXContentObject {
// we need to keep this for BWC // we need to keep this for BWC
ParseField CONTEXT_DEPRECATED = new ParseField("context"); ParseField CONTEXT_DEPRECATED = new ParseField("context");
ParseField SERVICE_KEY = new ParseField("service_key");
ParseField PAYLOAD = new ParseField("payload"); 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");
} }
} }

View File

@ -92,6 +92,85 @@ public class IncidentEventContext implements ToXContentObject {
return builder.endObject(); 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 { public static class Template implements ToXContentObject {
final Type type; final Type type;

View File

@ -48,8 +48,8 @@ public class PagerDutyAccount {
return eventDefaults; return eventDefaults;
} }
public SentEvent send(IncidentEvent event, Payload payload) throws IOException { public SentEvent send(IncidentEvent event, Payload payload, String watchId) throws IOException {
HttpRequest request = event.createRequest(serviceKey, payload); HttpRequest request = event.createRequest(serviceKey, payload, watchId);
HttpResponse response = httpClient.execute(request); HttpResponse response = httpClient.execute(request);
return SentEvent.responded(event, request, response); return SentEvent.responded(event, request, response);
} }

View File

@ -111,7 +111,7 @@ public class PagerDutyActionTests extends ESTestCase {
when(response.status()).thenReturn(200); when(response.status()).thenReturn(200);
HttpRequest request = mock(HttpRequest.class); HttpRequest request = mock(HttpRequest.class);
SentEvent sentEvent = SentEvent.responded(event, request, response); 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); when(service.getAccount(accountName)).thenReturn(account);
Action.Result result = executable.execute("_id", ctx, payload); Action.Result result = executable.execute("_id", ctx, payload);

View File

@ -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<IncidentEventContext> links = new ArrayList<>();
List<IncidentEventContext> 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<String, Object> payloadDetails = objectPath.evaluate("payload.custom_details.payload");
Payload actualPayload = null;
if (payloadDetails != null) {
actualPayload = new Payload.Simple(payloadDetails);
}
List<IncidentEventContext> actualLinks = new ArrayList<>();
List<Map<String, String>> linkMap = (List<Map<String, String>>) objectPath.evaluate(IncidentEvent.Fields.LINKS.getPreferredName());
if (linkMap != null) {
for (Map<String, String> iecValue : linkMap) {
actualLinks.add(IncidentEventContext.link(iecValue.get("href"), iecValue.get("text")));
}
}
List<IncidentEventContext> actualImages = new ArrayList<>();
List<Map<String, String>> imgMap = (List<Map<String, String>>) objectPath.evaluate(IncidentEvent.Fields.IMAGES.getPreferredName());
if (imgMap != null) {
for (Map<String, String> 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));
}
}

View File

@ -24,6 +24,7 @@ import java.util.HashSet;
import static org.hamcrest.Matchers.is; import static org.hamcrest.Matchers.is;
import static org.hamcrest.Matchers.notNullValue; import static org.hamcrest.Matchers.notNullValue;
import static org.hamcrest.Matchers.nullValue;
import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when; import static org.mockito.Mockito.when;
@ -48,7 +49,7 @@ public class PagerDutyAccountsTests extends ESTestCase {
HttpProxy proxy = new HttpProxy("localhost", 8080); HttpProxy proxy = new HttpProxy("localhost", 8080);
IncidentEvent event = new IncidentEvent("foo", null, null, null, null, account.getName(), true, null, proxy); 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(); HttpRequest request = argumentCaptor.getValue();
assertThat(request.proxy(), is(proxy)); 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") "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); 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(); HttpRequest request = argumentCaptor.getValue();
ObjectPath source = ObjectPath.createFromXContent(JsonXContent.jsonXContent, new BytesArray(request.body())); 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) { private void addAccountSettings(String name, Settings.Builder builder) {