/*
* Copyright 2015-2017 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.hawkular.alerter.elasticsearch;
import static org.hawkular.alerter.elasticsearch.ServiceNames.Service.ALERTS_SERVICE;
import static org.hawkular.alerter.elasticsearch.ServiceNames.Service.DEFINITIONS_SERVICE;
import static org.hawkular.alerter.elasticsearch.ServiceNames.Service.PROPERTIES_SERVICE;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.ScheduledThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
import javax.annotation.PostConstruct;
import javax.annotation.PreDestroy;
import javax.annotation.Resource;
import javax.ejb.Singleton;
import javax.ejb.Startup;
import javax.enterprise.concurrent.ManagedExecutorService;
import javax.naming.InitialContext;
import javax.naming.NamingException;
import org.hawkular.alerts.api.model.trigger.Trigger;
import org.hawkular.alerts.api.services.AlertsService;
import org.hawkular.alerts.api.services.DefinitionsService;
import org.hawkular.alerts.api.services.DistributedEvent;
import org.hawkular.alerts.api.services.PropertiesService;
import org.jboss.logging.Logger;
/**
* This is the main class of the Elasticsearch Alerter.
*
* The Elasticsearch Alerter will listen for triggers tagged with "Elasticsearch" tag. The Alerter will schedule a
* periodic query to an Elasticsearch system with the info provided from the tagged trigger context. The Alerter
* will convert Elasticsearch documents into Hawkular Alerting Events and send them into the Alerting engine.
*
* The Elasticsearch Alerter uses the following conventions for trigger tags and context:
*
* <pre>
*
* - [Required] trigger.tags["Elasticsearch"] = "<value reserved for future uses>"
*
* An "Elasticsearch" tag is required for the alerter to detect this trigger will query to an Elasticsearch system.
* Value is not necessary, it can be used as a description, it is reserved for future uses.
*
* i.e. trigger.tags["Elasticsearch"] = "" // Empty value is valid
* trigger.tags["Elasticsearch"] = "OpenShift Logging System" // It can be used as description
*
* - [Required] trigger.context["timestamp"] = "<timestamp field>"
*
* Documents fetched from Elasticsearch need a date field to indicate the timestamp.
* This timestamp will be used in the queries to fetch documents in interval basis.
*
* If there is not defined a specific pattern under the trigger.context["timestamp_pattern"] it will follow the
* default patterns:
*
* "yyyy-MM-dd'T'HH:mm:ss.SSSSSSZ"
* "yyyy-MM-dd'T'HH:mm:ssZ"
*
* - [Required] trigger.context["mapping"] = "<mapping_expression>"
*
* A mapping expressions defines how to convert an Elasticsearch document into a Hawkular Event:
*
* <mapping_expression> ::= <mapping> | <mapping> "," <mapping_expression>
* <mapping> ::= <elasticsearch_field> [ "|" "'" <DEFAULT_VALUE> "'" ] ":" <hawkular_event_field>
* <elasticsearch_field> ::= "index" | "id" | <SOURCE_FIELD>
* <hawkular_event_field> ::= "id" | "ctime" | "dataSource" | "dataId" | "category" | "text" | "context" | "tags"
*
* A minimum mapping for the "dataId" is required.
* If a mapping is not present in an Elasticsearch document it will return an empty value.
* It is possible to define a default value for cases when the Elasticsearch field is not present.
* Special Elasticsearch metafields "_index" and "_id" are supported under "index" and "id" labels.
*
* i.e. trigger.context["mapping"] = "level|'INFO':category,@timestamp:ctime,message:text,hostname:dataId,index:tags"
*
* - [Optional] trigger.context["interval"] = "[0-9]+[smh]" (i.e. 30s, 2h, 10m)
*
* Defines the periodic interval when a query will be performed against an Elasticsearch system.
* If not value provided, default one is "2m" (two minutes).
*
* i.e. trigger.context["interval"] = "30s" will perform queries each 30 seconds fetching new documents generated
* on the last 30 seconds, using the timestamp field provided in the Alerter tag.
*
* - [Optional] trigger.context["timestamp_pattern"] = "<date and time pattern>"
*
* Defines a new time pattern for the trigger.context["timestamp"]. It must follow supported formats of
* {@link java.time.format.DateTimeFormatter}. If it is not present, it will expect default patterns:
*
* "yyyy-MM-dd'T'HH:mm:ss.SSSSSSZ"
* "yyyy-MM-dd'T'HH:mm:ssZ"
*
* - [Optional] trigger.context["index"] = "<elastic_search_index>"
*
* Defines the index where the documents will be queried. If not index defined, query will search under all indexes
* defined.
*
* - [Optional] trigger.context["filter"] = "<elastic_search_query_filter>"
*
* By default the Elasticsearch Alerter performs a range query over the timestamp field provided in the alerter tag.
* This query accepts additional filter in Elasticsearch format. The final query should be built from:
*
* {
* "query":{
* "constant_score":{
* "filter":{
* "bool":{
* "must": [<range_query_on_timestamp>, <elastic_search_query_filter>]
* }
* }
* }
* }
* }
*
* - [Optional] trigger.context["url"]
*
* Elasticsearch url can be defined in several ways in the alerter.
* If can be defined globally as system properties:
*
* hawkular-alerts.elasticsearch-url
*
* It can be defined globally from system env variables:
*
* ELASTICSEARCH_URL
*
* Or it can be overwritten per trigger using
*
* trigger.context["url"]
*
* Url can be a list of valid {@link org.apache.http.HttpHost} urls. By default it will point to
*
* trigger.context["url"] = "http://localhost:9200"
*
* i.e.
*
* trigger.context["url"] = "http://host1:9200,http://host2:9200,http://host3:9200"
*
* </pre>
*
* @author Jay Shaughnessy
* @author Lucas Ponce
*/
@Startup
@Singleton
public class ElasticsearchAlerter {
private static final Logger log = Logger.getLogger(ElasticsearchAlerter.class);
private static final String ELASTICSEARCH_ALERTER = "hawkular-alerts.elasticsearch-alerter";
private static final String ELASTICSEARCH_ALERTER_ENV = "ELASTICSEARCH_ALERTER";
private static final String ELASTICSEARCH_ALERTER_DEFAULT = "true";
private boolean elasticSearchAlerter;
private static final String ELASTICSEARCH_URL="hawkular-alerts.elasticsearch-url";
private static final String ELASTICSEARCH_URL_ENV = "ELASTICSEARCH_URL";
private static final String ELASTICSEARCH_URL_DEFAULT = "http://localhost:9200";
private static final String ELASTICSEARCH_FORWARDED_FOR = "hawkular-alerts.elasticsearch-forwarded-for";
private static final String ELASTICSEARCH_FORWARDED_FOR_ENV = "ELASTICSEARCH_FORWARDED_FOR";
private static final String ELASTICSEARCH_FORWARDED_FOR_DEFAULT = "";
private static final String ELASTICSEARCH_TOKEN = "hawkular-alerts.elasticsearch-token";
private static final String ELASTICSEARCH_TOKEN_ENV = "ELASTICSEARCH_TOKEN";
private static final String ELASTICSEARCH_TOKEN_DEFAULT = "";
private static final String ELASTICSEARCH_PROXY_REMOTE_USER = "hawkular-alerts.elasticsearch-proxy-remote-user";
private static final String ELASTICSEARCH_PROXY_REMOTE_USER_ENV = "ELASTICSEARCH_PROXY_REMOTE_USER";
private static final String ELASTICSEARCH_PROXY_REMOTE_USER_DEFAULT = "";
private static final String ALERTER_NAME = "Elasticsearch";
private Map<TriggerKey, Trigger> activeTriggers = new ConcurrentHashMap<>();
private static final Integer THREAD_POOL_SIZE = 20;
private static final String INTERVAL = "interval";
private static final String INTERVAL_DEFAULT = "2m";
private static final String URL = "url";
private static final String FORWARDED_FOR = "forwarded-for";
private static final String PROXY_REMOTE_USER = "proxy-remote-user";
private static final String TOKEN = "token";
private ScheduledThreadPoolExecutor scheduledExecutor;
private Map<TriggerKey, ScheduledFuture<?>> queryFutures = new HashMap<>();
private PropertiesService properties;
private Map<String, String> defaultProperties;
private DefinitionsService definitions;
private AlertsService alerts;
@Resource
private ManagedExecutorService executor;
@PostConstruct
public void init() {
try {
InitialContext ctx = new InitialContext();
properties = (PropertiesService) ctx.lookup(ServiceNames.getServiceName(PROPERTIES_SERVICE));
definitions = (DefinitionsService) ctx.lookup(ServiceNames.getServiceName(DEFINITIONS_SERVICE));
alerts = (AlertsService) ctx.lookup(ServiceNames.getServiceName(ALERTS_SERVICE));
} catch (NamingException e) {
log.errorf("Cannot access to JNDI context", e);
}
if (properties == null || definitions == null || alerts == null) {
throw new IllegalStateException("Elasticsearch Alerter cannot connect with Hawkular Alerting");
}
elasticSearchAlerter = Boolean.parseBoolean(properties.getProperty(ELASTICSEARCH_ALERTER,
ELASTICSEARCH_ALERTER_ENV, ELASTICSEARCH_ALERTER_DEFAULT));
defaultProperties = new HashMap();
defaultProperties.put(URL, properties.getProperty(ELASTICSEARCH_URL, ELASTICSEARCH_URL_ENV,
ELASTICSEARCH_URL_DEFAULT));
defaultProperties.put(TOKEN, properties.getProperty(ELASTICSEARCH_TOKEN, ELASTICSEARCH_TOKEN_ENV,
ELASTICSEARCH_TOKEN_DEFAULT));
defaultProperties.put(FORWARDED_FOR, properties.getProperty(ELASTICSEARCH_FORWARDED_FOR,
ELASTICSEARCH_FORWARDED_FOR_ENV, ELASTICSEARCH_FORWARDED_FOR_DEFAULT));
defaultProperties.put(PROXY_REMOTE_USER, properties.getProperty(ELASTICSEARCH_PROXY_REMOTE_USER,
ELASTICSEARCH_PROXY_REMOTE_USER_ENV, ELASTICSEARCH_PROXY_REMOTE_USER_DEFAULT));
if (elasticSearchAlerter) {
definitions.registerDistributedListener(events -> refresh(events));
}
}
@PreDestroy
public void stop() {
if (scheduledExecutor != null) {
scheduledExecutor.shutdown();
scheduledExecutor = null;
}
}
private void refresh(Set<DistributedEvent> distEvents) {
log.debugf("Events received %s", distEvents);
executor.submit(() -> {
try {
for (DistributedEvent distEvent : distEvents) {
TriggerKey triggerKey = new TriggerKey(distEvent.getTenantId(), distEvent.getTriggerId());
switch (distEvent.getOperation()) {
case REMOVE:
activeTriggers.remove(triggerKey);
break;
case ADD:
if (activeTriggers.containsKey(triggerKey)) {
break;
}
case UPDATE:
Trigger trigger = definitions.getTrigger(distEvent.getTenantId(), distEvent.getTriggerId());
if (trigger != null && trigger.getTags().containsKey(ALERTER_NAME)) {
if (!trigger.isLoadable()) {
activeTriggers.remove(triggerKey);
break;
} else {
activeTriggers.put(triggerKey, trigger);
}
}
}
}
} catch (Exception e) {
log.error("Failed to fetch Triggers for external conditions.", e);
}
update();
});
}
private synchronized void update() {
final Set<TriggerKey> existingKeys = queryFutures.keySet();
final Set<TriggerKey> activeKeys = activeTriggers.keySet();
Set<TriggerKey> newKeys = new HashSet<>();
Set<TriggerKey> canceledKeys = new HashSet<>();
Set<TriggerKey> updatedKeys = new HashSet<>(activeKeys);
updatedKeys.retainAll(activeKeys);
activeKeys.stream().filter(key -> !existingKeys.contains(key)).forEach(key -> newKeys.add(key));
existingKeys.stream().filter(key -> !activeKeys.contains(key)).forEach(key -> canceledKeys.add(key));
log.debugf("newKeys %s", newKeys);
log.debugf("updatedKeys %s", updatedKeys);
log.debugf("canceledKeys %s", canceledKeys);
canceledKeys.stream().forEach(key -> {
ScheduledFuture canceled = queryFutures.remove(key);
if (canceled != null) {
canceled.cancel(false);
}
});
updatedKeys.stream().forEach(key -> {
ScheduledFuture updated = queryFutures.remove(key);
if (updated != null) {
updated.cancel(false);
}
});
if (scheduledExecutor == null) {
scheduledExecutor = new ScheduledThreadPoolExecutor(THREAD_POOL_SIZE);
}
newKeys.addAll(updatedKeys);
for (TriggerKey key : newKeys) {
Trigger t = activeTriggers.get(key);
String interval = t.getContext().get(INTERVAL) == null ? INTERVAL_DEFAULT : t.getContext().get(INTERVAL);
queryFutures.put(key, scheduledExecutor
.scheduleAtFixedRate(new ElasticsearchQuery(t, defaultProperties, alerts),0L,
getIntervalValue(interval), getIntervalUnit(interval)));
}
}
public static int getIntervalValue(String interval) {
if (interval == null || interval.isEmpty()) {
interval = INTERVAL_DEFAULT;
}
try {
return new Integer(interval.substring(0, interval.length() - 1)).intValue();
} catch (Exception e) {
return new Integer(INTERVAL_DEFAULT.substring(0, interval.length() - 1)).intValue();
}
}
public static TimeUnit getIntervalUnit(String interval) {
if (interval == null || interval.isEmpty()) {
interval = INTERVAL_DEFAULT;
}
char unit = interval.charAt(interval.length() - 1);
switch (unit) {
case 'h':
return TimeUnit.HOURS;
case 's':
return TimeUnit.SECONDS;
case 'm':
default:
return TimeUnit.MINUTES;
}
}
private class TriggerKey {
private String tenantId;
private String triggerId;
public TriggerKey(String tenantId, String triggerId) {
this.tenantId = tenantId;
this.triggerId = triggerId;
}
public String getTenantId() {
return tenantId;
}
public void setTenantId(String tenantId) {
this.tenantId = tenantId;
}
public String getTriggerId() {
return triggerId;
}
public void setTriggerId(String triggerId) {
this.triggerId = triggerId;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
TriggerKey that = (TriggerKey) o;
if (tenantId != null ? !tenantId.equals(that.tenantId) : that.tenantId != null) return false;
return triggerId != null ? triggerId.equals(that.triggerId) : that.triggerId == null;
}
@Override
public int hashCode() {
int result = tenantId != null ? tenantId.hashCode() : 0;
result = 31 * result + (triggerId != null ? triggerId.hashCode() : 0);
return result;
}
}
}