Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support for multi es instances #548

Merged
merged 14 commits into from
Nov 14, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
- Added support for shortening Kibana Discover URLs using Kibana Shorten URL API - [#512](https://github.com/jertel/elastalert2/pull/512) - @JeffAshton
- Added new alerter `HTTP Post 2` which allow more flexibility to build the body/headers of the request. - [#530](https://github.com/jertel/elastalert2/pull/530) - @lepouletsuisse
- [Slack] Added new option to include url to jira ticket if it is created in the same pipeline. - [#547](https://github.com/jertel/elastalert2/pull/547) - @hugefarsen
- Added support for multi ElasticSearch instances. - [#548](https://github.com/jertel/elastalert2/pull/548) - @buratinopy

## Other changes
- [Docs] Add exposed metrics documentation - [#498](https://github.com/jertel/elastalert2/pull/498) - @thisisxgp
Expand Down
7 changes: 7 additions & 0 deletions docs/source/elastalert.rst
Original file line number Diff line number Diff line change
Expand Up @@ -127,9 +127,16 @@ is set to true. Note that back filled data may not always trigger count based al
When ElastAlert 2 is started, it will query for information about the time that it was last run. This way,
even if ElastAlert 2 is stopped and restarted, it will never miss data or look at the same events twice. It will also specify the default cluster for each rule to run on.
The environment variable ``ES_HOST`` will override this field.
For multiple host Elasticsearch clusters see ``es_hosts`` parameter.

``es_port``: The port corresponding to ``es_host``. The environment variable ``ES_PORT`` will override this field.

``es_hosts`` is the list of addresses of the nodes of the Elasticsearch cluster. This
parameter can be used for high availability purposes, but the primary host must also
be specified in the ``es_host`` parameter. The ``es_hosts`` parameter can be overridden
within each rule. This value can be specified as ``host:port`` if overriding the default port.
The environment variable ``ES_HOSTS`` will override this field, and can be specified as a comma-separated value to denote multiple hosts.

``use_ssl``: Optional; whether or not to connect to ``es_host`` using TLS; set to ``True`` or ``False``.
The environment variable ``ES_USE_SSL`` will override this field.

Expand Down
8 changes: 5 additions & 3 deletions docs/source/recipes/faq-md.md
Original file line number Diff line number Diff line change
Expand Up @@ -374,13 +374,15 @@ alert_text: |
Does the alert notification destination support Alertmanager?
==========

Not supported.
Now supported as of ElastAlert 2.2.3.

The es_host parameter seems to use only one host. Is it possible to specify multiple nodes?
==========

Only one can be set in es_host.
Please use haproxy in front of elasticsearch to support multiple hosts.
There are two options:

1. Use haproxy in front of elasticsearch to support multiple hosts.
2. Use the new ``es_hosts`` parameter introduced in ElastAlert 2.2.3. See :ref:`Configuration <configuration>`.

Is there any plan to implement a REST API into this project?
==========
Expand Down
8 changes: 8 additions & 0 deletions docs/source/ruletypes.rst
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ Rule Configuration Cheat Sheet
+--------------------------------------------------------------+ |
| ``alert`` (string or list) | |
+--------------------------------------------------------------+-----------+
| ``es_hosts`` (list, no default) | |
+--------------------------------------------------------------+ |
| ``name`` (string, defaults to the filename) | |
+--------------------------------------------------------------+ |
| ``use_strftime_index`` (boolean, default False) | Optional |
Expand Down Expand Up @@ -227,6 +229,7 @@ es_host

``es_host``: The hostname of the Elasticsearch cluster the rule will use to query. (Required, string, no default)
The environment variable ``ES_HOST`` will override this field.
For multiple host Elasticsearch clusters see ``es_hosts`` parameter.

es_port
^^^^^^^
Expand Down Expand Up @@ -261,6 +264,11 @@ or loaded from a module. For loading from a module, the alert should be specifie

Optional Settings
~~~~~~~~~~~~~~~~~
es_hosts
^^^^^^^^

``es_hosts``: The list of nodes of the Elasticsearch cluster that the rule will use for the request. (Optional, list, default none). Values can be specified as ``host:port`` if overriding the default port.
The environment variable ``ES_HOSTS`` will override this field, and can be specified as a comma-separated value. Note that the ``es_host`` parameter must still be specified in order to identify a primary Elasticsearch host.

import
^^^^^^
Expand Down
11 changes: 9 additions & 2 deletions docs/source/running_elastalert.rst
Original file line number Diff line number Diff line change
Expand Up @@ -165,12 +165,19 @@ For this tutorial, we will use the ``examples/rules`` folder.
time each query is run. This value is ignored for rules where
``use_count_query`` or ``use_terms_query`` is set to true.

``es_host`` is the address of an Elasticsearch cluster where ElastAlert 2 will
``es_host`` is the primary address of an Elasticsearch cluster where ElastAlert 2 will
store data about its state, queries run, alerts, and errors. Each rule may also
use a different Elasticsearch host to query against.
use a different Elasticsearch host to query against. For multiple host Elasticsearch
clusters see ``es_hosts`` parameter.

``es_port`` is the port corresponding to ``es_host``.

``es_hosts`` is the list of addresses of the nodes of the Elasticsearch cluster. This
parameter can be used for high availability purposes, but the primary host must also
be specified in the ``es_host`` parameter. The ``es_hosts`` parameter can be overridden
within each rule. This value can be specified as ``host:port`` if overriding the default
port.

``use_ssl``: Optional; whether or not to connect to ``es_host`` using TLS; set
to ``True`` or ``False``.

Expand Down
3 changes: 2 additions & 1 deletion elastalert/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@ def __init__(self, conf):
"""
:arg conf: es_conn_config dictionary. Ref. :func:`~util.build_es_conn_config`
"""
super(ElasticSearchClient, self).__init__(host=conf['es_host'],
super(ElasticSearchClient, self).__init__(host=conf.get('es_host'),
hosts=conf.get('es_hosts'),
port=conf['es_port'],
url_prefix=conf['es_url_prefix'],
use_ssl=conf['use_ssl'],
Expand Down
2 changes: 1 addition & 1 deletion elastalert/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,14 +21,14 @@
'ES_USERNAME': 'es_username',
'ES_API_KEY': 'es_api_key',
'ES_HOST': 'es_host',
'ES_HOSTS': 'es_hosts',
'ES_PORT': 'es_port',
'ES_URL_PREFIX': 'es_url_prefix',
'STATSD_INSTANCE_TAG': 'statsd_instance_tag',
'STATSD_HOST': 'statsd_host'}

env = Env(ES_USE_SSL=bool)


# Used to map the names of rule loaders to their classes
loader_mapping = {
'file': loaders.FileRulesLoader,
Expand Down
1 change: 1 addition & 0 deletions elastalert/schema.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,7 @@ properties:
use_strftime_index: {type: boolean}

# Optional Settings
es_hosts: {type: array, items: {type: string}}
import:
anyOf:
- type: array
Expand Down
46 changes: 35 additions & 11 deletions elastalert/util.py
Original file line number Diff line number Diff line change
Expand Up @@ -363,6 +363,11 @@ def build_es_conn_config(conf):
parsed_conf['headers'] = None
parsed_conf['es_host'] = os.environ.get('ES_HOST', conf['es_host'])
parsed_conf['es_port'] = int(os.environ.get('ES_PORT', conf['es_port']))

es_hosts = os.environ.get('ES_HOSTS')
es_hosts = parse_hosts(es_hosts, parsed_conf.get('es_port')) if es_hosts else conf.get('es_hosts')
Copy link
Contributor

@JeffAshton JeffAshton Nov 14, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should only parse and assign es_hosts if there is an environment variable?

Copy link
Contributor

@JeffAshton JeffAshton Nov 14, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pretty sure this will always stomp the yml value. Similar cases pass the default through

os.environ.get('ES_HOST', conf['es_host'])

Doing the if es_hosts: fixes this though. Is there a test case for this scenario?

Copy link
Contributor

@JeffAshton JeffAshton Nov 14, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of passing parsed_conf.get('es_port'), should we pass the fully resolved value from a couple lines above

parsed_conf['es_port']

That way things work as expected if the port is set in yml but the hosts set via environment variable.

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should only parse and assign es_hosts if there is an environment variable?

No, it only parses the value if it came from an environment variable. If the environment variable was not set then the `else conf.get('es_hosts') applies. I added unit tests yesterday showing this behavior.

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of passing parsed_conf.get('es_port'), should we pass the fully resolved value from a couple lines above

parsed_conf['es_port']

That way things work as expected if the port is set in yml but the hosts set via environment variable.

Your suggested parsed_conf['es_port'] resolves to the same value as the implemented parsed_conf.get('es_port'), except that the implemented statement is safer since it won't throw an exception if the key doesn't exist in the map.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry completely missed the if es_hosts else conf.get('es_hosts') at the end of the line. Consider simplifying.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

es_hosts = os.environ.get('ES_HOSTS')
if es_hosts:
    parsed_conf['es_hosts'] = parse_hosts(es_hosts, parsed_conf.get('es_port'))

This avoids a bonus read and write to parsed_conf['es_hosts'] when configured by yml.

parsed_conf['es_hosts'] = es_hosts

parsed_conf['es_url_prefix'] = ''
parsed_conf['es_conn_timeout'] = conf.get('es_conn_timeout', 20)
parsed_conf['send_get_body_as'] = conf.get('es_send_get_body_as', 'GET')
Expand Down Expand Up @@ -486,34 +491,34 @@ def should_scrolling_continue(rule_conf):
return not stop_the_scroll


def _expand_string_into_dict(string, value, sep='.'):
def _expand_string_into_dict(string, value, sep='.'):
"""
Converts a encapsulated string-dict to a sequence of dict. Use separator (default '.') to split the string.
Example:
Example:
string1.string2.stringN : value -> {string1: {string2: {string3: value}}

:param string: The encapsulated "string-dict"
:param value: Value associated to the last field of the "string-dict"
:param sep: Separator character. Default: '.'
:rtype: dict
"""
if sep not in string:
return {string : value}
return {string: value}
key, val = string.split(sep, 1)
return {key: _expand_string_into_dict(val, value)}
def expand_string_into_dict(dictionary, string , value, sep='.'):


def expand_string_into_dict(dictionary, string, value, sep='.'):
"""
Useful function to "compile" a string-dict string used in metric and percentage rules into a dictionary sequence.

:param dictionary: The dictionary dict
:param string: String Key
:param string: String Key
:param value: String Value
:param sep: Separator character. Default: '.'
:rtype: dict
"""

if sep not in string:
dictionary[string] = value
return dictionary
Expand All @@ -526,7 +531,7 @@ def expand_string_into_dict(dictionary, string , value, sep='.'):
def format_string(format_config, target_value):
"""
Formats number, supporting %-format and str.format() syntax.

:param format_config: string format syntax, for example '{:.2%}' or '%.2f'
:param target_value: number to format
:rtype: string
Expand All @@ -536,3 +541,22 @@ def format_string(format_config, target_value):
else:
return format_config % (target_value)


def format_host_port(host, port):
host = host.strip()
if ":" not in host:
return "{host}:{port}".format(host=host, port=port)
return host


def parse_hosts(host, port=9200):
"""
Convert host str like "host1:port1, host2:port2" to list
:param host str: hostnames (separated with comma ) or single host name
:param port: default to 9200
:return: list of hosts
"""
host_list = host.split(",")
host_list = [format_host_port(x, port) for x in host_list]
return host_list

85 changes: 85 additions & 0 deletions tests/util_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
from elastalert.util import unixms_to_dt
from elastalert.util import format_string
from elastalert.util import pretty_ts
from elastalert.util import parse_hosts


@pytest.mark.parametrize('spec, expected_delta', [
Expand Down Expand Up @@ -338,6 +339,7 @@ def test_ts_utc_to_tz():
'profile': None,
'headers': None,
'es_host': 'localhost',
'es_hosts': None,
'es_port': 9200,
'es_url_prefix': '',
'es_conn_timeout': 20,
Expand All @@ -361,6 +363,7 @@ def test_ts_utc_to_tz():
'profile': 'default',
'headers': None,
'es_host': 'localhost',
'es_hosts': None,
'es_port': 9200,
'es_url_prefix': 'elasticsearch',
'es_conn_timeout': 30,
Expand Down Expand Up @@ -436,6 +439,77 @@ def test_build_es_conn_config2():
'profile': None,
'headers': None,
'es_host': 'localhost',
'es_hosts': None,
'es_port': 9200,
'es_url_prefix': '',
'es_conn_timeout': 20,
'send_get_body_as': 'GET',
'ssl_show_warn': True
}
actual = build_es_conn_config(conf)
assert expected == actual


@mock.patch.dict(os.environ, {'ES_USERNAME': 'USER',
'ES_PASSWORD': 'PASS',
'ES_API_KEY': 'KEY',
'ES_BEARER': 'BEARE'})
def test_build_es_conn_config_es_hosts_list():
conf = {}
conf['es_host'] = 'localhost'
conf['es_port'] = 9200
conf['es_hosts'] = ['host1:123', 'host2']
expected = {
'use_ssl': False,
'verify_certs': True,
'ca_certs': None,
'client_cert': None,
'client_key': None,
'http_auth': None,
'es_username': 'USER',
'es_password': 'PASS',
'es_api_key': 'KEY',
'es_bearer': 'BEARE',
'aws_region': None,
'profile': None,
'headers': None,
'es_host': 'localhost',
'es_hosts': ['host1:123', 'host2'],
'es_port': 9200,
'es_url_prefix': '',
'es_conn_timeout': 20,
'send_get_body_as': 'GET',
'ssl_show_warn': True
}
actual = build_es_conn_config(conf)
assert expected == actual


@mock.patch.dict(os.environ, {'ES_USERNAME': 'USER',
'ES_PASSWORD': 'PASS',
'ES_API_KEY': 'KEY',
'ES_HOSTS': 'host1:123,host2',
'ES_BEARER': 'BEARE'})
def test_build_es_conn_config_es_hosts_csv():
conf = {}
conf['es_host'] = 'localhost'
conf['es_port'] = 9200
expected = {
'use_ssl': False,
'verify_certs': True,
'ca_certs': None,
'client_cert': None,
'client_key': None,
'http_auth': None,
'es_username': 'USER',
'es_password': 'PASS',
'es_api_key': 'KEY',
'es_bearer': 'BEARE',
'aws_region': None,
'profile': None,
'headers': None,
'es_host': 'localhost',
'es_hosts': ['host1:123', 'host2:9200'],
'es_port': 9200,
'es_url_prefix': '',
'es_conn_timeout': 20,
Expand Down Expand Up @@ -519,3 +593,14 @@ def test_pretty_ts():
assert '2021-08-16 16:35 UTC' == pretty_ts(ts)
assert '2021-08-16 16:35 ' == pretty_ts(ts, False)
assert '2021-08-16 16:35 +0000' == pretty_ts(ts, ts_format='%Y-%m-%d %H:%M %z')


def test_parse_host():
assert parse_hosts("localhost", port=9200) == ["localhost:9200"]
jertel marked this conversation as resolved.
Show resolved Hide resolved
assert parse_hosts("localhost:9201", port=9200) == ["localhost:9201"]
assert parse_hosts("host1, host2, host3.foo") == ["host1:9200",
"host2:9200",
"host3.foo:9200"]
assert parse_hosts("host1, host2:9200, host3:9300") == ["host1:9200",
"host2:9200",
"host3:9300"]