From abe94d86c0a536c4d959f8bb8a7c5bdcffdb40f4 Mon Sep 17 00:00:00 2001 From: Swetha Swaminathan Date: Fri, 10 Apr 2026 10:55:24 +0530 Subject: [PATCH 01/19] New Generic rules class --- src/cosl/grouped_rules.py | 187 +++++++++++++++++++ src/cosl/loki.py | 12 ++ src/cosl/prometheus.py | 122 +++++++++++++ src/cosl/rules.py | 365 ++++++++++++++++++++++++++++---------- src/cosl/types.py | 4 +- 5 files changed, 597 insertions(+), 93 deletions(-) create mode 100644 src/cosl/grouped_rules.py create mode 100644 src/cosl/loki.py create mode 100644 src/cosl/prometheus.py diff --git a/src/cosl/grouped_rules.py b/src/cosl/grouped_rules.py new file mode 100644 index 0000000..d93c01f --- /dev/null +++ b/src/cosl/grouped_rules.py @@ -0,0 +1,187 @@ +# Copyright 2026 Canonical Ltd. +# See LICENSE file for licensing details. +"""Shared base for groups-based rule backends (Prometheus & Loki). + +This module provides :class:`_GroupedRuleBackend`, the common base class for +:class:`~cosl.prometheus.PrometheusRuleBackend` and +:class:`~cosl.loki.LokiRuleBackend`. +""" + +import copy +import hashlib +import logging +import re +from pathlib import Path +from typing import Any, Dict, List, Mapping, Optional, Tuple, cast + +import yaml + +from .cos_tool import CosTool +from .juju_topology import JujuTopology +from .rules import RuleBackend +from .types import ( + RULE_TYPES, + OfficialRuleFileFormat, + OfficialRuleFileItem, + QueryType, + SingleRuleFormat, +) + +logger = logging.getLogger(__name__) + + +class _GroupedRuleBackend(RuleBackend[OfficialRuleFileItem]): # type: ignore + """Shared base for Prometheus and Loki rule backends. + + Handles the groups-based rule format, topology injection into group names, + rule labels, and expressions. Subclasses only need to set :attr:`query_type`. + """ + + query_type: QueryType + + def __init__(self, topology: Optional[JujuTopology] = None) -> None: + super().__init__(topology=topology) + self.tool = CosTool(default_query_type=self.query_type) + + @property + def file_suffixes(self) -> List[str]: + return [".rule", ".rules", ".yml", ".yaml"] + + def from_dict( + self, + rule_dict: Mapping[str, Any], + *, + group_name: Optional[str] = None, + group_name_prefix: Optional[str] = None, + **kwargs: Any, + ) -> List[OfficialRuleFileItem]: + """Parse a Prometheus/Loki rule dict, normalise, and inject topology. + + Args: + rule_dict: Raw rule content as a YAML-loaded dict. + group_name: An identifier for the group (typically the file stem). + group_name_prefix: A prefix for the group name (typically topology + identifier + relative path). + """ + if not rule_dict: + raise ValueError("Empty") + + rule_copy = copy.deepcopy(rule_dict) + if self._is_official_format(rule_copy): + groups = [OfficialRuleFileItem(**g) for g in rule_copy.get("groups", [])] + elif self._is_single_rule_format(rule_copy): + single_rule = cast(SingleRuleFormat, rule_copy) + if not group_name: + # Note: the caller of this function should ensure this never happens: + # Either we use the standard format, or we'd pass a group_name. + # If/when we drop support for the single-rule-per-file format, this won't + # be needed anymore. + group_name = hashlib.shake_256(str(single_rule).encode("utf-8")).hexdigest(10) + + # convert to list of groups to match official rule format + groups = [OfficialRuleFileItem(name=group_name, rules=[single_rule])] + else: + # invalid/unsupported + raise ValueError("Invalid rule format") + + # update rules with additional metadata + for group in groups: + if not self._is_already_modified(group["name"]): + # update group name with topology and sub-path + new_name = "_".join(filter(None, [group_name_prefix, group["name"]])) + if not new_name.endswith("_rules"): + new_name += "_rules" + group["name"] = new_name + # after sanitizing we should not modify group.name anymore + group["name"] = self._sanitize_metric_name(group["name"]) + + # add "juju_" topology labels + for rule in group["rules"]: + if "labels" not in rule: + rule["labels"] = {} + + if self.topology: + # only insert labels that do not already exist + for label, val in self.topology.label_matcher_dict.items(): + if label not in rule["labels"]: + rule["labels"][label] = val + + # Inject topology matchers into the expression + repl = r'job=~".+"' if self.query_type == "logql" else "" + rule["expr"] = self.tool.inject_label_matchers( + expression=re.sub(r"%%juju_topology%%,?", repl, rule["expr"]), + topology={ + k: rule["labels"][k] + for k in ("juju_model", "juju_model_uuid", "juju_application") + if rule["labels"].get(k) is not None + }, + query_type=self.query_type, + ) + + return groups + + def from_file( + self, + file_path: Path, + root_path: Path, + **kwargs: Any, + ) -> List[OfficialRuleFileItem]: + """Read a rule file, using file context for group naming. + + Args: + file_path: Absolute path to the rule file. + topology: Juju topology to inject into the rules. + root_path: Root rules directory (used for computing relative + paths for group name prefixes). + """ + with file_path.open() as f: + try: + rule_file = yaml.safe_load(f) + except Exception as e: + logger.error("Failed to read rules from %s: %s", file_path.name, e) + return [] + + # Compute group name context from topology + relative path + rel_path = file_path.parent.relative_to(root_path) + rel_path_str = "" if rel_path == Path(".") else str(rel_path) + prefix_parts = [self.topology.identifier] if self.topology else [] + prefix_parts.append(rel_path_str) + group_name_prefix = "_".join(filter(None, prefix_parts)) + + try: + return self.from_dict( + rule_file, + group_name=file_path.stem, + group_name_prefix=group_name_prefix, + ) + except ValueError as e: + logger.error("Invalid rules file: %s (%s)", file_path.name, e) + return [] + + def validate(self, rules: Dict[str, List[OfficialRuleFileItem]]) -> Tuple[bool, str]: + """Validate rules using ``cos-tool``.""" + return self.tool.validate_alert_rules(OfficialRuleFileFormat(**rules)) + + def as_dict(self, items: List[OfficialRuleFileItem]) -> Dict[str, List[OfficialRuleFileItem]]: + """Serialise as ``{"groups": [...]}``.""" + return {"groups": items} if items else {} + + @staticmethod + def _is_official_format(rules_dict: Mapping[str, Any]) -> bool: + return "groups" in rules_dict + + @staticmethod + def _is_single_rule_format(rules_dict: Mapping[str, Any]) -> bool: + return "expr" in rules_dict and not RULE_TYPES.isdisjoint(rules_dict) + + @staticmethod + def _is_already_modified(name: str) -> bool: + """Detect whether a group name already contains a topology UUID hash.""" + return re.match(r"^.*?_[\da-f]{8}_.*?rules$", name) is not None + + @staticmethod + def _sanitize_metric_name(metric_name: str) -> str: + """Sanitize a metric name per the Prometheus data model.""" + return "".join( + char if re.match(r"[a-zA-Z0-9_:]", char) else "_" for char in metric_name + ) diff --git a/src/cosl/loki.py b/src/cosl/loki.py new file mode 100644 index 0000000..d50828a --- /dev/null +++ b/src/cosl/loki.py @@ -0,0 +1,12 @@ +# Copyright 2026 Canonical Ltd. +# See LICENSE file for licensing details. +"""Loki rule backend.""" + +from .grouped_rules import _GroupedRuleBackend # type: ignore +from .types import QueryType + + +class LokiRuleBackend(_GroupedRuleBackend): + """Backend for Loki alerting / recording rules (LogQL).""" + + query_type: QueryType = "logql" diff --git a/src/cosl/prometheus.py b/src/cosl/prometheus.py new file mode 100644 index 0000000..7ba6743 --- /dev/null +++ b/src/cosl/prometheus.py @@ -0,0 +1,122 @@ +# Copyright 2026 Canonical Ltd. +# See LICENSE file for licensing details. +"""Prometheus rule backend and pre-built generic alert groups.""" + +import copy +from types import SimpleNamespace +from typing import ClassVar, Final + +from .grouped_rules import _GroupedRuleBackend # type: ignore +from .types import OfficialRuleFileFormat, QueryType + +# --------------------------------------------------------------------------- +# Generic alert rules (pre-built) +# --------------------------------------------------------------------------- + +HOST_METRICS_MISSING_RULE_NAME = "HostMetricsMissing" + +_generic_alert_rules: Final = SimpleNamespace( + host_down={ + "alert": "HostDown", + "expr": "up < 1", + "for": "5m", + "labels": {"severity": "critical"}, + "annotations": { + "summary": "Host '{{ $labels.instance }}' is down.", + "description": ( + "Juju application '{{ $labels.juju_application }}' in model " + "'{{ $labels.juju_model }}' is down. Prometheus has been unable " + "to scrape it during at least the past five minutes." + ), + }, + }, + host_metrics_missing={ + "alert": HOST_METRICS_MISSING_RULE_NAME, + "expr": "absent(up)", + "for": "5m", + "labels": {"severity": "warning"}, + "annotations": { + "summary": ( + "Unit '{{ $labels.juju_unit }}' of application " + "'{{ $labels.juju_application }}' is down or failing to remote write." + ), + "description": ( + "`Up` missing for unit '{{ $labels.juju_unit }}' of application " + "{{ $labels.juju_application }} in model {{ $labels.juju_model }}. " + "Please ensure the unit or the collector scraping it is up and is " + "able to successfully reach the metrics backend." + ), + }, + }, + aggregator_metrics_missing={ + "alert": "AggregatorMetricsMissing", + "expr": "absent(up)", + "for": "5m", + "labels": {"severity": "critical"}, + "annotations": { + "summary": ( + "Metrics not received from application " + "'{{ $labels.juju_application }}'. All units are down or failing " + "to remote write." + ), + "description": ( + "`Up` missing for ALL units of application " + "{{ $labels.juju_application }} in model {{ $labels.juju_model }}. " + "This can also mean the units or the collector scraping them are " + "unable to reach the remote write endpoint of the metrics backend. " + "Please ensure the correct firewall rules are applied." + ), + }, + }, +) + + +class _GenericAlertGroups: + """Pre-built alert groups for common health-check rules.""" + + _application_rules: ClassVar[OfficialRuleFileFormat] = { + "groups": [ + { + "name": "HostHealth", + "rules": [ + _generic_alert_rules.host_down, + _generic_alert_rules.host_metrics_missing, + ], + }, + ] + } + _aggregator_rules: ClassVar[OfficialRuleFileFormat] = { + "groups": [ + { + "name": "AggregatorHostHealth", + "rules": [ + _generic_alert_rules.host_metrics_missing, + _generic_alert_rules.aggregator_metrics_missing, + ], + }, + ] + } + + @property + def application_rules(self) -> OfficialRuleFileFormat: + """Rules for application-level monitoring (scrape-based).""" + return copy.deepcopy(self._application_rules) + + @property + def aggregator_rules(self) -> OfficialRuleFileFormat: + """Rules for remote-write aggregator monitoring (no ``up`` metric from scrape).""" + return copy.deepcopy(self._aggregator_rules) + + +generic_alert_groups: Final = _GenericAlertGroups() + + +# --------------------------------------------------------------------------- +# Prometheus backend +# --------------------------------------------------------------------------- + + +class PrometheusRuleBackend(_GroupedRuleBackend): + """Backend for Prometheus alerting / recording rules (PromQL).""" + + query_type: QueryType = "promql" diff --git a/src/cosl/rules.py b/src/cosl/rules.py index 6e5230b..37fa802 100644 --- a/src/cosl/rules.py +++ b/src/cosl/rules.py @@ -1,10 +1,10 @@ # Copyright 2026 Canonical Ltd. # See LICENSE file for licensing details. -"""Alerting and Recording Rules. +"""The rules module. ## Overview -## Rules +## Rules class (Legacy) This library also supports gathering alerting and recording rules from all related charms and enabling corresponding alerting/recording rules within the @@ -73,24 +73,56 @@ - `juju_model` - `juju_model_uuid` - `juju_application` -""" # noqa: W505 + +## Generic Rules (Latest) + +The ``GenericRules`` class is a format-agnostic aggregator that collects alerting, +recording, or detection rules from files, directories and dicts. All format-specific +logic — parsing, topology injection, validation, and serialization — is +delegated to a :class:`RuleBackend` implementation. + +Usage:: + + from cosl.juju_topology import JujuTopology + from cosl.prometheus import PrometheusRuleBackend + from cosl.loki import LokiRuleBackend + from cosl.sigma import SigmaRuleBackend + + self._topology = JujuTopology.from_charm(charm) + + # Prometheus + prom_rules = GenericRules(backend=PrometheusRuleBackend(), topology=self._topology) + prom_rules.add_path("src/prometheus_alert_rules") + print(prom_rules.as_dict()) # {"groups": [...]} + + # Loki + loki_rules = GenericRules(backend=LokiRuleBackend(), topology=self._topology) + loki_rules.add_path("src/loki_alert_rules") + print(loki_rules.as_dict()) # {"groups": [...]} + + # Sigma + sigma_rules = GenericRules(backend=SigmaRuleBackend(), topology=self._topology) + sigma_rules.add_path("src/sigma_rules") + print(sigma_rules.as_dict()) # {"rules": [...]} +""" import contextlib import copy import hashlib import logging import re +from abc import ABC, abstractmethod from dataclasses import dataclass from pathlib import Path -from types import SimpleNamespace from typing import ( Any, - ClassVar, Dict, - Final, + Generic, List, Mapping, Optional, + Tuple, + TypeVar, Union, cast, ) @@ -109,89 +141,7 @@ logger = logging.getLogger(__name__) -HOST_METRICS_MISSING_RULE_NAME = "HostMetricsMissing" - -_generic_alert_rules: Final = SimpleNamespace( - # We use "5m" to avoid false positives on expected temporary "down", e.g. during intentional (re)start. - # Juju topology will be later injected by providers of alert rules. - host_down={ - "alert": "HostDown", - "expr": "up < 1", - "for": "5m", - "labels": {"severity": "critical"}, - "annotations": { - "summary": "Host '{{ $labels.instance }}' is down.", - "description": "Juju application '{{ $labels.juju_application }}' in model '{{ $labels.juju_model }}' is down. Prometheus has been unable to scrape it during at least the past five minutes.", - }, - }, - host_metrics_missing={ - "alert": HOST_METRICS_MISSING_RULE_NAME, - "expr": "absent(up)", - "for": "5m", - "labels": { - "severity": "warning" - }, # The remote writer will set this to critical for machine charms when initializing PrometheusRemoteWriteConsumer. - "annotations": { - "summary": "Unit '{{ $labels.juju_unit }}' of application '{{ $labels.juju_application }}' is down or failing to remote write.", - "description": "`Up` missing for unit '{{ $labels.juju_unit }}' of application {{ $labels.juju_application }} in model {{ $labels.juju_model }}. Please ensure the unit or the collector scraping it is up and is able to successfully reach the metrics backend.", - }, - }, - aggregator_metrics_missing={ - "alert": "AggregatorMetricsMissing", - "expr": "absent(up)", - "for": "5m", - "labels": {"severity": "critical"}, - "annotations": { - "summary": "Metrics not received from application '{{ $labels.juju_application }}'. All units are down or failing to remote write.", - "description": "`Up` missing for ALL units of application {{ $labels.juju_application }} in model {{ $labels.juju_model }}. This can also mean the units or the collector scraping them are unable to reach the remote write endpoint of the metrics backend. Please ensure the correct firewall rules are applied.", - }, - }, -) - -""" -Generic alert rules are in groups to ensure a predictable group name. -""" - - -class _GenericAlertGroups: - _application_rules: ClassVar[OfficialRuleFileFormat] = { - "groups": [ - { - "name": "HostHealth", - "rules": [ - _generic_alert_rules.host_down, - _generic_alert_rules.host_metrics_missing, - ], - }, - ] - } - _aggregator_rules: ClassVar[OfficialRuleFileFormat] = { - "groups": [ - { - "name": "AggregatorHostHealth", - "rules": [ - _generic_alert_rules.host_metrics_missing, - _generic_alert_rules.aggregator_metrics_missing, - ], - }, - ] - } - - @property - def application_rules(self) -> OfficialRuleFileFormat: - # Group names must be unique per alert rule file. The final group names may be adjusted by - # the providers of alert rules to include some topology information, to address deduplication. - return copy.deepcopy(self._application_rules) - - @property - def aggregator_rules(self) -> OfficialRuleFileFormat: - # If we push to Prometheus via remote-write with an aggregator, there are no UP metrics - # associated. Only a time series for the metrics we have pushed is available so omit the - # HostDown rule. - return copy.deepcopy(self._aggregator_rules) - - -generic_alert_groups: Final = _GenericAlertGroups() +T = TypeVar("T") class InvalidRulePathError(Exception): @@ -207,11 +157,238 @@ def __init__( super().__init__(self.message) +@dataclass +class Result(Generic[T]): + """Result of rule validation. + + Attributes: + rules: The rules dictionary. + errmsg: Optional error message produced during validation. + """ + + rules: Dict[str, List[T]] + errmsg: Optional[str] + +class RuleBackend(ABC, Generic[T]): + """Abstract base for format-specific rule handling. + + Type parameter *T* is the internal representation of a single rule item. + + Subclasses must implement four methods: + + * :meth:`file_suffixes` — which file extensions this backend reads. + * :meth:`from_dict` — parse a raw dict into normalised rule items, + injecting Juju topology where appropriate. + * :meth:`validate` — check the serialised output for correctness. + * :meth:`as_dict` — convert internal rule items into the backend's output format. + """ + + def __init__(self, topology: Optional[JujuTopology] = None) -> None: + self.topology = topology + + @property + @abstractmethod + def file_suffixes(self) -> List[str]: + """File extensions this backend supports (e.g. ``['.rule', '.yml']``).""" + ... + + @abstractmethod + def from_dict( + self, + rule_dict: Mapping[str, Any], + **kwargs: Any, + ) -> List[T]: + """Parse a rule dict, normalise it, and inject topology. + + Args: + rule_dict: Raw rule content as a YAML-loaded dict. + **kwargs: Backend-specific keyword arguments. + + Returns: + A list of normalised rule items. + + Raises: + ValueError: If *rule_dict* is empty or in an invalid format. + """ + ... + + def from_file( + self, + file_path: Path, + **kwargs: Any, + ) -> List[T]: + """Read a single rule file and parse it. + + The default implementation loads YAML and delegates to :meth:`from_dict`. + Backends that need file-level context (e.g. Prometheus uses the file + stem as a group name) should override this method. + + Args: + file_path: Absolute path to the rule file. + **kwargs: Backend-specific keyword arguments. + + Returns: + A list of normalised rule items, or an empty list on error. + """ + + with file_path.open() as f: + try: + rule_file = yaml.safe_load(f) + except Exception as e: + logger.error("Failed to read rules from %s: %s", file_path.name, e) + return [] + + try: + return self.from_dict(rule_file) + except ValueError as e: + logger.error("Invalid rules file: %s (%s)", file_path.name, e) + return [] + + @abstractmethod + def validate(self, rules: Dict[str, List[T]]) -> Tuple[bool, str]: + """Validate rules in their serialised dict form. + + Args: + rules: The output of :meth:`as_dict`. + + Returns: + A ``(is_valid, error_message)`` tuple. + """ + ... + + @abstractmethod + def as_dict(self, items: List[T]) -> Dict[str, List[T]]: + """Serialise rule items into the backend's output format.""" + ... + + +class GenericRules(Generic[T]): + """Format-agnostic rule aggregator. + + Collects rules from files and dicts, delegating format-specific parsing, + topology injection, and validation to a pluggable :class:`RuleBackend`. + """ + + def __init__(self, backend: RuleBackend[T], topology: Optional[JujuTopology] = None): + """Build a Rules instance. + + Args: + backend: A :class:`RuleBackend` implementation that handles all + format-specific logic. + topology: An optional :class:`JujuTopology` instance used to + annotate all rules. If provided, it is set on the backend, + overriding any topology already configured there. + """ + self.backend = backend + if topology is not None: + self.backend.topology = topology + self._items: List[T] = [] + + def add( + self, + rule_dict: Mapping[str, Any], + **kwargs: Any, + ) -> None: + """Add rules from a dict to the existing ruleset. + + Args: + rule_dict: A rule mapping in whatever format the backend accepts. + **kwargs: Backend-specific keyword arguments forwarded to + :meth:`RuleBackend.from_dict`. For example, Prometheus/Loki + backends accept ``group_name`` and ``group_name_prefix``. + """ + self._items.extend( + self.backend.from_dict( + rule_dict, + **kwargs, + ) + ) + + def add_path(self, dir_path: Union[str, Path], *, recursive: bool = False) -> None: + """Add rules from a file or directory. + + All rules from files are aggregated into the internal ruleset. + Group names (where applicable) are augmented with Juju topology. + + Args: + dir_path: A rules file or a directory of rule files. + recursive: Whether to read files recursively (no impact if + *dir_path* is a single file). + """ + path = Path(dir_path) if isinstance(dir_path, str) else dir_path + if path.is_dir(): + self._items.extend(self._from_dir(path, recursive)) + elif path.is_file(): + self._items.extend( + self.backend.from_file(path, root_path=path.parent) + ) + else: + logger.debug("Rules path does not exist: %s", path) + + def as_dict(self) -> Dict[str, List[T]]: + """Return the accumulated rules in the backend's output format. + + Returns: + A dictionary whose structure depends on the backend + (e.g. ``{"groups": [...]}`` for Prometheus). + """ + return self.backend.as_dict(self._items) if self._items else {} + + def validate( + self, + rules: Mapping[str, List[T]], + ) -> Result[T]: + """Validate rules. + + This is a **standalone** operation — it validates the given *rules* + without modifying the internal ruleset. + + Args: + rules: A rule mapping in the backend's output format. + + Returns: + A :class:`Result` with the rules and an optional + error message. + """ + if not rules: + return Result(rules=self.backend.as_dict([]), errmsg=None) + + output = dict(rules) + valid, errmsg = self.backend.validate(output) + if not valid: + return Result(rules=output, errmsg=errmsg) + return Result(rules=output, errmsg=None) + + def _from_dir(self, dir_path: Path, recursive: bool) -> List[T]: + """Read all matching rule files in a directory.""" + all_files = dir_path.glob("**/*" if recursive else "*") + matched = sorted( + f + for f in all_files + if f.is_file() and f.suffix in self.backend.file_suffixes + ) + items: List[T] = [] + for file_path in matched: + from_file = self.backend.from_file( + file_path, root_path=dir_path + ) + if from_file: + logger.debug("Reading rule from %s", file_path) + items.extend(from_file) + return items + +# --------------------------------------------------------------------------- +# OLDER RULES CLASS +# --------------------------------------------------------------------------- @dataclass class InjectResult: """Typed result for rule injection and validation. + .. deprecated:: + Use :class:`Result` when switching over from Rules to GenericRules class. This class + will be removed in a future release. + Attributes: rules: The (possibly injected) rules dictionary. errmsg: Optional error message produced during validation. @@ -224,6 +401,10 @@ class InjectResult: class Rules: """Utility class for amalgamating alerting/recording rule files and injecting juju topology. + .. deprecated:: + Use :class:`GenericRules` with Prometheus and Loki backends. This class will be + removed in a future release. + A `Rules` object supports aggregating rules from files and directories in both official and single rule file formats using the `add_path()` method. All the rules read are annotated with Juju topology labels and amalgamated into a single data structure @@ -560,7 +741,8 @@ class AlertRules(Rules): """Utility class for amalgamating alerting files and injecting juju topology. .. deprecated:: - Use :class:`Rules` directly. This class will be removed in a future release. + Use :class:`GenericRules` with Prometheus and Loki backends. This class will be + removed in a future release. The official format is a YAML file conforming to the Prometheus/Cortex documentation (https://prometheus.io/docs/prometheus/latest/configuration/alerting_rules/). @@ -575,7 +757,8 @@ class RecordingRules(Rules): """Utility class for amalgamating recording files and injecting juju topology. .. deprecated:: - Use :class:`Rules` directly. This class will be removed in a future release. + Use :class:`GenericRules` with Prometheus and Loki backends. This class will be + removed in a future release. The official format is a YAML file conforming to the Prometheus/Cortex documentation (https://prometheus.io/docs/prometheus/latest/configuration/recording_rules/). diff --git a/src/cosl/types.py b/src/cosl/types.py index 6123a03..30aa00b 100644 --- a/src/cosl/types.py +++ b/src/cosl/types.py @@ -54,14 +54,14 @@ class OfficialRuleFileItem(TypedDict): - """Typing for a single node of the official rule file format.""" + """A single group in the official Prometheus/Loki rule file format.""" name: str rules: List[SingleRuleFormat] class OfficialRuleFileFormat(TypedDict, total=False): - """Typing for the official rule file format. + """The official Prometheus/Loki rule file format. References: - https://prometheus.io/docs/prometheus/latest/configuration/alerting_rules/ From 25a0b420919d3638dba013898da75f4a1aee4a83 Mon Sep 17 00:00:00 2001 From: Swetha Swaminathan Date: Fri, 10 Apr 2026 11:19:50 +0530 Subject: [PATCH 02/19] remove args from function definition --- src/cosl/grouped_rules.py | 19 ++++++++++--------- src/cosl/rules.py | 2 +- 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/src/cosl/grouped_rules.py b/src/cosl/grouped_rules.py index d93c01f..97a6583 100644 --- a/src/cosl/grouped_rules.py +++ b/src/cosl/grouped_rules.py @@ -50,19 +50,20 @@ def file_suffixes(self) -> List[str]: def from_dict( self, rule_dict: Mapping[str, Any], - *, - group_name: Optional[str] = None, - group_name_prefix: Optional[str] = None, **kwargs: Any, ) -> List[OfficialRuleFileItem]: """Parse a Prometheus/Loki rule dict, normalise, and inject topology. Args: rule_dict: Raw rule content as a YAML-loaded dict. - group_name: An identifier for the group (typically the file stem). - group_name_prefix: A prefix for the group name (typically topology + **kwargs: Accepts ``group_name`` (str) — an identifier for the + group (typically the file stem), and ``group_name_prefix`` + (str) — a prefix for the group name (typically topology identifier + relative path). """ + group_name: Optional[str] = kwargs.get("group_name") + group_name_prefix: Optional[str] = kwargs.get("group_name_prefix") + if not rule_dict: raise ValueError("Empty") @@ -123,17 +124,17 @@ def from_dict( def from_file( self, file_path: Path, - root_path: Path, **kwargs: Any, ) -> List[OfficialRuleFileItem]: """Read a rule file, using file context for group naming. Args: file_path: Absolute path to the rule file. - topology: Juju topology to inject into the rules. - root_path: Root rules directory (used for computing relative - paths for group name prefixes). + **kwargs: Must include ``root_path`` (:class:`~pathlib.Path`) — + the root rules directory used for computing relative paths + for group name prefixes. """ + root_path: Path = kwargs.get("root_path", file_path.parent) with file_path.open() as f: try: rule_file = yaml.safe_load(f) diff --git a/src/cosl/rules.py b/src/cosl/rules.py index 37fa802..b198a3f 100644 --- a/src/cosl/rules.py +++ b/src/cosl/rules.py @@ -239,7 +239,7 @@ def from_file( return [] try: - return self.from_dict(rule_file) + return self.from_dict(rule_file, **kwargs) except ValueError as e: logger.error("Invalid rules file: %s (%s)", file_path.name, e) return [] From ceb49e8ea07cbe3a55f6768611e7322924e33e1c Mon Sep 17 00:00:00 2001 From: Swetha Swaminathan Date: Fri, 10 Apr 2026 17:24:14 +0530 Subject: [PATCH 03/19] Add unit tests --- src/cosl/grouped_rules.py | 8 +- src/cosl/loki.py | 2 +- src/cosl/prometheus.py | 2 +- src/cosl/rules.py | 32 +++--- tests/helpers.py | 52 ++++++++++ tests/test_backend.py | 190 ++++++++++++++++++++++++++++++++++++ tests/test_generic_rules.py | 158 ++++++++++++++++++++++++++++++ 7 files changed, 421 insertions(+), 23 deletions(-) create mode 100644 tests/helpers.py create mode 100644 tests/test_backend.py create mode 100644 tests/test_generic_rules.py diff --git a/src/cosl/grouped_rules.py b/src/cosl/grouped_rules.py index 97a6583..01c794b 100644 --- a/src/cosl/grouped_rules.py +++ b/src/cosl/grouped_rules.py @@ -30,7 +30,7 @@ logger = logging.getLogger(__name__) -class _GroupedRuleBackend(RuleBackend[OfficialRuleFileItem]): # type: ignore +class _GroupedRuleBackend(RuleBackend[OfficialRuleFileItem]): # type: ignore """Shared base for Prometheus and Loki rule backends. Handles the groups-based rule format, topology injection into group names, @@ -100,7 +100,7 @@ def from_dict( for rule in group["rules"]: if "labels" not in rule: rule["labels"] = {} - + if self.topology: # only insert labels that do not already exist for label, val in self.topology.label_matcher_dict.items(): @@ -183,6 +183,4 @@ def _is_already_modified(name: str) -> bool: @staticmethod def _sanitize_metric_name(metric_name: str) -> str: """Sanitize a metric name per the Prometheus data model.""" - return "".join( - char if re.match(r"[a-zA-Z0-9_:]", char) else "_" for char in metric_name - ) + return "".join(char if re.match(r"[a-zA-Z0-9_:]", char) else "_" for char in metric_name) diff --git a/src/cosl/loki.py b/src/cosl/loki.py index d50828a..d258d26 100644 --- a/src/cosl/loki.py +++ b/src/cosl/loki.py @@ -2,7 +2,7 @@ # See LICENSE file for licensing details. """Loki rule backend.""" -from .grouped_rules import _GroupedRuleBackend # type: ignore +from .grouped_rules import _GroupedRuleBackend # type: ignore from .types import QueryType diff --git a/src/cosl/prometheus.py b/src/cosl/prometheus.py index 7ba6743..b46ebf3 100644 --- a/src/cosl/prometheus.py +++ b/src/cosl/prometheus.py @@ -6,7 +6,7 @@ from types import SimpleNamespace from typing import ClassVar, Final -from .grouped_rules import _GroupedRuleBackend # type: ignore +from .grouped_rules import _GroupedRuleBackend # type: ignore from .types import OfficialRuleFileFormat, QueryType # --------------------------------------------------------------------------- diff --git a/src/cosl/rules.py b/src/cosl/rules.py index b198a3f..e9cdc43 100644 --- a/src/cosl/rules.py +++ b/src/cosl/rules.py @@ -157,6 +157,7 @@ def __init__( super().__init__(self.message) + @dataclass class Result(Generic[T]): """Result of rule validation. @@ -169,6 +170,7 @@ class Result(Generic[T]): rules: Dict[str, List[T]] errmsg: Optional[str] + class RuleBackend(ABC, Generic[T]): """Abstract base for format-specific rule handling. @@ -230,7 +232,6 @@ def from_file( Returns: A list of normalised rule items, or an empty list on error. """ - with file_path.open() as f: try: rule_file = yaml.safe_load(f) @@ -319,11 +320,12 @@ def add_path(self, dir_path: Union[str, Path], *, recursive: bool = False) -> No if path.is_dir(): self._items.extend(self._from_dir(path, recursive)) elif path.is_file(): - self._items.extend( - self.backend.from_file(path, root_path=path.parent) - ) + self._items.extend(self.backend.from_file(path, root_path=path.parent)) else: - logger.debug("Rules path does not exist: %s", path) + raise InvalidRulePathError( + rules_absolute_path=path, + message=f"Invalid rules path: {path}" + ) def as_dict(self) -> Dict[str, List[T]]: """Return the accumulated rules in the backend's output format. @@ -363,30 +365,28 @@ def _from_dir(self, dir_path: Path, recursive: bool) -> List[T]: """Read all matching rule files in a directory.""" all_files = dir_path.glob("**/*" if recursive else "*") matched = sorted( - f - for f in all_files - if f.is_file() and f.suffix in self.backend.file_suffixes + f for f in all_files if f.is_file() and f.suffix in self.backend.file_suffixes ) items: List[T] = [] for file_path in matched: - from_file = self.backend.from_file( - file_path, root_path=dir_path - ) + from_file = self.backend.from_file(file_path, root_path=dir_path) if from_file: logger.debug("Reading rule from %s", file_path) items.extend(from_file) return items - + + # --------------------------------------------------------------------------- # OLDER RULES CLASS # --------------------------------------------------------------------------- + @dataclass class InjectResult: """Typed result for rule injection and validation. .. deprecated:: - Use :class:`Result` when switching over from Rules to GenericRules class. This class + Use :class:`Result` when switching over from Rules to GenericRules class. This class will be removed in a future release. Attributes: @@ -402,7 +402,7 @@ class Rules: """Utility class for amalgamating alerting/recording rule files and injecting juju topology. .. deprecated:: - Use :class:`GenericRules` with Prometheus and Loki backends. This class will be + Use :class:`GenericRules` with Prometheus and Loki backends. This class will be removed in a future release. A `Rules` object supports aggregating rules from files and directories in both @@ -741,7 +741,7 @@ class AlertRules(Rules): """Utility class for amalgamating alerting files and injecting juju topology. .. deprecated:: - Use :class:`GenericRules` with Prometheus and Loki backends. This class will be + Use :class:`GenericRules` with Prometheus and Loki backends. This class will be removed in a future release. The official format is a YAML file conforming to the Prometheus/Cortex documentation @@ -757,7 +757,7 @@ class RecordingRules(Rules): """Utility class for amalgamating recording files and injecting juju topology. .. deprecated:: - Use :class:`GenericRules` with Prometheus and Loki backends. This class will be + Use :class:`GenericRules` with Prometheus and Loki backends. This class will be removed in a future release. The official format is a YAML file conforming to the Prometheus/Cortex documentation diff --git a/tests/helpers.py b/tests/helpers.py new file mode 100644 index 0000000..4b8367d --- /dev/null +++ b/tests/helpers.py @@ -0,0 +1,52 @@ +# Copyright 2026 Canonical Ltd. +# See LICENSE file for licensing details. + +"""Shared test helpers and fixtures for GenericRules tests.""" + +from pathlib import Path + +import yaml + +from cosl.juju_topology import JujuTopology + +FIXTURE_DIR = Path(__file__).resolve().parent / "promql_rules" +PROMETHEUS_RULES_DIR = FIXTURE_DIR / "prometheus_alert_rules" +BAD_YAML_RULE_PATH = FIXTURE_DIR / "bad_alert_rules" / "bad_yaml.rule" + + +def make_topology(**overrides): + defaults = dict( + model="mymodel", + model_uuid="12de4fae-06cc-4ceb-9089-567be09fec78", + application="myapp", + unit="myapp/0", + charm_name="mycharm", + ) + defaults.update(overrides) + return JujuTopology(**defaults) + + +def load_rule(path: Path) -> dict: + """Load a YAML rule file into a dict.""" + with path.open() as f: + return yaml.safe_load(f) + + +# Single-rule format loaded from fixture +SINGLE_ALERT_RULE = load_rule(PROMETHEUS_RULES_DIR / "cpu_overuse.rule") + +# Official groups format (no fixture file exists for this format) +OFFICIAL_RULE = { + "groups": [ + { + "name": "TestGroup", + "rules": [ + { + "alert": "TestAlert", + "expr": "up < 1", + "labels": {"severity": "warning"}, + } + ], + } + ] +} diff --git a/tests/test_backend.py b/tests/test_backend.py new file mode 100644 index 0000000..6c329b3 --- /dev/null +++ b/tests/test_backend.py @@ -0,0 +1,190 @@ +# Copyright 2026 Canonical Ltd. +# See LICENSE file for licensing details. + +"""Tests for _GroupedRuleBackend (from_dict, from_file, as_dict, file_suffixes). + +These tests exercise the shared backend base class via PrometheusRuleBackend, +since _GroupedRuleBackend is private and requires a concrete query_type. +""" + +import re +import unittest + +from cosl.prometheus import PrometheusRuleBackend + +from helpers import ( + BAD_YAML_RULE_PATH, + OFFICIAL_RULE, + PROMETHEUS_RULES_DIR, + SINGLE_ALERT_RULE, + load_rule, + make_topology, +) + + +# =================================================================== +# GroupedRules – from_dict +# =================================================================== + + +class TestGroupedRulesFromDict(unittest.TestCase): + """Tests for _GroupedRuleBackend.from_dict (via PrometheusRuleBackend).""" + + def test_official_format_parsed(self): + """Official groups format is parsed correctly.""" + backend = PrometheusRuleBackend() + groups = backend.from_dict(OFFICIAL_RULE) + self.assertEqual(len(groups), 1) + self.assertIn("TestGroup", groups[0]["name"]) + + def test_single_rule_format_parsed(self): + """Single-rule format is wrapped into a group.""" + backend = PrometheusRuleBackend() + groups = backend.from_dict(SINGLE_ALERT_RULE, group_name="my_group") + self.assertEqual(len(groups), 1) + self.assertIn("my_group", groups[0]["name"]) + self.assertEqual(len(groups[0]["rules"]), 1) + self.assertEqual(groups[0]["rules"][0]["alert"], "CPUOverUse") + + def test_single_rule_gets_hash_name_when_no_group_name(self): + """When no group_name is provided, a hash-based name is generated.""" + backend = PrometheusRuleBackend() + groups = backend.from_dict(SINGLE_ALERT_RULE) + # Group name should end with _rules + self.assertTrue(groups[0]["name"].endswith("_rules")) + + def test_group_name_prefix_applied(self): + """group_name_prefix is prepended to the group name.""" + backend = PrometheusRuleBackend() + groups = backend.from_dict( + SINGLE_ALERT_RULE, + group_name="mygroup", + group_name_prefix="prefix", + ) + self.assertTrue(groups[0]["name"].startswith("prefix_")) + + def test_official_format_group_name_prefix(self): + """For official format, the existing group name is prefixed.""" + backend = PrometheusRuleBackend() + groups = backend.from_dict(OFFICIAL_RULE, group_name_prefix="topo") + self.assertTrue(groups[0]["name"].startswith("topo_")) + self.assertIn("TestGroup", groups[0]["name"]) + + def test_empty_dict_raises_value_error(self): + """An empty dict raises ValueError.""" + backend = PrometheusRuleBackend() + with self.assertRaises(ValueError) as ctx: + backend.from_dict({}) + self.assertEqual(str(ctx.exception), "Empty") + + def test_topology_labels_injected(self): + """Topology labels are injected into rule labels.""" + topo = make_topology() + backend = PrometheusRuleBackend(topology=topo) + groups = backend.from_dict(SINGLE_ALERT_RULE, group_name="test") + rule = groups[0]["rules"][0] + self.assertIn("juju_model", rule["labels"]) + self.assertIn("juju_application", rule["labels"]) + self.assertIn("juju_model_uuid", rule["labels"]) + self.assertEqual(rule["labels"]["juju_model"], "mymodel") + self.assertEqual(rule["labels"]["juju_application"], "myapp") + + def test_topology_labels_not_overwritten(self): + """Pre-existing topology labels in a rule are not overwritten.""" + topo = make_topology() + backend = PrometheusRuleBackend(topology=topo) + rule_with_labels = { + "alert": "Test", + "expr": "up < 1", + "labels": {"severity": "critical", "juju_model": "existing_model"}, + } + groups = backend.from_dict(rule_with_labels, group_name="test") + self.assertEqual(groups[0]["rules"][0]["labels"]["juju_model"], "existing_model") + + def test_no_topology_no_labels_injected(self): + """Without topology, no juju labels are added.""" + backend = PrometheusRuleBackend() + groups = backend.from_dict(SINGLE_ALERT_RULE, group_name="test") + rule = groups[0]["rules"][0] + self.assertNotIn("juju_model", rule["labels"]) + + def test_group_name_sanitized(self): + """Special characters in group names are sanitized to underscores.""" + backend = PrometheusRuleBackend() + groups = backend.from_dict( + SINGLE_ALERT_RULE, group_name="Foo$Bar/Baz" + ) + name = groups[0]["name"] + # Only [a-zA-Z0-9_:] should remain + self.assertIsNotNone(re.match(r"^[a-zA-Z0-9_:]+$", name)) + + def test_juju_topology_placeholder_replaced_promql(self): + """The %%juju_topology%% placeholder is replaced in PromQL expressions.""" + topo = make_topology() + backend = PrometheusRuleBackend(topology=topo) + rule = load_rule(PROMETHEUS_RULES_DIR / "with_template_string.rule") + groups = backend.from_dict(rule, group_name="test") + expr = groups[0]["rules"][0]["expr"] + self.assertNotIn("%%juju_topology%%", expr) + + +# =================================================================== +# GroupedRules – from_file +# =================================================================== + + +class TestGroupedRulesFromFile(unittest.TestCase): + """Tests for from_file using existing fixture files using Prometheus backend.""" + + def test_from_file_single_rule(self): + """A single-rule fixture file is parsed into one group.""" + backend = PrometheusRuleBackend() + path = PROMETHEUS_RULES_DIR / "cpu_overuse.rule" + groups = backend.from_file(path, root_path=path.parent) + self.assertEqual(len(groups), 1) + self.assertEqual(groups[0]["rules"][0]["alert"], "CPUOverUse") + + def test_from_file_group_name_from_stem(self): + """Group name should be derived from the file stem.""" + backend = PrometheusRuleBackend() + path = PROMETHEUS_RULES_DIR / "cpu_overuse.rule" + groups = backend.from_file(path, root_path=path.parent) + self.assertIn("cpu_overuse", groups[0]["name"]) + + def test_from_file_invalid_yaml_returns_empty(self): + """Invalid YAML files return an empty list instead of raising.""" + backend = PrometheusRuleBackend() + groups = backend.from_file(BAD_YAML_RULE_PATH, root_path=BAD_YAML_RULE_PATH.parent) + self.assertEqual(groups, []) + + +# =================================================================== +# GroupedRules – as_dict, validate, file_suffixes +# =================================================================== + + +class TestGroupedRulesOther(unittest.TestCase): + """Tests for as_dict, validate, and file_suffixes using Prometheus backend.""" + + def test_prometheus_as_dict(self): + """as_dict wraps items under a 'groups' key.""" + backend = PrometheusRuleBackend() + items = backend.from_dict(OFFICIAL_RULE) + result = backend.as_dict(items) + self.assertIn("groups", result) + self.assertEqual(len(result["groups"]), 1) + + def test_prometheus_as_dict_empty(self): + """as_dict returns an empty dict when given no items.""" + backend = PrometheusRuleBackend() + result = backend.as_dict([]) + self.assertEqual(result, {}) + + def test_file_suffixes(self): + """file_suffixes returns the expected rule file extensions.""" + backend = PrometheusRuleBackend() + self.assertEqual(backend.file_suffixes, [".rule", ".rules", ".yml", ".yaml"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_generic_rules.py b/tests/test_generic_rules.py new file mode 100644 index 0000000..642ed3f --- /dev/null +++ b/tests/test_generic_rules.py @@ -0,0 +1,158 @@ +# Copyright 2026 Canonical Ltd. +# See LICENSE file for licensing details. + +"""Tests for GenericRules (add, add_path, validate, backward compatibility).""" + +import unittest +from pathlib import Path + +from deepdiff import DeepDiff + +from cosl.prometheus import PrometheusRuleBackend +from cosl.rules import GenericRules, Result + +from helpers import OFFICIAL_RULE, PROMETHEUS_RULES_DIR, SINGLE_ALERT_RULE, make_topology + + +# =================================================================== +# GenericRules – add +# =================================================================== + + +class TestGenericRulesAdd(unittest.TestCase): + """Tests for GenericRules.add method.""" + + def test_add_single_rule(self): + """Adding a single rule creates one group.""" + rules = GenericRules(backend=PrometheusRuleBackend()) + rules.add(SINGLE_ALERT_RULE, group_name="mygroup") + result = rules.as_dict() + self.assertIn("groups", result) + self.assertEqual(len(result["groups"]), 1) + + def test_add_official_rule(self): + """Adding an official-format rule creates one group.""" + rules = GenericRules(backend=PrometheusRuleBackend()) + rules.add(OFFICIAL_RULE) + result = rules.as_dict() + self.assertEqual(len(result["groups"]), 1) + + def test_add_multiple_rules_accumulate(self): + """Multiple add calls accumulate groups.""" + rules = GenericRules(backend=PrometheusRuleBackend()) + rules.add(SINGLE_ALERT_RULE, group_name="group1") + rules.add(SINGLE_ALERT_RULE, group_name="group2") + result = rules.as_dict() + self.assertEqual(len(result["groups"]), 2) + + def test_add_with_topology(self): + """Rules added with topology get juju labels injected.""" + topo = make_topology() + rules = GenericRules(backend=PrometheusRuleBackend(), topology=topo) + rules.add(SINGLE_ALERT_RULE, group_name="test") + result = rules.as_dict() + rule = result["groups"][0]["rules"][0] + self.assertEqual(rule["labels"]["juju_model"], "mymodel") + + def test_as_dict_empty_when_no_rules_added(self): + """as_dict returns empty dict when no rules have been added.""" + rules = GenericRules(backend=PrometheusRuleBackend()) + self.assertEqual(rules.as_dict(), {}) + + def test_topology_set_on_backend_via_generic_rules(self): + """Passing topology to GenericRules sets it on the backend.""" + topo = make_topology() + backend = PrometheusRuleBackend() + self.assertIsNone(backend.topology) + GenericRules(backend=backend, topology=topo) + self.assertEqual(backend.topology, topo) + +# =================================================================== +# GenericRules – add_path +# =================================================================== + + +class TestGenericRulesAddPath(unittest.TestCase): + """Tests for GenericRules.add_path with file and directory loading.""" + + def setUp(self): + self.rules_dir = PROMETHEUS_RULES_DIR + self.topology = make_topology() + + def test_add_path_single_file(self): + """Loading a single file creates one group.""" + rules = GenericRules(backend=PrometheusRuleBackend(), topology=self.topology) + rules.add_path(self.rules_dir / "cpu_overuse.rule") + result = rules.as_dict() + self.assertIn("groups", result) + self.assertEqual(len(result["groups"]), 1) + + def test_add_path_directory_non_recursive(self): + """Non-recursive directory scan finds only top-level files.""" + rules = GenericRules(backend=PrometheusRuleBackend(), topology=self.topology) + rules.add_path(self.rules_dir) + result = rules.as_dict() + # Should find top-level .rule files but not nested/ + top_level_rules = [f for f in self.rules_dir.iterdir() if f.is_file() and f.suffix == ".rule"] + self.assertEqual(len(result["groups"]), len(top_level_rules)) + + def test_add_path_directory_recursive(self): + """Recursive directory scan finds nested files.""" + rules = GenericRules(backend=PrometheusRuleBackend(), topology=self.topology) + rules.add_path(self.rules_dir, recursive=True) + result = rules.as_dict() + all_rules = list(self.rules_dir.rglob("*.rule")) + self.assertEqual(len(result["groups"]), len(all_rules)) + + def test_add_path_nonexistent_path_raises(self): + """A nonexistent path raises InvalidRulePathError.""" + from cosl.rules import InvalidRulePathError + + rules = GenericRules(backend=PrometheusRuleBackend()) + with self.assertRaises(InvalidRulePathError): + rules.add_path("/nonexistent/path") + + def test_add_path_topology_in_group_name(self): + """Group names include topology identifier.""" + rules = GenericRules(backend=PrometheusRuleBackend(), topology=self.topology) + rules.add_path(self.rules_dir / "cpu_overuse.rule") + result = rules.as_dict() + group_name = result["groups"][0]["name"] + self.assertIn(self.topology.identifier, group_name) + + def test_add_path_nested_includes_relative_path(self): + """Nested files include relative directory path in group name prefix.""" + rules = GenericRules(backend=PrometheusRuleBackend(), topology=self.topology) + rules.add_path(self.rules_dir, recursive=True) + result = rules.as_dict() + nested_groups = [g for g in result["groups"] if "nested" in g["name"]] + self.assertTrue(len(nested_groups) > 0) + + +# =================================================================== +# GenericRules – produces same output as legacy Rules +# =================================================================== + + +class TestGenericRulesBackwardCompat(unittest.TestCase): + """Verify GenericRules with PrometheusRuleBackend matches legacy Rules output.""" + + def setUp(self): + self.topology = make_topology() + + def test_add_produces_same_as_legacy(self): + """GenericRules.add with group_name produces same structure as legacy Rules._from_dict.""" + from cosl.rules import Rules + + legacy = Rules(query_type="promql") + legacy_groups = legacy._from_dict(SINGLE_ALERT_RULE, group_name="test") + + generic = GenericRules(backend=PrometheusRuleBackend()) + generic.add(SINGLE_ALERT_RULE, group_name="test") + generic_result = generic.as_dict() + + self.assertEqual({}, DeepDiff({"groups": legacy_groups}, generic_result)) + + +if __name__ == "__main__": + unittest.main() From df11eb8bb1477cb2036dad8fdf63a6a8b7c9a753 Mon Sep 17 00:00:00 2001 From: Swetha Swaminathan Date: Fri, 10 Apr 2026 17:27:27 +0530 Subject: [PATCH 04/19] Fix linting --- src/cosl/rules.py | 3 +-- tests/helpers.py | 14 +++++++------- tests/test_backend.py | 7 ++----- tests/test_generic_rules.py | 12 ++++++------ 4 files changed, 16 insertions(+), 20 deletions(-) diff --git a/src/cosl/rules.py b/src/cosl/rules.py index e9cdc43..f714ece 100644 --- a/src/cosl/rules.py +++ b/src/cosl/rules.py @@ -323,8 +323,7 @@ def add_path(self, dir_path: Union[str, Path], *, recursive: bool = False) -> No self._items.extend(self.backend.from_file(path, root_path=path.parent)) else: raise InvalidRulePathError( - rules_absolute_path=path, - message=f"Invalid rules path: {path}" + rules_absolute_path=path, message=f"Invalid rules path: {path}" ) def as_dict(self) -> Dict[str, List[T]]: diff --git a/tests/helpers.py b/tests/helpers.py index 4b8367d..738b6db 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -15,13 +15,13 @@ def make_topology(**overrides): - defaults = dict( - model="mymodel", - model_uuid="12de4fae-06cc-4ceb-9089-567be09fec78", - application="myapp", - unit="myapp/0", - charm_name="mycharm", - ) + defaults = { + "model": "mymodel", + "model_uuid": "12de4fae-06cc-4ceb-9089-567be09fec78", + "application": "myapp", + "unit": "myapp/0", + "charm_name": "mycharm", + } defaults.update(overrides) return JujuTopology(**defaults) diff --git a/tests/test_backend.py b/tests/test_backend.py index 6c329b3..a679948 100644 --- a/tests/test_backend.py +++ b/tests/test_backend.py @@ -10,8 +10,6 @@ import re import unittest -from cosl.prometheus import PrometheusRuleBackend - from helpers import ( BAD_YAML_RULE_PATH, OFFICIAL_RULE, @@ -21,6 +19,7 @@ make_topology, ) +from cosl.prometheus import PrometheusRuleBackend # =================================================================== # GroupedRules – from_dict @@ -111,9 +110,7 @@ def test_no_topology_no_labels_injected(self): def test_group_name_sanitized(self): """Special characters in group names are sanitized to underscores.""" backend = PrometheusRuleBackend() - groups = backend.from_dict( - SINGLE_ALERT_RULE, group_name="Foo$Bar/Baz" - ) + groups = backend.from_dict(SINGLE_ALERT_RULE, group_name="Foo$Bar/Baz") name = groups[0]["name"] # Only [a-zA-Z0-9_:] should remain self.assertIsNotNone(re.match(r"^[a-zA-Z0-9_:]+$", name)) diff --git a/tests/test_generic_rules.py b/tests/test_generic_rules.py index 642ed3f..55800c4 100644 --- a/tests/test_generic_rules.py +++ b/tests/test_generic_rules.py @@ -4,15 +4,12 @@ """Tests for GenericRules (add, add_path, validate, backward compatibility).""" import unittest -from pathlib import Path from deepdiff import DeepDiff - -from cosl.prometheus import PrometheusRuleBackend -from cosl.rules import GenericRules, Result - from helpers import OFFICIAL_RULE, PROMETHEUS_RULES_DIR, SINGLE_ALERT_RULE, make_topology +from cosl.prometheus import PrometheusRuleBackend +from cosl.rules import GenericRules # =================================================================== # GenericRules – add @@ -67,6 +64,7 @@ def test_topology_set_on_backend_via_generic_rules(self): GenericRules(backend=backend, topology=topo) self.assertEqual(backend.topology, topo) + # =================================================================== # GenericRules – add_path # =================================================================== @@ -93,7 +91,9 @@ def test_add_path_directory_non_recursive(self): rules.add_path(self.rules_dir) result = rules.as_dict() # Should find top-level .rule files but not nested/ - top_level_rules = [f for f in self.rules_dir.iterdir() if f.is_file() and f.suffix == ".rule"] + top_level_rules = [ + f for f in self.rules_dir.iterdir() if f.is_file() and f.suffix == ".rule" + ] self.assertEqual(len(result["groups"]), len(top_level_rules)) def test_add_path_directory_recursive(self): From 0dafe6592d48db4b198daa18d29b68acc7bbb186 Mon Sep 17 00:00:00 2001 From: Swetha Swaminathan Date: Fri, 10 Apr 2026 17:30:14 +0530 Subject: [PATCH 05/19] update pyproject.toml --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 7ac3103..e649fea 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "cosl" -version = "1.9.1" +version = "1.10.0" authors = [ { name = "sed-i", email = "82407168+sed-i@users.noreply.github.com" }, ] From f22c940200750c8f71b5b7e74855d752577ade09 Mon Sep 17 00:00:00 2001 From: Swetha Swaminathan Date: Mon, 13 Apr 2026 18:35:23 +0530 Subject: [PATCH 06/19] address review comments --- src/cosl/common_rules.py | 110 ++++++++++++++++++++++++++++++++++++++ src/cosl/prometheus.py | 112 +-------------------------------------- src/cosl/rules.py | 5 ++ 3 files changed, 116 insertions(+), 111 deletions(-) create mode 100644 src/cosl/common_rules.py diff --git a/src/cosl/common_rules.py b/src/cosl/common_rules.py new file mode 100644 index 0000000..65fe9f7 --- /dev/null +++ b/src/cosl/common_rules.py @@ -0,0 +1,110 @@ +# Copyright 2026 Canonical Ltd. +# See LICENSE file for licensing details. +"""Pre-built generic alert rules and groups.""" + +import copy +from types import SimpleNamespace +from typing import ClassVar, Final + +from .types import OfficialRuleFileFormat + +# --------------------------------------------------------------------------- +# Generic alert rules (pre-built) +# --------------------------------------------------------------------------- + +HOST_METRICS_MISSING_RULE_NAME = "HostMetricsMissing" + +_generic_alert_rules: Final = SimpleNamespace( + host_down={ + "alert": "HostDown", + "expr": "up < 1", + "for": "5m", + "labels": {"severity": "critical"}, + "annotations": { + "summary": "Host '{{ $labels.instance }}' is down.", + "description": ( + "Juju application '{{ $labels.juju_application }}' in model " + "'{{ $labels.juju_model }}' is down. Prometheus has been unable " + "to scrape it during at least the past five minutes." + ), + }, + }, + host_metrics_missing={ + "alert": HOST_METRICS_MISSING_RULE_NAME, + "expr": "absent(up)", + "for": "5m", + "labels": {"severity": "warning"}, + "annotations": { + "summary": ( + "Unit '{{ $labels.juju_unit }}' of application " + "'{{ $labels.juju_application }}' is down or failing to remote write." + ), + "description": ( + "`Up` missing for unit '{{ $labels.juju_unit }}' of application " + "{{ $labels.juju_application }} in model {{ $labels.juju_model }}. " + "Please ensure the unit or the collector scraping it is up and is " + "able to successfully reach the metrics backend." + ), + }, + }, + aggregator_metrics_missing={ + "alert": "AggregatorMetricsMissing", + "expr": "absent(up)", + "for": "5m", + "labels": {"severity": "critical"}, + "annotations": { + "summary": ( + "Metrics not received from application " + "'{{ $labels.juju_application }}'. All units are down or failing " + "to remote write." + ), + "description": ( + "`Up` missing for ALL units of application " + "{{ $labels.juju_application }} in model {{ $labels.juju_model }}. " + "This can also mean the units or the collector scraping them are " + "unable to reach the remote write endpoint of the metrics backend. " + "Please ensure the correct firewall rules are applied." + ), + }, + }, +) + + +class _GenericAlertGroups: + """Pre-built alert groups for common health-check rules.""" + + _application_rules: ClassVar[OfficialRuleFileFormat] = { + "groups": [ + { + "name": "HostHealth", + "rules": [ + _generic_alert_rules.host_down, + _generic_alert_rules.host_metrics_missing, + ], + }, + ] + } + _aggregator_rules: ClassVar[OfficialRuleFileFormat] = { + "groups": [ + { + "name": "AggregatorHostHealth", + "rules": [ + _generic_alert_rules.host_metrics_missing, + _generic_alert_rules.aggregator_metrics_missing, + ], + }, + ] + } + + @property + def application_rules(self) -> OfficialRuleFileFormat: + """Rules for application-level monitoring (scrape-based).""" + return copy.deepcopy(self._application_rules) + + @property + def aggregator_rules(self) -> OfficialRuleFileFormat: + """Rules for remote-write aggregator monitoring (no ``up`` metric from scrape).""" + return copy.deepcopy(self._aggregator_rules) + + +generic_alert_groups: Final = _GenericAlertGroups() diff --git a/src/cosl/prometheus.py b/src/cosl/prometheus.py index b46ebf3..fae2ff6 100644 --- a/src/cosl/prometheus.py +++ b/src/cosl/prometheus.py @@ -2,118 +2,8 @@ # See LICENSE file for licensing details. """Prometheus rule backend and pre-built generic alert groups.""" -import copy -from types import SimpleNamespace -from typing import ClassVar, Final - from .grouped_rules import _GroupedRuleBackend # type: ignore -from .types import OfficialRuleFileFormat, QueryType - -# --------------------------------------------------------------------------- -# Generic alert rules (pre-built) -# --------------------------------------------------------------------------- - -HOST_METRICS_MISSING_RULE_NAME = "HostMetricsMissing" - -_generic_alert_rules: Final = SimpleNamespace( - host_down={ - "alert": "HostDown", - "expr": "up < 1", - "for": "5m", - "labels": {"severity": "critical"}, - "annotations": { - "summary": "Host '{{ $labels.instance }}' is down.", - "description": ( - "Juju application '{{ $labels.juju_application }}' in model " - "'{{ $labels.juju_model }}' is down. Prometheus has been unable " - "to scrape it during at least the past five minutes." - ), - }, - }, - host_metrics_missing={ - "alert": HOST_METRICS_MISSING_RULE_NAME, - "expr": "absent(up)", - "for": "5m", - "labels": {"severity": "warning"}, - "annotations": { - "summary": ( - "Unit '{{ $labels.juju_unit }}' of application " - "'{{ $labels.juju_application }}' is down or failing to remote write." - ), - "description": ( - "`Up` missing for unit '{{ $labels.juju_unit }}' of application " - "{{ $labels.juju_application }} in model {{ $labels.juju_model }}. " - "Please ensure the unit or the collector scraping it is up and is " - "able to successfully reach the metrics backend." - ), - }, - }, - aggregator_metrics_missing={ - "alert": "AggregatorMetricsMissing", - "expr": "absent(up)", - "for": "5m", - "labels": {"severity": "critical"}, - "annotations": { - "summary": ( - "Metrics not received from application " - "'{{ $labels.juju_application }}'. All units are down or failing " - "to remote write." - ), - "description": ( - "`Up` missing for ALL units of application " - "{{ $labels.juju_application }} in model {{ $labels.juju_model }}. " - "This can also mean the units or the collector scraping them are " - "unable to reach the remote write endpoint of the metrics backend. " - "Please ensure the correct firewall rules are applied." - ), - }, - }, -) - - -class _GenericAlertGroups: - """Pre-built alert groups for common health-check rules.""" - - _application_rules: ClassVar[OfficialRuleFileFormat] = { - "groups": [ - { - "name": "HostHealth", - "rules": [ - _generic_alert_rules.host_down, - _generic_alert_rules.host_metrics_missing, - ], - }, - ] - } - _aggregator_rules: ClassVar[OfficialRuleFileFormat] = { - "groups": [ - { - "name": "AggregatorHostHealth", - "rules": [ - _generic_alert_rules.host_metrics_missing, - _generic_alert_rules.aggregator_metrics_missing, - ], - }, - ] - } - - @property - def application_rules(self) -> OfficialRuleFileFormat: - """Rules for application-level monitoring (scrape-based).""" - return copy.deepcopy(self._application_rules) - - @property - def aggregator_rules(self) -> OfficialRuleFileFormat: - """Rules for remote-write aggregator monitoring (no ``up`` metric from scrape).""" - return copy.deepcopy(self._aggregator_rules) - - -generic_alert_groups: Final = _GenericAlertGroups() - - -# --------------------------------------------------------------------------- -# Prometheus backend -# --------------------------------------------------------------------------- +from .types import QueryType class PrometheusRuleBackend(_GroupedRuleBackend): diff --git a/src/cosl/rules.py b/src/cosl/rules.py index f714ece..6916f12 100644 --- a/src/cosl/rules.py +++ b/src/cosl/rules.py @@ -138,6 +138,11 @@ RuleType, SingleRuleFormat, ) +# rules.py — backward-compat re-exports (deprecated, remove in next major) +from .common_rules import ( + generic_alert_groups, # pyright: ignore[reportUnusedImport] + HOST_METRICS_MISSING_RULE_NAME # pyright: ignore[reportUnusedImport] +) logger = logging.getLogger(__name__) From da1c8b4d39e5c64532f1136737dcd32a2e0da9cc Mon Sep 17 00:00:00 2001 From: Swetha Swaminathan Date: Mon, 13 Apr 2026 18:36:08 +0530 Subject: [PATCH 07/19] lint --- src/cosl/rules.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/cosl/rules.py b/src/cosl/rules.py index 6916f12..c493d6e 100644 --- a/src/cosl/rules.py +++ b/src/cosl/rules.py @@ -130,6 +130,8 @@ import yaml from . import CosTool, JujuTopology + +# rules.py — backward-compat re-exports (deprecated, remove in next major) from .types import ( RULE_TYPES, OfficialRuleFileFormat, @@ -138,11 +140,6 @@ RuleType, SingleRuleFormat, ) -# rules.py — backward-compat re-exports (deprecated, remove in next major) -from .common_rules import ( - generic_alert_groups, # pyright: ignore[reportUnusedImport] - HOST_METRICS_MISSING_RULE_NAME # pyright: ignore[reportUnusedImport] -) logger = logging.getLogger(__name__) From 232df24314135b4ca6db5520eefb6c70cbcc3a5d Mon Sep 17 00:00:00 2001 From: swetha1654 Date: Wed, 15 Apr 2026 13:13:43 +0530 Subject: [PATCH 08/19] Apply suggestions from code review Co-authored-by: Michael Thamm Signed-off-by: swetha1654 --- src/cosl/rules.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/cosl/rules.py b/src/cosl/rules.py index c493d6e..72dfd06 100644 --- a/src/cosl/rules.py +++ b/src/cosl/rules.py @@ -279,7 +279,7 @@ def __init__(self, backend: RuleBackend[T], topology: Optional[JujuTopology] = N backend: A :class:`RuleBackend` implementation that handles all format-specific logic. topology: An optional :class:`JujuTopology` instance used to - annotate all rules. If provided, it is set on the backend, + annotate all rules. If provided, it is set on the backend, overriding any topology already configured there. """ self.backend = backend From abd85dc534d49d0934d886b2363c1ea025d5ebb1 Mon Sep 17 00:00:00 2001 From: Swetha Swaminathan Date: Wed, 15 Apr 2026 13:35:19 +0530 Subject: [PATCH 09/19] Address review comments --- src/cosl/backends/__init__.py | 4 ++++ src/cosl/{ => backends}/grouped_rules.py | 8 ++++---- src/cosl/{ => backends}/loki.py | 2 +- src/cosl/{ => backends}/prometheus.py | 2 +- src/cosl/rules.py | 20 ++++++++----------- tests/test_backend.py | 2 +- tests/test_generic_rules.py | 25 ++++++++++++------------ 7 files changed, 31 insertions(+), 32 deletions(-) create mode 100644 src/cosl/backends/__init__.py rename src/cosl/{ => backends}/grouped_rules.py (98%) rename src/cosl/{ => backends}/loki.py (91%) rename src/cosl/{ => backends}/prometheus.py (92%) diff --git a/src/cosl/backends/__init__.py b/src/cosl/backends/__init__.py new file mode 100644 index 0000000..6b6914b --- /dev/null +++ b/src/cosl/backends/__init__.py @@ -0,0 +1,4 @@ +# Copyright 2026 Canonical Ltd. +# See LICENSE file for licensing details. + +"""Rule backends for GenericRules.""" diff --git a/src/cosl/grouped_rules.py b/src/cosl/backends/grouped_rules.py similarity index 98% rename from src/cosl/grouped_rules.py rename to src/cosl/backends/grouped_rules.py index 01c794b..f6bc424 100644 --- a/src/cosl/grouped_rules.py +++ b/src/cosl/backends/grouped_rules.py @@ -16,10 +16,10 @@ import yaml -from .cos_tool import CosTool -from .juju_topology import JujuTopology -from .rules import RuleBackend -from .types import ( +from ..cos_tool import CosTool +from ..juju_topology import JujuTopology +from ..rules import RuleBackend +from ..types import ( RULE_TYPES, OfficialRuleFileFormat, OfficialRuleFileItem, diff --git a/src/cosl/loki.py b/src/cosl/backends/loki.py similarity index 91% rename from src/cosl/loki.py rename to src/cosl/backends/loki.py index d258d26..1087fe6 100644 --- a/src/cosl/loki.py +++ b/src/cosl/backends/loki.py @@ -3,7 +3,7 @@ """Loki rule backend.""" from .grouped_rules import _GroupedRuleBackend # type: ignore -from .types import QueryType +from ..types import QueryType class LokiRuleBackend(_GroupedRuleBackend): diff --git a/src/cosl/prometheus.py b/src/cosl/backends/prometheus.py similarity index 92% rename from src/cosl/prometheus.py rename to src/cosl/backends/prometheus.py index fae2ff6..66a605a 100644 --- a/src/cosl/prometheus.py +++ b/src/cosl/backends/prometheus.py @@ -3,7 +3,7 @@ """Prometheus rule backend and pre-built generic alert groups.""" from .grouped_rules import _GroupedRuleBackend # type: ignore -from .types import QueryType +from ..types import QueryType class PrometheusRuleBackend(_GroupedRuleBackend): diff --git a/src/cosl/rules.py b/src/cosl/rules.py index 72dfd06..916754e 100644 --- a/src/cosl/rules.py +++ b/src/cosl/rules.py @@ -84,24 +84,24 @@ Usage:: from cosl.juju_topology import JujuTopology - from cosl.prometheus import PrometheusRuleBackend - from cosl.loki import LokiRuleBackend + from cosl.backends.prometheus import PrometheusRuleBackend + from cosl.backends.loki import LokiRuleBackend from cosl.sigma import SigmaRuleBackend self._topology = JujuTopology.from_charm(charm) # Prometheus - prom_rules = GenericRules(backend=PrometheusRuleBackend(), topology=self._topology) + prom_rules = GenericRules(backend=PrometheusRuleBackend(topology=self._topology)) prom_rules.add_path("src/prometheus_alert_rules") print(prom_rules.as_dict()) # {"groups": [...]} # Loki - loki_rules = GenericRules(backend=LokiRuleBackend(), topology=self._topology) + loki_rules = GenericRules(backend=LokiRuleBackend(topology=self._topology)) loki_rules.add_path("src/loki_alert_rules") print(loki_rules.as_dict()) # {"groups": [...]} # Sigma - sigma_rules = GenericRules(backend=SigmaRuleBackend(), topology=self._topology) + sigma_rules = GenericRules(backend=SigmaRuleBackend(topology=self._topology)) sigma_rules.add_path("src/sigma_rules") print(sigma_rules.as_dict()) # {"rules": [...]} """ @@ -272,19 +272,15 @@ class GenericRules(Generic[T]): topology injection, and validation to a pluggable :class:`RuleBackend`. """ - def __init__(self, backend: RuleBackend[T], topology: Optional[JujuTopology] = None): + def __init__(self, backend: RuleBackend[T]): """Build a Rules instance. Args: backend: A :class:`RuleBackend` implementation that handles all - format-specific logic. - topology: An optional :class:`JujuTopology` instance used to - annotate all rules. If provided, it is set on the backend, - overriding any topology already configured there. + format-specific logic. Pass topology directly to the backend + constructor (e.g. ``PrometheusRuleBackend(topology=topo)``). """ self.backend = backend - if topology is not None: - self.backend.topology = topology self._items: List[T] = [] def add( diff --git a/tests/test_backend.py b/tests/test_backend.py index a679948..2627c50 100644 --- a/tests/test_backend.py +++ b/tests/test_backend.py @@ -19,7 +19,7 @@ make_topology, ) -from cosl.prometheus import PrometheusRuleBackend +from cosl.backends.prometheus import PrometheusRuleBackend # =================================================================== # GroupedRules – from_dict diff --git a/tests/test_generic_rules.py b/tests/test_generic_rules.py index 55800c4..3d5ffee 100644 --- a/tests/test_generic_rules.py +++ b/tests/test_generic_rules.py @@ -8,7 +8,7 @@ from deepdiff import DeepDiff from helpers import OFFICIAL_RULE, PROMETHEUS_RULES_DIR, SINGLE_ALERT_RULE, make_topology -from cosl.prometheus import PrometheusRuleBackend +from cosl.backends.prometheus import PrometheusRuleBackend from cosl.rules import GenericRules # =================================================================== @@ -45,7 +45,7 @@ def test_add_multiple_rules_accumulate(self): def test_add_with_topology(self): """Rules added with topology get juju labels injected.""" topo = make_topology() - rules = GenericRules(backend=PrometheusRuleBackend(), topology=topo) + rules = GenericRules(backend=PrometheusRuleBackend(topology=topo)) rules.add(SINGLE_ALERT_RULE, group_name="test") result = rules.as_dict() rule = result["groups"][0]["rules"][0] @@ -56,13 +56,12 @@ def test_as_dict_empty_when_no_rules_added(self): rules = GenericRules(backend=PrometheusRuleBackend()) self.assertEqual(rules.as_dict(), {}) - def test_topology_set_on_backend_via_generic_rules(self): - """Passing topology to GenericRules sets it on the backend.""" + def test_topology_set_on_backend(self): + """Topology passed to the backend is available on GenericRules.backend.""" topo = make_topology() - backend = PrometheusRuleBackend() - self.assertIsNone(backend.topology) - GenericRules(backend=backend, topology=topo) - self.assertEqual(backend.topology, topo) + backend = PrometheusRuleBackend(topology=topo) + rules = GenericRules(backend=backend) + self.assertEqual(rules.backend.topology, topo) # =================================================================== @@ -79,7 +78,7 @@ def setUp(self): def test_add_path_single_file(self): """Loading a single file creates one group.""" - rules = GenericRules(backend=PrometheusRuleBackend(), topology=self.topology) + rules = GenericRules(backend=PrometheusRuleBackend(topology=self.topology)) rules.add_path(self.rules_dir / "cpu_overuse.rule") result = rules.as_dict() self.assertIn("groups", result) @@ -87,7 +86,7 @@ def test_add_path_single_file(self): def test_add_path_directory_non_recursive(self): """Non-recursive directory scan finds only top-level files.""" - rules = GenericRules(backend=PrometheusRuleBackend(), topology=self.topology) + rules = GenericRules(backend=PrometheusRuleBackend(topology=self.topology)) rules.add_path(self.rules_dir) result = rules.as_dict() # Should find top-level .rule files but not nested/ @@ -98,7 +97,7 @@ def test_add_path_directory_non_recursive(self): def test_add_path_directory_recursive(self): """Recursive directory scan finds nested files.""" - rules = GenericRules(backend=PrometheusRuleBackend(), topology=self.topology) + rules = GenericRules(backend=PrometheusRuleBackend(topology=self.topology)) rules.add_path(self.rules_dir, recursive=True) result = rules.as_dict() all_rules = list(self.rules_dir.rglob("*.rule")) @@ -114,7 +113,7 @@ def test_add_path_nonexistent_path_raises(self): def test_add_path_topology_in_group_name(self): """Group names include topology identifier.""" - rules = GenericRules(backend=PrometheusRuleBackend(), topology=self.topology) + rules = GenericRules(backend=PrometheusRuleBackend(topology=self.topology)) rules.add_path(self.rules_dir / "cpu_overuse.rule") result = rules.as_dict() group_name = result["groups"][0]["name"] @@ -122,7 +121,7 @@ def test_add_path_topology_in_group_name(self): def test_add_path_nested_includes_relative_path(self): """Nested files include relative directory path in group name prefix.""" - rules = GenericRules(backend=PrometheusRuleBackend(), topology=self.topology) + rules = GenericRules(backend=PrometheusRuleBackend(topology=self.topology)) rules.add_path(self.rules_dir, recursive=True) result = rules.as_dict() nested_groups = [g for g in result["groups"] if "nested" in g["name"]] From 501301fc97bc2c76b9649c6fb290cdb83ba68687 Mon Sep 17 00:00:00 2001 From: Swetha Swaminathan Date: Wed, 15 Apr 2026 13:39:06 +0530 Subject: [PATCH 10/19] lint issues --- src/cosl/backends/loki.py | 2 +- src/cosl/backends/prometheus.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/cosl/backends/loki.py b/src/cosl/backends/loki.py index 1087fe6..2bf0e8d 100644 --- a/src/cosl/backends/loki.py +++ b/src/cosl/backends/loki.py @@ -2,8 +2,8 @@ # See LICENSE file for licensing details. """Loki rule backend.""" -from .grouped_rules import _GroupedRuleBackend # type: ignore from ..types import QueryType +from .grouped_rules import _GroupedRuleBackend # type: ignore class LokiRuleBackend(_GroupedRuleBackend): diff --git a/src/cosl/backends/prometheus.py b/src/cosl/backends/prometheus.py index 66a605a..60e5ff0 100644 --- a/src/cosl/backends/prometheus.py +++ b/src/cosl/backends/prometheus.py @@ -2,8 +2,8 @@ # See LICENSE file for licensing details. """Prometheus rule backend and pre-built generic alert groups.""" -from .grouped_rules import _GroupedRuleBackend # type: ignore from ..types import QueryType +from .grouped_rules import _GroupedRuleBackend # type: ignore class PrometheusRuleBackend(_GroupedRuleBackend): From 8a5e13cd58a170e321ab1dea833db1776ff7284c Mon Sep 17 00:00:00 2001 From: Swetha Swaminathan Date: Thu, 16 Apr 2026 09:20:22 +0530 Subject: [PATCH 11/19] Add common_rules in rules.py --- src/cosl/rules.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/cosl/rules.py b/src/cosl/rules.py index 916754e..5864f20 100644 --- a/src/cosl/rules.py +++ b/src/cosl/rules.py @@ -132,6 +132,11 @@ from . import CosTool, JujuTopology # rules.py — backward-compat re-exports (deprecated, remove in next major) +from .common_rules import ( + generic_alert_groups, # pyright: ignore[reportUnusedImport] + HOST_METRICS_MISSING_RULE_NAME, # pyright: ignore[reportUnusedImport] +) + from .types import ( RULE_TYPES, OfficialRuleFileFormat, From 48d7c5c759a9f3ef4200d839dcd883b7b96dcf48 Mon Sep 17 00:00:00 2001 From: Swetha Swaminathan Date: Thu, 16 Apr 2026 09:28:05 +0530 Subject: [PATCH 12/19] lint --- src/cosl/rules.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/cosl/rules.py b/src/cosl/rules.py index 5864f20..f2d34a9 100644 --- a/src/cosl/rules.py +++ b/src/cosl/rules.py @@ -131,12 +131,11 @@ from . import CosTool, JujuTopology -# rules.py — backward-compat re-exports (deprecated, remove in next major) +# backward-compat re-exports (deprecated, remove in next major) from .common_rules import ( - generic_alert_groups, # pyright: ignore[reportUnusedImport] - HOST_METRICS_MISSING_RULE_NAME, # pyright: ignore[reportUnusedImport] + HOST_METRICS_MISSING_RULE_NAME, # noqa: F401 # pyright: ignore[reportUnusedImport] + generic_alert_groups, # noqa: F401 # pyright: ignore[reportUnusedImport] ) - from .types import ( RULE_TYPES, OfficialRuleFileFormat, From 4599c2921991586af023e2130487f468edeba907 Mon Sep 17 00:00:00 2001 From: Swetha Swaminathan Date: Wed, 22 Apr 2026 18:26:06 +0530 Subject: [PATCH 13/19] move common rules back --- src/cosl/common_rules.py | 110 --------------------------------------- src/cosl/rules.py | 110 ++++++++++++++++++++++++++++++++++++--- 2 files changed, 104 insertions(+), 116 deletions(-) delete mode 100644 src/cosl/common_rules.py diff --git a/src/cosl/common_rules.py b/src/cosl/common_rules.py deleted file mode 100644 index 65fe9f7..0000000 --- a/src/cosl/common_rules.py +++ /dev/null @@ -1,110 +0,0 @@ -# Copyright 2026 Canonical Ltd. -# See LICENSE file for licensing details. -"""Pre-built generic alert rules and groups.""" - -import copy -from types import SimpleNamespace -from typing import ClassVar, Final - -from .types import OfficialRuleFileFormat - -# --------------------------------------------------------------------------- -# Generic alert rules (pre-built) -# --------------------------------------------------------------------------- - -HOST_METRICS_MISSING_RULE_NAME = "HostMetricsMissing" - -_generic_alert_rules: Final = SimpleNamespace( - host_down={ - "alert": "HostDown", - "expr": "up < 1", - "for": "5m", - "labels": {"severity": "critical"}, - "annotations": { - "summary": "Host '{{ $labels.instance }}' is down.", - "description": ( - "Juju application '{{ $labels.juju_application }}' in model " - "'{{ $labels.juju_model }}' is down. Prometheus has been unable " - "to scrape it during at least the past five minutes." - ), - }, - }, - host_metrics_missing={ - "alert": HOST_METRICS_MISSING_RULE_NAME, - "expr": "absent(up)", - "for": "5m", - "labels": {"severity": "warning"}, - "annotations": { - "summary": ( - "Unit '{{ $labels.juju_unit }}' of application " - "'{{ $labels.juju_application }}' is down or failing to remote write." - ), - "description": ( - "`Up` missing for unit '{{ $labels.juju_unit }}' of application " - "{{ $labels.juju_application }} in model {{ $labels.juju_model }}. " - "Please ensure the unit or the collector scraping it is up and is " - "able to successfully reach the metrics backend." - ), - }, - }, - aggregator_metrics_missing={ - "alert": "AggregatorMetricsMissing", - "expr": "absent(up)", - "for": "5m", - "labels": {"severity": "critical"}, - "annotations": { - "summary": ( - "Metrics not received from application " - "'{{ $labels.juju_application }}'. All units are down or failing " - "to remote write." - ), - "description": ( - "`Up` missing for ALL units of application " - "{{ $labels.juju_application }} in model {{ $labels.juju_model }}. " - "This can also mean the units or the collector scraping them are " - "unable to reach the remote write endpoint of the metrics backend. " - "Please ensure the correct firewall rules are applied." - ), - }, - }, -) - - -class _GenericAlertGroups: - """Pre-built alert groups for common health-check rules.""" - - _application_rules: ClassVar[OfficialRuleFileFormat] = { - "groups": [ - { - "name": "HostHealth", - "rules": [ - _generic_alert_rules.host_down, - _generic_alert_rules.host_metrics_missing, - ], - }, - ] - } - _aggregator_rules: ClassVar[OfficialRuleFileFormat] = { - "groups": [ - { - "name": "AggregatorHostHealth", - "rules": [ - _generic_alert_rules.host_metrics_missing, - _generic_alert_rules.aggregator_metrics_missing, - ], - }, - ] - } - - @property - def application_rules(self) -> OfficialRuleFileFormat: - """Rules for application-level monitoring (scrape-based).""" - return copy.deepcopy(self._application_rules) - - @property - def aggregator_rules(self) -> OfficialRuleFileFormat: - """Rules for remote-write aggregator monitoring (no ``up`` metric from scrape).""" - return copy.deepcopy(self._aggregator_rules) - - -generic_alert_groups: Final = _GenericAlertGroups() diff --git a/src/cosl/rules.py b/src/cosl/rules.py index f2d34a9..0ed872d 100644 --- a/src/cosl/rules.py +++ b/src/cosl/rules.py @@ -114,9 +114,12 @@ from abc import ABC, abstractmethod from dataclasses import dataclass from pathlib import Path +from types import SimpleNamespace from typing import ( Any, + ClassVar, Dict, + Final, Generic, List, Mapping, @@ -130,12 +133,6 @@ import yaml from . import CosTool, JujuTopology - -# backward-compat re-exports (deprecated, remove in next major) -from .common_rules import ( - HOST_METRICS_MISSING_RULE_NAME, # noqa: F401 # pyright: ignore[reportUnusedImport] - generic_alert_groups, # noqa: F401 # pyright: ignore[reportUnusedImport] -) from .types import ( RULE_TYPES, OfficialRuleFileFormat, @@ -147,6 +144,107 @@ logger = logging.getLogger(__name__) +# --------------------------------------------------------------------------- +# Generic alert rules (pre-built) +# --------------------------------------------------------------------------- + +HOST_METRICS_MISSING_RULE_NAME = "HostMetricsMissing" + +_generic_alert_rules: Final = SimpleNamespace( + host_down={ + "alert": "HostDown", + "expr": "up < 1", + "for": "5m", + "labels": {"severity": "critical"}, + "annotations": { + "summary": "Host '{{ $labels.instance }}' is down.", + "description": ( + "Juju application '{{ $labels.juju_application }}' in model " + "'{{ $labels.juju_model }}' is down. Prometheus has been unable " + "to scrape it during at least the past five minutes." + ), + }, + }, + host_metrics_missing={ + "alert": HOST_METRICS_MISSING_RULE_NAME, + "expr": "absent(up)", + "for": "5m", + "labels": {"severity": "warning"}, + "annotations": { + "summary": ( + "Unit '{{ $labels.juju_unit }}' of application " + "'{{ $labels.juju_application }}' is down or failing to remote write." + ), + "description": ( + "`Up` missing for unit '{{ $labels.juju_unit }}' of application " + "{{ $labels.juju_application }} in model {{ $labels.juju_model }}. " + "Please ensure the unit or the collector scraping it is up and is " + "able to successfully reach the metrics backend." + ), + }, + }, + aggregator_metrics_missing={ + "alert": "AggregatorMetricsMissing", + "expr": "absent(up)", + "for": "5m", + "labels": {"severity": "critical"}, + "annotations": { + "summary": ( + "Metrics not received from application " + "'{{ $labels.juju_application }}'. All units are down or failing " + "to remote write." + ), + "description": ( + "`Up` missing for ALL units of application " + "{{ $labels.juju_application }} in model {{ $labels.juju_model }}. " + "This can also mean the units or the collector scraping them are " + "unable to reach the remote write endpoint of the metrics backend. " + "Please ensure the correct firewall rules are applied." + ), + }, + }, +) + + +class _GenericAlertGroups: + """Pre-built alert groups for common health-check rules.""" + + _application_rules: ClassVar[OfficialRuleFileFormat] = { + "groups": [ + { + "name": "HostHealth", + "rules": [ + _generic_alert_rules.host_down, + _generic_alert_rules.host_metrics_missing, + ], + }, + ] + } + _aggregator_rules: ClassVar[OfficialRuleFileFormat] = { + "groups": [ + { + "name": "AggregatorHostHealth", + "rules": [ + _generic_alert_rules.host_metrics_missing, + _generic_alert_rules.aggregator_metrics_missing, + ], + }, + ] + } + + @property + def application_rules(self) -> OfficialRuleFileFormat: + """Rules for application-level monitoring (scrape-based).""" + return copy.deepcopy(self._application_rules) + + @property + def aggregator_rules(self) -> OfficialRuleFileFormat: + """Rules for remote-write aggregator monitoring (no ``up`` metric from scrape).""" + return copy.deepcopy(self._aggregator_rules) + + +generic_alert_groups: Final = _GenericAlertGroups() + T = TypeVar("T") From c2ba23ace7b1951a6bd68d0053dcd44c7b271dc1 Mon Sep 17 00:00:00 2001 From: Swetha Swaminathan Date: Wed, 22 Apr 2026 18:29:14 +0530 Subject: [PATCH 14/19] add comments back --- src/cosl/rules.py | 55 ++++++++++++++++------------------------------- 1 file changed, 19 insertions(+), 36 deletions(-) diff --git a/src/cosl/rules.py b/src/cosl/rules.py index 0ed872d..d3c42fc 100644 --- a/src/cosl/rules.py +++ b/src/cosl/rules.py @@ -144,13 +144,11 @@ logger = logging.getLogger(__name__) -# --------------------------------------------------------------------------- -# Generic alert rules (pre-built) -# --------------------------------------------------------------------------- - HOST_METRICS_MISSING_RULE_NAME = "HostMetricsMissing" _generic_alert_rules: Final = SimpleNamespace( + # We use "5m" to avoid false positives on expected temporary "down", e.g. during intentional (re)start. + # Juju topology will be later injected by providers of alert rules. host_down={ "alert": "HostDown", "expr": "up < 1", @@ -158,29 +156,19 @@ "labels": {"severity": "critical"}, "annotations": { "summary": "Host '{{ $labels.instance }}' is down.", - "description": ( - "Juju application '{{ $labels.juju_application }}' in model " - "'{{ $labels.juju_model }}' is down. Prometheus has been unable " - "to scrape it during at least the past five minutes." - ), + "description": "Juju application '{{ $labels.juju_application }}' in model '{{ $labels.juju_model }}' is down. Prometheus has been unable to scrape it during at least the past five minutes.", }, }, host_metrics_missing={ "alert": HOST_METRICS_MISSING_RULE_NAME, "expr": "absent(up)", "for": "5m", - "labels": {"severity": "warning"}, + "labels": { + "severity": "warning" + }, # The remote writer will set this to critical for machine charms when initializing PrometheusRemoteWriteConsumer. "annotations": { - "summary": ( - "Unit '{{ $labels.juju_unit }}' of application " - "'{{ $labels.juju_application }}' is down or failing to remote write." - ), - "description": ( - "`Up` missing for unit '{{ $labels.juju_unit }}' of application " - "{{ $labels.juju_application }} in model {{ $labels.juju_model }}. " - "Please ensure the unit or the collector scraping it is up and is " - "able to successfully reach the metrics backend." - ), + "summary": "Unit '{{ $labels.juju_unit }}' of application '{{ $labels.juju_application }}' is down or failing to remote write.", + "description": "`Up` missing for unit '{{ $labels.juju_unit }}' of application {{ $labels.juju_application }} in model {{ $labels.juju_model }}. Please ensure the unit or the collector scraping it is up and is able to successfully reach the metrics backend.", }, }, aggregator_metrics_missing={ @@ -189,26 +177,18 @@ "for": "5m", "labels": {"severity": "critical"}, "annotations": { - "summary": ( - "Metrics not received from application " - "'{{ $labels.juju_application }}'. All units are down or failing " - "to remote write." - ), - "description": ( - "`Up` missing for ALL units of application " - "{{ $labels.juju_application }} in model {{ $labels.juju_model }}. " - "This can also mean the units or the collector scraping them are " - "unable to reach the remote write endpoint of the metrics backend. " - "Please ensure the correct firewall rules are applied." - ), + "summary": "Metrics not received from application '{{ $labels.juju_application }}'. All units are down or failing to remote write.", + "description": "`Up` missing for ALL units of application {{ $labels.juju_application }} in model {{ $labels.juju_model }}. This can also mean the units or the collector scraping them are unable to reach the remote write endpoint of the metrics backend. Please ensure the correct firewall rules are applied.", }, }, ) +""" +Generic alert rules are in groups to ensure a predictable group name. +""" -class _GenericAlertGroups: - """Pre-built alert groups for common health-check rules.""" +class _GenericAlertGroups: _application_rules: ClassVar[OfficialRuleFileFormat] = { "groups": [ { @@ -234,12 +214,15 @@ class _GenericAlertGroups: @property def application_rules(self) -> OfficialRuleFileFormat: - """Rules for application-level monitoring (scrape-based).""" + # Group names must be unique per alert rule file. The final group names may be adjusted by + # the providers of alert rules to include some topology information, to address deduplication. return copy.deepcopy(self._application_rules) @property def aggregator_rules(self) -> OfficialRuleFileFormat: - """Rules for remote-write aggregator monitoring (no ``up`` metric from scrape).""" + # If we push to Prometheus via remote-write with an aggregator, there are no UP metrics + # associated. Only a time series for the metrics we have pushed is available so omit the + # HostDown rule. return copy.deepcopy(self._aggregator_rules) From 276812f1039ea48b9c17657075dd8ed858b81837 Mon Sep 17 00:00:00 2001 From: swetha1654 Date: Thu, 23 Apr 2026 13:02:41 +0530 Subject: [PATCH 15/19] Apply suggestions from code review Co-authored-by: Michael Thamm Signed-off-by: swetha1654 --- src/cosl/backends/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/cosl/backends/__init__.py b/src/cosl/backends/__init__.py index 6b6914b..a185617 100644 --- a/src/cosl/backends/__init__.py +++ b/src/cosl/backends/__init__.py @@ -1,4 +1,4 @@ # Copyright 2026 Canonical Ltd. # See LICENSE file for licensing details. -"""Rule backends for GenericRules.""" +"""Implementations of RuleBackend.""" From 7357aa55dac65d59d1e81be5d830784d0b66aff3 Mon Sep 17 00:00:00 2001 From: Swetha Swaminathan Date: Thu, 23 Apr 2026 13:07:48 +0530 Subject: [PATCH 16/19] update GenericRules to AgnosticRules --- src/cosl/backends/__init__.py | 2 +- src/cosl/rules.py | 20 +++++++------- tests/helpers.py | 2 +- tests/test_generic_rules.py | 52 +++++++++++++++++------------------ 4 files changed, 38 insertions(+), 38 deletions(-) diff --git a/src/cosl/backends/__init__.py b/src/cosl/backends/__init__.py index a185617..1725e81 100644 --- a/src/cosl/backends/__init__.py +++ b/src/cosl/backends/__init__.py @@ -1,4 +1,4 @@ # Copyright 2026 Canonical Ltd. # See LICENSE file for licensing details. -"""Implementations of RuleBackend.""" +"""Implementations ofAbstractRules.""" diff --git a/src/cosl/rules.py b/src/cosl/rules.py index d3c42fc..7a99418 100644 --- a/src/cosl/rules.py +++ b/src/cosl/rules.py @@ -74,9 +74,9 @@ - `juju_model_uuid` - `juju_application` -## Generic Rules (Latest) +## AbstractRules (Latest) -The ``GenericRules`` class is a format-agnostic aggregator that collects alerting, +The ``AbstractRules`` class is a format-agnostic aggregator that collects alerting, recording, or detection rules from files, directories and dicts. All format-specific logic — parsing, topology injection, validation, and serialization — is delegated to a :class:`RuleBackend` implementation. @@ -91,17 +91,17 @@ self._topology = JujuTopology.from_charm(charm) # Prometheus - prom_rules = GenericRules(backend=PrometheusRuleBackend(topology=self._topology)) + prom_rules = AbstractRules(backend=PrometheusRuleBackend(topology=self._topology)) prom_rules.add_path("src/prometheus_alert_rules") print(prom_rules.as_dict()) # {"groups": [...]} # Loki - loki_rules = GenericRules(backend=LokiRuleBackend(topology=self._topology)) + loki_rules = AbstractRules(backend=LokiRuleBackend(topology=self._topology)) loki_rules.add_path("src/loki_alert_rules") print(loki_rules.as_dict()) # {"groups": [...]} # Sigma - sigma_rules = GenericRules(backend=SigmaRuleBackend(topology=self._topology)) + sigma_rules = AbstractRules(backend=SigmaRuleBackend(topology=self._topology)) sigma_rules.add_path("src/sigma_rules") print(sigma_rules.as_dict()) # {"rules": [...]} """ @@ -350,7 +350,7 @@ def as_dict(self, items: List[T]) -> Dict[str, List[T]]: ... -class GenericRules(Generic[T]): +class AbstractRules(Generic[T]): """Format-agnostic rule aggregator. Collects rules from files and dicts, delegating format-specific parsing, @@ -468,7 +468,7 @@ class InjectResult: """Typed result for rule injection and validation. .. deprecated:: - Use :class:`Result` when switching over from Rules to GenericRules class. This class + Use :class:`Result` when switching over from Rules to AbstractRules class. This class will be removed in a future release. Attributes: @@ -484,7 +484,7 @@ class Rules: """Utility class for amalgamating alerting/recording rule files and injecting juju topology. .. deprecated:: - Use :class:`GenericRules` with Prometheus and Loki backends. This class will be + Use :class:`AbstractRules` with Prometheus and Loki backends. This class will be removed in a future release. A `Rules` object supports aggregating rules from files and directories in both @@ -823,7 +823,7 @@ class AlertRules(Rules): """Utility class for amalgamating alerting files and injecting juju topology. .. deprecated:: - Use :class:`GenericRules` with Prometheus and Loki backends. This class will be + Use :class:`AbstractRules` with Prometheus and Loki backends. This class will be removed in a future release. The official format is a YAML file conforming to the Prometheus/Cortex documentation @@ -839,7 +839,7 @@ class RecordingRules(Rules): """Utility class for amalgamating recording files and injecting juju topology. .. deprecated:: - Use :class:`GenericRules` with Prometheus and Loki backends. This class will be + Use :class:`AbstractRules` with Prometheus and Loki backends. This class will be removed in a future release. The official format is a YAML file conforming to the Prometheus/Cortex documentation diff --git a/tests/helpers.py b/tests/helpers.py index 738b6db..b5d87ff 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -1,7 +1,7 @@ # Copyright 2026 Canonical Ltd. # See LICENSE file for licensing details. -"""Shared test helpers and fixtures for GenericRules tests.""" +"""Shared test helpers and fixtures for AbstractRules tests.""" from pathlib import Path diff --git a/tests/test_generic_rules.py b/tests/test_generic_rules.py index 3d5ffee..94644f3 100644 --- a/tests/test_generic_rules.py +++ b/tests/test_generic_rules.py @@ -1,7 +1,7 @@ # Copyright 2026 Canonical Ltd. # See LICENSE file for licensing details. -"""Tests for GenericRules (add, add_path, validate, backward compatibility).""" +"""Tests for AbstractRules (add, add_path, validate, backward compatibility).""" import unittest @@ -9,19 +9,19 @@ from helpers import OFFICIAL_RULE, PROMETHEUS_RULES_DIR, SINGLE_ALERT_RULE, make_topology from cosl.backends.prometheus import PrometheusRuleBackend -from cosl.rules import GenericRules +from cosl.rules import AbstractRules # =================================================================== -# GenericRules – add +# AbstractRules – add # =================================================================== -class TestGenericRulesAdd(unittest.TestCase): - """Tests for GenericRules.add method.""" +class TestAbstractRulesAdd(unittest.TestCase): + """Tests for AbstractRules.add method.""" def test_add_single_rule(self): """Adding a single rule creates one group.""" - rules = GenericRules(backend=PrometheusRuleBackend()) + rules = AbstractRules(backend=PrometheusRuleBackend()) rules.add(SINGLE_ALERT_RULE, group_name="mygroup") result = rules.as_dict() self.assertIn("groups", result) @@ -29,14 +29,14 @@ def test_add_single_rule(self): def test_add_official_rule(self): """Adding an official-format rule creates one group.""" - rules = GenericRules(backend=PrometheusRuleBackend()) + rules = AbstractRules(backend=PrometheusRuleBackend()) rules.add(OFFICIAL_RULE) result = rules.as_dict() self.assertEqual(len(result["groups"]), 1) def test_add_multiple_rules_accumulate(self): """Multiple add calls accumulate groups.""" - rules = GenericRules(backend=PrometheusRuleBackend()) + rules = AbstractRules(backend=PrometheusRuleBackend()) rules.add(SINGLE_ALERT_RULE, group_name="group1") rules.add(SINGLE_ALERT_RULE, group_name="group2") result = rules.as_dict() @@ -45,7 +45,7 @@ def test_add_multiple_rules_accumulate(self): def test_add_with_topology(self): """Rules added with topology get juju labels injected.""" topo = make_topology() - rules = GenericRules(backend=PrometheusRuleBackend(topology=topo)) + rules = AbstractRules(backend=PrometheusRuleBackend(topology=topo)) rules.add(SINGLE_ALERT_RULE, group_name="test") result = rules.as_dict() rule = result["groups"][0]["rules"][0] @@ -53,24 +53,24 @@ def test_add_with_topology(self): def test_as_dict_empty_when_no_rules_added(self): """as_dict returns empty dict when no rules have been added.""" - rules = GenericRules(backend=PrometheusRuleBackend()) + rules = AbstractRules(backend=PrometheusRuleBackend()) self.assertEqual(rules.as_dict(), {}) def test_topology_set_on_backend(self): - """Topology passed to the backend is available on GenericRules.backend.""" + """Topology passed to the backend is available on AbstractRules.backend.""" topo = make_topology() backend = PrometheusRuleBackend(topology=topo) - rules = GenericRules(backend=backend) + rules = AbstractRules(backend=backend) self.assertEqual(rules.backend.topology, topo) # =================================================================== -# GenericRules – add_path +# AbstractRules – add_path # =================================================================== -class TestGenericRulesAddPath(unittest.TestCase): - """Tests for GenericRules.add_path with file and directory loading.""" +class TestAbstractRulesAddPath(unittest.TestCase): + """Tests for AbstractRules.add_path with file and directory loading.""" def setUp(self): self.rules_dir = PROMETHEUS_RULES_DIR @@ -78,7 +78,7 @@ def setUp(self): def test_add_path_single_file(self): """Loading a single file creates one group.""" - rules = GenericRules(backend=PrometheusRuleBackend(topology=self.topology)) + rules = AbstractRules(backend=PrometheusRuleBackend(topology=self.topology)) rules.add_path(self.rules_dir / "cpu_overuse.rule") result = rules.as_dict() self.assertIn("groups", result) @@ -86,7 +86,7 @@ def test_add_path_single_file(self): def test_add_path_directory_non_recursive(self): """Non-recursive directory scan finds only top-level files.""" - rules = GenericRules(backend=PrometheusRuleBackend(topology=self.topology)) + rules = AbstractRules(backend=PrometheusRuleBackend(topology=self.topology)) rules.add_path(self.rules_dir) result = rules.as_dict() # Should find top-level .rule files but not nested/ @@ -97,7 +97,7 @@ def test_add_path_directory_non_recursive(self): def test_add_path_directory_recursive(self): """Recursive directory scan finds nested files.""" - rules = GenericRules(backend=PrometheusRuleBackend(topology=self.topology)) + rules = AbstractRules(backend=PrometheusRuleBackend(topology=self.topology)) rules.add_path(self.rules_dir, recursive=True) result = rules.as_dict() all_rules = list(self.rules_dir.rglob("*.rule")) @@ -107,13 +107,13 @@ def test_add_path_nonexistent_path_raises(self): """A nonexistent path raises InvalidRulePathError.""" from cosl.rules import InvalidRulePathError - rules = GenericRules(backend=PrometheusRuleBackend()) + rules = AbstractRules(backend=PrometheusRuleBackend()) with self.assertRaises(InvalidRulePathError): rules.add_path("/nonexistent/path") def test_add_path_topology_in_group_name(self): """Group names include topology identifier.""" - rules = GenericRules(backend=PrometheusRuleBackend(topology=self.topology)) + rules = AbstractRules(backend=PrometheusRuleBackend(topology=self.topology)) rules.add_path(self.rules_dir / "cpu_overuse.rule") result = rules.as_dict() group_name = result["groups"][0]["name"] @@ -121,7 +121,7 @@ def test_add_path_topology_in_group_name(self): def test_add_path_nested_includes_relative_path(self): """Nested files include relative directory path in group name prefix.""" - rules = GenericRules(backend=PrometheusRuleBackend(topology=self.topology)) + rules = AbstractRules(backend=PrometheusRuleBackend(topology=self.topology)) rules.add_path(self.rules_dir, recursive=True) result = rules.as_dict() nested_groups = [g for g in result["groups"] if "nested" in g["name"]] @@ -129,24 +129,24 @@ def test_add_path_nested_includes_relative_path(self): # =================================================================== -# GenericRules – produces same output as legacy Rules +# AbstractRules – produces same output as legacy Rules # =================================================================== -class TestGenericRulesBackwardCompat(unittest.TestCase): - """Verify GenericRules with PrometheusRuleBackend matches legacy Rules output.""" +class TestAbstractRulesBackwardCompat(unittest.TestCase): + """Verify AbstractRules with PrometheusRuleBackend matches legacy Rules output.""" def setUp(self): self.topology = make_topology() def test_add_produces_same_as_legacy(self): - """GenericRules.add with group_name produces same structure as legacy Rules._from_dict.""" + """AbstractRules.add with group_name produces same structure as legacy Rules._from_dict.""" from cosl.rules import Rules legacy = Rules(query_type="promql") legacy_groups = legacy._from_dict(SINGLE_ALERT_RULE, group_name="test") - generic = GenericRules(backend=PrometheusRuleBackend()) + generic = AbstractRules(backend=PrometheusRuleBackend()) generic.add(SINGLE_ALERT_RULE, group_name="test") generic_result = generic.as_dict() From 5231d37543cc1db5983fa5241b291ab9701e7d15 Mon Sep 17 00:00:00 2001 From: Swetha Swaminathan Date: Thu, 23 Apr 2026 13:16:21 +0530 Subject: [PATCH 17/19] update documentation --- src/cosl/backends/loki.py | 32 ++++++++++++++++++++++++++++++-- src/cosl/backends/prometheus.py | 32 ++++++++++++++++++++++++++++++-- 2 files changed, 60 insertions(+), 4 deletions(-) diff --git a/src/cosl/backends/loki.py b/src/cosl/backends/loki.py index 2bf0e8d..3fafd2b 100644 --- a/src/cosl/backends/loki.py +++ b/src/cosl/backends/loki.py @@ -1,12 +1,40 @@ # Copyright 2026 Canonical Ltd. # See LICENSE file for licensing details. -"""Loki rule backend.""" +"""Loki rule backend. + +This module provides :class:`LokiRuleBackend`, the Loki-specific +implementation of :class:`~cosl.rules.RuleBackend`. It is used by +:class:`~cosl.rules.AbstractRules` to load, validate, and manage +alert and recording rules written in LogQL. +""" from ..types import QueryType from .grouped_rules import _GroupedRuleBackend # type: ignore class LokiRuleBackend(_GroupedRuleBackend): - """Backend for Loki alerting / recording rules (LogQL).""" + """Backend for Loki alerting / recording rules (LogQL). + + Inherits all behaviour from :class:`~cosl.backends.grouped_rules._GroupedRuleBackend` + and sets :attr:`query_type` to ``"logql"``. + + Responsibilities: + + * Parse rule files/dicts in the official Loki rule format or the + single-rule-per-file shorthand. + * Inject Juju topology labels into rule labels and LogQL expressions + via ``cos-tool``. + * Validate the resulting rules through ``cos-tool`` LogQL validation. + * Serialise the rules into the ``{"groups": [...]}`` format expected by + Loki. + + Usage:: + + from cosl.backends.loki import LokiRuleBackend + from cosl.rules import AbstractRules + + rules = AbstractRules(backend=LokiRuleBackend(topology=my_topology)) + rules.add_path("./loki_alert_rules") + """ query_type: QueryType = "logql" diff --git a/src/cosl/backends/prometheus.py b/src/cosl/backends/prometheus.py index 60e5ff0..cd03259 100644 --- a/src/cosl/backends/prometheus.py +++ b/src/cosl/backends/prometheus.py @@ -1,12 +1,40 @@ # Copyright 2026 Canonical Ltd. # See LICENSE file for licensing details. -"""Prometheus rule backend and pre-built generic alert groups.""" +"""Prometheus rule backend. + +This module provides :class:`PrometheusRuleBackend`, the Prometheus-specific +implementation of :class:`~cosl.rules.RuleBackend`. It is used by +:class:`~cosl.rules.AbstractRules` to load, validate, and manage +alert and recording rules written in PromQL. +""" from ..types import QueryType from .grouped_rules import _GroupedRuleBackend # type: ignore class PrometheusRuleBackend(_GroupedRuleBackend): - """Backend for Prometheus alerting / recording rules (PromQL).""" + """Backend for Prometheus alerting / recording rules (PromQL). + + Inherits all behaviour from :class:`~cosl.backends.grouped_rules._GroupedRuleBackend` + and sets :attr:`query_type` to ``"promql"``. + + Responsibilities: + + * Parse rule files/dicts in the official Prometheus rule format or the + single-rule-per-file shorthand. + * Inject Juju topology labels into rule labels and PromQL expressions + via ``cos-tool``. + * Validate the resulting rules through ``cos-tool`` PromQL validation. + * Serialise the rules into the ``{"groups": [...]}`` format expected by + Prometheus. + + Usage:: + + from cosl.backends.prometheus import PrometheusRuleBackend + from cosl.rules import AbstractRules + + rules = AbstractRules(backend=PrometheusRuleBackend(topology=my_topology)) + rules.add_path("./prometheus_alert_rules") + """ query_type: QueryType = "promql" From e4dfd03297a6745f86a2485b1518b77add29f339 Mon Sep 17 00:00:00 2001 From: swetha1654 Date: Fri, 24 Apr 2026 17:31:30 +0530 Subject: [PATCH 18/19] Update __init__.py Signed-off-by: swetha1654 --- src/cosl/backends/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/cosl/backends/__init__.py b/src/cosl/backends/__init__.py index 1725e81..3295bc5 100644 --- a/src/cosl/backends/__init__.py +++ b/src/cosl/backends/__init__.py @@ -1,4 +1,4 @@ # Copyright 2026 Canonical Ltd. # See LICENSE file for licensing details. -"""Implementations ofAbstractRules.""" +"""Implementations of AbstractRules.""" From e8c7c1e529205482a7ab855e8fdd46aea9b95559 Mon Sep 17 00:00:00 2001 From: swetha1654 Date: Fri, 24 Apr 2026 17:32:32 +0530 Subject: [PATCH 19/19] Update __init__.py Signed-off-by: swetha1654 --- src/cosl/backends/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/cosl/backends/__init__.py b/src/cosl/backends/__init__.py index 3295bc5..a2f44f9 100644 --- a/src/cosl/backends/__init__.py +++ b/src/cosl/backends/__init__.py @@ -1,4 +1,4 @@ # Copyright 2026 Canonical Ltd. # See LICENSE file for licensing details. -"""Implementations of AbstractRules.""" +"""Implementations of the RuleBackend class."""