Merge ~npochet/charm-graylog:graylog-ha into ~graylog-charmers/charm-graylog:master

Proposed by Nicolas Pochet
Status: Merged
Approved by: Jeremy Lounder
Approved revision: b5487d2b9823cdffbb7b84fe34aff98518df4389
Merged at revision: f95473e145f97f5a7e6bec9e1b44e2e289f3b2fb
Proposed branch: ~npochet/charm-graylog:graylog-ha
Merge into: ~graylog-charmers/charm-graylog:master
Diff against target: 556 lines (+149/-131)
11 files modified
Makefile (+4/-12)
dev/null (+0/-112)
layer.yaml (+1/-0)
reactive/graylog.py (+35/-0)
tests/bundles/bionic-ha.yaml (+39/-0)
tests/bundles/bionic.yaml (+39/-0)
tests/bundles/overlays/local-charm-overlay.yaml.j2 (+3/-0)
tests/requirements.txt (+1/-0)
tests/tests.yaml (+12/-5)
tox.ini (+6/-0)
unit_tests/test_graylog.py (+9/-2)
Reviewer Review Type Date Requested Status
Jeremy Lounder (community) Approve
Stuart Bishop (community) Approve
Michael Iatrou Pending
Graylog Charmers Pending
Review via email: mp+368200@code.launchpad.net

Commit message

Support multiple nodes

* Select the inital master based on juju leadership. This information is
then stored for further use.
* The master is only changed if the actual master is departed.
* The elected leader will also send its password to the others.
* Migrate functional tests to zaza
* Fixes LP:1794631

Description of the change

Support multiple nodes

* Select the inital master based on juju leadership. This information is
then stored for further use.
* The master is only changed if the actual master is departed.
* The elected leader will also send its password to the others.
* Migrate functional tests to zaza
* Fixes LP:1794631

To post a comment you must log in.
Revision history for this message
🤖 Canonical IS Merge Bot (canonical-is-mergebot) wrote :

This merge proposal is being monitored by mergebot. Change the status to Approved to merge.

Revision history for this message
Stuart Bishop (stub) wrote :

I don't think we can have the graylog master follow the Juju leader, because we will have periods where there are multiple masters (the old master will only learn that it is no longer master when the new leader publishes new leadership settings), and because we will get unnecessary master changes when controller or network issues trigger leadership elections (probably exactly the times when we want logging to be stable).

Instead, the leader should appoint the master, and the master will remain master until the unit departs.

from charms import leadership

@when('leadership.is_leader')
def appoint_master():
    master = leadership.leader_get('master')
    if master and (master == hookenv.local_unit() or master in set(hookenv.expected_peer_units())):
        return # Already have a master
    leadership.leader_set(master, hookenv.local_unit()) # Appoint myself master

def is_master():
    return leadership.leader_get('master') == hookenv.local_unit())

If you are worried about a failed unit, you could also have the leader perform a liveness check to graylog running on the master (which should handle the master being powered off). However, this risks a split brain with two live graylogs thinking they are master (the original proposal has the same flaw too, where a master loses network connectivity to Juju, and a new unit gets leadership and we get a new master and the old master will have no idea until the network connectivity to Juju is repaired).

review: Needs Fixing
Revision history for this message
Stuart Bishop (stub) wrote :

(everything else is great, including the zaza tests)

Revision history for this message
Nicolas Pochet (npochet) wrote :

I'll apply the changes and update the MP today
Regarding zaza, see my comment inline

Revision history for this message
Nicolas Pochet (npochet) wrote :

Regarding zaza, I'm not sure that my previous comment is valid. I can see, in the openstack charms that are using zaza, that they always use master.
For example: https://github.com/openstack/charm-vault/blob/60c35da8b638cf0854bc1ac8039a66499c9259cb/src/test-requirements.txt#L4

Revision history for this message
Stuart Bishop (stub) wrote :

This seems good, so +1 from ~canonical-is-reviewers. Should get someone else from graylog-charmers for a second opinion.

review: Approve
Revision history for this message
Jeremy Lounder (jldev) wrote :

We're going to hold on this merge until we fully understand the scope of what is required to make Graylog HA. While having multi-node Graylog does have some benefits, such as distributed ingestion. I need to take the time to understand how the Graylog hostnames are communicated out via relation, and how they are consumed on relations by other charms/services.
At the moment, it would appear there is no recovery mechanism if a node fails. It may make sense to put haproxy in front of the ingestion endpoint - in addition to the web endpoint.

