diff --git a/lib/charms/loki_k8s/v1/loki_push_api.py b/lib/charms/loki_k8s/v1/loki_push_api.py index ff5ea8de5..a7832952f 100644 --- a/lib/charms/loki_k8s/v1/loki_push_api.py +++ b/lib/charms/loki_k8s/v1/loki_push_api.py @@ -1328,11 +1328,24 @@ def __init__( @staticmethod def _inject_extra_labels_to_alert_rules(rules: Dict, extra_alert_labels: Dict) -> Dict: - """Return a copy of the rules dict with extra labels injected.""" + """Return a copy of the rules dict with extra labels injected. + + Labels whose value is None or an empty string are removed from every + rule rather than being set. If removing labels leaves the ``labels`` + dict empty, the key is dropped from the rule entirely. + """ result = copy.deepcopy(rules) + labels_to_drop = {k for k, v in extra_alert_labels.items() if v is None or v == ""} + labels_to_set = {k: v for k, v in extra_alert_labels.items() if k not in labels_to_drop} + for group in result.get("groups", []): for rule in group.get("rules", []): - rule.setdefault("labels", {}).update(extra_alert_labels) + rule_labels = rule.setdefault("labels", {}) + rule_labels.update(labels_to_set) + for key in labels_to_drop: + rule_labels.pop(key, None) + if not rule_labels: + del rule["labels"] return result def _handle_alert_rules(self, relation): diff --git a/pyproject.toml b/pyproject.toml index e5a4fb703..a72ed8d85 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,6 +28,7 @@ dev = [ "pyright", # Unit "pytest", + "pytest-bdd", "coverage[toml]", "ops[testing]", "responses", @@ -86,3 +87,8 @@ markers = ["setup", "work", "teardown"] [tool.codespell] skip = ".git,.tox,build,venv*" ignore-words-list = "assertIn" + +[dependency-groups] +dev = [ + "pytest-bdd>=8.1.0", +] diff --git a/tests/unit/features/inject_extra_labels.feature b/tests/unit/features/inject_extra_labels.feature new file mode 100644 index 000000000..b4d33a6c8 --- /dev/null +++ b/tests/unit/features/inject_extra_labels.feature @@ -0,0 +1,123 @@ +Feature: Inject extra labels to alert rules + + Scenario: Extra labels are appended to all rules + Given rules + """ + groups: + - rules: + - labels: + severity: critical + - labels: + severity: warning + """ + When extra labels are injected + """ + environment: production + team: observability + """ + Then modified rules match + """ + groups: + - rules: + - labels: + severity: critical + environment: production + team: observability + - labels: + severity: warning + environment: production + team: observability + """ + + Scenario: Extra labels override existing labels with the same key + Given rules + """ + groups: + - rules: + - labels: + severity: warning + team: old-team + """ + When extra labels are injected + """ + team: new-team + """ + Then modified rules match + """ + groups: + - rules: + - labels: + severity: warning + team: new-team + """ + + Scenario: Empty extra labels leaves rules unchanged + Given rules + """ + groups: + - rules: + - labels: + severity: warning + team: some-team + """ + When extra labels are injected + """ + --- + """ + Then modified rules match + """ + groups: + - rules: + - labels: + severity: warning + team: some-team + """ + + Scenario: Rules without a labels key get labels added + Given rules + """ + groups: + - rules: + - alert: NoLabelsAlert + """ + When extra labels are injected + """ + env: staging + """ + Then modified rules match + """ + groups: + - rules: + - alert: NoLabelsAlert + labels: + env: staging + """ + + Scenario: Empty label values removes the label + Given rules + """ + groups: + - rules: + - name: first + labels: + env: env1 + - name: second + labels: + env: env2 + foo: bar + severity: warning + """ + When extra labels are injected + """ + env: null + foo: "" + """ + Then modified rules match + """ + groups: + - rules: + - name: first + - name: second + labels: + severity: warning + """ diff --git a/tests/unit/test_inject_extra_labels.py b/tests/unit/test_inject_extra_labels.py new file mode 100644 index 000000000..db05b9ed3 --- /dev/null +++ b/tests/unit/test_inject_extra_labels.py @@ -0,0 +1,31 @@ +"""Tests for ConsumerBase._inject_extra_labels_to_alert_rules using pytest-bdd.""" + + +import yaml +from charms.loki_k8s.v1.loki_push_api import ConsumerBase +from pytest_bdd import given, scenarios, then, when + +scenarios("features/inject_extra_labels.feature") + + +@given("rules", target_fixture="rules") +def given_rules(docstring): + """Parse the alert rules from the docstring fixture.""" + return yaml.safe_load(docstring) + + +@when("extra labels are injected", target_fixture="modified_rules") +def when_extra_labels_injected(docstring, rules): + """Inject extra labels using the method under test.""" + extra_labels = yaml.safe_load(docstring) or {} + + return ConsumerBase._inject_extra_labels_to_alert_rules( + rules, extra_labels + ) + + +@then("modified rules match") +def then_modified_rules_match(docstring, modified_rules): + """Verify every rule has the extra labels.""" + expected = yaml.safe_load(docstring) + assert expected == modified_rules diff --git a/uv.lock b/uv.lock index bb0038262..4a8c6a555 100644 --- a/uv.lock +++ b/uv.lock @@ -404,6 +404,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b9/5c/a3d95dc1ec6cdeb032d789b552ecc76effa3557ea9186e1566df6aac18df/fs-2.4.16-py2.py3-none-any.whl", hash = "sha256:660064febbccda264ae0b6bace80a8d1be9e089e0a5eb2427b7d517f9a91545c", size = 135261, upload-time = "2022-05-02T09:25:52.363Z" }, ] +[[package]] +name = "gherkin-official" +version = "29.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/d8/7a28537efd7638448f7512a0cce011d4e3bf1c7f4794ad4e9c87b3f1e98e/gherkin_official-29.0.0.tar.gz", hash = "sha256:dbea32561158f02280d7579d179b019160d072ce083197625e2f80a6776bb9eb", size = 32303, upload-time = "2024-08-12T09:41:09.595Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f8/fc/b86c22ad3b18d8324a9d6fe5a3b55403291d2bf7572ba6a16efa5aa88059/gherkin_official-29.0.0-py3-none-any.whl", hash = "sha256:26967b0d537a302119066742669e0e8b663e632769330be675457ae993e1d1bc", size = 37085, upload-time = "2024-08-12T09:41:07.954Z" }, +] + [[package]] name = "google-auth" version = "2.40.1" @@ -686,6 +695,7 @@ dev = [ { name = "ops", extra = ["testing"] }, { name = "pyright" }, { name = "pytest" }, + { name = "pytest-bdd" }, { name = "pytest-interface-tester" }, { name = "pytest-operator" }, { name = "responses" }, @@ -693,6 +703,11 @@ dev = [ { name = "sh" }, ] +[package.dev-dependencies] +dev = [ + { name = "pytest-bdd" }, +] + [package.metadata] requires-dist = [ { name = "aiohttp", specifier = "!=3.11.13" }, @@ -712,6 +727,7 @@ requires-dist = [ { name = "pydantic", specifier = ">=2.9.0" }, { name = "pyright", marker = "extra == 'dev'" }, { name = "pytest", marker = "extra == 'dev'" }, + { name = "pytest-bdd", marker = "extra == 'dev'" }, { name = "pytest-interface-tester", marker = "extra == 'dev'" }, { name = "pytest-operator", marker = "extra == 'dev'" }, { name = "pyyaml" }, @@ -722,6 +738,9 @@ requires-dist = [ ] provides-extras = ["dev"] +[package.metadata.requires-dev] +dev = [{ name = "pytest-bdd", specifier = ">=8.1.0" }] + [[package]] name = "macaroonbakery" version = "1.3.4" @@ -739,6 +758,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/60/42/227f748dc222b7a1c5cb40c7c74ab4162c7fc146b88980776b490ab673a1/macaroonbakery-1.3.4-py2.py3-none-any.whl", hash = "sha256:1e952a189f5c1e96ef82b081b2852c770d7daa20987e2088e762dd5689fb253b", size = 103184, upload-time = "2023-12-13T14:22:20.159Z" }, ] +[[package]] +name = "mako" +version = "1.3.12" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/00/62/791b31e69ae182791ec67f04850f2f062716bbd205483d63a215f3e062d3/mako-1.3.12.tar.gz", hash = "sha256:9f778e93289bd410bb35daadeb4fc66d95a746f0b75777b942088b7fd7af550a", size = 400219, upload-time = "2026-04-28T19:01:08.512Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bc/b1/a0ec7a5a9db730a08daef1fdfb8090435b82465abbf758a596f0ea88727e/mako-1.3.12-py3-none-any.whl", hash = "sha256:8f61569480282dbf557145ce441e4ba888be453c30989f879f0d652e39f53ea9", size = 78521, upload-time = "2026-04-28T19:01:10.393Z" }, +] + [[package]] name = "markupsafe" version = "3.0.2" @@ -952,6 +983,28 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/15/f8/c7bd0ef12954a81a1d3cea60a13946bd9a49a0036a5927770c461eade7ae/paramiko-3.5.1-py3-none-any.whl", hash = "sha256:43b9a0501fc2b5e70680388d9346cf252cfb7d00b0667c39e80eb43a408b8f61", size = 227298, upload-time = "2025-02-04T02:37:57.672Z" }, ] +[[package]] +name = "parse" +version = "1.22.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7b/a2/dd269daedd5ac3a244ca7855b4878d8655393fd4554d5c24a56bc31e302a/parse-1.22.0.tar.gz", hash = "sha256:d4987d68ccf08b6ba3bf80b5004ff7de61c4337cba2d8350ae5c9925794979d9", size = 36767, upload-time = "2026-05-02T01:36:25.575Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/3a/0c2cf5922c6133b74c1cebe4b66f6949818e2cf8121aa59e3ebcd64ac6ac/parse-1.22.0-py2.py3-none-any.whl", hash = "sha256:eea8ed34e2614cea65d9c1d4af9cb68cce26aea13d44bdcaf83c1b40884fe945", size = 20839, upload-time = "2026-05-02T01:36:24.403Z" }, +] + +[[package]] +name = "parse-type" +version = "0.6.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "parse" }, + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/ea/42ba6ce0abba04ab6e0b997dcb9b528a4661b62af1fe1b0d498120d5ea78/parse_type-0.6.6.tar.gz", hash = "sha256:513a3784104839770d690e04339a8b4d33439fcd5dd99f2e4580f9fc1097bfb2", size = 98012, upload-time = "2025-08-11T22:53:48.066Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/85/8d/eef3d8cdccc32abdd91b1286884c99b8c3a6d3b135affcc2a7a0f383bb32/parse_type-0.6.6-py2.py3-none-any.whl", hash = "sha256:3ca79bbe71e170dfccc8ec6c341edfd1c2a0fc1e5cfd18330f93af938de2348c", size = 27085, upload-time = "2025-08-11T22:53:46.396Z" }, +] + [[package]] name = "parso" version = "0.8.4" @@ -1233,6 +1286,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9c/ce/1e4b53c213dce25d6e8b163697fbce2d43799d76fa08eea6ad270451c370/pytest_asyncio-0.21.2-py3-none-any.whl", hash = "sha256:ab664c88bb7998f711d8039cacd4884da6430886ae8bbd4eded552ed2004f16b", size = 13368, upload-time = "2024-04-29T13:23:23.126Z" }, ] +[[package]] +name = "pytest-bdd" +version = "8.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "gherkin-official" }, + { name = "mako" }, + { name = "packaging" }, + { name = "parse" }, + { name = "parse-type" }, + { name = "pytest" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2d/2f/14c2e55372a5718a93b56aea48cd6ccc15d2d245364e516cd7b19bbd07ad/pytest_bdd-8.1.0.tar.gz", hash = "sha256:ef0896c5cd58816dc49810e8ff1d632f4a12019fb3e49959b2d349ffc1c9bfb5", size = 56147, upload-time = "2024-12-05T21:45:58.83Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9f/7d/1461076b0cc9a9e6fa8b51b9dea2677182ba8bc248d99d95ca321f2c666f/pytest_bdd-8.1.0-py3-none-any.whl", hash = "sha256:2124051e71a05ad7db15296e39013593f72ebf96796e1b023a40e5453c47e5fb", size = 49149, upload-time = "2024-12-05T21:45:56.184Z" }, +] + [[package]] name = "pytest-interface-tester" version = "3.3.1"