diff --git a/lib/charms/grafana_k8s/v0/grafana_dashboard.py b/lib/charms/grafana_k8s/v0/grafana_dashboard.py index 2d84073..2e7ce25 100644 --- a/lib/charms/grafana_k8s/v0/grafana_dashboard.py +++ b/lib/charms/grafana_k8s/v0/grafana_dashboard.py @@ -184,7 +184,7 @@ def __init__(self, *args): import re import subprocess import tempfile -import uuid + from pathlib import Path from typing import Any, Callable, Dict, List, Optional, Tuple import yaml @@ -217,7 +217,7 @@ def __init__(self, *args): # Increment this PATCH version before using `charmcraft publish-lib` or reset # to 0 if you are raising the major API version -LIBPATCH = 50 +LIBPATCH = 51 PYDEPS = ["cosl >= 0.0.50"] @@ -392,6 +392,13 @@ def __init__(self, *args): } +def _data_hash(data: Any) -> str: + """Deterministic hash of a template dict for use as a stable relation data key.""" + return hashlib.shake_128( + json.dumps(data, sort_keys=True).encode() + ).digest(8).hex() + + class RelationNotFoundError(Exception): """Raised if there is no relation with the given name.""" @@ -1366,10 +1373,12 @@ def _upset_dashboards_on_relation(self, relation: Relation) -> None: return # No change in templates, don't update the databag # It's completely ridiculous to add a UUID, but if we don't have some - # pseudo-random value, this never makes it across 'juju set-state' + # pseudo-random value, this never makes it across 'juju set-state'. + # Use a deterministic hash of the templates so the value is stable when + # templates haven't changed, avoiding spurious relation-changed events. stored_data = { "templates": new_templates, - "uuid": str(uuid.uuid4()), + "uuid": _data_hash(new_templates), } relation.data[self._charm.app]["dashboards"] = json.dumps(stored_data) @@ -1733,7 +1742,7 @@ def set_peer_data(self, key: str, data: Any) -> None: if not peers or not peers.data: logger.info("set_peer_data: no peer relation. Is the charm being installed/removed?") return - peers.data[self._charm.app][key] = json.dumps(data) # type: ignore[attr-defined] + peers.data[self._charm.app][key] = json.dumps(data, sort_keys=True) # type: ignore[attr-defined] def get_peer_data(self, key: str) -> Any: """Retrieve information from the peer data bucket instead of `StoredState`.""" @@ -1864,10 +1873,12 @@ def _update_remote_grafana(self, _: Optional[RelationEvent] = None) -> None: if new_templates == existing_templates: continue # No change in templates, don't update the databag - # It's still ridiculous to add a UUID here, but needed + # It's still ridiculous to add a UUID here, but needed. + # Use a deterministic hash of the templates so the value is stable when + # templates haven't changed, avoiding spurious relation-changed events. stored_data = { "templates": new_templates, - "uuid": str(uuid.uuid4()), + "uuid": _data_hash(new_templates), } grafana_relation.data[self._charm.app]["dashboards"] = json.dumps(stored_data) @@ -1883,9 +1894,10 @@ def remove_dashboards(self, event: RelationBrokenEvent) -> None: for id in app_ids: del self._stored.dashboard_templates[id] # type: ignore + remaining_templates = type_convert_stored(self._stored.dashboard_templates) # pyright: ignore stored_data = { - "templates": type_convert_stored(self._stored.dashboard_templates), # pyright: ignore - "uuid": str(uuid.uuid4()), + "templates": remaining_templates, + "uuid": _data_hash(remaining_templates), } if self._charm.unit.is_leader(): @@ -2130,11 +2142,11 @@ def validate_alert_rules(self, rules: dict) -> Tuple[bool, str]: # - alert: OtherAlert # expr: up transformed_rules = {"groups": []} # type: ignore - for rule in rules["groups"]: - transformed = {"name": str(uuid.uuid4()), "rules": [rule]} + for i, rule in enumerate(rules["groups"]): + transformed = {"name": f"group_{i}", "rules": [rule]} transformed_rules["groups"].append(transformed) - rule_path.write_text(yaml.dump(transformed_rules)) + rule_path.write_text(yaml.safe_dump(transformed_rules, sort_keys=True)) # databag-order: ignore args = [str(self.path), "validate", str(rule_path)] # noinspection PyBroadException diff --git a/lib/charms/loki_k8s/v1/loki_push_api.py b/lib/charms/loki_k8s/v1/loki_push_api.py index e6c8c21..e005222 100644 --- a/lib/charms/loki_k8s/v1/loki_push_api.py +++ b/lib/charms/loki_k8s/v1/loki_push_api.py @@ -544,7 +544,7 @@ def __init__(self, ...): # Increment this PATCH version before using `charmcraft publish-lib` or reset # to 0 if you are raising the major API version -LIBPATCH = 30 +LIBPATCH = 31 PYDEPS = ["cosl"] @@ -1210,6 +1210,42 @@ def alerts(self) -> dict: # noqa: C901 return alerts + def has_invalid_alert_rules(self) -> bool: + """Check whether any relation reported invalid alert rules. + + Validation errors, written to relation app data by the :attr:`alerts` + property, are read back to determine whether the relation currently + carries invalid alert rules. Non-leader units never write the app data + that holds these errors, so they always report no errors. + + Returns: + True if any related consumer reported alert rule validation + errors, False otherwise. + """ + if not self._charm.unit.is_leader(): + return False + + for relation in self._charm.model.relations.get(self._relation_name, []): + app_data = relation.data.get(self._charm.app) + if not app_data: + continue + + event_raw = app_data.get("event", "{}") + try: + event_data = json.loads(event_raw) + except (json.JSONDecodeError, TypeError): + continue + + if error_msg := event_data.get("errors"): + logger.error( + "Alert rule validation error on relation %s: %s", + relation.id, + error_msg, + ) + return True + + return False + def _get_identifier_by_alert_rules( self, rules: dict ) -> Tuple[Union[str, None], Union[JujuTopology, None]]: diff --git a/lib/charms/prometheus_k8s/v0/prometheus_scrape.py b/lib/charms/prometheus_k8s/v0/prometheus_scrape.py index 8716172..358c08d 100644 --- a/lib/charms/prometheus_k8s/v0/prometheus_scrape.py +++ b/lib/charms/prometheus_k8s/v0/prometheus_scrape.py @@ -362,7 +362,7 @@ def _on_scrape_targets_changed(self, event): # Increment this PATCH version before using `charmcraft publish-lib` or reset # to 0 if you are raising the major API version -LIBPATCH = 62 +LIBPATCH = 65 # Version 0.0.53 needed for cosl.rules.generic_alert_groups PYDEPS = ["cosl>=0.0.53"] @@ -1055,11 +1055,16 @@ def jobs(self) -> list: try: _validate_scrape_jobs(static_scrape_jobs) except subprocess.CalledProcessError as e: + logger.error(f"Invalid scrape job file: {e}") if self._charm.unit.is_leader(): data = json.loads(relation.data[self._charm.app].get("event", "{}")) data["scrape_job_errors"] = str(e) relation.data[self._charm.app]["event"] = json.dumps(data) else: + if self._charm.unit.is_leader(): + data = json.loads(relation.data[self._charm.app].get("event", "{}")) + data.pop("scrape_job_errors", None) + relation.data[self._charm.app]["event"] = json.dumps(data) scrape_jobs.extend(static_scrape_jobs) scrape_jobs = _dedupe_job_names(scrape_jobs) @@ -1358,6 +1363,65 @@ def _target_parts(self, target) -> list: return parts + def has_invalid_scrape_jobs(self) -> bool: + """Check whether any relation reported invalid scrape jobs. + + Validation errors, written to relation app data by this consumer + (see :meth:`jobs`), are read back to determine whether the relationship + currently carries an invalid scrape job. + + Returns: + True if any related metrics provider reported scrape job validation + errors, False otherwise. + """ + return self._has_relation_error("scrape_job_errors", "Scrape job validation error") + + def has_invalid_alert_rules(self) -> bool: + """Check whether any relation reported invalid alert rules. + + Validation errors, written to relation app data by this consumer + (see :attr:`alerts`), are read back to determine whether the relationship + currently carries invalid alert rules. + + Returns: + True if any related metrics provider reported alert rule validation + errors, False otherwise. + """ + return self._has_relation_error("errors", "Alert rule validation error") + + def _has_relation_error(self, error_key: str, error_label: str) -> bool: + """Check whether any relation reported the given validation error. + + Args: + error_key: the relation app data key that holds the validation error, + i.e. "scrape_job_errors" or "errors". + error_label: a human readable description of the validation error + type, used for logging, e.g. "Scrape job validation error". + + Returns: + True if any related metrics provider reported the validation error, + False otherwise. + """ + if not self._charm.unit.is_leader(): + return False + + for relation in self._charm.model.relations.get(self._relation_name, []): + app_data = relation.data.get(self._charm.app) + if not app_data: + continue + + event_raw = app_data.get("event", "{}") + try: + event_data = json.loads(event_raw) + except (json.JSONDecodeError, TypeError): + continue + + if error_msg := event_data.get(error_key): + logger.error("%s on relation %s: %s", error_label, relation.id, error_msg) + return True + + return False + def _validate_scrape_jobs(jobs: list) -> bool: """Validate scrape jobs using cos-tool.