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);
}
SentEvent sentEvent = account.send(event, payload);
SentEvent sentEvent = account.send(event, payload, ctx.id().watchId());
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.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<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
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");
}
}

View File

@ -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;

View File

@ -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);
}

View File

@ -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);

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.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) {