Merge ~hloeung/charm-graylog:master into ~graylog-charmers/charm-graylog:master

Proposed by Haw Loeung
Status: Superseded
Proposed branch: ~hloeung/charm-graylog:master
Merge into: ~graylog-charmers/charm-graylog:master
Diff against target: 367 lines (+247/-15)
5 files modified
TODO (+1/-2)
config.yaml (+11/-0)
metadata.yaml (+1/-1)
reactive/graylog.py (+169/-11)
unit_tests/graylog.py (+65/-1)
Reviewer Review Type Date Requested Status
Tim Kuhlman (community) Needs Fixing
Review via email: mp+329093@code.launchpad.net

This proposal has been superseded by a proposal from 2017-08-23.

Description of the change

Add log inputs from charm config

This adds new 'log_inputs' charm config option to define a set of log inputs. These inputs are then set up via the Graylog API and ports opened (juju open-port) so that a juju expose sets up the correct secgroup rules.

While this works, I'm not proud of it. I have a task to split the API parts out to it's own library.

To post a comment you must log in.
Revision history for this message
Tim Kuhlman (timkuhlman) wrote :

Nice new functionality!

I think the implementation can be improved with some unit tests and by pulling all of the Graylog API code into a library, ideally one that can be shared between the reactive code and the amulet tests.

Also this is pretty significant new functionality, the Amulet tests should be extended to cover setting and verifying log_inputs.

review: Needs Fixing
Revision history for this message
Haw Loeung (hloeung) :
~hloeung/charm-graylog:master updated
0846e8f... by Haw Loeung

Add log inputs from charm config

This adds new 'log_inputs' charm config option to define a set of log
inputs. These inputs are then set up via the Graylog API and ports
opened (juju open-port) so that a juju expose sets up the correct
secgroup rules.

Revision history for this message
Tim Kuhlman (timkuhlman) wrote :

I took a brief look at the updates with some comments below. The changes look good but I would still like to see unit and/or amulet tests and at some point a class for the Graylog API.

~hloeung/charm-graylog:master updated
962a4a4... by Haw Loeung

Add unit tests for input type map and input exists

Unmerged commits

962a4a4... by Haw Loeung

Add unit tests for input type map and input exists

0846e8f... by Haw Loeung

Add log inputs from charm config

