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:
parent
cd0de16089
commit
4c90a61a35
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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));
|
||||
}
|
||||
}
|
|
@ -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) {
|
||||
|
|
Loading…
Reference in New Issue