Java tutorial
/* * The MIT License (MIT) * * Copyright (c) 2016 Gerd Naschenweng / bidorbuy.co.za * * Original idea from https://github.com/tjackiw/graylog-plugin-jira * * Permission is hereby granted, free of charge, to any person obtaining a copy * of this software and associated documentation files (the "Software"), to deal * in the Software without restriction, including without limitation the rights * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell * copies of the Software, and to permit persons to whom the Software is * furnished to do so, subject to the following conditions: * * The above copyright notice and this permission notice shall be included in * all copies or substantial portions of the Software. * * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. * */ package com.bidorbuy.graylog.alarmcallbacks.jira; import java.math.BigInteger; import java.net.URI; import java.net.URISyntaxException; import java.security.MessageDigest; import java.util.*; import java.util.regex.*; import org.apache.commons.lang3.StringEscapeUtils; import org.apache.commons.lang3.StringUtils; import org.graylog2.plugin.MessageSummary; import org.graylog2.plugin.Tools; import org.graylog2.plugin.alarms.AlertCondition; import org.graylog2.plugin.alarms.callbacks.*; import org.graylog2.plugin.configuration.*; import org.graylog2.plugin.configuration.fields.ConfigurationField; import org.graylog2.plugin.configuration.fields.TextField; import org.graylog2.plugin.streams.Stream; import org.graylog2.plugin.streams.StreamRule; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import com.google.common.collect.ImmutableList; import com.google.common.collect.Maps; public class JiraAlarmCallback implements AlarmCallback { // Configuration Constants public static final String CK_JIRA_INSTANCE_URL = "jira_instance_url"; public static final String CK_JIRA_USERNAME = "jira_username"; public static final String CK_JIRA_PASSWORD = "jira_password"; public static final String CK_JIRA_PROJECT_KEY = "jira_project_key"; public static final String CK_JIRA_TITLE_TEMPLATE = "jira_title_template"; public static final String CK_JIRA_ISSUE_TYPE = "jira_issue_type"; public static final String CK_JIRA_LABELS = "jira_labels"; public static final String CK_JIRA_PRIORITY = "jira_priority"; public static final String CK_JIRA_COMPONENTS = "jira_components"; public static final String CK_JIRA_MESSAGE_TEMPLATE = "jira_message_template"; public static final String CK_JIRA_MD5_CUSTOM_FIELD = "jira_md5_custom_field"; public static final String CK_JIRA_MD5_HASH_PATTERN = "jira_md5_hash_pattern"; public static final String CK_JIRA_MD5_FILTER_QUERY = "jira_md5_filter_query"; public static final String CK_JIRA_GRAYLOG_MAPPING = "jira_graylog_message_field_mapping"; public static final String CK_GRAYLOG_URL = "graylog_url"; public static final String CK_MESSAGE_REGEX = "message_regex"; // Validation rules for config check private static final List<String> SENSITIVE_CONFIGURATION_KEYS = ImmutableList.of(CK_JIRA_PASSWORD); private static final String[] CONFIGURATION_KEYS_MANDATORY = new String[] { CK_JIRA_INSTANCE_URL, CK_JIRA_USERNAME, CK_JIRA_PASSWORD, CK_JIRA_PROJECT_KEY, CK_JIRA_ISSUE_TYPE }; private static final String[] CONFIGURATION_KEYS_URL_VALIDATION = new String[] { CK_JIRA_INSTANCE_URL, CK_GRAYLOG_URL }; // The default template for JIRA messages private static final String CONST_JIRA_MESSAGE_TEMPLATE = "[STREAM_RESULT]\n\n *Stream title:* \n [STREAM_TITLE]\n\n" + " *Stream URL:* \n [STREAM_URL]\n\n" + " *Stream rules:* \n [STREAM_RULES]\n\n" + " *Alert triggered at:* \n [ALERT_TRIGGERED_AT]\n\n" + " *Triggered condition:* \n [ALERT_TRIGGERED_CONDITION]\n\n" + " *Source:* \n [LAST_MESSAGE.source]\n\n" + " *Message:* \n [LAST_MESSAGE.message]\n\n"; // The default title template for JIRA messages private static final String CONST_JIRA_TITLE_TEMPLATE = "Jira [MESSAGE_REGEX]"; // The message regex template used to extract content for an exception MD5 private static final String CONST_JIRA_MESSAGE_REGEX = "([a-zA-Z_.]+(?!.*Exception): .+)"; // The default MD5 template private static final String CONST_JIRA_MD5_TEMPLATE = "[MESSAGE_REGEX]"; private static final String CONST_JIRA_MD5_FILTER_QUERY_TEMPLATE = "AND Status not in (Closed, Done, Resolved)"; // The logger private static final Logger LOG = LoggerFactory.getLogger(JiraAlarmCallback.class); // The plugin configuration private Configuration configuration; /* This is called once at the very beginning of the lifecycle of this plugin. It is common practice to * store the Configuration as a private member for later access. * @see org.graylog2.plugin.alarms.callbacks.AlarmCallback#initialize(org.graylog2.plugin.configuration.Configuration) */ @Override public void initialize(final Configuration config) throws AlarmCallbackConfigurationException { this.configuration = config; } /* This is the actual alarm callback being triggered. * @see org.graylog2.plugin.alarms.callbacks.AlarmCallback#call(org.graylog2.plugin.streams.Stream, org.graylog2.plugin.alarms.AlertCondition.CheckResult) */ @Override public void call(final Stream stream, final AlertCondition.CheckResult result) throws AlarmCallbackException { call(new JiraIssueClient(configuration.getString(CK_JIRA_PROJECT_KEY), buildJIRATitle(stream, result), buildDescription(stream, result), configuration.getString(CK_JIRA_LABELS), configuration.getString(CK_JIRA_ISSUE_TYPE), configuration.getString(CK_JIRA_COMPONENTS), configuration.getString(CK_JIRA_PRIORITY), configuration.getString(CK_JIRA_INSTANCE_URL), configuration.getString(CK_JIRA_USERNAME), configuration.getString(CK_JIRA_PASSWORD), configuration.getString(CK_JIRA_MD5_FILTER_QUERY), configuration.getString(CK_JIRA_MD5_CUSTOM_FIELD), buildJIRAGraylogMapping(stream, result), getJIRAMessageDigest(stream, result)), stream, result); } /** * Trigger the event * @param client * @param stream * @param result * @throws AlarmCallbackException */ void call(final JiraIssueClient client, final Stream stream, final AlertCondition.CheckResult result) throws AlarmCallbackException { client.trigger(stream, result); } /* Plugins can request configurations. The UI in the Graylog web interface is generated from this information and * the filled out configuration values are passed back to the plugin in initialize(Configuration configuration). * @see org.graylog2.plugin.alarms.callbacks.AlarmCallback#getRequestedConfiguration() */ @Override public ConfigurationRequest getRequestedConfiguration() { final ConfigurationRequest configurationRequest = new ConfigurationRequest(); configurationRequest.addField(new TextField(CK_JIRA_INSTANCE_URL, "JIRA Instance URL", "", "JIRA server URL.", ConfigurationField.Optional.NOT_OPTIONAL)); configurationRequest.addField(new TextField(CK_JIRA_USERNAME, "JIRA username", "", "Username to login to JIRA and create issues.", ConfigurationField.Optional.NOT_OPTIONAL)); configurationRequest .addField(new TextField(CK_JIRA_PASSWORD, "JIRA password", "", "Password to login to JIRA.", ConfigurationField.Optional.NOT_OPTIONAL, TextField.Attribute.IS_PASSWORD)); configurationRequest.addField(new TextField(CK_JIRA_PROJECT_KEY, "JIRA project Key", "", "Project under which the issue will be created.", ConfigurationField.Optional.NOT_OPTIONAL)); configurationRequest.addField(new TextField(CK_JIRA_ISSUE_TYPE, "JIRA issue Type", "Bug", "Type of issue.", ConfigurationField.Optional.NOT_OPTIONAL)); configurationRequest.addField(new TextField(CK_JIRA_MESSAGE_TEMPLATE, "JIRA message template", CONST_JIRA_MESSAGE_TEMPLATE.replaceAll("\n", "\\\n"), "Message template for JIRA", ConfigurationField.Optional.NOT_OPTIONAL)); configurationRequest.addField( new TextField(CK_JIRA_TITLE_TEMPLATE, "JIRA issue title template", CONST_JIRA_TITLE_TEMPLATE, "Title template for JIRA tasks", ConfigurationField.Optional.NOT_OPTIONAL)); configurationRequest.addField(new TextField(CK_GRAYLOG_URL, "Graylog URL", null, "URL to your Graylog web interface. Used to build links in alarm notification.", ConfigurationField.Optional.NOT_OPTIONAL)); configurationRequest.addField(new TextField(CK_JIRA_PRIORITY, "JIRA Issue Priority", "Minor", "Priority of the issue.", ConfigurationField.Optional.OPTIONAL)); configurationRequest.addField(new TextField(CK_JIRA_LABELS, "JIRA Labels", "", "List of comma-separated labels to add to this issue - i.e. graylog", ConfigurationField.Optional.OPTIONAL)); configurationRequest.addField(new TextField(CK_JIRA_COMPONENTS, "JIRA Components", "", "List of comma-separated components to add to this issue.", ConfigurationField.Optional.OPTIONAL)); configurationRequest.addField(new TextField(CK_MESSAGE_REGEX, "Message regex", "", "Message regex to extract message content. Example: " + CONST_JIRA_MESSAGE_REGEX, ConfigurationField.Optional.OPTIONAL)); configurationRequest.addField(new TextField(CK_JIRA_MD5_HASH_PATTERN, "JIRA MD5 pattern", "", "Pattern to construct MD5. Example: " + CONST_JIRA_MD5_TEMPLATE, ConfigurationField.Optional.OPTIONAL)); configurationRequest.addField(new TextField(CK_JIRA_MD5_CUSTOM_FIELD, "JIRA MD5 custom field", "", "Custom field name for the MD5 hash, this will be in the format of customfield_####. If not set, we will try and find it", ConfigurationField.Optional.OPTIONAL)); configurationRequest.addField(new TextField(CK_JIRA_MD5_FILTER_QUERY, "JIRA duplicate filter query", "", "Additional filter query to check for duplicates. Example: " + CONST_JIRA_MD5_FILTER_QUERY_TEMPLATE, ConfigurationField.Optional.OPTIONAL)); configurationRequest.addField(new TextField(CK_JIRA_GRAYLOG_MAPPING, "JIRA/Graylog field mapping", "", "List of comma-separated Graylog/JIRA mapping fields to automatically map Graylog message fields into JIRA", ConfigurationField.Optional.OPTIONAL)); return configurationRequest; } /* Return attributes that might be interesting to be shown under the alarm callback in the Graylog web interface. * It is common practice to at least return the used configuration here. * @see org.graylog2.plugin.alarms.callbacks.AlarmCallback#getAttributes() */ @Override public Map<String, Object> getAttributes() { return Maps.transformEntries(configuration.getSource(), new Maps.EntryTransformer<String, Object, Object>() { @Override public Object transformEntry(String key, Object value) { if (SENSITIVE_CONFIGURATION_KEYS.contains(key)) { return "****"; } return value; } }); } /* Throw a ConfigurationException if the user should have entered missing or invalid configuration parameters. * @see org.graylog2.plugin.alarms.callbacks.AlarmCallback#checkConfiguration() */ @Override public void checkConfiguration() throws ConfigurationException { // Check if we have all mandatory keys for (String key : CONFIGURATION_KEYS_MANDATORY) { if (!configuration.stringIsSet(key)) { throw new ConfigurationException(key + " is mandatory and must not be empty."); } } // Check if the provided URLs are valid for (String key : CONFIGURATION_KEYS_URL_VALIDATION) { if (configuration.stringIsSet(key) && !configuration.getString(key).equals("null")) { try { final URI configURI = new URI(configuration.getString(key)); if (!"http".equals(configURI.getScheme()) && !"https".equals(configURI.getScheme())) { throw new ConfigurationException(key + " must be a valid HTTP or HTTPS URL."); } } catch (URISyntaxException e) { throw new ConfigurationException("Couldn't parse " + key + " correctly.", e); } } } } /* Return a human readable name of this plugin. * @see org.graylog2.plugin.alarms.callbacks.AlarmCallback#getName() */ @Override public String getName() { return "Graylog JIRA integration plugin"; } /** * Generates the MD5 digest of either the message or a number of fields provided * @param stream * @param checkResult * @return */ private String getJIRAMessageDigest(final Stream stream, final AlertCondition.CheckResult result) { String JiraMessageDigest = ""; // Get the last message if (!result.getMatchingMessages().isEmpty()) { String JiraMessageRegex = ""; MessageSummary lastMessage = result.getMatchingMessages().get(0); // Let's extract the message regex first if (configuration.stringIsSet(CK_MESSAGE_REGEX) && !configuration.getString(CK_MESSAGE_REGEX).equals("null")) { try { Matcher matcher = Pattern.compile(configuration.getString(CK_MESSAGE_REGEX)) .matcher(lastMessage.getMessage()); if (matcher.find()) { JiraMessageRegex = lastMessage.getMessage().substring(matcher.start()); } } catch (Exception ex) { LOG.warn("Error in JIRA-issue MD5-MESSAGE_REGEX generation: " + ex.getMessage()); } } String JiraMD5Content = ""; // Let's extract the message regex first if (configuration.stringIsSet(CK_JIRA_MD5_HASH_PATTERN) && !configuration.getString(CK_JIRA_MD5_HASH_PATTERN).equals("null")) { try { JiraMD5Content = configuration.getString(CK_JIRA_MD5_HASH_PATTERN); // replace the message-regex place-holder JiraMD5Content = JiraMD5Content.replace("[MESSAGE_REGEX]", JiraMessageRegex); // iterate through all the message fields and replace the template Map<String, Object> lastMessageFields = lastMessage.getFields(); for (Map.Entry<String, Object> arg : lastMessageFields.entrySet()) { JiraMD5Content = JiraMD5Content.replace("[LAST_MESSAGE." + arg.getKey() + "]", arg.getValue().toString()); } // We regex template fields which have not been replaced JiraMD5Content = JiraMD5Content.replaceAll("\\[LAST_MESSAGE\\.[^\\]]*\\]", ""); } catch (Exception ex) { LOG.warn("Error in JIRA-issue MD5-HASH_PATTERN generation: " + ex.getMessage()); } } // We default the extracted message as the template if (StringUtils.isBlank(JiraMD5Content)) { JiraMD5Content = JiraMessageRegex; } // Create the MD5 from the template if (StringUtils.isNotBlank(JiraMD5Content)) { try { MessageDigest m = MessageDigest.getInstance("MD5"); m.update(JiraMD5Content.getBytes(), 0, JiraMD5Content.length()); JiraMessageDigest = new BigInteger(1, m.digest()).toString(16); } catch (Exception ex) { LOG.warn("Error in JIRA-issue MD5 generation (MD5-string=" + JiraMD5Content + "): " + ex.getMessage()); } } else { LOG.warn("Skipped MD5-hash creation, MD5-string is empty. Check your config"); } } else { LOG.warn("Skipping JIRA-issue MD5 generation, alarmcallback did not provide a message"); } return JiraMessageDigest; } /** * Build the JIRA issue title * @param stream * @param result * @return */ private String buildJIRATitle(final Stream stream, final AlertCondition.CheckResult result) { StringBuilder sb = new StringBuilder(); try { if (!result.getMatchingMessages().isEmpty()) { // get fields from last message only MessageSummary lastMessage = result.getMatchingMessages().get(0); Map<String, Object> lastMessageFields = lastMessage.getFields(); String strTitle = "[Alert] Graylog alert for stream: " + stream.getTitle(); if (configuration.stringIsSet(CK_JIRA_TITLE_TEMPLATE) && !configuration.getString(CK_JIRA_TITLE_TEMPLATE).equals("null")) { strTitle = configuration.getString(CK_JIRA_TITLE_TEMPLATE); } strTitle = strTitle.replace("[LAST_MESSAGE.source]", lastMessage.getSource()); for (Map.Entry<String, Object> arg : lastMessageFields.entrySet()) { strTitle = strTitle.replace("[LAST_MESSAGE." + arg.getKey() + "]", arg.getValue().toString()); } if (configuration.stringIsSet(CK_MESSAGE_REGEX) && !configuration.getString(CK_MESSAGE_REGEX).equals("null")) { Matcher matcher = Pattern.compile(configuration.getString(CK_MESSAGE_REGEX)) .matcher(lastMessage.getMessage()); if (matcher.find()) { if (configuration.stringIsSet(CK_JIRA_TITLE_TEMPLATE) && !configuration.getString(CK_JIRA_TITLE_TEMPLATE).equals("null")) { strTitle = strTitle.replace("[MESSAGE_REGEX]", matcher.group()); } else { strTitle = "[Graylog] " + matcher.group(); } } } // We regex template fields which have not been replaced strTitle = strTitle.replaceAll("\\[LAST_MESSAGE\\.[^\\]]*\\]", ""); sb.append(strTitle); } } catch (Exception ex) { ; // can not do anything - we skip LOG.error("Error in building title: " + ex.getMessage()); } if (sb.length() == 0) { sb.append("[Alert] Graylog alert for stream: ").append(stream.getTitle()); } return sb.toString(); } /** * Build the JIRA description * @param stream * @param result * @return */ private String buildDescription(final Stream stream, final AlertCondition.CheckResult result) { String strMessage = CONST_JIRA_MESSAGE_TEMPLATE; if (configuration.stringIsSet(CK_JIRA_MESSAGE_TEMPLATE) && !configuration.getString(CK_JIRA_MESSAGE_TEMPLATE).equals("null") && !configuration.getString(CK_JIRA_MESSAGE_TEMPLATE).isEmpty()) { strMessage = configuration.getString(CK_JIRA_MESSAGE_TEMPLATE); } strMessage = StringEscapeUtils.unescapeJava(strMessage); // Get the last message if (!result.getMatchingMessages().isEmpty()) { // get fields from last message only MessageSummary lastMessage = result.getMatchingMessages().get(0); Map<String, Object> lastMessageFields = lastMessage.getFields(); strMessage = strMessage.replace("[LAST_MESSAGE.message]", lastMessage.getMessage()); strMessage = strMessage.replace("[LAST_MESSAGE.source]", lastMessage.getSource()); for (Map.Entry<String, Object> arg : lastMessageFields.entrySet()) { strMessage = strMessage.replace("[LAST_MESSAGE." + arg.getKey() + "]", arg.getValue().toString()); } // We regex template fields which have not been replaced strMessage = strMessage.replaceAll("\\[LAST_MESSAGE\\.[^\\]]*\\]", ""); } // replace placeholders strMessage = strMessage.replace("[CALLBACK_DATE]", Tools.iso8601().toString()); strMessage = strMessage.replace("[STREAM_ID]", stream.getId()); strMessage = strMessage.replace("[STREAM_TITLE]", stream.getTitle()); strMessage = strMessage.replace("[STREAM_URL]", buildStreamURL(configuration.getString(CK_GRAYLOG_URL), stream)); strMessage = strMessage.replace("[STREAM_RULES]", buildStreamRules(stream)); strMessage = strMessage.replace("[STREAM_RESULT]", result.getResultDescription()); strMessage = strMessage.replace("[ALERT_TRIGGERED_AT]", result.getTriggeredAt().toString()); strMessage = strMessage.replace("[ALERT_TRIGGERED_CONDITION]", result.getTriggeredCondition().toString()); // create final string StringBuilder sb = new StringBuilder(); sb.append("\n\n"); sb.append(strMessage).append("\n\n"); return sb.toString(); } /** * Build stream URL string * @param baseUrl * @param stream * @return */ protected String buildStreamURL(final String configURL, final Stream stream) { String baseUrl = configURL; if (!baseUrl.endsWith("/")) { baseUrl += "/"; } return baseUrl + "streams/" + stream.getId() + "/messages?q=*&rangetype=relative&relative=3600"; } /** * Build the stream rules * @param stream * @return */ protected String buildStreamRules(final Stream stream) { StringBuilder sb = new StringBuilder(); for (StreamRule streamRule : stream.getStreamRules()) { sb.append("_").append(streamRule.getField()).append("_ "); sb.append(streamRule.getType()).append(" _").append(streamRule.getValue()).append("_").append("\n"); } return sb.toString(); } /** * Build up a list of JIRA/Graylog field mappings * @param stream * @param result * @return */ private Map<String, String> buildJIRAGraylogMapping(final Stream stream, final AlertCondition.CheckResult result) { Map<String, String> JIRAFieldMapping = new HashMap<String, String>(); if (configuration.stringIsSet(CK_JIRA_GRAYLOG_MAPPING) && !configuration.getString(CK_JIRA_GRAYLOG_MAPPING).equals("null") && !result.getMatchingMessages().isEmpty()) { try { // get fields from last message only MessageSummary lastMessage = result.getMatchingMessages().get(0); String[] mappingPairs = StringUtils.split(configuration.getString(CK_JIRA_GRAYLOG_MAPPING), ','); if (mappingPairs != null && mappingPairs.length > 0) { for (String mappingString : mappingPairs) { String[] mapping = StringUtils.split(mappingString, '='); if (mapping.length == 2 && lastMessage.hasField(mapping[0])) { Object test = lastMessage.getField(mapping[0]); JIRAFieldMapping.put(mapping[1], test.toString()); } } } } catch (Exception ex) { ; // can not do anything - we skip LOG.error("Error in generating JIRA/Graylog mapping " + ex.getMessage()); } } return JIRAFieldMapping; } }