This adds new 'log_inputs' charm config option to define a set of log
inputs. These inputs are then set up via the Graylog API and ports
opened (juju open-port) so that a juju expose sets up the correct
secgroup rules.

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
diff --git a/TODO b/TODO
index e0c4f88..2272c2a 100644
--- a/TODO
+++ b/TODO
@@ -1,8 +1,7 @@
1Before Production Use:1Before Production Use:
2- Clustering of multiple graylog instances.2- Clustering of multiple graylog instances.
3- Support for nrpe relation, including service specific nagios check.
4- Support for removing relations.3- Support for removing relations.
5- Configuration of Inputs, specifically beats setup, most likely via the REST API.4- Support for logstash relation.
6 - When charm-based configuration of beats is in place, support the logstash relation so that5 - When charm-based configuration of beats is in place, support the logstash relation so that
7 filebeats can use that relation to setup the proper output when in the same Juju model.6 filebeats can use that relation to setup the proper output when in the same Juju model.
87
diff --git a/config.yaml b/config.yaml
index 8db8a8c..9614533 100644
--- a/config.yaml
+++ b/config.yaml
@@ -47,6 +47,17 @@ options:
47 default: 647 default: 6
48 description: |48 description: |
49 Maximum number of indices to keep before deleting the oldest ones49 Maximum number of indices to keep before deleting the oldest ones
50 log_inputs:
51 type: string
52 default: |
53 - name: Beats Input
54 type: Beats
55 bind_address: 0.0.0.0
56 bind_port: 5044
57 description: |
58 YAML-formatted list of log inputs. First input gets passed
59 through relations. Any input not defined here will be removed unless it
60 is prefixed with "Custom" in the title.
50 nagios_context:61 nagios_context:
51 default: "juju"62 default: "juju"
52 type: string63 type: string
diff --git a/metadata.yaml b/metadata.yaml
index b5f3096..8277908 100644
--- a/metadata.yaml
+++ b/metadata.yaml
@@ -1,6 +1,6 @@
1name: graylog1name: graylog
2summary: Graylog log management system2summary: Graylog log management system
3maintainer: Tim Kuhlman <timothy.kuhlman@canonical.com>3maintainer: Graylog Charmers <graylog-charmers@lists.launchpad.net>
4description: >4description: >
5 Installs the Graylog log management system. Connections to elasticsearch and5 Installs the Graylog log management system. Connections to elasticsearch and
6 mongodb are required for a fully functioning system. https://www.graylog.org/6 mongodb are required for a fully functioning system. https://www.graylog.org/
diff --git a/reactive/graylog.py b/reactive/graylog.py
index 016f061..a93b5e8 100644
--- a/reactive/graylog.py
+++ b/reactive/graylog.py
@@ -4,6 +4,7 @@ import os
4import re4import re
5import requests5import requests
6import time6import time
7import yaml
7from urllib.parse import urlparse, urljoin8from urllib.parse import urlparse, urljoin
89
9from charms.reactive import hook, when, when_not, remove_state, set_state10from charms.reactive import hook, when, when_not, remove_state, set_state
@@ -30,8 +31,6 @@ def update_config():
3031
3132
32def api_request(url, auth, method='GET', data=None):33def api_request(url, auth, method='GET', data=None):
33 if method not in ('GET', 'POST', 'PUT'):
34 return
35 tries = 034 tries = 0
36 while tries < 3:35 while tries < 3:
37 tries += 136 tries += 1
@@ -40,6 +39,8 @@ def api_request(url, auth, method='GET', data=None):
40 headers={'Accept': 'application/json',39 headers={'Accept': 'application/json',
41 'Content-Type': 'application/json'})40 'Content-Type': 'application/json'})
42 if resp.ok:41 if resp.ok:
42 if method == 'DELETE':
43 return True
43 return json.loads(resp.content.decode('utf-8'))44 return json.loads(resp.content.decode('utf-8'))
44 except Exception:45 except Exception:
45 pass46 pass
@@ -60,11 +61,6 @@ def api_retrieve_token(api_base_url, auth, name='graylog-charm'):
60 return api_retrieve_token(api_base_url, auth, name=name)61 return api_retrieve_token(api_base_url, auth, name=name)
6162
6263
63def api_retrieve_index_sets(api_base_url, auth):
64 url = urljoin(api_base_url, 'system/indices/index_sets')
65 return api_request(url, auth=auth)['index_sets']
66
67
68rotation_strategies = {64rotation_strategies = {
69 'time': {65 'time': {
70 'class': 'org.graylog2.indexer.rotation.strategies.TimeBasedRotationStrategy',66 'class': 'org.graylog2.indexer.rotation.strategies.TimeBasedRotationStrategy',
@@ -111,14 +107,29 @@ def configure_graylog():
111@when('elasticsearch.available')107@when('elasticsearch.available')
112@when_not('graylog.needs_restart')108@when_not('graylog.needs_restart')
113@when_not('graylog_api.configured')109@when_not('graylog_api.configured')
114def configure_graylog_via_api(*discard):110def configure_graylog_api(*discard):
115 conf = hookenv.config()
116 db = unitdata.kv()111 db = unitdata.kv()
117 admin_password = db.get('admin_password')112 admin_password = db.get('admin_password')
118 api_token = api_retrieve_token(API_URL, auth=('admin', admin_password))113 api_token = api_retrieve_token(API_URL, auth=('admin', admin_password))
119 if not api_token:114 if not api_token:
120 return115 return
121 index_sets = api_retrieve_index_sets(API_URL, auth=(api_token, 'token'))116 if db.get('api_token') != api_token:
117 db.set('api_token', api_token)
118 remove_state('graylog_index_sets.configured')
119 remove_state('graylog_inputs.configured')
120 set_state('graylog_api.configured')
121
122
123@when('graylog_api.configured')
124@when_not('graylog_index_sets.configured')
125def configure_index_sets(*discard):
126 conf = hookenv.config()
127 db = unitdata.kv()
128 api_token = db.get('api_token')
129 if not api_token:
130 return
131 url = urljoin(API_URL, 'system/indices/index_sets')
132 index_sets = api_request(url, auth=(api_token, 'token'))['index_sets']
122 if not index_sets:133 if not index_sets:
123 return134 return
124 for iset in index_sets:135 for iset in index_sets:
@@ -141,7 +152,154 @@ def configure_graylog_via_api(*discard):
141 data=json.dumps(iset, indent=True)):152 data=json.dumps(iset, indent=True)):
142 return153 return
143154
144 set_state('graylog_api.configured')155 set_state('graylog_index_sets.configured')
156
157
158def _map_input_type(input_types, type):
159 """
160 Map specified int types to Gray's input type objects. Examples:
161
162 "Beats" -> org.graylog.plugins.beats.BeatsInput
163 "Raw TCP" -> org.graylog2.inputs.raw.tcp.RawTCPInput
164 "Syslog UDP" -> org.graylog2.inputs.syslog.udp.SyslogUDPInput
165 """
166 for k, v in input_types.items():
167 if v.lower().find(type.lower()) != -1:
168 return k
169 # For raw input types, map something like "Raw TCP" and "Raw UDP"
170 # instead of requiring the exact name, "Raw/Plaintext TCP".
171 if k.lower().find('.'.join(type.split()).lower()) != -1:
172 return k
173 return None
174
175
176def _check_input_exists(current_inputs, new):
177 """
178 Given the current list of configured Graylog inputs, check if new input
179 exists and return an ID as well as "changed" flag to indicate that the
180 new input provided has changed (input type, bind address, and port).
181 """
182 changed = True
183 cur = None
184 for input in current_inputs:
185 if input['title'] == new['title']:
186 cur = input
187 break
188 if not cur:
189 return (True, None)
190 current = [cur['type'], cur['attributes']['bind_address'], str(cur['attributes']['port'])]
191 new = [new['type'], new['configuration']['bind_address'], str(new['configuration']['port'])]
192 if sorted(new) == sorted(current):
193 changed = False
194 return (changed, cur['id'])
195
196
197def _close_uneeded_ports(current_inputs, new):
198 for cur in current_inputs:
199 title = cur['title']
200 if title.lower().startswith('custom'):
201 continue
202 type = cur['type']
203 if 'udp' in type.lower():
204 proto = 'UDP'
205 else:
206 proto = 'TCP'
207 port = cur['attributes']['port']
208 if '{}/{}'.format(port, proto) not in new:
209 hookenv.close_port(port, proto)
210
211
212def _remove_old_inputs(inputs, new_inputs):
213 """
214 Removes any old/previously defined inputs, or any unknown. We'll keep those
215 prefixed with 'Custom' to allow configuring additional inputs via the web
216 UI.
217 """
218 db = unitdata.kv()
219 api_token = db.get('api_token')
220 success = True
221 for cur in inputs:
222 title = cur['title']
223 if title.lower().startswith('custom'):
224 continue
225 if title in new_inputs:
226 continue
227 url = urljoin(urljoin(API_URL, 'system/inputs/'), cur['id'])
228 if api_request(url, auth=(api_token, 'token'), method='DELETE'):
229 hookenv.log('Removed old input: {} ({})'.format(cur['title'], cur['id']))
230 else:
231 success = False
232 return success
233
234
235@when('graylog_api.configured')
236@when_not('graylog_inputs.configured')
237def configure_inputs(*discard):
238 """
239 Configure log inputs in Graylog via the API.
240 """
241 conf = hookenv.config()
242 db = unitdata.kv()
243 api_token = db.get('api_token')
244 if not api_token:
245 return
246 url = urljoin(API_URL, 'system/inputs')
247 inputs = api_request(url, auth=(api_token, 'token'))['inputs']
248 url = urljoin(API_URL, 'system/inputs/types')
249 input_types = api_request(url, auth=(api_token, 'token'))['types']
250 new_opened_ports = []
251 new_inputs = []
252 for new in yaml.safe_load(conf['log_inputs']) or {}:
253 type = _map_input_type(input_types, new['type'])
254 if not type:
255 hookenv.log('Input type "{}" not supported'.format(new['type']))
256 continue
257 d = {
258 'title': new['name'],
259 'type': type,
260 'global': "true",
261 'configuration': {
262 'bind_address': new['bind_address'],
263 'port': new['bind_port'],
264 }
265 }
266 if 'udp' in type.lower():
267 proto = 'UDP'
268 else:
269 proto = 'TCP'
270 d['configuration']['tcp_keepalive'] = 'true'
271
272 hookenv.open_port(new['bind_port'], proto)
273 new_opened_ports.append('{}/{}'.format(new['bind_port'], proto))
274
275 (changed, input_id) = _check_input_exists(inputs, d)
276 if not changed:
277 new_inputs.append(new['name'])
278 continue
279 if input_id:
280 change_text = 'Updated existing input'
281 method = 'PUT'
282 url = urljoin(urljoin(API_URL, 'system/inputs/'), input_id)
283 else:
284 change_text = 'Adding new input'
285 method = 'POST'
286 url = urljoin(API_URL, 'system/inputs/')
287 ret = api_request(url, auth=(api_token, 'token'), method=method,
288 data=json.dumps(d, indent=True))
289 if not ret:
290 # Can't add input, let's try again later
291 return
292 hookenv.log('{}: {} ({})'.format(change_text, new['name'], ret['id']))
293 new_inputs.append(new['name'])
294
295 # Now remove inputs that should no longer be there as well as close ports
296 _close_uneeded_ports(inputs, new_opened_ports)
297 url = urljoin(API_URL, 'system/inputs')
298 refreshed_inputs = api_request(url, auth=(api_token, 'token'))['inputs']
299 if not _remove_old_inputs(refreshed_inputs, new_inputs):
300 # Can't delete input, let's try again later
301 return
302 set_state('graylog_inputs.configured')
145303
146304
147@when('graylog.configured')305@when('graylog.configured')
diff --git a/unit_tests/graylog.py b/unit_tests/graylog.py
index 0ee4134..2b16c78 100644
--- a/unit_tests/graylog.py
+++ b/unit_tests/graylog.py
@@ -5,7 +5,7 @@ import unittest
55
6sys.path.append(os.path.dirname(os.path.dirname(os.path.realpath(__file__))))6sys.path.append(os.path.dirname(os.path.dirname(os.path.realpath(__file__))))
77
8from reactive.graylog import set_conf8from reactive.graylog import set_conf, _map_input_type, _check_input_exists
99
1010
11initial_conf = u"""#key1 = value111initial_conf = u"""#key1 = value1
@@ -38,6 +38,25 @@ key2 = value6
38### key3 = value 438### key3 = value 4
39key4 = value5"""39key4 = value5"""
4040
41log_input_types = {
42 "org.graylog.plugins.beats.BeatsInput": "Beats",
43 "org.graylog2.inputs.raw.udp.RawUDPInput": "Raw/Plaintext UDP",
44}
45
46log_inputs = [{
47 "title": "Beats Input",
48 "global": "true",
49 "name": "Beats",
50 "created_at": "2017-08-17T10:19:10.634Z",
51 "type": "org.graylog.plugins.beats.BeatsInput",
52 "creator_user_id": "admin",
53 "attributes": {
54 "bind_address": "0.0.0.0",
55 "port": 11044
56 },
57 "id": "5994f3aab0d37f23c38adbda"
58}]
59
4160
42class TestSetConf(unittest.TestCase):61class TestSetConf(unittest.TestCase):
4362
@@ -81,3 +100,48 @@ class TestSetConf(unittest.TestCase):
81100
82 def test_changed(self):101 def test_changed(self):
83 self.assertTrue(set_conf('key2', 'some-new-value', self.conf_file))102 self.assertTrue(set_conf('key2', 'some-new-value', self.conf_file))
103
104 def test_input_type_exists(self):
105 self.assertTrue(_map_input_type(log_input_types, 'beats'))
106
107 def test_input_type_raw(self):
108 self.assertTrue(_map_input_type(log_input_types, 'raw udp'))
109
110 def test_input_type_invalid(self):
111 self.assertFalse(_map_input_type(log_input_types, 'some-weird-type'))
112
113 def test_input_exists(self):
114 new_input = {
115 "title": "Beats Input",
116 "type": "org.graylog.plugins.beats.BeatsInput",
117 "configuration": {
118 "bind_address": "0.0.0.0",
119 "port": 11044
120 }
121 }
122 (changed, input_id) = _check_input_exists(log_inputs, new_input)
123 self.assertTrue(input_id == "5994f3aab0d37f23c38adbda" and changed is False)
124
125 def test_input_changed(self):
126 new_input = {
127 "title": "Beats Input",
128 "type": "org.graylog.plugins.beats.BeatsInput",
129 "configuration": {
130 "bind_address": "0.0.0.0",
131 "port": 12222
132 }
133 }
134 (changed, input_id) = _check_input_exists(log_inputs, new_input)
135 self.assertTrue(changed is True)
136
137 def test_input_new(self):
138 new_input = {
139 "title": "Beats Input 2",
140 "type": "org.graylog.plugins.beats.BeatsInput",
141 "configuration": {
142 "bind_address": "0.0.0.0",
143 "port": 11033
144 }
145 }
146 (changed, input_id) = _check_input_exists(log_inputs, new_input)
147 self.assertTrue(input_id is None)

Subscribers

People subscribed via source and target branches

to all changes: