diff --git a/lib/charms/data_platform_libs/v0/data_interfaces.py b/lib/charms/data_platform_libs/v0/data_interfaces.py index aa7981492..5be1d9315 100644 --- a/lib/charms/data_platform_libs/v0/data_interfaces.py +++ b/lib/charms/data_platform_libs/v0/data_interfaces.py @@ -453,7 +453,7 @@ def _on_subject_requested(self, event: SubjectRequestedEvent): # Increment this PATCH version before using `charmcraft publish-lib` or reset # to 0 if you are raising the major API version -LIBPATCH = 56 +LIBPATCH = 58 PYDEPS = ["ops>=2.0.0"] @@ -842,6 +842,11 @@ def _legacy_compat_find_secret_by_old_label(self) -> None: self._secret_meta = self._model.get_secret(label=label) except SecretNotFoundError: pass + except ModelError as e: + # Permission denied can be raised if the secret exists but is not yet granted to us. + if "permission denied" in str(e): + return + raise else: if label != self.label: self.current_label = label @@ -876,6 +881,8 @@ def _legacy_migration_to_new_label_if_needed(self) -> None: except ModelError as err: if MODEL_ERRORS["not_leader"] not in str(err): raise + if "permission denied" not in str(err): + raise self.current_label = None ########################################################################## @@ -4268,6 +4275,14 @@ def _on_secret_changed_event(self, event: SecretChangedEvent): if relation.app == self.charm.app: logging.info("Secret changed event ignored for Secret Owner") + if relation.name != self.relation_data.relation_name: + logger.debug( + "Ignoring secret-changed from endpoint %s (expected %s)", + relation.name, + self.relation_data.relation_name, + ) + return + remote_unit = None for unit in relation.units: if unit.app != self.charm.app: @@ -5294,6 +5309,14 @@ def _on_secret_changed_event(self, event: SecretChangedEvent): ) return + if relation.name != self.relation_data.relation_name: + logger.debug( + "Ignoring secret-changed from endpoint %s (expected %s)", + relation.name, + self.relation_data.relation_name, + ) + return + if relation.app == self.charm.app: logging.info("Secret changed event ignored for Secret Owner") @@ -5556,6 +5579,14 @@ def _on_secret_changed_event(self, event: SecretChangedEvent): ) return + if relation.name != self.relation_data.relation_name: + logger.debug( + "Ignoring secret-changed from endpoint %s (expected %s)", + relation.name, + self.relation_data.relation_name, + ) + return + if relation.app == self.charm.app: logging.info("Secret changed event ignored for Secret Owner") @@ -5701,6 +5732,14 @@ def _on_secret_changed_event(self, event: SecretChangedEvent): if relation.app == self.charm.app: logging.info("Secret changed event ignored for Secret Owner") + if relation.name != self.relation_data.relation_name: + logger.debug( + "Ignoring secret-changed from endpoint %s (expected %s)", + relation.name, + self.relation_data.relation_name, + ) + return + remote_unit = None for unit in relation.units: if unit.app != self.charm.app: diff --git a/lib/charms/grafana_k8s/v0/grafana_dashboard.py b/lib/charms/grafana_k8s/v0/grafana_dashboard.py index 334ecd0db..586357b1d 100644 --- a/lib/charms/grafana_k8s/v0/grafana_dashboard.py +++ b/lib/charms/grafana_k8s/v0/grafana_dashboard.py @@ -217,7 +217,7 @@ def __init__(self, *args): # Increment this PATCH version before using `charmcraft publish-lib` or reset # to 0 if you are raising the major API version -LIBPATCH = 47 +LIBPATCH = 48 PYDEPS = ["cosl >= 0.0.50"] @@ -585,9 +585,19 @@ def _convert_dashboard_fields(cls, content: str, inject_dropdowns: bool = True) datasources[template_value["name"]] = template_value["query"].lower() # Put our own variables in the template + # We only want to inject our own dropdowns IFF they are NOT + # already in the template coming over relation data. + # We'll store all dropdowns in the template from the provider + # in a set. We'll add our own if they are not in this set. + existing_names = { + item.get("name") + for item in dict_content["templating"]["list"] + } + for d in template_dropdowns: # type: ignore - if d not in dict_content["templating"]["list"]: + if d.get("name") not in existing_names: dict_content["templating"]["list"].insert(0, d) + existing_names.add(d.get("name")) dict_content = cls._replace_template_fields(dict_content, datasources, existing_templates) return json.dumps(dict_content) diff --git a/lib/charms/loki_k8s/v1/loki_push_api.py b/lib/charms/loki_k8s/v1/loki_push_api.py index 961fb7a24..3e2e9bd20 100644 --- a/lib/charms/loki_k8s/v1/loki_push_api.py +++ b/lib/charms/loki_k8s/v1/loki_push_api.py @@ -79,7 +79,6 @@ def __init__(self, *args): external_url = urlparse(self._external_url) self.loki_provider = LokiPushApiProvider( self, - address=external_url.hostname or self.hostname, port=external_url.port or 80, scheme=external_url.scheme, path=f"{external_url.path}/loki/api/v1/push", @@ -96,6 +95,7 @@ def __init__(self, *args): 1. Set the URL of the Loki Push API in the relation application data bag; the URL must be unique to all instances (e.g. using a load balancer). + The default URL is the FQDN, but this can be overridden by calling `update_endpoint()`. 2. Set the Promtail binary URL (`promtail_binary_zip_url`) so clients that use `LogProxyConsumer` object could download and configure it. @@ -508,6 +508,7 @@ def __init__(self, ...): import subprocess import tempfile import typing +import warnings from copy import deepcopy from gzip import GzipFile from hashlib import sha256 @@ -544,7 +545,7 @@ def __init__(self, ...): # Increment this PATCH version before using `charmcraft publish-lib` or reset # to 0 if you are raising the major API version -LIBPATCH = 21 +LIBPATCH = 22 PYDEPS = ["cosl"] @@ -1150,7 +1151,7 @@ def __init__( *, port: Union[str, int] = 3100, scheme: str = "http", - address: str = "localhost", + address: str = "", path: str = "loki/api/v1/push", ): """A Loki service provider. @@ -1165,7 +1166,9 @@ def __init__( other charms that consume metrics endpoints. port: an optional port of the Loki service (default is "3100"). scheme: an optional scheme of the Loki API URL (default is "http"). - address: an optional address of the Loki service (default is "localhost"). + address: DEPRECATED. This argument is ignored and will be removed in v2. + It is kept for backward compatibility. + Use `update_endpoint()` instead. path: an optional path of the Loki API URL (default is "loki/api/v1/push") Raises: @@ -1181,14 +1184,23 @@ def __init__( _validate_relation_by_interface_and_direction( charm, relation_name, RELATION_INTERFACE_NAME, RelationRole.provides ) + + if address != "": + warnings.warn( + "The 'address' parameter is deprecated and will be removed in v2. " + "Use 'update_endpoint()' instead.", + DeprecationWarning, + stacklevel=2, + ) + super().__init__(charm, relation_name) self._charm = charm self._relation_name = relation_name self._tool = CosTool(self) self.port = int(port) self.scheme = scheme - self.address = address self.path = path + self._custom_url = None events = self._charm.on[relation_name] self.framework.observe(self._charm.on.upgrade_charm, self._on_lifecycle_event) @@ -1326,6 +1338,11 @@ def update_endpoint(self, url: str = "", relation: Optional[Relation] = None) -> host address change because the charmed operator becomes connected to an Ingress after the `logging` relation is established. + To make this library reconciler-friendly, the endpoint URL was made sticky i.e., once the + endpoint is updated with a custom URL, using the public method, it cannot be unset. Users + of this method should set the "url" arg to an internal URL if the charms ingress is no + longer available. + Args: url: An optional url value to update relation data. relation: An optional instance of `class:ops.model.Relation` to update. @@ -1339,7 +1356,10 @@ def update_endpoint(self, url: str = "", relation: Optional[Relation] = None) -> else: relations_list = [relation] - endpoint = self._endpoint(url or self._url) + if url: + self._custom_url = url + + endpoint = self._endpoint(self._custom_url or self._url) for relation in relations_list: relation.data[self._charm.unit].update({"endpoint": json.dumps(endpoint)}) diff --git a/lib/charms/smtp_integrator/v0/smtp.py b/lib/charms/smtp_integrator/v0/smtp.py index 9641b070f..8ab7f285e 100644 --- a/lib/charms/smtp_integrator/v0/smtp.py +++ b/lib/charms/smtp_integrator/v0/smtp.py @@ -68,27 +68,53 @@ def _on_config_changed(self, _) -> None: # Increment this PATCH version before using `charmcraft publish-lib` or reset # to 0 if you are raising the major API version -LIBPATCH = 19 +LIBPATCH = 21 -PYDEPS = ["pydantic>=2"] +PYDEPS = ["pydantic>=1.10,<3", "email-validator>=2"] # pylint: disable=wrong-import-position import itertools +import json import logging import typing from ast import literal_eval from enum import Enum -from typing import Dict, Optional +from typing import Any, Callable, Dict, List, Optional, TypeVar, cast import ops -from pydantic import BaseModel, Field, ValidationError +from pydantic import BaseModel, EmailStr, Field, ValidationError logger = logging.getLogger(__name__) +_F = TypeVar("_F", bound=Callable[..., Any]) + +try: + # Pydantic v2 + from pydantic import field_validator as _pyd_field_validator + + _PYDANTIC_V2 = True +except ImportError: + _pyd_field_validator = None # type: ignore[assignment] + _PYDANTIC_V2 = False + +# Pydantic v1 field validation decorator (v2 uses field_validator) +from pydantic import validator as _pyd_validator # type: ignore[attr-defined] + DEFAULT_RELATION_NAME = "smtp" LEGACY_RELATION_NAME = "smtp-legacy" +def recipients_validator() -> Callable[[_F], _F]: + """Return the correct recipients validator decorator for pydantic v1/v2. + + Returns: + A decorator to validate/normalize the recipients field before EmailStr validation. + """ + if _PYDANTIC_V2: + return cast(Any, _pyd_field_validator)("recipients", mode="before") + return cast(Any, _pyd_validator)("recipients", pre=True) + + class SmtpError(Exception): """Common ancestor for Smtp related exceptions.""" @@ -138,6 +164,8 @@ class SmtpRelationData(BaseModel): transport_security: The security protocol to use for the outgoing SMTP relay. domain: The domain used by the emails sent from SMTP relay. skip_ssl_verify: Specifies if certificate trust verification is skipped in the SMTP relay. + smtp_sender: Optional sender email address for outgoing notifications. + recipients: List of recipient email addresses for notifications. """ host: str = Field(..., min_length=1) @@ -149,6 +177,14 @@ class SmtpRelationData(BaseModel): transport_security: TransportSecurity domain: Optional[str] = None skip_ssl_verify: Optional[bool] = False + smtp_sender: Optional[EmailStr] = None + recipients: List[EmailStr] = Field(default_factory=list) + + @recipients_validator() + @classmethod + def _recipients_str_to_list(cls, value: Any) -> Any: + """Convert recipients input to list[str] before EmailStr validation.""" + return parse_recipients(value) def to_relation_data(self) -> Dict[str, str]: """Convert an instance of SmtpRelationData to the relation representation. @@ -174,6 +210,14 @@ def to_relation_data(self) -> Dict[str, str]: logger.warning("password field exists along with password_id field, removing.") del result["password"] result["password_id"] = self.password_id + + if self.smtp_sender: + result["smtp_sender"] = str(self.smtp_sender) + + if self.recipients: + recipients = list(self.recipients) + result["recipients"] = json.dumps([str(r) for r in recipients]) + return result @@ -190,6 +234,8 @@ class SmtpDataAvailableEvent(ops.RelationEvent): transport_security: The security protocol to use for the outgoing SMTP relay. domain: The domain used by the emails sent from SMTP relay. skip_ssl_verify: Specifies if certificate trust verification is skipped in the SMTP relay. + smtp_sender: Optional sender email address for outgoing notifications. + recipients: List of recipient email addresses for notifications. """ @property @@ -248,6 +294,27 @@ def skip_ssl_verify(self) -> bool: typing.cast(str, self.relation.data[self.relation.app].get("skip_ssl_verify")) ) + @property + def smtp_sender(self) -> Optional[str]: + """Fetch the SMTP sender from the relation. + + Returns: + smtp_sender: Optional sender email address for outgoing notifications. + """ + assert self.relation.app + return self.relation.data[self.relation.app].get("smtp_sender") + + @property + def recipients(self) -> List[str]: + """Fetch the SMTP recipients from the relation. + + Returns: + recipients: list of recipient email addresses for notifications. + """ + assert self.relation.app + raw = self.relation.data[self.relation.app].get("recipients") + return parse_recipients(raw) + class SmtpRequiresEvents(ops.CharmEvents): """SMTP events. @@ -307,24 +374,27 @@ def get_relation_data_from_relation( SecretError: if the secret can't be read. """ assert relation.app - relation_data = relation.data[relation.app] - if not relation_data: + raw_relation_data = relation.data[relation.app] + if not raw_relation_data: return None - password = relation_data.get("password") - if password is None and relation_data.get("password_id"): + data: Dict[str, Any] = dict(raw_relation_data) + + password = data.get("password") + if password is None and data.get("password_id"): try: password = ( - self.model.get_secret(id=relation_data.get("password_id")) + self.model.get_secret(id=data["password_id"]) .get_content(refresh=True) .get("password") ) except ops.model.ModelError as exc: - raise SecretError( - f"Could not consume secret {relation_data.get('password_id')}" - ) from exc + raise SecretError(f"Could not consume secret {data.get('password_id')}") from exc + + # normalize recipients + data["recipients"] = parse_recipients(data.get("recipients")) - return SmtpRelationData(**{**relation_data, "password": password}) # type: ignore + return SmtpRelationData(**{**data, "password": password}) def _is_relation_data_valid(self, relation: ops.Relation) -> bool: """Validate the relation data. @@ -431,3 +501,58 @@ def update_relation_data(self, relation: ops.Relation, smtp_data: SmtpRelationDa logger.info("update data in relation id:%s", relation.id) relation_data.clear() relation_data.update(new_data) + + +def parse_recipients(raw: Any) -> list[str]: + """Normalize SMTP recipient input into a list of email strings. + + The function produces a normalized list[str] so that downstream validation (EmailStr) + can be applied consistently. + + Args: + raw: Recipient input as received from relation data, charm config, + May be None, str, or list. + + Accepted input forms: + - None or empty string + - list of stripped string values + - JSON list string + - Comma-separated string + - Single address string + + Returns: + A list of recipient strings. The email correctness is validated later by EmailStr. + + Raises: + TypeError: If raw is not None, str or list. + ValueError: If a JSON-encoded value does not decode to a list. + """ + if raw is None: + return [] + + if isinstance(raw, list): + return [str(x).strip() for x in raw if str(x).strip()] + + if not isinstance(raw, str): + raise TypeError("recipients must be a string, list, or None") + + s = raw.strip() + if not s: + return [] + + # JSON list string + if s.startswith("["): + loaded = json.loads(s) + if not isinstance(loaded, list): + raise ValueError("recipients JSON must decode to a list") + return [str(x).strip() for x in loaded if str(x).strip()] + + # JSON without a bracelet: '"a@x.com", "b@y.com"' + if '"' in s and "," in s: + loaded = json.loads(f"[{s}]") + if not isinstance(loaded, list): + raise ValueError("recipients must decode to a list") + return [str(x).strip() for x in loaded if str(x).strip()] + + # comma-separated or single + return [p.strip() for p in s.split(",") if p.strip()]