From b4ac7d9d0771985899d17d15a7d8c7485a0de49d Mon Sep 17 00:00:00 2001 From: Noctua Date: Tue, 15 Sep 2026 12:19:52 +0000 Subject: [PATCH] chore: update charm libraries --- .../charms/grafana_k8s/v0/grafana_source.py | 8 +- .../charms/grafana_k8s/v1/grafana_source.py | 8 +- .../lib/charms/loki_k8s/v1/loki_push_api.py | 200 +++++++++++++++++- 3 files changed, 200 insertions(+), 16 deletions(-) diff --git a/coordinator/lib/charms/grafana_k8s/v0/grafana_source.py b/coordinator/lib/charms/grafana_k8s/v0/grafana_source.py index 0ca414e..08512ee 100644 --- a/coordinator/lib/charms/grafana_k8s/v0/grafana_source.py +++ b/coordinator/lib/charms/grafana_k8s/v0/grafana_source.py @@ -162,7 +162,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 = 29 +LIBPATCH = 30 logger = logging.getLogger(__name__) @@ -705,7 +705,7 @@ def _relation_hosts(self, rel: Relation) -> Dict: the specified relation. """ hosts = {} - for unit in rel.units: + for unit in sorted(rel.units, key=lambda unit: unit.name): host_address = rel.data[unit].get("grafana_source_host") if not host_address: continue @@ -799,7 +799,7 @@ def upgrade_keys(self) -> None: ) self._stored.sources_to_delete = set() peer_sources_to_delete = set(self.get_peer_data("sources_to_delete")) - sources_to_delete = set.union(old_sources_to_delete, peer_sources_to_delete) # pyright: ignore + sources_to_delete = sorted(set.union(old_sources_to_delete, peer_sources_to_delete)) # pyright: ignore self.set_peer_data("sources_to_delete", sources_to_delete) def update_sources(self, relation: Optional[Relation] = None) -> None: @@ -838,7 +838,7 @@ def _set_default_data(self) -> None: if not self.get_peer_data(k): self.set_peer_data(k, v) - def set_peer_data(self, key: str, data: Any) -> None: + def set_peer_data(self, key: str, data: Union[List[Any], Dict[str, Any]]) -> None: """Put information into the peer data bucket instead of `StoredState`.""" peers = self._charm.peers # type: ignore[attr-defined] if not peers or not peers.data: diff --git a/coordinator/lib/charms/grafana_k8s/v1/grafana_source.py b/coordinator/lib/charms/grafana_k8s/v1/grafana_source.py index 7aeb9a0..0bf6589 100644 --- a/coordinator/lib/charms/grafana_k8s/v1/grafana_source.py +++ b/coordinator/lib/charms/grafana_k8s/v1/grafana_source.py @@ -239,7 +239,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 = 0 +LIBPATCH = 1 logger = logging.getLogger(__name__) @@ -930,7 +930,7 @@ def _relation_hosts(self, rel: Relation) -> Dict: the specified relation. """ hosts = {} - for unit in rel.units: + for unit in sorted(rel.units, key=lambda unit: unit.name): host_address = rel.data[unit].get("grafana_source_host") if not host_address: continue @@ -1033,7 +1033,7 @@ def upgrade_keys(self) -> None: ) self._stored.sources_to_delete = set() peer_sources_to_delete = set(self.get_peer_data("sources_to_delete")) - sources_to_delete = set.union(old_sources_to_delete, peer_sources_to_delete) # pyright: ignore + sources_to_delete = sorted(set.union(old_sources_to_delete, peer_sources_to_delete)) # pyright: ignore self.set_peer_data("sources_to_delete", sources_to_delete) def update_sources(self, relation: Optional[Relation] = None) -> None: @@ -1072,7 +1072,7 @@ def _set_default_data(self) -> None: if not self.get_peer_data(k): self.set_peer_data(k, v) - def set_peer_data(self, key: str, data: Any) -> None: + def set_peer_data(self, key: str, data: Union[List[Any], Dict[str, Any]]) -> None: """Put information into the peer data bucket instead of `StoredState`.""" peers = self._charm.peers # type: ignore[attr-defined] if not peers or not peers.data: diff --git a/coordinator/lib/charms/loki_k8s/v1/loki_push_api.py b/coordinator/lib/charms/loki_k8s/v1/loki_push_api.py index 2b3f09f..66d64d5 100644 --- a/coordinator/lib/charms/loki_k8s/v1/loki_push_api.py +++ b/coordinator/lib/charms/loki_k8s/v1/loki_push_api.py @@ -478,6 +478,24 @@ def _alert_rules_error(self, event): Units of consumer charm send their alert rules over app relation data using the `alert_rules` key. +## Alert rules encoding + +The consumer publishes its alert rules to the `alert_rules` key of its application +databag. Because large deployments can produce enough alert rules to exceed Juju's +relation data size limit, the rules can be stored LZMA-compressed and base64-encoded +instead of as plain JSON. + +Compression is negotiated over the relation: the provider advertises the encodings it +is able to read in the `alert_rules_encodings` key of its own application databag, and +the consumer picks the best encoding both sides support. A consumer related to a +provider running an older version of this library (which advertises nothing) keeps +writing plain JSON, so upgrades are safe in any order. + +An admin can decode compressed rules with: +```bash + | base64 -d | xz -d | jq +``` + ## Charm logging The `charms.loki_k8s.v0.charm_logging` library can be used in conjunction with this one to configure python's logging module to forward all logs to Loki via the loki-push-api interface. @@ -501,6 +519,7 @@ def __init__(self, ...): import copy import json import logging +import lzma import os import platform import re @@ -511,12 +530,12 @@ def __init__(self, ...): from hashlib import sha256 from io import BytesIO from pathlib import Path -from typing import Any, Dict, List, Optional, Tuple, Union, cast +from typing import Any, Dict, Final, List, Mapping, Optional, Tuple, Union, cast from urllib import request from urllib.error import URLError import yaml -from cosl import CosTool, JujuTopology +from cosl import CosTool, JujuTopology, LZMABase64 from cosl.rules import AlertRules from cosl.types import OfficialRuleFileFormat from ops.charm import ( @@ -544,7 +563,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 = 33 +LIBPATCH = 34 PYDEPS = ["cosl"] @@ -593,6 +612,123 @@ def __init__(self, ...): HTTP_LISTEN_PORT_START = 9080 # even start port GRPC_LISTEN_PORT_START = 9095 # odd start port +ALERT_RULES_KEY: Final[str] = "alert_rules" +"""Databag key holding the consumer's alert rules.""" + +ALERT_RULES_ENCODINGS_KEY: Final[str] = "alert_rules_encodings" +"""Databag key with which the provider advertises the encodings it can read.""" + +JSON_ENCODING: Final[str] = "json" +"""Plain JSON alert rules, as written by every version of this library.""" + +LZMA_ENCODING: Final[str] = "lzma" +"""LZMA-compressed, base64-encoded JSON alert rules.""" + +SUPPORTED_ALERT_RULES_ENCODINGS: Final[Tuple[str, ...]] = (LZMA_ENCODING, JSON_ENCODING) +"""Alert rules encodings this library can read and write, most preferred first. + +This is in preference order, not sorted: it is a constant, so the bytes written to the +databag are stable across hooks, which is what matters for avoiding spurious +relation-changed events. +""" + + +def _encode_alert_rules(rules: Mapping[str, Any], encoding: str = JSON_ENCODING) -> str: + """Serialize alert rules for storing them in a relation databag. + + Args: + rules: alert rules in the official Loki rule file format. + encoding: one of `SUPPORTED_ALERT_RULES_ENCODINGS`. Anything else is treated + as `JSON_ENCODING`, because plain JSON is readable by every version of + this library. + + Returns: + The serialized alert rules. + """ + # Sort keys to prevent unnecessary relation-changed churn from key reordering. + serialized = json.dumps(rules, sort_keys=True) + if encoding == LZMA_ENCODING: + return LZMABase64.compress(serialized) + return serialized + + +def _decode_alert_rules(raw: str) -> OfficialRuleFileFormat: + """Deserialize alert rules read from a relation databag. + + Both plain JSON and LZMA-compressed, base64-encoded JSON are accepted, regardless + of the encodings this library advertises, so that a provider can always read the + rules of a consumer running any version of this library. + + Args: + raw: the raw databag value. + + Returns: + The alert rules in the official Loki rule file format. + + Raises: + ValueError: if `raw` is neither valid JSON nor a valid compressed payload, or if it + decodes to something other than a JSON object. + """ + if not raw: + return cast(OfficialRuleFileFormat, {}) + + try: + decoded = json.loads(raw) + except json.JSONDecodeError: + # Not JSON, so this must be a compressed payload. + decoded = raw + + if isinstance(decoded, str): + # A compressed payload, either bare or (as pydantic based libraries write it) + # JSON-encoded. + try: + decoded = json.loads(LZMABase64.decompress(decoded)) + except (ValueError, lzma.LZMAError) as e: + raise ValueError(f"Could not decompress alert rules: {e}") from e + + if not isinstance(decoded, dict): + raise ValueError(f"Alert rules must be a JSON object, not {type(decoded).__name__}") + + return cast(OfficialRuleFileFormat, decoded) + + +def _best_alert_rules_encoding(remote_app_databag: Optional[Mapping[str, str]]) -> str: + """Return the best alert rules encoding the remote application is able to read. + + Providers advertise the encodings they support in their application databag. + Providers running an older version of this library advertise nothing, in which + case plain JSON is used for backwards compatibility. + + Args: + remote_app_databag: the remote application databag, or None if it is not + readable yet (e.g. the relation is still being set up). + + Returns: + One of `SUPPORTED_ALERT_RULES_ENCODINGS`. + """ + raw = remote_app_databag.get(ALERT_RULES_ENCODINGS_KEY, "[]") if remote_app_databag else "[]" + + try: + advertised = json.loads(raw) + if not isinstance(advertised, list): + raise TypeError("expected a list, got {}".format(type(advertised).__name__)) + except (json.JSONDecodeError, TypeError) as e: + logger.warning( + "Ignoring malformed '%s' (%s); assuming the remote end is only able to read " + "uncompressed alert rules.", + ALERT_RULES_ENCODINGS_KEY, + e, + ) + return JSON_ENCODING + + for encoding in SUPPORTED_ALERT_RULES_ENCODINGS: + if encoding in advertised: + return encoding + + # Either nothing was advertised (an older provider), or only encodings this library + # does not know about. Plain JSON is the encoding every version can read. + return JSON_ENCODING + class LokiPushApiError(Exception): """Base class for errors raised by this module.""" @@ -940,6 +1076,10 @@ def __init__( self.framework.observe(events.relation_changed, self._on_logging_relation_changed) self.framework.observe(events.relation_departed, self._on_logging_relation_departed) self.framework.observe(events.relation_broken, self._on_logging_relation_broken) + self.framework.observe( + self._charm.on.leader_elected, + self._publish_encodings_to_all_relation_databags, + ) def _on_lifecycle_event(self, _): # Upgrade event or other charm-level event @@ -968,6 +1108,7 @@ def _on_logging_relation_joined(self, event: RelationJoinedEvent): if self._charm.unit.is_leader(): event.relation.data[self._charm.app].update(self._promtail_binary_url) logger.debug("Saved promtail binary url: %s", self._promtail_binary_url) + self._publish_alert_rules_encodings(event.relation) def _on_logging_relation_changed(self, event: HookEvent): """Handle changes in related consumers. @@ -1046,6 +1187,7 @@ def _process_logging_relation_changed(self, relation: Relation) -> bool: """ relation.data[self._charm.unit]["public_address"] = socket.getfqdn() or "" self.update_endpoint(relation=relation) + self._publish_alert_rules_encodings(relation) # Ensure promtail binary URL is set in app data. This is normally done on # relation_joined, but charms using the reconcile pattern may miss that event @@ -1056,6 +1198,28 @@ def _process_logging_relation_changed(self, relation: Relation) -> bool: return self._should_update_alert_rules(relation) + def _publish_encodings_to_all_relation_databags(self, _: Optional[HookEvent]) -> None: + for relation in self._charm.model.relations[self._relation_name]: + self._publish_alert_rules_encodings(relation) + + def _publish_alert_rules_encodings(self, relation: Relation) -> None: + """Advertise the alert rules encodings this library is able to read. + + Consumers use this to decide whether they may compress their alert rules: a + consumer related to a provider that does not advertise anything keeps writing + plain JSON, which every version of this library can read. + + Args: + relation: The relation whose data to update. + """ + if not self._charm.unit.is_leader(): + # Only the leader unit can write to app data. + return + + relation.data[self._charm.app][ALERT_RULES_ENCODINGS_KEY] = json.dumps( + list(SUPPORTED_ALERT_RULES_ENCODINGS) + ) + @property def _promtail_binary_url(self) -> dict: """URL from which Promtail binary can be downloaded.""" @@ -1103,6 +1267,7 @@ def update_endpoint(self, url: str = "", relation: Optional[Relation] = None) -> for relation in relations_list: relation.data[self._charm.unit].update({"endpoint": json.dumps(endpoint)}) + self._publish_alert_rules_encodings(relation) logger.debug("Saved endpoint in unit relation data") @@ -1159,15 +1324,23 @@ def alerts(self) -> dict: # noqa: C901 metadata indexed by relation ID. """ alerts = {} # type: Dict[str, dict] # mapping b/w juju identifiers and alert rule files + unreadable: Dict[int, str] = {} for relation in self._charm.model.relations[self._relation_name]: if not relation.units or not relation.app: continue - alert_rules = json.loads(relation.data[relation.app].get("alert_rules", "{}")) + try: + alert_rules = _decode_alert_rules( + relation.data[relation.app].get(ALERT_RULES_KEY, "{}") + ) + except Exception as e: + unreadable[relation.id] = str(e) + continue + if not alert_rules: continue - alert_rules = self._inject_alert_expr_labels(alert_rules) + alert_rules = self._inject_alert_expr_labels(cast(Dict[str, Any], alert_rules)) identifier, topology = self._get_identifier_by_alert_rules(alert_rules) if not topology: @@ -1208,6 +1381,12 @@ def alerts(self) -> dict: # noqa: C901 alerts[identifier] = alert_rules + if unreadable: + logger.error( + "Could not read the alert rules published over relation(s): %s", + "; ".join("{} ({})".format(rel_id, err) for rel_id, err in unreadable.items()), + ) + return alerts def has_invalid_alert_rules(self) -> bool: @@ -1404,9 +1583,9 @@ def _handle_alert_rules(self, relation): ) relation.data[self._charm.app]["metadata"] = json.dumps(self.topology.as_dict()) - relation.data[self._charm.app]["alert_rules"] = json.dumps( - alert_rules_as_dict, - sort_keys=True, # sort, to prevent unnecessary relation_changed events + remote_app_databag = relation.data.get(relation.app) if relation.app else None + relation.data[self._charm.app][ALERT_RULES_KEY] = _encode_alert_rules( + alert_rules_as_dict, _best_alert_rules_encoding(remote_app_databag) ) @property @@ -1591,6 +1770,11 @@ def _on_logging_relation_changed(self, event: RelationEvent): loki_push_api_alert_rules_error: This event is emitted when an invalid alert rules file is encountered or if `alert_rules_path` is empty. """ + # The provider advertises the alert rules encodings it supports over relation data, + # which may only become known after relation_joined; (re)send alert rules here so the + # negotiated encoding is picked up. + self._handle_alert_rules(event.relation) # pyright: ignore + if self._charm.unit.is_leader(): ev = json.loads(event.relation.data[event.app].get("event", "{}"))