review: Needs Information
Revision history for this message
Jeremy Lounder (jldev) wrote :

Approving this for merge and release. Additional research reveals that the HA process for our implementation is handled on the filebeats side. Filebeats does client-end loading balancing and delivery guarantee.

For other input types, we'll still need to evaluate improving the haproxy relation, and placing a load balancer in front of the inputs.

review: Approve
Revision history for this message
🤖 Canonical IS Merge Bot (canonical-is-mergebot) wrote :

Change successfully merged at revision f95473e145f97f5a7e6bec9e1b44e2e289f3b2fb

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1diff --git a/Makefile b/Makefile
2index 9e5089e..44e05cc 100644
3--- a/Makefile
4+++ b/Makefile
5@@ -21,11 +21,8 @@ sysdeps:
6
7 .PHONY: testdeps
8 testdeps:
9- -sudo apt-add-repository -y ppa:juju/stable
10- -sudo apt-add-repository -y ppa:tvansteenburgh/ppa
11- @sudo apt update
12- @sudo apt install -y juju charm amulet python-pip python3-nose
13- @sudo pip2 install bundletester
14+ @sudo snap install juju
15+ @sudo snap install charm
16
17 .PHONY: lint
18 lint: sysdeps
19@@ -67,11 +64,6 @@ charmrelease:
20 @echo ---------------------------------------------------------------------
21
22 .PHONY: test
23-test: check-jujumodel testdeps
24+test: testdeps charmbuild
25 @echo "Running functional tests (including lint and unit tests)..."
26- bundletester -t $(BUILTCHARMDIR) -Fvl DEBUG -e $(JUJU_MODEL)
27-
28-check-jujumodel:
29-ifndef JUJU_MODEL
30- $(error JUJU_MODEL is undefined)
31-endif
32+ @tox -e func
33diff --git a/layer.yaml b/layer.yaml
34index 3d0f751..d4fa469 100644
35--- a/layer.yaml
36+++ b/layer.yaml
37@@ -2,6 +2,7 @@ repo: git+ssh://git.launchpad.net/graylog-charm
38 includes:
39 - 'layer:basic'
40 - 'layer:snap'
41+ - 'layer:leadership'
42 - 'interface:elasticsearch'
43 - 'interface:elastic-beats'
44 - 'interface:http'
45diff --git a/reactive/graylog.py b/reactive/graylog.py
46index 786df04..e408e0c 100644
47--- a/reactive/graylog.py
48+++ b/reactive/graylog.py
49@@ -10,6 +10,7 @@ from charms.reactive import hook, when, when_not, is_state, remove_state, set_st
50 from charms.reactive.helpers import data_changed
51 from charmhelpers.core import host, hookenv, unitdata
52 from charmhelpers.contrib.charmsupport import nrpe
53+import charms.leadership
54
55 from charms.layer.graylog import logextract
56 from charms.layer.graylog.api import GraylogApi
57@@ -102,6 +103,40 @@ def configure_graylog():
58 report_status()
59
60
61+@when('leadership.is_leader')
62+@when_not('leadership.set.admin_password')
63+def generate_admin_password():
64+ admin_password = host.pwgen(18)
65+ charms.leadership.leader_set(admin_password=admin_password)
66+
67+
68+@when('leadership.changed.admin_password')
69+def get_leader_admin_password():
70+ db = unitdata.kv()
71+ admin_password = charms.leadership.leader_get('admin_password')
72+ db.set('admin_password', admin_password)
73+ remove_state('graylog.configured')
74+
75+
76+@when('leadership.is_leader')
77+def appoint_master():
78+ master = charms.leadership.leader_get('master')
79+ if master and (master == hookenv.local_unit() or master in set(hookenv.expected_peer_units())):
80+ # A master already exists
81+ return
82+ # Appoint myself as master
83+ charms.leadership.leader_set(master=hookenv.local_unit())
84+
85+
86+def is_master():
87+ return charms.leadership.leader_get('master') == hookenv.local_unit()
88+
89+
90+@when('leadership.changed.master')
91+def set_master():
92+ set_conf('is_master', is_master())
93+
94+
95 @when('graylog.configured') # noqa: C901
96 def report_status():
97 beats_connected = is_state('beats.connected')
98diff --git a/tests/bundles/bionic-ha.yaml b/tests/bundles/bionic-ha.yaml
99new file mode 100644
100index 0000000..e266cf5
101--- /dev/null
102+++ b/tests/bundles/bionic-ha.yaml
103@@ -0,0 +1,39 @@
104+series: bionic
105+
106+applications:
107+ ubuntu:
108+ charm: cs:ubuntu
109+ num_units: 1
110+
111+ filebeat:
112+ charm: cs:filebeat
113+ num_units: 0
114+
115+ graylog:
116+ charm: ../../../graylog-built/builds/graylog
117+ num_units: 3
118+ series: bionic
119+
120+ elastic:
121+ charm: cs:elasticsearch
122+ num_units: 1
123+
124+ mongo:
125+ charm: cs:mongodb
126+ num_units: 1
127+
128+ haproxy:
129+ charm: cs:haproxy
130+ num_units: 1
131+
132+relations:
133+ - - ubuntu
134+ - filebeat
135+ - - graylog:beats
136+ - filebeat:logstash
137+ - - graylog
138+ - mongo
139+ - - graylog
140+ - elastic
141+ - - graylog
142+ - haproxy
143\ No newline at end of file
144diff --git a/tests/bundles/bionic.yaml b/tests/bundles/bionic.yaml
145new file mode 100644
146index 0000000..e196355
147--- /dev/null
148+++ b/tests/bundles/bionic.yaml
149@@ -0,0 +1,39 @@
150+series: bionic
151+
152+applications:
153+ ubuntu:
154+ charm: cs:ubuntu
155+ num_units: 1
156+
157+ filebeat:
158+ charm: cs:filebeat
159+ num_units: 0
160+
161+ graylog:
162+ charm: ../../../graylog-built/builds/graylog
163+ num_units: 1
164+ series: bionic
165+
166+ elastic:
167+ charm: cs:elasticsearch
168+ num_units: 1
169+
170+ mongo:
171+ charm: cs:mongodb
172+ num_units: 1
173+
174+ haproxy:
175+ charm: cs:haproxy
176+ num_units: 1
177+
178+relations:
179+ - - ubuntu
180+ - filebeat
181+ - - graylog:beats
182+ - filebeat:logstash
183+ - - graylog
184+ - mongo
185+ - - graylog
186+ - elastic
187+ - - graylog
188+ - haproxy
189\ No newline at end of file
190diff --git a/tests/bundles/overlays/local-charm-overlay.yaml.j2 b/tests/bundles/overlays/local-charm-overlay.yaml.j2
191new file mode 100644
192index 0000000..b459ab1
193--- /dev/null
194+++ b/tests/bundles/overlays/local-charm-overlay.yaml.j2
195@@ -0,0 +1,3 @@
196+applications:
197+ graylog:
198+ charm: ../../../graylog-built/builds/graylog
199\ No newline at end of file
200diff --git a/tests/requirements.txt b/tests/requirements.txt
201new file mode 100644
202index 0000000..3da5fb4
203--- /dev/null
204+++ b/tests/requirements.txt
205@@ -0,0 +1 @@
206+git+https://github.com/openstack-charmers/zaza.git#egg=zaza
207\ No newline at end of file
208diff --git a/tests/test_10_basic.py b/tests/test_10_basic.py
209deleted file mode 100755
210index 54133c2..0000000
211--- a/tests/test_10_basic.py
212+++ /dev/null
213@@ -1,152 +0,0 @@
214-#!/usr/bin/python3
215-
216-import amulet
217-import os
218-import re
219-import sys
220-import time
221-import unittest
222-import yaml
223-
224-libs_dir = os.path.abspath(os.path.join(os.path.dirname(__file__),
225- '..', 'lib'))
226-if libs_dir not in sys.path:
227- sys.path.append(libs_dir)
228-
229-from graylogapi import GraylogAPI # noqa: E402
230-
231-
232-ADMIN_PASSWORD = 'admin'
233-CLUSTER_NAME = 'amulet'
234-CURL_TIMEOUT = 180
235-DEFAULT_API_PORT = '9001'
236-DEFAULT_WEB_PORT = '9000'
237-
238-
239-class TestCharm(unittest.TestCase):
240- @classmethod
241- def setUpClass(cls):
242- cls.d = amulet.Deployment(series='xenial')
243-
244- cls.d.add('graylog')
245- cls.d.configure('graylog', {
246- 'elasticsearch_cluster_name': CLUSTER_NAME,
247- })
248- cls.d.expose('graylog')
249-
250- cls.d.add('nrpe', 'cs:nrpe')
251- cls.d.relate('graylog:nrpe-external-master',
252- 'nrpe:nrpe-external-master')
253-
254- cls.d.add('mongodb', 'cs:mongodb')
255- cls.d.configure('mongodb', {
256- 'replicaset': CLUSTER_NAME,
257- })
258- cls.d.relate('graylog:mongodb', 'mongodb:database')
259-
260- cls.d.add('elasticsearch', 'cs:elasticsearch')
261- cls.d.configure('elasticsearch', {
262- 'cluster-name': CLUSTER_NAME,
263- })
264- cls.d.relate('graylog:elasticsearch', 'elasticsearch:client')
265-
266- cls.d.add('apache2', 'cs:apache2')
267- cls.d.relate('graylog:website', 'apache2:reverseproxy')
268- cls.d.expose('apache2')
269-
270- try:
271- timeout = int(os.environ.get('AMULET_SETUP_TIMEOUT', 900))
272- cls.d.setup(timeout=timeout)
273- cls.d.sentry.wait(timeout=timeout)
274- cls.d.sentry.wait_for_messages({'graylog': re.compile('Ready with'),
275- 'elasticsearch': 'Ready',
276- 'nrpe': 'ready'},
277- timeout=timeout)
278- except amulet.helpers.TimeoutError:
279- amulet.raise_status(
280- amulet.FAIL,
281- msg="Deployment timed out ({}s)".format(timeout)
282- )
283- except Exception:
284- raise
285-
286- cls.elasticsearch = cls.d.sentry['elasticsearch'][0]
287- cls.mongodb = cls.d.sentry['mongodb'][0]
288- cls.graylog = cls.d.sentry['graylog'][0]
289- cls.nrpe = cls.d.sentry['nrpe'][0]
290- cls.graylog.run_action('set-admin-password', {'password': ADMIN_PASSWORD})
291-
292- cls.config = cls.graylog.file_contents('/var/snap/graylog/common/server.conf')
293- api_url = "http://{}:{}/api".format(cls.graylog.info['public-address'], DEFAULT_API_PORT)
294- cls.api = GraylogAPI(api_url, 'admin', ADMIN_PASSWORD)
295-
296- # https://bugs.launchpad.net/graylog-charm/+bug/1748040
297- cls.graylog.run('open-port {}'.format(DEFAULT_API_PORT))
298-
299- def test_api_ready(self):
300- ''' Curl the api endpoint on the sentried graylog unit. We'll retry
301- until the CURL_TIMEOUT because it may take a few seconds for the
302- graylog systemd service to start.'''
303- curl_command = 'curl http://localhost:{}'.format(DEFAULT_API_PORT)
304- timeout = time.time() + CURL_TIMEOUT
305- while time.time() < timeout:
306- response = self.graylog.run(curl_command)
307- # run returns a msg,retcode tuple
308- if response[1] == 0:
309- return
310- else:
311- print("Unexpected curl response: {}. "
312- "Retrying in 30s.".format(response[0]))
313- time.sleep(30)
314-
315- # we didn't get rc=0 in the alloted time; raise amulet failure
316- msg = (
317- "Graylog didn't respond to the command \n"
318- "'{curl_command}' as expected.\n"
319- "Return code: {return_code}\n"
320- "Result: {result}".format(
321- curl_command=curl_command,
322- return_code=response[1],
323- result=response[0])
324- )
325- amulet.raise_status(amulet.FAIL, msg=msg)
326-
327- def test_elasticsearch_active(self):
328- elasticsearch_ip = self.elasticsearch.relation('client',
329- 'graylog:elasticsearch')['private-address']
330- self.assertTrue(elasticsearch_ip in self.config)
331- resp = self.api.indexer_cluster_health()
332- self.assertTrue(resp['status'] == 'green')
333-
334- def test_mongodb_active(self):
335- mongodb_ip = self.mongodb.relation('database', 'graylog:mongodb')['private-address']
336- self.assertTrue(mongodb_ip in self.config)
337- resp = self.api.cluster_get()
338- self.assertTrue(resp[list(resp)[0]]['is_processing'])
339-
340- def test_website_active(self):
341- relation = self.graylog.relation('website', 'apache2:reverseproxy')
342- self.assertEqual(relation['port'], DEFAULT_WEB_PORT)
343-
344- service_ports = {}
345- for service in yaml.safe_load(relation['all_services']):
346- service_ports[service['service_name']] = service['service_port']
347-
348- self.assertEqual(str(service_ports['web']), DEFAULT_WEB_PORT)
349- self.assertEqual(str(service_ports['api']), DEFAULT_API_PORT)
350-
351- def test_nrpe_config(self):
352- cfg = self.nrpe.file_contents(
353- '/etc/nagios/nrpe.d/check_graylog_health.cfg')
354- self.assertTrue(
355- re.search(r'command.*check_graylog_health', cfg))
356-
357- def test_nrpe_health_check(self):
358- cfg = self.nrpe.file_contents(
359- '/usr/local/lib/nagios/plugins/check_graylog_health.py')
360- self.assertTrue(
361- re.search(r'CRITICAL', cfg))
362-
363-
364-if __name__ == "__main__":
365- unittest.main()
366diff --git a/tests/test_20_clustered.py b/tests/test_20_clustered.py
367deleted file mode 100755
368index 96284f7..0000000
369--- a/tests/test_20_clustered.py
370+++ /dev/null
371@@ -1,112 +0,0 @@
372-#!/usr/bin/python3
373-
374-import amulet
375-import os
376-import re
377-import sys
378-import time
379-import unittest
380-
381-libs_dir = os.path.abspath(os.path.join(os.path.dirname(__file__),
382- '..', 'lib'))
383-if libs_dir not in sys.path:
384- sys.path.append(libs_dir)
385-
386-from graylogapi import GraylogAPI # noqa: E402
387-
388-
389-ADMIN_PASSWORD = 'admin'
390-CLUSTER_NAME = 'amulet'
391-CURL_TIMEOUT = 180
392-DEFAULT_API_PORT = '9001'
393-DEFAULT_WEB_PORT = '9000'
394-
395-
396-class TestCharm(unittest.TestCase):
397- @classmethod
398- def setUpClass(cls):
399- cls.d = amulet.Deployment(series='xenial')
400-
401- cls.d.add('graylog')
402- # TODO add in clustered graylog when support for it exists
403- cls.d.configure('graylog', {
404- 'elasticsearch_cluster_name': CLUSTER_NAME,
405- })
406- cls.d.expose('graylog')
407-
408- cls.d.add('mongodb', 'cs:mongodb', units=3)
409- cls.d.configure('mongodb', {
410- 'replicaset': CLUSTER_NAME,
411- })
412- cls.d.relate('graylog:mongodb', 'mongodb:database')
413-
414- cls.d.add('elasticsearch', 'cs:elasticsearch', units=3)
415- cls.d.configure('elasticsearch', {
416- 'cluster-name': CLUSTER_NAME,
417- })
418- cls.d.relate('graylog:elasticsearch', 'elasticsearch:client')
419-
420- try:
421- cls.d.setup(timeout=900)
422- cls.d.sentry.wait_for_messages({'graylog': re.compile('Ready with'),
423- 'elasticsearch': 'Ready'},
424- timeout=1800)
425- except amulet.helpers.TimeoutError:
426- # Setup didn't complete before timeout
427- pass
428-
429- cls.graylog = cls.d.sentry['graylog'][0]
430- cls.graylog.run_action('set-admin-password', {'password': ADMIN_PASSWORD})
431-
432- cls.config = cls.graylog.file_contents('/var/snap/graylog/common/server.conf')
433- api_url = "http://{}:{}/api".format(cls.graylog.info['public-address'], DEFAULT_API_PORT)
434- cls.api = GraylogAPI(api_url, 'admin', ADMIN_PASSWORD)
435-
436- # https://bugs.launchpad.net/graylog-charm/+bug/1748040
437- cls.graylog.run('open-port {}'.format(DEFAULT_API_PORT))
438-
439- def test_api_ready(self):
440- ''' Curl the api endpoint on the sentried graylog unit. We'll retry
441- until the CURL_TIMEOUT because it may take a few seconds for the
442- graylog systemd service to start.'''
443- curl_command = 'curl http://localhost:{}'.format(DEFAULT_API_PORT)
444- timeout = time.time() + CURL_TIMEOUT
445- while time.time() < timeout:
446- response = self.graylog.run(curl_command)
447- # run returns a msg,retcode tuple
448- if response[1] == 0:
449- return
450- else:
451- print("Unexpected curl response: {}. "
452- "Retrying in 30s.".format(response[0]))
453- time.sleep(30)
454-
455- # we didn't get rc=0 in the alloted time; raise amulet failure
456- msg = (
457- "Graylog didn't respond to the command \n"
458- "'{curl_command}' as expected.\n"
459- "Return code: {return_code}\n"
460- "Result: {result}".format(
461- curl_command=curl_command,
462- return_code=response[1],
463- result=response[0])
464- )
465- amulet.raise_status(amulet.FAIL, msg=msg)
466-
467- def test_elasticsearch_active(self):
468- resp = self.api.indexer_cluster_health()
469- self.assertTrue(resp['status'] == 'green')
470- for elasticsearch in self.d.sentry['elasticsearch']:
471- ip = elasticsearch.relation('client', 'graylog:elasticsearch')['private-address']
472- self.assertTrue(ip in self.config)
473-
474- def test_mongodb_active(self):
475- resp = self.api.cluster_get()
476- self.assertTrue(resp[list(resp)[0]]['is_processing'])
477- for mongodb in self.d.sentry['mongodb']:
478- ip = mongodb.relation('database', 'graylog:mongodb')['private-address']
479- self.assertTrue(ip in self.config)
480-
481-
482-if __name__ == "__main__":
483- unittest.main()
484diff --git a/tests/tests.yaml b/tests/tests.yaml
485index 5c4cb8e..1bb1e58 100644
486--- a/tests/tests.yaml
487+++ b/tests/tests.yaml
488@@ -1,5 +1,12 @@
489-makefile:
490- - lint
491- - unittest
492-packages:
493- - amulet
494+charm_name: graylog
495+gate_bundles:
496+ - bionic
497+ - bionic-ha
498+smoke_bundles:
499+ - bionic
500+dev_bundles:
501+ - bionic
502+configure:
503+ - zaza.charm_tests.noop.setup.basic_setup
504+tests:
505+ - zaza.charm_tests.noop.tests.NoopTest
506diff --git a/tox.ini b/tox.ini
507index d92a339..a1f299d 100644
508--- a/tox.ini
509+++ b/tox.ini
510@@ -6,12 +6,18 @@ skip_missing_interpreters = True
511 [testenv]
512 basepython = python3
513 setenv = PYTHONPATH={toxinidir}/reactive:{toxinidir}/lib/
514+whitelist_externals = juju
515+passenv = HOME
516
517 [testenv:unit]
518 commands = pytest -v --ignore {toxinidir}/tests --cov=lib --cov=reactive --cov=actions --cov-report=term-missing --cov-branch
519 deps = -r{toxinidir}/unit_tests/requirements.txt
520 -r{toxinidir}/requirements.txt
521
522+[testenv:func]
523+commands = functest-run-suite --keep-model
524+deps = -r{toxinidir}/tests/requirements.txt
525+
526 [testenv:lint]
527 commands = flake8
528 deps = flake8
529diff --git a/unit_tests/test_graylog.py b/unit_tests/test_graylog.py
530index 9a25473..38ac6fa 100644
531--- a/unit_tests/test_graylog.py
532+++ b/unit_tests/test_graylog.py
533@@ -1,14 +1,21 @@
534 import os
535+import sys
536 import tempfile
537 import unittest
538 from unittest import mock
539
540 from charms.layer.graylog.api import GraylogApi
541+
542+# charms.leadership only exists in the built charm; mock it out before
543+# the graylog imports since those depend on charms.leadership
544+layer_mock = mock.Mock()
545+sys.modules['charms.leadership'] = layer_mock
546+
547 from reactive.graylog import (
548 set_conf,
549 set_jvm_heap_size,
550- _check_input_exists)
551-from files import check_graylog_health
552+ _check_input_exists) # noqa: E402
553+from files import check_graylog_health # noqa: E402
554
555 initial_conf = u"""#key1 = value1
556 key2 = value2

Subscribers

People subscribed via source and target branches