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

add private method to reinitialize alerts #76

Merged
merged 6 commits into from
Jan 26, 2022
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
11 changes: 9 additions & 2 deletions lib/charms/loki_k8s/v0/loki_push_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -405,7 +405,7 @@ def _alert_rules_error(self, event):

# Increment this PATCH version before using `charmcraft publish-lib` or reset
# to 0 if you are raising the major API version
LIBPATCH = 8
LIBPATCH = 9

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -1093,7 +1093,9 @@ def _handle_alert_rules(self, relation):
self.on.loki_push_api_alert_rules_error.emit(alert_rules_error_message)

relation.data[self._charm.app]["metadata"] = json.dumps(self.topology.as_dict())
relation.data[self._charm.app]["alert_rules"] = json.dumps({"groups": alert_groups})
relation.data[self._charm.app]["alert_rules"] = json.dumps(
{"groups": alert_groups} if alert_groups else {}
)

def _check_alert_rules(self, alert_groups, invalid_files) -> str:
"""Check alert rules.
Expand Down Expand Up @@ -1210,6 +1212,11 @@ def _on_logging_relation_changed(self, event):
for relation in self._charm.model.relations[self._relation_name]:
self._process_logging_relation_changed(relation)

def _reinitialize_alert_rules(self):
"""Reloads alert rules and updates all relations."""
for relation in self._charm.model.relations[self._relation_name]:
self._handle_alert_rules(relation)

def _process_logging_relation_changed(self, relation: Relation):
loki_push_api_data = relation.data[relation.app].get("loki_push_api")

Expand Down
53 changes: 53 additions & 0 deletions tests/unit/helpers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
#!/usr/bin/env python3
# Copyright 2021 Canonical Ltd.
# See LICENSE file for licensing details.

import os
import tempfile
from typing import Tuple


class TempFolderSandbox:
"""A helper class for creating files in a temporary folder (sandbox)."""

def __init__(self):
self.root = tempfile.mkdtemp()

def put_file(self, rel_path: str, contents: str):
"""Write string to file.

Args:
rel_path: path to file, relative to the sandbox root.
contents: the data to write to file.
"""
file_path = os.path.join(self.root, rel_path)
os.makedirs(os.path.dirname(file_path), exist_ok=True)
with open(file_path, "wt") as f:
f.write(contents)

def put_files(self, *args: Tuple[str, str]):
"""Write strings to files. A vectorized version of `put_file`.

Args:
args: a tuple of path and contents.
"""
for rel_path, contents in args:
self.put_file(rel_path, contents)

def remove(self, rel_path: str):
"""Delete file from disk.

Args:
rel_path: path to file, relative to the sandbox root.
"""
file_path = os.path.join(self.root, rel_path)
os.remove(file_path)

def rmdir(self, rel_path):
"""Delete an empty dir.

Args:
rel_path: path to dir, relative to the sandbox root.
"""
dir_path = os.path.join(self.root, rel_path)
os.rmdir(dir_path)
127 changes: 127 additions & 0 deletions tests/unit/test_consumer.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,14 @@
# See LICENSE file for licensing details.

import json
import os
import textwrap
import unittest
from unittest.mock import PropertyMock, patch

import yaml
from charms.loki_k8s.v0.loki_push_api import LokiPushApiConsumer, _is_valid_rule
from helpers import TempFolderSandbox
from ops.charm import CharmBase
from ops.framework import StoredState
from ops.testing import Harness
Expand Down Expand Up @@ -158,3 +161,127 @@ def test__is_valid_rule(self):
rule_3 = ONE_RULE.copy()
rule_3["expr"] = "Missing Juju topology placeholder"
self.assertFalse(_is_valid_rule(rule_3, allow_free_standing=False))


class TestReloadAlertRules(unittest.TestCase):
"""Feature: Consumer charm can manually invoke reloading of alerts.

