Skip to content
Open
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
36 changes: 24 additions & 12 deletions lib/charms/grafana_k8s/v0/grafana_dashboard.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"]

Expand Down Expand Up @@ -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."""

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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`."""
Expand Down Expand Up @@ -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)

Expand All @@ -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():
Expand Down Expand Up @@ -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
Expand Down
38 changes: 37 additions & 1 deletion lib/charms/loki_k8s/v1/loki_push_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]

Expand Down Expand Up @@ -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]]:
Expand Down
66 changes: 65 additions & 1 deletion lib/charms/prometheus_k8s/v0/prometheus_scrape.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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.
Expand Down