-
Notifications
You must be signed in to change notification settings - Fork 17
/
alertmanager_remote_configuration.py
478 lines (375 loc) · 18.1 KB
/
alertmanager_remote_configuration.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
# Copyright 2022 Canonical Ltd.
# See LICENSE file for licensing details.
"""Alertmanager Remote Configuration library.
This library offers the option of configuring Alertmanager via relation data.
It has been created with the `alertmanager-k8s` and the `alertmanager-k8s-configurer`
(https://charmhub.io/alertmanager-configurer-k8s) charms in mind, but can be used by any charms
which require functionalities implemented by this library.
To get started using the library, you just need to fetch the library using `charmcraft`.
```shell
cd some-charm
charmcraft fetch-lib charms.alertmanager_k8s.v0.alertmanager_remote_configuration
```
Charms that need to push Alertmanager configuration to a charm exposing relation using
the `alertmanager_remote_configuration` interface, should use the `RemoteConfigurationProvider`.
Charms that need to can utilize the Alertmanager configuration provided from the external source
through a relation using the `alertmanager_remote_configuration` interface, should use
the `RemoteConfigurationRequirer`.
"""
import json
import logging
from typing import Optional, Tuple
import yaml
from ops.charm import CharmBase
from ops.framework import EventBase, EventSource, Object, ObjectEvents
# The unique Charmhub library identifier, never change it
LIBID = "0e5a4c0ecde34c9880bb8899ac53444d"
# Increment this major API version when introducing breaking changes
LIBAPI = 0
# Increment this PATCH version before using `charmcraft publish-lib` or reset
# to 0 if you are raising the major API version
LIBPATCH = 3
logger = logging.getLogger(__name__)
DEFAULT_RELATION_NAME = "remote-configuration"
class ConfigReadError(Exception):
"""Raised if Alertmanager configuration can't be read."""
def __init__(self, config_file: str):
self.message = "Failed to read {}".format(config_file)
super().__init__(self.message)
def config_main_keys_are_valid(config: Optional[dict]) -> bool:
"""Checks whether main keys in the Alertmanager's config file are valid.
This method facilitates the basic sanity check of Alertmanager's configuration. It checks
whether given configuration contains only allowed main keys or not. `templates` have been
removed from the list of allowed main keys to reflect the fact that `alertmanager-k8s` doesn't
accept it as part of config (see `alertmanager-k8s` description for more details).
Full validation of the config is done on the `alertmanager-k8s` charm side.
Args:
config: Alertmanager config dictionary
Returns:
bool: True/False
"""
allowed_main_keys = [
"global",
"receivers",
"route",
"inhibit_rules",
"time_intervals",
"mute_time_intervals",
]
return all(item in allowed_main_keys for item in config.keys()) if config else False
class AlertmanagerRemoteConfigurationChangedEvent(EventBase):
"""Event emitted when Alertmanager remote_configuration relation data bag changes."""
pass
class AlertmanagerRemoteConfigurationRequirerEvents(ObjectEvents):
"""Event descriptor for events raised by `AlertmanagerRemoteConfigurationRequirer`."""
remote_configuration_changed = EventSource(AlertmanagerRemoteConfigurationChangedEvent)
class RemoteConfigurationRequirer(Object):
"""API that manages a required `alertmanager_remote_configuration` relation.
The `RemoteConfigurationRequirer` object can be instantiated as follows in your charm:
```
from charms.alertmanager_k8s.v0.alertmanager_remote_configuration import (
RemoteConfigurationRequirer,
)
def __init__(self, *args):
...
self.remote_configuration = RemoteConfigurationRequirer(self)
...
```
The `RemoteConfigurationRequirer` assumes that, in the `metadata.yaml` of your charm,
you declare a required relation as follows:
```
requires:
remote-configuration: # Relation name
interface: alertmanager_remote_configuration # Relation interface
limit: 1
```
The `RemoteConfigurationRequirer` provides a public `config` method for exposing the data
from the relation data bag. Typical usage of these methods in the provider charm would look
something like:
```
def get_config(self, *args):
...
configuration, templates = self.remote_configuration.config()
...
self.container.push("/alertmanager/config/file.yml", configuration)
self.container.push("/alertmanager/templates/file.tmpl", templates)
...
```
Separation of the main configuration and the templates is dictated by the assumption that
the default provider of the `alertmanager_remote_configuration` relation will be
`alertmanager-k8s` charm, which requires such separation.
"""
on = AlertmanagerRemoteConfigurationRequirerEvents() # pyright: ignore
def __init__(
self,
charm: CharmBase,
relation_name: str = DEFAULT_RELATION_NAME,
):
"""API that manages a required `remote-configuration` relation.
Args:
charm: The charm object that instantiated this class.
relation_name: Name of the relation with the `alertmanager_remote_configuration`
interface as defined in metadata.yaml. Defaults to `remote-configuration`.
"""
super().__init__(charm, relation_name)
self._charm = charm
self._relation_name = relation_name
on_relation = self._charm.on[self._relation_name]
self.framework.observe(on_relation.relation_created, self._on_relation_created)
self.framework.observe(on_relation.relation_changed, self._on_relation_changed)
self.framework.observe(on_relation.relation_broken, self._on_relation_broken)
def _on_relation_created(self, _) -> None:
"""Event handler for remote configuration relation created event.
Informs about the fact that the configuration from remote provider will be used.
"""
logger.debug("Using remote configuration from the remote_configuration relation.")
def _on_relation_changed(self, _) -> None:
"""Event handler for remote configuration relation changed event.
Emits custom `remote_configuration_changed` event every time remote configuration
changes.
"""
self.on.remote_configuration_changed.emit() # pyright: ignore
def _on_relation_broken(self, _) -> None:
"""Event handler for remote configuration relation broken event.
Informs about the fact that the configuration from remote provider will no longer be used.
"""
logger.debug("Remote configuration no longer available.")
def config(self) -> Tuple[Optional[dict], Optional[list]]:
"""Exposes Alertmanager configuration sent inside the relation data bag.
Charm which requires Alertmanager configuration, can access it like below:
```
def get_config(self, *args):
...
configuration, templates = self.remote_configuration.config()
...
self.container.push("/alertmanager/config/file.yml", configuration)
self.container.push("/alertmanager/templates/file.tmpl", templates)
...
```
Returns:
tuple: Alertmanager configuration (dict) and templates (list)
"""
return self._alertmanager_config, self._alertmanager_templates
@property
def _alertmanager_config(self) -> Optional[dict]:
"""Returns Alertmanager configuration sent inside the relation data bag.
If the `alertmanager-remote-configuration` relation exists, takes the Alertmanager
configuration provided in the relation data bag and returns it in a form of a dictionary
if configuration passes the validation against the Alertmanager config schema.
If configuration fails the validation, error is logged and config is rejected (empty config
is returned).
Returns:
dict: Alertmanager configuration dictionary
"""
remote_configuration_relation = self._charm.model.get_relation(self._relation_name)
if remote_configuration_relation and remote_configuration_relation.app:
try:
config_raw = remote_configuration_relation.data[remote_configuration_relation.app][
"alertmanager_config"
]
config = yaml.safe_load(config_raw)
if config_main_keys_are_valid(config):
return config
except KeyError:
logger.warning(
"Remote config provider relation exists, but no config has been provided."
)
return None
@property
def _alertmanager_templates(self) -> Optional[list]:
"""Returns Alertmanager templates sent inside the relation data bag.
If the `alertmanager-remote-configuration` relation exists and the relation data bag
contains Alertmanager templates, returns the templates in the form of a list.
Returns:
list: Alertmanager templates
"""
templates = None
remote_configuration_relation = self._charm.model.get_relation(self._relation_name)
if remote_configuration_relation and remote_configuration_relation.app:
try:
templates_raw = remote_configuration_relation.data[
remote_configuration_relation.app
]["alertmanager_templates"]
templates = json.loads(templates_raw)
except KeyError:
logger.warning(
"Remote config provider relation exists, but no templates have been provided."
)
return templates
class AlertmanagerConfigurationBrokenEvent(EventBase):
"""Event emitted when configuration provided by the Provider charm is invalid."""
pass
class AlertmanagerRemoteConfigurationProviderEvents(ObjectEvents):
"""Event descriptor for events raised by `AlertmanagerRemoteConfigurationProvider`."""
configuration_broken = EventSource(AlertmanagerConfigurationBrokenEvent)
class RemoteConfigurationProvider(Object):
"""API that manages a provided `alertmanager_remote_configuration` relation.
The `RemoteConfigurationProvider` is intended to be used by charms that need to push data
to other charms over the `alertmanager_remote_configuration` interface.
The `RemoteConfigurationProvider` object can be instantiated as follows in your charm:
```
from charms.alertmanager_k8s.v0.alertmanager_remote_configuration import
RemoteConfigurationProvider,
)
def __init__(self, *args):
...
config = RemoteConfigurationProvider.load_config_file(FILE_PATH)
self.remote_configuration_provider = RemoteConfigurationProvider(
charm=self,
alertmanager_config=config,
)
...
```
Alternatively, RemoteConfigurationProvider can be instantiated using a factory, which allows
using a configuration file path directly instead of a configuration string:
```
from charms.alertmanager_k8s.v0.alertmanager_remote_configuration import
RemoteConfigurationProvider,
)
def __init__(self, *args):
...
self.remote_configuration_provider = RemoteConfigurationProvider.with_config_file(
charm=self,
config_file=FILE_PATH,
)
...
```
The `RemoteConfigurationProvider` assumes that, in the `metadata.yaml` of your charm,
you declare a required relation as follows:
```
provides:
remote-configuration: # Relation name
interface: alertmanager_remote_configuration # Relation interface
```
The `RemoteConfigurationProvider` provides handling of the most relevant charm
lifecycle events. On each of the defined Juju events, Alertmanager configuration and templates
from a specified file will be pushed to the relation data bag.
Inside the relation data bag, Alertmanager configuration will be stored under
`alertmanager_configuration` key, while the templates under the `alertmanager_templates` key.
Separation of the main configuration and the templates is dictated by the assumption that
the default provider of the `alertmanager_remote_configuration` relation will be
`alertmanager-k8s` charm, which requires such separation.
"""
on = AlertmanagerRemoteConfigurationProviderEvents() # pyright: ignore
def __init__(
self,
charm: CharmBase,
alertmanager_config: Optional[dict] = None,
relation_name: str = DEFAULT_RELATION_NAME,
):
"""API that manages a provided `remote-configuration` relation.
Args:
charm: The charm object that instantiated this class.
alertmanager_config: Alertmanager configuration dictionary.
relation_name: Name of the relation with the `alertmanager_remote_configuration`
interface as defined in metadata.yaml. Defaults to `remote-configuration`.
"""
super().__init__(charm, relation_name)
self._charm = charm
self.alertmanager_config = alertmanager_config
self._relation_name = relation_name
on_relation = self._charm.on[self._relation_name]
self.framework.observe(on_relation.relation_joined, self._on_relation_joined)
@classmethod
def with_config_file(
cls,
charm: CharmBase,
config_file: str,
relation_name: str = DEFAULT_RELATION_NAME,
):
"""The RemoteConfigurationProvider object factory.
This factory provides an alternative way of instantiating the RemoteConfigurationProvider.
While the default constructor requires passing a config dict, the factory allows using
a configuration file path.
Args:
charm: The charm object that instantiated this class.
config_file: Path to the Alertmanager configuration file.
relation_name: Name of the relation with the `alertmanager_remote_configuration`
interface as defined in metadata.yaml. Defaults to `remote-configuration`.
Returns:
RemoteConfigurationProvider object
"""
return cls(charm, cls.load_config_file(config_file), relation_name)
def _on_relation_joined(self, _) -> None:
"""Event handler for RelationJoinedEvent.
Takes care of pushing Alertmanager configuration to the relation data bag.
"""
if not self._charm.unit.is_leader():
return
self.update_relation_data_bag(self.alertmanager_config)
@staticmethod
def load_config_file(path: str) -> dict:
"""Reads given Alertmanager configuration file and turns it into a dictionary.
Args:
path: Path to the Alertmanager configuration file
Returns:
dict: Alertmanager configuration file in a form of a dictionary
Raises:
ConfigReadError: if a problem with reading given config file happens
"""
try:
with open(path, "r") as config_yaml:
config = yaml.safe_load(config_yaml)
return config
except (FileNotFoundError, OSError, yaml.YAMLError) as e:
raise ConfigReadError(path) from e
def update_relation_data_bag(self, alertmanager_config: Optional[dict]) -> None:
"""Updates relation data bag with Alertmanager config and templates.
Before updating relation data bag, basic sanity check of given configuration is done.
Args:
alertmanager_config: Alertmanager configuration dictionary.
"""
if not self._charm.unit.is_leader():
return
config, templates = self._prepare_relation_data(alertmanager_config)
if config_main_keys_are_valid(config):
for relation in self._charm.model.relations[self._relation_name]:
relation.data[self._charm.app]["alertmanager_config"] = json.dumps(config)
relation.data[self._charm.app]["alertmanager_templates"] = json.dumps(templates)
else:
logger.warning("Invalid Alertmanager configuration. Ignoring...")
self._clear_relation_data()
self.on.configuration_broken.emit() # pyright: ignore
def _prepare_relation_data(
self, config: Optional[dict]
) -> Tuple[Optional[dict], Optional[list]]:
"""Prepares relation data to be put in a relation data bag.
If the main config file contains templates section, content of the files specified in this
section will be concatenated. At the same time, templates section will be removed from
the main config, as alertmanager-k8s-operator charm doesn't tolerate it.
Args:
config: Content of the Alertmanager configuration file
Returns:
dict: Alertmanager configuration
list: List of templates
"""
templates = []
if config and config.get("templates") is not None:
for file in config.pop("templates"):
try:
templates.append(self._load_templates_file(file))
except FileNotFoundError:
logger.warning("Template file {} not found. Skipping.".format(file))
continue
return config, templates
@staticmethod
def _load_templates_file(path: str) -> str:
"""Reads given Alertmanager templates file and returns its content in a form of a string.
Args:
path: Alertmanager templates file path
Returns:
str: Alertmanager templates
Raises:
ConfigReadError: if a problem with reading given config file happens
"""
try:
with open(path, "r") as template_file:
templates = template_file.read()
return templates
except (FileNotFoundError, OSError, ValueError) as e:
raise ConfigReadError(path) from e
def _clear_relation_data(self) -> None:
"""Clears relation data bag."""
for relation in self._charm.model.relations[self._relation_name]:
relation.data[self._charm.app]["alertmanager_config"] = ""
relation.data[self._charm.app]["alertmanager_templates"] = ""