Background: In use cases such as cos-configuration-k8s-operator, the last hook can fire before
the alert files show up on disk. In that case relation data would remain empty of alerts. To
circumvent that, a public method for reloading alert rules is offered.
"""

NO_ALERTS = json.dumps({}) # relation data representation for the case of "no alerts"

# use a short-form free-standing alert, for brevity
ALERT = yaml.safe_dump({"alert": "free_standing", "expr": "avg(some_vector[5m]) > 5"})

def setUp(self):
self.sandbox = TempFolderSandbox()
alert_rules_path = os.path.join(self.sandbox.root, "alerts")
self.alert_rules_path = alert_rules_path

class ConsumerCharm(CharmBase):
metadata_yaml = textwrap.dedent(
"""
requires:
logging:
interface: loki_push_api
"""
)

def __init__(self, *args, **kwargs):
super().__init__(*args)
self._port = 3100
self.loki_consumer = LokiPushApiConsumer(
self, alert_rules_path=alert_rules_path, allow_free_standing_rules=True
)

self.harness = Harness(ConsumerCharm, meta=ConsumerCharm.metadata_yaml)
# self.harness = Harness(FakeConsumerCharm, meta=FakeConsumerCharm.metadata_yaml)
self.addCleanup(self.harness.cleanup)
self.harness.begin_with_initial_hooks()
self.harness.set_leader(True)
self.rel_id = self.harness.add_relation("logging", "loki")

# need to manually emit relation changed
# https://github.com/canonical/operator/issues/682
self.harness.charm.on.logging_relation_changed.emit(
self.harness.charm.model.get_relation("logging")
)

def test_reload_when_dir_is_still_empty_changes_nothing(self):
"""Scenario: The reload method is called when the alerts dir is still empty."""
# GIVEN relation data contains no alerts
relation = self.harness.charm.model.get_relation("logging")
self.assertEqual(relation.data[self.harness.charm.app].get("alert_rules"), self.NO_ALERTS)

# WHEN no rule files are present

# AND the reload method is called
self.harness.charm.loki_consumer._reinitialize_alert_rules()

# THEN relation data is unchanged
relation = self.harness.charm.model.get_relation("logging")
self.assertEqual(relation.data[self.harness.charm.app].get("alert_rules"), self.NO_ALERTS)

def test_reload_after_dir_is_populated_updates_relation_data(self):
"""Scenario: The reload method is called after some alert files are added."""
# GIVEN relation data contains no alerts
relation = self.harness.charm.model.get_relation("logging")
self.assertEqual(relation.data[self.harness.charm.app].get("alert_rules"), self.NO_ALERTS)

# WHEN some rule files are added to the alerts dir
self.sandbox.put_file(os.path.join(self.alert_rules_path, "alert.rule"), self.ALERT)

# AND the reload method is called
self.harness.charm.loki_consumer._reinitialize_alert_rules()

# THEN relation data is updated
relation = self.harness.charm.model.get_relation("logging")
self.assertNotEqual(
relation.data[self.harness.charm.app].get("alert_rules"), self.NO_ALERTS
)

def test_reload_after_dir_is_emptied_updates_relation_data(self):
"""Scenario: The reload method is called after all the loaded alert files are removed."""
# GIVEN alert files are present and relation data contains respective alerts
alert_filename = os.path.join(self.alert_rules_path, "alert.rule")
self.sandbox.put_file(alert_filename, self.ALERT)
self.harness.charm.loki_consumer._reinitialize_alert_rules()
relation = self.harness.charm.model.get_relation("logging")
self.assertNotEqual(
relation.data[self.harness.charm.app].get("alert_rules"), self.NO_ALERTS
)

# WHEN all rule files are deleted from the alerts dir
self.sandbox.remove(alert_filename)

# AND the reload method is called
self.harness.charm.loki_consumer._reinitialize_alert_rules()

# THEN relation data is empty again
relation = self.harness.charm.model.get_relation("logging")
self.assertEqual(relation.data[self.harness.charm.app].get("alert_rules"), self.NO_ALERTS)

def test_reload_after_dir_itself_removed_updates_relation_data(self):
"""Scenario: The reload method is called after the alerts dir doesn't exist anymore."""
# GIVEN alert files are present and relation data contains respective alerts
alert_filename = os.path.join(self.alert_rules_path, "alert.rule")
self.sandbox.put_file(alert_filename, self.ALERT)
self.harness.charm.loki_consumer._reinitialize_alert_rules()
relation = self.harness.charm.model.get_relation("logging")
self.assertNotEqual(
relation.data[self.harness.charm.app].get("alert_rules"), self.NO_ALERTS
)

# WHEN the alerts dir itself is deleted
self.sandbox.remove(alert_filename)
self.sandbox.rmdir(self.alert_rules_path)

# AND the reload method is called
self.harness.charm.loki_consumer._reinitialize_alert_rules()

# THEN relation data is empty again
relation = self.harness.charm.model.get_relation("logging")
self.assertEqual(relation.data[self.harness.charm.app].get("alert_rules"), self.NO_ALERTS)
2 changes: 1 addition & 1 deletion tox.ini
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ deps =
commands =
coverage run \
--source={[vars]src_path},{[vars]lib_path} \
-m pytest -v --tb native -s {posargs} {[vars]tst_path}/unit
-m pytest -v --tb native --log-cli-level=INFO -s {posargs} {[vars]tst_path}/unit
coverage report

[testenv:integration]
Expand Down